diff --git a/AGENTS.md b/AGENTS.md
index 55ab25d8b00..3d918135ec6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -50,6 +50,7 @@ go build -o test-output ./cmd/server && rm test-output # Verify compile (REQUIRE
- As a rule, do not make standalone changes to `internal/translator/`. You may modify it only as part of broader changes elsewhere.
- If a task requires changing only `internal/translator/`, run `gh repo view --json viewerPermission -q .viewerPermission` to confirm you have `WRITE`, `MAINTAIN`, or `ADMIN`. If you do, you may proceed; otherwise, file a GitHub issue including the goal, rationale, and the intended implementation code, then stop further work.
- `internal/runtime/executor/` should contain executors and their unit tests only. Place any helper/supporting files under `internal/runtime/executor/helps/`.
+- Do not use `github.com/tidwall/gjson`, `github.com/tidwall/sjson`, or Go's standard `encoding/base64`. Always use `github.com/MoeForge/moego/ojson` and `github.com/MoeForge/moego/base64` for all JSON and Base64 operations. All JSON write operations (`Set`, `SetRaw`, `Delete`, etc.) MUST be performed on `*ojson.Doc` (`ojson.NewDoc` / `ojson.NewDocString`), passing `*ojson.Doc` across mutation stages and serializing only once at the final exit (`doc.Bytes()` / `doc.String()`), rather than mutating raw `[]byte` or `string` payloads directly.
- Payload configuration MUST be the final semantic barrier before sending requests in every executor, including streaming, WebSocket, continuation, retry/fallback, image, and token-count paths where applicable. Complete all built-in payload translation, normalization, injection, and cleanup first, then evaluate and apply user payload rules exactly once to the final business payload. No subsequent logic may overwrite configured values or restore filtered fields. Only necessary transport framing, serialization, signature calculation, and read-only validation may follow without changing business payload semantics. Add regression tests when introducing or changing request-building paths to enforce this ordering.
- Follow `gofmt`; keep imports goimports-style; wrap errors with context where helpful
- Do not use `log.Fatal`/`log.Fatalf` (terminates the process); prefer returning errors and logging via logrus
diff --git a/README.md b/README.md
index 39f30075fd2..9735daa45f3 100644
--- a/README.md
+++ b/README.md
@@ -191,10 +191,6 @@ PackyCode provides special discounts for our software users: register using PatewayAI is an API relay service provider for experienced AI developers, with full support for the Claude and Codex model families. All models come from high-quality official channels, with no dilution or counterfeiting, and transparent, verifiable billing details. Economy mode starts at just 5% of the official price. Register through this link to receive trial credits and participate in occasional promotions for free credits. The platform also supports enterprise-grade concurrency, a dedicated management dashboard, formal contracts and invoices, and two-way referral rewards of up to $150.
-
-Thank you to OpenLux for sponsoring this project! OpenLux is an all-in-one AI platform for businesses, bringing together leading AI models from major providers worldwide. With fast, reliable service and responsive technical support, OpenLux offers base pricing for Claude, OpenAI, and Gemini models as low as 8.82%, 4%, and 8% of official rates, respectively. Exclusive offer for CLI Proxy API users: Sign up through our referral link and enjoy up to 7.5% off credit top-ups! Get started with OpenLux → https://www.openlux.ai/register?channel=c_6axb1q5n
-
-
Thanks to Modelflare for sponsoring this project! Modelflare: Full strength, stable, nothing watered down. Global SOTA models at a lower cost. Operated by US-based Havenbyte LLC, Modelflare supports GPT, Claude, Gemini, Grok, and other leading models through common compatible protocols, plus 40+ image and video models. Cache compensation is a highlight: selected OpenAI groups (price-first, stable, and premium) guarantee daily cache hit rates of 65%, 75%, or 85%, and if eligible requests fall short, Modelflare refunds the difference, visible on the usage dashboard after settlement. Special offers: a single top-up of US$20 unlocks the GPT discount group at a 0.015x price multiplier, and a single top-up of US$50 unlocks the Claude discount group at a 0.03x multiplier. Register via this exclusive link to get started.
@@ -204,8 +200,14 @@ PackyCode provides special discounts for our software users: register using
-This AI model transfer service provider offers immediate refunds; any user can obtain a free invoice; it can be done for both corporate and individual clients; all major models are available. The customer service is always online and ready to respond to your requests; just let us know what you need and we'll take care of it!!!
- Xcode VIP access:https://xcode.best
+
+ 🥚 [Freebie Alert!] Register & submit a ticket with code to grab $5 free credit + 15% OFF first top-up!
+ 🚀 More than just API routing — an all-in-one Web workspace: Enjoy Web chat, AI image generation, video creation, and automated workflows right out of the box without any frontend setup.
+ ⚡ All mainstream models supported: High-speed routing for Claude, GPT, Gemini, DeepSeek, and more.
+ 🛡️ Reliable guarantees: Hassle-free refunds | Official invoicing | B2B & individual billing | High-availability customer support ready to help!
+ 👉 Xcode VIP Portal: https://xcode.best
+ 💬 Ticket Code: CLIProxyAPI (Submit a ticket to claim your credits instantly)
+
@@ -237,6 +239,8 @@ PackyCode provides special discounts for our software users: register using 此链接 注册即可获得试用额度,还可参与不定时营销活动领取免费额度。平台同时支持企业级并发、专属管理后台、正式合同与发票,并提供最高 150 美元的双向推荐奖励。
-
-感谢 OpenLux 对本项目的赞助!Openlux是一个面向企业的一站式 AI 聚合平台,汇集全球各大厂商主流大模型,平台提供高效、稳定的服务与及时的技术支持。Claude、OpenAI、Gemini 系列模型基准折扣分别低至官方的 0.882 折、0.4 折和 0.8 折。 CLI Proxy API 用户还可享受专属福利:通过专属链接注册,充值最高可享 7.5% 优惠! 立即体验:https://www.openlux.ai/register?channel=c_6axb1q5n
-
-
感谢 Modelflare 赞助本项目!Modelflare:满血稳定不掺水,低成本享受全球 SOTA 模型。站点由美国 LLC 主体 Havenbyte LLC 运营,支持 GPT、Claude、Gemini、Grok 等主流模型和常用兼容协议,还支持 40 多种图片和视频模型。缓存补偿是一大亮点:部分 OpenAI 价格优先、稳定和高级分组提供每日 65%、75%、85% 的缓存命中率保障,符合条件的请求没达到目标的,平台补偿差额,结算后可在用量看板查看。特惠活动:单笔充值 US$20 即可解锁 0.015 倍价格倍率的 GPT 特惠分组,单笔充值 US$50 即可解锁 0.03 倍价格倍率的 Claude 特惠分组。通过专属链接 注册即可参与。
@@ -105,7 +101,14 @@ PackyCode 为本软件用户提供了特别优惠:使用
-这个 AI 模型中转服务商可以随时退款;任意用户可免费开票;对公对私都能做;主流模型全都有。客服长时间在线等候您的差遣;有事儿您吩咐,主打一个听劝! Xcode贵宾入口:https://xcode.best
+
+ 🥚 【免费领鸡蛋!】注册提工单报暗号,白嫖 $5 额度 + 首充 85 折!
+ 🚀 不只是 API 中转,更是全能 Web 工作台: 无需自建前端,网页直接用对话、AI绘图、视频生成、自动化工作流。
+ ⚡ 全主流覆盖: Claude、GPT、Gemini、DeepSeek 等顶尖模型极速聚合。
+ 🛡️ 服务保障: 随时退款 | 免费开票 | 对公对私 | 客服长时在线,主打一个听劝!
+ 👉 Xcode贵宾入口:https://xcode.best/register?aff=OuDv
+ 💬 工单暗号:CLIProxyAPI(提交工单即可直接到账)
+
diff --git a/README_JA.md b/README_JA.md
index 7c7af13f269..8eca9f71b95 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -92,10 +92,6 @@ PackyCodeは当ソフトウェアのユーザーに特別割引を提供して
PatewayAI は、経験豊富な AI 開発者向けの API リレーサービスプロバイダーで、Claude および Codex シリーズのモデルを完全にサポートしています。すべてのモデルは高品質な公式チャネルから提供され、希釈や偽装は一切なく、課金明細も透明で確認可能です。エコノミーモードは公式価格のわずか5%から利用できます。こちらのリンク から登録するとトライアルクレジットを受け取れるほか、不定期のキャンペーンで無料クレジットを獲得できます。また、エンタープライズ級の同時接続、専用管理ダッシュボード、正式な契約書と請求書に対応し、双方に最大150米ドルが付与される紹介特典も提供しています。
-
-OpenLux による本プロジェクトへのご支援に感謝します! OpenLux は、世界の主要プロバイダーが提供する主流の大規模 AI モデルを集約した、企業向けのワンストップ AI プラットフォームです。迅速で安定したサービスと適時の技術サポートを提供しています。Claude、OpenAI、Gemini シリーズのモデルの基本料金は、それぞれ公式料金の8.82%、4%、8%からご利用いただけます。 CLI Proxy API ユーザー限定特典:専用リンクから登録すると、チャージ時に最大7.5%の割引を受けられます! 今すぐ OpenLux を試す → https://www.openlux.ai/register?channel=c_6axb1q5n
-
-
Modelflare による本プロジェクトへのご支援に感謝します!Modelflare:フルスペックで安定、水増しなし。世界の SOTA モデルを低コストで利用できます。米国法人 Havenbyte LLC が運営し、GPT、Claude、Gemini、Grok などの主要モデルと一般的な互換プロトコルに対応しているほか、40 種類以上の画像・動画モデルもサポートしています。キャッシュ補償が大きな特長です:一部の OpenAI 価格優先・安定・プレミアムグループでは、1 日あたり 65%、75%、85% のキャッシュヒット率を保証し、対象リクエストが目標に達しなかった場合は差額を補償します。精算後に使用量ダッシュボードで確認できます。特別キャンペーン:1 回 US$20 のチャージで価格倍率 0.015 倍の GPT 特別グループが、1 回 US$50 のチャージで価格倍率 0.03 倍の Claude 特別グループが解放されます。専用リンク から登録してご参加ください。
@@ -105,7 +101,14 @@ PackyCodeは当ソフトウェアのユーザーに特別割引を提供して
-こちらのAIモデル中转サービスはいつでも返金可能で、どのユーザーも無料でインボイスを発行できます。法人のご利用も個人のご利用もどちらも対応可能で、主要なモデルはすべて揃っています。カスタマーサポートは長時間オンラインで皆様のご指示をお待ちしております。何かございましたらお気軽にご指示ください。お客様のご意見をしっかり取り入れるのがモットーです!!! Xcode VIPエントランス→ https://xcode.best
+
+ 🥚 【タダポチ案件!】登録&合言葉で $5 相当の無料クレジット + 初回チャージ 15% OFF!
+ 🚀 単なる API 中継ではなく、オールインワンの Web ワークスペース: フロントエンド構築不要で、Web チャット・AI 画像生成・動画生成・自動ワークフローがブラウザ上ですぐ使えます。
+ ⚡ 主要モデル完全網羅: Claude、GPT、Gemini、DeepSeek などの最先端モデルを高速集約。
+ 🛡️ 安心のサポート体制: いつでも返金可能 | 請求書・領収書対応 | 法人・個人問わず利用可能 | ユーザーの声を大切にする充実のサポート!
+ 👉 Xcode VIP 専用リンク:https://xcode.best
+ 💬 チケット申請用合言葉:CLIProxyAPI(チケット送信で即時付与)
+
diff --git a/cmd/fetch_antigravity_models/main.go b/cmd/fetch_antigravity_models/main.go
index e4f2d6eeb02..d2d7221c74c 100644
--- a/cmd/fetch_antigravity_models/main.go
+++ b/cmd/fetch_antigravity_models/main.go
@@ -26,6 +26,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
@@ -34,7 +35,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
const (
@@ -279,7 +279,7 @@ func fetchModelsFromBaseURLs(ctx context.Context, auth *coreauth.Auth, baseURLs
continue
}
- result := gjson.GetBytes(bodyBytes, "models")
+ result := ojson.GetBytesNoCopy(bodyBytes, "models")
if !result.Exists() {
continue
}
@@ -287,7 +287,7 @@ func fetchModelsFromBaseURLs(ctx context.Context, auth *coreauth.Auth, baseURLs
var models []modelEntry
for originalName, modelData := range result.Map() {
- modelID := strings.TrimSpace(originalName)
+ modelID := strings.Clone(strings.TrimSpace(originalName))
if modelID == "" {
continue
}
@@ -297,7 +297,7 @@ func fetchModelsFromBaseURLs(ctx context.Context, auth *coreauth.Auth, baseURLs
continue
}
- displayName := modelData.Get("displayName").String()
+ displayName := strings.Clone(modelData.Get("displayName").String())
if displayName == "" {
displayName = modelID
}
diff --git a/cmd/fetch_codex_models/main.go b/cmd/fetch_codex_models/main.go
index 76ddf4d2168..ba9408f5d17 100644
--- a/cmd/fetch_codex_models/main.go
+++ b/cmd/fetch_codex_models/main.go
@@ -29,6 +29,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
codexauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
@@ -37,8 +38,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -318,34 +317,32 @@ func ensureBaselineModel(raw []byte) ([]byte, int, error) {
}
foundBaseline := false
+ rawDoc := ojson.NewDoc(raw)
+ rawModified := false
for i, modelRaw := range payload.Models {
- slugResult := gjson.GetBytes(modelRaw, "slug")
- if slugResult.Type != gjson.String || strings.TrimSpace(slugResult.Str) != baselineModelSlug {
+ slugResult := ojson.GetBytesNoCopy(modelRaw, "slug")
+ if slugResult.Type != ojson.String || strings.TrimSpace(slugResult.Str) != baselineModelSlug {
continue
}
foundBaseline = true
- visResult := gjson.GetBytes(modelRaw, "visibility")
- if visResult.Type != gjson.String || visResult.Str != baselineModelVisibility {
- updated, errSet := sjson.SetBytes(raw, fmt.Sprintf("models.%d.visibility", i), baselineModelVisibility)
- if errSet != nil {
+ visResult := ojson.GetBytesNoCopy(modelRaw, "visibility")
+ if visResult.Type != ojson.String || visResult.Str != baselineModelVisibility {
+ if errSet := rawDoc.Set(fmt.Sprintf("models.%d.visibility", i), baselineModelVisibility); errSet != nil {
return nil, 0, fmt.Errorf("failed to update %s visibility: %w", baselineModelSlug, errSet)
}
- raw = updated
+ rawModified = true
}
}
count := len(payload.Models)
if !foundBaseline {
- cloned := append([]byte(nil), payload.Models[0]...)
- var errSet error
- cloned, errSet = sjson.SetBytes(cloned, "slug", baselineModelSlug)
- if errSet != nil {
+ cloneDoc := ojson.NewDoc(payload.Models[0])
+ if errSet := cloneDoc.Set("slug", baselineModelSlug); errSet != nil {
return nil, 0, fmt.Errorf("failed to set %s slug on cloned model: %w", baselineModelSlug, errSet)
}
- visResult := gjson.GetBytes(cloned, "visibility")
- if visResult.Type != gjson.String || visResult.Str != baselineModelVisibility {
- cloned, errSet = sjson.SetBytes(cloned, "visibility", baselineModelVisibility)
- if errSet != nil {
+ visResult := ojson.GetBytesNoCopy(payload.Models[0], "visibility")
+ if visResult.Type != ojson.String || visResult.Str != baselineModelVisibility {
+ if errSet := cloneDoc.Set("visibility", baselineModelVisibility); errSet != nil {
return nil, 0, fmt.Errorf("failed to set %s visibility on cloned model: %w", baselineModelSlug, errSet)
}
}
@@ -357,17 +354,19 @@ func ensureBaselineModel(raw []byte) ([]byte, int, error) {
{path: "use_responses_lite", value: false},
{path: "supports_reasoning_effort_updates", value: false},
} {
- cloned, errSet = sjson.SetBytes(cloned, reset.path, reset.value)
- if errSet != nil {
+ if errSet := cloneDoc.Set(reset.path, reset.value); errSet != nil {
return nil, 0, fmt.Errorf("failed to set %s %s on cloned model: %w", baselineModelSlug, reset.path, errSet)
}
}
- raw, errSet = sjson.SetRawBytes(raw, "models.-1", cloned)
- if errSet != nil {
+ if errSet := rawDoc.SetRawBytes("models.-1", cloneDoc.Bytes()); errSet != nil {
return nil, 0, fmt.Errorf("failed to append %s model: %w", baselineModelSlug, errSet)
}
+ rawModified = true
count++
}
+ if rawModified {
+ raw = rawDoc.Bytes()
+ }
return raw, count, nil
}
diff --git a/cmd/fetch_codex_models/main_test.go b/cmd/fetch_codex_models/main_test.go
index ebe2b6a2d4a..bf9ebb8ed3b 100644
--- a/cmd/fetch_codex_models/main_test.go
+++ b/cmd/fetch_codex_models/main_test.go
@@ -3,7 +3,7 @@ package main
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestCodexModelsURL(t *testing.T) {
@@ -61,28 +61,28 @@ func TestEnsureBaselineModel(t *testing.T) {
if count != 3 {
t.Fatalf("count = %d, want 3", count)
}
- if got := gjson.GetBytes(out, "models.0.slug").String(); got != "gpt-6.1-sol" {
+ if got := ojson.GetBytesNoCopy(out, "models.0.slug").String(); got != "gpt-6.1-sol" {
t.Fatalf("models[0].slug = %q, want gpt-6.1-sol", got)
}
- if got := gjson.GetBytes(out, "models.0.tool_mode").String(); got != "code_mode_only" {
+ if got := ojson.GetBytesNoCopy(out, "models.0.tool_mode").String(); got != "code_mode_only" {
t.Fatalf("models[0].tool_mode = %q, want code_mode_only", got)
}
- if got := gjson.GetBytes(out, "models.2.slug").String(); got != "gpt-5.5" {
+ if got := ojson.GetBytesNoCopy(out, "models.2.slug").String(); got != "gpt-5.5" {
t.Fatalf("models[2].slug = %q, want gpt-5.5", got)
}
- if got := gjson.GetBytes(out, "models.2.display_name").String(); got != "GPT-6.1-Sol" {
+ if got := ojson.GetBytesNoCopy(out, "models.2.display_name").String(); got != "GPT-6.1-Sol" {
t.Fatalf("models[2].display_name = %q, want GPT-6.1-Sol", got)
}
- if got := gjson.GetBytes(out, "models.2.visibility").String(); got != "list" {
+ if got := ojson.GetBytesNoCopy(out, "models.2.visibility").String(); got != "list" {
t.Fatalf("models[2].visibility = %q, want list", got)
}
- if got := gjson.GetBytes(out, "models.2.tool_mode"); got.Type != gjson.String || got.Str != "direct" {
+ if got := ojson.GetBytesNoCopy(out, "models.2.tool_mode"); got.Type != ojson.String || got.Str != "direct" {
t.Fatalf("models[2].tool_mode = %v (%s), want string \"direct\"", got.Value(), got.Type)
}
- if got := gjson.GetBytes(out, "models.2.use_responses_lite"); got.Type != gjson.False {
+ if got := ojson.GetBytesNoCopy(out, "models.2.use_responses_lite"); got.Type != ojson.False {
t.Fatalf("models[2].use_responses_lite = %v (%s), want false", got.Value(), got.Type)
}
- if got := gjson.GetBytes(out, "models.2.supports_reasoning_effort_updates"); got.Type != gjson.False {
+ if got := ojson.GetBytesNoCopy(out, "models.2.supports_reasoning_effort_updates"); got.Type != ojson.False {
t.Fatalf("models[2].supports_reasoning_effort_updates = %v (%s), want false", got.Value(), got.Type)
}
})
@@ -96,13 +96,13 @@ func TestEnsureBaselineModel(t *testing.T) {
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
- if got := gjson.GetBytes(out, "models.0.visibility").String(); got != "hide" {
+ if got := ojson.GetBytesNoCopy(out, "models.0.visibility").String(); got != "hide" {
t.Fatalf("models[0].visibility = %q, want hide", got)
}
- if got := gjson.GetBytes(out, "models.1.slug").String(); got != "gpt-5.5" {
+ if got := ojson.GetBytesNoCopy(out, "models.1.slug").String(); got != "gpt-5.5" {
t.Fatalf("models[1].slug = %q, want gpt-5.5", got)
}
- if got := gjson.GetBytes(out, "models.1.visibility").String(); got != "list" {
+ if got := ojson.GetBytesNoCopy(out, "models.1.visibility").String(); got != "list" {
t.Fatalf("models[1].visibility = %q, want list", got)
}
})
@@ -116,10 +116,10 @@ func TestEnsureBaselineModel(t *testing.T) {
if count != 2 {
t.Fatalf("count = %d, want 2", count)
}
- if got := gjson.GetBytes(out, "models.1.slug").String(); got != "gpt-5.5" {
+ if got := ojson.GetBytesNoCopy(out, "models.1.slug").String(); got != "gpt-5.5" {
t.Fatalf("models[1].slug = %q, want gpt-5.5", got)
}
- if got := gjson.GetBytes(out, "models.1.visibility").String(); got != "list" {
+ if got := ojson.GetBytesNoCopy(out, "models.1.visibility").String(); got != "list" {
t.Fatalf("models[1].visibility = %q, want list", got)
}
})
@@ -136,8 +136,8 @@ func TestEnsureBaselineModel(t *testing.T) {
if count != 1 {
t.Fatalf("count = %d, want 1", count)
}
- vis := gjson.GetBytes(out, "models.0.visibility")
- if vis.Type != gjson.String || vis.Str != "list" {
+ vis := ojson.GetBytesNoCopy(out, "models.0.visibility")
+ if vis.Type != ojson.String || vis.Str != "list" {
t.Fatalf("models[0].visibility = %v (%s), want string \"list\"", vis.Value(), vis.Type)
}
}
diff --git a/config.example.yaml b/config.example.yaml
index 27dca93332c..90311aeb898 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -90,6 +90,13 @@ server:
# exclude: [] # Additional interface patterns to exclude
# auth-required: true # Advertise auth=1 (credentials required)
+ # Local persistent KV store settings (bbolt). Ignored when Home mode is enabled.
+ kv-store:
+ # Disable local bbolt KV persistence and fall back to in-memory maps (default: false).
+ disabled: false
+ # File path for the bbolt database (default: "/kv.db" when empty).
+ path: ""
+
# Management API settings
management:
# Whether to allow remote (non-localhost) management access.
@@ -239,6 +246,10 @@ requests:
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
# Per-entry proxy-url also supports "direct" or "none" to bypass both the global proxy-url and environment proxies explicitly.
proxy-url: ""
+ # Connection pooling has no global switch: it is enabled per api-key credential with
+ # `connection-pool` on an api-keys entry (group level, overridable per key). See the
+ # api-keys section below. OAuth credentials are never pooled by this setting;
+ # Antigravity keeps its own antigravity.connection-pool switch.
# Maximum time, in seconds, for upstream TLS handshakes made by the shared built-in
# uTLS client. It applies to both the Claude Code Anthropic transport and the Chrome
@@ -349,6 +360,12 @@ client:
ttl: "24h"
limit: 1024
+ claude:
+ # Show public Codex commentary as visible Claude text, retaining phase markers
+ # for replay. Genuine reasoning summaries remain thinking blocks.
+ # Default false preserves existing behavior for clients such as TTS consumers.
+ show-codex-commentary: false
+
# Shared upstream behavior applies to OAuth and API-key credentials.
upstream:
codex:
@@ -456,9 +473,12 @@ upstream:
# Default false keeps the translation and catalog behavior that predate tool_search support.
tool-search: false
- # Days to retain the cloaked currentDate pin per session, so an idle gap or a credential
+ # Days to retain the cloaked currentDate pin per session in in-memory mode
+ # (server.kv-store.disabled: true without Home), so an idle gap or a credential
# switch cannot re-anchor the date and invalidate earlier thinking signatures and the
# prompt cache. Default 7; an explicit 0 keeps the continuity-scoped pin (1h lifetime).
+ # When a KV store (local bbolt or Home KV) is active, session date pins are persisted
+ # permanently and session-date-pin-days is ignored.
session-date-pin-days: 7
# When true, disable Claude request cloaking on OAuth and API-key CLI profiles (the Claude Code CLI disguise and
@@ -500,6 +520,9 @@ upstream:
# timeout: "600"
# timezone: "Asia/Singapore" # fallback IANA timezone for cloaked currentDate; a credential JSON "timezone" takes priority
# stabilize-device-profile: false # optional, default false; set true to enable per-auth fingerprint pinning
+ # # Additional Claude Code entrypoint labels (beyond built-in cli, sdk-cli, sdk-ts, claude-vscode)
+ # # treated as native Claude Code for passthrough when strong signals match (e.g. T3 Code's "t3-code").
+ # native-entrypoints: ["t3-code"]
# # Advanced operator overrides for exact-parity or gateway lanes. They apply to every Claude
# # request, after the per-request derivation above, and can produce a beta set real Claude Code
# # never sends, which api.anthropic.com may reject. Names are matched exactly; strip runs first.
@@ -687,6 +710,18 @@ upstream:
# # X-Claude-Code-Session-Id: "$ABC" # copies client's "ABC" header
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
+# # Optional connection reuse for this credential group. Writing the block is the opt-in;
+# # `connection-pool: {}` opts in with the defaults below. Pools stay per key, so pooling
+# # one key never shares connections with another key. Applies to the standard transport
+# # and the Claude Code TLS transport (Anthropic upstreams); the ChatGPT browser-profile
+# # transport keeps its one-connection-per-request lifecycle.
+# # Defaults: idle-conn-timeout 300s, max-idle-conns-per-host 2. Zero or negative values
+# # disable keep-alives (every request dials). Authenticated proxy URLs are isolated per
+# # proxy credential digest so rotating user:pass or sticky-session usernames never mix.
+# # connection-pool:
+# # enabled: true
+# # idle-conn-timeout: "300s" # defaults to 300s; measured upstreams reuse idle conns >600s
+# # max-idle-conns-per-host: 2 # defaults to 2
# models:
# - name: "gpt-5-codex" # upstream model name
# alias: "codex-latest" # client alias mapped to the upstream model
@@ -708,6 +743,8 @@ upstream:
# keys:
# - api-key: "sk-atSM..."
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
+# # connection-pool: # optional: per-key override of the group pool setting
+# # enabled: false # opt this key out while the group stays pooled
# disable-codex-cloaking: false # optional: true disables cloaking; OAuth provider settings do not apply here
# alpha-search: false # optional: allow this key to serve /v1/alpha/search via base-url + /alpha/search
# websockets: false # optional: use the Codex upstream WebSocket transport
@@ -916,6 +953,8 @@ upstream:
# weight: 5 # optional: weighted-round-robin share; omitted defaults to 1; maximum 1,000,000
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# # proxy-url: "direct" # optional: explicit direct connect for this credential
+# # connection-pool: # optional: per-key override of the group pool block
+# # enabled: true
# - api-key: "sk-or-v1-...b781" # without proxy-url
# models: # The models supported by the provider.
# - name: "moonshotai/kimi-k2:free" # The actual model name.
@@ -1170,12 +1209,20 @@ multimedia:
# - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits).
# - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled.
# - "passthrough": never inject or strip image_generation on non-images endpoints (forward the client payload unchanged); behaves like "chat" on /v1/images/* endpoints.
+ # Native Codex Responses-Lite routes reject forced hosted image_generation with HTTP 400; use the Images API instead.
+ # Explicit "passthrough" keeps forwarding these requests without capability validation.
disable-image-generation: false
# Base model used by the legacy hosted image_generation tool path when a Codex image request is not proxied directly through the Image API.
# Must start with "gpt-" (case-insensitive). If unset or invalid, defaults to "gpt-5.4-mini".
# gpt-image-2-base-model: "gpt-5.4-mini"
+ # Grok video routes accept grok-imagine-video, grok-imagine-video-1.5,
+ # grok-imagine-video-1.5-preview, and grok-imagine-video-1.5-lite.
+ # Supported on /openai/v1/videos and the native xAI /v1/videos/* endpoints.
+ # The OpenAI-compatible route accepts resolution 480p, 720p, or 1080p (default: 720p).
+ # Built-in xAI video models remain available when a refreshed catalog omits them.
+
# How long video IDs returned by /openai/v1/videos and xAI video creation stay bound
# to the credential that created them. Default: 3h.
video-result-auth-cache-ttl: "3h"
diff --git a/examples/claude-code-codex/README.md b/examples/claude-code-codex/README.md
new file mode 100644
index 00000000000..03fee8e00b7
--- /dev/null
+++ b/examples/claude-code-codex/README.md
@@ -0,0 +1,108 @@
+# Claude Code with a Codex backend
+
+Claude Code can remain the client while CLIProxyAPI routes its Messages requests
+to a Codex credential. Client model names are routing aliases: they do not change
+the upstream model or make the connection an Anthropic subscription session.
+
+## Public progress during tool use
+
+Codex distinguishes public `commentary` messages from `final_answer` messages.
+By default, the Claude translator carries commentary as a `thinking` block. This
+keeps existing text-only and speech consumers from receiving both a preamble and
+a final answer. In an interactive coding session, however, progress and answers
+to questions asked during a tool loop can consequently appear hidden.
+
+To show that public text:
+
+```yaml
+client:
+ claude:
+ show-codex-commentary: true
+```
+
+The default is `false`. `CPA_VISIBLE_CODEX_COMMENTARY=1` also enables the option
+for deployments and embedded translation callers using environment settings.
+The display policy is captured for each request; reloading the configuration
+does not change the representation halfway through an existing stream.
+
+This works with streaming and non-streaming Codex-to-Claude responses. It does
+not turn genuine reasoning summaries into public text. A commentary block keeps
+its existing `cpa:commentary:v1` replay marker and gains a visible text copy when
+the public block finishes. Final-answer text continues to stream normally.
+Both forms are stored in the client's history. With the option enabled, request
+translation consumes the exact adjacent visible copy, preserving one upstream
+`commentary` item. Other final-answer text, tool calls, and user messages remain.
+This display operation does not issue an additional generation or increase the
+upstream-reported output token count.
+
+Existing history is not rewritten. Use the setting consistently within a
+conversation: disabling it leaves previously recorded copies in history and
+restores the original replay behavior rather than modifying stored messages.
+
+## The Auto Mode classifier has its own model
+
+Claude Code may use a different model for tool authorization than it uses for
+the main conversation. A working main-model alias therefore does not prove the
+classifier route is available. For example, a client may request
+`claude-sonnet-5` even when its conversation and default Sonnet alias use a
+different version. Check the actual model named by the client and the request
+logs. Configure a separate alias for that model when needed:
+
+```yaml
+oauth:
+ model-alias:
+ codex:
+ - name: "your-codex-model"
+ alias: "claude-sonnet-5"
+ fork: true
+```
+
+Replace `your-codex-model` with a model available to the selected credential.
+Tool authorization still requires a real classifier verdict. Permission rules,
+workspace trust, and client approvals continue to be owned by Claude Code.
+See the client's [permission-mode documentation](https://code.claude.com/docs/en/permission-modes)
+for its current classifier-selection rules.
+
+## Image tools and coding latency
+
+CLIProxyAPI can add the native OpenAI `image_generation` tool automatically.
+That is separate from any external MCP image tool declared by a client.
+Operators who want the client to own its tool list can use the existing mode:
+
+```yaml
+multimedia:
+ disable-image-generation: "passthrough"
+```
+
+On non-image endpoints this mode neither injects nor strips the image tool.
+Explicitly requested image tools remain available, and `/v1/images/generations`
+and `/v1/images/edits` remain enabled. This is an operator choice; the default
+image-generation behavior is unchanged by the commentary feature.
+
+Match tool definitions when comparing latency. A direct request without an
+image tool and a proxied request with an automatically injected one are different
+model workloads. Also distinguish response headers, the first SSE event, the
+first generated content, the first visible text, and completion. A
+`response.created` event is not generated answer text.
+
+For large OAuth requests, existing upstream request compression can reduce
+network bytes:
+
+```yaml
+upstream:
+ codex:
+ upstream-request-compression: true
+```
+
+This is lossless zstd transport compression, not conversation summarization or
+token reduction. The executor retries a rejected compressed body without
+compression. Model reasoning, context processing, classifier requests, and
+upstream queueing can still dominate latency.
+
+## Validate with isolated client state
+
+Use a temporary `CLAUDE_CONFIG_DIR` and a separate test session for live checks.
+Check a simple answer, a harmless tool call, and a resume of that same session.
+If testing a Telegram setup, use an empty strict MCP configuration instead of
+starting a second poller with a live bot token. Native client history and a
+gateway's upstream credential store are distinct and should remain separate.
diff --git a/examples/plugin/Makefile b/examples/plugin/Makefile
index 78ff07a4f1f..74c68df009f 100644
--- a/examples/plugin/Makefile
+++ b/examples/plugin/Makefile
@@ -4,6 +4,14 @@ BIN_DIR := $(CURDIR)/bin
BUILD_DIR := $(BIN_DIR)/build
UNAME_S := $(shell uname -s)
+GOARCH ?= $(strip $(shell go env GOARCH))
+GOAMD64 ?= v1
+GOARM64 ?= v8.0
+ifeq ($(GOARCH),amd64)
+GO_PLUGIN_CGO_CFLAGS ?= -march=x86-64
+else ifeq ($(GOARCH),arm64)
+GO_PLUGIN_CGO_CFLAGS ?= -march=armv8-a
+endif
ifeq ($(OS),Windows_NT)
PLUGIN_EXT := dll
@@ -36,7 +44,7 @@ $(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(BIN_DIR)/%-go.$(PLUGIN_EXT): %/go/main.go %/go/go.mod | $(BIN_DIR)
- cd $*/go && go build -buildmode=c-shared -o $(abspath $@) .
+ cd $*/go && GOARCH=$(GOARCH) GOAMD64=$(GOAMD64) GOARM64=$(GOARM64) CGO_CFLAGS="$(strip $(GO_PLUGIN_CGO_CFLAGS) $(CGO_CFLAGS))" go build -buildmode=c-shared -o $(abspath $@) .
rm -f $(BIN_DIR)/$*-go.h
$(BIN_DIR)/%-c.$(PLUGIN_EXT): %/c/CMakeLists.txt %/c/src/plugin.c | $(BIN_DIR) $(BUILD_DIR)
diff --git a/examples/plugin/README.md b/examples/plugin/README.md
index 502be19834e..32e88008c80 100644
--- a/examples/plugin/README.md
+++ b/examples/plugin/README.md
@@ -172,6 +172,11 @@ make -C examples/plugin build
Artifacts are written to `examples/plugin/bin`.
+When building prebuilt `c-shared` plugin release artifacts on CI runners for distribution across diverse machines, pin the baseline CPU instruction set so compiler-emitted instructions (such as AVX/AVX2/AVX-512 on modern `ubuntu-latest` runners) do not trigger `SIGILL` on older CPUs or virtualized hosts:
+
+- `amd64`: `GOAMD64=v1 CGO_CFLAGS="-march=x86-64"`
+- `arm64`: `GOARM64=v8.0 CGO_CFLAGS="-march=armv8-a"`
+
## Notes
`protocol-format` uses a minimal executor because format declarations belong to executor capabilities.
diff --git a/examples/plugin/README_CN.md b/examples/plugin/README_CN.md
index a3f6d1963c6..205cc39e142 100644
--- a/examples/plugin/README_CN.md
+++ b/examples/plugin/README_CN.md
@@ -161,6 +161,11 @@ make -C examples/plugin build
构建产物会写入 `examples/plugin/bin`。
+在 CI 构建机(例如 `ubuntu-latest`)上编译用于跨机器分发的预编译 `c-shared` 动态库插件时,请显式锁定最低 CPU 指令集基线,避免构建机默认生成的高阶指令集(如 AVX/AVX2/AVX-512)在不支持相应扩展的宿主机或虚拟化环境中触发 `SIGILL`:
+
+- `amd64`:`GOAMD64=v1 CGO_CFLAGS="-march=x86-64"`
+- `arm64`:`GOARM64=v8.0 CGO_CFLAGS="-march=armv8-a"`
+
## 说明
`protocol-format` 使用最小执行器承载,因为格式声明属于执行器能力。
diff --git a/examples/plugin/claude-web-search-router/go/detect.go b/examples/plugin/claude-web-search-router/go/detect.go
index b74ae7dec1a..8f0e4c67190 100644
--- a/examples/plugin/claude-web-search-router/go/detect.go
+++ b/examples/plugin/claude-web-search-router/go/detect.go
@@ -3,7 +3,7 @@ package main
import (
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const (
@@ -26,7 +26,7 @@ func isClaudeTypedWebSearchToolType(toolType string) bool {
}
func hasClaudeTypedWebSearchTool(body []byte) bool {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.IsArray() {
return false
}
@@ -39,7 +39,7 @@ func hasClaudeTypedWebSearchTool(body []byte) bool {
}
func hasOnlyClaudeTypedWebSearchTools(body []byte) bool {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.IsArray() {
return false
}
@@ -57,7 +57,9 @@ func hasOnlyClaudeTypedWebSearchTools(body []byte) bool {
}
func looksLikeClaudeCodeWebSearchAssistant(body []byte) bool {
- system := gjson.GetBytes(body, "system")
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], body, "system", "messages")
+ system := fields[0]
if system.IsArray() {
for _, block := range system.Array() {
text := strings.ToLower(block.Get("text").String())
@@ -67,13 +69,13 @@ func looksLikeClaudeCodeWebSearchAssistant(body []byte) bool {
}
}
}
- if system.Type == gjson.String {
+ if system.Type == ojson.String {
text := strings.ToLower(system.String())
if strings.Contains(text, "web search tool use") {
return true
}
}
- messages := gjson.GetBytes(body, "messages")
+ messages := fields[1]
if !messages.IsArray() {
return false
}
@@ -101,13 +103,13 @@ func isClaudeCodeBuiltinWebSearchRequest(body []byte, requireWebSearchOnly bool)
func extractClaudeWebSearchQuery(body []byte) string {
if q := extractQueryFromPerformPrefix(body); q != "" {
- return q
+ return strings.Clone(q)
}
- return extractQueryFromUserMessages(body)
+ return strings.Clone(extractQueryFromUserMessages(body))
}
func extractQueryFromPerformPrefix(body []byte) string {
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
if !messages.IsArray() {
return ""
}
@@ -126,7 +128,7 @@ func extractQueryFromPerformPrefix(body []byte) string {
}
func extractQueryFromUserMessages(body []byte) string {
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
if !messages.IsArray() {
return ""
}
@@ -144,8 +146,8 @@ func extractQueryFromUserMessages(body []byte) string {
return ""
}
-func extractClaudeMessageText(content gjson.Result) string {
- if content.Type == gjson.String {
+func extractClaudeMessageText(content ojson.Result) string {
+ if content.Type == ojson.String {
return content.String()
}
if !content.IsArray() {
@@ -167,7 +169,7 @@ func extractClaudeWebSearchMaxUses(body []byte, defaultMax int) int {
if defaultMax <= 0 {
defaultMax = 5
}
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.IsArray() {
return defaultMax
}
diff --git a/examples/plugin/claude-web-search-router/go/go.mod b/examples/plugin/claude-web-search-router/go/go.mod
index 659a184f2d1..0908778ed6b 100644
--- a/examples/plugin/claude-web-search-router/go/go.mod
+++ b/examples/plugin/claude-web-search-router/go/go.mod
@@ -1,18 +1,16 @@
-module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/claude-web-search-router/go
+module github.com/router-for-me/CLIProxyAPI/v8/examples/plugin/claude-web-search-router/go
go 1.26.10
require (
- github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
- github.com/tidwall/gjson v1.18.0
+ github.com/MoeForge/moego v0.1.0-alpha.1
+ github.com/router-for-me/CLIProxyAPI/v8 v8.0.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/sirupsen/logrus v1.9.3 // indirect
- github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
-replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
+replace github.com/router-for-me/CLIProxyAPI/v8 => ../../../..
diff --git a/examples/plugin/claude-web-search-router/go/go.sum b/examples/plugin/claude-web-search-router/go/go.sum
index 08317b1acae..ae2ae079c93 100644
--- a/examples/plugin/claude-web-search-router/go/go.sum
+++ b/examples/plugin/claude-web-search-router/go/go.sum
@@ -1,3 +1,5 @@
+github.com/MoeForge/moego v0.1.0-alpha.1 h1:d+asBZu+2oKnBj1kzdvdANMStbIKw9BLCpknuWjiR28=
+github.com/MoeForge/moego v0.1.0-alpha.1/go.mod h1:SBNiQjCpWC3IEBMKFBOwOyhhQ3jywMrISqCwJTdr4aA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -8,16 +10,8 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/examples/plugin/claude-web-search-router/go/tavily_test.go b/examples/plugin/claude-web-search-router/go/tavily_test.go
index e04c428d567..6881268ff94 100644
--- a/examples/plugin/claude-web-search-router/go/tavily_test.go
+++ b/examples/plugin/claude-web-search-router/go/tavily_test.go
@@ -9,8 +9,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
)
func TestTavilyClientSearchMockAPI(t *testing.T) {
@@ -174,7 +174,7 @@ func TestRunTavilyClaudeJSONWithMock(t *testing.T) {
if errRun != nil {
t.Fatal(errRun)
}
- root := gjson.ParseBytes(payload)
+ root := ojson.GetBytesNoCopy(payload, "@this")
if root.Get("type").String() != "message" {
t.Fatalf("type = %s", root.Get("type").String())
}
diff --git a/examples/plugin/codex-service-tier/go/go.mod b/examples/plugin/codex-service-tier/go/go.mod
index 28422bc1a46..fb599bf75cf 100644
--- a/examples/plugin/codex-service-tier/go/go.mod
+++ b/examples/plugin/codex-service-tier/go/go.mod
@@ -1,17 +1,11 @@
-module github.com/router-for-me/CLIProxyAPI/v7/examples/plugin/codex-service-tier/go
+module github.com/router-for-me/CLIProxyAPI/v8/examples/plugin/codex-service-tier/go
go 1.26.10
require (
- github.com/router-for-me/CLIProxyAPI/v7 v7.0.0
- github.com/tidwall/sjson v1.2.5
+ github.com/MoeForge/moego v0.1.0-alpha.1
+ github.com/router-for-me/CLIProxyAPI/v8 v8.0.0
gopkg.in/yaml.v3 v3.0.1
)
-require (
- github.com/tidwall/gjson v1.18.0 // indirect
- github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.0 // indirect
-)
-
-replace github.com/router-for-me/CLIProxyAPI/v7 => ../../../..
+replace github.com/router-for-me/CLIProxyAPI/v8 => ../../../..
diff --git a/examples/plugin/codex-service-tier/go/go.sum b/examples/plugin/codex-service-tier/go/go.sum
index 9186dfd8029..82194772aa2 100644
--- a/examples/plugin/codex-service-tier/go/go.sum
+++ b/examples/plugin/codex-service-tier/go/go.sum
@@ -1,12 +1,5 @@
-github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
-github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
+github.com/MoeForge/moego v0.1.0-alpha.1 h1:d+asBZu+2oKnBj1kzdvdANMStbIKw9BLCpknuWjiR28=
+github.com/MoeForge/moego v0.1.0-alpha.1/go.mod h1:SBNiQjCpWC3IEBMKFBOwOyhhQ3jywMrISqCwJTdr4aA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/examples/plugin/codex-service-tier/go/main.go b/examples/plugin/codex-service-tier/go/main.go
index 18aafa8c0d7..f80e3459647 100644
--- a/examples/plugin/codex-service-tier/go/main.go
+++ b/examples/plugin/codex-service-tier/go/main.go
@@ -39,9 +39,9 @@ import (
"sync/atomic"
"unsafe"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/sjson"
"gopkg.in/yaml.v3"
)
@@ -213,7 +213,7 @@ func decodeFastConfig(configYAML []byte) (bool, error) {
}
func setPriorityServiceTier(body []byte) ([]byte, bool) {
- updated, errSet := sjson.SetBytes(body, "service_tier", "priority")
+ updated, errSet := ojson.SetBytes(body, "service_tier", "priority")
if errSet != nil {
return nil, false
}
diff --git a/examples/realtime-openai-go/go.mod b/examples/realtime-openai-go/go.mod
index 6de15fc0c39..31872ca6f12 100644
--- a/examples/realtime-openai-go/go.mod
+++ b/examples/realtime-openai-go/go.mod
@@ -3,6 +3,7 @@ module github.com/router-for-me/CLIProxyAPI/v7/examples/realtime-openai-go
go 1.26.0
require (
+ github.com/MoeForge/moego v0.1.0-alpha.1
github.com/gorilla/websocket v1.5.3
github.com/openai/openai-go/v3 v3.50.0
)
diff --git a/examples/realtime-openai-go/go.sum b/examples/realtime-openai-go/go.sum
index 8df405b419a..34b73d910f9 100644
--- a/examples/realtime-openai-go/go.sum
+++ b/examples/realtime-openai-go/go.sum
@@ -1,3 +1,5 @@
+github.com/MoeForge/moego v0.1.0-alpha.1 h1:d+asBZu+2oKnBj1kzdvdANMStbIKw9BLCpknuWjiR28=
+github.com/MoeForge/moego v0.1.0-alpha.1/go.mod h1:SBNiQjCpWC3IEBMKFBOwOyhhQ3jywMrISqCwJTdr4aA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/openai/openai-go/v3 v3.50.0 h1:CXn+C8a10oQiI5CMyMbCiykhITVhVxhdHX8j3CfLa2U=
diff --git a/examples/realtime-openai-go/main.go b/examples/realtime-openai-go/main.go
index a16f26e2703..ff6ee7710dc 100644
--- a/examples/realtime-openai-go/main.go
+++ b/examples/realtime-openai-go/main.go
@@ -3,10 +3,10 @@ package main
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/url"
diff --git a/examples/realtime-openai-go/main_test.go b/examples/realtime-openai-go/main_test.go
index aabaf12ea3a..dc442e01db3 100644
--- a/examples/realtime-openai-go/main_test.go
+++ b/examples/realtime-openai-go/main_test.go
@@ -3,8 +3,8 @@ package main
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"net/http"
"net/http/httptest"
"os"
diff --git a/go.mod b/go.mod
index 92367274c9b..3b4b50d7ed6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/router-for-me/CLIProxyAPI/v8
go 1.26.10
require (
+ github.com/MoeForge/moego v0.1.0-alpha.1
github.com/andybalholm/brotli v1.0.6
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
@@ -29,8 +30,6 @@ require (
github.com/refraction-networking/utls v1.8.2
github.com/sirupsen/logrus v1.9.3
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
- github.com/tidwall/gjson v1.18.0
- github.com/tidwall/sjson v1.2.5
github.com/tiktoken-go/tokenizer v0.8.1
golang.org/x/crypto v0.57.0
golang.org/x/net v0.60.0
@@ -42,6 +41,8 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
+replace github.com/MoeForge/moego v0.1.0-alpha.1 => github.com/router-for-me/moego v0.1.2
+
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.1 // indirect
@@ -57,6 +58,7 @@ require (
github.com/pion/turn/v5 v5.0.12 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
+ go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/time v0.14.0 // indirect
)
@@ -113,8 +115,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
- github.com/tidwall/match v1.1.1 // indirect
- github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
diff --git a/go.sum b/go.sum
index 50767c7dc1c..8873b5cdf17 100644
--- a/go.sum
+++ b/go.sum
@@ -73,14 +73,10 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
-github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk=
-github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E=
github.com/go-git/go-billy/v6 v6.0.0-alpha.2 h1:1Sv5WemXL8CxKrAx1gioJ+uHNb2bZJhiQLfwSZ4Et8c=
github.com/go-git/go-billy/v6 v6.0.0-alpha.2/go.mod h1:r/bsv9i/iDyyEU8/Z6mjC+YraOVwie1ddfUqBCElKXQ=
github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA=
github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ=
-github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260520124234-0860a7d8a164 h1:chk74EHqDOHvIx/WH43JfdLImedxN98qGvEFd7WYgus=
-github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260520124234-0860a7d8a164/go.mod h1:OTUSi3RzPFoC0j/+uxHdVG1X/xXz84QCxLzYvXRvyXk=
github.com/go-git/go-git/v6 v6.0.0-alpha.5 h1:sE+OlkHgYWNMVmN1s9sR7uyFgsWLtxcNWse/vBYKxRE=
github.com/go-git/go-git/v6 v6.0.0-alpha.5/go.mod h1:3IjhiZnM+uBmUrOGSeqrJpsmi4Vd0H2NZO/uK2a7d0s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -159,8 +155,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1 h1:gIZQQ2+wbsdQGWLPDSdJFptR25tBCjSC0KbSIQZ1rgE=
github.com/pelletier/go-toml/v2 v2.4.3-0.20260624044523-35f78d5908f1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo=
@@ -211,6 +205,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
+github.com/router-for-me/moego v0.1.2 h1:ME0iDkiFZK7N4MDj2C/XMvDT7euvTVIWMYEGd+VQGf0=
+github.com/router-for-me/moego v0.1.2/go.mod h1:SBNiQjCpWC3IEBMKFBOwOyhhQ3jywMrISqCwJTdr4aA=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
@@ -222,26 +218,14 @@ github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:s
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
-github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.8.1 h1:4obDoB6/dhdBt9xMweX4nww5cjdOq/nYF4ecwPq2+mg=
github.com/tiktoken-go/tokenizer v0.8.1/go.mod h1:eLA0t6nGvn9mDc7gt90qt7pMat+gE9ViqwQ6l9B+tA4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -254,30 +238,24 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
-golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
-golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
-golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
-golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
-golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
-golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
-golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -287,20 +265,13 @@ golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
-golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
-golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
+golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
-golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
-golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
-golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go
index f59aaf0d15a..8305345fe94 100644
--- a/internal/api/handlers/management/auth_files.go
+++ b/internal/api/handlers/management/auth_files.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/antigravity"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
@@ -20,7 +21,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
@@ -308,7 +308,7 @@ func matchesAuthFileLookup(auth *coreauth.Auth, name string, authIndex string) b
func (h *Handler) lookupAuthFile(name string, authIndex string) (*coreauth.Auth, bool) {
name = strings.TrimSpace(name)
authIndex = strings.TrimSpace(authIndex)
- if h == nil || h.authManager == nil || name == "" {
+ if h == nil || h.authManager == nil || (name == "" && authIndex == "") {
return nil, false
}
if authIndex == "" {
@@ -423,29 +423,31 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context, pagination authFilesPagi
// Read file to get type field
full := filepath.Join(h.cfg.AuthDir, name)
if data, errRead := os.ReadFile(full); errRead == nil {
- typeValue := gjson.GetBytes(data, "type").String()
- emailValue := gjson.GetBytes(data, "email").String()
+ var fieldBuf [7]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldBuf[:0], data, "type", "email", "project_id", "priority", coreauth.AttributeWeight, "note", "websockets")
+ typeValue := strings.Clone(fields[0].String())
+ emailValue := strings.Clone(fields[1].String())
fileData["type"] = typeValue
fileData["email"] = emailValue
- if projectID := strings.TrimSpace(gjson.GetBytes(data, "project_id").String()); projectID != "" {
- fileData["project_id"] = projectID
+ if projectID := strings.TrimSpace(fields[2].String()); projectID != "" {
+ fileData["project_id"] = strings.Clone(projectID)
}
- if pv := gjson.GetBytes(data, "priority"); pv.Exists() {
+ if pv := fields[3]; pv.Exists() {
switch pv.Type {
- case gjson.Number:
+ case ojson.Number:
fileData["priority"] = int(pv.Int())
- case gjson.String:
+ case ojson.String:
if parsed, errAtoi := strconv.Atoi(strings.TrimSpace(pv.String())); errAtoi == nil {
fileData["priority"] = parsed
}
}
}
- if wv := gjson.GetBytes(data, coreauth.AttributeWeight); wv.Exists() {
+ if wv := fields[4]; wv.Exists() {
var rawWeight string
switch wv.Type {
- case gjson.Number:
+ case ojson.Number:
rawWeight = wv.Raw
- case gjson.String:
+ case ojson.String:
rawWeight = wv.String()
}
if rawWeight != "" {
@@ -454,18 +456,18 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context, pagination authFilesPagi
}
}
}
- if nv := gjson.GetBytes(data, "note"); nv.Exists() && nv.Type == gjson.String {
+ if nv := fields[5]; nv.Exists() && nv.Type == ojson.String {
if trimmed := strings.TrimSpace(nv.String()); trimmed != "" {
- fileData["note"] = trimmed
+ fileData["note"] = strings.Clone(trimmed)
}
}
- if wv := gjson.GetBytes(data, "websockets"); wv.Exists() {
+ if wv := fields[6]; wv.Exists() {
switch wv.Type {
- case gjson.True:
+ case ojson.True:
fileData["websockets"] = true
- case gjson.False:
+ case ojson.False:
fileData["websockets"] = false
- case gjson.String:
+ case ojson.String:
if parsed, errParse := strconv.ParseBool(strings.TrimSpace(wv.String())); errParse == nil {
fileData["websockets"] = parsed
}
diff --git a/internal/api/handlers/management/auth_files_cooldown_test.go b/internal/api/handlers/management/auth_files_cooldown_test.go
index 6d291bacac5..b8f15ecb1e7 100644
--- a/internal/api/handlers/management/auth_files_cooldown_test.go
+++ b/internal/api/handlers/management/auth_files_cooldown_test.go
@@ -1303,3 +1303,99 @@ func (e *failingRefreshManagementExecutor) CountTokens(context.Context, *coreaut
func (e *failingRefreshManagementExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
return nil, nil
}
+
+func TestRefreshAuthFilesV8_SignInRequiredAndRedaction_Issue7135(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ authDir := t.TempDir()
+ cfg := &config.Config{AuthDir: authDir}
+ manager := coreauth.NewManager(nil, nil, nil)
+
+ exec := &failingRefreshManagementExecutor{
+ provider: "codex",
+ err: fmt.Errorf(`token refresh failed with status 401: {"error":{"code":"refresh_token_reused","message":"Refresh token has already been used"}}`),
+ }
+ manager.RegisterExecutor(exec)
+
+ credPath := filepath.Join(authDir, "codex-dead.json")
+ if errWrite := os.WriteFile(credPath, []byte(`{"type":"codex","email":"dead@example.com","access_token":"secret-dead-access-token","refresh_token":"secret-dead-refresh-token"}`), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+
+ futureExpiry := time.Now().UTC().Add(4 * time.Hour).Truncate(time.Second)
+ registerAuthForLookupTest(t, manager, &coreauth.Auth{
+ ID: "codex-dead.json",
+ Index: "idx-codex-dead",
+ FileName: "codex-dead.json",
+ Provider: "codex",
+ Status: coreauth.StatusError,
+ Unavailable: true,
+ RequiresSignIn: true,
+ RejectedAccessToken: "secret-dead-access-token",
+ RejectedRefreshToken: "secret-dead-refresh-token",
+ StatusMessage: "unauthorized (refresh token invalid)",
+ LastError: &coreauth.Error{Code: "unauthorized", Message: "refresh token was already used", HTTPStatus: http.StatusUnauthorized},
+ RefreshError: &coreauth.Error{Code: "refresh_token_reused", Message: "refresh token was already used", HTTPStatus: http.StatusUnauthorized},
+ Metadata: map[string]any{
+ "type": "codex",
+ "email": "dead@example.com",
+ "access_token": "secret-dead-access-token",
+ "refresh_token": "secret-dead-refresh-token",
+ "expired": futureExpiry.Format(time.RFC3339),
+ },
+ Attributes: map[string]string{"path": credPath, "source": credPath},
+ })
+
+ // Simulate executor.Refresh returning (auth, nil) without rotating tokens when RejectedRefreshToken is unchanged.
+ exec.err = nil
+ exec.rotateTokens = false
+
+ h := NewHandlerWithoutConfigFilePath(cfg, manager)
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(ConfigV8ContextKey, true)
+ c.Next()
+ })
+ router.POST("/v8/management/credentials/refresh", h.RefreshAuthFiles)
+ router.GET("/v8/management/credentials", h.ListAuthFiles)
+
+ // 1. Refreshing a sign-in-required dead credential with unchanged tokens must fail (HTTP 500) and keep status=error, unavailable=true.
+ wRefreshDead := httptest.NewRecorder()
+ reqRefreshDead := httptest.NewRequest(http.MethodPost, "/v8/management/credentials/refresh", strings.NewReader(`{"name":"codex-dead.json"}`))
+ reqRefreshDead.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wRefreshDead, reqRefreshDead)
+ if wRefreshDead.Code != http.StatusInternalServerError {
+ t.Fatalf("POST /v8/management/credentials/refresh on dead sign-in-required credential status = %d, want 500: %s", wRefreshDead.Code, wRefreshDead.Body.String())
+ }
+
+ wList := httptest.NewRecorder()
+ router.ServeHTTP(wList, httptest.NewRequest(http.MethodGet, "/v8/management/credentials?name=codex-dead.json", nil))
+ var listResp struct {
+ Files []map[string]any `json:"files"`
+ }
+ if errUnmarshal := json.Unmarshal(wList.Body.Bytes(), &listResp); errUnmarshal != nil {
+ t.Fatalf("unmarshal list: %v", errUnmarshal)
+ }
+ if len(listResp.Files) != 1 {
+ t.Fatalf("len(files) = %d, want 1", len(listResp.Files))
+ }
+ deadEntry := listResp.Files[0]
+ if deadEntry["status"] != string(coreauth.StatusError) || deadEntry["unavailable"] != true || deadEntry["requires_sign_in"] != true || deadEntry["recovery_stage"] != coreauth.RecoveryStageSignInRequired {
+ t.Fatalf("expected status=error unavailable=true requires_sign_in=true recovery_stage=sign_in_required, got %#v", deadEntry)
+ }
+
+ // 2. When a credential refresh genuinely rotates tokens on v8 /credentials/refresh, the response must use the redacted entry view.
+ exec.rotateTokens = true
+ wRefreshOK := httptest.NewRecorder()
+ reqRefreshOK := httptest.NewRequest(http.MethodPost, "/v8/management/credentials/refresh", strings.NewReader(`{"name":"codex-dead.json"}`))
+ reqRefreshOK.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wRefreshOK, reqRefreshOK)
+ if wRefreshOK.Code != http.StatusOK {
+ t.Fatalf("expected 200 after token rotation, got %d: %s", wRefreshOK.Code, wRefreshOK.Body.String())
+ }
+ rawOK := wRefreshOK.Body.String()
+ for _, secret := range []string{"rotated-access-token", "rotated-refresh-token", "secret-dead-access-token", "secret-dead-refresh-token"} {
+ if strings.Contains(rawOK, secret) {
+ t.Fatalf("POST /v8/management/credentials/refresh leaked plaintext token %q in response: %s", secret, rawOK)
+ }
+ }
+}
diff --git a/internal/api/handlers/management/auth_files_crud.go b/internal/api/handlers/management/auth_files_crud.go
index f1d29069884..ddecf8cee35 100644
--- a/internal/api/handlers/management/auth_files_crud.go
+++ b/internal/api/handlers/management/auth_files_crud.go
@@ -504,6 +504,10 @@ func (h *Handler) authIDForPath(path string) string {
}
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
+ return h.registerAuthFromFileWithID(ctx, path, data, "")
+}
+
+func (h *Handler) registerAuthFromFileWithID(ctx context.Context, path string, data []byte, preferredID string) error {
if h.authManager == nil {
return nil
}
@@ -511,6 +515,19 @@ func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []
if err != nil {
return err
}
+ if trimmedID := strings.TrimSpace(preferredID); trimmedID != "" && auth != nil && auth.ID != trimmedID {
+ auth.ID = trimmedID
+ if existing, ok := h.authManager.GetByID(trimmedID); ok && existing != nil {
+ auth.CreatedAt = existing.CreatedAt
+ if !coreauth.CredentialsChanged(existing, auth) {
+ if auth.LastRefreshedAt.IsZero() {
+ auth.LastRefreshedAt = existing.LastRefreshedAt
+ }
+ auth.NextRefreshAfter = existing.NextRefreshAfter
+ }
+ auth.Runtime = existing.Runtime
+ }
+ }
return h.upsertAuthRecord(ctx, auth)
}
diff --git a/internal/api/handlers/management/auth_files_devin_oauth.go b/internal/api/handlers/management/auth_files_devin_oauth.go
index 79aab8c8b69..12cfeeef2ee 100644
--- a/internal/api/handlers/management/auth_files_devin_oauth.go
+++ b/internal/api/handlers/management/auth_files_devin_oauth.go
@@ -65,6 +65,8 @@ func (h *Handler) RequestDevinToken(c *gin.Context) {
authSvc := newDevinOAuthService(cfg)
authURL := authSvc.BuildAuthorizationURL(redirectURI, pkceCodes.CodeChallenge, state)
RegisterOAuthSessionWithProxy(state, "devin", sessionProxyURL)
+ respPayload := gin.H{"status": "ok", "url": authURL, "state": state}
+ bindOAuthSessionTarget(c, state, respPayload)
// Do not inherit the HTTP request cancellation: login continues after returning the URL.
ctx := PopulateAuthContext(context.Background(), c)
authDir := ""
@@ -72,7 +74,7 @@ func (h *Handler) RequestDevinToken(c *gin.Context) {
authDir = cfg.AuthDir
}
go h.completeDevinOAuth(ctx, authDir, state, pkceCodes.CodeVerifier, authSvc)
- c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
+ c.JSON(http.StatusOK, respPayload)
}
func (h *Handler) completeDevinOAuth(ctx context.Context, authDir, state, codeVerifier string, authSvc devinOAuthService) {
@@ -128,15 +130,9 @@ func (h *Handler) completeDevinOAuth(ctx context.Context, authDir, state, codeVe
}
return
}
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, "devin"); errGuard != nil {
+ if _, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, "devin", sessionProxyURL, record, "Failed to save authentication tokens", false); !okSaved {
return
}
- if _, errSave := h.saveTokenRecord(ctx, record); errSave != nil {
- FailOAuthSessionCommit(state, "Failed to save authentication tokens")
- return
- }
- CompleteOAuthSession(state)
log.Info("Devin authentication successful")
}
diff --git a/internal/api/handlers/management/auth_files_devin_oauth_test.go b/internal/api/handlers/management/auth_files_devin_oauth_test.go
index 0c050508f11..7bf92c8747c 100644
--- a/internal/api/handlers/management/auth_files_devin_oauth_test.go
+++ b/internal/api/handlers/management/auth_files_devin_oauth_test.go
@@ -3,9 +3,9 @@ package management
import (
"context"
"crypto/sha256"
- "encoding/base64"
"encoding/json"
"errors"
+ "github.com/MoeForge/moego/base64"
"net/http"
"net/http/httptest"
"net/url"
diff --git a/internal/api/handlers/management/auth_files_fields.go b/internal/api/handlers/management/auth_files_fields.go
index 6352f5ca607..f2f89ae84cb 100644
--- a/internal/api/handlers/management/auth_files_fields.go
+++ b/internal/api/handlers/management/auth_files_fields.go
@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
+ xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/credentialweight"
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
@@ -959,12 +960,20 @@ func (h *Handler) mergeExistingAuthFileMetadata(record *coreauth.Auth) {
var existingMap map[string]any
if cfg := h.configSnapshot(); cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" {
- targetFile := record.FileName
- if targetFile == "" {
- targetFile = record.ID
+ fullPath := ""
+ if record.Attributes != nil {
+ fullPath = strings.TrimSpace(record.Attributes[coreauth.AttributePath])
}
- if targetFile != "" {
- fullPath := filepath.Join(cfg.AuthDir, targetFile)
+ if fullPath == "" {
+ targetFile := record.FileName
+ if targetFile == "" {
+ targetFile = record.ID
+ }
+ if targetFile != "" {
+ fullPath = filepath.Join(cfg.AuthDir, targetFile)
+ }
+ }
+ if fullPath != "" {
if raw, errRead := os.ReadFile(fullPath); errRead == nil && len(raw) > 0 {
_ = json.Unmarshal(raw, &existingMap)
}
@@ -989,6 +998,23 @@ func (h *Handler) mergeExistingAuthFileMetadata(record *coreauth.Auth) {
}
}
+type targetedOAuthSaveContextKey struct{}
+
+func withTargetedOAuthSave(ctx context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return context.WithValue(ctx, targetedOAuthSaveContextKey{}, true)
+}
+
+func isTargetedOAuthSave(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ targeted, _ := ctx.Value(targetedOAuthSaveContextKey{}).(bool)
+ return targeted
+}
+
func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
if record == nil {
return "", fmt.Errorf("token record is nil")
@@ -998,12 +1024,50 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s
if store == nil {
return "", fmt.Errorf("token store unavailable")
}
- legacyClaudeCredential, errLegacy := claudeauth.FindMatchingLegacyCredential(ctx, store, record)
- if errLegacy != nil {
- return "", errLegacy
+ var legacyClaudeCredential *coreauth.Auth
+ var legacyXAICredential *coreauth.Auth
+ if !isTargetedOAuthSave(ctx) {
+ var errLegacy error
+ legacyClaudeCredential, errLegacy = claudeauth.FindMatchingLegacyCredential(ctx, store, record)
+ if errLegacy != nil {
+ return "", errLegacy
+ }
+ if legacyClaudeCredential != nil {
+ coreauth.MergeExistingAuthMetadata(record, legacyClaudeCredential.Metadata)
+ }
+ var errLegacyXAI error
+ legacyXAICredential, errLegacyXAI = xaiauth.FindMatchingLegacyCredential(ctx, store, record)
+ if errLegacyXAI != nil {
+ return "", errLegacyXAI
+ }
}
- if legacyClaudeCredential != nil {
- coreauth.MergeExistingAuthMetadata(record, legacyClaudeCredential.Metadata)
+ adoptXAICredential := legacyXAICredential != nil &&
+ strings.EqualFold(strings.TrimSpace(record.Provider), "xai") &&
+ metadataStringValue(record.Metadata, "email") == "" &&
+ metadataStringValue(record.Metadata, "sub") != "" &&
+ metadataStringValue(legacyXAICredential.Metadata, "email") != ""
+ if adoptXAICredential {
+ // The upstream stopped returning the email; keep the account on its
+ // richer email-named credential (richest-known name wins) so the next
+ // login that returns the email finds the same file again. Renaming is
+ // limited to stores that resolve the save path from the record name;
+ // every built-in backend (file, git, Postgres, object) exposes the
+ // SetBaseDir capability, so unknown stores keep the ordinary
+ // migration instead of risking a rename they cannot honor.
+ if _, isFileNameStore := store.(interface{ SetBaseDir(string) }); !isFileNameStore {
+ adoptXAICredential = false
+ } else if disabled, hasDisabled := record.Metadata["disabled"].(bool); hasDisabled && disabled {
+ // The destination file deliberately disables this credential; renaming
+ // would leave it disabled while the richer file gets deleted, losing
+ // the operator's opt-out and the saved credential on re-login.
+ adoptXAICredential = false
+ }
+ }
+ if legacyXAICredential != nil {
+ coreauth.MergeExistingAuthMetadata(record, legacyXAICredential.Metadata)
+ }
+ if adoptXAICredential {
+ adoptLegacyXAIFileName(record, legacyXAICredential)
}
if h.postAuthHook != nil {
if err := h.postAuthHook(ctx, record); err != nil {
@@ -1026,12 +1090,29 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s
return savedPath, fmt.Errorf("canonical Claude credential saved but legacy credential cleanup failed: %w", errDelete)
}
}
+ if legacyXAICredential != nil && !adoptXAICredential {
+ if strings.TrimSpace(savedPath) == "" {
+ return "", fmt.Errorf("canonical xAI credential was not persisted; legacy credential retained")
+ }
+ legacyID := strings.TrimSpace(legacyXAICredential.ID)
+ if legacyID == "" {
+ legacyID = strings.TrimSpace(legacyXAICredential.FileName)
+ }
+ if errDelete := store.Delete(ctx, legacyID); errDelete != nil {
+ return savedPath, fmt.Errorf("canonical xAI credential saved but legacy credential cleanup failed: %w", errDelete)
+ }
+ }
if h.postAuthPersistHook != nil {
persistedRecord := record
if data, errRead := os.ReadFile(savedPath); errRead == nil && len(data) > 0 {
+ cfg := h.configSnapshot()
+ authDir := filepath.Dir(savedPath)
+ if cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" {
+ authDir = cfg.AuthDir
+ }
auths, errSynthesize := synthesizer.SynthesizeAuthFile(&synthesizer.SynthesisContext{
- Config: h.configSnapshot(),
- AuthDir: filepath.Dir(savedPath),
+ Config: cfg,
+ AuthDir: authDir,
Now: time.Now(),
IDGenerator: synthesizer.NewStableIDGenerator(),
PluginAuthParser: h.pluginHost,
@@ -1041,6 +1122,9 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s
}
for _, auth := range auths {
if auth != nil && (auth.ID == record.ID || len(auths) == 1) {
+ if len(auths) == 1 && strings.TrimSpace(record.ID) != "" {
+ auth.ID = record.ID
+ }
persistedRecord = auth
break
}
@@ -1050,9 +1134,46 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (s
return savedPath, fmt.Errorf("post-auth persist hook failed: %w", errHook)
}
} else if h.authManager != nil && strings.TrimSpace(savedPath) != "" {
- if errReg := h.registerAuthFromFile(ctx, savedPath, nil); errReg != nil {
+ if errReg := h.registerAuthFromFileWithID(ctx, savedPath, nil, record.ID); errReg != nil {
return savedPath, fmt.Errorf("register persisted auth failed: %w", errReg)
}
}
return savedPath, nil
}
+
+func metadataStringValue(metadata map[string]any, key string) string {
+ value, _ := metadata[key].(string)
+ return strings.TrimSpace(value)
+}
+
+// adoptLegacyXAIFileName points a subject-only xAI record at the existing
+// email-named credential so the save refreshes that file in place. The token
+// storage email is backfilled because SaveTokenToFile merges the record
+// metadata over the serialized storage and would otherwise drop the email.
+// Path attributes pointing at the superseded subject-named file are cleared
+// so the store resolves the adopted name.
+func adoptLegacyXAIFileName(record, legacy *coreauth.Auth) {
+ legacyFileName := strings.TrimSpace(legacy.FileName)
+ if legacyFileName == "" {
+ legacyFileName = strings.TrimSpace(legacy.ID)
+ }
+ if legacyFileName == "" {
+ return
+ }
+ oldBaseName := filepath.Base(strings.TrimSpace(record.FileName))
+ if oldBaseName == "" || oldBaseName == "." {
+ oldBaseName = filepath.Base(strings.TrimSpace(record.ID))
+ }
+ record.ID = legacyFileName
+ record.FileName = legacyFileName
+ if storage, ok := record.Storage.(*xaiauth.TokenStorage); ok && storage != nil {
+ if email := metadataStringValue(legacy.Metadata, "email"); email != "" {
+ storage.Email = email
+ }
+ }
+ for key, value := range record.Attributes {
+ if strings.TrimSpace(value) != "" && strings.EqualFold(filepath.Base(strings.TrimSpace(value)), oldBaseName) {
+ delete(record.Attributes, key)
+ }
+ }
+}
diff --git a/internal/api/handlers/management/auth_files_oauth_callback.go b/internal/api/handlers/management/auth_files_oauth_callback.go
index 0efdb35fbed..554ed49cf00 100644
--- a/internal/api/handlers/management/auth_files_oauth_callback.go
+++ b/internal/api/handlers/management/auth_files_oauth_callback.go
@@ -243,7 +243,15 @@ func (h *Handler) ServePluginAuthURL(c *gin.Context) bool {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to generate authorization url"})
return true
}
- c.JSON(http.StatusOK, gin.H{"status": "ok", "url": resp.URL, "state": state})
+ sessionTarget, hasSessionTarget := oauthSessionTargetFromContext(c)
+ if hasSessionTarget {
+ SetOAuthSessionTarget(state, sessionTarget)
+ }
+ respPayload := gin.H{"status": "ok", "url": resp.URL, "state": state}
+ if hasSessionTarget {
+ respPayload["target_auth_id"] = sessionTarget.AuthID
+ }
+ c.JSON(http.StatusOK, respPayload)
return true
}
diff --git a/internal/api/handlers/management/auth_files_patch_fields_test.go b/internal/api/handlers/management/auth_files_patch_fields_test.go
index 4e2331f2830..21339d84f1f 100644
--- a/internal/api/handlers/management/auth_files_patch_fields_test.go
+++ b/internal/api/handlers/management/auth_files_patch_fields_test.go
@@ -2,9 +2,9 @@ package management
import (
"context"
- "encoding/base64"
"encoding/json"
"fmt"
+ "github.com/MoeForge/moego/base64"
"net/http"
"net/http/httptest"
"os"
diff --git a/internal/api/handlers/management/auth_files_plugin_oauth_test.go b/internal/api/handlers/management/auth_files_plugin_oauth_test.go
index e548440297b..a1bbe57e385 100644
--- a/internal/api/handlers/management/auth_files_plugin_oauth_test.go
+++ b/internal/api/handlers/management/auth_files_plugin_oauth_test.go
@@ -2,6 +2,7 @@ package management
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"net/http"
@@ -262,15 +263,25 @@ func (s *pluginLoginRollbackStore) Delete(_ context.Context, id string) error {
func (s *pluginLoginRollbackStore) SetBaseDir(string) {}
type testAuthProvider struct {
- identifier string
- startLogin func(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error)
- pollLogin func(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error)
+ identifier string
+ identifiers []string
+ entries []pluginapi.AuthProviderEntry
+ startLogin func(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error)
+ pollLogin func(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error)
}
func (p *testAuthProvider) Identifier() string {
return p.identifier
}
+func (p *testAuthProvider) Identifiers() []string {
+ return append([]string(nil), p.identifiers...)
+}
+
+func (p *testAuthProvider) AuthEntries() []pluginapi.AuthProviderEntry {
+ return append([]pluginapi.AuthProviderEntry(nil), p.entries...)
+}
+
func (p *testAuthProvider) ParseAuth(context.Context, pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
return pluginapi.AuthParseResponse{}, nil
}
@@ -409,3 +420,687 @@ func TestV8PluginOAuthUsesIndependentCallback(t *testing.T) {
t.Fatalf("plugin login calls=%d, want 2", callCount)
}
}
+
+func TestV8PluginMultiPortalOAuthListLoginPollAndRelogin(t *testing.T) {
+ authDir := t.TempDir()
+ pluginsDir := writeManagementPluginFile(t, "omc-zcode-oauth")
+ enabled := true
+ host := pluginhost.New()
+ callCount := 0
+ host.RegisterPluginForTest("omc-zcode-oauth", pluginapi.Plugin{
+ Metadata: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Version: "1.0.0",
+ Author: "test",
+ GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
+ },
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: &testAuthProvider{
+ identifier: "zcode-zai",
+ identifiers: []string{"zcode-zai", "zcode-bigmodel"},
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ callCount++
+ state := fmt.Sprintf("state-zcode-%s-%d", req.Provider, callCount)
+ t.Cleanup(func() { CancelOAuthSession(state) })
+ loginURL := "https://chat.z.ai/api/oauth/authorize"
+ if req.Provider == "zcode-bigmodel" {
+ loginURL = "https://bigmodel.cn/login?appId=zcode"
+ }
+ return pluginapi.AuthLoginStartResponse{
+ Provider: req.Provider,
+ URL: loginURL,
+ State: state,
+ ExpiresAt: time.Now().Add(time.Hour),
+ }, nil
+ },
+ pollLogin: func(_ context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{
+ Status: pluginapi.AuthLoginStatusSuccess,
+ Auth: pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "zcode-account.json",
+ FileName: "zcode-account.json",
+ Label: "ZCode Account (" + req.Provider + ")",
+ StorageJSON: []byte(`{"type":"zcode","access_token":"tok"}`),
+ Metadata: map[string]any{"type": "zcode", "portal": req.Provider, "email": "user@example.com"},
+ },
+ }, nil
+ },
+ },
+ },
+ })
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ h := NewHandlerWithoutConfigFilePath(&config.Config{
+ AuthDir: authDir,
+ Port: 8317,
+ Plugins: config.PluginsConfig{
+ Enabled: true,
+ Dir: pluginsDir,
+ Configs: map[string]config.PluginInstanceConfig{
+ "omc-zcode-oauth": {Enabled: &enabled},
+ },
+ },
+ }, manager)
+ h.SetPluginHost(host)
+
+ router := gin.New()
+ router.GET("/v8/management/plugins", h.ListPlugins)
+ router.GET("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.GET("/v8/management/get-auth-status", h.GetAuthStatus)
+
+ listRec := httptest.NewRecorder()
+ router.ServeHTTP(listRec, httptest.NewRequest(http.MethodGet, "/v8/management/plugins", nil))
+ if listRec.Code != http.StatusOK {
+ t.Fatalf("GET /v8/management/plugins status=%d body=%s", listRec.Code, listRec.Body.String())
+ }
+ var listBody struct {
+ Plugins []struct {
+ ID string `json:"id"`
+ SupportsOAuth bool `json:"supports_oauth"`
+ OAuthProvider string `json:"oauth_provider"`
+ OAuthProviders []string `json:"oauth_providers"`
+ } `json:"plugins"`
+ }
+ if errUnmarshal := json.Unmarshal(listRec.Body.Bytes(), &listBody); errUnmarshal != nil {
+ t.Fatalf("decode plugins list: %v", errUnmarshal)
+ }
+ if len(listBody.Plugins) != 1 || !listBody.Plugins[0].SupportsOAuth || listBody.Plugins[0].OAuthProvider != "zcode-zai" {
+ t.Fatalf("plugins list = %#v, want SupportsOAuth=true OAuthProvider=zcode-zai", listBody.Plugins)
+ }
+ if !reflect.DeepEqual(listBody.Plugins[0].OAuthProviders, []string{"zcode-zai", "zcode-bigmodel"}) {
+ t.Fatalf("oauth_providers = %#v, want [zcode-zai zcode-bigmodel]", listBody.Plugins[0].OAuthProviders)
+ }
+ if strings.Contains(listRec.Body.String(), "oauth_provider_entries") {
+ t.Fatalf("expected plugin without AuthEntries to omit oauth_provider_entries, got body=%s", listRec.Body.String())
+ }
+
+ // Start and complete login via second portal (zcode-bigmodel)
+ startRec := httptest.NewRecorder()
+ router.ServeHTTP(startRec, httptest.NewRequest(http.MethodGet, "/v8/management/oauth/auth-url?provider=zcode-bigmodel", nil))
+ if startRec.Code != http.StatusOK {
+ t.Fatalf("start zcode-bigmodel status=%d body=%s", startRec.Code, startRec.Body.String())
+ }
+ var startPayload struct {
+ URL string `json:"url"`
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(startRec.Body.Bytes(), &startPayload); errUnmarshal != nil {
+ t.Fatalf("decode start payload: %v", errUnmarshal)
+ }
+ if startPayload.URL != "https://bigmodel.cn/login?appId=zcode" {
+ t.Fatalf("start URL = %q, want bigmodel portal URL", startPayload.URL)
+ }
+
+ pollRec := httptest.NewRecorder()
+ router.ServeHTTP(pollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+startPayload.State, nil))
+ if pollRec.Code != http.StatusOK || !strings.Contains(pollRec.Body.String(), `"status":"ok"`) {
+ t.Fatalf("poll zcode-bigmodel status=%d body=%s", pollRec.Code, pollRec.Body.String())
+ }
+
+ // Simulate a cold-started host (no prior in-memory PollLogin state) with the saved "zcode" credential
+ pollEmail := "user@example.com"
+ coldHost := pluginhost.New()
+ coldHost.RegisterPluginForTest("omc-zcode-oauth", pluginapi.Plugin{
+ Metadata: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Version: "1.0.0",
+ Author: "test",
+ GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
+ },
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: &testAuthProvider{
+ identifier: "zcode-zai",
+ identifiers: []string{"zcode-zai", "zcode-bigmodel"},
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ callCount++
+ state := fmt.Sprintf("state-zcode-cold-%s-%d", req.Provider, callCount)
+ t.Cleanup(func() { CancelOAuthSession(state) })
+ return pluginapi.AuthLoginStartResponse{
+ Provider: req.Provider,
+ URL: "https://portal.example/" + req.Provider,
+ State: state,
+ ExpiresAt: time.Now().Add(time.Hour),
+ }, nil
+ },
+ pollLogin: func(_ context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{
+ Status: pluginapi.AuthLoginStatusSuccess,
+ Auth: pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "zcode-zai-different-file.json",
+ FileName: "zcode-zai-different-file.json",
+ Label: "ZCode Relogin (" + req.Provider + ")",
+ StorageJSON: []byte(`{"type":"zcode","access_token":"tok-relogin"}`),
+ Metadata: map[string]any{"type": "zcode", "email": pollEmail, "access_token": "tok-relogin"},
+ Attributes: map[string]string{"path": filepath.Join(authDir, "zcode-zai-different-file.json")},
+ },
+ }, nil
+ },
+ },
+ },
+ })
+ h.SetPluginHost(coldHost)
+
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: "zcode-account.json",
+ FileName: "zcode-account.json",
+ Provider: "zcode",
+ Metadata: map[string]any{"email": "user@example.com", "oauth_provider": "zcode-bigmodel"},
+ })
+ // 1. Re-login specifying an explicit portal ("zcode-zai") against canonical credential provider ("zcode") on cold start
+ reloginRec := httptest.NewRecorder()
+ router.ServeHTTP(reloginRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"zcode-zai","name":"zcode-account.json"}`)))
+ if reloginRec.Code != http.StatusOK {
+ t.Fatalf("relogin via zcode-zai portal for zcode credential status=%d body=%s, want 200", reloginRec.Code, reloginRec.Body.String())
+ }
+ var reloginStart struct {
+ State string `json:"state"`
+ TargetAuthID string `json:"target_auth_id"`
+ }
+ if errUnmarshal := json.Unmarshal(reloginRec.Body.Bytes(), &reloginStart); errUnmarshal != nil {
+ t.Fatalf("decode relogin start payload: %v", errUnmarshal)
+ }
+ if reloginStart.TargetAuthID != "zcode-account.json" {
+ t.Fatalf("relogin target_auth_id = %q, want zcode-account.json", reloginStart.TargetAuthID)
+ }
+ reloginPollRec := httptest.NewRecorder()
+ router.ServeHTTP(reloginPollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+reloginStart.State, nil))
+ if reloginPollRec.Code != http.StatusOK || !strings.Contains(reloginPollRec.Body.String(), `"status":"ok"`) {
+ t.Fatalf("poll relogin status=%d body=%s, want ok", reloginPollRec.Code, reloginPollRec.Body.String())
+ }
+ if _, errStat := os.Stat(filepath.Join(authDir, "zcode-zai-different-file.json")); !os.IsNotExist(errStat) {
+ t.Fatalf("expected relogin to replace target file instead of writing zcode-zai-different-file.json, stat err=%v", errStat)
+ }
+ rawSaved, errRead := os.ReadFile(filepath.Join(authDir, "zcode-account.json"))
+ if errRead != nil {
+ t.Fatalf("read replaced target file: %v", errRead)
+ }
+ if !strings.Contains(string(rawSaved), `"oauth_provider":"zcode-zai"`) || !strings.Contains(string(rawSaved), `"access_token":"tok-relogin"`) {
+ t.Fatalf("replaced target file content = %s, want oauth_provider=zcode-zai and access_token=tok-relogin", string(rawSaved))
+ }
+ if strings.Contains(string(rawSaved), `"portal":"zcode-bigmodel"`) {
+ t.Fatalf("replaced target file retained stale portal metadata: %s", string(rawSaved))
+ }
+
+ // 2. Re-login omitting "provider" (only passing credential "name") on cold start, and verify mismatched email is rejected
+ pollEmail = "wrong-user@example.com"
+ reloginNoProviderRec := httptest.NewRecorder()
+ router.ServeHTTP(reloginNoProviderRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"name":"zcode-account.json"}`)))
+ if reloginNoProviderRec.Code != http.StatusOK {
+ t.Fatalf("relogin omitting provider for zcode credential status=%d body=%s, want 200", reloginNoProviderRec.Code, reloginNoProviderRec.Body.String())
+ }
+ var reloginNoProvStart struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(reloginNoProviderRec.Body.Bytes(), &reloginNoProvStart); errUnmarshal != nil {
+ t.Fatalf("decode reloginNoProvStart: %v", errUnmarshal)
+ }
+ mismatchPollRec := httptest.NewRecorder()
+ router.ServeHTTP(mismatchPollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+reloginNoProvStart.State, nil))
+ if mismatchPollRec.Code != http.StatusOK || !strings.Contains(mismatchPollRec.Body.String(), "Authenticated account does not match target credential") {
+ t.Fatalf("mismatch poll status=%d body=%s, want mismatch error", mismatchPollRec.Code, mismatchPollRec.Body.String())
+ }
+
+ // 3. Target credential without email/account_id must reject blind overwrite when filename differs
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: "zcode-anonymous.json",
+ FileName: "zcode-anonymous.json",
+ Provider: "zcode",
+ Metadata: map[string]any{"oauth_provider": "zcode-bigmodel"},
+ })
+ pollEmail = "user@example.com"
+ anonStartRec := httptest.NewRecorder()
+ router.ServeHTTP(anonStartRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"name":"zcode-anonymous.json"}`)))
+ if anonStartRec.Code != http.StatusOK {
+ t.Fatalf("anon relogin start status=%d body=%s, want 200", anonStartRec.Code, anonStartRec.Body.String())
+ }
+ var anonStart struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(anonStartRec.Body.Bytes(), &anonStart); errUnmarshal != nil {
+ t.Fatalf("decode anonStart: %v", errUnmarshal)
+ }
+ anonPollRec := httptest.NewRecorder()
+ router.ServeHTTP(anonPollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+anonStart.State, nil))
+ if anonPollRec.Code != http.StatusOK || !strings.Contains(anonPollRec.Body.String(), "Authenticated account does not match target credential") {
+ t.Fatalf("anon poll status=%d body=%s, want mismatch error when target has no identity and filename differs", anonPollRec.Code, anonPollRec.Body.String())
+ }
+}
+
+func TestV8PluginReloginPreservesSubdirTargetAndRollsBackOverwriteSafely(t *testing.T) {
+ authDir := t.TempDir()
+ rootFile := filepath.Join(authDir, "account.json")
+ teamDir := filepath.Join(authDir, "team")
+ if errMkdir := os.MkdirAll(teamDir, 0o700); errMkdir != nil {
+ t.Fatalf("mkdir team: %v", errMkdir)
+ }
+ teamFile := filepath.Join(teamDir, "account.json")
+ rootOriginal := `{"type":"zcode","email":"root@example.com","access_token":"root-token"}`
+ teamOriginal := `{"type":"zcode","email":"user@example.com","oauth_provider":"zcode-bigmodel","access_token":"old-team-token"}`
+ if errWrite := os.WriteFile(rootFile, []byte(rootOriginal), 0o600); errWrite != nil {
+ t.Fatalf("write root file: %v", errWrite)
+ }
+ if errWrite := os.WriteFile(teamFile, []byte(teamOriginal), 0o600); errWrite != nil {
+ t.Fatalf("write team file: %v", errWrite)
+ }
+
+ callCount := 0
+ currentToken := "team-relogin-token"
+ host := pluginhost.New()
+ host.RegisterPluginForTest("omc-zcode-oauth", pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: &testAuthProvider{
+ identifier: "zcode-zai",
+ identifiers: []string{"zcode-zai", "zcode-bigmodel"},
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ callCount++
+ state := fmt.Sprintf("state-subdir-%d", callCount)
+ t.Cleanup(func() { CancelOAuthSession(state) })
+ return pluginapi.AuthLoginStartResponse{
+ Provider: req.Provider,
+ URL: "https://portal.example/" + req.Provider,
+ State: state,
+ ExpiresAt: time.Now().Add(time.Hour),
+ }, nil
+ },
+ pollLogin: func(_ context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{
+ Status: pluginapi.AuthLoginStatusSuccess,
+ Auth: pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "plugin-generated.json",
+ FileName: "plugin-generated.json",
+ StorageJSON: []byte(fmt.Sprintf(`{"type":"zcode","access_token":%q}`, currentToken)),
+ Metadata: map[string]any{"type": "zcode", "email": "user@example.com", "access_token": currentToken},
+ Attributes: map[string]string{"path": filepath.Join(authDir, "plugin-generated.json")},
+ },
+ }, nil
+ },
+ },
+ },
+ })
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: "team/account.json",
+ FileName: "account.json",
+ Provider: "zcode",
+ Metadata: map[string]any{"email": "user@example.com", "oauth_provider": "zcode-bigmodel", "access_token": "old-team-token"},
+ Attributes: map[string]string{"path": teamFile},
+ })
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, manager)
+ h.SetPluginHost(host)
+
+ router := gin.New()
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.GET("/v8/management/get-auth-status", h.GetAuthStatus)
+
+ // 1. Re-login targeting subdirectory credential "team/account.json" must update team/account.json, leave root account.json untouched,
+ // and pass persistedRecord.ID == "team/account.json" (not "account.json") to postAuthPersistHook.
+ var persistedIDs []string
+ h.postAuthPersistHook = func(_ context.Context, auth *coreauth.Auth) error {
+ if auth != nil {
+ persistedIDs = append(persistedIDs, auth.ID)
+ }
+ return nil
+ }
+ startRec := httptest.NewRecorder()
+ router.ServeHTTP(startRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"zcode-zai","name":"team/account.json"}`)))
+ if startRec.Code != http.StatusOK {
+ t.Fatalf("start subdir relogin status=%d body=%s", startRec.Code, startRec.Body.String())
+ }
+ var startPayload struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(startRec.Body.Bytes(), &startPayload); errUnmarshal != nil {
+ t.Fatalf("decode start payload: %v", errUnmarshal)
+ }
+ pollRec := httptest.NewRecorder()
+ router.ServeHTTP(pollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+startPayload.State, nil))
+ if pollRec.Code != http.StatusOK || !strings.Contains(pollRec.Body.String(), `"status":"ok"`) {
+ t.Fatalf("poll subdir relogin status=%d body=%s, want ok", pollRec.Code, pollRec.Body.String())
+ }
+ if !reflect.DeepEqual(persistedIDs, []string{"team/account.json"}) {
+ t.Fatalf("postAuthPersistHook received IDs = %#v, want [team/account.json]", persistedIDs)
+ }
+ rootBytes, errReadRoot := os.ReadFile(rootFile)
+ if errReadRoot != nil || string(rootBytes) != rootOriginal {
+ t.Fatalf("root account.json was modified: %q (err=%v), want %q", string(rootBytes), errReadRoot, rootOriginal)
+ }
+ teamBytes, errReadTeam := os.ReadFile(teamFile)
+ if errReadTeam != nil || !strings.Contains(string(teamBytes), `"access_token":"team-relogin-token"`) {
+ t.Fatalf("team/account.json content = %q (err=%v), want updated team-relogin-token", string(teamBytes), errReadTeam)
+ }
+
+ // 2. If postAuthPersistHook fails after overwriting team/account.json, rollback must restore the previous file and runtime auth instead of deleting them.
+ h.postAuthPersistHook = func(_ context.Context, _ *coreauth.Auth) error {
+ return fmt.Errorf("simulated post-persist failure")
+ }
+ currentToken = "broken-token"
+ failStartRec := httptest.NewRecorder()
+ router.ServeHTTP(failStartRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"zcode-zai","name":"team/account.json"}`)))
+ if failStartRec.Code != http.StatusOK {
+ t.Fatalf("failStartRec status=%d body=%s", failStartRec.Code, failStartRec.Body.String())
+ }
+ var failStart struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(failStartRec.Body.Bytes(), &failStart); errUnmarshal != nil {
+ t.Fatalf("decode failStart: %v", errUnmarshal)
+ }
+ failPollRec := httptest.NewRecorder()
+ router.ServeHTTP(failPollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+failStart.State, nil))
+ if failPollRec.Code != http.StatusOK || !strings.Contains(failPollRec.Body.String(), "Failed to save authentication tokens") {
+ t.Fatalf("failPollRec status=%d body=%s, want save failure", failPollRec.Code, failPollRec.Body.String())
+ }
+ restoredBytes, errReadRestored := os.ReadFile(teamFile)
+ if errReadRestored != nil {
+ t.Fatalf("expected team/account.json to be preserved after failed overwrite, got err=%v", errReadRestored)
+ }
+ if !strings.Contains(string(restoredBytes), `"access_token":"team-relogin-token"`) || strings.Contains(string(restoredBytes), `"broken-token"`) {
+ t.Fatalf("restored team/account.json = %q, want previous team-relogin-token", string(restoredBytes))
+ }
+ if _, okAuth := manager.GetByID("team/account.json"); !okAuth {
+ t.Fatal("expected team/account.json to remain in runtime auth manager after failed overwrite")
+ }
+
+ // 3. If another session concurrently commits a newer credential before rollback runs, rollback must not clobber the newer file or runtime state.
+ concurrentWinnerJSON := `{"access_token":"concurrent-winner-token","disabled":false,"email":"user@example.com","oauth_provider":"zcode-zai","type":"zcode"}`
+ h.postAuthPersistHook = func(ctx context.Context, _ *coreauth.Auth) error {
+ if errWrite := os.WriteFile(teamFile, []byte(concurrentWinnerJSON), 0o600); errWrite != nil {
+ return errWrite
+ }
+ _, _ = manager.Update(ctx, &coreauth.Auth{
+ ID: "team/account.json",
+ FileName: "account.json",
+ Provider: "zcode",
+ Metadata: map[string]any{"email": "user@example.com", "oauth_provider": "zcode-zai", "access_token": "concurrent-winner-token"},
+ Attributes: map[string]string{"path": teamFile},
+ })
+ return fmt.Errorf("session A persist hook failed after concurrent session B succeeded")
+ }
+ currentToken = "session-a-stale-token"
+ raceStartRec := httptest.NewRecorder()
+ router.ServeHTTP(raceStartRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"zcode-zai","name":"team/account.json"}`)))
+ if raceStartRec.Code != http.StatusOK {
+ t.Fatalf("raceStartRec status=%d body=%s", raceStartRec.Code, raceStartRec.Body.String())
+ }
+ var raceStart struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(raceStartRec.Body.Bytes(), &raceStart); errUnmarshal != nil {
+ t.Fatalf("decode raceStart: %v", errUnmarshal)
+ }
+ racePollRec := httptest.NewRecorder()
+ router.ServeHTTP(racePollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+raceStart.State, nil))
+ if racePollRec.Code != http.StatusOK || !strings.Contains(racePollRec.Body.String(), "Failed to save authentication tokens") {
+ t.Fatalf("racePollRec status=%d body=%s, want save failure", racePollRec.Code, racePollRec.Body.String())
+ }
+ winnerBytes, errReadWinner := os.ReadFile(teamFile)
+ if errReadWinner != nil || !strings.Contains(string(winnerBytes), `"concurrent-winner-token"`) {
+ t.Fatalf("team/account.json after concurrent session = %q (err=%v), want concurrent-winner-token preserved", string(winnerBytes), errReadWinner)
+ }
+ if currentAuth, okAuth := manager.GetByID("team/account.json"); !okAuth || currentAuth == nil || currentAuth.Metadata["access_token"] != "concurrent-winner-token" {
+ t.Fatalf("runtime auth after concurrent session = %#v, want access_token=concurrent-winner-token", currentAuth)
+ }
+
+ // 4. When a plugin puts access_token only in StorageJSON (not Metadata) and a batch save fails on the second record,
+ // both disk and runtime auth for the first record must be restored to the previous state.
+ h.postAuthPersistHook = nil
+ batchRecords := []*coreauth.Auth{
+ host.AuthDataToCoreAuth(pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "team/account.json",
+ FileName: "account.json",
+ StorageJSON: []byte(`{"type":"zcode","email":"user@example.com","access_token":"storage-only-token"}`),
+ Metadata: map[string]any{"type": "zcode", "email": "user@example.com", "oauth_provider": "zcode-zai"},
+ Attributes: map[string]string{"path": teamFile},
+ }, teamFile, "account.json"),
+ {
+ ID: "invalid-second-record",
+ Provider: "zcode",
+ },
+ }
+ if errBatch := h.savePluginLoginRecords(context.Background(), batchRecords); errBatch == nil {
+ t.Fatal("expected batch savePluginLoginRecords to fail on second record")
+ }
+ if currentAuth, okAuth := manager.GetByID("team/account.json"); !okAuth || currentAuth == nil || currentAuth.Metadata["access_token"] != "concurrent-winner-token" {
+ t.Fatalf("runtime auth after StorageJSON-only batch rollback = %#v, want access_token=concurrent-winner-token", currentAuth)
+ }
+
+ // 5. Targeted re-login succeeds when email is only present in StorageJSON (omitted from AuthData.Metadata).
+ records := []*coreauth.Auth{
+ host.AuthDataToCoreAuth(pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "other-id.json",
+ FileName: "other-id.json",
+ StorageJSON: []byte(`{"type":"zcode","email":"user@example.com","access_token":"storage-email-only"}`),
+ Metadata: map[string]any{"type": "zcode"},
+ }, "", "other-id.json"),
+ }
+ if !applyPluginOAuthRecoveryTarget(oauthRecoveryTarget{
+ AuthID: "team/account.json",
+ FileName: "account.json",
+ Path: teamFile,
+ Provider: "zcode",
+ Email: "user@example.com",
+ }, "zcode-zai", records) {
+ t.Fatal("applyPluginOAuthRecoveryTarget rejected matching email stored only in StorageJSON")
+ }
+
+ // 6. When the existing target credential itself only stores email in StorageJSON (omitted from Metadata),
+ // StartOAuthV8 must still populate target.Email from RawJSON so sibling-portal relogin with a different filename succeeds.
+ storageOnlyTarget := host.AuthDataToCoreAuth(pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "storage-only-target.json",
+ FileName: "storage-only-target.json",
+ StorageJSON: []byte(`{"type":"zcode","email":"user@example.com","oauth_provider":"zcode-bigmodel","access_token":"old-tok"}`),
+ Metadata: map[string]any{"type": "zcode", "oauth_provider": "zcode-bigmodel"},
+ }, filepath.Join(authDir, "storage-only-target.json"), "storage-only-target.json")
+ _, _ = manager.Register(context.Background(), storageOnlyTarget)
+ currentToken = "storage-target-relogin-tok"
+ storageTargetStartRec := httptest.NewRecorder()
+ router.ServeHTTP(storageTargetStartRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"zcode-zai","name":"storage-only-target.json"}`)))
+ if storageTargetStartRec.Code != http.StatusOK {
+ t.Fatalf("storageTargetStartRec status=%d body=%s", storageTargetStartRec.Code, storageTargetStartRec.Body.String())
+ }
+ var storageTargetStart struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(storageTargetStartRec.Body.Bytes(), &storageTargetStart); errUnmarshal != nil {
+ t.Fatalf("decode storageTargetStart: %v", errUnmarshal)
+ }
+ storageTargetPollRec := httptest.NewRecorder()
+ router.ServeHTTP(storageTargetPollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+storageTargetStart.State, nil))
+ if storageTargetPollRec.Code != http.StatusOK || !strings.Contains(storageTargetPollRec.Body.String(), `"status":"ok"`) {
+ t.Fatalf("storageTargetPollRec status=%d body=%s, want ok", storageTargetPollRec.Code, storageTargetPollRec.Body.String())
+ }
+
+ // 7. When a newly created credential in a batch has record.ID != record.FileName and the second record fails,
+ // rollback must remove the runtime auth registered under the file-derived ID ("different-file-name.json").
+ mismatchIDRecords := []*coreauth.Auth{
+ host.AuthDataToCoreAuth(pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "custom-logical-id",
+ FileName: "different-file-name.json",
+ StorageJSON: []byte(`{"type":"zcode","email":"new@example.com","access_token":"temp-tok"}`),
+ Metadata: map[string]any{"type": "zcode", "email": "new@example.com"},
+ }, "", "different-file-name.json"),
+ {
+ ID: "invalid-second-record",
+ Provider: "zcode",
+ },
+ }
+ if errBatch := h.savePluginLoginRecords(context.Background(), mismatchIDRecords); errBatch == nil {
+ t.Fatal("expected mismatchIDRecords batch save to fail on second record")
+ }
+ if _, ok := manager.GetByID("different-file-name.json"); ok {
+ t.Fatal("expected different-file-name.json to be removed from runtime manager after batch rollback")
+ }
+ if _, ok := manager.GetByID("custom-logical-id"); ok {
+ t.Fatal("expected custom-logical-id to be absent from runtime manager after batch rollback")
+ }
+
+ // 8. When postAuthPersistHook is nil and an existing target credential has logical ID != FileName,
+ // relogin must update the existing logical ID in manager without creating a duplicate file-derived ID.
+ logicalTargetFile := filepath.Join(authDir, "custom-target-file.json")
+ if errWrite := os.WriteFile(logicalTargetFile, []byte(`{"type":"zcode","email":"user@example.com","oauth_provider":"zcode-bigmodel","access_token":"old-logical-tok"}`), 0o600); errWrite != nil {
+ t.Fatalf("write logicalTargetFile: %v", errWrite)
+ }
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: "custom-target-id",
+ FileName: "custom-target-file.json",
+ Provider: "zcode",
+ Metadata: map[string]any{"email": "user@example.com", "oauth_provider": "zcode-bigmodel", "access_token": "old-logical-tok"},
+ Attributes: map[string]string{"path": logicalTargetFile},
+ })
+ currentToken = "updated-logical-tok"
+ logicalStartRec := httptest.NewRecorder()
+ router.ServeHTTP(logicalStartRec, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"zcode-zai","name":"custom-target-id"}`)))
+ if logicalStartRec.Code != http.StatusOK {
+ t.Fatalf("logicalStartRec status=%d body=%s", logicalStartRec.Code, logicalStartRec.Body.String())
+ }
+ var logicalStart struct {
+ State string `json:"state"`
+ }
+ if errUnmarshal := json.Unmarshal(logicalStartRec.Body.Bytes(), &logicalStart); errUnmarshal != nil {
+ t.Fatalf("decode logicalStart: %v", errUnmarshal)
+ }
+ logicalPollRec := httptest.NewRecorder()
+ router.ServeHTTP(logicalPollRec, httptest.NewRequest(http.MethodGet, "/v8/management/get-auth-status?state="+logicalStart.State, nil))
+ if logicalPollRec.Code != http.StatusOK || !strings.Contains(logicalPollRec.Body.String(), `"status":"ok"`) {
+ t.Fatalf("logicalPollRec status=%d body=%s, want ok", logicalPollRec.Code, logicalPollRec.Body.String())
+ }
+ if updatedLogical, ok := manager.GetByID("custom-target-id"); !ok || updatedLogical == nil || updatedLogical.Metadata["access_token"] != "updated-logical-tok" {
+ t.Fatalf("expected custom-target-id updated in manager, got %#v", updatedLogical)
+ }
+ if _, okDup := manager.GetByID("custom-target-file.json"); okDup {
+ t.Fatal("expected no duplicate file-derived ID custom-target-file.json in manager")
+ }
+
+ // 9. When an existing file-backed credential ("team/account.json") is overwritten by a batch record with a new logical ID
+ // ("new-logical-id-for-team") and the second record fails, rollback must remove "new-logical-id-for-team" and preserve exact disk bytes.
+ if errWrite := os.WriteFile(teamFile, []byte(`{"type":"zcode","email":"user@example.com","access_token":"newer-disk-token","custom_disk_only":"keep"}`), 0o600); errWrite != nil {
+ t.Fatalf("write teamFile: %v", errWrite)
+ }
+ overwriteWithNewID := []*coreauth.Auth{
+ host.AuthDataToCoreAuth(pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "new-logical-id-for-team",
+ FileName: "account.json",
+ StorageJSON: []byte(`{"type":"zcode","email":"user@example.com","access_token":"failed-batch-token"}`),
+ Metadata: map[string]any{"type": "zcode", "email": "user@example.com"},
+ Attributes: map[string]string{"path": teamFile},
+ }, teamFile, "account.json"),
+ {
+ ID: "invalid-second-record",
+ Provider: "zcode",
+ },
+ }
+ if errBatch := h.savePluginLoginRecords(context.Background(), overwriteWithNewID); errBatch == nil {
+ t.Fatal("expected overwriteWithNewID batch save to fail on second record")
+ }
+ if _, okNewLogical := manager.GetByID("new-logical-id-for-team"); okNewLogical {
+ t.Fatal("expected new-logical-id-for-team to be removed from runtime manager after failed overwrite")
+ }
+ afterDiskBytes, errReadAfter := os.ReadFile(teamFile)
+ if errReadAfter != nil || !strings.Contains(string(afterDiskBytes), `"custom_disk_only":"keep"`) || !strings.Contains(string(afterDiskBytes), `"newer-disk-token"`) {
+ t.Fatalf("expected exact pre-save disk bytes preserved, got %q (err=%v)", string(afterDiskBytes), errReadAfter)
+ }
+}
+
+func TestListPluginsExposesOAuthProviderEntriesWithFallbackAndSanitization(t *testing.T) {
+ pluginsDir := writeManagementPluginFile(t, "omc-zcode-oauth")
+ enabled := true
+ host := pluginhost.New()
+ host.RegisterPluginForTest("omc-zcode-oauth", pluginapi.Plugin{
+ Metadata: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Version: "1.0.0",
+ Author: "test",
+ GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
+ Logo: "https://default.example/logo.svg?x=1&y=2",
+ },
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: &testAuthProvider{
+ identifier: "zcode-zai",
+ identifiers: []string{"zcode-zai", "zcode-bigmodel"},
+ entries: []pluginapi.AuthProviderEntry{
+ {
+ Identifier: " ZCode-ZAI ",
+ Name: "Z.ai ",
+ Logo: "https://chat.z.ai/logo.svg?a=1&b=2",
+ },
+ {
+ Identifier: "zcode-bigmodel",
+ Name: "",
+ Logo: "",
+ },
+ },
+ },
+ },
+ })
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{
+ AuthDir: t.TempDir(),
+ Port: 8317,
+ Plugins: config.PluginsConfig{
+ Enabled: true,
+ Dir: pluginsDir,
+ Configs: map[string]config.PluginInstanceConfig{
+ "omc-zcode-oauth": {Enabled: &enabled},
+ },
+ },
+ }, nil)
+ h.SetPluginHost(host)
+
+ router := gin.New()
+ router.GET("/v8/management/plugins", h.ListPlugins)
+
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v8/management/plugins", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /v8/management/plugins status=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var listBody struct {
+ Plugins []struct {
+ ID string `json:"id"`
+ SupportsOAuth bool `json:"supports_oauth"`
+ OAuthProvider string `json:"oauth_provider"`
+ OAuthProviders []string `json:"oauth_providers"`
+ Logo string `json:"logo"`
+ OAuthProviderEntries []struct {
+ Identifier string `json:"identifier"`
+ Name string `json:"name"`
+ Logo string `json:"logo"`
+ } `json:"oauth_provider_entries"`
+ } `json:"plugins"`
+ }
+ if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &listBody); errUnmarshal != nil {
+ t.Fatalf("decode plugins list: %v", errUnmarshal)
+ }
+ if len(listBody.Plugins) != 1 {
+ t.Fatalf("plugins len = %d, want 1", len(listBody.Plugins))
+ }
+ p := listBody.Plugins[0]
+ if p.OAuthProvider != "zcode-zai" || !reflect.DeepEqual(p.OAuthProviders, []string{"zcode-zai", "zcode-bigmodel"}) {
+ t.Fatalf("oauth_provider=%q oauth_providers=%#v", p.OAuthProvider, p.OAuthProviders)
+ }
+ if len(p.OAuthProviderEntries) != 2 {
+ t.Fatalf("oauth_provider_entries = %#v, want 2 entries", p.OAuthProviderEntries)
+ }
+ if p.OAuthProviderEntries[0].Identifier != "zcode-zai" ||
+ p.OAuthProviderEntries[0].Name != "Z.ai <International>" ||
+ p.OAuthProviderEntries[0].Logo != "https://chat.z.ai/logo.svg?a=1&b=2" {
+ t.Fatalf("entry[0] = %#v, want sanitized Z.ai entry", p.OAuthProviderEntries[0])
+ }
+ if p.OAuthProviderEntries[1].Identifier != "zcode-bigmodel" ||
+ p.OAuthProviderEntries[1].Name != "OMC ZCode OAuth" ||
+ p.OAuthProviderEntries[1].Logo != "https://default.example/logo.svg?x=1&y=2" {
+ t.Fatalf("entry[1] = %#v, want fallback to Metadata.Name and Metadata.Logo", p.OAuthProviderEntries[1])
+ }
+}
diff --git a/internal/api/handlers/management/auth_files_provider_oauth.go b/internal/api/handlers/management/auth_files_provider_oauth.go
index 52a70422745..24070794597 100644
--- a/internal/api/handlers/management/auth_files_provider_oauth.go
+++ b/internal/api/handlers/management/auth_files_provider_oauth.go
@@ -10,6 +10,7 @@ import (
"net/http"
"os"
"path/filepath"
+ "reflect"
"strings"
"time"
@@ -20,9 +21,11 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
metaauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/meta"
xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+ sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
log "github.com/sirupsen/logrus"
@@ -41,6 +44,57 @@ type antigravityOAuthService interface {
FetchProjectID(ctx context.Context, accessToken string) (string, error)
}
+type claudeOAuthService interface {
+ GenerateAuthURL(state string, pkceCodes *claude.PKCECodes) (string, string, error)
+ ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *claude.PKCECodes) (*claude.ClaudeAuthBundle, error)
+ CreateTokenStorage(bundle *claude.ClaudeAuthBundle) *claude.ClaudeTokenStorage
+}
+
+type kimiOAuthService interface {
+ StartDeviceFlow(ctx context.Context) (*kimi.DeviceCodeResponse, error)
+ WaitForAuthorization(ctx context.Context, deviceCode *kimi.DeviceCodeResponse) (*kimi.KimiAuthBundle, error)
+ CreateTokenStorage(bundle *kimi.KimiAuthBundle) *kimi.KimiTokenStorage
+}
+
+type xaiOAuthService interface {
+ StartDeviceFlow(ctx context.Context) (*xaiauth.DeviceCodeResponse, error)
+ WaitForAuthorization(ctx context.Context, dc *xaiauth.DeviceCodeResponse) (*xaiauth.AuthBundle, error)
+ CreateTokenStorage(bundle *xaiauth.AuthBundle) *xaiauth.TokenStorage
+}
+
+type metaOAuthService interface {
+ StartDeviceFlow(ctx context.Context) (*metaauth.DeviceCodeResponse, error)
+ WaitForAuthorization(ctx context.Context, dc *metaauth.DeviceCodeResponse) (*metaauth.MetaAuthBundle, error)
+ CreateTokenStorage(bundle *metaauth.MetaAuthBundle) *metaauth.MetaTokenStorage
+}
+
+var (
+ newClaudeOAuthService = func(cfg *config.Config, proxyURL string) claudeOAuthService {
+ return claude.NewClaudeAuthWithProxyURL(cfg, proxyURL)
+ }
+ newKimiOAuthService = func(cfg *config.Config, domain string) kimiOAuthService {
+ return kimi.NewKimiAuthWithDomain(cfg, domain)
+ }
+ newXAIOAuthService = func(cfg *config.Config, proxyURL string) xaiOAuthService {
+ return xaiauth.NewXAIAuthWithProxyURL(cfg, proxyURL)
+ }
+ newMetaOAuthService = func(cfg *config.Config, proxyURL string) metaOAuthService {
+ return metaauth.NewMetaAuthWithProxyURL(cfg, proxyURL)
+ }
+)
+
+func bindOAuthSessionTarget(c *gin.Context, state string, respPayload gin.H) (oauthRecoveryTarget, bool) {
+ sessionTarget, hasSessionTarget := oauthSessionTargetFromContext(c)
+ if !hasSessionTarget {
+ return oauthRecoveryTarget{}, false
+ }
+ SetOAuthSessionTarget(state, sessionTarget)
+ if respPayload != nil {
+ respPayload["target_auth_id"] = sessionTarget.AuthID
+ }
+ return sessionTarget, true
+}
+
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
ctx := context.Background()
ctx = PopulateAuthContext(ctx, c)
@@ -66,7 +120,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
sessionProxyURL := oauthSessionProxyURLFromContext(c)
cfg := h.oauthConfigWithProxy(sessionProxyURL)
// Initialize Claude auth service
- anthropicAuth := claude.NewClaudeAuthWithProxyURL(cfg, sessionProxyURL)
+ anthropicAuth := newClaudeOAuthService(cfg, sessionProxyURL)
// Generate authorization URL (then override redirect_uri to reuse server port)
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
@@ -77,6 +131,8 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
}
RegisterOAuthSessionWithProxy(state, "anthropic", sessionProxyURL)
+ respPayload := gin.H{"status": "ok", "url": authURL, "state": state}
+ bindOAuthSessionTarget(c, state, respPayload)
isWebUI := isWebUIRequest(c)
var forwarder *callbackForwarder
@@ -183,14 +239,8 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
Storage: tokenStorage,
Metadata: metadata,
}
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, "anthropic"); errGuard != nil {
- return
- }
- savedPath, errSave := h.saveTokenRecord(ctx, record)
- if errSave != nil {
- log.Errorf("Failed to save authentication tokens: %v", errSave)
- FailOAuthSessionCommit(state, "Failed to save authentication tokens")
+ savedPath, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, "anthropic", sessionProxyURL, record, "Failed to save authentication tokens", false)
+ if !okSaved {
return
}
@@ -199,10 +249,9 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Claude services through this CLI")
- CompleteOAuthSession(state)
}()
- c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
+ c.JSON(200, respPayload)
}
func (h *Handler) RequestCodexToken(c *gin.Context) {
@@ -338,16 +387,6 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
tokenStorage.PlanType = planType
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
- if target, okTarget := GetOAuthSessionTarget(state); okTarget && target.AuthID != "" {
- if !matchesOAuthRecoveryTarget(target, tokenStorage.Email, tokenStorage.AccountID, fileName) {
- SetOAuthSessionError(state, "Authenticated account does not match target credential")
- log.Warnf("Codex OAuth recovery rejected for %s: authenticated account does not match target credential", target.AuthID)
- return
- }
- if target.FileName != "" {
- fileName = target.FileName
- }
- }
record := &coreauth.Auth{
ID: fileName,
Provider: "codex",
@@ -362,63 +401,15 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
"plan_type": planType,
},
}
- target, hasTarget := GetOAuthSessionTarget(state)
- if hasTarget && target.AuthID != "" {
- record.ID = target.AuthID
- }
- var hadPrevAuth bool
- var prevVersion uint64
- var prevEpoch uint64
- if h.authManager != nil {
- if existing, okExisting := h.authManager.GetByID(record.ID); okExisting && existing != nil {
- hadPrevAuth = true
- prevVersion = existing.CredentialVersion
- prevEpoch = existing.RegistrationEpoch
- }
- }
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, "codex"); errGuard != nil {
+ savedPath, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, "codex", sessionProxyURL, record, "Failed to save authentication tokens", true)
+ if !okSaved {
return
}
- savedPath, errSave := h.saveTokenRecord(ctx, record)
- if errSave != nil && strings.TrimSpace(savedPath) == "" {
- FailOAuthSessionCommit(state, "Failed to save authentication tokens")
- log.Errorf("Failed to save authentication tokens: %v", errSave)
- return
- }
- savedGrantHash := oauthGrantFingerprint(tokenStorage.AccessToken, tokenStorage.RefreshToken, tokenStorage.Email, tokenStorage.AccountID)
- var adopted bool
- var adoptedVersion uint64
- var adoptedEpoch uint64
- var adoptErr string
- if errSave != nil {
- adoptErr = "Credential saved to storage, but runtime adoption failed"
- log.Errorf("Codex credential saved to %s, but runtime adoption failed: %v", savedPath, errSave)
- } else if h.authManager != nil {
- if runtimeAuth, okRuntime := h.authManager.GetByID(record.ID); okRuntime && runtimeAuth != nil {
- if authGrantFingerprint(runtimeAuth) == savedGrantHash {
- adopted = true
- adoptedVersion = runtimeAuth.CredentialVersion
- adoptedEpoch = runtimeAuth.RegistrationEpoch
- } else {
- adoptErr = "Credential saved to storage, but runtime did not adopt the new credential version"
- }
- } else {
- adoptErr = "Credential saved to storage, but was not registered in active runtime"
- }
- }
- var verifyErr string
- if adopted && hasTarget && target.VerifyAfterLogin && h.authManager != nil {
- if _, errVerify := h.authManager.VerifyInference(ctx, record.ID, target.VerifyModel); errVerify != nil {
- verifyErr = coreauth.SanitizeRenewalMessage(errVerify.Error())
- }
- }
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if bundle.APIKey != "" {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Codex services through this CLI")
- CompleteOAuthSessionWithOutcome(state, record.ID, savedGrantHash, strings.TrimSpace(savedPath) != "", adopted, hadPrevAuth, prevVersion, prevEpoch, adoptedVersion, adoptedEpoch, adoptErr, verifyErr)
}()
respPayload := gin.H{"status": "ok", "url": authURL, "state": state}
@@ -428,11 +419,36 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
c.JSON(200, respPayload)
}
+func isIdentitylessOAuthProvider(provider string) bool {
+ trimmed := strings.ToLower(strings.TrimSpace(provider))
+ return trimmed == "kimi" || trimmed == "kimi-ai"
+}
+
func matchesOAuthRecoveryTarget(target oauthRecoveryTarget, email, accountID, candidateFileName string) bool {
+ return matchesOAuthRecoveryTargetWithOrg(target, email, accountID, "", candidateFileName)
+}
+
+func matchesOAuthRecoveryTargetWithOrg(target oauthRecoveryTarget, email, accountID, organizationID, candidateFileName string) bool {
targetAccountID := strings.TrimSpace(target.AccountID)
targetEmail := strings.TrimSpace(target.Email)
+ targetOrgID := strings.TrimSpace(target.OrganizationID)
gotAccountID := strings.TrimSpace(accountID)
gotEmail := strings.TrimSpace(email)
+ gotOrgID := strings.TrimSpace(organizationID)
+
+ if targetOrgID != "" {
+ if gotOrgID == "" || !strings.EqualFold(gotOrgID, targetOrgID) {
+ return false
+ }
+ }
+
+ if strings.EqualFold(strings.TrimSpace(target.Provider), "xai") && (targetEmail != "" || targetAccountID != "") {
+ emailMatches := targetEmail != "" && gotEmail != "" && strings.EqualFold(targetEmail, gotEmail)
+ emailConflicts := targetEmail != "" && gotEmail != "" && !strings.EqualFold(targetEmail, gotEmail)
+ subjectMatches := targetAccountID != "" && gotAccountID != "" && targetAccountID == gotAccountID
+ subjectConflicts := targetAccountID != "" && gotAccountID != "" && targetAccountID != gotAccountID
+ return (emailMatches || subjectMatches) && !emailConflicts && !subjectConflicts
+ }
if targetAccountID != "" {
if gotAccountID == "" || !strings.EqualFold(gotAccountID, targetAccountID) {
@@ -446,7 +462,7 @@ func matchesOAuthRecoveryTarget(target oauthRecoveryTarget, email, accountID, ca
}
if targetAccountID == "" && targetEmail == "" {
if gotAccountID == "" && gotEmail == "" {
- return false
+ return isIdentitylessOAuthProvider(target.Provider)
}
if target.FileName != "" && candidateFileName != target.FileName && candidateFileName != target.AuthID {
return false
@@ -455,6 +471,154 @@ func matchesOAuthRecoveryTarget(target oauthRecoveryTarget, email, accountID, ca
return true
}
+func applyOAuthRecoveryTargetToRecord(target oauthRecoveryTarget, record *coreauth.Auth) bool {
+ if record == nil || strings.TrimSpace(target.AuthID) == "" {
+ return false
+ }
+ gotEmail, gotAccountID := authIdentityEmailAndAccountID(record)
+ gotOrgID := authIdentityOrganizationID(record)
+ candidateFileName := firstNonEmpty(strings.TrimSpace(record.FileName), strings.TrimSpace(record.ID))
+
+ if !matchesOAuthRecoveryTargetWithOrg(target, gotEmail, gotAccountID, gotOrgID, candidateFileName) {
+ return false
+ }
+
+ if strings.EqualFold(strings.TrimSpace(record.Provider), "xai") {
+ if gotEmail == "" && strings.TrimSpace(target.Email) != "" {
+ preservedEmail := strings.TrimSpace(target.Email)
+ if record.Metadata == nil {
+ record.Metadata = make(map[string]any)
+ }
+ record.Metadata["email"] = preservedEmail
+ if storage, ok := record.Storage.(*xaiauth.TokenStorage); ok && storage != nil {
+ storage.Email = preservedEmail
+ }
+ if record.Label == "" || record.Label == "xAI" {
+ record.Label = preservedEmail
+ }
+ }
+ if gotAccountID == "" && strings.TrimSpace(target.AccountID) != "" {
+ preservedSub := strings.TrimSpace(target.AccountID)
+ if record.Metadata == nil {
+ record.Metadata = make(map[string]any)
+ }
+ record.Metadata["sub"] = preservedSub
+ if storage, ok := record.Storage.(*xaiauth.TokenStorage); ok && storage != nil {
+ storage.Subject = preservedSub
+ }
+ }
+ }
+
+ record.ID = target.AuthID
+ targetFileName := strings.TrimSpace(target.FileName)
+ if targetFileName == "" {
+ targetFileName = target.AuthID
+ }
+ targetPath := strings.TrimSpace(target.Path)
+ if targetPath == "" && strings.ContainsAny(target.AuthID, "/\\") {
+ targetFileName = target.AuthID
+ }
+ record.FileName = targetFileName
+ if record.Attributes == nil {
+ record.Attributes = make(map[string]string)
+ }
+ if targetPath != "" {
+ record.Attributes[coreauth.AttributePath] = targetPath
+ record.Attributes[coreauth.AttributeSource] = targetPath
+ } else {
+ delete(record.Attributes, coreauth.AttributePath)
+ record.Attributes[coreauth.AttributeSource] = targetFileName
+ }
+ return true
+}
+
+func (h *Handler) commitAndCompleteBuiltinOAuthSession(ctx context.Context, state, sessionProvider, sessionProxyURL string, record *coreauth.Auth, failSaveMessage string, alwaysTrackOutcome bool) (string, bool) {
+ if record == nil {
+ SetOAuthSessionError(state, failSaveMessage)
+ return "", false
+ }
+ target, hasTarget := GetOAuthSessionTarget(state)
+ isTargeted := hasTarget && strings.TrimSpace(target.AuthID) != ""
+ if isTargeted {
+ if strings.TrimSpace(target.Provider) != "" && strings.TrimSpace(record.Provider) != "" &&
+ !h.oauthTargetProviderMatches(target.Provider, record.Provider) {
+ SetOAuthSessionError(state, "Authenticated account does not match target credential")
+ log.Warnf("%s OAuth recovery rejected for %s: provider %s does not match target provider %s", record.Provider, target.AuthID, record.Provider, target.Provider)
+ return "", false
+ }
+ if !applyOAuthRecoveryTargetToRecord(target, record) {
+ SetOAuthSessionError(state, "Authenticated account does not match target credential")
+ log.Warnf("%s OAuth recovery rejected for %s: authenticated account does not match target credential", record.Provider, target.AuthID)
+ return "", false
+ }
+ ctx = withTargetedOAuthSave(ctx)
+ }
+
+ var hadPrevAuth bool
+ var prevVersion uint64
+ var prevEpoch uint64
+ if h != nil && h.authManager != nil {
+ if existing, okExisting := h.authManager.GetByID(record.ID); okExisting && existing != nil {
+ hadPrevAuth = true
+ prevVersion = existing.CredentialVersion
+ prevEpoch = existing.RegistrationEpoch
+ }
+ }
+
+ applySessionProxyToAuthRecord(record, sessionProxyURL)
+ if errGuard := claimOAuthSessionCommit(state, sessionProvider); errGuard != nil {
+ return "", false
+ }
+ savedPath, errSave := h.saveTokenRecord(ctx, record)
+ if !alwaysTrackOutcome && !isTargeted {
+ if errSave != nil {
+ log.Errorf("%s: %v", failSaveMessage, errSave)
+ FailOAuthSessionCommit(state, failSaveMessage)
+ return "", false
+ }
+ CompleteOAuthSession(state)
+ return savedPath, true
+ }
+
+ if errSave != nil && strings.TrimSpace(savedPath) == "" {
+ FailOAuthSessionCommit(state, failSaveMessage)
+ log.Errorf("%s: %v", failSaveMessage, errSave)
+ return "", false
+ }
+
+ savedGrantHash := authGrantFingerprint(record)
+ var adopted bool
+ var adoptedVersion uint64
+ var adoptedEpoch uint64
+ var adoptErr string
+ if errSave != nil {
+ adoptErr = "Credential saved to storage, but runtime adoption failed"
+ log.Errorf("%s credential saved to %s, but runtime adoption failed: %v", record.Provider, savedPath, errSave)
+ } else if h != nil && h.authManager != nil {
+ if runtimeAuth, okRuntime := h.authManager.GetByID(record.ID); okRuntime && runtimeAuth != nil {
+ if authGrantFingerprint(runtimeAuth) == savedGrantHash || runtimeAuth.AdoptedGrantHash == savedGrantHash {
+ adopted = true
+ adoptedVersion = runtimeAuth.CredentialVersion
+ adoptedEpoch = runtimeAuth.RegistrationEpoch
+ } else {
+ adoptErr = "Credential saved to storage, but runtime did not adopt the new credential version"
+ }
+ } else {
+ adoptErr = "Credential saved to storage, but was not registered in active runtime"
+ }
+ }
+
+ var verifyErr string
+ if adopted && isTargeted && target.VerifyAfterLogin && h != nil && h.authManager != nil {
+ if _, errVerify := h.authManager.VerifyInference(ctx, record.ID, target.VerifyModel); errVerify != nil {
+ verifyErr = coreauth.SanitizeRenewalMessage(errVerify.Error())
+ }
+ }
+
+ CompleteOAuthSessionWithOutcome(state, record.ID, savedGrantHash, strings.TrimSpace(savedPath) != "", adopted, hadPrevAuth, prevVersion, prevEpoch, adoptedVersion, adoptedEpoch, adoptErr, verifyErr)
+ return savedPath, true
+}
+
func oauthGrantFingerprint(accessToken, refreshToken, email, accountID string) string {
return coreauth.OAuthGrantFingerprint(accessToken, refreshToken, email, accountID)
}
@@ -464,15 +628,56 @@ func authGrantFingerprint(auth *coreauth.Auth) string {
return ""
}
var accessToken, refreshToken, accountID string
- if storage, ok := auth.Storage.(*codex.CodexTokenStorage); ok && storage != nil {
- accessToken = strings.TrimSpace(storage.AccessToken)
- refreshToken = strings.TrimSpace(storage.RefreshToken)
- accountID = strings.TrimSpace(storage.AccountID)
+ email := authEmail(auth)
+ switch storage := auth.Storage.(type) {
+ case *codex.CodexTokenStorage:
+ if storage != nil {
+ accessToken = strings.TrimSpace(storage.AccessToken)
+ refreshToken = strings.TrimSpace(storage.RefreshToken)
+ accountID = strings.TrimSpace(storage.AccountID)
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ case *claude.ClaudeTokenStorage:
+ if storage != nil {
+ accessToken = strings.TrimSpace(storage.AccessToken)
+ refreshToken = strings.TrimSpace(storage.RefreshToken)
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ case *kimi.KimiTokenStorage:
+ if storage != nil {
+ accessToken = strings.TrimSpace(storage.AccessToken)
+ refreshToken = strings.TrimSpace(storage.RefreshToken)
+ }
+ case *xaiauth.TokenStorage:
+ if storage != nil {
+ accessToken = strings.TrimSpace(storage.AccessToken)
+ refreshToken = strings.TrimSpace(storage.RefreshToken)
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ case *metaauth.MetaTokenStorage:
+ if storage != nil {
+ accessToken = strings.TrimSpace(storage.AccessToken)
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
}
if auth.Metadata != nil {
if accessToken == "" {
accessToken, _ = auth.Metadata["access_token"].(string)
}
+ if accessToken == "" {
+ accessToken, _ = auth.Metadata["api_key"].(string)
+ }
+ if accessToken == "" {
+ accessToken, _ = auth.Metadata["session_token"].(string)
+ }
if refreshToken == "" {
refreshToken, _ = auth.Metadata["refresh_token"].(string)
}
@@ -480,7 +685,45 @@ func authGrantFingerprint(auth *coreauth.Auth) string {
accountID, _ = auth.Metadata["account_id"].(string)
}
}
- return oauthGrantFingerprint(accessToken, refreshToken, authEmail(auth), accountID)
+ if auth.Attributes != nil {
+ if accessToken == "" {
+ accessToken = strings.TrimSpace(auth.Attributes["api_key"])
+ }
+ if accessToken == "" {
+ accessToken = strings.TrimSpace(auth.Attributes["session_token"])
+ }
+ if accountID == "" {
+ accountID = strings.TrimSpace(auth.Attributes["account_id"])
+ }
+ }
+ if (accessToken == "" || refreshToken == "" || accountID == "" || email == "") && auth.Storage != nil {
+ if rawer, ok := auth.Storage.(interface{ RawJSON() []byte }); ok {
+ if raw := rawer.RawJSON(); len(raw) > 0 {
+ var rawMap map[string]any
+ if json.Unmarshal(raw, &rawMap) == nil {
+ if accessToken == "" {
+ accessToken, _ = rawMap["access_token"].(string)
+ }
+ if accessToken == "" {
+ accessToken, _ = rawMap["api_key"].(string)
+ }
+ if accessToken == "" {
+ accessToken, _ = rawMap["session_token"].(string)
+ }
+ if refreshToken == "" {
+ refreshToken, _ = rawMap["refresh_token"].(string)
+ }
+ if accountID == "" {
+ accountID, _ = rawMap["account_id"].(string)
+ }
+ if email == "" {
+ email, _ = rawMap["email"].(string)
+ }
+ }
+ }
+ }
+ }
+ return oauthGrantFingerprint(accessToken, refreshToken, email, accountID)
}
func (h *Handler) RequestAntigravityToken(c *gin.Context) {
@@ -504,6 +747,8 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
authURL := authSvc.BuildAuthURL(state, redirectURI)
RegisterOAuthSessionWithProxy(state, "antigravity", sessionProxyURL)
+ respPayload := gin.H{"status": "ok", "url": authURL, "state": state}
+ bindOAuthSessionTarget(c, state, respPayload)
isWebUI := isWebUIRequest(c)
var forwarder *callbackForwarder
@@ -636,18 +881,11 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
Label: label,
Metadata: metadata,
}
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, "antigravity"); errGuard != nil {
- return
- }
- savedPath, errSave := h.saveTokenRecord(ctx, record)
- if errSave != nil {
- log.Errorf("Failed to save token to file: %v", errSave)
- FailOAuthSessionCommit(state, "Failed to save token to file")
+ savedPath, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, "antigravity", sessionProxyURL, record, "Failed to save token to file", false)
+ if !okSaved {
return
}
- CompleteOAuthSession(state)
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if projectID != "" {
fmt.Printf("Using GCP project: %s\n", util.HideAPIKey(projectID))
@@ -655,7 +893,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
fmt.Println("You can now use Antigravity services through this CLI")
}()
- c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
+ c.JSON(200, respPayload)
}
func (h *Handler) RequestXAIToken(c *gin.Context) {
@@ -667,7 +905,7 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
sessionProxyURL := oauthSessionProxyURLFromContext(c)
cfg := h.oauthConfigWithProxy(sessionProxyURL)
state := fmt.Sprintf("xai-%d", time.Now().UnixNano())
- authSvc := xaiauth.NewXAIAuthWithProxyURL(cfg, sessionProxyURL)
+ authSvc := newXAIOAuthService(cfg, sessionProxyURL)
deviceFlow, errStartDeviceFlow := authSvc.StartDeviceFlow(ctx)
if errStartDeviceFlow != nil {
@@ -681,6 +919,16 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
}
RegisterOAuthSessionWithProxy(state, "xai", sessionProxyURL)
+ response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
+ if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
+ response["user_code"] = userCode
+ }
+ if deviceFlow.ExpiresIn > 0 {
+ response["expires_in"] = deviceFlow.ExpiresIn
+ } else {
+ response["expires_in"] = int(xaiauth.MaxPollDuration / time.Second)
+ }
+ bindOAuthSessionTarget(c, state, response)
go func() {
pollCtx, cancelPoll := context.WithCancel(ctx)
@@ -746,31 +994,15 @@ func (h *Handler) RequestXAIToken(c *gin.Context) {
"base_url": tokenStorage.BaseURL,
},
}
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, "xai"); errGuard != nil {
- return
- }
- savedPath, errSave := h.saveTokenRecord(ctx, record)
- if errSave != nil {
- log.Errorf("Failed to save xAI token to file: %v", errSave)
- FailOAuthSessionCommit(state, "Failed to save token to file")
+ savedPath, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, "xai", sessionProxyURL, record, "Failed to save token to file", false)
+ if !okSaved {
return
}
- CompleteOAuthSession(state)
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use xAI services through this CLI")
}()
- response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
- if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
- response["user_code"] = userCode
- }
- if deviceFlow.ExpiresIn > 0 {
- response["expires_in"] = deviceFlow.ExpiresIn
- } else {
- response["expires_in"] = int(xaiauth.MaxPollDuration / time.Second)
- }
c.JSON(200, response)
}
@@ -783,7 +1015,7 @@ func (h *Handler) RequestMetaToken(c *gin.Context) {
sessionProxyURL := oauthSessionProxyURLFromContext(c)
cfg := h.oauthConfigWithProxy(sessionProxyURL)
state := fmt.Sprintf("meta-%d", time.Now().UnixNano())
- authSvc := metaauth.NewMetaAuthWithProxyURL(cfg, sessionProxyURL)
+ authSvc := newMetaOAuthService(cfg, sessionProxyURL)
deviceFlow, errStartDeviceFlow := authSvc.StartDeviceFlow(ctx)
if errStartDeviceFlow != nil {
@@ -797,6 +1029,16 @@ func (h *Handler) RequestMetaToken(c *gin.Context) {
}
RegisterOAuthSessionWithProxy(state, "meta", sessionProxyURL)
+ response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
+ if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
+ response["user_code"] = userCode
+ }
+ if deviceFlow.ExpiresIn > 0 {
+ response["expires_in"] = deviceFlow.ExpiresIn
+ } else {
+ response["expires_in"] = int(metaauth.MaxPollDuration / time.Second)
+ }
+ bindOAuthSessionTarget(c, state, response)
go func() {
pollCtx, cancelPoll := context.WithCancel(ctx)
@@ -825,31 +1067,15 @@ func (h *Handler) RequestMetaToken(c *gin.Context) {
}
record := buildMetaAuthRecord(bundle, tokenStorage)
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, "meta"); errGuard != nil {
- return
- }
- savedPath, errSave := h.saveTokenRecord(ctx, record)
- if errSave != nil {
- log.Errorf("Failed to save Meta token to file: %v", errSave)
- FailOAuthSessionCommit(state, "Failed to save token to file")
+ savedPath, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, "meta", sessionProxyURL, record, "Failed to save token to file", false)
+ if !okSaved {
return
}
- CompleteOAuthSession(state)
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use Meta services through this CLI")
}()
- response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
- if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
- response["user_code"] = userCode
- }
- if deviceFlow.ExpiresIn > 0 {
- response["expires_in"] = deviceFlow.ExpiresIn
- } else {
- response["expires_in"] = int(metaauth.MaxPollDuration / time.Second)
- }
c.JSON(200, response)
}
@@ -960,7 +1186,7 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) {
cfg := h.oauthConfigWithProxy(sessionProxyURL)
state := fmt.Sprintf("%s-%d", statePrefix, time.Now().UnixNano())
// Initialize Kimi auth service
- kimiAuth := kimi.NewKimiAuthWithDomain(cfg, domain)
+ kimiAuth := newKimiOAuthService(cfg, domain)
// Generate authorization URL
deviceFlow, errStartDeviceFlow := kimiAuth.StartDeviceFlow(ctx)
@@ -975,6 +1201,14 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) {
}
RegisterOAuthSessionWithProxy(state, providerName, sessionProxyURL)
+ response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
+ if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
+ response["user_code"] = userCode
+ }
+ if deviceFlow.ExpiresIn > 0 {
+ response["expires_in"] = deviceFlow.ExpiresIn
+ }
+ bindOAuthSessionTarget(c, state, response)
go func() {
pollCtx, cancelPoll := context.WithCancel(ctx)
@@ -1032,29 +1266,15 @@ func (h *Handler) requestKimiTokenWithDomain(c *gin.Context, domain string) {
"domain": domain,
},
}
- applySessionProxyToAuthRecord(record, sessionProxyURL)
- if errGuard := claimOAuthSessionCommit(state, providerName); errGuard != nil {
- return
- }
- savedPath, errSave := h.saveTokenRecord(ctx, record)
- if errSave != nil {
- log.Errorf("Failed to save authentication tokens: %v", errSave)
- FailOAuthSessionCommit(state, "Failed to save authentication tokens")
+ savedPath, okSaved := h.commitAndCompleteBuiltinOAuthSession(ctx, state, providerName, sessionProxyURL, record, "Failed to save authentication tokens", false)
+ if !okSaved {
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Printf("You can now use %s services through this CLI\n", displayName)
- CompleteOAuthSession(state)
}()
- response := gin.H{"status": "ok", "url": authURL, "state": state, "flow": "device"}
- if userCode := strings.TrimSpace(deviceFlow.UserCode); userCode != "" {
- response["user_code"] = userCode
- }
- if deviceFlow.ExpiresIn > 0 {
- response["expires_in"] = deviceFlow.ExpiresIn
- }
c.JSON(200, response)
}
@@ -1112,60 +1332,7 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
return
}
if session.Completed {
- resp := gin.H{"status": "ok"}
- if session.AuthID != "" {
- resp["auth_id"] = session.AuthID
- resp["saved"] = session.Saved
- var adoptedAuth *coreauth.Auth
- var adoptErr string
- if h != nil && h.authManager != nil {
- if candidate, okAuth := h.authManager.GetByID(session.AuthID); okAuth && candidate != nil {
- if session.SavedGrantHash != "" {
- if authGrantFingerprint(candidate) == session.SavedGrantHash ||
- candidate.AdoptedGrantHash == session.SavedGrantHash {
- adoptedAuth = candidate
- if !session.Adopted {
- oauthSessions.MarkAdopted(state, candidate.CredentialVersion, candidate.RegistrationEpoch)
- session.Adopted = true
- session.AdoptedVersion = candidate.CredentialVersion
- session.AdoptedEpoch = candidate.RegistrationEpoch
- session.AdoptError = ""
- }
- } else if session.Adopted {
- adoptErr = "Credential was replaced in active runtime after login"
- }
- } else if session.Adopted {
- if (session.AdoptedVersion == 0 && session.AdoptedEpoch == 0) ||
- (candidate.RegistrationEpoch == session.AdoptedEpoch && candidate.CredentialVersion == session.AdoptedVersion) {
- adoptedAuth = candidate
- }
- }
- }
- }
- if adoptErr == "" {
- adoptErr = session.AdoptError
- }
- if adoptedAuth != nil {
- nowUTC := time.Now().UTC()
- resp["adopted"] = true
- resp["recovery_stage"] = adoptedAuth.RecoveryStageAt(nowUTC)
- resp["renewal_state"] = adoptedAuth.RenewalStateAt(nowUTC)
- resp["requires_sign_in"] = adoptedAuth.RequiresSignIn
- resp["inference_verified"] = adoptedAuth.InferenceVerified
- resp["renewal_verified"] = adoptedAuth.RenewalVerified
- resp["quota_blocked"] = adoptedAuth.RecoveryStageAt(nowUTC) == coreauth.RecoveryStageQuotaBlocked
- } else if session.Saved {
- resp["adopted"] = false
- resp["recovery_stage"] = coreauth.RecoveryStageCredentialSaved
- if adoptErr != "" {
- resp["adoption_error"] = adoptErr
- }
- }
- if session.VerifyError != "" {
- resp["verification_error"] = session.VerifyError
- }
- }
- c.JSON(http.StatusOK, resp)
+ h.writeCompletedOAuthStatus(c, state, session)
return
}
if session.Status != "" {
@@ -1208,12 +1375,32 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
writeNonCommitPluginPollStatus(c, state, false)
return
}
- records := pluginLoginPollAuths(host, resp)
+ records := pluginLoginPollAuthsWithProvider(host, provider, resp)
if len(records) == 0 {
FailOAuthSessionCommit(state, "Authentication failed")
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Authentication failed"})
return
}
+ target, hasTarget := GetOAuthSessionTarget(state)
+ isTargeted := hasTarget && strings.TrimSpace(target.AuthID) != ""
+ if isTargeted {
+ if !applyPluginOAuthRecoveryTarget(target, provider, records) {
+ FailOAuthSessionCommit(state, "Authenticated account does not match target credential")
+ c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Authenticated account does not match target credential"})
+ return
+ }
+ ctx = withTargetedOAuthSave(ctx)
+ }
+ var hadPrevAuth bool
+ var prevVersion uint64
+ var prevEpoch uint64
+ if isTargeted && len(records) == 1 && records[0] != nil && h != nil && h.authManager != nil {
+ if existing, okExisting := h.authManager.GetByID(records[0].ID); okExisting && existing != nil {
+ hadPrevAuth = true
+ prevVersion = existing.CredentialVersion
+ prevEpoch = existing.RegistrationEpoch
+ }
+ }
for _, record := range records {
applySessionProxyToAuthRecord(record, sessionProxyURL)
}
@@ -1223,6 +1410,38 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "error", "error": "Failed to save authentication tokens"})
return
}
+ if isTargeted && len(records) == 1 && records[0] != nil {
+ record := records[0]
+ savedGrantHash := authGrantFingerprint(record)
+ var adopted bool
+ var adoptedVersion uint64
+ var adoptedEpoch uint64
+ var adoptErr string
+ if h != nil && h.authManager != nil {
+ if runtimeAuth, okRuntime := h.authManager.GetByID(record.ID); okRuntime && runtimeAuth != nil {
+ if authGrantFingerprint(runtimeAuth) == savedGrantHash || runtimeAuth.AdoptedGrantHash == savedGrantHash {
+ adopted = true
+ adoptedVersion = runtimeAuth.CredentialVersion
+ adoptedEpoch = runtimeAuth.RegistrationEpoch
+ } else {
+ adoptErr = "Credential saved to storage, but runtime did not adopt the new credential version"
+ }
+ } else {
+ adoptErr = "Credential saved to storage, but was not registered in active runtime"
+ }
+ }
+ var verifyErr string
+ if adopted && target.VerifyAfterLogin && h != nil && h.authManager != nil {
+ if _, errVerify := h.authManager.VerifyInference(ctx, record.ID, target.VerifyModel); errVerify != nil {
+ verifyErr = coreauth.SanitizeRenewalMessage(errVerify.Error())
+ }
+ }
+ CompleteOAuthSessionWithOutcome(state, record.ID, savedGrantHash, true, adopted, hadPrevAuth, prevVersion, prevEpoch, adoptedVersion, adoptedEpoch, adoptErr, verifyErr)
+ if completedSession, okSession := oauthSessions.Get(state); okSession {
+ h.writeCompletedOAuthStatus(c, state, completedSession)
+ return
+ }
+ }
CompleteOAuthSession(state)
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
@@ -1235,6 +1454,63 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "wait"})
}
+func (h *Handler) writeCompletedOAuthStatus(c *gin.Context, state string, session oauthSession) {
+ resp := gin.H{"status": "ok"}
+ if session.AuthID != "" {
+ resp["auth_id"] = session.AuthID
+ resp["saved"] = session.Saved
+ var adoptedAuth *coreauth.Auth
+ var adoptErr string
+ if h != nil && h.authManager != nil {
+ if candidate, okAuth := h.authManager.GetByID(session.AuthID); okAuth && candidate != nil {
+ if session.SavedGrantHash != "" {
+ if authGrantFingerprint(candidate) == session.SavedGrantHash ||
+ candidate.AdoptedGrantHash == session.SavedGrantHash {
+ adoptedAuth = candidate
+ if !session.Adopted {
+ oauthSessions.MarkAdopted(state, candidate.CredentialVersion, candidate.RegistrationEpoch)
+ session.Adopted = true
+ session.AdoptedVersion = candidate.CredentialVersion
+ session.AdoptedEpoch = candidate.RegistrationEpoch
+ session.AdoptError = ""
+ }
+ } else if session.Adopted {
+ adoptErr = "Credential was replaced in active runtime after login"
+ }
+ } else if session.Adopted {
+ if (session.AdoptedVersion == 0 && session.AdoptedEpoch == 0) ||
+ (candidate.RegistrationEpoch == session.AdoptedEpoch && candidate.CredentialVersion == session.AdoptedVersion) {
+ adoptedAuth = candidate
+ }
+ }
+ }
+ }
+ if adoptErr == "" {
+ adoptErr = session.AdoptError
+ }
+ if adoptedAuth != nil {
+ nowUTC := time.Now().UTC()
+ resp["adopted"] = true
+ resp["recovery_stage"] = adoptedAuth.RecoveryStageAt(nowUTC)
+ resp["renewal_state"] = adoptedAuth.RenewalStateAt(nowUTC)
+ resp["requires_sign_in"] = adoptedAuth.RequiresSignIn
+ resp["inference_verified"] = adoptedAuth.InferenceVerified
+ resp["renewal_verified"] = adoptedAuth.RenewalVerified
+ resp["quota_blocked"] = adoptedAuth.RecoveryStageAt(nowUTC) == coreauth.RecoveryStageQuotaBlocked
+ } else if session.Saved {
+ resp["adopted"] = false
+ resp["recovery_stage"] = coreauth.RecoveryStageCredentialSaved
+ if adoptErr != "" {
+ resp["adoption_error"] = adoptErr
+ }
+ }
+ if session.VerifyError != "" {
+ resp["verification_error"] = session.VerifyError
+ }
+ }
+ c.JSON(http.StatusOK, resp)
+}
+
func writePluginPollFailureStatus(c *gin.Context, state, message string) {
errStatus, completed, committing, ok := oauthSessions.FailPendingPoll(state, message)
if !ok {
@@ -1274,6 +1550,10 @@ func writeNonCommitPluginPollStatus(c *gin.Context, state string, allowPendingWa
}
func pluginLoginPollAuths(host *pluginhost.Host, resp pluginapi.AuthLoginPollResponse) []*coreauth.Auth {
+ return pluginLoginPollAuthsWithProvider(host, "", resp)
+}
+
+func pluginLoginPollAuthsWithProvider(host *pluginhost.Host, provider string, resp pluginapi.AuthLoginPollResponse) []*coreauth.Auth {
if host == nil {
return nil
}
@@ -1282,7 +1562,42 @@ func pluginLoginPollAuths(host *pluginhost.Host, resp pluginapi.AuthLoginPollRes
authDatas = []pluginapi.AuthData{resp.Auth}
}
records := make([]*coreauth.Auth, 0, len(authDatas))
+ trimmedSessionProvider := strings.ToLower(strings.TrimSpace(provider))
for _, authData := range authDatas {
+ if strings.TrimSpace(authData.Provider) == "" && trimmedSessionProvider != "" {
+ authData.Provider = trimmedSessionProvider
+ }
+ effectivePortal := trimmedSessionProvider
+ for _, key := range []string{"oauth_provider", "portal"} {
+ if authData.Attributes != nil {
+ if v := strings.ToLower(strings.TrimSpace(authData.Attributes[key])); v != "" {
+ effectivePortal = v
+ break
+ }
+ }
+ if authData.Metadata != nil {
+ if raw, ok := authData.Metadata[key].(string); ok {
+ if v := strings.ToLower(strings.TrimSpace(raw)); v != "" {
+ effectivePortal = v
+ break
+ }
+ }
+ }
+ }
+ if effectivePortal != "" && !strings.EqualFold(strings.TrimSpace(authData.Provider), effectivePortal) {
+ if authData.Metadata == nil {
+ authData.Metadata = make(map[string]any)
+ }
+ if _, exists := authData.Metadata["oauth_provider"]; !exists {
+ authData.Metadata["oauth_provider"] = effectivePortal
+ }
+ if authData.Attributes == nil {
+ authData.Attributes = make(map[string]string)
+ }
+ if strings.TrimSpace(authData.Attributes["oauth_provider"]) == "" {
+ authData.Attributes["oauth_provider"] = effectivePortal
+ }
+ }
record := host.AuthDataToCoreAuth(authData, "", "")
if record == nil {
return nil
@@ -1292,31 +1607,480 @@ func pluginLoginPollAuths(host *pluginhost.Host, resp pluginapi.AuthLoginPollRes
return records
}
+var authIdentityAccountIDKeys = []string{"account_id", "account_uuid", "sub", "user_id", "user_name"}
+
+func authIdentityEmailAndAccountID(auth *coreauth.Auth) (email string, accountID string) {
+ if auth == nil {
+ return "", ""
+ }
+ if auth.Metadata != nil {
+ for _, key := range authIdentityAccountIDKeys {
+ if raw, ok := auth.Metadata[key].(string); ok {
+ if trimmed := strings.TrimSpace(raw); trimmed != "" {
+ accountID = trimmed
+ break
+ }
+ }
+ }
+ }
+ if accountID == "" && auth.Attributes != nil {
+ for _, key := range authIdentityAccountIDKeys {
+ if trimmed := strings.TrimSpace(auth.Attributes[key]); trimmed != "" {
+ accountID = trimmed
+ break
+ }
+ }
+ }
+ email = strings.TrimSpace(authEmail(auth))
+ if auth.Storage != nil {
+ switch storage := auth.Storage.(type) {
+ case *codex.CodexTokenStorage:
+ if storage != nil {
+ if accountID == "" {
+ accountID = strings.TrimSpace(storage.AccountID)
+ }
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ case *claude.ClaudeTokenStorage:
+ if storage != nil {
+ if accountID == "" {
+ accountID = strings.TrimSpace(storage.AccountUUID)
+ }
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ case *xaiauth.TokenStorage:
+ if storage != nil {
+ if accountID == "" {
+ accountID = strings.TrimSpace(storage.Subject)
+ }
+ if email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ case *metaauth.MetaTokenStorage:
+ if storage != nil && email == "" {
+ email = strings.TrimSpace(storage.Email)
+ }
+ }
+ }
+ if (accountID == "" || email == "") && auth.Storage != nil {
+ if rawer, ok := auth.Storage.(interface{ RawJSON() []byte }); ok {
+ if raw := rawer.RawJSON(); len(raw) > 0 {
+ var rawMap map[string]any
+ if json.Unmarshal(raw, &rawMap) == nil {
+ if accountID == "" {
+ for _, key := range authIdentityAccountIDKeys {
+ if rawAcc, okAcc := rawMap[key].(string); okAcc {
+ if trimmed := strings.TrimSpace(rawAcc); trimmed != "" {
+ accountID = trimmed
+ break
+ }
+ }
+ }
+ }
+ if email == "" {
+ rawEmail, _ := rawMap["email"].(string)
+ email = strings.TrimSpace(rawEmail)
+ }
+ }
+ }
+ }
+ }
+ return email, accountID
+}
+
+func authIdentityOrganizationID(auth *coreauth.Auth) string {
+ if auth == nil {
+ return ""
+ }
+ if auth.Metadata != nil {
+ if raw, ok := auth.Metadata["organization_uuid"].(string); ok {
+ if trimmed := strings.TrimSpace(raw); trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+ if auth.Attributes != nil {
+ if trimmed := strings.TrimSpace(auth.Attributes["organization_uuid"]); trimmed != "" {
+ return trimmed
+ }
+ }
+ if storage, ok := auth.Storage.(*claude.ClaudeTokenStorage); ok && storage != nil {
+ if trimmed := strings.TrimSpace(storage.OrganizationUUID); trimmed != "" {
+ return trimmed
+ }
+ }
+ if auth.Storage != nil {
+ if rawer, ok := auth.Storage.(interface{ RawJSON() []byte }); ok {
+ if raw := rawer.RawJSON(); len(raw) > 0 {
+ var rawMap map[string]any
+ if json.Unmarshal(raw, &rawMap) == nil {
+ if rawOrg, okOrg := rawMap["organization_uuid"].(string); okOrg {
+ if trimmed := strings.TrimSpace(rawOrg); trimmed != "" {
+ return trimmed
+ }
+ }
+ }
+ }
+ }
+ }
+ return ""
+}
+
+func applyPluginOAuthRecoveryTarget(target oauthRecoveryTarget, provider string, records []*coreauth.Auth) bool {
+ if len(records) != 1 || records[0] == nil {
+ return false
+ }
+ record := records[0]
+ if !applyOAuthRecoveryTargetToRecord(target, record) {
+ return false
+ }
+ if trimmedProvider := strings.ToLower(strings.TrimSpace(provider)); trimmedProvider != "" {
+ if record.Metadata == nil {
+ record.Metadata = make(map[string]any)
+ }
+ record.Metadata["oauth_provider"] = trimmedProvider
+ record.Attributes["oauth_provider"] = trimmedProvider
+ if setter, ok := record.Storage.(interface{ SetMetadata(map[string]any) }); ok {
+ setter.SetMetadata(record.Metadata)
+ }
+ }
+ return true
+}
+
+type savedPluginRecordRollback struct {
+ savedPath string
+ diskPath string
+ existedOnDisk bool
+ prevDiskBytes []byte
+ savedDiskBytes []byte
+ hadRuntimeAuth bool
+ hadRecordIDAuth bool
+ hadFileDerivedAuth bool
+ prevAuth *coreauth.Auth
+ record *coreauth.Auth
+}
+
+func (h *Handler) candidatePluginRecordPath(record *coreauth.Auth) string {
+ if h == nil || record == nil {
+ return ""
+ }
+ if record.Attributes != nil {
+ if p := strings.TrimSpace(record.Attributes[coreauth.AttributePath]); p != "" {
+ return p
+ }
+ }
+ name := firstNonEmpty(strings.TrimSpace(record.FileName), strings.TrimSpace(record.ID))
+ if name == "" {
+ return ""
+ }
+ if filepath.IsAbs(name) {
+ return name
+ }
+ if cfg := h.configSnapshot(); cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" {
+ return filepath.Join(cfg.AuthDir, name)
+ }
+ return ""
+}
+
func (h *Handler) savePluginLoginRecords(ctx context.Context, records []*coreauth.Auth) error {
- savedPaths := make([]string, 0, len(records))
+ if h != nil {
+ h.pluginLoginSaveMu.Lock()
+ defer h.pluginLoginSaveMu.Unlock()
+ }
+ rollbacks := make([]savedPluginRecordRollback, 0, len(records))
for _, record := range records {
+ var rb savedPluginRecordRollback
+ candidatePath := h.candidatePluginRecordPath(record)
+ if candidatePath != "" {
+ prevBytes, errRead := os.ReadFile(candidatePath)
+ if errRead == nil {
+ rb.existedOnDisk = true
+ rb.prevDiskBytes = prevBytes
+ } else if !os.IsNotExist(errRead) {
+ h.rollbackSavedPluginLoginRecords(ctx, rollbacks)
+ return fmt.Errorf("read existing auth file %s: %w", candidatePath, errRead)
+ }
+ }
+ if h != nil && h.authManager != nil && record != nil {
+ if recID := strings.TrimSpace(record.ID); recID != "" {
+ if _, ok := h.authManager.GetByID(recID); ok {
+ rb.hadRecordIDAuth = true
+ }
+ }
+ if fileID := h.authIDForPath(candidatePath); fileID != "" {
+ if _, ok := h.authManager.GetByID(fileID); ok {
+ rb.hadFileDerivedAuth = true
+ }
+ }
+ for _, candidateID := range []string{strings.TrimSpace(record.ID), h.authIDForPath(candidatePath)} {
+ if candidateID == "" {
+ continue
+ }
+ if existing, ok := h.authManager.GetByID(candidateID); ok && existing != nil {
+ rb.hadRuntimeAuth = true
+ rb.prevAuth = existing.Clone()
+ break
+ }
+ }
+ }
savedPath, errSave := h.saveTokenRecord(ctx, record)
- if strings.TrimSpace(savedPath) != "" {
- savedPaths = append(savedPaths, savedPath)
+ rb.savedPath = strings.TrimSpace(savedPath)
+ rb.diskPath = firstNonEmpty(rb.savedPath, candidatePath)
+ rb.record = record
+ if record != nil && record.Storage != nil {
+ if rawer, ok := record.Storage.(interface{ RawJSON() []byte }); ok {
+ rb.savedDiskBytes = rawer.RawJSON()
+ }
+ }
+ if rb.savedPath != "" || rb.existedOnDisk || rb.hadRuntimeAuth {
+ rollbacks = append(rollbacks, rb)
}
if errSave != nil {
- h.rollbackSavedTokenRecords(ctx, savedPaths)
+ h.rollbackSavedPluginLoginRecords(ctx, rollbacks)
return errSave
}
}
return nil
}
-func (h *Handler) rollbackSavedTokenRecords(ctx context.Context, savedPaths []string) {
- for i := len(savedPaths) - 1; i >= 0; i-- {
- path := strings.TrimSpace(savedPaths[i])
- if path == "" {
+func jsonPayloadsEqualOrBytesEqual(left, right []byte) bool {
+ if strings.TrimSpace(string(left)) == strings.TrimSpace(string(right)) {
+ return true
+ }
+ var leftVal, rightVal any
+ if json.Unmarshal(left, &leftVal) != nil || json.Unmarshal(right, &rightVal) != nil {
+ return false
+ }
+ return reflect.DeepEqual(leftVal, rightVal)
+}
+
+var errPluginRollbackSuperseded = errors.New("plugin login rollback superseded")
+
+func (h *Handler) restoreOverwrittenPluginRecord(ctx context.Context, rb savedPluginRecordRollback, diskPath string) {
+ if diskPath != "" && len(rb.prevDiskBytes) > 0 {
+ if errWrite := os.WriteFile(diskPath, rb.prevDiskBytes, 0o600); errWrite != nil {
+ log.WithError(errWrite).WithField("path", diskPath).Warn("failed to restore overwritten plugin auth token")
+ }
+ }
+ if h == nil || (rb.prevAuth == nil && len(rb.prevDiskBytes) == 0) {
+ return
+ }
+ store := h.tokenStoreWithBaseDir()
+ if store == nil {
+ return
+ }
+ if _, isFileStore := store.(*sdkAuth.FileTokenStore); isFileStore && diskPath != "" && len(rb.prevDiskBytes) > 0 {
+ return
+ }
+ var diskMeta map[string]any
+ if len(rb.prevDiskBytes) > 0 {
+ _ = json.Unmarshal(rb.prevDiskBytes, &diskMeta)
+ }
+ var restoreRecord *coreauth.Auth
+ if rb.prevAuth != nil {
+ restoreRecord = rb.prevAuth.Clone()
+ if len(diskMeta) > 0 {
+ restoreRecord.Metadata = diskMeta
+ }
+ } else {
+ provider := ""
+ if diskMeta != nil {
+ provider, _ = diskMeta["type"].(string)
+ }
+ if provider == "" && rb.record != nil {
+ provider = rb.record.Provider
+ }
+ fileName := filepath.Base(diskPath)
+ id := h.authIDForPath(diskPath)
+ if rb.record != nil {
+ if fileName == "" || fileName == "." {
+ fileName = rb.record.FileName
+ }
+ if id == "" {
+ id = rb.record.ID
+ }
+ }
+ restoreRecord = &coreauth.Auth{
+ ID: firstNonEmpty(id, fileName),
+ FileName: fileName,
+ Provider: provider,
+ Metadata: diskMeta,
+ Attributes: map[string]string{
+ coreauth.AttributePath: diskPath,
+ },
+ }
+ }
+ if len(diskMeta) > 0 {
+ if disabled, ok := diskMeta["disabled"].(bool); ok {
+ restoreRecord.Disabled = disabled
+ if disabled {
+ restoreRecord.Status = coreauth.StatusDisabled
+ } else if restoreRecord.Status == coreauth.StatusDisabled {
+ restoreRecord.Status = coreauth.StatusActive
+ }
+ }
+ }
+ if len(rb.prevDiskBytes) > 0 && h.pluginHost != nil {
+ if restored := h.pluginHost.AuthDataToCoreAuth(pluginapi.AuthData{
+ Provider: restoreRecord.Provider,
+ ID: restoreRecord.ID,
+ FileName: restoreRecord.FileName,
+ Label: restoreRecord.Label,
+ Prefix: restoreRecord.Prefix,
+ ProxyURL: restoreRecord.ProxyURL,
+ Disabled: restoreRecord.Disabled,
+ StorageJSON: rb.prevDiskBytes,
+ Metadata: restoreRecord.Metadata,
+ Attributes: restoreRecord.Attributes,
+ }, diskPath, restoreRecord.FileName); restored != nil {
+ restoreRecord.Storage = restored.Storage
+ }
+ }
+ if _, errRestore := store.Save(ctx, restoreRecord); errRestore != nil {
+ log.WithError(errRestore).WithField("path", diskPath).Warn("failed to restore overwritten plugin auth token in store")
+ }
+}
+
+func (h *Handler) rollbackSavedPluginLoginRecords(ctx context.Context, rollbacks []savedPluginRecordRollback) {
+ for i := len(rollbacks) - 1; i >= 0; i-- {
+ rb := rollbacks[i]
+ diskPath := strings.TrimSpace(rb.diskPath)
+ savedPath := strings.TrimSpace(rb.savedPath)
+ if rb.hadRuntimeAuth && rb.prevAuth != nil && h != nil && h.authManager != nil {
+ _, _, errModify := h.authManager.Modify(coreauth.WithSkipPersist(ctx), rb.prevAuth.ID, 0, func(current *coreauth.Auth) error {
+ currFP := authGrantFingerprint(current)
+ if currFP != "" && currFP != authGrantFingerprint(rb.record) && currFP != authGrantFingerprint(rb.prevAuth) {
+ return errPluginRollbackSuperseded
+ }
+ if rb.existedOnDisk && diskPath != "" {
+ currentBytes, errRead := os.ReadFile(diskPath)
+ if errRead != nil {
+ return errPluginRollbackSuperseded
+ }
+ matchesSaved := len(rb.savedDiskBytes) == 0 || jsonPayloadsEqualOrBytesEqual(currentBytes, rb.savedDiskBytes)
+ if !matchesSaved && !jsonPayloadsEqualOrBytesEqual(currentBytes, rb.prevDiskBytes) {
+ return errPluginRollbackSuperseded
+ }
+ if matchesSaved {
+ h.restoreOverwrittenPluginRecord(ctx, rb, diskPath)
+ }
+ } else if savedPath != "" {
+ if diskPath != "" {
+ if currentBytes, errRead := os.ReadFile(diskPath); errRead == nil && len(rb.savedDiskBytes) > 0 && !jsonPayloadsEqualOrBytesEqual(currentBytes, rb.savedDiskBytes) {
+ return errPluginRollbackSuperseded
+ }
+ }
+ h.restoreOverwrittenPluginRecord(ctx, rb, diskPath)
+ }
+ *current = *rb.prevAuth.Clone()
+ return nil
+ })
+ if !errors.Is(errModify, errPluginRollbackSuperseded) {
+ removeNewID := func(extraID string) {
+ if extraID == "" || extraID == rb.prevAuth.ID {
+ return
+ }
+ _ = h.authManager.RemoveIf(ctx, extraID, func(current *coreauth.Auth) bool {
+ if current == nil {
+ return false
+ }
+ currFP := authGrantFingerprint(current)
+ return currFP == "" || currFP == authGrantFingerprint(rb.record)
+ })
+ }
+ if !rb.hadFileDerivedAuth {
+ removeNewID(h.authIDForPath(savedPath))
+ }
+ if !rb.hadRecordIDAuth && rb.record != nil {
+ removeNewID(strings.TrimSpace(rb.record.ID))
+ }
+ }
continue
}
- if errDelete := h.deleteTokenRecord(ctx, path); errDelete != nil {
- log.WithError(errDelete).WithField("path", path).Warn("failed to roll back plugin auth token")
+
+ if !rb.hadRuntimeAuth && h != nil && h.authManager != nil && rb.record != nil {
+ primaryID := firstNonEmpty(h.authIDForPath(savedPath), strings.TrimSpace(rb.record.ID))
+ if primaryID != "" {
+ superseded := false
+ diskHandled := false
+ removeMatching := func(current *coreauth.Auth) bool {
+ if current != nil {
+ currFP := authGrantFingerprint(current)
+ if currFP != "" && currFP != authGrantFingerprint(rb.record) {
+ superseded = true
+ return false
+ }
+ }
+ if !diskHandled {
+ if rb.existedOnDisk && diskPath != "" {
+ if currentBytes, errRead := os.ReadFile(diskPath); errRead == nil {
+ matchesSaved := len(rb.savedDiskBytes) == 0 || jsonPayloadsEqualOrBytesEqual(currentBytes, rb.savedDiskBytes)
+ if !matchesSaved && !jsonPayloadsEqualOrBytesEqual(currentBytes, rb.prevDiskBytes) {
+ superseded = true
+ return false
+ }
+ if matchesSaved {
+ h.restoreOverwrittenPluginRecord(ctx, rb, diskPath)
+ }
+ }
+ } else if savedPath != "" {
+ if diskPath != "" {
+ if currentBytes, errRead := os.ReadFile(diskPath); errRead == nil && len(rb.savedDiskBytes) > 0 && !jsonPayloadsEqualOrBytesEqual(currentBytes, rb.savedDiskBytes) {
+ superseded = true
+ return false
+ }
+ }
+ if errDelete := h.deleteTokenRecord(ctx, savedPath); errDelete != nil {
+ log.WithError(errDelete).WithField("path", savedPath).Warn("failed to roll back plugin auth token")
+ }
+ }
+ diskHandled = true
+ }
+ return current != nil
+ }
+ _ = h.authManager.RemoveIf(ctx, primaryID, removeMatching)
+ if !superseded {
+ if altID := strings.TrimSpace(rb.record.ID); altID != "" && altID != primaryID {
+ _ = h.authManager.RemoveIf(ctx, altID, removeMatching)
+ }
+ }
+ continue
+ }
+ }
+
+ diskSuperseded := false
+ if rb.existedOnDisk && diskPath != "" {
+ currentBytes, errRead := os.ReadFile(diskPath)
+ if errRead == nil {
+ matchesSaved := len(rb.savedDiskBytes) == 0 || jsonPayloadsEqualOrBytesEqual(currentBytes, rb.savedDiskBytes)
+ if !matchesSaved {
+ if !jsonPayloadsEqualOrBytesEqual(currentBytes, rb.prevDiskBytes) {
+ diskSuperseded = true
+ }
+ } else {
+ h.restoreOverwrittenPluginRecord(ctx, rb, diskPath)
+ }
+ }
+ } else if savedPath != "" {
+ if diskPath != "" {
+ if currentBytes, errRead := os.ReadFile(diskPath); errRead == nil && len(rb.savedDiskBytes) > 0 && !jsonPayloadsEqualOrBytesEqual(currentBytes, rb.savedDiskBytes) {
+ diskSuperseded = true
+ }
+ }
+ if !diskSuperseded {
+ if errDelete := h.deleteTokenRecord(ctx, savedPath); errDelete != nil {
+ log.WithError(errDelete).WithField("path", savedPath).Warn("failed to roll back plugin auth token")
+ }
+ }
+ }
+ if !diskSuperseded && !rb.hadRuntimeAuth && savedPath != "" {
+ h.removeAuthsForPath(ctx, savedPath, savedPath)
}
- h.removeAuthsForPath(ctx, path, path)
}
}
diff --git a/internal/api/handlers/management/auth_files_refresh.go b/internal/api/handlers/management/auth_files_refresh.go
index c144379c467..7c17dfdd656 100644
--- a/internal/api/handlers/management/auth_files_refresh.go
+++ b/internal/api/handlers/management/auth_files_refresh.go
@@ -70,6 +70,21 @@ func (h *Handler) RefreshAuthFiles(c *gin.Context) {
return
}
+ if c.GetBool(ConfigV8ContextKey) {
+ h.mu.Lock()
+ host := h.pluginHost
+ h.mu.Unlock()
+ quota := authFileQuotaContext{v8: true}
+ if host != nil {
+ quota.pluginIDs = host.QuotaProviderPluginIDs(ctx)
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "ok": true,
+ "auth": h.buildAuthFileEntry(refreshed, quota),
+ })
+ return
+ }
+
c.JSON(http.StatusOK, gin.H{
"ok": true,
"auth": refreshed,
diff --git a/internal/api/handlers/management/auth_files_relogin_preserve_test.go b/internal/api/handlers/management/auth_files_relogin_preserve_test.go
index bc34d77a5ce..4f670dccb13 100644
--- a/internal/api/handlers/management/auth_files_relogin_preserve_test.go
+++ b/internal/api/handlers/management/auth_files_relogin_preserve_test.go
@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/auth/codex"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -254,6 +255,213 @@ func TestSaveTokenRecord_MigratesMatchingLegacyClaudeCredential(t *testing.T) {
}
}
+func TestSaveTokenRecord_MigratesMatchingLegacyXAICredential(t *testing.T) {
+ authDir := t.TempDir()
+ legacyFileName := "xai-a-b@example.com.json"
+ targetFileName := xai.CredentialFileName("a+b@example.com", "sub-first")
+ legacyPath := filepath.Join(authDir, legacyFileName)
+ targetPath := filepath.Join(authDir, targetFileName)
+
+ existing := map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "old-token",
+ "refresh_token": "old-refresh",
+ "prefix": "team",
+ "proxy_url": "http://127.0.0.1:8080",
+ "disabled": true,
+ "weight": float64(5),
+ }
+ raw, errMarshal := json.Marshal(existing)
+ if errMarshal != nil {
+ t.Fatalf("marshal legacy credential: %v", errMarshal)
+ }
+ if errWrite := os.WriteFile(legacyPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write legacy credential: %v", errWrite)
+ }
+
+ tokenStorage := &xai.TokenStorage{
+ AccessToken: "new-token",
+ RefreshToken: "new-refresh",
+ Email: "a+b@example.com",
+ Subject: "sub-first",
+ Expire: "2026-12-31T23:59:59Z",
+ }
+ record := &coreauth.Auth{
+ ID: targetFileName,
+ Provider: "xai",
+ FileName: targetFileName,
+ Storage: tokenStorage,
+ Metadata: map[string]any{
+ "email": tokenStorage.Email,
+ "sub": tokenStorage.Subject,
+ },
+ }
+
+ h := NewHandler(&config.Config{AuthDir: authDir}, "", nil)
+ savedPath, errSave := h.saveTokenRecord(context.Background(), record)
+ if errSave != nil {
+ t.Fatalf("saveTokenRecord error: %v", errSave)
+ }
+ if savedPath != targetPath {
+ t.Fatalf("savedPath = %s, want %s", savedPath, targetPath)
+ }
+ if _, errStat := os.Stat(legacyPath); !os.IsNotExist(errStat) {
+ t.Fatalf("legacy credential still exists or stat failed: %v", errStat)
+ }
+
+ savedRaw, errRead := os.ReadFile(targetPath)
+ if errRead != nil {
+ t.Fatalf("read migrated credential: %v", errRead)
+ }
+ var saved map[string]any
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal migrated credential: %v", errUnmarshal)
+ }
+ for key, want := range map[string]any{
+ "access_token": "new-token",
+ "refresh_token": "new-refresh",
+ "prefix": "team",
+ "proxy_url": "http://127.0.0.1:8080",
+ "disabled": true,
+ "weight": float64(5),
+ } {
+ if got := saved[key]; got != want {
+ t.Errorf("%s = %#v, want %#v", key, got, want)
+ }
+ }
+}
+
+func TestSaveTokenRecord_ReloginKeepsCanonicalXAICredential(t *testing.T) {
+ authDir := t.TempDir()
+ targetFileName := xai.CredentialFileName("a+b@example.com", "sub-first")
+ targetPath := filepath.Join(authDir, targetFileName)
+
+ existing := map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "old-token",
+ "refresh_token": "old-refresh",
+ "prefix": "team",
+ }
+ raw, errMarshal := json.Marshal(existing)
+ if errMarshal != nil {
+ t.Fatalf("marshal canonical credential: %v", errMarshal)
+ }
+ if errWrite := os.WriteFile(targetPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write canonical credential: %v", errWrite)
+ }
+
+ tokenStorage := &xai.TokenStorage{
+ AccessToken: "new-token",
+ RefreshToken: "new-refresh",
+ Email: "a+b@example.com",
+ Subject: "sub-first",
+ Expire: "2026-12-31T23:59:59Z",
+ }
+ record := &coreauth.Auth{
+ ID: targetFileName,
+ Provider: "xai",
+ FileName: targetFileName,
+ Storage: tokenStorage,
+ Metadata: map[string]any{
+ "email": tokenStorage.Email,
+ "sub": tokenStorage.Subject,
+ },
+ }
+
+ h := NewHandler(&config.Config{AuthDir: authDir}, "", nil)
+ savedPath, errSave := h.saveTokenRecord(context.Background(), record)
+ if errSave != nil {
+ t.Fatalf("saveTokenRecord error: %v", errSave)
+ }
+ if savedPath != targetPath {
+ t.Fatalf("savedPath = %s, want %s", savedPath, targetPath)
+ }
+ savedRaw, errRead := os.ReadFile(targetPath)
+ if errRead != nil {
+ t.Fatalf("canonical credential was deleted by re-login: %v", errRead)
+ }
+ var saved map[string]any
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal canonical credential: %v", errUnmarshal)
+ }
+ if saved["access_token"] != "new-token" || saved["prefix"] != "team" {
+ t.Fatalf("saved credential = %#v, want new token with preserved prefix", saved)
+ }
+}
+
+func TestSaveTokenRecord_SubjectOnlyReloginKeepsEmailCredential(t *testing.T) {
+ authDir := t.TempDir()
+ emailFileName := xai.CredentialFileName("a+b@example.com", "sub-first")
+ emailPath := filepath.Join(authDir, emailFileName)
+
+ existing := map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "old-token",
+ "refresh_token": "old-refresh",
+ "prefix": "team",
+ "weight": float64(5),
+ }
+ raw, errMarshal := json.Marshal(existing)
+ if errMarshal != nil {
+ t.Fatalf("marshal canonical credential: %v", errMarshal)
+ }
+ if errWrite := os.WriteFile(emailPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write canonical credential: %v", errWrite)
+ }
+
+ // The upstream stops returning an email, so the record is named after
+ // the subject; its identity keys omit the missing email.
+ tokenStorage := &xai.TokenStorage{
+ AccessToken: "new-token",
+ RefreshToken: "new-refresh",
+ Subject: "sub-first",
+ Expire: "2026-12-31T23:59:59Z",
+ }
+ subjectFileName := xai.CredentialFileName("", tokenStorage.Subject)
+ record := &coreauth.Auth{
+ ID: subjectFileName,
+ Provider: "xai",
+ FileName: subjectFileName,
+ Storage: tokenStorage,
+ Metadata: map[string]any{
+ "sub": tokenStorage.Subject,
+ },
+ }
+
+ h := NewHandler(&config.Config{AuthDir: authDir}, "", nil)
+ savedPath, errSave := h.saveTokenRecord(context.Background(), record)
+ if errSave != nil {
+ t.Fatalf("saveTokenRecord error: %v", errSave)
+ }
+ if savedPath != emailPath {
+ t.Fatalf("savedPath = %s, want canonical email credential %s", savedPath, emailPath)
+ }
+ savedRaw, errRead := os.ReadFile(emailPath)
+ if errRead != nil {
+ t.Fatalf("read canonical credential: %v", errRead)
+ }
+ var saved map[string]any
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal canonical credential: %v", errUnmarshal)
+ }
+ if saved["access_token"] != "new-token" || saved["prefix"] != "team" || saved["weight"] != float64(5) || saved["email"] != "a+b@example.com" {
+ t.Fatalf("saved credential = %#v, want new token with preserved settings and email", saved)
+ }
+ entries, errReadDir := os.ReadDir(authDir)
+ if errReadDir != nil {
+ t.Fatalf("ReadDir error: %v", errReadDir)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("auth dir holds %d files, want the single canonical credential", len(entries))
+ }
+}
+
func TestPatchAuthFileFields_DeletesPluginFields(t *testing.T) {
gin.SetMode(gin.TestMode)
authDir := t.TempDir()
diff --git a/internal/api/handlers/management/auth_files_targeted_oauth_test.go b/internal/api/handlers/management/auth_files_targeted_oauth_test.go
new file mode 100644
index 00000000000..9f36501156b
--- /dev/null
+++ b/internal/api/handlers/management/auth_files_targeted_oauth_test.go
@@ -0,0 +1,1044 @@
+package management
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/antigravity"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/devin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
+ metaauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/meta"
+ xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+type targetedTestAntigravityOAuthService struct {
+ email string
+ accessToken string
+}
+
+func (s *targetedTestAntigravityOAuthService) BuildAuthURL(state, _ string) string {
+ return "https://accounts.google.com/o/oauth2/v2/auth?state=" + state
+}
+
+func (s *targetedTestAntigravityOAuthService) ExchangeCodeForTokens(_ context.Context, code, _ string) (*antigravity.TokenResponse, error) {
+ tok := s.accessToken
+ if tok == "" {
+ tok = "ag-fresh-access-" + code
+ }
+ return &antigravity.TokenResponse{
+ AccessToken: tok,
+ RefreshToken: "ag-fresh-refresh-" + code,
+ ExpiresIn: 3600,
+ TokenType: "Bearer",
+ }, nil
+}
+
+func (s *targetedTestAntigravityOAuthService) FetchUserInfo(_ context.Context, _ string) (string, error) {
+ return s.email, nil
+}
+
+func (s *targetedTestAntigravityOAuthService) FetchProjectID(_ context.Context, _ string) (string, error) {
+ return "ag-project-1", nil
+}
+
+type targetedTestKimiOAuthService struct {
+ domain string
+ accessToken string
+ refreshToken string
+ deviceID string
+}
+
+func (s *targetedTestKimiOAuthService) StartDeviceFlow(_ context.Context) (*kimi.DeviceCodeResponse, error) {
+ return &kimi.DeviceCodeResponse{
+ DeviceCode: "kimi-dev-code",
+ UserCode: "KIMI-1234",
+ VerificationURI: "https://auth.kimi.com/verify",
+ VerificationURIComplete: "https://auth.kimi.com/verify?user_code=KIMI-1234",
+ ExpiresIn: 600,
+ Interval: 1,
+ }, nil
+}
+
+func (s *targetedTestKimiOAuthService) WaitForAuthorization(_ context.Context, _ *kimi.DeviceCodeResponse) (*kimi.KimiAuthBundle, error) {
+ return &kimi.KimiAuthBundle{
+ TokenData: &kimi.KimiTokenData{
+ AccessToken: s.accessToken,
+ RefreshToken: s.refreshToken,
+ TokenType: "Bearer",
+ ExpiresAt: time.Now().Add(time.Hour).Unix(),
+ Scope: "coding",
+ },
+ DeviceID: s.deviceID,
+ }, nil
+}
+
+func (s *targetedTestKimiOAuthService) CreateTokenStorage(bundle *kimi.KimiAuthBundle) *kimi.KimiTokenStorage {
+ return kimi.NewKimiAuthWithDomain(nil, s.domain).CreateTokenStorage(bundle)
+}
+
+type targetedTestClaudeOAuthService struct {
+ authDir string
+ email string
+ accountUUID string
+ orgUUID string
+ accessToken string
+}
+
+func (s *targetedTestClaudeOAuthService) GenerateAuthURL(state string, _ *claude.PKCECodes) (string, string, error) {
+ if s.authDir != "" {
+ waitFile := filepath.Join(s.authDir, ".oauth-anthropic-"+state+".oauth")
+ payload, _ := json.Marshal(map[string]string{"state": state, "code": "claude-code-" + state})
+ _ = os.WriteFile(waitFile, payload, 0o600)
+ }
+ return "https://claude.ai/oauth/authorize?state=" + state, state, nil
+}
+
+func (s *targetedTestClaudeOAuthService) ExchangeCodeForTokens(_ context.Context, code, _ string, _ *claude.PKCECodes) (*claude.ClaudeAuthBundle, error) {
+ tok := s.accessToken
+ if tok == "" {
+ tok = "claude-access-" + code
+ }
+ return &claude.ClaudeAuthBundle{
+ TokenData: claude.ClaudeTokenData{
+ AccessToken: tok,
+ RefreshToken: "claude-refresh-" + code,
+ Email: s.email,
+ AccountUUID: s.accountUUID,
+ OrganizationUUID: s.orgUUID,
+ Expire: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
+ },
+ LastRefresh: time.Now().UTC().Format(time.RFC3339),
+ }, nil
+}
+
+func (s *targetedTestClaudeOAuthService) CreateTokenStorage(bundle *claude.ClaudeAuthBundle) *claude.ClaudeTokenStorage {
+ return &claude.ClaudeTokenStorage{
+ AccessToken: bundle.TokenData.AccessToken,
+ RefreshToken: bundle.TokenData.RefreshToken,
+ Email: bundle.TokenData.Email,
+ AccountUUID: bundle.TokenData.AccountUUID,
+ OrganizationUUID: bundle.TokenData.OrganizationUUID,
+ Expire: bundle.TokenData.Expire,
+ LastRefresh: bundle.LastRefresh,
+ Type: "claude",
+ }
+}
+
+type targetedTestXAIOAuthService struct {
+ email string
+ subject string
+ accessToken string
+}
+
+func (s *targetedTestXAIOAuthService) StartDeviceFlow(_ context.Context) (*xaiauth.DeviceCodeResponse, error) {
+ return &xaiauth.DeviceCodeResponse{
+ DeviceCode: "xai-dev-code",
+ UserCode: "XAI-1234",
+ VerificationURI: "https://auth.x.ai/device",
+ VerificationURIComplete: "https://auth.x.ai/device?user_code=XAI-1234",
+ ExpiresIn: 600,
+ Interval: 1,
+ }, nil
+}
+
+func (s *targetedTestXAIOAuthService) WaitForAuthorization(_ context.Context, _ *xaiauth.DeviceCodeResponse) (*xaiauth.AuthBundle, error) {
+ return &xaiauth.AuthBundle{
+ TokenData: xaiauth.TokenData{
+ AccessToken: s.accessToken,
+ RefreshToken: "xai-refresh-fresh",
+ TokenType: "Bearer",
+ ExpiresIn: 3600,
+ Expire: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
+ Email: s.email,
+ Subject: s.subject,
+ },
+ LastRefresh: time.Now().UTC().Format(time.RFC3339),
+ BaseURL: xaiauth.DefaultAPIBaseURL,
+ }, nil
+}
+
+func (s *targetedTestXAIOAuthService) CreateTokenStorage(bundle *xaiauth.AuthBundle) *xaiauth.TokenStorage {
+ return &xaiauth.TokenStorage{
+ Type: "xai",
+ AuthKind: "oauth",
+ AccessToken: bundle.TokenData.AccessToken,
+ RefreshToken: bundle.TokenData.RefreshToken,
+ TokenType: bundle.TokenData.TokenType,
+ ExpiresIn: bundle.TokenData.ExpiresIn,
+ Expire: bundle.TokenData.Expire,
+ LastRefresh: bundle.LastRefresh,
+ BaseURL: bundle.BaseURL,
+ Email: bundle.TokenData.Email,
+ Subject: bundle.TokenData.Subject,
+ }
+}
+
+type targetedTestMetaOAuthService struct {
+ email string
+ accessToken string
+}
+
+func (s *targetedTestMetaOAuthService) StartDeviceFlow(_ context.Context) (*metaauth.DeviceCodeResponse, error) {
+ return &metaauth.DeviceCodeResponse{
+ DeviceCode: "meta-dev-code",
+ UserCode: "META-1234",
+ VerificationURI: "https://auth.meta.com/device",
+ VerificationURIComplete: "https://auth.meta.com/device?user_code=META-1234",
+ ExpiresIn: 600,
+ Interval: 1,
+ }, nil
+}
+
+func (s *targetedTestMetaOAuthService) WaitForAuthorization(_ context.Context, _ *metaauth.DeviceCodeResponse) (*metaauth.MetaAuthBundle, error) {
+ return &metaauth.MetaAuthBundle{
+ TokenData: &metaauth.TokenData{
+ AccessToken: s.accessToken,
+ TokenType: "Bearer",
+ ExpiresIn: 3600,
+ },
+ MintedKey: &metaauth.MintedKeyResponse{
+ APIKey: "meta-api-key-" + s.accessToken,
+ BaseURL: metaauth.DefaultAPIBaseURL,
+ UserEmail: s.email,
+ },
+ Email: s.email,
+ }, nil
+}
+
+func (s *targetedTestMetaOAuthService) CreateTokenStorage(bundle *metaauth.MetaAuthBundle) *metaauth.MetaTokenStorage {
+ return &metaauth.MetaTokenStorage{
+ Type: "meta",
+ AuthKind: "oauth",
+ AccessToken: bundle.TokenData.AccessToken,
+ DCAToken: bundle.TokenData.AccessToken,
+ APIKey: bundle.MintedKey.APIKey,
+ TokenType: bundle.TokenData.TokenType,
+ ExpiresIn: bundle.TokenData.ExpiresIn,
+ Expired: time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
+ BaseURL: metaauth.DefaultAPIBaseURL,
+ Email: bundle.Email,
+ }
+}
+
+func TestStartOAuthV8TargetedSessionRegistrationAcrossBuiltinProviders(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ origTransport := http.DefaultTransport
+ http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ rec := httptest.NewRecorder()
+ rec.Header().Set("Content-Type", "application/json")
+ switch {
+ case strings.Contains(req.URL.Path, "openid-configuration"):
+ _, _ = rec.WriteString(`{"device_authorization_endpoint":"https://auth.x.ai/device","token_endpoint":"https://auth.x.ai/token"}`)
+ case strings.Contains(req.URL.Path, "device"):
+ _, _ = rec.WriteString(`{"device_code":"dev-code","user_code":"ABCD-1234","verification_uri":"https://example.test/verify","verification_uri_complete":"https://example.test/verify?code=ABCD-1234","expires_in":600,"interval":5}`)
+ default:
+ _, _ = rec.WriteString(`{"error":"authorization_pending"}`)
+ }
+ return rec.Result(), nil
+ })
+ t.Cleanup(func() { http.DefaultTransport = origTransport })
+
+ origCodexFactory := newCodexOAuthService
+ newCodexOAuthService = func(*config.Config) codexOAuthService {
+ return &targetedRecoveryTestCodexOAuthService{authDir: t.TempDir(), email: "a@x.test", accountID: "acc-1"}
+ }
+ t.Cleanup(func() { newCodexOAuthService = origCodexFactory })
+
+ origAgFactory := newAntigravityOAuthService
+ newAntigravityOAuthService = func(*config.Config) antigravityOAuthService {
+ return &targetedTestAntigravityOAuthService{email: "a@x.test"}
+ }
+ t.Cleanup(func() { newAntigravityOAuthService = origAgFactory })
+
+ origDevinFactory := newDevinOAuthService
+ newDevinOAuthService = func(*config.Config) devinOAuthService {
+ return &fakeDevinOAuthService{}
+ }
+ t.Cleanup(func() { newDevinOAuthService = origDevinFactory })
+
+ authDir := t.TempDir()
+ manager := coreauth.NewManager(nil, nil, nil)
+
+ targets := []struct {
+ provider string
+ fileName string
+ meta map[string]any
+ }{
+ {provider: "codex", fileName: "codex-target.json", meta: map[string]any{"type": "codex", "email": "a@x.test", "account_id": "acc-1"}},
+ {provider: "antigravity", fileName: "antigravity-target.json", meta: map[string]any{"type": "antigravity", "email": "a@x.test"}},
+ {provider: "kimi", fileName: "kimi-target.json", meta: map[string]any{"type": "kimi", "domain": "kimi.com"}},
+ {provider: "kimi-ai", fileName: "kimi-ai-target.json", meta: map[string]any{"type": "kimi-ai", "domain": "kimi.ai"}},
+ {provider: "claude", fileName: "claude-target.json", meta: map[string]any{"type": "claude", "email": "a@x.test", "account_uuid": "uuid-1"}},
+ {provider: "xai", fileName: "xai-target.json", meta: map[string]any{"type": "xai", "email": "a@x.test", "sub": "sub-1"}},
+ {provider: "meta", fileName: "meta-target.json", meta: map[string]any{"type": "meta", "email": "a@x.test"}},
+ {provider: "devin", fileName: "devin-target.json", meta: map[string]any{"type": "devin", "email": "a@x.test", "user_id": "usr-1"}},
+ }
+
+ for _, tc := range targets {
+ raw, _ := json.Marshal(tc.meta)
+ fullPath := filepath.Join(authDir, tc.fileName)
+ if errWrite := os.WriteFile(fullPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write %s: %v", tc.fileName, errWrite)
+ }
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: tc.fileName,
+ FileName: tc.fileName,
+ Provider: tc.provider,
+ Metadata: tc.meta,
+ Attributes: map[string]string{coreauth.AttributePath: fullPath},
+ })
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, manager)
+ router := gin.New()
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+
+ for _, tc := range targets {
+ t.Run(tc.provider, func(t *testing.T) {
+ reqBody, _ := json.Marshal(map[string]any{
+ "provider": tc.provider,
+ "name": tc.fileName,
+ "verify": true,
+ })
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(string(reqBody)))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: StartOAuthV8 status = %d, want 200: %s", tc.provider, rec.Code, rec.Body.String())
+ }
+ var startResp map[string]any
+ if errUnmarshal := json.Unmarshal(rec.Body.Bytes(), &startResp); errUnmarshal != nil {
+ t.Fatalf("%s: unmarshal response: %v", tc.provider, errUnmarshal)
+ }
+ state, _ := startResp["state"].(string)
+ if state == "" {
+ t.Fatalf("%s: expected non-empty state in response: %v", tc.provider, startResp)
+ }
+ t.Cleanup(func() { CancelOAuthSession(state) })
+
+ if gotTargetID, _ := startResp["target_auth_id"].(string); gotTargetID != tc.fileName {
+ t.Fatalf("%s: start response target_auth_id = %q, want %q (full response: %v)", tc.provider, gotTargetID, tc.fileName, startResp)
+ }
+ sessionTarget, okTarget := GetOAuthSessionTarget(state)
+ if !okTarget || sessionTarget.AuthID != tc.fileName || !sessionTarget.VerifyAfterLogin {
+ t.Fatalf("%s: GetOAuthSessionTarget(%q) = (%+v, %v), want AuthID=%q VerifyAfterLogin=true", tc.provider, state, sessionTarget, okTarget, tc.fileName)
+ }
+ })
+ }
+}
+
+func TestTargetedOAuthReloginAntigravityRejectsMismatchedAccountAndUpdatesMatchingTarget(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ targetFile := "antigravity-a@x.test.json"
+ targetPath := filepath.Join(authDir, targetFile)
+ initialContent := `{"type":"antigravity","email":"a@x.test","access_token":"old-access","refresh_token":"old-refresh"}`
+ if errWrite := os.WriteFile(targetPath, []byte(initialContent), 0o600); errWrite != nil {
+ t.Fatalf("write target file: %v", errWrite)
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: targetFile,
+ FileName: targetFile,
+ Provider: "antigravity",
+ RequiresSignIn: true,
+ Metadata: map[string]any{
+ "type": "antigravity",
+ "email": "a@x.test",
+ "access_token": "old-access",
+ "refresh_token": "old-refresh",
+ },
+ Attributes: map[string]string{
+ coreauth.AttributePath: targetPath,
+ "email": "a@x.test",
+ },
+ })
+
+ agSvc := &targetedTestAntigravityOAuthService{email: "b@x.test"}
+ origAgFactory := newAntigravityOAuthService
+ newAntigravityOAuthService = func(*config.Config) antigravityOAuthService {
+ return agSvc
+ }
+ t.Cleanup(func() { newAntigravityOAuthService = origAgFactory })
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, manager)
+ router := gin.New()
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.GET("/v8/management/oauth/status", h.GetAuthStatus)
+
+ // 1. Mismatched account (b@x.test) must be rejected and must NOT write antigravity-b@x.test.json.
+ wMismatch := httptest.NewRecorder()
+ reqMismatch := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"antigravity","name":"antigravity-a@x.test.json"}`))
+ reqMismatch.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wMismatch, reqMismatch)
+ if wMismatch.Code != http.StatusOK {
+ t.Fatalf("StartOAuthV8 status = %d: %s", wMismatch.Code, wMismatch.Body.String())
+ }
+ var mismatchStart struct {
+ State string `json:"state"`
+ TargetAuthID string `json:"target_auth_id"`
+ }
+ _ = json.Unmarshal(wMismatch.Body.Bytes(), &mismatchStart)
+ t.Cleanup(func() { CancelOAuthSession(mismatchStart.State) })
+ if mismatchStart.TargetAuthID != targetFile {
+ t.Fatalf("target_auth_id = %q, want %q", mismatchStart.TargetAuthID, targetFile)
+ }
+ if _, errCb := WriteOAuthCallbackFileForPendingSession(authDir, "antigravity", mismatchStart.State, "code-mismatch", ""); errCb != nil {
+ t.Fatalf("write callback: %v", errCb)
+ }
+ mismatchStatus := pollOAuthStatusUntilDone(t, router, mismatchStart.State)
+ if mismatchStatus["status"] != "error" || !strings.Contains(strings.ToLower(mismatchStatus["error"].(string)), "does not match target credential") {
+ t.Fatalf("expected account mismatch error, got %#v", mismatchStatus)
+ }
+ if _, errStat := os.Stat(filepath.Join(authDir, "antigravity-b@x.test.json")); !os.IsNotExist(errStat) {
+ t.Fatalf("mismatched login must not create antigravity-b@x.test.json, stat err = %v", errStat)
+ }
+ afterMismatchBytes, _ := os.ReadFile(targetPath)
+ if string(afterMismatchBytes) != initialContent {
+ t.Fatalf("target file changed after mismatched login: %s", string(afterMismatchBytes))
+ }
+
+ // 2. Matching account (a@x.test) updates target file in place and reports adopted status.
+ agSvc.email = "a@x.test"
+ agSvc.accessToken = "ag-fresh-token-match"
+ wMatch := httptest.NewRecorder()
+ reqMatch := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"antigravity","name":"antigravity-a@x.test.json"}`))
+ reqMatch.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wMatch, reqMatch)
+ if wMatch.Code != http.StatusOK {
+ t.Fatalf("StartOAuthV8 (match) status = %d: %s", wMatch.Code, wMatch.Body.String())
+ }
+ var matchStart struct {
+ State string `json:"state"`
+ }
+ _ = json.Unmarshal(wMatch.Body.Bytes(), &matchStart)
+ t.Cleanup(func() { CancelOAuthSession(matchStart.State) })
+ if _, errCb := WriteOAuthCallbackFileForPendingSession(authDir, "antigravity", matchStart.State, "code-match", ""); errCb != nil {
+ t.Fatalf("write match callback: %v", errCb)
+ }
+ matchStatus := pollOAuthStatusUntilDone(t, router, matchStart.State)
+ if matchStatus["status"] != "ok" || matchStatus["auth_id"] != targetFile || matchStatus["saved"] != true || matchStatus["adopted"] != true {
+ t.Fatalf("expected saved & adopted ok status for %s, got %#v", targetFile, matchStatus)
+ }
+ repaired, okRepaired := manager.GetByID(targetFile)
+ if !okRepaired || repaired == nil || repaired.RequiresSignIn {
+ t.Fatalf("expected %s repaired in manager, got %+v", targetFile, repaired)
+ }
+ savedRaw, errRead := os.ReadFile(targetPath)
+ if errRead != nil || !strings.Contains(string(savedRaw), "ag-fresh-token-match") {
+ t.Fatalf("expected target file updated with fresh token, got %q (err=%v)", string(savedRaw), errRead)
+ }
+}
+
+func TestTargetedOAuthReloginKimiUpdatesTargetFileWithoutAddingNewFiles(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ targetFile := "kimi-1700000000000.json"
+ targetPath := filepath.Join(authDir, targetFile)
+ initialContent := `{"type":"kimi","domain":"kimi.com","access_token":"old-kimi-access","refresh_token":"old-kimi-refresh","device_id":"dev-old"}`
+ if errWrite := os.WriteFile(targetPath, []byte(initialContent), 0o600); errWrite != nil {
+ t.Fatalf("write kimi target file: %v", errWrite)
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ _, _ = manager.Register(context.Background(), &coreauth.Auth{
+ ID: targetFile,
+ FileName: targetFile,
+ Provider: "kimi",
+ RequiresSignIn: true,
+ Metadata: map[string]any{
+ "type": "kimi",
+ "domain": "kimi.com",
+ "access_token": "old-kimi-access",
+ "refresh_token": "old-kimi-refresh",
+ },
+ Attributes: map[string]string{
+ coreauth.AttributePath: targetPath,
+ "domain": "kimi.com",
+ },
+ })
+
+ kimiSvc := &targetedTestKimiOAuthService{
+ domain: kimi.KimiDefaultDomain,
+ accessToken: "kimi-access-1",
+ refreshToken: "kimi-refresh-1",
+ deviceID: "dev-1",
+ }
+ origKimiFactory := newKimiOAuthService
+ newKimiOAuthService = func(_ *config.Config, domain string) kimiOAuthService {
+ kimiSvc.domain = domain
+ return kimiSvc
+ }
+ t.Cleanup(func() { newKimiOAuthService = origKimiFactory })
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, manager)
+ router := gin.New()
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.GET("/v8/management/oauth/status", h.GetAuthStatus)
+
+ // Domain mismatch ("kimi" with domain "kimi.ai" targeting a kimi.com credential) must return 400.
+ wDomainMismatch := httptest.NewRecorder()
+ reqDomainMismatch := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"kimi","domain":"kimi.ai","name":"kimi-1700000000000.json"}`))
+ reqDomainMismatch.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wDomainMismatch, reqDomainMismatch)
+ if wDomainMismatch.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400 for kimi.ai domain against kimi.com target, got %d: %s", wDomainMismatch.Code, wDomainMismatch.Body.String())
+ }
+
+ // Two targeted logins in a row must both update kimi-1700000000000.json in place and keep file count at 1.
+ for i, tok := range []string{"kimi-access-relogin-1", "kimi-access-relogin-2"} {
+ kimiSvc.accessToken = tok
+ kimiSvc.refreshToken = "kimi-refresh-" + tok
+ wStart := httptest.NewRecorder()
+ reqStart := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"kimi","name":"kimi-1700000000000.json"}`))
+ reqStart.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wStart, reqStart)
+ if wStart.Code != http.StatusOK {
+ t.Fatalf("run %d: StartOAuthV8 status = %d: %s", i+1, wStart.Code, wStart.Body.String())
+ }
+ var startResp struct {
+ State string `json:"state"`
+ TargetAuthID string `json:"target_auth_id"`
+ }
+ if errUnmarshal := json.Unmarshal(wStart.Body.Bytes(), &startResp); errUnmarshal != nil {
+ t.Fatalf("run %d: unmarshal startResp: %v", i+1, errUnmarshal)
+ }
+ t.Cleanup(func() { CancelOAuthSession(startResp.State) })
+ if startResp.TargetAuthID != targetFile {
+ t.Fatalf("run %d: target_auth_id = %q, want %q", i+1, startResp.TargetAuthID, targetFile)
+ }
+
+ status := pollOAuthStatusUntilDone(t, router, startResp.State)
+ if status["status"] != "ok" || status["auth_id"] != targetFile || status["saved"] != true || status["adopted"] != true {
+ t.Fatalf("run %d: unexpected status: %#v", i+1, status)
+ }
+
+ entries, errDir := os.ReadDir(authDir)
+ if errDir != nil {
+ t.Fatalf("run %d: ReadDir: %v", i+1, errDir)
+ }
+ var jsonFiles []string
+ for _, entry := range entries {
+ if strings.HasSuffix(entry.Name(), ".json") {
+ jsonFiles = append(jsonFiles, entry.Name())
+ }
+ }
+ if len(jsonFiles) != 1 || jsonFiles[0] != targetFile {
+ t.Fatalf("run %d: expected only [%s] in authDir, got %v", i+1, targetFile, jsonFiles)
+ }
+
+ rawSaved, errRead := os.ReadFile(targetPath)
+ if errRead != nil || !strings.Contains(string(rawSaved), tok) {
+ t.Fatalf("run %d: expected target file to contain %q, got %s (err=%v)", i+1, tok, string(rawSaved), errRead)
+ }
+ }
+}
+
+func TestTargetedOAuthReloginClaudeXAIMetaDevinAndAuthIndexVerify(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ manager := coreauth.NewManager(nil, nil, nil)
+
+ claudeSvc := &targetedTestClaudeOAuthService{authDir: authDir, email: "a@x.test", accountUUID: "uuid-a", orgUUID: "org-a"}
+ origClaude := newClaudeOAuthService
+ newClaudeOAuthService = func(*config.Config, string) claudeOAuthService { return claudeSvc }
+ t.Cleanup(func() { newClaudeOAuthService = origClaude })
+
+ xaiSvc := &targetedTestXAIOAuthService{email: "a@x.test", subject: "sub-a", accessToken: "xai-fresh-1"}
+ origXAI := newXAIOAuthService
+ newXAIOAuthService = func(*config.Config, string) xaiOAuthService { return xaiSvc }
+ t.Cleanup(func() { newXAIOAuthService = origXAI })
+
+ metaSvc := &targetedTestMetaOAuthService{email: "a@x.test", accessToken: "meta-fresh-1"}
+ origMeta := newMetaOAuthService
+ newMetaOAuthService = func(*config.Config, string) metaOAuthService { return metaSvc }
+ t.Cleanup(func() { newMetaOAuthService = origMeta })
+
+ devinEmail := "a@x.test"
+ devinUserID := "usr-a"
+ devinToken := "devin-fresh-1"
+ origDevin := newDevinOAuthService
+ newDevinOAuthService = func(*config.Config) devinOAuthService {
+ return &fakeDevinOAuthService{
+ exchange: func(context.Context, string, string) (string, error) { return devinToken, nil },
+ create: func(_ context.Context, token string) (*coreauth.Auth, error) {
+ return &coreauth.Auth{
+ ID: "devin-generated.json",
+ FileName: "devin-generated.json",
+ Provider: "devin",
+ Metadata: map[string]any{
+ "type": "devin",
+ "api_key": devin.FormatSessionToken(token),
+ "session_token": devin.FormatSessionToken(token),
+ "email": devinEmail,
+ "user_id": devinUserID,
+ "auth_kind": "oauth",
+ },
+ Attributes: map[string]string{
+ "api_key": devin.FormatSessionToken(token),
+ "session_token": devin.FormatSessionToken(token),
+ "email": devinEmail,
+ "user_id": devinUserID,
+ "auth_kind": "oauth",
+ },
+ }, nil
+ },
+ }
+ }
+ t.Cleanup(func() { newDevinOAuthService = origDevin })
+
+ setupTarget := func(provider, fileName string, meta map[string]any) *coreauth.Auth {
+ raw, _ := json.Marshal(meta)
+ path := filepath.Join(authDir, fileName)
+ if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {
+ t.Fatalf("write %s: %v", fileName, errWrite)
+ }
+ auth := &coreauth.Auth{
+ ID: fileName,
+ FileName: fileName,
+ Provider: provider,
+ RequiresSignIn: true,
+ Metadata: meta,
+ Attributes: map[string]string{coreauth.AttributePath: path},
+ }
+ auth.EnsureIndex()
+ registered, errReg := manager.Register(context.Background(), auth)
+ if errReg != nil {
+ t.Fatalf("register %s: %v", fileName, errReg)
+ }
+ return registered
+ }
+
+ claudeAuth := setupTarget("claude", "claude-custom.json", map[string]any{"type": "claude", "email": "a@x.test", "account_uuid": "uuid-a", "access_token": "old", "refresh_token": "old"})
+ _ = setupTarget("xai", "xai-custom.json", map[string]any{"type": "xai", "email": "a@x.test", "sub": "sub-a", "access_token": "old", "refresh_token": "old"})
+ _ = setupTarget("meta", "meta-custom.json", map[string]any{"type": "meta", "email": "a@x.test", "access_token": "old"})
+ _ = setupTarget("devin", "devin-custom.json", map[string]any{"type": "devin", "email": "a@x.test", "user_id": "usr-a", "api_key": "devin-session-token$old"})
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, manager)
+ router := gin.New()
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.POST("/v8/management/oauth/callback", h.PostOAuthCallbackV8)
+ router.GET("/v8/management/oauth/status", h.GetAuthStatus)
+
+ // 1. Claude targeted relogin via auth_index (omitting name & provider) rejects mismatched account_uuid, then succeeds on match.
+ claudeSvc.accountUUID = "uuid-wrong"
+ wClaudeMismatch := httptest.NewRecorder()
+ reqClaudeMismatch := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"auth_index":"`+claudeAuth.Index+`"}`))
+ reqClaudeMismatch.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wClaudeMismatch, reqClaudeMismatch)
+ if wClaudeMismatch.Code != http.StatusOK {
+ t.Fatalf("claude StartOAuthV8 by auth_index status = %d: %s", wClaudeMismatch.Code, wClaudeMismatch.Body.String())
+ }
+ var claudeMismatchStart struct {
+ State string `json:"state"`
+ TargetAuthID string `json:"target_auth_id"`
+ }
+ _ = json.Unmarshal(wClaudeMismatch.Body.Bytes(), &claudeMismatchStart)
+ if claudeMismatchStart.TargetAuthID != "claude-custom.json" {
+ t.Fatalf("claude target_auth_id = %q, want claude-custom.json", claudeMismatchStart.TargetAuthID)
+ }
+ if st := pollOAuthStatusUntilDone(t, router, claudeMismatchStart.State); st["status"] != "error" {
+ t.Fatalf("claude mismatch status = %#v, want error", st)
+ }
+
+ claudeSvc.accountUUID = "uuid-a"
+ claudeSvc.accessToken = "claude-fresh-ok"
+ wClaudeMatch := httptest.NewRecorder()
+ reqClaudeMatch := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"auth_index":"`+claudeAuth.Index+`"}`))
+ reqClaudeMatch.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(wClaudeMatch, reqClaudeMatch)
+ var claudeMatchStart struct {
+ State string `json:"state"`
+ }
+ _ = json.Unmarshal(wClaudeMatch.Body.Bytes(), &claudeMatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, claudeMatchStart.State); st["status"] != "ok" || st["auth_id"] != "claude-custom.json" || st["adopted"] != true {
+ t.Fatalf("claude match status = %#v, want ok & adopted", st)
+ }
+
+ // 2. xAI mismatch rejected, match updates xai-custom.json in place.
+ xaiSvc.subject = "sub-wrong"
+ wXAIMismatch := httptest.NewRecorder()
+ router.ServeHTTP(wXAIMismatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"xai","name":"xai-custom.json"}`)))
+ var xaiMismatchStart struct{ State string }
+ _ = json.Unmarshal(wXAIMismatch.Body.Bytes(), &xaiMismatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, xaiMismatchStart.State); st["status"] != "error" {
+ t.Fatalf("xai mismatch status = %#v, want error", st)
+ }
+
+ xaiSvc.subject = "sub-a"
+ wXAIMatch := httptest.NewRecorder()
+ router.ServeHTTP(wXAIMatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"xai","name":"xai-custom.json"}`)))
+ var xaiMatchStart struct{ State string }
+ _ = json.Unmarshal(wXAIMatch.Body.Bytes(), &xaiMatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, xaiMatchStart.State); st["status"] != "ok" || st["auth_id"] != "xai-custom.json" || st["adopted"] != true {
+ t.Fatalf("xai match status = %#v, want ok & adopted", st)
+ }
+
+ // 3. Meta mismatch rejected, match updates meta-custom.json in place.
+ metaSvc.email = "wrong@x.test"
+ wMetaMismatch := httptest.NewRecorder()
+ router.ServeHTTP(wMetaMismatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"meta","name":"meta-custom.json"}`)))
+ var metaMismatchStart struct{ State string }
+ _ = json.Unmarshal(wMetaMismatch.Body.Bytes(), &metaMismatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, metaMismatchStart.State); st["status"] != "error" {
+ t.Fatalf("meta mismatch status = %#v, want error", st)
+ }
+
+ metaSvc.email = "a@x.test"
+ wMetaMatch := httptest.NewRecorder()
+ router.ServeHTTP(wMetaMatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"meta","name":"meta-custom.json"}`)))
+ var metaMatchStart struct{ State string }
+ _ = json.Unmarshal(wMetaMatch.Body.Bytes(), &metaMatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, metaMatchStart.State); st["status"] != "ok" || st["auth_id"] != "meta-custom.json" || st["adopted"] != true {
+ t.Fatalf("meta match status = %#v, want ok & adopted", st)
+ }
+
+ // 4. Devin mismatch rejected, match updates devin-custom.json in place.
+ devinUserID = "usr-wrong"
+ wDevinMismatch := httptest.NewRecorder()
+ router.ServeHTTP(wDevinMismatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"devin","name":"devin-custom.json"}`)))
+ var devinMismatchStart struct{ State string }
+ _ = json.Unmarshal(wDevinMismatch.Body.Bytes(), &devinMismatchStart)
+ _, _ = WriteOAuthCallbackFileForPendingSession(authDir, "devin", devinMismatchStart.State, "devin-code-1", "")
+ if st := pollOAuthStatusUntilDone(t, router, devinMismatchStart.State); st["status"] != "error" {
+ t.Fatalf("devin mismatch status = %#v, want error", st)
+ }
+
+ devinUserID = "usr-a"
+ wDevinMatch := httptest.NewRecorder()
+ router.ServeHTTP(wDevinMatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"devin","name":"devin-custom.json"}`)))
+ var devinMatchStart struct{ State string }
+ _ = json.Unmarshal(wDevinMatch.Body.Bytes(), &devinMatchStart)
+ _, _ = WriteOAuthCallbackFileForPendingSession(authDir, "devin", devinMatchStart.State, "devin-code-2", "")
+ if st := pollOAuthStatusUntilDone(t, router, devinMatchStart.State); st["status"] != "ok" || st["auth_id"] != "devin-custom.json" || st["adopted"] != true {
+ t.Fatalf("devin match status = %#v, want ok & adopted", st)
+ }
+}
+
+func TestTargetedOAuthReloginReviewEdgeCasesAndVerification(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ authDir := t.TempDir()
+ manager := coreauth.NewManager(nil, nil, nil)
+
+ // 1. Kimi domain isolation when provider is omitted or auth_index is used.
+ kimiComAuth := &coreauth.Auth{
+ ID: "kimi-com-target.json",
+ FileName: "kimi-com-target.json",
+ Provider: "kimi",
+ RequiresSignIn: true,
+ Attributes: map[string]string{"domain": "kimi.com"},
+ Metadata: map[string]any{"type": "kimi", "domain": "kimi.com", "access_token": "old-com", "refresh_token": "old-com"},
+ }
+ kimiComAuth.EnsureIndex()
+ registeredKimiCom, errRegKimiCom := manager.Register(context.Background(), kimiComAuth)
+ if errRegKimiCom != nil {
+ t.Fatalf("register kimi-com: %v", errRegKimiCom)
+ }
+ kimiAIAuth := &coreauth.Auth{
+ ID: "kimi-ai-target.json",
+ FileName: "kimi-ai-target.json",
+ Provider: "kimi-ai",
+ RequiresSignIn: true,
+ Attributes: map[string]string{"domain": "kimi.ai"},
+ Metadata: map[string]any{"type": "kimi-ai", "domain": "kimi.ai", "access_token": "old-ai", "refresh_token": "old-ai"},
+ }
+ kimiAIAuth.EnsureIndex()
+ if _, errRegKimiAI := manager.Register(context.Background(), kimiAIAuth); errRegKimiAI != nil {
+ t.Fatalf("register kimi-ai: %v", errRegKimiAI)
+ }
+
+ h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, manager)
+ router := gin.New()
+ router.POST("/v8/management/oauth/auth-url", h.StartOAuthV8)
+ router.GET("/v8/management/oauth/status", h.GetAuthStatus)
+
+ for _, body := range []string{
+ `{"name":"kimi-com-target.json","domain":"kimi.ai"}`,
+ `{"auth_index":"` + registeredKimiCom.Index + `","domain":"kimi.ai"}`,
+ `{"name":"kimi-ai-target.json","domain":"kimi.com"}`,
+ `{"provider":"kimi-ai","name":"kimi-ai-target.json","domain":"kimi.com"}`,
+ } {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Fatalf("body %s status = %d, want 400: %s", body, w.Code, w.Body.String())
+ }
+ }
+
+ // Commit-time guard also rejects cross-domain Kimi overwrite.
+ crossDomainState := "kimi-cross-domain-commit"
+ RegisterOAuthSession(crossDomainState, "kimi")
+ SetOAuthSessionTarget(crossDomainState, oauthRecoveryTarget{
+ AuthID: "kimi-com-target.json",
+ FileName: "kimi-com-target.json",
+ Provider: "kimi",
+ })
+ t.Cleanup(func() { CompleteOAuthSession(crossDomainState) })
+ if _, ok := h.commitAndCompleteBuiltinOAuthSession(context.Background(), crossDomainState, "kimi-ai", "", &coreauth.Auth{
+ ID: "kimi-ai-fresh.json",
+ FileName: "kimi-ai-fresh.json",
+ Provider: "kimi-ai",
+ Metadata: map[string]any{"type": "kimi-ai", "access_token": "ai-tok"},
+ }, "save failed", false); ok {
+ t.Fatalf("expected commitAndCompleteBuiltinOAuthSession to reject cross-domain kimi-ai record for kimi target")
+ }
+
+ // 2. Claude organization_uuid isolation: same email + account_uuid in a different organization_uuid must be rejected.
+ claudeOrgSvc := &targetedTestClaudeOAuthService{
+ authDir: authDir,
+ email: "a@x.test",
+ accountUUID: "uuid-a",
+ orgUUID: "org-b",
+ accessToken: "claude-org-b-token",
+ }
+ origClaude := newClaudeOAuthService
+ newClaudeOAuthService = func(*config.Config, string) claudeOAuthService { return claudeOrgSvc }
+ t.Cleanup(func() { newClaudeOAuthService = origClaude })
+
+ claudeOrgPath := filepath.Join(authDir, "claude-org-a.json")
+ claudeOrgMeta := map[string]any{
+ "type": "claude",
+ "email": "a@x.test",
+ "account_uuid": "uuid-a",
+ "organization_uuid": "org-a",
+ "access_token": "old-org-a-token",
+ "refresh_token": "old-org-a-refresh",
+ }
+ rawClaudeOrg, _ := json.Marshal(claudeOrgMeta)
+ if errWrite := os.WriteFile(claudeOrgPath, rawClaudeOrg, 0o600); errWrite != nil {
+ t.Fatalf("write claude-org-a.json: %v", errWrite)
+ }
+ if _, errReg := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "claude-org-a.json",
+ FileName: "claude-org-a.json",
+ Provider: "claude",
+ RequiresSignIn: true,
+ Metadata: claudeOrgMeta,
+ Attributes: map[string]string{coreauth.AttributePath: claudeOrgPath},
+ }); errReg != nil {
+ t.Fatalf("register claude-org-a.json: %v", errReg)
+ }
+
+ wOrgMismatch := httptest.NewRecorder()
+ router.ServeHTTP(wOrgMismatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"claude","name":"claude-org-a.json"}`)))
+ var orgMismatchStart struct{ State string }
+ _ = json.Unmarshal(wOrgMismatch.Body.Bytes(), &orgMismatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, orgMismatchStart.State); st["status"] != "error" || st["error"] != "Authenticated account does not match target credential" {
+ t.Fatalf("claude org mismatch status = %#v, want error", st)
+ }
+
+ claudeOrgSvc.orgUUID = "org-a"
+ claudeOrgSvc.accessToken = "claude-org-a-fresh"
+ wOrgMatch := httptest.NewRecorder()
+ router.ServeHTTP(wOrgMatch, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"claude","name":"claude-org-a.json"}`)))
+ var orgMatchStart struct{ State string }
+ _ = json.Unmarshal(wOrgMatch.Body.Bytes(), &orgMatchStart)
+ if st := pollOAuthStatusUntilDone(t, router, orgMatchStart.State); st["status"] != "ok" || st["auth_id"] != "claude-org-a.json" || st["adopted"] != true {
+ t.Fatalf("claude org match status = %#v, want ok & adopted", st)
+ }
+
+ // 3. xAI optional identity fields: subject-only or email-only re-login succeeds and preserves the other field; conflicting field fails.
+ xaiSvc := &targetedTestXAIOAuthService{
+ email: "",
+ subject: "sub-a",
+ accessToken: "xai-sub-only-fresh",
+ }
+ origXAI := newXAIOAuthService
+ newXAIOAuthService = func(*config.Config, string) xaiOAuthService { return xaiSvc }
+ t.Cleanup(func() { newXAIOAuthService = origXAI })
+
+ xaiPath := filepath.Join(authDir, "xai-both.json")
+ xaiMeta := map[string]any{
+ "type": "xai",
+ "email": "a@x.test",
+ "sub": "sub-a",
+ "access_token": "old-xai",
+ "refresh_token": "old-xai-refresh",
+ }
+ rawXAI, _ := json.Marshal(xaiMeta)
+ if errWrite := os.WriteFile(xaiPath, rawXAI, 0o600); errWrite != nil {
+ t.Fatalf("write xai-both.json: %v", errWrite)
+ }
+ if _, errReg := manager.Register(context.Background(), &coreauth.Auth{
+ ID: "xai-both.json",
+ FileName: "xai-both.json",
+ Provider: "xai",
+ RequiresSignIn: true,
+ Metadata: xaiMeta,
+ Attributes: map[string]string{coreauth.AttributePath: xaiPath},
+ }); errReg != nil {
+ t.Fatalf("register xai-both.json: %v", errReg)
+ }
+
+ wXAISubOnly := httptest.NewRecorder()
+ router.ServeHTTP(wXAISubOnly, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"xai","name":"xai-both.json"}`)))
+ var xaiSubOnlyStart struct{ State string }
+ _ = json.Unmarshal(wXAISubOnly.Body.Bytes(), &xaiSubOnlyStart)
+ if st := pollOAuthStatusUntilDone(t, router, xaiSubOnlyStart.State); st["status"] != "ok" || st["auth_id"] != "xai-both.json" || st["adopted"] != true {
+ t.Fatalf("xai sub-only status = %#v, want ok & adopted", st)
+ }
+ savedXAIRaw, errReadXAI := os.ReadFile(xaiPath)
+ if errReadXAI != nil {
+ t.Fatalf("read xai-both.json: %v", errReadXAI)
+ }
+ var savedXAIMap map[string]any
+ _ = json.Unmarshal(savedXAIRaw, &savedXAIMap)
+ if savedXAIMap["email"] != "a@x.test" || savedXAIMap["sub"] != "sub-a" {
+ t.Fatalf("saved xai-both.json after sub-only login = %#v, want preserved email=a@x.test and sub=sub-a", savedXAIMap)
+ }
+
+ // Email-only login also succeeds and preserves sub.
+ xaiSvc.email = "a@x.test"
+ xaiSvc.subject = ""
+ xaiSvc.accessToken = "xai-email-only-fresh"
+ wXAIEmailOnly := httptest.NewRecorder()
+ router.ServeHTTP(wXAIEmailOnly, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"xai","name":"xai-both.json"}`)))
+ var xaiEmailOnlyStart struct{ State string }
+ _ = json.Unmarshal(wXAIEmailOnly.Body.Bytes(), &xaiEmailOnlyStart)
+ if st := pollOAuthStatusUntilDone(t, router, xaiEmailOnlyStart.State); st["status"] != "ok" || st["auth_id"] != "xai-both.json" || st["adopted"] != true {
+ t.Fatalf("xai email-only status = %#v, want ok & adopted", st)
+ }
+ savedXAIRaw2, errReadXAI2 := os.ReadFile(xaiPath)
+ if errReadXAI2 != nil {
+ t.Fatalf("read xai-both.json after email-only: %v", errReadXAI2)
+ }
+ var savedXAIMap2 map[string]any
+ _ = json.Unmarshal(savedXAIRaw2, &savedXAIMap2)
+ if savedXAIMap2["email"] != "a@x.test" || savedXAIMap2["sub"] != "sub-a" {
+ t.Fatalf("saved xai-both.json after email-only login = %#v, want preserved email=a@x.test and sub=sub-a", savedXAIMap2)
+ }
+
+ // Conflicting email with matching sub must be rejected.
+ xaiSvc.email = "conflict@x.test"
+ xaiSvc.subject = "sub-a"
+ wXAIConflict := httptest.NewRecorder()
+ router.ServeHTTP(wXAIConflict, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"xai","name":"xai-both.json"}`)))
+ var xaiConflictStart struct{ State string }
+ _ = json.Unmarshal(wXAIConflict.Body.Bytes(), &xaiConflictStart)
+ if st := pollOAuthStatusUntilDone(t, router, xaiConflictStart.State); st["status"] != "error" {
+ t.Fatalf("xai conflicting email status = %#v, want error", st)
+ }
+
+ // 4. Targeted completion with verify=true runs VerifyInference and reports verification fields.
+ agPath := filepath.Join(authDir, "ag-verify.json")
+ agMeta := map[string]any{
+ "type": "antigravity",
+ "email": "verify@x.test",
+ "access_token": "old-ag",
+ "refresh_token": "old-ag-refresh",
+ }
+ rawAG, _ := json.Marshal(agMeta)
+ if errWrite := os.WriteFile(agPath, rawAG, 0o600); errWrite != nil {
+ t.Fatalf("write ag-verify.json: %v", errWrite)
+ }
+ agExec := &recordingRefreshVerifyExecutor{}
+ managerWithVerify := coreauth.NewManager(nil, nil, nil)
+ managerWithVerify.RegisterExecutor(agExec)
+ if _, errReg := managerWithVerify.Register(context.Background(), &coreauth.Auth{
+ ID: "ag-verify.json",
+ FileName: "ag-verify.json",
+ Provider: "antigravity",
+ RequiresSignIn: true,
+ Metadata: agMeta,
+ Attributes: map[string]string{coreauth.AttributePath: agPath},
+ }); errReg != nil {
+ t.Fatalf("register ag-verify.json: %v", errReg)
+ }
+
+ agSvc := &targetedTestAntigravityOAuthService{
+ email: "verify@x.test",
+ accessToken: "ag-verified-access",
+ }
+ origAG := newAntigravityOAuthService
+ newAntigravityOAuthService = func(*config.Config) antigravityOAuthService { return agSvc }
+ t.Cleanup(func() { newAntigravityOAuthService = origAG })
+
+ hVerify := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir, Port: 8317}, managerWithVerify)
+ routerVerify := gin.New()
+ routerVerify.POST("/v8/management/oauth/auth-url", hVerify.StartOAuthV8)
+ routerVerify.GET("/v8/management/oauth/status", hVerify.GetAuthStatus)
+
+ wVerifyStart := httptest.NewRecorder()
+ routerVerify.ServeHTTP(wVerifyStart, httptest.NewRequest(http.MethodPost, "/v8/management/oauth/auth-url", strings.NewReader(`{"provider":"antigravity","name":"ag-verify.json","verify":true,"verify_model":"gemini-2.5-flash"}`)))
+ if wVerifyStart.Code != http.StatusOK {
+ t.Fatalf("antigravity verify StartOAuthV8 status = %d: %s", wVerifyStart.Code, wVerifyStart.Body.String())
+ }
+ var verifyStartBody struct{ State string }
+ _ = json.Unmarshal(wVerifyStart.Body.Bytes(), &verifyStartBody)
+ _, _ = WriteOAuthCallbackFileForPendingSession(authDir, "antigravity", verifyStartBody.State, "ag-code-verify", "")
+ verifyStatus := pollOAuthStatusUntilDone(t, routerVerify, verifyStartBody.State)
+ if verifyStatus["status"] != "ok" || verifyStatus["inference_verified"] != true || verifyStatus["recovery_stage"] != "inference_verified" {
+ t.Fatalf("antigravity verify status = %#v, want ok with inference_verified=true", verifyStatus)
+ }
+ if agExec.calls == 0 || agExec.lastModel != "gemini-2.5-flash" {
+ t.Fatalf("expected executor VerifyInference call with model gemini-2.5-flash, got calls=%d model=%q", agExec.calls, agExec.lastModel)
+ }
+}
+
+type recordingRefreshVerifyExecutor struct {
+ calls int
+ lastModel string
+ err error
+}
+
+func (e *recordingRefreshVerifyExecutor) Identifier() string { return "antigravity" }
+func (e *recordingRefreshVerifyExecutor) Execute(_ context.Context, _ *coreauth.Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ e.calls++
+ e.lastModel = req.Model
+ if e.err != nil {
+ return cliproxyexecutor.Response{}, e.err
+ }
+ return cliproxyexecutor.Response{Payload: []byte(`{"id":"resp_ok"}`)}, nil
+}
+func (e *recordingRefreshVerifyExecutor) ExecuteStream(_ context.Context, _ *coreauth.Auth, req cliproxyexecutor.Request, _ cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ e.calls++
+ e.lastModel = req.Model
+ if e.err != nil {
+ return nil, e.err
+ }
+ ch := make(chan cliproxyexecutor.StreamChunk, 1)
+ ch <- cliproxyexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.completed\"}\n\n")}
+ close(ch)
+ return &cliproxyexecutor.StreamResult{Chunks: ch}, nil
+}
+func (e *recordingRefreshVerifyExecutor) Refresh(_ context.Context, auth *coreauth.Auth) (*coreauth.Auth, error) {
+ return auth, nil
+}
+func (e *recordingRefreshVerifyExecutor) CountTokens(context.Context, *coreauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, nil
+}
+func (e *recordingRefreshVerifyExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
+ return nil, nil
+}
+func (e *recordingRefreshVerifyExecutor) VerifyInference(_ context.Context, _ *coreauth.Auth, model string) error {
+ e.calls++
+ e.lastModel = model
+ return e.err
+}
diff --git a/internal/api/handlers/management/auth_files_v8.go b/internal/api/handlers/management/auth_files_v8.go
index 365523c0655..721d1d9a53a 100644
--- a/internal/api/handlers/management/auth_files_v8.go
+++ b/internal/api/handlers/management/auth_files_v8.go
@@ -6,9 +6,12 @@ import (
"fmt"
"io"
"net/http"
+ "os"
+ "path/filepath"
"strings"
"github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
@@ -316,29 +319,80 @@ func (h *Handler) StartOAuthV8(c *gin.Context) {
}
}
+ reqDomain := firstNonEmpty(strings.TrimSpace(c.Query("domain")), strings.TrimSpace(c.Query("channel")), oauthSessionDomainFromContext(c))
+ if provider != "" {
+ normalizedProvider, okProvider := normalizeOAuthProviderWithDomain(provider, reqDomain)
+ if !okProvider {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid domain"})
+ return
+ }
+ provider = normalizedProvider
+ }
+
if targetName != "" || targetAuthIndex != "" {
targetAuth, okTarget := h.lookupAuthFile(targetName, targetAuthIndex)
if !okTarget || targetAuth == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
return
}
- if provider != "" && !strings.EqualFold(strings.TrimSpace(targetAuth.Provider), strings.TrimSpace(provider)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "provider does not match target credential"})
- return
+ targetPath := ""
+ if targetAuth.Attributes != nil {
+ targetPath = strings.TrimSpace(targetAuth.Attributes[coreauth.AttributePath])
+ }
+ targetFileName := firstNonEmpty(strings.TrimSpace(targetAuth.FileName), targetAuth.ID)
+ effectiveTargetAuth := targetAuth
+ diskPath := targetPath
+ if diskPath == "" && targetFileName != "" {
+ if cfg := h.configSnapshot(); cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" {
+ diskPath = filepath.Join(cfg.AuthDir, targetFileName)
+ }
+ }
+ if diskPath != "" {
+ if raw, errRead := os.ReadFile(diskPath); errRead == nil && len(raw) > 0 {
+ var diskMeta map[string]any
+ if json.Unmarshal(raw, &diskMeta) == nil && len(diskMeta) > 0 {
+ mergedMeta := make(map[string]any, len(diskMeta)+len(targetAuth.Metadata))
+ for k, v := range diskMeta {
+ mergedMeta[k] = v
+ }
+ for k, v := range targetAuth.Metadata {
+ mergedMeta[k] = v
+ }
+ cloned := *targetAuth
+ cloned.Metadata = mergedMeta
+ effectiveTargetAuth = &cloned
+ }
+ }
}
if provider == "" {
- provider = strings.TrimSpace(targetAuth.Provider)
+ provider = h.resolveTargetOAuthProvider(effectiveTargetAuth)
+ if reqDomain != "" {
+ normalizedProvider, okProvider := normalizeOAuthProviderWithDomain(provider, reqDomain)
+ if !okProvider {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "provider does not match target credential"})
+ return
+ }
+ provider = normalizedProvider
+ }
}
- targetAccountID := ""
- if targetAuth.Metadata != nil {
- targetAccountID, _ = targetAuth.Metadata["account_id"].(string)
+ if provider != "" && !h.oauthTargetAuthMatches(effectiveTargetAuth, provider) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "provider does not match target credential"})
+ return
+ }
+ targetEmail, targetAccountID := authIdentityEmailAndAccountID(effectiveTargetAuth)
+ targetOrgID := authIdentityOrganizationID(effectiveTargetAuth)
+ targetProvider := h.resolveTargetOAuthProvider(effectiveTargetAuth)
+ if targetProvider == "" {
+ targetProvider = strings.TrimSpace(effectiveTargetAuth.Provider)
}
c.Set(oauthSessionTargetContextKey, oauthRecoveryTarget{
AuthID: targetAuth.ID,
- FileName: firstNonEmpty(strings.TrimSpace(targetAuth.FileName), targetAuth.ID),
- Provider: strings.TrimSpace(targetAuth.Provider),
- Email: authEmail(targetAuth),
- AccountID: strings.TrimSpace(targetAccountID),
+ FileName: targetFileName,
+ Path: targetPath,
+ Provider: targetProvider,
+ Email: targetEmail,
+ AccountID: targetAccountID,
+ OrganizationID: targetOrgID,
VerifyAfterLogin: verifyAfterLogin,
VerifyModel: verifyModel,
})
@@ -372,6 +426,107 @@ func (h *Handler) StartOAuthV8(c *gin.Context) {
}
}
+func normalizeOAuthProviderWithDomain(provider, reqDomain string) (string, bool) {
+ provider = strings.TrimSpace(provider)
+ reqDomain = strings.TrimSpace(reqDomain)
+ if reqDomain == "" {
+ return provider, true
+ }
+ if strings.EqualFold(provider, "kimi") {
+ if kimi.IsKimiAIDomain(reqDomain) {
+ return "kimi-ai", true
+ }
+ if kimi.IsKimiComDomain(reqDomain) {
+ return "kimi", true
+ }
+ return "", false
+ }
+ if strings.EqualFold(provider, "kimi-ai") {
+ if kimi.IsKimiAIDomain(reqDomain) {
+ return "kimi-ai", true
+ }
+ return "", false
+ }
+ return provider, true
+}
+
+func (h *Handler) resolveTargetOAuthProvider(targetAuth *coreauth.Auth) string {
+ if targetAuth == nil {
+ return ""
+ }
+ if h != nil {
+ h.mu.Lock()
+ host := h.pluginHost
+ h.mu.Unlock()
+ if host != nil {
+ for _, key := range []string{"oauth_provider", "portal"} {
+ if targetAuth.Attributes != nil {
+ if candidate := strings.TrimSpace(targetAuth.Attributes[key]); candidate != "" && host.HasAuthProvider(candidate) {
+ return candidate
+ }
+ }
+ if targetAuth.Metadata != nil {
+ if raw, ok := targetAuth.Metadata[key].(string); ok {
+ if candidate := strings.TrimSpace(raw); candidate != "" && host.HasAuthProvider(candidate) {
+ return candidate
+ }
+ }
+ }
+ }
+ }
+ }
+ rawProvider := strings.TrimSpace(targetAuth.Provider)
+ switch strings.ToLower(rawProvider) {
+ case "kimi", "kimi-ai", "kimi.ai", "kimi.com":
+ if kimi.IsKimiAIAuth(targetAuth) {
+ return "kimi-ai"
+ }
+ return "kimi"
+ default:
+ return rawProvider
+ }
+}
+
+func (h *Handler) oauthTargetAuthMatches(targetAuth *coreauth.Auth, requestedProvider string) bool {
+ if targetAuth == nil {
+ return false
+ }
+ resolvedTargetProvider := h.resolveTargetOAuthProvider(targetAuth)
+ if h.oauthTargetProviderMatches(resolvedTargetProvider, requestedProvider) {
+ return true
+ }
+ for _, key := range []string{"oauth_provider", "portal"} {
+ if targetAuth.Attributes != nil {
+ if candidate := strings.TrimSpace(targetAuth.Attributes[key]); candidate != "" && h.oauthTargetProviderMatches(candidate, requestedProvider) {
+ return true
+ }
+ }
+ if targetAuth.Metadata != nil {
+ if raw, ok := targetAuth.Metadata[key].(string); ok {
+ if candidate := strings.TrimSpace(raw); candidate != "" && h.oauthTargetProviderMatches(candidate, requestedProvider) {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func (h *Handler) oauthTargetProviderMatches(targetProvider, requestedProvider string) bool {
+ targetProvider = strings.TrimSpace(targetProvider)
+ requestedProvider = strings.TrimSpace(requestedProvider)
+ if strings.EqualFold(targetProvider, requestedProvider) {
+ return true
+ }
+ if h == nil {
+ return false
+ }
+ h.mu.Lock()
+ host := h.pluginHost
+ h.mu.Unlock()
+ return host != nil && host.SameAuthProvider(targetProvider, requestedProvider)
+}
+
// ImportOAuthV8 dispatches credential import using the provider query parameter.
func (h *Handler) ImportOAuthV8(c *gin.Context) {
switch strings.ToLower(strings.TrimSpace(c.Query("provider"))) {
diff --git a/internal/api/handlers/management/config_apikey_disable.go b/internal/api/handlers/management/config_apikey_disable.go
index 9344e716d06..baebb1a505e 100644
--- a/internal/api/handlers/management/config_apikey_disable.go
+++ b/internal/api/handlers/management/config_apikey_disable.go
@@ -50,7 +50,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
if key == "" && base == "" {
continue
}
- id, _ := idGen.Next("gemini:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("gemini:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
@@ -65,7 +69,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
if key == "" && base == "" {
continue
}
- id, _ := idGen.Next("gemini-interactions:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("gemini-interactions:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
@@ -80,7 +88,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
if key == "" && base == "" {
continue
}
- id, _ := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("claude:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
@@ -95,7 +107,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
if key == "" && base == "" {
continue
}
- id, _ := idGen.Next("codex:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("codex:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
@@ -110,7 +126,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
if key == "" && base == "" {
continue
}
- id, _ := idGen.Next("xai:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("xai:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
@@ -125,7 +145,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
if key == "" && base == "" {
continue
}
- id, _ := idGen.Next("meta:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("meta:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
@@ -136,7 +160,11 @@ func toggleConfigAPIKeyExcludedAll(cfg *config.Config, auth *coreauth.Auth, disa
key := strings.TrimSpace(entry.APIKey)
base := strings.TrimSpace(entry.BaseURL)
proxy := strings.TrimSpace(entry.ProxyURL)
- id, _ := idGen.Next("vertex:apikey", key, base, proxy)
+ idParts := []string{key, base, proxy}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("vertex:apikey", idParts...)
if id == authID {
entry.ExcludedModels = setConfigAPIKeyExcludedAll(entry.ExcludedModels, disable)
return true, nil
diff --git a/internal/api/handlers/management/config_auth_index.go b/internal/api/handlers/management/config_auth_index.go
index 9d38363e25b..8f393a3a831 100644
--- a/internal/api/handlers/management/config_auth_index.go
+++ b/internal/api/handlers/management/config_auth_index.go
@@ -264,6 +264,63 @@ func resolveInheritedHeaders(keyNode, groupNode *yaml.Node, field string) map[st
return nil
}
+func yamlMapNodePresent(node *yaml.Node, key string) *yaml.Node {
+ if node == nil || node.Kind != yaml.MappingNode {
+ return nil
+ }
+ for i := 0; i+1 < len(node.Content); i += 2 {
+ if node.Content[i].Value == key {
+ valNode := node.Content[i+1]
+ if valNode == nil || valNode.Tag == "!!null" {
+ return nil
+ }
+ return valNode
+ }
+ }
+ return nil
+}
+
+func resolveInheritedConnectionPool(keyNode, groupNode *yaml.Node) *config.ProxyConnectionPoolConfig {
+ groupPoolNode := yamlMapNodePresent(groupNode, "connection-pool")
+ keyPoolNode := yamlMapNodePresent(keyNode, "connection-pool")
+ if groupPoolNode == nil && keyPoolNode == nil {
+ return nil
+ }
+ mergedNode := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
+ if groupPoolNode != nil && groupPoolNode.Kind == yaml.MappingNode {
+ for i := 0; i+1 < len(groupPoolNode.Content); i += 2 {
+ if groupPoolNode.Content[i+1].Tag == "!!null" {
+ continue
+ }
+ mergedNode.Content = append(mergedNode.Content, groupPoolNode.Content[i], groupPoolNode.Content[i+1])
+ }
+ }
+ if keyPoolNode != nil && keyPoolNode.Kind == yaml.MappingNode {
+ for i := 0; i+1 < len(keyPoolNode.Content); i += 2 {
+ if keyPoolNode.Content[i+1].Tag == "!!null" {
+ continue
+ }
+ field := keyPoolNode.Content[i].Value
+ replaced := false
+ for j := 0; j+1 < len(mergedNode.Content); j += 2 {
+ if mergedNode.Content[j].Value == field {
+ mergedNode.Content[j+1] = keyPoolNode.Content[i+1]
+ replaced = true
+ break
+ }
+ }
+ if !replaced {
+ mergedNode.Content = append(mergedNode.Content, keyPoolNode.Content[i], keyPoolNode.Content[i+1])
+ }
+ }
+ }
+ var pool config.ProxyConnectionPoolConfig
+ if errDecode := mergedNode.Decode(&pool); errDecode != nil {
+ return nil
+ }
+ return &pool
+}
+
func normalizeModelPrefixHelper(prefix string) string {
trimmed := strings.TrimSpace(prefix)
trimmed = strings.Trim(trimmed, "/")
@@ -273,7 +330,7 @@ func normalizeModelPrefixHelper(prefix string) string {
return trimmed
}
-func formatCredentialDedupKey(key, base, proxyURL, prefix string, headers map[string]string) string {
+func formatCredentialDedupKey(key, base, proxyURL, prefix string, headers map[string]string, pool *config.ProxyConnectionPoolConfig) string {
var b strings.Builder
b.WriteString(strings.TrimSpace(key))
b.WriteByte(0)
@@ -284,6 +341,8 @@ func formatCredentialDedupKey(key, base, proxyURL, prefix string, headers map[st
b.WriteString(normalizeModelPrefixHelper(prefix))
b.WriteByte(0)
b.WriteString(config.FormatSortedHeaders(config.NormalizeHeaders(headers)))
+ b.WriteByte(0)
+ b.WriteString(config.FormatConnectionPoolDedupID(pool))
return b.String()
}
@@ -347,7 +406,7 @@ func (h *Handler) injectV8APIKeyAuthIndexesLocked(root *yaml.Node, data []byte)
continue
}
e := cfg.GeminiKey[idx]
- key := formatCredentialDedupKey(e.APIKey, e.BaseURL, e.ProxyURL, e.Prefix, e.Headers)
+ key := formatCredentialDedupKey(e.APIKey, e.BaseURL, e.ProxyURL, e.Prefix, e.Headers, e.ConnectionPool)
providerAuthMap["gemini"][key] = resolveAuthIndex(a)
}
for _, a := range authsByProvider["gemini-interactions"] {
@@ -359,7 +418,7 @@ func (h *Handler) injectV8APIKeyAuthIndexesLocked(root *yaml.Node, data []byte)
continue
}
e := cfg.InteractionsKey[idx]
- key := formatCredentialDedupKey(e.APIKey, e.BaseURL, e.ProxyURL, e.Prefix, e.Headers)
+ key := formatCredentialDedupKey(e.APIKey, e.BaseURL, e.ProxyURL, e.Prefix, e.Headers, e.ConnectionPool)
providerAuthMap["interactions"][key] = resolveAuthIndex(a)
}
for _, a := range authsByProvider["vertex"] {
@@ -371,7 +430,7 @@ func (h *Handler) injectV8APIKeyAuthIndexesLocked(root *yaml.Node, data []byte)
continue
}
e := cfg.VertexCompatAPIKey[idx]
- key := strings.TrimSpace(e.APIKey) + "|" + strings.TrimSpace(e.BaseURL)
+ key := strings.TrimSpace(e.APIKey) + "|" + strings.TrimSpace(e.BaseURL) + "\x00" + config.FormatConnectionPoolDedupID(e.ConnectionPool)
providerAuthMap["vertex"][key] = resolveAuthIndex(a)
}
@@ -554,8 +613,10 @@ func (h *Handler) injectV8APIKeyAuthIndexesLocked(root *yaml.Node, data []byte)
continue
}
apiKey := strings.TrimSpace(yamlMapScalar(keyNode, "api-key"))
+ pool := resolveInheritedConnectionPool(keyNode, group)
if providerName == "vertex" {
- if idx, ok := providerAuthMap["vertex"][apiKey+"|"+groupBase]; ok {
+ vertexKey := apiKey + "|" + groupBase + "\x00" + config.FormatConnectionPoolDedupID(pool)
+ if idx, ok := providerAuthMap["vertex"][vertexKey]; ok {
setMapScalar(keyNode, "auth_index", idx)
}
continue
@@ -564,7 +625,7 @@ func (h *Handler) injectV8APIKeyAuthIndexesLocked(root *yaml.Node, data []byte)
proxyURL := resolveInheritedScalar(keyNode, group, "proxy-url")
prefix := resolveInheritedScalar(keyNode, group, "prefix")
headers := resolveInheritedHeaders(keyNode, group, "headers")
- dedupKey := formatCredentialDedupKey(apiKey, groupBase, proxyURL, prefix, headers)
+ dedupKey := formatCredentialDedupKey(apiKey, groupBase, proxyURL, prefix, headers, pool)
if idx, ok := providerAuthMap[providerName][dedupKey]; ok {
setMapScalar(keyNode, "auth_index", idx)
}
@@ -595,7 +656,11 @@ func (h *Handler) geminiKeysWithAuthIndex() []geminiKeyWithAuthIndex {
proxyURL := strings.TrimSpace(entry.ProxyURL)
prefix := strings.TrimSpace(entry.Prefix)
if key != "" || base != "" {
- id, _ := idGen.Next("gemini:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("gemini:apikey", idParts...)
authIndex = liveIndexByID[id]
}
out[i] = geminiKeyWithAuthIndex{
@@ -628,7 +693,11 @@ func (h *Handler) interactionsKeysWithAuthIndex() []geminiKeyWithAuthIndex {
proxyURL := strings.TrimSpace(entry.ProxyURL)
prefix := strings.TrimSpace(entry.Prefix)
if key != "" || base != "" {
- id, _ := idGen.Next("gemini-interactions:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("gemini-interactions:apikey", idParts...)
authIndex = liveIndexByID[id]
}
out[i] = geminiKeyWithAuthIndex{
@@ -661,7 +730,11 @@ func (h *Handler) claudeKeysWithAuthIndex() []claudeKeyWithAuthIndex {
proxyURL := strings.TrimSpace(entry.ProxyURL)
prefix := strings.TrimSpace(entry.Prefix)
if key != "" || base != "" {
- id, _ := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("claude:apikey", idParts...)
authIndex = liveIndexByID[id]
}
out[i] = claudeKeyWithAuthIndex{
@@ -694,7 +767,11 @@ func (h *Handler) codexKeysWithAuthIndex() []codexKeyWithAuthIndex {
proxyURL := strings.TrimSpace(entry.ProxyURL)
prefix := strings.TrimSpace(entry.Prefix)
if key != "" || base != "" {
- id, _ := idGen.Next("codex:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("codex:apikey", idParts...)
authIndex = liveIndexByID[id]
}
out[i] = codexKeyWithAuthIndex{
@@ -727,7 +804,11 @@ func (h *Handler) xaiKeysWithAuthIndex() []xaiKeyWithAuthIndex {
proxyURL := strings.TrimSpace(entry.ProxyURL)
prefix := strings.TrimSpace(entry.Prefix)
if key != "" || base != "" {
- id, _ := idGen.Next("xai:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("xai:apikey", idParts...)
authIndex = liveIndexByID[id]
}
out[i] = xaiKeyWithAuthIndex{
@@ -760,7 +841,11 @@ func (h *Handler) metaKeysWithAuthIndex() []metaKeyWithAuthIndex {
proxyURL := strings.TrimSpace(entry.ProxyURL)
prefix := strings.TrimSpace(entry.Prefix)
if key != "" || base != "" {
- id, _ := idGen.Next("meta:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("meta:apikey", idParts...)
authIndex = liveIndexByID[id]
}
out[i] = metaKeyWithAuthIndex{
@@ -787,7 +872,11 @@ func (h *Handler) vertexCompatKeysWithAuthIndex() []vertexCompatKeyWithAuthIndex
out := make([]vertexCompatKeyWithAuthIndex, len(h.cfg.VertexCompatAPIKey))
for i := range h.cfg.VertexCompatAPIKey {
entry := h.cfg.VertexCompatAPIKey[i]
- id, _ := idGen.Next("vertex:apikey", entry.APIKey, entry.BaseURL, entry.ProxyURL)
+ idParts := []string{entry.APIKey, entry.BaseURL, entry.ProxyURL}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next("vertex:apikey", idParts...)
authIndex := liveIndexByID[id]
out[i] = vertexCompatKeyWithAuthIndex{
VertexCompatKey: entry,
@@ -835,13 +924,21 @@ func (h *Handler) openAICompatibilityWithAuthIndex() []openAICompatibilityWithAu
AuthIndex: "",
}
if len(entry.APIKeyEntries) == 0 {
- id, _ := idGen.Next(idKind, entry.BaseURL)
+ idParts := []string{entry.BaseURL}
+ if poolID := config.FormatConnectionPoolDedupID(synthesizer.OpenAICompatConnectionPool(&entry, nil)); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next(idKind, idParts...)
response.AuthIndex = liveIndexByID[id]
} else {
response.APIKeyEntries = make([]openAICompatibilityAPIKeyWithAuthIndex, len(entry.APIKeyEntries))
for j := range entry.APIKeyEntries {
apiKeyEntry := entry.APIKeyEntries[j]
- id, _ := idGen.Next(idKind, apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL)
+ idParts := []string{apiKeyEntry.APIKey, entry.BaseURL, apiKeyEntry.ProxyURL}
+ if poolID := config.FormatConnectionPoolDedupID(synthesizer.OpenAICompatConnectionPool(&entry, &apiKeyEntry)); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, _ := idGen.Next(idKind, idParts...)
response.APIKeyEntries[j] = openAICompatibilityAPIKeyWithAuthIndex{
OpenAICompatibilityAPIKey: apiKeyEntry,
AuthIndex: liveIndexByID[id],
diff --git a/internal/api/handlers/management/config_v8_test.go b/internal/api/handlers/management/config_v8_test.go
index a9d9eb97942..95741ac20b0 100644
--- a/internal/api/handlers/management/config_v8_test.go
+++ b/internal/api/handlers/management/config_v8_test.go
@@ -13,6 +13,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"gopkg.in/yaml.v3"
)
@@ -892,3 +894,84 @@ api-keys:
t.Fatalf("unexpected loaded headers: %+v", loaded.OpenAICompatibility[0].Headers)
}
}
+
+func TestConfigV8InjectsDistinctAuthIndexesForDistinctConnectionPools(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ file := filepath.Join(t.TempDir(), "config.yaml")
+ raw := `config-version: 8
+api-keys:
+ gemini:
+ - name: gemini-group
+ base-url: "https://example.invalid"
+ connection-pool:
+ enabled: true
+ idle-conn-timeout: "120s"
+ max-idle-conns-per-host: 2
+ keys:
+ - api-key: "shared-gemini-key"
+ - api-key: "shared-gemini-key"
+ connection-pool:
+ idle-conn-timeout: ""
+ max-idle-conns-per-host: 5
+ vertex:
+ - name: vertex-group
+ base-url: "https://vertex.example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 2
+ keys:
+ - api-key: "shared-vertex-key"
+ - api-key: "shared-vertex-key"
+ connection-pool:
+ max-idle-conns-per-host: 6
+`
+ if errWrite := os.WriteFile(file, []byte(raw), 0o600); errWrite != nil {
+ t.Fatal(errWrite)
+ }
+ cfg, errLoad := config.LoadConfig(file)
+ if errLoad != nil {
+ t.Fatal(errLoad)
+ }
+ manager := coreauth.NewManager(nil, nil, nil)
+ synth := synthesizer.NewConfigSynthesizer()
+ auths, errSynth := synth.Synthesize(&synthesizer.SynthesisContext{
+ Config: cfg,
+ Now: time.Now(),
+ IDGenerator: synthesizer.NewStableIDGenerator(),
+ })
+ if errSynth != nil {
+ t.Fatal(errSynth)
+ }
+ for _, a := range auths {
+ if _, errReg := manager.Register(context.Background(), a); errReg != nil {
+ t.Fatal(errReg)
+ }
+ }
+ h := &Handler{cfg: cfg, configFilePath: file, authManager: manager}
+ router := gin.New()
+ router.GET("/v8/management/config/*path", h.ConfigV8)
+
+ for _, provider := range []string{"gemini", "vertex"} {
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v8/management/config/api-keys/"+provider, nil))
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET %s status=%d body=%s", provider, w.Code, w.Body.String())
+ }
+ var groups []struct {
+ Keys []struct {
+ AuthIndex string `yaml:"auth_index" json:"auth_index"`
+ } `yaml:"keys" json:"keys"`
+ }
+ if err := yaml.Unmarshal(w.Body.Bytes(), &groups); err != nil {
+ t.Fatalf("unmarshal %s response: %v", provider, err)
+ }
+ if len(groups) != 1 || len(groups[0].Keys) != 2 {
+ t.Fatalf("%s groups = %+v, want 1 group with 2 keys", provider, groups)
+ }
+ first := groups[0].Keys[0].AuthIndex
+ second := groups[0].Keys[1].AuthIndex
+ if first == "" || second == "" || first == second {
+ t.Fatalf("%s auth_index values = (%q, %q), want two distinct non-empty indexes", provider, first, second)
+ }
+ }
+}
diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go
index 529c3a8ba82..7e8db2f76e6 100644
--- a/internal/api/handlers/management/handler.go
+++ b/internal/api/handlers/management/handler.go
@@ -63,6 +63,7 @@ type Handler struct {
pluginReleases pluginReleaseCache
siwcOAuthFactory func(*config.Config) siwcOAuthService
siwcCommitMu sync.Mutex
+ pluginLoginSaveMu sync.Mutex
}
type configReloadSnapshot struct {
diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go
index c8a326440e9..d9e3e7e2c66 100644
--- a/internal/api/handlers/management/logs.go
+++ b/internal/api/handlers/management/logs.go
@@ -4,10 +4,10 @@ import (
"bufio"
"bytes"
"crypto/sha256"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"math"
"net/http"
diff --git a/internal/api/handlers/management/logs_test.go b/internal/api/handlers/management/logs_test.go
index 8fe6bc4ca19..0201aee955d 100644
--- a/internal/api/handlers/management/logs_test.go
+++ b/internal/api/handlers/management/logs_test.go
@@ -1,8 +1,8 @@
package management
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"net/http"
"net/http/httptest"
"net/url"
diff --git a/internal/api/handlers/management/oauth_codex_concurrency_test.go b/internal/api/handlers/management/oauth_codex_concurrency_test.go
index 2461d77a035..daa19fac6ff 100644
--- a/internal/api/handlers/management/oauth_codex_concurrency_test.go
+++ b/internal/api/handlers/management/oauth_codex_concurrency_test.go
@@ -2,8 +2,8 @@ package management
import (
"context"
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"net/http"
"net/http/httptest"
"os"
diff --git a/internal/api/handlers/management/oauth_sessions.go b/internal/api/handlers/management/oauth_sessions.go
index 62559a1dc83..28ff2375aa3 100644
--- a/internal/api/handlers/management/oauth_sessions.go
+++ b/internal/api/handlers/management/oauth_sessions.go
@@ -40,9 +40,11 @@ var ErrSIWCCallbackRequiresV8 = fmt.Errorf("%w: SIWC callbacks require /v8/manag
type oauthRecoveryTarget struct {
AuthID string
FileName string
+ Path string
Provider string
Email string
AccountID string
+ OrganizationID string
VerifyAfterLogin bool
VerifyModel string
}
diff --git a/internal/api/handlers/management/plugin_quota.go b/internal/api/handlers/management/plugin_quota.go
index f8a6cdb69ee..e3ee737dc65 100644
--- a/internal/api/handlers/management/plugin_quota.go
+++ b/internal/api/handlers/management/plugin_quota.go
@@ -10,12 +10,13 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
xcurrency "golang.org/x/text/currency"
)
@@ -500,7 +501,7 @@ func (h *Handler) executeQuotaProbe(c *gin.Context, auth *coreauth.Auth, probe m
hasPlan := quotaResp.Subscription != nil && strings.TrimSpace(quotaResp.Subscription.Plan) != ""
// Filter groups to retain only buckets with an actual valid numeric remaining fraction in raw JSON
filteredGroups := make([]pluginapi.QuotaGroup, 0, len(quotaResp.Groups))
- groupsRes := gjson.GetBytes(respBytes, "groups")
+ groupsRes := ojson.GetBytesNoCopy(respBytes, "groups")
if groupsRes.IsArray() {
for gIdx, grp := range groupsRes.Array() {
if gIdx >= len(quotaResp.Groups) {
@@ -563,7 +564,7 @@ func filterUsableQuotaSummary(raw []byte) []pluginapi.QuotaMetric {
}
}
}
- summaryResult := gjson.ParseBytes(rawSummary)
+ summaryResult := util.ParseGJSONBytesNoCopy(rawSummary)
if !summaryResult.IsArray() {
return nil
}
@@ -574,28 +575,28 @@ func filterUsableQuotaSummary(raw []byte) []pluginapi.QuotaMetric {
key := strings.TrimSpace(keyResult.String())
label := strings.TrimSpace(labelResult.String())
value := rawMetric.Get("value")
- if keyResult.Type != gjson.String || labelResult.Type != gjson.String || key == "" || label == "" || value.Type != gjson.Number || math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
+ if keyResult.Type != ojson.String || labelResult.Type != ojson.String || key == "" || label == "" || value.Type != ojson.Number || math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
continue
}
metric := pluginapi.QuotaMetric{
- Key: key,
- Label: label,
+ Key: strings.Clone(key),
+ Label: strings.Clone(label),
Value: value.Float(),
}
- if unitResult := rawMetric.Get("unit"); unitResult.Type == gjson.String {
- metric.Unit = strings.TrimSpace(unitResult.String())
+ if unitResult := rawMetric.Get("unit"); unitResult.Type == ojson.String {
+ metric.Unit = strings.Clone(strings.TrimSpace(unitResult.String()))
}
- if formatResult := rawMetric.Get("format"); formatResult.Type == gjson.String {
+ if formatResult := rawMetric.Get("format"); formatResult.Type == ojson.String {
format := strings.TrimSpace(formatResult.String())
switch format {
case "number":
- metric.Format = format
+ metric.Format = strings.Clone(format)
case "currency":
- if currencyResult := rawMetric.Get("currency"); currencyResult.Type == gjson.String {
+ if currencyResult := rawMetric.Get("currency"); currencyResult.Type == ojson.String {
code := strings.ToUpper(strings.TrimSpace(currencyResult.String()))
if _, err := xcurrency.ParseISO(code); err == nil {
- metric.Format = format
- metric.Currency = code
+ metric.Format = strings.Clone(format)
+ metric.Currency = strings.Clone(code)
}
}
}
@@ -605,18 +606,18 @@ func filterUsableQuotaSummary(raw []byte) []pluginapi.QuotaMetric {
return usable
}
-func parseNumericFraction(res gjson.Result) (float64, bool) {
+func parseNumericFraction(res ojson.Result) (float64, bool) {
if !res.Exists() {
return 0, false
}
switch res.Type {
- case gjson.Number:
+ case ojson.Number:
val := res.Float()
if math.IsNaN(val) || math.IsInf(val, 0) {
return 0, false
}
return val, true
- case gjson.String:
+ case ojson.String:
s := strings.TrimSpace(res.String())
if s == "" {
return 0, false
@@ -634,41 +635,41 @@ func parseNumericFraction(res gjson.Result) (float64, bool) {
func mapProbeResponse(respBytes []byte, mapping map[string]any) (pluginapi.QuotaFetchResponse, error) {
out := pluginapi.QuotaFetchResponse{}
if planPath, ok := mapping["plan"].(string); ok && planPath != "" {
- if res := gjson.GetBytes(respBytes, planPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ if res := ojson.GetBytesNoCopy(respBytes, planPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
if out.Subscription == nil {
out.Subscription = &pluginapi.QuotaSubscription{}
}
- out.Subscription.Plan = res.String()
+ out.Subscription.Plan = strings.Clone(res.String())
}
}
if tierPath, ok := mapping["tier_name"].(string); ok && tierPath != "" {
- if res := gjson.GetBytes(respBytes, tierPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ if res := ojson.GetBytesNoCopy(respBytes, tierPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
if out.Subscription == nil {
out.Subscription = &pluginapi.QuotaSubscription{}
}
- out.Subscription.TierName = res.String()
+ out.Subscription.TierName = strings.Clone(res.String())
}
} else if tierPath, ok := mapping["tierName"].(string); ok && tierPath != "" {
- if res := gjson.GetBytes(respBytes, tierPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ if res := ojson.GetBytesNoCopy(respBytes, tierPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
if out.Subscription == nil {
out.Subscription = &pluginapi.QuotaSubscription{}
}
- out.Subscription.TierName = res.String()
+ out.Subscription.TierName = strings.Clone(res.String())
}
}
if tierIDPath, ok := mapping["tier_id"].(string); ok && tierIDPath != "" {
- if res := gjson.GetBytes(respBytes, tierIDPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ if res := ojson.GetBytesNoCopy(respBytes, tierIDPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
if out.Subscription == nil {
out.Subscription = &pluginapi.QuotaSubscription{}
}
- out.Subscription.TierID = res.String()
+ out.Subscription.TierID = strings.Clone(res.String())
}
} else if tierIDPath, ok := mapping["tierId"].(string); ok && tierIDPath != "" {
- if res := gjson.GetBytes(respBytes, tierIDPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ if res := ojson.GetBytesNoCopy(respBytes, tierIDPath); res.Exists() && strings.TrimSpace(res.String()) != "" {
if out.Subscription == nil {
out.Subscription = &pluginapi.QuotaSubscription{}
}
- out.Subscription.TierID = res.String()
+ out.Subscription.TierID = strings.Clone(res.String())
}
}
@@ -681,21 +682,21 @@ func mapProbeResponse(respBytes []byte, mapping map[string]any) (pluginapi.Quota
}
group := pluginapi.QuotaGroup{}
if namePath, okName := gm["display_name"].(string); okName {
- if res := gjson.GetBytes(respBytes, namePath); res.Exists() && strings.TrimSpace(res.String()) != "" {
- group.DisplayName = res.String()
+ if res := ojson.GetBytesNoCopy(respBytes, namePath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ group.DisplayName = strings.Clone(res.String())
} else {
group.DisplayName = namePath
}
} else if namePath, okName := gm["displayName"].(string); okName {
- if res := gjson.GetBytes(respBytes, namePath); res.Exists() && strings.TrimSpace(res.String()) != "" {
- group.DisplayName = res.String()
+ if res := ojson.GetBytesNoCopy(respBytes, namePath); res.Exists() && strings.TrimSpace(res.String()) != "" {
+ group.DisplayName = strings.Clone(res.String())
} else {
group.DisplayName = namePath
}
}
if bucketsPath, okBP := gm["buckets_path"].(string); okBP && bucketsPath != "" {
- arrayRes := gjson.GetBytes(respBytes, bucketsPath)
+ arrayRes := ojson.GetBytesNoCopy(respBytes, bucketsPath)
if arrayRes.IsArray() && len(arrayRes.Array()) > 0 {
winKey, _ := gm["window_key"].(string)
if winKey == "" {
@@ -737,10 +738,10 @@ func mapProbeResponse(respBytes []byte, mapping map[string]any) (pluginapi.Quota
continue
}
bucket := pluginapi.QuotaBucket{
- Window: item.Get(winKey).String(),
+ Window: strings.Clone(item.Get(winKey).String()),
RemainingFraction: frac,
- ResetTime: item.Get(resetKey).String(),
- Description: item.Get(descKey).String(),
+ ResetTime: strings.Clone(item.Get(resetKey).String()),
+ Description: strings.Clone(item.Get(descKey).String()),
}
group.Buckets = append(group.Buckets, bucket)
}
@@ -756,7 +757,7 @@ func mapProbeResponse(respBytes []byte, mapping map[string]any) (pluginapi.Quota
var hasFraction bool
var frac float64
if rf, ok := bm["remaining_fraction"].(string); ok && rf != "" {
- if val, okNum := parseNumericFraction(gjson.GetBytes(respBytes, rf)); okNum {
+ if val, okNum := parseNumericFraction(ojson.GetBytesNoCopy(respBytes, rf)); okNum {
frac = val
hasFraction = true
}
@@ -764,8 +765,8 @@ func mapProbeResponse(respBytes []byte, mapping map[string]any) (pluginapi.Quota
if !hasFraction {
if rem, okRem := bm["remaining_amount"].(string); okRem && rem != "" {
if tot, okTot := bm["total_amount"].(string); okTot && tot != "" {
- remVal, okRemNum := parseNumericFraction(gjson.GetBytes(respBytes, rem))
- totVal, okTotNum := parseNumericFraction(gjson.GetBytes(respBytes, tot))
+ remVal, okRemNum := parseNumericFraction(ojson.GetBytesNoCopy(respBytes, rem))
+ totVal, okTotNum := parseNumericFraction(ojson.GetBytesNoCopy(respBytes, tot))
if okRemNum && okTotNum && totVal > 0 {
frac = remVal / totVal
hasFraction = true
@@ -780,22 +781,22 @@ func mapProbeResponse(respBytes []byte, mapping map[string]any) (pluginapi.Quota
RemainingFraction: frac,
}
if win, ok := bm["window"].(string); ok {
- if res := gjson.GetBytes(respBytes, win); res.Exists() {
- bucket.Window = res.String()
+ if res := ojson.GetBytesNoCopy(respBytes, win); res.Exists() {
+ bucket.Window = strings.Clone(res.String())
} else {
bucket.Window = win
}
}
if desc, ok := bm["description"].(string); ok {
- if res := gjson.GetBytes(respBytes, desc); res.Exists() {
- bucket.Description = res.String()
+ if res := ojson.GetBytesNoCopy(respBytes, desc); res.Exists() {
+ bucket.Description = strings.Clone(res.String())
} else {
bucket.Description = desc
}
}
if reset, ok := bm["reset_time"].(string); ok {
- if res := gjson.GetBytes(respBytes, reset); res.Exists() {
- bucket.ResetTime = res.String()
+ if res := ojson.GetBytesNoCopy(respBytes, reset); res.Exists() {
+ bucket.ResetTime = strings.Clone(res.String())
} else {
bucket.ResetTime = reset
}
diff --git a/internal/api/handlers/management/plugin_store.go b/internal/api/handlers/management/plugin_store.go
index dc214243055..ec2470f80f9 100644
--- a/internal/api/handlers/management/plugin_store.go
+++ b/internal/api/handlers/management/plugin_store.go
@@ -47,34 +47,35 @@ type pluginStoreSourceErr struct {
}
type pluginStoreListEntry struct {
- StoreID string `json:"store_id"`
- SourceID string `json:"source_id"`
- SourceName string `json:"source_name"`
- SourceURL string `json:"source_url"`
- ID string `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Author string `json:"author"`
- Version string `json:"version"`
- Repository string `json:"repository"`
- InstallType string `json:"install_type"`
- AuthRequired bool `json:"auth_required"`
- AuthConfigured bool `json:"auth_configured"`
- Platforms []pluginStorePlatform `json:"platforms,omitempty"`
- Logo string `json:"logo,omitempty"`
- Homepage string `json:"homepage,omitempty"`
- License string `json:"license,omitempty"`
- Tags []string `json:"tags,omitempty"`
- Installed bool `json:"installed"`
- InstalledVersion string `json:"installed_version"`
- InstalledSourceID string `json:"installed_source_id,omitempty"`
- InstallSourceStatus string `json:"install_source_status,omitempty"`
- Path string `json:"path"`
- Configured bool `json:"configured"`
- Registered bool `json:"registered"`
- Enabled bool `json:"enabled"`
- EffectiveEnabled bool `json:"effective_enabled"`
- UpdateAvailable bool `json:"update_available"`
+ StoreID string `json:"store_id"`
+ SourceID string `json:"source_id"`
+ SourceName string `json:"source_name"`
+ SourceURL string `json:"source_url"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Author string `json:"author"`
+ Version string `json:"version"`
+ Repository string `json:"repository"`
+ InstallType string `json:"install_type"`
+ AuthRequired bool `json:"auth_required"`
+ AuthConfigured bool `json:"auth_configured"`
+ Platforms []pluginStorePlatform `json:"platforms,omitempty"`
+ Logo string `json:"logo,omitempty"`
+ Homepage string `json:"homepage,omitempty"`
+ License string `json:"license,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Locales map[string]pluginstore.PluginLocale `json:"locales,omitempty"`
+ Installed bool `json:"installed"`
+ InstalledVersion string `json:"installed_version"`
+ InstalledSourceID string `json:"installed_source_id,omitempty"`
+ InstallSourceStatus string `json:"install_source_status,omitempty"`
+ Path string `json:"path"`
+ Configured bool `json:"configured"`
+ Registered bool `json:"registered"`
+ Enabled bool `json:"enabled"`
+ EffectiveEnabled bool `json:"effective_enabled"`
+ UpdateAvailable bool `json:"update_available"`
}
type pluginStorePlatform struct {
@@ -196,6 +197,7 @@ func (h *Handler) ListPluginStore(c *gin.Context) {
Homepage: htmlsanitize.String(plugin.Homepage),
License: htmlsanitize.String(plugin.License),
Tags: htmlsanitize.Strings(plugin.Tags),
+ Locales: sanitizePluginStoreLocales(plugin.Locales),
Installed: status.Installed,
InstalledVersion: htmlsanitize.String(installedVersion),
InstalledSourceID: htmlsanitize.String(installedSourceID),
@@ -682,6 +684,25 @@ func sanitizePluginStoreSourceErrors(sourceErrors []pluginStoreSourceErr) []plug
return out
}
+func sanitizePluginStoreLocales(locales map[string]pluginstore.PluginLocale) map[string]pluginstore.PluginLocale {
+ normalized := pluginstore.NormalizePluginLocales(locales)
+ if len(normalized) == 0 {
+ return nil
+ }
+ out := make(map[string]pluginstore.PluginLocale, len(normalized))
+ for locale, item := range normalized {
+ out[htmlsanitize.String(locale)] = pluginstore.PluginLocale{
+ Name: htmlsanitize.String(item.Name),
+ Description: htmlsanitize.String(item.Description),
+ Author: htmlsanitize.String(item.Author),
+ Homepage: htmlsanitize.String(item.Homepage),
+ License: htmlsanitize.String(item.License),
+ Tags: htmlsanitize.Strings(item.Tags),
+ }
+ }
+ return out
+}
+
func sanitizePluginStorePlatforms(platforms []pluginstore.Platform) []pluginStorePlatform {
if len(platforms) == 0 {
return nil
diff --git a/internal/api/handlers/management/plugin_store_test.go b/internal/api/handlers/management/plugin_store_test.go
index 8db26e5cedc..4669b73935e 100644
--- a/internal/api/handlers/management/plugin_store_test.go
+++ b/internal/api/handlers/management/plugin_store_test.go
@@ -240,7 +240,12 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) {
"logo": "",
"homepage": "https://example.com/?q=",
"license": "MIT ",
- "tags": ["", "safe & sound"]
+ "tags": ["", "safe & sound"],
+ "locales": {
+ "zh-CN": {
+ "description": "中文描述 "
+ }
+ }
}]
}`),
},
@@ -278,6 +283,70 @@ func TestListPluginStoreEscapesRegistryStrings(t *testing.T) {
entry.Tags[1] != html.EscapeString("safe & sound") {
t.Fatalf("tags = %#v, want escaped strings", entry.Tags)
}
+ if entry.Locales["zh-CN"].Description != html.EscapeString("中文描述 ") {
+ t.Fatalf("locales = %#v, want escaped zh-CN description", entry.Locales)
+ }
+}
+
+func TestListPluginStorePassesThroughLocales(t *testing.T) {
+ t.Parallel()
+
+ h := &Handler{
+ cfg: &config.Config{
+ Plugins: config.PluginsConfig{
+ Enabled: true,
+ Dir: t.TempDir(),
+ },
+ },
+ configFilePath: writeTestConfigFile(t),
+ pluginStoreRegistryURL: "https://registry.example/registry.json",
+ pluginStoreHTTPClient: fakePluginStoreHTTPClient{
+ "https://registry.example/registry.json": []byte(`{
+ "schema_version": 1,
+ "plugins": [{
+ "id": "sample-provider",
+ "name": "Sample Provider",
+ "description": "Adds sample provider support.",
+ "author": "author-name",
+ "version": "0.1.0",
+ "repository": "https://github.com/author-name/cliproxy-sample-provider-plugin",
+ "tags": ["provider"],
+ "locales": {
+ "zh-CN": {
+ "description": "增加示例提供商支持。"
+ },
+ "ja": {
+ "description": "サンプルプロバイダーを追加します。"
+ }
+ }
+ }]
+ }`),
+ },
+ }
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v8/management/plugins/store", nil)
+
+ h.ListPluginStore(c)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ var body pluginStoreListResponse
+ if errDecode := json.Unmarshal(rec.Body.Bytes(), &body); errDecode != nil {
+ t.Fatalf("Unmarshal() error = %v; body=%s", errDecode, rec.Body.String())
+ }
+ if len(body.Plugins) != 1 {
+ t.Fatalf("plugins len = %d, want 1", len(body.Plugins))
+ }
+ entry := body.Plugins[0]
+ if entry.Description != "Adds sample provider support." {
+ t.Fatalf("description = %q, want English description", entry.Description)
+ }
+ if entry.Locales["zh-CN"].Description != "增加示例提供商支持。" || entry.Locales["ja"].Description != "サンプルプロバイダーを追加します。" {
+ t.Fatalf("locales = %#v, want passthrough zh-CN and ja locales", entry.Locales)
+ }
}
func TestListPluginStoreShowsLatestReleaseVersionAndCaches(t *testing.T) {
diff --git a/internal/api/handlers/management/plugins.go b/internal/api/handlers/management/plugins.go
index 2259abdca96..f09ef236566 100644
--- a/internal/api/handlers/management/plugins.go
+++ b/internal/api/handlers/management/plugins.go
@@ -25,20 +25,22 @@ type pluginListResponse struct {
}
type pluginListEntry struct {
- ID string `json:"id"`
- Path string `json:"path"`
- Configured bool `json:"configured"`
- Registered bool `json:"registered"`
- Enabled bool `json:"enabled"`
- EffectiveEnabled bool `json:"effective_enabled"`
- SupportsOAuth bool `json:"supports_oauth"`
- OAuthProvider string `json:"oauth_provider"`
- SupportsQuota bool `json:"supports_quota"`
- QuotaProvider string `json:"quota_provider,omitempty"`
- Logo string `json:"logo"`
- ConfigFields []pluginConfigFieldInfo `json:"config_fields"`
- Menus []pluginMenuInfo `json:"menus"`
- Metadata *pluginMetadataInfo `json:"metadata"`
+ ID string `json:"id"`
+ Path string `json:"path"`
+ Configured bool `json:"configured"`
+ Registered bool `json:"registered"`
+ Enabled bool `json:"enabled"`
+ EffectiveEnabled bool `json:"effective_enabled"`
+ SupportsOAuth bool `json:"supports_oauth"`
+ OAuthProvider string `json:"oauth_provider"`
+ OAuthProviders []string `json:"oauth_providers,omitempty"`
+ OAuthProviderEntries []pluginOAuthProviderEntryInfo `json:"oauth_provider_entries,omitempty"`
+ SupportsQuota bool `json:"supports_quota"`
+ QuotaProvider string `json:"quota_provider,omitempty"`
+ Logo string `json:"logo"`
+ ConfigFields []pluginConfigFieldInfo `json:"config_fields"`
+ Menus []pluginMenuInfo `json:"menus"`
+ Metadata *pluginMetadataInfo `json:"metadata"`
}
type pluginMetadataInfo struct {
@@ -58,6 +60,12 @@ type pluginConfigFieldInfo struct {
Description string `json:"description"`
}
+type pluginOAuthProviderEntryInfo struct {
+ Identifier string `json:"identifier"`
+ Name string `json:"name"`
+ Logo string `json:"logo"`
+}
+
type pluginMenuInfo struct {
Path string `json:"path"`
Menu string `json:"menu"`
@@ -125,6 +133,8 @@ func (h *Handler) ListPlugins(c *gin.Context) {
entry.Registered = true
entry.SupportsOAuth = info.SupportsOAuth
entry.OAuthProvider = htmlsanitize.String(info.OAuthProvider)
+ entry.OAuthProviders = pluginOAuthProviders(info.OAuthProviders)
+ entry.OAuthProviderEntries = pluginOAuthProviderEntries(info.OAuthProviderEntries)
entry.SupportsQuota = info.SupportsQuota
entry.QuotaProvider = htmlsanitize.String(info.QuotaProvider)
entry.Logo = htmlsanitize.String(info.Metadata.Logo)
@@ -478,6 +488,28 @@ func pluginConfigFields(fields []pluginapi.ConfigField) []pluginConfigFieldInfo
return out
}
+func pluginOAuthProviders(providers []string) []string {
+ if len(providers) == 0 {
+ return nil
+ }
+ return htmlsanitize.Strings(providers)
+}
+
+func pluginOAuthProviderEntries(entries []pluginapi.AuthProviderEntry) []pluginOAuthProviderEntryInfo {
+ if len(entries) == 0 {
+ return nil
+ }
+ out := make([]pluginOAuthProviderEntryInfo, 0, len(entries))
+ for _, entry := range entries {
+ out = append(out, pluginOAuthProviderEntryInfo{
+ Identifier: htmlsanitize.String(entry.Identifier),
+ Name: htmlsanitize.String(entry.Name),
+ Logo: htmlsanitize.String(entry.Logo),
+ })
+ }
+ return out
+}
+
func pluginMenus(menus []pluginhost.RegisteredPluginMenu) []pluginMenuInfo {
out := make([]pluginMenuInfo, 0, len(menus))
for _, menu := range menus {
diff --git a/internal/api/middleware/request_logging.go b/internal/api/middleware/request_logging.go
index bd5bf7dcb0a..5ffb153b6fb 100644
--- a/internal/api/middleware/request_logging.go
+++ b/internal/api/middleware/request_logging.go
@@ -62,6 +62,7 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
// Create response writer wrapper
wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)
+ wrapper.ginContext = c
if !loggerEnabled {
wrapper.logOnErrorOnly = true
}
@@ -86,17 +87,38 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
})
}
+ // Finalize logging after request processing, even if a downstream handler panics
+ // and unwinds to an outer recovery middleware.
+ defer func() {
+ recovered := recover()
+ if recovered != nil && !isAbortHandlerPanic(recovered) {
+ wrapper.panicked = true
+ if !wrapper.ResponseWriter.Written() {
+ wrapper.statusCode = http.StatusInternalServerError
+ if wrapper.streamWriter != nil {
+ _ = wrapper.streamWriter.WriteStatus(http.StatusInternalServerError, wrapper.headers)
+ }
+ }
+ }
+ if errFinalize := wrapper.Finalize(c); errFinalize != nil {
+ // Log error but don't interrupt the response
+ // In a real implementation, you might want to use a proper logger here
+ }
+ if recovered != nil {
+ panic(recovered)
+ }
+ }()
+
// Process the request
c.Next()
-
- // Finalize logging after request processing
- if err = wrapper.Finalize(c); err != nil {
- // Log error but don't interrupt the response
- // In a real implementation, you might want to use a proper logger here
- }
}
}
+func isAbortHandlerPanic(recovered any) bool {
+ err, ok := recovered.(error)
+ return ok && errors.Is(err, http.ErrAbortHandler)
+}
+
type fileBodySourceFactory interface {
NewFileBodySource(prefix string) (*logging.FileBodySource, error)
}
diff --git a/internal/api/middleware/request_logging_test.go b/internal/api/middleware/request_logging_test.go
index 4ed3d503a44..b1bf861ad39 100644
--- a/internal/api/middleware/request_logging_test.go
+++ b/internal/api/middleware/request_logging_test.go
@@ -4,11 +4,13 @@ import (
"bytes"
"context"
"encoding/hex"
+ "errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
+ "path/filepath"
"runtime"
"strings"
"testing"
@@ -540,6 +542,63 @@ func TestRequestLoggingMiddleware_ClientCancellationExclusion(t *testing.T) {
}
})
+ t.Run("mid-stream 400 API_RESPONSE_ERROR writes error log with Status 400 in both modes", func(t *testing.T) {
+ for _, requestLogEnabled := range []bool{false, true} {
+ logsDir := t.TempDir()
+ logger := logging.NewFileRequestLogger(requestLogEnabled, logsDir, "", 10)
+ cfg := &config.Config{SDKConfig: config.SDKConfig{RequestLog: requestLogEnabled}}
+
+ router := gin.New()
+ router.Use(RequestLoggingMiddleware(logger))
+ router.POST("/v1beta/models/gemini-4-argon-high:streamGenerateContent", func(c *gin.Context) {
+ c.Header("Content-Type", "text/event-stream")
+ executorCtx := context.WithValue(context.Background(), "gin", c)
+ helps.RecordAPIRequest(executorCtx, cfg, helps.UpstreamRequestLog{
+ URL: "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse",
+ Method: http.MethodPost,
+ Body: []byte(`{"model":"gemini-4-argon-high"}`),
+ })
+ c.Writer.WriteHeader(http.StatusOK)
+ _, _ = c.Writer.Write([]byte("data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"thought\":true,\"text\":\"thinking\"}]}}]}\n\n"))
+ c.Set("API_RESPONSE_ERROR", []*interfaces.ErrorMessage{
+ {
+ StatusCode: http.StatusBadRequest,
+ Error: errors.New("This request was blocked by Gemini's filters. You can read more about our policies here (https://policies.google.com/terms/generative-ai/use-policy)."),
+ },
+ })
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon-high:streamGenerateContent?alt=sse", strings.NewReader(`{"contents":[]}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+
+ entries, errRead := os.ReadDir(logsDir)
+ if errRead != nil {
+ t.Fatalf("read logs dir: %v", errRead)
+ }
+ var errorLogPath string
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), "error-") && strings.HasSuffix(entry.Name(), ".log") {
+ errorLogPath = filepath.Join(logsDir, entry.Name())
+ }
+ }
+ if errorLogPath == "" {
+ t.Fatalf("requestLog=%v: expected error-*.log file, got entries=%v", requestLogEnabled, entries)
+ }
+ content, errReadFile := os.ReadFile(errorLogPath)
+ if errReadFile != nil {
+ t.Fatalf("read error log: %v", errReadFile)
+ }
+ if !bytes.Contains(content, []byte("=== API ERROR RESPONSE ===\nHTTP Status: 400")) {
+ t.Fatalf("requestLog=%v: missing API ERROR RESPONSE 400 in log:\n%s", requestLogEnabled, string(content))
+ }
+ if !bytes.Contains(content, []byte("=== RESPONSE ===\nStatus: 400\n")) {
+ t.Fatalf("requestLog=%v: expected RESPONSE Status: 400 in log:\n%s", requestLogEnabled, string(content))
+ }
+ }
+ })
+
t.Run("499 status logs standard request when request-log is true", func(t *testing.T) {
logsDir := t.TempDir()
logger := logging.NewFileRequestLogger(true, logsDir, "", 10)
@@ -603,6 +662,7 @@ func TestManagementV8RequestsAreNotLogged(t *testing.T) {
type spyRequestLogger struct {
lastLogRequestID string
lastStreamingRequestID string
+ lastStreamWriter *testStreamingLogWriter
underlying logging.RequestLogger
}
@@ -619,7 +679,9 @@ func (s *spyRequestLogger) LogStreamingRequest(url, method string, headers map[s
if s.underlying != nil {
return s.underlying.LogStreamingRequest(url, method, headers, body, requestID)
}
- return &testStreamingLogWriter{}, nil
+ w := &testStreamingLogWriter{}
+ s.lastStreamWriter = w
+ return w, nil
}
func (s *spyRequestLogger) IsEnabled() bool {
@@ -717,6 +779,82 @@ func TestRequestLoggingMiddleware_StreamingPreservesFullUUIDForLogger(t *testing
}
}
+func TestRequestLoggingMiddleware_FinalizesStreamingWriterOnHandlerPanic(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ spy := &spyRequestLogger{}
+
+ router := gin.New()
+ router.Use(logging.GinLogrusRecovery())
+ router.Use(RequestLoggingMiddleware(spy))
+ router.POST("/v1/responses", func(c *gin.Context) {
+ c.Header("Content-Type", "text/event-stream")
+ c.Writer.WriteHeader(http.StatusOK)
+ _, _ = c.Writer.Write([]byte("data: chunk\n\n"))
+ panic("handler boom")
+ })
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"stream":true}`))
+ req.Header.Set("Content-Type", "application/json")
+ resp := httptest.NewRecorder()
+ router.ServeHTTP(resp, req)
+
+ if spy.lastStreamWriter == nil {
+ t.Fatal("expected streaming log writer to be created before panic")
+ }
+ if !spy.lastStreamWriter.closed {
+ t.Fatal("expected streaming log writer to be closed when handler panics")
+ }
+
+ // Verify that a non-streaming handler panicking before writing a status is
+ // finalized as 500 so error-only request logging persists the error log.
+ logsDir := t.TempDir()
+ errorOnlyLogger := logging.NewFileRequestLogger(false, logsDir, "", 10)
+ errorRouter := gin.New()
+ errorRouter.Use(logging.GinLogrusRecovery())
+ errorRouter.Use(RequestLoggingMiddleware(errorOnlyLogger))
+ errorRouter.POST("/v1/chat/completions", func(c *gin.Context) {
+ c.Status(http.StatusOK)
+ panic("pre-status boom")
+ })
+ reqPreStatus := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-4"}`))
+ reqPreStatus.Header.Set("Content-Type", "application/json")
+ respPreStatus := httptest.NewRecorder()
+ errorRouter.ServeHTTP(respPreStatus, reqPreStatus)
+ if respPreStatus.Code != http.StatusInternalServerError {
+ t.Fatalf("panic status = %d, want 500", respPreStatus.Code)
+ }
+ entries, errRead := os.ReadDir(logsDir)
+ if errRead != nil {
+ t.Fatalf("ReadDir logsDir: %v", errRead)
+ }
+ if len(entries) != 1 || !strings.HasPrefix(entries[0].Name(), "error-") {
+ t.Fatalf("expected 1 error log file on pre-status panic, got %+v", entries)
+ }
+
+ // http.ErrAbortHandler must not stamp a synthetic 500 error log in error-only mode.
+ abortLogsDir := t.TempDir()
+ abortLogger := logging.NewFileRequestLogger(false, abortLogsDir, "", 10)
+ abortRouter := gin.New()
+ abortRouter.Use(RequestLoggingMiddleware(abortLogger))
+ abortRouter.POST("/v1/chat/completions", func(c *gin.Context) {
+ panic(http.ErrAbortHandler)
+ })
+ func() {
+ defer func() { _ = recover() }()
+ reqAbort := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-4"}`))
+ reqAbort.Header.Set("Content-Type", "application/json")
+ abortRouter.ServeHTTP(httptest.NewRecorder(), reqAbort)
+ }()
+ abortEntries, errReadAbort := os.ReadDir(abortLogsDir)
+ if errReadAbort != nil {
+ t.Fatalf("ReadDir abortLogsDir: %v", errReadAbort)
+ }
+ if len(abortEntries) != 0 {
+ t.Fatalf("expected no error log for http.ErrAbortHandler, got %+v", abortEntries)
+ }
+}
+
// streamingZstdBomb is a small zstd body (a stream frame with a 1 MiB window, as zstd makes from a pipe) that expands to
// size bytes of zeros.
func streamingZstdBomb(t *testing.T, size int) []byte {
@@ -818,3 +956,42 @@ func TestDecodeCapturedZstdRefusesAnOversizedWindow(t *testing.T) {
t.Fatalf("an oversized window must leave the raw bytes, got %q", got)
}
}
+
+func TestResponseWriterLogOnErrorOnlyZeroBufferUntilActionableError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader("{}"))
+
+ logger := logging.NewFileRequestLogger(false, t.TempDir(), "", 10)
+ wrapper := NewResponseWriterWrapper(c.Writer, logger, &RequestInfo{
+ URL: "/v1/messages",
+ Method: http.MethodPost,
+ })
+ wrapper.logOnErrorOnly = true
+ wrapper.ginContext = c
+
+ // Normal 200 OK writes (even 4 MiB) must not allocate/buffer any bytes when request-log is disabled.
+ largePayload := bytes.Repeat([]byte("a"), 4<<20) // 4 MiB
+ if _, err := wrapper.Write(largePayload); err != nil {
+ t.Fatalf("Write failed: %v", err)
+ }
+ if wrapper.body.Len() != 0 {
+ t.Fatalf("wrapper.body.Len() for 200 OK = %d, want 0", wrapper.body.Len())
+ }
+
+ // Once an actionable mid-stream error is recorded, subsequent error frame writes are buffered.
+ c.Set("API_RESPONSE_ERROR", []*interfaces.ErrorMessage{
+ {
+ StatusCode: http.StatusBadRequest,
+ Error: errors.New("claude refusal (cyber): blocked"),
+ },
+ })
+ const errFrame = "event: error\ndata: {\"type\":\"error\"}\n\n"
+ if _, err := wrapper.WriteString(errFrame); err != nil {
+ t.Fatalf("WriteString failed: %v", err)
+ }
+ if got := wrapper.body.String(); got != errFrame {
+ t.Fatalf("wrapper.body.String() = %q, want %q", got, errFrame)
+ }
+}
diff --git a/internal/api/middleware/response_writer.go b/internal/api/middleware/response_writer.go
index 83887887eeb..72c977df121 100644
--- a/internal/api/middleware/response_writer.go
+++ b/internal/api/middleware/response_writer.go
@@ -47,10 +47,13 @@ type ResponseWriterWrapper struct {
streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
logger logging.RequestLogger // logger is the instance of the request logger service.
requestInfo *RequestInfo // requestInfo holds the details of the original request.
+ ginContext *gin.Context // ginContext holds the Gin request context for mid-stream error inspection.
statusCode int // statusCode stores the HTTP status code of the response.
headers map[string][]string // headers stores the response headers.
logOnErrorOnly bool // logOnErrorOnly enables logging only when an error response is detected.
hijacked bool // hijacked reports whether the underlying connection was hijacked.
+ panicked bool // panicked records whether a handler panicked (even after committing a 2xx response).
+ finalized bool // finalized prevents post-Finalize writes (e.g. outer recovery) from reopening stream writers.
firstChunkTimestamp time.Time // firstChunkTimestamp captures TTFB for streaming responses.
}
@@ -169,6 +172,11 @@ func (w *ResponseWriterWrapper) shouldBufferResponseBody() bool {
return false
}
status := w.effectiveStatusCode()
+ if status < http.StatusBadRequest {
+ if apiErrStatus := actionableAPIResponseErrorStatus(extractAPIResponseErrors(w.ginContext)); apiErrStatus >= http.StatusBadRequest {
+ status = apiErrStatus
+ }
+ }
return status >= http.StatusBadRequest && status != clienterror.StatusClientClosedRequest
}
@@ -205,6 +213,12 @@ func (w *ResponseWriterWrapper) WriteString(data string) (int, error) {
// It captures the status code, detects if the response is streaming based on the Content-Type header,
// and initializes the appropriate logging mechanism (standard or streaming).
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
+ if w.ResponseWriter.Written() {
+ if statusCode >= http.StatusBadRequest && statusCode != clienterror.StatusClientClosedRequest {
+ w.panicked = true
+ }
+ return
+ }
w.statusCode = statusCode
w.closeConnectionIfRequestBodyUnconsumed(statusCode)
@@ -213,28 +227,33 @@ func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
// Detect streaming based on Content-Type
contentType := w.ResponseWriter.Header().Get("Content-Type")
- w.isStreaming = w.detectStreaming(contentType)
-
- // If streaming, initialize streaming log writer
- if w.isStreaming && w.logger.IsEnabled() {
- streamWriter, err := w.logger.LogStreamingRequest(
- w.requestInfo.URL,
- w.requestInfo.Method,
- w.requestInfo.Headers,
- w.currentCapturedRequestBody(),
- w.requestInfo.RequestID,
- )
- if err == nil {
- w.streamWriter = streamWriter
- w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
- doneChan := make(chan struct{})
- w.streamDone = doneChan
-
- // Start async chunk processor
- go w.processStreamingChunks(doneChan)
-
- // Write status immediately
- _ = streamWriter.WriteStatus(statusCode, w.headers)
+ w.isStreaming = w.detectStreaming(contentType) || w.streamWriter != nil
+
+ // If streaming, initialize streaming log writer once and refresh uncommitted status updates.
+ if !w.finalized && w.isStreaming && w.logger != nil && w.logger.IsEnabled() {
+ if w.streamWriter == nil {
+ streamWriter, err := w.logger.LogStreamingRequest(
+ w.requestInfo.URL,
+ w.requestInfo.Method,
+ w.requestInfo.Headers,
+ w.currentCapturedRequestBody(),
+ w.requestInfo.RequestID,
+ )
+ if err == nil {
+ chunkChan := make(chan []byte, 100) // Buffered channel for async writes
+ doneChan := make(chan struct{})
+ w.streamWriter = streamWriter
+ w.chunkChannel = chunkChan
+ w.streamDone = doneChan
+
+ // Start async chunk processor with captured locals so Finalize clearing fields cannot race.
+ go w.processStreamingChunks(doneChan, chunkChan, streamWriter)
+
+ // Write status immediately
+ _ = streamWriter.WriteStatus(statusCode, w.headers)
+ }
+ } else {
+ _ = w.streamWriter.WriteStatus(statusCode, w.headers)
}
}
@@ -318,19 +337,19 @@ func (w *ResponseWriterWrapper) currentCapturedRequestBody() []byte {
// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
// It asynchronously writes each chunk to the streaming log writer.
-func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
+func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}, chunkChan <-chan []byte, streamWriter logging.StreamingLogWriter) {
if done == nil {
return
}
defer close(done)
- if w.streamWriter == nil || w.chunkChannel == nil {
+ if streamWriter == nil || chunkChan == nil {
return
}
- for chunk := range w.chunkChannel {
- w.streamWriter.WriteChunkAsync(chunk)
+ for chunk := range chunkChan {
+ streamWriter.WriteChunkAsync(chunk)
}
}
@@ -339,6 +358,10 @@ func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
// For non-streaming responses, it logs the complete request and response details,
// including any API-specific request/response data stored in the Gin context.
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
+ if w == nil || w.finalized {
+ return nil
+ }
+ w.finalized = true
if w.requestInfo != nil && w.requestInfo.deferredBodyCapture != nil {
defer w.requestInfo.deferredBodyCapture.Cleanup()
}
@@ -362,8 +385,13 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
slicesAPIResponseError = apiErrors
}
}
+ if finalStatusCode < http.StatusBadRequest {
+ if errStatus := actionableAPIResponseErrorStatus(slicesAPIResponseError); errStatus >= http.StatusBadRequest {
+ finalStatusCode = errStatus
+ }
+ }
- hasAPIError := hasActionableError(c, finalStatusCode, slicesAPIResponseError)
+ hasAPIError := w.panicked || hasActionableError(c, finalStatusCode, slicesAPIResponseError)
forceLog := w.logOnErrorOnly && hasAPIError && !w.logger.IsEnabled()
websocketTimelineSource := w.extractWebsocketTimelineSource(c)
apiRequestSource := w.extractAPIRequestSource(c)
@@ -435,6 +463,12 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
if len(apiWebsocketTimeline) > 0 {
_ = w.streamWriter.WriteAPIWebsocketTimeline(apiWebsocketTimeline)
}
+ if finalStatusCode >= http.StatusBadRequest {
+ _ = w.streamWriter.WriteStatus(finalStatusCode, nil)
+ }
+ if len(slicesAPIResponseError) > 0 {
+ _ = w.streamWriter.WriteAPIResponseErrors(slicesAPIResponseError)
+ }
if err := w.streamWriter.Close(); err != nil {
w.streamWriter = nil
cleanupFileBodySources(websocketTimelineSource, apiRequestSource, apiResponseSource)
@@ -816,6 +850,34 @@ func hasActionableAPIResponseErrors(apiErrors []*interfaces.ErrorMessage) bool {
return false
}
+func extractAPIResponseErrors(c *gin.Context) []*interfaces.ErrorMessage {
+ if c == nil {
+ return nil
+ }
+ if apiResponseError, exists := c.Get("API_RESPONSE_ERROR"); exists {
+ if apiErrors, ok := apiResponseError.([]*interfaces.ErrorMessage); ok {
+ return apiErrors
+ }
+ }
+ return nil
+}
+
+func actionableAPIResponseErrorStatus(apiErrors []*interfaces.ErrorMessage) int {
+ for i := len(apiErrors) - 1; i >= 0; i-- {
+ errMsg := apiErrors[i]
+ if isClientCancellationErrorMessage(errMsg) {
+ continue
+ }
+ if errMsg.StatusCode >= http.StatusBadRequest && errMsg.StatusCode != clienterror.StatusClientClosedRequest {
+ return errMsg.StatusCode
+ }
+ if status := clienterror.HTTPStatusFromError(errMsg.Error); status >= http.StatusBadRequest && status != clienterror.StatusClientClosedRequest {
+ return status
+ }
+ }
+ return 0
+}
+
func isContextCanceled(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
diff --git a/internal/api/middleware/response_writer_test.go b/internal/api/middleware/response_writer_test.go
index c0929f027bc..70814fc2536 100644
--- a/internal/api/middleware/response_writer_test.go
+++ b/internal/api/middleware/response_writer_test.go
@@ -177,6 +177,7 @@ func (l *testRequestLogger) IsEnabled() bool {
type testStreamingLogWriter struct {
apiWebsocketTimeline []byte
+ apiResponseErrors []*interfaces.ErrorMessage
closed bool
}
@@ -199,6 +200,11 @@ func (w *testStreamingLogWriter) WriteAPIWebsocketTimeline(apiWebsocketTimeline
return nil
}
+func (w *testStreamingLogWriter) WriteAPIResponseErrors(errors []*interfaces.ErrorMessage) error {
+ w.apiResponseErrors = append([]*interfaces.ErrorMessage(nil), errors...)
+ return nil
+}
+
func (w *testStreamingLogWriter) SetFirstChunkTimestamp(time.Time) {}
func (w *testStreamingLogWriter) Close() error {
diff --git a/internal/api/server.go b/internal/api/server.go
index 32a7c567d2a..099b5dda52f 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -171,15 +171,12 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// Add middleware
engine.Use(logging.GinLogrusLogger())
- engine.Use(logging.GinLogrusRecovery())
engine.Use(logging.CPATraceIDMiddleware())
engine.Use(s.maxRequestBodyBytesMiddleware())
- for _, mw := range optionState.extraMiddleware {
- engine.Use(mw)
- }
- // Add request logging middleware (positioned after recovery, before auth)
- // Resolve logs directory relative to the configuration file directory.
+ // Add request logging middleware before recovery and extra middleware so that
+ // any panic response written by GinLogrusRecovery or custom extraMiddleware is
+ // captured before request logging finalizes.
var requestLogger logging.RequestLogger
var toggle func(bool)
if !cfg.CommercialMode {
@@ -196,6 +193,11 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
s.requestLogger = requestLogger
s.loggerToggle = toggle
+ engine.Use(logging.GinLogrusRecovery())
+ for _, mw := range optionState.extraMiddleware {
+ engine.Use(mw)
+ }
+
engine.Use(corsMiddleware())
engine.Use(s.responseCompressionMiddleware())
openai.SetResponsesWebsocketCompressionEnabled(cfg.Codex.WebsocketCompressionEnabled())
diff --git a/internal/api/server_model_detail_test.go b/internal/api/server_model_detail_test.go
index 9c0b52b04cd..4e1a56fa6c3 100644
--- a/internal/api/server_model_detail_test.go
+++ b/internal/api/server_model_detail_test.go
@@ -6,11 +6,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
)
type issue5190LifecycleHost struct {
@@ -81,7 +81,7 @@ func TestIssue5190ModelDetailUsesVisibleCatalog(t *testing.T) {
if rr.Code != tc.status {
t.Errorf("%s: status=%d want=%d body=%s", tc.id, rr.Code, tc.status, rr.Body.String())
}
- if tc.status == 200 && gjson.Get(rr.Body.String(), "id").String() != tc.id {
+ if tc.status == 200 && ojson.GetBytesNoCopy(rr.Body.Bytes(), "id").String() != tc.id {
t.Errorf("wrong model: %s", rr.Body.String())
}
}
@@ -123,7 +123,7 @@ func TestIssue5190ModelDetailHomeVisibility(t *testing.T) {
previousHome := home.Current()
home.SetCurrent(newHomeCatalogClient(t, `{"openai":[{"id":"home/visible"}]}`))
t.Cleanup(func() { home.SetCurrent(previousHome) })
- if rr := get("home/visible", true); rr.Code != 200 || gjson.Get(rr.Body.String(), "id").String() != "home/visible" {
+ if rr := get("home/visible", true); rr.Code != 200 || ojson.GetBytesNoCopy(rr.Body.Bytes(), "id").String() != "home/visible" {
t.Fatalf("Home detail: %d %s", rr.Code, rr.Body.String())
}
if rr := get("local/only", true); rr.Code != 404 {
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 0a030084844..3cfb8b60766 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -20,6 +20,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
gin "github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
@@ -43,7 +44,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
"gopkg.in/yaml.v3"
)
@@ -2159,6 +2159,9 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
{ID: "gpt-image-2.5-flare", Object: "model", OwnedBy: "openai", Type: "openai"},
{ID: "gpt-image-2.5-sunburst", Object: "model", OwnedBy: "openai", Type: "openai"},
{ID: "gpt-image-2.5", Object: "model", OwnedBy: "openai", Type: "openai"},
+ {ID: "gpt-image-2.6-flare", Object: "model", OwnedBy: "openai", Type: "openai"},
+ {ID: "gpt-image-2.6-sunburst", Object: "model", OwnedBy: "openai", Type: "openai"},
+ {ID: "gpt-image-2.6", Object: "model", OwnedBy: "openai", Type: "openai"},
{ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", Type: "openai"},
{ID: "grok-imagine-image-2.0", Object: "model", OwnedBy: "xai", Type: "openai"},
{ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", Type: "openai"},
@@ -2275,6 +2278,9 @@ func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
"gpt-image-2.5-flare": false,
"gpt-image-2.5-sunburst": false,
"gpt-image-2.5": false,
+ "gpt-image-2.6-flare": false,
+ "gpt-image-2.6-sunburst": false,
+ "gpt-image-2.6": false,
"grok-imagine-image": false,
"grok-imagine-image-2.0": false,
"grok-imagine-video": false,
@@ -3921,7 +3927,16 @@ func TestServerResponsesWebsocketReauthorizesEachTurn(t *testing.T) {
registry.GetGlobalRegistry().UnregisterClient(authEntry.ID)
})
- httpServer := httptest.NewServer(server.engine)
+ // The websocket handler can still write its request log after the client
+ // closes the socket. Wait for in-flight handlers before TempDir cleanup runs
+ // (cleanups are LIFO), or Windows fails to remove the log directory.
+ var inFlight sync.WaitGroup
+ t.Cleanup(inFlight.Wait)
+ httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ inFlight.Add(1)
+ defer inFlight.Done()
+ server.engine.ServeHTTP(w, r)
+ }))
defer httpServer.Close()
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/v1/responses"
@@ -3940,7 +3955,7 @@ func TestServerResponsesWebsocketReauthorizesEachTurn(t *testing.T) {
if errRead1 != nil {
t.Fatalf("read turn 1: %v", errRead1)
}
- if got := gjson.GetBytes(msg1, "type").String(); got != "response.completed" {
+ if got := ojson.GetBytesNoCopy(msg1, "type").String(); got != "response.completed" {
t.Fatalf("turn 1 event type = %q, want response.completed (%s)", got, msg1)
}
@@ -3972,10 +3987,10 @@ func TestServerResponsesWebsocketReauthorizesEachTurn(t *testing.T) {
if errRead2 != nil {
t.Fatalf("read turn 2: %v", errRead2)
}
- if got := gjson.GetBytes(msg2, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(msg2, "type").String(); got != "error" {
t.Fatalf("turn 2 event type = %q, want error (%s)", got, msg2)
}
- if got := int(gjson.GetBytes(msg2, "status").Int()); got != http.StatusUnauthorized {
+ if got := int(ojson.GetBytesNoCopy(msg2, "status").Int()); got != http.StatusUnauthorized {
t.Fatalf("turn 2 error status = %d, want %d (%s)", got, http.StatusUnauthorized, msg2)
}
if gotCalls := wsExec.calls.Load(); gotCalls != 1 {
diff --git a/internal/auth/antigravity/auth_test.go b/internal/auth/antigravity/auth_test.go
index d632bb3bd6d..b5033fec95a 100644
--- a/internal/auth/antigravity/auth_test.go
+++ b/internal/auth/antigravity/auth_test.go
@@ -4,6 +4,7 @@ import (
"context"
"io"
"net/http"
+ "net/url"
"strings"
"testing"
)
@@ -14,6 +15,20 @@ func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+func TestBuildAuthURLIncludesAICodeScope(t *testing.T) {
+ parsedURL, err := url.Parse(NewAntigravityAuth(nil, nil).BuildAuthURL("state", "http://localhost/callback"))
+ if err != nil {
+ t.Fatalf("parse auth URL: %v", err)
+ }
+
+ for _, scope := range strings.Fields(parsedURL.Query().Get("scope")) {
+ if scope == "https://www.googleapis.com/auth/aicode" {
+ return
+ }
+ }
+ t.Fatalf("authorization URL scope does not include %q", "https://www.googleapis.com/auth/aicode")
+}
+
func TestFetchProjectIDFromLoadCodeAssist(t *testing.T) {
auth := NewAntigravityAuth(nil, &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.String() != "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" {
diff --git a/internal/auth/antigravity/constants.go b/internal/auth/antigravity/constants.go
index 2ba464d44bf..a94c42b4ea7 100644
--- a/internal/auth/antigravity/constants.go
+++ b/internal/auth/antigravity/constants.go
@@ -13,6 +13,7 @@ var Scopes = []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/aicode",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
}
diff --git a/internal/auth/claude/pkce.go b/internal/auth/claude/pkce.go
index 98d40202b7c..e8b11f9a9c9 100644
--- a/internal/auth/claude/pkce.go
+++ b/internal/auth/claude/pkce.go
@@ -6,8 +6,9 @@ package claude
import (
"crypto/rand"
"crypto/sha256"
- "encoding/base64"
"fmt"
+
+ "github.com/MoeForge/moego/base64"
)
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
@@ -45,12 +46,12 @@ func generateCodeVerifier() (string, error) {
}
// Encode to URL-safe base64 without padding
- return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
+ return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// generateCodeChallenge creates a SHA256 hash of the code verifier
// and encodes it using URL-safe base64 encoding without padding
func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
- return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
+ return base64.RawURLEncoding.EncodeToString(hash[:])
}
diff --git a/internal/auth/codex/jwt_parser.go b/internal/auth/codex/jwt_parser.go
index a49befef6db..36a897dc2a8 100644
--- a/internal/auth/codex/jwt_parser.go
+++ b/internal/auth/codex/jwt_parser.go
@@ -1,11 +1,12 @@
package codex
import (
- "encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
+
+ "github.com/MoeForge/moego/base64"
)
// JWTClaims represents the claims section of a JSON Web Token (JWT).
@@ -79,6 +80,9 @@ func ParseJWTToken(token string) (*JWTClaims, error) {
// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures
// correct decoding by re-adding the padding before decoding.
func base64URLDecode(data string) ([]byte, error) {
+ if decoded, errDecode := base64.RawURLEncoding.DecodeString(data); errDecode == nil {
+ return decoded, nil
+ }
// Add padding if necessary
switch len(data) % 4 {
case 2:
@@ -86,7 +90,6 @@ func base64URLDecode(data string) ([]byte, error) {
case 3:
data += "="
}
-
return base64.URLEncoding.DecodeString(data)
}
diff --git a/internal/auth/codex/jwt_parser_test.go b/internal/auth/codex/jwt_parser_test.go
index b3ee2b855d3..e65e2663b0e 100644
--- a/internal/auth/codex/jwt_parser_test.go
+++ b/internal/auth/codex/jwt_parser_test.go
@@ -1,8 +1,8 @@
package codex
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"testing"
)
diff --git a/internal/auth/codex/pkce.go b/internal/auth/codex/pkce.go
index c1f0fb69a75..9a180d8f0b2 100644
--- a/internal/auth/codex/pkce.go
+++ b/internal/auth/codex/pkce.go
@@ -6,8 +6,9 @@ package codex
import (
"crypto/rand"
"crypto/sha256"
- "encoding/base64"
"fmt"
+
+ "github.com/MoeForge/moego/base64"
)
// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.
@@ -43,7 +44,7 @@ func generateCodeVerifier() (string, error) {
}
// Encode to URL-safe base64 without padding
- return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
+ return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// generateCodeChallenge creates a code challenge from a given code verifier.
@@ -52,5 +53,5 @@ func generateCodeVerifier() (string, error) {
// request and later verified against the verifier.
func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
- return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
+ return base64.RawURLEncoding.EncodeToString(hash[:])
}
diff --git a/internal/auth/codex/siwc_auth.go b/internal/auth/codex/siwc_auth.go
index 3b0ffd5bea5..f8587090603 100644
--- a/internal/auth/codex/siwc_auth.go
+++ b/internal/auth/codex/siwc_auth.go
@@ -3,10 +3,10 @@ package codex
import (
"context"
"crypto/rand"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/url"
diff --git a/internal/auth/devin/devin_auth.go b/internal/auth/devin/devin_auth.go
index 93ac53b8c9e..f960fdd266b 100644
--- a/internal/auth/devin/devin_auth.go
+++ b/internal/auth/devin/devin_auth.go
@@ -15,8 +15,9 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
const (
@@ -141,7 +142,7 @@ func (s *DevinAuthService) ExchangeCodeForToken(ctx context.Context, code, codeV
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBytes))
}
- token := strings.TrimSpace(gjson.GetBytes(respBytes, "token").String())
+ token := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(respBytes, "token").String()))
if token == "" {
return "", fmt.Errorf("response did not contain a valid token: %s", string(respBytes))
}
@@ -173,10 +174,10 @@ func (s *DevinAuthService) FetchSelfProfile(ctx context.Context, sessionToken st
}
if resp.StatusCode == http.StatusOK {
- root := gjson.ParseBytes(respBytes)
- userName = root.Get("user_name").String()
- userID = root.Get("user_id").String()
- orgID = root.Get("org_id").String()
+ root := util.ParseGJSONBytesNoCopy(respBytes)
+ userName = strings.Clone(root.Get("user_name").String())
+ userID = strings.Clone(root.Get("user_id").String())
+ orgID = strings.Clone(root.Get("org_id").String())
}
return userName, userID, orgID, nil
diff --git a/internal/auth/devin/pkce.go b/internal/auth/devin/pkce.go
index 3e3af2cc226..e5302235f74 100644
--- a/internal/auth/devin/pkce.go
+++ b/internal/auth/devin/pkce.go
@@ -3,8 +3,9 @@ package devin
import (
"crypto/rand"
"crypto/sha256"
- "encoding/base64"
"fmt"
+
+ "github.com/MoeForge/moego/base64"
)
// PKCECodes contains the PKCE code verifier and code challenge pair.
diff --git a/internal/auth/vertex/keyutil.go b/internal/auth/vertex/keyutil.go
index a10ade17e35..166180998a8 100644
--- a/internal/auth/vertex/keyutil.go
+++ b/internal/auth/vertex/keyutil.go
@@ -3,11 +3,12 @@ package vertex
import (
"crypto/rsa"
"crypto/x509"
- "encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"strings"
+
+ "github.com/MoeForge/moego/base64"
)
// NormalizeServiceAccountJSON normalizes the given JSON-encoded service account payload.
diff --git a/internal/auth/xai/filename.go b/internal/auth/xai/filename.go
new file mode 100644
index 00000000000..c1b6f96d8e7
--- /dev/null
+++ b/internal/auth/xai/filename.go
@@ -0,0 +1,219 @@
+package xai
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// CredentialFileName returns the filename used to persist xAI OAuth credentials.
+// It keeps a readable account name and appends a hash of the original identity
+// so distinct emails that sanitize identically cannot overwrite each other.
+func CredentialFileName(email, subject string) string {
+ cleanEmail := strings.TrimSpace(email)
+ if cleanEmail != "" {
+ // Emails are case-insensitive, so normalize case to keep the same
+ // account on the same file regardless of the case returned upstream.
+ // The hash covers the normalized email only so the filename stays
+ // stable even when a later login stops returning a subject.
+ normalizedEmail := strings.ToLower(cleanEmail)
+ sanitized := sanitizeFileSegment(normalizedEmail)
+ if sanitized == "" {
+ sanitized = "account"
+ }
+ // Leave room for the prefix, identity hash and extension on common filesystems.
+ if len(sanitized) > 120 {
+ sanitized = sanitized[:120]
+ }
+ digest := sha256.Sum256([]byte("email\x00" + normalizedEmail))
+ return fmt.Sprintf("xai-%s-%s.json", sanitized, hex.EncodeToString(digest[:8]))
+ }
+ cleanSubject := strings.TrimSpace(subject)
+ if cleanSubject != "" {
+ sanitized := sanitizeFileSegment(cleanSubject)
+ if sanitized == "" {
+ sanitized = "account"
+ }
+ if len(sanitized) > 120 {
+ sanitized = sanitized[:120]
+ }
+ digest := sha256.Sum256([]byte("subject\x00" + cleanSubject))
+ return fmt.Sprintf("xai-%s-%s.json", sanitized, hex.EncodeToString(digest[:8]))
+ }
+ return fmt.Sprintf("xai-%d.json", time.Now().UnixMilli())
+}
+
+func sanitizeFileSegment(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return ""
+ }
+ var b strings.Builder
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z':
+ b.WriteRune(r)
+ case r >= 'A' && r <= 'Z':
+ b.WriteRune(r)
+ case r >= '0' && r <= '9':
+ b.WriteRune(r)
+ case r == '@' || r == '.' || r == '_' || r == '-':
+ b.WriteRune(r)
+ default:
+ b.WriteRune('-')
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+
+// legacyCredentialFileName reconstructs the pre-hash filename format so a
+// credential saved before the hash suffix was introduced can be migrated.
+func legacyCredentialFileName(email, subject string) string {
+ if sanitized := sanitizeFileSegment(email); sanitized != "" {
+ return fmt.Sprintf("xai-%s.json", sanitized)
+ }
+ if sanitized := sanitizeFileSegment(subject); sanitized != "" {
+ return fmt.Sprintf("xai-%s.json", sanitized)
+ }
+ return ""
+}
+
+// FindMatchingLegacyCredential locates a prior filename for the same xAI
+// identity as target. A candidate's recognized names are derived from every
+// subset of its stored identity, covering the pre-hash format as well as
+// hashed names from logins that saw only the email or only the subject.
+// The target's own canonical filename is never a predecessor, so an ordinary
+// re-login does not match the destination file itself. A legacy filename
+// alone is ambiguous because distinct accounts could sanitize to it, so a
+// candidate is only returned when its stored identity affirmatively matches
+// the target identity.
+func FindMatchingLegacyCredential(ctx context.Context, store coreauth.Store, target *coreauth.Auth) (*coreauth.Auth, error) {
+ if store == nil || !isHashedCredentialTarget(target) {
+ return nil, nil
+ }
+
+ records, errList := store.List(ctx)
+ if errList != nil {
+ if errors.Is(errList, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("list xAI credentials for legacy migration: %w", errList)
+ }
+
+ targetEmail := metadataString(target.Metadata, "email")
+ targetSubject := metadataString(target.Metadata, "sub")
+ // A nonempty identity always yields a hashed filename even when the
+ // pre-hash sanitization would have produced an empty segment, so do not
+ // require a legacy name here; isHashedCredentialTarget already verified
+ // that at least one identity field is present.
+ targetFileName := strings.TrimSpace(target.FileName)
+ if targetFileName == "" {
+ targetFileName = strings.TrimSpace(target.ID)
+ }
+ targetBaseName := filepath.Base(targetFileName)
+
+ for _, candidate := range records {
+ if candidate == nil || !strings.EqualFold(strings.TrimSpace(candidate.Provider), "xai") {
+ continue
+ }
+ if !identityMatches(targetEmail, targetSubject, candidate) {
+ continue
+ }
+ candidateFileName := strings.TrimSpace(candidate.FileName)
+ if candidateFileName == "" {
+ candidateFileName = strings.TrimSpace(candidate.ID)
+ }
+ candidateBaseName := filepath.Base(candidateFileName)
+ // Only root-level generated files participate in migration. Login
+ // always writes flat names, and a nested relative ID would not
+ // resolve against the auth directory on cleanup.
+ if candidateBaseName != candidateFileName {
+ continue
+ }
+ // Never match the destination file itself: callers delete the returned
+ // predecessor after saving, which would erase the fresh credential.
+ if strings.EqualFold(candidateBaseName, targetBaseName) {
+ continue
+ }
+ candidateEmail := metadataString(candidate.Metadata, "email")
+ candidateSubject := metadataString(candidate.Metadata, "sub")
+ if isRecognizedPredecessor(candidateBaseName, candidateEmail, candidateSubject) {
+ return candidate, nil
+ }
+ }
+
+ return nil, nil
+}
+
+// isRecognizedPredecessor reports whether baseName is a filename the login
+// flow itself would have produced from some subset of the stored identity:
+// the pre-hash name, or a hashed name derived from either identity field.
+// A file named when only the subject was known stays recognizable after a
+// later login backfills the email into its stored metadata.
+func isRecognizedPredecessor(baseName, email, subject string) bool {
+ if email == "" && subject == "" {
+ return false
+ }
+ if strings.EqualFold(baseName, CredentialFileName(email, subject)) {
+ return true
+ }
+ if email != "" && strings.EqualFold(baseName, CredentialFileName(email, "")) {
+ return true
+ }
+ if subject != "" && strings.EqualFold(baseName, CredentialFileName("", subject)) {
+ return true
+ }
+ if legacyName := legacyCredentialFileName(email, subject); legacyName != "" && strings.EqualFold(baseName, legacyName) {
+ return true
+ }
+ if subject != "" {
+ if legacyName := legacyCredentialFileName("", subject); legacyName != "" && strings.EqualFold(baseName, legacyName) {
+ return true
+ }
+ }
+ return false
+}
+
+func isHashedCredentialTarget(target *coreauth.Auth) bool {
+ if target == nil || !strings.EqualFold(strings.TrimSpace(target.Provider), "xai") {
+ return false
+ }
+ email := metadataString(target.Metadata, "email")
+ subject := metadataString(target.Metadata, "sub")
+ if email == "" && subject == "" {
+ return false
+ }
+ targetFileName := strings.TrimSpace(target.FileName)
+ if targetFileName == "" {
+ targetFileName = strings.TrimSpace(target.ID)
+ }
+ return strings.EqualFold(filepath.Base(targetFileName), CredentialFileName(email, subject))
+}
+
+// identityMatches reports whether a candidate credential is provably the
+// same account as the target: at least one stored identity field must match
+// affirmatively and no stored identity field may conflict. Emails compare
+// case-insensitively; subjects are case-sensitive identifiers and must match
+// exactly.
+func identityMatches(targetEmail, targetSubject string, candidate *coreauth.Auth) bool {
+ candidateEmail := metadataString(candidate.Metadata, "email")
+ candidateSubject := metadataString(candidate.Metadata, "sub")
+ emailMatches := targetEmail != "" && candidateEmail != "" && strings.EqualFold(targetEmail, candidateEmail)
+ emailConflicts := targetEmail != "" && candidateEmail != "" && !strings.EqualFold(targetEmail, candidateEmail)
+ subjectMatches := targetSubject != "" && candidateSubject != "" && targetSubject == candidateSubject
+ subjectConflicts := targetSubject != "" && candidateSubject != "" && targetSubject != candidateSubject
+ return (emailMatches || subjectMatches) && !emailConflicts && !subjectConflicts
+}
+
+func metadataString(metadata map[string]any, key string) string {
+ value, _ := metadata[key].(string)
+ return strings.TrimSpace(value)
+}
diff --git a/internal/auth/xai/filename_test.go b/internal/auth/xai/filename_test.go
new file mode 100644
index 00000000000..4f16afe636e
--- /dev/null
+++ b/internal/auth/xai/filename_test.go
@@ -0,0 +1,273 @@
+package xai
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+type credentialListStore struct {
+ records []*coreauth.Auth
+}
+
+func (s *credentialListStore) List(context.Context) ([]*coreauth.Auth, error) {
+ return s.records, nil
+}
+
+func (*credentialListStore) Save(context.Context, *coreauth.Auth) (string, error) {
+ panic("unexpected Save call")
+}
+
+func (*credentialListStore) Delete(context.Context, string) error {
+ panic("unexpected Delete call")
+}
+
+func TestCredentialFileNameSeparatesCollidingEmails(t *testing.T) {
+ plus := CredentialFileName("a+b@example.com", "sub-first")
+ hyphen := CredentialFileName("a-b@example.com", "sub-second")
+ if plus == hyphen {
+ t.Fatalf("CredentialFileName(a+b@example.com) = %q collides with CredentialFileName(a-b@example.com)", plus)
+ }
+ for _, name := range []string{plus, hyphen} {
+ if !strings.HasPrefix(name, "xai-") || !strings.HasSuffix(name, ".json") || !filepath.IsLocal(name) {
+ t.Fatalf("CredentialFileName() = %q, want local xai-*.json filename", name)
+ }
+ }
+}
+
+func TestCredentialFileNameStableForSameAccount(t *testing.T) {
+ first := CredentialFileName("User+Work@example.com", "sub-1")
+ second := CredentialFileName(" user+work@example.com ", "sub-1")
+ if first != second {
+ t.Fatalf("re-login changed account file: %q != %q", first, second)
+ }
+}
+
+func TestCredentialFileNameStableWhenSubjectAvailabilityChanges(t *testing.T) {
+ withoutSubject := CredentialFileName("user@example.com", "")
+ withSubject := CredentialFileName("user@example.com", "sub-1")
+ if withoutSubject != withSubject {
+ t.Fatalf("subject availability changed account file: %q != %q", withoutSubject, withSubject)
+ }
+}
+
+func TestCredentialFileNameSeparatesSubjects(t *testing.T) {
+ if got := CredentialFileName("", "sub one"); got == CredentialFileName("", "sub-one") {
+ t.Fatalf("subject-only filenames collide: %q", got)
+ }
+}
+
+func TestCredentialFileNameLongEmailFitsFilesystem(t *testing.T) {
+ name := CredentialFileName(strings.Repeat("a", 300)+"@example.com", "sub-1")
+ if len(name) > 255 || !filepath.IsLocal(name) {
+ t.Fatalf("filename %q (len %d) cannot be saved on common filesystems", name, len(name))
+ }
+ shortTail := CredentialFileName(strings.Repeat("a", 300)+"b@example.com", "sub-1")
+ if name == shortTail {
+ t.Fatalf("long emails differing past truncation point collide: %q", name)
+ }
+}
+
+func TestFindMatchingLegacyCredential(t *testing.T) {
+ const collidingLegacyFileName = "xai-a-b@example.com.json"
+
+ tests := []struct {
+ name string
+ targetEmail string
+ targetSubject string
+ legacyEmail string
+ legacySubject string
+ // legacyFileName overrides the default colliding legacy filename so a
+ // fixture can carry the exact predecessor name it claims to exercise.
+ legacyFileName string
+ wantMatch bool
+ }{
+ {
+ name: "same account migrates from legacy filename",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ legacyFileName: collidingLegacyFileName,
+ wantMatch: true,
+ },
+ {
+ name: "colliding account does not claim the other account file",
+ targetEmail: "a-b@example.com",
+ targetSubject: "sub-second",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ legacyFileName: collidingLegacyFileName,
+ },
+ {
+ name: "conflicting subject blocks migration",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-other",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ legacyFileName: collidingLegacyFileName,
+ },
+ {
+ name: "conflicting email blocks migration",
+ targetEmail: "a-b@example.com",
+ targetSubject: "sub-shared",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-shared",
+ legacyFileName: collidingLegacyFileName,
+ },
+ {
+ name: "matching email with missing stored subject migrates",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacyFileName: collidingLegacyFileName,
+ wantMatch: true,
+ },
+ {
+ name: "legacy file without stored identity stays unclaimed",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyFileName: collidingLegacyFileName,
+ },
+ {
+ name: "disjoint identity fields stay unclaimed",
+ targetEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ legacyFileName: "xai-sub-first.json",
+ },
+ {
+ name: "matching subject migrates from pre-hash subject filename",
+ targetSubject: "sub-first",
+ legacySubject: "sub-first",
+ legacyFileName: "xai-sub-first.json",
+ wantMatch: true,
+ },
+ {
+ name: "pre-hash subject filename differing only by case stays unclaimed",
+ targetSubject: "abc",
+ legacySubject: "ABC",
+ legacyFileName: "xai-ABC.json",
+ },
+ {
+ name: "email gained later migrates hashed subject-only predecessor",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacySubject: "sub-first",
+ wantMatch: true,
+ },
+ {
+ name: "email gained later migrates pre-hash subject-only predecessor",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacySubject: "sub-first",
+ legacyFileName: "xai-sub-first.json",
+ wantMatch: true,
+ },
+ {
+ name: "email lost later selects email-hashed predecessor",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ wantMatch: true,
+ },
+ {
+ name: "canonical destination file is not its own predecessor",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ // Default fixture name is the hashed name, identical to the
+ // target's own destination on an ordinary re-login.
+ },
+ {
+ name: "custom-named credential is never claimed",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ // Same stored identity, but the file was renamed or imported;
+ // migration must not merge or delete a file it does not own.
+ legacyFileName: "my-xai.json",
+ },
+ {
+ name: "subject-named file with backfilled email stays reconcilable",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ // Hashed subject-only name; the stored identity gained the email
+ // when an earlier migration merged it into this file.
+ legacyFileName: CredentialFileName("", "sub-first"),
+ wantMatch: true,
+ },
+ {
+ name: "pre-hash subject-named file with backfilled email stays reconcilable",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ legacyFileName: "xai-sub-first.json",
+ wantMatch: true,
+ },
+ {
+ name: "subject-only file named by foreign identity stays unclaimed",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ // The filename was derived from a different subject than the stored
+ // identity, so the file is not one the login flow produced for it.
+ legacyFileName: CredentialFileName("", "sub-other"),
+ },
+ {
+ name: "nested credential is never claimed",
+ targetEmail: "a+b@example.com",
+ targetSubject: "sub-first",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "sub-first",
+ legacyFileName: "team/" + collidingLegacyFileName,
+ },
+ {
+ name: "unsanitizable subject still reconciles by identity",
+ targetSubject: "+++",
+ legacyEmail: "a+b@example.com",
+ legacySubject: "+++",
+ wantMatch: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ targetFileName := CredentialFileName(tt.targetEmail, tt.targetSubject)
+ target := &coreauth.Auth{
+ ID: targetFileName,
+ FileName: targetFileName,
+ Provider: "xai",
+ Metadata: map[string]any{"email": tt.targetEmail, "sub": tt.targetSubject},
+ }
+ legacyFileName := tt.legacyFileName
+ if legacyFileName == "" {
+ // Default to the predecessor filename the stored identity itself
+ // would have produced; fixtures that need the ambiguous
+ // colliding name set legacyFileName explicitly.
+ legacyFileName = CredentialFileName(tt.legacyEmail, tt.legacySubject)
+ }
+ legacy := &coreauth.Auth{
+ ID: legacyFileName,
+ FileName: legacyFileName,
+ Provider: "xai",
+ Metadata: map[string]any{"email": tt.legacyEmail, "sub": tt.legacySubject},
+ }
+ got, err := FindMatchingLegacyCredential(context.Background(), &credentialListStore{records: []*coreauth.Auth{legacy}}, target)
+ if err != nil {
+ t.Fatalf("FindMatchingLegacyCredential() error = %v", err)
+ }
+ if (got != nil) != tt.wantMatch {
+ t.Fatalf("FindMatchingLegacyCredential() match = %v, want %v", got != nil, tt.wantMatch)
+ }
+ })
+ }
+}
diff --git a/internal/auth/xai/token.go b/internal/auth/xai/token.go
index 100d3322a9f..6fe82cec907 100644
--- a/internal/auth/xai/token.go
+++ b/internal/auth/xai/token.go
@@ -5,8 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
- "strings"
- "time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
log "github.com/sirupsen/logrus"
@@ -68,39 +66,3 @@ func (ts *TokenStorage) SaveTokenToFile(authFilePath string) error {
}
return nil
}
-
-// CredentialFileName returns the filename used for xAI credentials.
-func CredentialFileName(email, subject string) string {
- email = sanitizeFileSegment(email)
- if email != "" {
- return fmt.Sprintf("xai-%s.json", email)
- }
- subject = sanitizeFileSegment(subject)
- if subject != "" {
- return fmt.Sprintf("xai-%s.json", subject)
- }
- return fmt.Sprintf("xai-%d.json", time.Now().UnixMilli())
-}
-
-func sanitizeFileSegment(value string) string {
- value = strings.TrimSpace(value)
- if value == "" {
- return ""
- }
- var b strings.Builder
- for _, r := range value {
- switch {
- case r >= 'a' && r <= 'z':
- b.WriteRune(r)
- case r >= 'A' && r <= 'Z':
- b.WriteRune(r)
- case r >= '0' && r <= '9':
- b.WriteRune(r)
- case r == '@' || r == '.' || r == '_' || r == '-':
- b.WriteRune(r)
- default:
- b.WriteRune('-')
- }
- }
- return strings.Trim(b.String(), "-")
-}
diff --git a/internal/auth/xai/xai.go b/internal/auth/xai/xai.go
index 39009d57982..7fe735fc4f0 100644
--- a/internal/auth/xai/xai.go
+++ b/internal/auth/xai/xai.go
@@ -2,7 +2,6 @@ package xai
import (
"context"
- "encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -11,6 +10,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
@@ -466,9 +466,8 @@ func parseJWTIdentity(token string) (email string, subject string) {
if len(parts) < 2 {
return "", ""
}
- payload := parts[1]
- payload += strings.Repeat("=", (4-len(payload)%4)%4)
- raw, err := base64.URLEncoding.DecodeString(payload)
+ payload := strings.TrimRight(parts[1], "=")
+ raw, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return "", ""
}
diff --git a/internal/auth/xai/xai_auth_test.go b/internal/auth/xai/xai_auth_test.go
index 5773fd90419..92e9e29dce3 100644
--- a/internal/auth/xai/xai_auth_test.go
+++ b/internal/auth/xai/xai_auth_test.go
@@ -2,7 +2,6 @@ package xai
import (
"context"
- "encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -13,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/base64"
"golang.org/x/sync/singleflight"
)
@@ -333,7 +333,7 @@ func TestXAIAuthDefaultPollInterval(t *testing.T) {
}
func fakeJWTWithEmail(email, subject string) string {
- header := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
- payload := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"email":"` + email + `","sub":"` + subject + `"}`))
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"email":"` + email + `","sub":"` + subject + `"}`))
return header + "." + payload + ".sig"
}
diff --git a/internal/cache/antigravity_reasoning_replay_cache.go b/internal/cache/antigravity_reasoning_replay_cache.go
index c6254cf9db7..a53017f400f 100644
--- a/internal/cache/antigravity_reasoning_replay_cache.go
+++ b/internal/cache/antigravity_reasoning_replay_cache.go
@@ -11,10 +11,10 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -429,12 +429,12 @@ func marshalAntigravityReasoningReplayHomeValue(items [][]byte, branch string) (
if branch == "" {
branch = newAntigravityReasoningReplayGeneration()
}
- marker := []byte(`{"type":"","generation":"","branch":""}`)
- marker, _ = sjson.SetBytes(marker, "type", antigravityReasoningReplayGenerationItemType)
- marker, _ = sjson.SetBytes(marker, "generation", newAntigravityReasoningReplayGeneration())
- marker, _ = sjson.SetBytes(marker, "branch", branch)
+ doc := ojson.NewDocString(`{"type":"","generation":"","branch":""}`)
+ _ = doc.Set("type", antigravityReasoningReplayGenerationItemType)
+ _ = doc.Set("generation", newAntigravityReasoningReplayGeneration())
+ _ = doc.Set("branch", branch)
stored := make([][]byte, 0, len(items)+1)
- stored = append(stored, marker)
+ stored = append(stored, doc.Bytes())
stored = append(stored, items...)
return json.Marshal(stored)
}
@@ -443,10 +443,10 @@ func decodeAntigravityReasoningReplayHomeValue(raw []byte) (items [][]byte, dele
if errUnmarshal := json.Unmarshal(raw, &items); errUnmarshal != nil {
return nil, false, "", "", false
}
- if len(items) == 0 || strings.TrimSpace(gjson.GetBytes(items[0], "type").String()) != antigravityReasoningReplayGenerationItemType {
+ if len(items) == 0 || strings.TrimSpace(ojson.GetBytesNoCopy(items[0], "type").String()) != antigravityReasoningReplayGenerationItemType {
return items, false, "", "", true
}
- marker := gjson.ParseBytes(items[0])
+ marker := util.ParseGJSONBytesNoCopy(items[0])
deleted = marker.Get("deleted").Bool()
generation = strings.TrimSpace(marker.Get("generation").String())
branch = strings.TrimSpace(marker.Get("branch").String())
@@ -466,11 +466,11 @@ func antigravityReasoningReplayItemsPrefix(prefix, items [][]byte) bool {
}
func newAntigravityReasoningReplayTombstone() []byte {
- marker := []byte(`{"type":"","generation":"","branch":"","deleted":true}`)
- marker, _ = sjson.SetBytes(marker, "type", antigravityReasoningReplayGenerationItemType)
- marker, _ = sjson.SetBytes(marker, "generation", newAntigravityReasoningReplayGeneration())
- marker, _ = sjson.SetBytes(marker, "branch", newAntigravityReasoningReplayGeneration())
- raw, _ := json.Marshal([][]byte{marker})
+ doc := ojson.NewDocString(`{"type":"","generation":"","branch":"","deleted":true}`)
+ _ = doc.Set("type", antigravityReasoningReplayGenerationItemType)
+ _ = doc.Set("generation", newAntigravityReasoningReplayGeneration())
+ _ = doc.Set("branch", newAntigravityReasoningReplayGeneration())
+ raw, _ := json.Marshal([][]byte{doc.Bytes()})
return raw
}
@@ -517,7 +517,7 @@ func normalizeAntigravityReasoningReplayItems(items [][]byte) ([][]byte, bool) {
}
func normalizeAntigravityReasoningReplayItem(item []byte) ([]byte, bool) {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "thought_signature":
return normalizeAntigravityThoughtSignatureReplayItem(itemResult)
@@ -528,7 +528,7 @@ func normalizeAntigravityReasoningReplayItem(item []byte) ([]byte, bool) {
}
}
-func normalizeAntigravityThoughtSignatureReplayItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeAntigravityThoughtSignatureReplayItem(itemResult ojson.Result) ([]byte, bool) {
sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
if sig == "" {
sig = strings.TrimSpace(itemResult.Get("thought_signature").String())
@@ -536,30 +536,30 @@ func normalizeAntigravityThoughtSignatureReplayItem(itemResult gjson.Result) ([]
if sig == "" || sig == "skip_thought_signature_validator" || len(sig) < minAntigravityThoughtSignatureReplayLen {
return nil, false
}
- normalized := []byte(`{"type":"thought_signature"}`)
- normalized, _ = sjson.SetBytes(normalized, "thoughtSignature", sig)
- if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == gjson.Number {
- normalized, _ = sjson.SetBytes(normalized, "contentIndex", contentIndex.Int())
+ doc := ojson.NewDocString(`{"type":"thought_signature"}`)
+ _ = doc.Set("thoughtSignature", sig)
+ if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == ojson.Number {
+ _ = doc.Set("contentIndex", contentIndex.Int())
}
- if partIndex := itemResult.Get("partIndex"); partIndex.Type == gjson.Number {
- normalized, _ = sjson.SetBytes(normalized, "partIndex", partIndex.Int())
+ if partIndex := itemResult.Get("partIndex"); partIndex.Type == ojson.Number {
+ _ = doc.Set("partIndex", partIndex.Int())
}
if targetKind := strings.TrimSpace(itemResult.Get("targetKind").String()); targetKind == "text" || targetKind == "thought" {
- normalized, _ = sjson.SetBytes(normalized, "targetKind", targetKind)
+ _ = doc.Set("targetKind", targetKind)
}
if targetHash := strings.TrimSpace(itemResult.Get("targetHash").String()); targetHash != "" {
- normalized, _ = sjson.SetBytes(normalized, "targetHash", targetHash)
+ _ = doc.Set("targetHash", targetHash)
}
- if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == gjson.Number && targetOccurrence.Int() >= 0 {
- normalized, _ = sjson.SetBytes(normalized, "targetOccurrence", targetOccurrence.Int())
+ if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == ojson.Number && targetOccurrence.Int() >= 0 {
+ _ = doc.Set("targetOccurrence", targetOccurrence.Int())
}
if contextHash := strings.TrimSpace(itemResult.Get("contextHash").String()); contextHash != "" {
- normalized, _ = sjson.SetBytes(normalized, "contextHash", contextHash)
+ _ = doc.Set("contextHash", contextHash)
}
- return normalized, true
+ return doc.Bytes(), true
}
-func normalizeAntigravityFunctionCallPartReplayItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeAntigravityFunctionCallPartReplayItem(itemResult ojson.Result) ([]byte, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
if callID == "" {
callID = strings.TrimSpace(itemResult.Get("id").String())
@@ -583,33 +583,33 @@ func normalizeAntigravityFunctionCallPartReplayItem(itemResult gjson.Result) ([]
if name == "" || !args.Exists() {
return nil, false
}
- normalized := []byte(`{"type":"function_call_part"}`)
+ doc := ojson.NewDocString(`{"type":"function_call_part"}`)
if callID != "" {
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
+ _ = doc.Set("call_id", callID)
}
- normalized, _ = sjson.SetBytes(normalized, "name", name)
- if args.Type == gjson.String {
- normalized, _ = sjson.SetBytes(normalized, "args", args.String())
+ _ = doc.Set("name", name)
+ if args.Type == ojson.String {
+ _ = doc.Set("args", args.String())
} else {
- normalized, _ = sjson.SetRawBytes(normalized, "args", []byte(args.Raw))
+ _ = doc.SetRaw("args", args.Raw)
}
sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
if sig != "" && sig != "skip_thought_signature_validator" {
- normalized, _ = sjson.SetBytes(normalized, "thoughtSignature", sig)
+ _ = doc.Set("thoughtSignature", sig)
}
- if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == gjson.Number {
- normalized, _ = sjson.SetBytes(normalized, "contentIndex", contentIndex.Int())
+ if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == ojson.Number {
+ _ = doc.Set("contentIndex", contentIndex.Int())
}
- if partIndex := itemResult.Get("partIndex"); partIndex.Type == gjson.Number {
- normalized, _ = sjson.SetBytes(normalized, "partIndex", partIndex.Int())
+ if partIndex := itemResult.Get("partIndex"); partIndex.Type == ojson.Number {
+ _ = doc.Set("partIndex", partIndex.Int())
}
- if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == gjson.Number && targetOccurrence.Int() >= 0 {
- normalized, _ = sjson.SetBytes(normalized, "targetOccurrence", targetOccurrence.Int())
+ if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == ojson.Number && targetOccurrence.Int() >= 0 {
+ _ = doc.Set("targetOccurrence", targetOccurrence.Int())
}
if contextHash := strings.TrimSpace(itemResult.Get("contextHash").String()); contextHash != "" {
- normalized, _ = sjson.SetBytes(normalized, "contextHash", contextHash)
+ _ = doc.Set("contextHash", contextHash)
}
- return normalized, true
+ return doc.Bytes(), true
}
func cloneAntigravityReasoningReplayItems(items [][]byte) [][]byte {
diff --git a/internal/cache/antigravity_reasoning_replay_cache_test.go b/internal/cache/antigravity_reasoning_replay_cache_test.go
index 708f2bf9f16..c0215c7f919 100644
--- a/internal/cache/antigravity_reasoning_replay_cache_test.go
+++ b/internal/cache/antigravity_reasoning_replay_cache_test.go
@@ -9,8 +9,8 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
- "github.com/tidwall/gjson"
)
type fakeAntigravityReasoningReplayKVClient struct {
@@ -507,7 +507,7 @@ func TestAntigravityReasoningReplayHomeWritesRemainLegacyArrayReadable(t *testin
if errUnmarshal := json.Unmarshal(raw, &legacyItems); errUnmarshal != nil {
t.Fatalf("new Home value is not readable as legacy [][]byte: %v", errUnmarshal)
}
- if len(legacyItems) != 2 || gjson.GetBytes(legacyItems[0], "type").String() != antigravityReasoningReplayGenerationItemType || !bytes.Contains(legacyItems[1], []byte("legacy-readable-signature")) {
+ if len(legacyItems) != 2 || ojson.GetBytes(legacyItems[0], "type").String() != antigravityReasoningReplayGenerationItemType || !bytes.Contains(legacyItems[1], []byte("legacy-readable-signature")) {
t.Fatalf("legacy-readable Home array malformed: %q", legacyItems)
}
}
diff --git a/internal/cache/claude_thinking_replay_cache.go b/internal/cache/claude_thinking_replay_cache.go
index 6b1e7d8a3e3..b5734646ccf 100644
--- a/internal/cache/claude_thinking_replay_cache.go
+++ b/internal/cache/claude_thinking_replay_cache.go
@@ -10,10 +10,11 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
const (
@@ -321,7 +322,7 @@ func marshalClaudeThinkingReplayHomeValue(generation string, deleted bool, conte
}
func decodeClaudeThinkingReplayHomeValue(raw []byte) ([][]byte, string, bool, bool) {
- if len(raw) == 0 || len(raw) > claudeThinkingReplayCacheMaxSerializedBytes || !gjson.ValidBytes(raw) {
+ if len(raw) == 0 || len(raw) > claudeThinkingReplayCacheMaxSerializedBytes || !ojson.ValidBytes(raw) {
return nil, "", false, false
}
var value claudeThinkingReplayHomeValue
@@ -411,10 +412,10 @@ func claudeThinkingReplayKVKey(modelFamily, sessionKey string) string {
}
func validClaudeThinkingReplayContent(content []byte) bool {
- if len(content) == 0 || len(content) > ClaudeThinkingReplayCacheMaxBytesPerSession || !gjson.ValidBytes(content) {
+ if len(content) == 0 || len(content) > ClaudeThinkingReplayCacheMaxBytesPerSession || !ojson.ValidBytes(content) {
return false
}
- root := gjson.ParseBytes(content)
+ root := util.ParseGJSONBytesNoCopy(content)
return root.IsArray() && len(root.Array()) > 0 && len(root.Array()) <= ClaudeThinkingReplayCacheMaxBlocksPerTurn
}
diff --git a/internal/cache/codex_reasoning_replay_cache.go b/internal/cache/codex_reasoning_replay_cache.go
index d899218821b..f9898789816 100644
--- a/internal/cache/codex_reasoning_replay_cache.go
+++ b/internal/cache/codex_reasoning_replay_cache.go
@@ -8,11 +8,11 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -188,17 +188,17 @@ func AppendCodexReasoningReplayItemsBestEffort(ctx context.Context, modelName, s
}
func appendCodexReasoningReplayTurn(existing, turn [][]byte) [][]byte {
- if len(existing) > 0 && strings.TrimSpace(gjson.GetBytes(existing[0], "type").String()) != CodexReasoningReplayTurnType {
+ if len(existing) > 0 && strings.TrimSpace(ojson.GetBytesNoCopy(existing[0], "type").String()) != CodexReasoningReplayTurnType {
existing = nil
}
turnID := ""
- if len(turn) > 0 && strings.TrimSpace(gjson.GetBytes(turn[0], "type").String()) == CodexReasoningReplayTurnType {
- turnID = strings.TrimSpace(gjson.GetBytes(turn[0], "id").String())
+ if len(turn) > 0 && strings.TrimSpace(ojson.GetBytesNoCopy(turn[0], "type").String()) == CodexReasoningReplayTurnType {
+ turnID = strings.TrimSpace(ojson.GetBytesNoCopy(turn[0], "id").String())
}
if turnID != "" {
for _, item := range existing {
- if strings.TrimSpace(gjson.GetBytes(item, "type").String()) == CodexReasoningReplayTurnType &&
- strings.TrimSpace(gjson.GetBytes(item, "id").String()) == turnID {
+ if strings.TrimSpace(ojson.GetBytesNoCopy(item, "type").String()) == CodexReasoningReplayTurnType &&
+ strings.TrimSpace(ojson.GetBytesNoCopy(item, "id").String()) == turnID {
return trimCodexReasoningReplayItems(cloneCodexReasoningReplayItems(existing))
}
}
@@ -215,7 +215,7 @@ func trimCodexReasoningReplayItems(items [][]byte) [][]byte {
totalBytes := 0
for index, item := range items {
totalBytes += len(item)
- if index > 0 && strings.TrimSpace(gjson.GetBytes(item, "type").String()) == CodexReasoningReplayTurnType {
+ if index > 0 && strings.TrimSpace(ojson.GetBytesNoCopy(item, "type").String()) == CodexReasoningReplayTurnType {
turnStarts = append(turnStarts, index)
}
}
@@ -236,7 +236,7 @@ func GetCodexReasoningReplayItem(modelName, sessionKey string) ([]byte, bool) {
return nil, false
}
for _, item := range items {
- if strings.TrimSpace(gjson.GetBytes(item, "type").String()) != CodexReasoningReplayTurnType {
+ if strings.TrimSpace(ojson.GetBytesNoCopy(item, "type").String()) != CodexReasoningReplayTurnType {
return item, true
}
}
@@ -357,7 +357,7 @@ func normalizeCodexReasoningReplayItems(items [][]byte) ([][]byte, bool) {
}
func normalizeCodexReasoningReplayItem(item []byte) ([]byte, bool) {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case CodexReasoningReplayTurnType:
return normalizeCodexReasoningReplayTurn(itemResult)
@@ -372,33 +372,33 @@ func normalizeCodexReasoningReplayItem(item []byte) ([]byte, bool) {
}
}
-func normalizeCodexReasoningReplayTurn(itemResult gjson.Result) ([]byte, bool) {
+func normalizeCodexReasoningReplayTurn(itemResult ojson.Result) ([]byte, bool) {
turnID := strings.TrimSpace(itemResult.Get("id").String())
if turnID == "" {
return nil, false
}
- normalized := []byte(`{"type":"` + CodexReasoningReplayTurnType + `"}`)
- normalized, _ = sjson.SetBytes(normalized, "id", turnID)
+ doc := ojson.NewDocString(`{"type":"` + CodexReasoningReplayTurnType + `"}`)
+ _ = doc.Set("id", turnID)
if fingerprint := strings.TrimSpace(itemResult.Get("assistant_fingerprint").String()); fingerprint != "" {
- normalized, _ = sjson.SetBytes(normalized, "assistant_fingerprint", fingerprint)
+ _ = doc.Set("assistant_fingerprint", fingerprint)
}
if fingerprint := strings.TrimSpace(itemResult.Get("request_fingerprint").String()); fingerprint != "" {
- normalized, _ = sjson.SetBytes(normalized, "request_fingerprint", fingerprint)
+ _ = doc.Set("request_fingerprint", fingerprint)
}
callIDs := itemResult.Get("call_ids")
if callIDs.IsArray() {
for _, callIDResult := range callIDs.Array() {
if callID := strings.TrimSpace(callIDResult.String()); callID != "" {
- normalized, _ = sjson.SetBytes(normalized, "call_ids.-1", callID)
+ _ = doc.Set("call_ids.-1", callID)
}
}
}
- return normalized, true
+ return doc.Bytes(), true
}
-func normalizeCodexReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeCodexReasoningReplayReasoningItem(itemResult ojson.Result) ([]byte, bool) {
encryptedContentResult := itemResult.Get("encrypted_content")
- if encryptedContentResult.Type != gjson.String {
+ if encryptedContentResult.Type != ojson.String {
return nil, false
}
encryptedContent := encryptedContentResult.String()
@@ -410,26 +410,26 @@ func normalizeCodexReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte
}
normalized := []byte(`{"type":"reasoning","summary":[],"content":null}`)
- normalized, _ = sjson.SetBytes(normalized, "encrypted_content", encryptedContent)
+ normalized, _ = ojson.SetBytes(normalized, "encrypted_content", encryptedContent)
return normalized, true
}
-func normalizeCodexReasoningReplayFunctionCallItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeCodexReasoningReplayFunctionCallItem(itemResult ojson.Result) ([]byte, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
name := strings.TrimSpace(itemResult.Get("name").String())
arguments := itemResult.Get("arguments")
- if callID == "" || name == "" || arguments.Type != gjson.String {
+ if callID == "" || name == "" || arguments.Type != ojson.String {
return nil, false
}
- normalized := []byte(`{"type":"function_call"}`)
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
- normalized, _ = sjson.SetBytes(normalized, "name", name)
- normalized, _ = sjson.SetBytes(normalized, "arguments", arguments.String())
- return normalized, true
+ doc := ojson.NewDocString(`{"type":"function_call"}`)
+ _ = doc.Set("call_id", callID)
+ _ = doc.Set("name", name)
+ _ = doc.Set("arguments", arguments.String())
+ return doc.Bytes(), true
}
-func normalizeCodexReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeCodexReasoningReplayCustomToolCallItem(itemResult ojson.Result) ([]byte, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
name := strings.TrimSpace(itemResult.Get("name").String())
input := itemResult.Get("input")
@@ -437,18 +437,18 @@ func normalizeCodexReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([
return nil, false
}
- normalized := []byte(`{"type":"custom_tool_call","status":"completed"}`)
+ doc := ojson.NewDocString(`{"type":"custom_tool_call","status":"completed"}`)
if status := strings.TrimSpace(itemResult.Get("status").String()); status != "" {
- normalized, _ = sjson.SetBytes(normalized, "status", status)
+ _ = doc.Set("status", status)
}
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
- normalized, _ = sjson.SetBytes(normalized, "name", name)
- if input.Type == gjson.String {
- normalized, _ = sjson.SetBytes(normalized, "input", input.String())
+ _ = doc.Set("call_id", callID)
+ _ = doc.Set("name", name)
+ if input.Type == ojson.String {
+ _ = doc.Set("input", input.String())
} else {
- normalized, _ = sjson.SetRawBytes(normalized, "input", []byte(input.Raw))
+ _ = doc.SetRaw("input", input.Raw)
}
- return normalized, true
+ return doc.Bytes(), true
}
func cloneCodexReasoningReplayItems(items [][]byte) [][]byte {
diff --git a/internal/cache/codex_reasoning_replay_cache_test.go b/internal/cache/codex_reasoning_replay_cache_test.go
index f47a45b1e71..441a2d928d4 100644
--- a/internal/cache/codex_reasoning_replay_cache_test.go
+++ b/internal/cache/codex_reasoning_replay_cache_test.go
@@ -3,16 +3,16 @@ package cache
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"sync"
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
- "github.com/tidwall/gjson"
)
type fakeCodexReasoningReplayKVClient struct {
@@ -244,10 +244,10 @@ func TestCodexReasoningReplayAppendPreservesCumulativeTurnsInHome(t *testing.T)
if len(items) != 4 {
t.Fatalf("cumulative item count = %d, want 4: %q", len(items), items)
}
- if got := gjson.GetBytes(items[0], "id").String(); got != "turn-1" {
+ if got := ojson.GetBytes(items[0], "id").String(); got != "turn-1" {
t.Fatalf("first turn id = %q, want turn-1", got)
}
- if got := gjson.GetBytes(items[2], "id").String(); got != "turn-2" {
+ if got := ojson.GetBytes(items[2], "id").String(); got != "turn-2" {
t.Fatalf("second turn id = %q, want turn-2", got)
}
}
@@ -297,7 +297,7 @@ func TestCodexReasoningReplayAppendBoundsTurnsPerEntry(t *testing.T) {
if len(trimmed) != CodexReasoningReplayCacheMaxTurnsPerEntry*2 {
t.Fatalf("trimmed item count = %d, want %d", len(trimmed), CodexReasoningReplayCacheMaxTurnsPerEntry*2)
}
- if firstID := gjson.GetBytes(trimmed[0], "id").String(); firstID != "turn-1" {
+ if firstID := ojson.GetBytes(trimmed[0], "id").String(); firstID != "turn-1" {
t.Fatalf("first retained turn = %q, want turn-1", firstID)
}
}
diff --git a/internal/cache/issue6332_delivery_test.go b/internal/cache/issue6332_delivery_test.go
index 73573229b8e..afb1900433b 100644
--- a/internal/cache/issue6332_delivery_test.go
+++ b/internal/cache/issue6332_delivery_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/api/middleware"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
@@ -22,8 +23,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers/openai"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func TestIssue6332ReplayCommitPrecedesCompletion(t *testing.T) {
@@ -93,7 +92,7 @@ func TestIssue6332ReplayCommitPrecedesCompletion(t *testing.T) {
return writer.Body.String()
}
first := `{"model":"gemini-3.7-flash","input":[{"role":"user","content":"hello"}],"stream":true,"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"key":{"type":"string"}}}}]}`
- first, _ = sjson.Set(first, "metadata.session_id", session)
+ first, _ = ojson.Set(first, "metadata.session_id", session)
firstDone := make(chan string, 1)
go func() { firstDone <- call(first) }()
<-requests
@@ -105,12 +104,12 @@ func TestIssue6332ReplayCommitPrecedesCompletion(t *testing.T) {
}
unblock()
downstream := <-firstDone
- var functionCall gjson.Result
+ var functionCall ojson.Result
for _, line := range strings.Split(downstream, "\n") {
if !strings.HasPrefix(line, "data:") {
continue
}
- event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ event := ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
if event.Get("type").String() == "response.completed" {
for _, item := range event.Get("response.output").Array() {
if item.Get("type").String() == "function_call" {
@@ -123,12 +122,12 @@ func TestIssue6332ReplayCommitPrecedesCompletion(t *testing.T) {
t.Fatalf("no completed tool call: %s", downstream)
}
// Deliberately omit encrypted reasoning: restoration must come from the session ledger.
- second, _ := sjson.Set(first, "input.-1", map[string]any{"type": "function_call", "call_id": functionCall.Get("call_id").String(), "name": "lookup", "arguments": `{"key":"value"}`})
- second, _ = sjson.Set(second, "input.-1", map[string]any{"type": "function_call_output", "call_id": functionCall.Get("call_id").String(), "output": "found"})
+ second, _ := ojson.Set(first, "input.-1", map[string]any{"type": "function_call", "call_id": functionCall.Get("call_id").String(), "name": "lookup", "arguments": `{"key":"value"}`})
+ second, _ = ojson.Set(second, "input.-1", map[string]any{"type": "function_call_output", "call_id": functionCall.Get("call_id").String(), "output": "found"})
call(second)
upstream := <-requests
restored := false
- for _, content := range gjson.GetBytes(upstream, "request.contents").Array() {
+ for _, content := range ojson.GetBytes(upstream, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("functionCall.name").String() == "lookup" && part.Get("thoughtSignature").String() == signature {
restored = true
diff --git a/internal/cache/kimi_thinking_replay_cache.go b/internal/cache/kimi_thinking_replay_cache.go
index f15180ad3d5..8a9c1848397 100644
--- a/internal/cache/kimi_thinking_replay_cache.go
+++ b/internal/cache/kimi_thinking_replay_cache.go
@@ -9,10 +9,11 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
const (
@@ -316,10 +317,10 @@ func marshalKimiThinkingReplayHomeValue(generation string, deleted bool, content
}
func decodeKimiThinkingReplayHomeValue(raw []byte) ([]byte, string, bool, bool) {
- if len(raw) == 0 || len(raw) > kimiThinkingReplayCacheMaxSerializedBytes || !gjson.ValidBytes(raw) {
+ if len(raw) == 0 || len(raw) > kimiThinkingReplayCacheMaxSerializedBytes || !ojson.ValidBytes(raw) {
return nil, "", false, false
}
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
if root.IsArray() {
if !validKimiThinkingReplayContent(raw) {
return nil, "", false, false
@@ -372,10 +373,10 @@ func kimiThinkingReplayKVKey(modelFamily, sessionKey string) string {
}
func validKimiThinkingReplayContent(content []byte) bool {
- if len(content) == 0 || len(content) > KimiThinkingReplayCacheMaxBytesPerEntry || !gjson.ValidBytes(content) {
+ if len(content) == 0 || len(content) > KimiThinkingReplayCacheMaxBytesPerEntry || !ojson.ValidBytes(content) {
return false
}
- root := gjson.ParseBytes(content)
+ root := util.ParseGJSONBytesNoCopy(content)
return root.IsArray() && len(root.Array()) > 0 && len(root.Array()) <= KimiThinkingReplayCacheMaxBlocksPerEntry
}
diff --git a/internal/cache/kv_store_bbolt_test.go b/internal/cache/kv_store_bbolt_test.go
new file mode 100644
index 00000000000..79c1f3d3a92
--- /dev/null
+++ b/internal/cache/kv_store_bbolt_test.go
@@ -0,0 +1,117 @@
+package cache
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
+)
+
+func TestKVStoreBBoltPersistenceAndRouting(t *testing.T) {
+ homekv.ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ ClearSignatureCache("")
+ ClearCodexReasoningReplayCache()
+ ClearClaudeWebSearchResultCache()
+ t.Cleanup(func() {
+ homekv.ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ ClearSignatureCache("")
+ ClearCodexReasoningReplayCache()
+ ClearClaudeWebSearchResultCache()
+ })
+
+ dbPath := filepath.Join(t.TempDir(), "kv.db")
+ if errCfg := kvstore.ConfigureLocal(true, dbPath); errCfg != nil {
+ t.Fatalf("kvstore.ConfigureLocal(true, %q) error = %v", dbPath, errCfg)
+ }
+
+ // 1. Signature cache persists to bbolt when in-memory cache is cleared.
+ sigText := "thinking text persisted in bbolt"
+ sigVal := "bboltValidSignature1234567890123456789012345678901234567890"
+ CacheSignature(testModelName, sigText, sigVal)
+ ClearSignatureCache("")
+ if gotSig := GetCachedSignature(testModelName, sigText); gotSig != sigVal {
+ t.Fatalf("GetCachedSignature() after ClearSignatureCache = %q, want %q", gotSig, sigVal)
+ }
+
+ // 2. Codex reasoning replay items persist to bbolt (including CAS append) when in-memory map is cleared.
+ turn1 := [][]byte{
+ []byte(`{"type":"` + CodexReasoningReplayTurnType + `","id":"turn-1","assistant_fingerprint":"answer-1"}`),
+ validCodexReasoningReplayItemForTest(21),
+ }
+ turn2 := [][]byte{
+ []byte(`{"type":"` + CodexReasoningReplayTurnType + `","id":"turn-2","call_ids":["call-2"]}`),
+ validCodexReasoningReplayItemForTest(22),
+ }
+ if !CacheCodexReasoningReplayItems("gpt-5.4", "session-bbolt", turn1) {
+ t.Fatalf("CacheCodexReasoningReplayItems() = false, want true")
+ }
+ if !AppendCodexReasoningReplayItemsBestEffort(context.Background(), "gpt-5.4", "session-bbolt", turn2) {
+ t.Fatalf("AppendCodexReasoningReplayItemsBestEffort() = false, want true")
+ }
+ ClearCodexReasoningReplayCache()
+ replayItems, foundReplay := GetCodexReasoningReplayItems("gpt-5.4", "session-bbolt")
+ if !foundReplay || len(replayItems) != 4 {
+ t.Fatalf("GetCodexReasoningReplayItems() after ClearCodexReasoningReplayCache = (len=%d, found=%v), want (len=4, found=true)", len(replayItems), foundReplay)
+ }
+ if gotID1 := ojson.GetBytes(replayItems[0], "id").String(); gotID1 != "turn-1" {
+ t.Fatalf("replayItems[0] id = %q, want turn-1", gotID1)
+ }
+ if gotID2 := ojson.GetBytes(replayItems[2], "id").String(); gotID2 != "turn-2" {
+ t.Fatalf("replayItems[2] id = %q, want turn-2", gotID2)
+ }
+
+ // 3. Claude web search result cache persists to bbolt when in-memory cache is cleared.
+ webContent := []byte(`[{"type":"web_search_result","url":"https://example.com/bbolt"}]`)
+ CacheClaudeWebSearchResult("session-bbolt", "srvtoolu_bbolt", webContent, 30*time.Minute, 16)
+ ClearClaudeWebSearchResultCache()
+ gotWeb, foundWeb := GetClaudeWebSearchResult("session-bbolt", "srvtoolu_bbolt", 30*time.Minute)
+ if !foundWeb || !bytes.Equal(gotWeb, webContent) {
+ t.Fatalf("GetClaudeWebSearchResult() after ClearClaudeWebSearchResultCache = (%q, %v), want (%q, true)", gotWeb, foundWeb, webContent)
+ }
+
+ // 4. When Home mode is active (home.SetCurrent), Home KV takes priority over local bbolt.
+ homeClient := homekv.New(config.HomeConfig{Enabled: true, Host: "127.0.0.1", Port: 1})
+ homekv.SetCurrent(homeClient)
+ if gotHomeSig := GetCachedSignature(testModelName, sigText); gotHomeSig != "" {
+ t.Fatalf("GetCachedSignature() in Home mode = %q, want empty (must not read local bbolt)", gotHomeSig)
+ }
+ if _, errHomeSig := GetCachedSignatureRequired(context.Background(), testModelName, sigText); errHomeSig == nil {
+ t.Fatalf("GetCachedSignatureRequired() in unavailable Home mode = nil error, want Home unavailable error")
+ }
+ homeOnlyText := "thinking text attempted while Home active"
+ CacheSignature(testModelName, homeOnlyText, sigVal)
+ homekv.ClearCurrent()
+
+ // After clearing Home client, bbolt is active again: original key is still readable, Home-only write did not touch bbolt.
+ if gotAfterHome := GetCachedSignature(testModelName, sigText); gotAfterHome != sigVal {
+ t.Fatalf("GetCachedSignature() after ClearCurrent = %q, want %q", gotAfterHome, sigVal)
+ }
+ if gotHomeOnly := GetCachedSignature(testModelName, homeOnlyText); gotHomeOnly != "" {
+ t.Fatalf("GetCachedSignature(homeOnlyText) = %q, want empty", gotHomeOnly)
+ }
+
+ // 5. Disabling local bbolt KV restores standard in-memory cache behavior.
+ if errDisable := kvstore.ConfigureLocal(false, ""); errDisable != nil {
+ t.Fatalf("kvstore.ConfigureLocal(false, \"\") error = %v", errDisable)
+ }
+ if gotAfterDisable := GetCachedSignature(testModelName, sigText); gotAfterDisable != "" {
+ t.Fatalf("GetCachedSignature() after disabling bbolt = %q, want empty in-memory cache", gotAfterDisable)
+ }
+ memText := "thinking text in memory only"
+ CacheSignature(testModelName, memText, sigVal)
+ if gotMem := GetCachedSignature(testModelName, memText); gotMem != sigVal {
+ t.Fatalf("GetCachedSignature() in memory mode = %q, want %q", gotMem, sigVal)
+ }
+ ClearSignatureCache("")
+ if gotCleared := GetCachedSignature(testModelName, memText); gotCleared != "" {
+ t.Fatalf("GetCachedSignature() after ClearSignatureCache in memory mode = %q, want empty", gotCleared)
+ }
+}
diff --git a/internal/cache/xai_reasoning_replay_cache.go b/internal/cache/xai_reasoning_replay_cache.go
index b0bd144e309..b786aeba711 100644
--- a/internal/cache/xai_reasoning_replay_cache.go
+++ b/internal/cache/xai_reasoning_replay_cache.go
@@ -8,11 +8,11 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -248,7 +248,7 @@ func normalizeXAIReasoningReplayItems(items [][]byte) ([][]byte, bool) {
normalizedItem, ok := normalizeXAIReasoningReplayItem(item)
if ok {
normalized = append(normalized, normalizedItem)
- switch strings.TrimSpace(gjson.GetBytes(normalizedItem, "type").String()) {
+ switch strings.TrimSpace(ojson.GetBytesNoCopy(normalizedItem, "type").String()) {
case "reasoning", "function_call", "custom_tool_call":
hasReplayAnchor = true
}
@@ -258,7 +258,7 @@ func normalizeXAIReasoningReplayItems(items [][]byte) ([][]byte, bool) {
}
func normalizeXAIReasoningReplayItem(item []byte) ([]byte, bool) {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "reasoning":
return normalizeXAIReasoningReplayReasoningItem(itemResult)
@@ -273,9 +273,9 @@ func normalizeXAIReasoningReplayItem(item []byte) ([]byte, bool) {
}
}
-func normalizeXAIReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeXAIReasoningReplayReasoningItem(itemResult ojson.Result) ([]byte, bool) {
encryptedContentResult := itemResult.Get("encrypted_content")
- if encryptedContentResult.Type != gjson.String {
+ if encryptedContentResult.Type != ojson.String {
return nil, false
}
encryptedContent := encryptedContentResult.String()
@@ -287,11 +287,11 @@ func normalizeXAIReasoningReplayReasoningItem(itemResult gjson.Result) ([]byte,
}
normalized := []byte(`{"type":"reasoning","summary":[],"content":null}`)
- normalized, _ = sjson.SetBytes(normalized, "encrypted_content", encryptedContent)
+ normalized, _ = ojson.SetBytes(normalized, "encrypted_content", encryptedContent)
return normalized, true
}
-func normalizeXAIReasoningReplayMessageItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeXAIReasoningReplayMessageItem(itemResult ojson.Result) ([]byte, bool) {
if !strings.EqualFold(strings.TrimSpace(itemResult.Get("role").String()), "assistant") {
return nil, false
}
@@ -300,57 +300,55 @@ func normalizeXAIReasoningReplayMessageItem(itemResult gjson.Result) ([]byte, bo
return nil, false
}
- normalized := []byte(`{"type":"message","role":"assistant","content":[]}`)
+ doc := ojson.NewDocString(`{"type":"message","role":"assistant","content":[]}`)
for _, part := range content.Array() {
partType := strings.TrimSpace(part.Get("type").String())
var nextPart []byte
switch partType {
case "output_text":
textValue := part.Get("text")
- if textValue.Type != gjson.String {
+ if textValue.Type != ojson.String {
continue
}
nextPart = []byte(`{"type":"output_text","text":""}`)
- nextPart, _ = sjson.SetBytes(nextPart, "text", textValue.String())
+ nextPart, _ = ojson.SetBytes(nextPart, "text", textValue.String())
case "refusal":
// Responses API refusal parts use the "refusal" field, not "text".
refusalValue := part.Get("refusal")
- if refusalValue.Type != gjson.String {
+ if refusalValue.Type != ojson.String {
continue
}
nextPart = []byte(`{"type":"refusal","refusal":""}`)
- nextPart, _ = sjson.SetBytes(nextPart, "refusal", refusalValue.String())
+ nextPart, _ = ojson.SetBytes(nextPart, "refusal", refusalValue.String())
default:
continue
}
- updated, errSet := sjson.SetRawBytes(normalized, "content.-1", nextPart)
- if errSet != nil {
+ if errSet := doc.SetRawBytes("content.-1", nextPart); errSet != nil {
return nil, false
}
- normalized = updated
}
- if len(gjson.GetBytes(normalized, "content").Array()) == 0 {
+ if len(doc.Get("content").Array()) == 0 {
return nil, false
}
- return normalized, true
+ return doc.Bytes(), true
}
-func normalizeXAIReasoningReplayFunctionCallItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeXAIReasoningReplayFunctionCallItem(itemResult ojson.Result) ([]byte, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
name := strings.TrimSpace(itemResult.Get("name").String())
arguments := itemResult.Get("arguments")
- if callID == "" || name == "" || arguments.Type != gjson.String {
+ if callID == "" || name == "" || arguments.Type != ojson.String {
return nil, false
}
- normalized := []byte(`{"type":"function_call"}`)
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
- normalized, _ = sjson.SetBytes(normalized, "name", name)
- normalized, _ = sjson.SetBytes(normalized, "arguments", arguments.String())
- return normalized, true
+ doc := ojson.NewDocString(`{"type":"function_call"}`)
+ _ = doc.Set("call_id", callID)
+ _ = doc.Set("name", name)
+ _ = doc.Set("arguments", arguments.String())
+ return doc.Bytes(), true
}
-func normalizeXAIReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]byte, bool) {
+func normalizeXAIReasoningReplayCustomToolCallItem(itemResult ojson.Result) ([]byte, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
name := strings.TrimSpace(itemResult.Get("name").String())
input := itemResult.Get("input")
@@ -358,18 +356,18 @@ func normalizeXAIReasoningReplayCustomToolCallItem(itemResult gjson.Result) ([]b
return nil, false
}
- normalized := []byte(`{"type":"custom_tool_call","status":"completed"}`)
+ doc := ojson.NewDocString(`{"type":"custom_tool_call","status":"completed"}`)
if status := strings.TrimSpace(itemResult.Get("status").String()); status != "" {
- normalized, _ = sjson.SetBytes(normalized, "status", status)
+ _ = doc.Set("status", status)
}
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
- normalized, _ = sjson.SetBytes(normalized, "name", name)
- if input.Type == gjson.String {
- normalized, _ = sjson.SetBytes(normalized, "input", input.String())
+ _ = doc.Set("call_id", callID)
+ _ = doc.Set("name", name)
+ if input.Type == ojson.String {
+ _ = doc.Set("input", input.String())
} else {
- normalized, _ = sjson.SetRawBytes(normalized, "input", []byte(input.Raw))
+ _ = doc.SetRaw("input", input.Raw)
}
- return normalized, true
+ return doc.Bytes(), true
}
func cloneXAIReasoningReplayItems(items [][]byte) [][]byte {
diff --git a/internal/cache/xai_reasoning_replay_cache_test.go b/internal/cache/xai_reasoning_replay_cache_test.go
index 090b0835f94..e3faf53fd42 100644
--- a/internal/cache/xai_reasoning_replay_cache_test.go
+++ b/internal/cache/xai_reasoning_replay_cache_test.go
@@ -3,14 +3,14 @@ package cache
import (
"context"
"crypto/sha256"
- "encoding/base64"
"encoding/json"
"errors"
+ "github.com/MoeForge/moego/base64"
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
- "github.com/tidwall/gjson"
)
type fakeXAIReasoningReplayKVClient struct {
@@ -121,10 +121,10 @@ func TestXAIReasoningReplayCacheStoresGrokEncryptedContent(t *testing.T) {
if !ok {
t.Fatal("xAI replay cache item missing after store")
}
- if got := gjson.GetBytes(item, "encrypted_content").String(); got != encryptedContent {
+ if got := ojson.GetBytes(item, "encrypted_content").String(); got != encryptedContent {
t.Fatalf("encrypted_content = %q, want %q; item=%s", got, encryptedContent, string(item))
}
- if got := gjson.GetBytes(item, "summary").Array(); len(got) != 0 {
+ if got := ojson.GetBytes(item, "summary").Array(); len(got) != 0 {
t.Fatalf("summary length = %d, want normalized empty summary; item=%s", len(got), string(item))
}
}
@@ -146,13 +146,13 @@ func TestXAIReasoningReplayCacheStoresAssistantMessageWithReasoning(t *testing.T
if !ok || len(got) != 2 {
t.Fatalf("cached items = %q, %v, want two items", got, ok)
}
- if gjson.GetBytes(got[0], "encrypted_content").String() != encryptedContent {
+ if ojson.GetBytes(got[0], "encrypted_content").String() != encryptedContent {
t.Fatalf("reasoning encrypted_content not preserved: %s", got[0])
}
- if gotText := gjson.GetBytes(got[1], "content.0.text").String(); gotText != "answer" {
+ if gotText := ojson.GetBytes(got[1], "content.0.text").String(); gotText != "answer" {
t.Fatalf("assistant message text = %q, want answer; item=%s", gotText, got[1])
}
- if gjson.GetBytes(got[1], "id").Exists() || gjson.GetBytes(got[1], "status").Exists() {
+ if ojson.GetBytes(got[1], "id").Exists() || ojson.GetBytes(got[1], "status").Exists() {
t.Fatalf("assistant message transport fields were not stripped: %s", got[1])
}
}
@@ -208,14 +208,14 @@ func TestXAIReasoningReplayCacheStoresToolCallWithoutReasoning(t *testing.T) {
if !ok || len(items) != 1 {
t.Fatalf("cached items = %q, %v, want one item", items, ok)
}
- if got := gjson.GetBytes(items[0], "type").String(); got != tt.wantType {
+ if got := ojson.GetBytes(items[0], "type").String(); got != tt.wantType {
t.Fatalf("cached type = %q, want %q; item=%s", got, tt.wantType, items[0])
}
payloadPath := "arguments"
if tt.wantType == "custom_tool_call" {
payloadPath = "input"
}
- if got := gjson.GetBytes(items[0], payloadPath).String(); got != tt.wantPayload {
+ if got := ojson.GetBytes(items[0], payloadPath).String(); got != tt.wantPayload {
t.Fatalf("cached %s = %q, want %q; item=%s", payloadPath, got, tt.wantPayload, items[0])
}
})
@@ -269,13 +269,13 @@ func TestXAIReasoningReplayCacheStoresRefusalMessagePart(t *testing.T) {
if !ok || len(got) != 2 {
t.Fatalf("cached items = %q, %v, want reasoning + refusal message", got, ok)
}
- if gjson.GetBytes(got[1], "content.0.type").String() != "refusal" {
- t.Fatalf("message part type = %s, want refusal; item=%s", gjson.GetBytes(got[1], "content.0.type").String(), got[1])
+ if ojson.GetBytes(got[1], "content.0.type").String() != "refusal" {
+ t.Fatalf("message part type = %s, want refusal; item=%s", ojson.GetBytes(got[1], "content.0.type").String(), got[1])
}
- if gjson.GetBytes(got[1], "content.0.refusal").String() != "I cannot help with that" {
+ if ojson.GetBytes(got[1], "content.0.refusal").String() != "I cannot help with that" {
t.Fatalf("refusal text missing; item=%s", got[1])
}
- if gjson.GetBytes(got[1], "content.0.text").Exists() {
+ if ojson.GetBytes(got[1], "content.0.text").Exists() {
t.Fatalf("refusal part should not use text field; item=%s", got[1])
}
}
diff --git a/internal/client/codex/apply-patch/tool.go b/internal/client/codex/apply-patch/tool.go
index 602f819f767..8372b5f4bb7 100644
--- a/internal/client/codex/apply-patch/tool.go
+++ b/internal/client/codex/apply-patch/tool.go
@@ -6,7 +6,7 @@ import (
"io"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const parametersJSON = `{"type":"object","properties":{"input":{"type":"string","description":"The complete apply_patch patch text."}},"required":["input"],"additionalProperties":false}`
@@ -27,7 +27,7 @@ Example input:
*** End Patch`
// IsCustomTool reports whether the declaration is the custom apply_patch tool.
-func IsCustomTool(tool gjson.Result) bool {
+func IsCustomTool(tool ojson.Result) bool {
return tool.Get("type").String() == "custom" && strings.TrimSpace(tool.Get("name").String()) == "apply_patch"
}
@@ -37,7 +37,7 @@ func Parameters() []byte {
}
// Description explains the JSON wrapper and preserves the original patch grammar.
-func Description(tool gjson.Result) string {
+func Description(tool ojson.Result) string {
original := strings.ReplaceAll(tool.Get("description").String(), "This is a FREEFORM tool, so do not wrap the patch in JSON.", "")
var description strings.Builder
if strings.TrimSpace(original) != "" {
diff --git a/internal/client/codex/apply-patch/tool_test.go b/internal/client/codex/apply-patch/tool_test.go
index 7964c2e76d9..47a1dc08098 100644
--- a/internal/client/codex/apply-patch/tool_test.go
+++ b/internal/client/codex/apply-patch/tool_test.go
@@ -5,17 +5,17 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestApplyPatchToolContract(t *testing.T) {
- tool := gjson.Parse(`{"type":"custom","name":"apply_patch","description":"This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: begin_patch hunk+ end_patch"}}`)
+ tool := ojson.Parse(`{"type":"custom","name":"apply_patch","description":"This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","syntax":"lark","definition":"start: begin_patch hunk+ end_patch"}}`)
if !IsCustomTool(tool) {
t.Fatal("custom apply_patch was not recognized")
}
- params := gjson.ParseBytes(Parameters())
- if params.Get("properties.input.type").String() != "string" || params.Get("additionalProperties").Bool() {
- t.Fatalf("invalid schema: %s", Parameters())
+ params := Parameters()
+ if ojson.GetBytesNoCopy(params, "properties.input.type").String() != "string" || ojson.GetBytesNoCopy(params, "additionalProperties").Bool() {
+ t.Fatalf("invalid schema: %s", params)
}
description := Description(tool)
for _, token := range []string{"*** Begin Patch", "*** End Patch", "*** Add File:", "*** Delete File:", "*** Update File:", "@@", "input", "start: begin_patch hunk+ end_patch"} {
@@ -57,7 +57,7 @@ func TestApplyPatchToolRecognition(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- if got := IsCustomTool(gjson.Parse(test.tool)); got != test.want {
+ if got := IsCustomTool(ojson.Parse(test.tool)); got != test.want {
t.Fatalf("IsCustomTool(%s) = %v, want %v", test.tool, got, test.want)
}
})
@@ -66,26 +66,25 @@ func TestApplyPatchToolRecognition(t *testing.T) {
func TestApplyPatchToolParameters(t *testing.T) {
parameters := Parameters()
- if !gjson.ValidBytes(parameters) {
+ if !ojson.ValidBytes(parameters) {
t.Fatalf("invalid JSON schema: %s", parameters)
}
- schema := gjson.ParseBytes(parameters)
- if schema.Get("type").String() != "object" {
+ if ojson.GetBytesNoCopy(parameters, "type").String() != "object" {
t.Fatalf("schema must describe an object: %s", parameters)
}
- properties := schema.Get("properties").Map()
+ properties := ojson.GetBytesNoCopy(parameters, "properties").Map()
if len(properties) != 1 || properties["input"].Get("type").String() != "string" {
t.Fatalf("schema must expose only a string input: %s", parameters)
}
if properties["input"].Get("description").String() != "The complete apply_patch patch text." {
t.Fatalf("schema does not describe the complete patch input: %s", parameters)
}
- required := schema.Get("required").Array()
+ required := ojson.GetBytesNoCopy(parameters, "required").Array()
if len(required) != 1 || required[0].String() != "input" {
t.Fatalf("input must be required: %s", parameters)
}
- additional := schema.Get("additionalProperties")
- if additional.Type != gjson.False {
+ additional := ojson.GetBytesNoCopy(parameters, "additionalProperties")
+ if additional.Type != ojson.False {
t.Fatalf("additional properties must be explicitly forbidden: %s", parameters)
}
}
@@ -94,11 +93,11 @@ func TestApplyPatchToolParametersAreIndependent(t *testing.T) {
first := Parameters()
second := Parameters()
first[0] = '['
- if second[0] != '{' || !gjson.ValidBytes(second) {
+ if second[0] != '{' || !ojson.ValidBytes(second) {
t.Fatalf("mutating one schema changed another: %s", second)
}
third := Parameters()
- if third[0] != '{' || !gjson.ValidBytes(third) {
+ if third[0] != '{' || !ojson.ValidBytes(third) {
t.Fatalf("mutating one schema changed later calls: %s", third)
}
}
@@ -115,7 +114,7 @@ func TestApplyPatchToolDescriptionRules(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- description := Description(gjson.Parse(test.tool))
+ description := Description(ojson.Parse(test.tool))
for _, instruction := range []string{
"Call this function with a JSON object whose input field contains the complete patch text.",
"Use the Codex apply_patch format, not a conventional git unified diff.",
@@ -160,7 +159,7 @@ func TestApplyPatchToolDescriptionPreservesNonWrapperInstructions(t *testing.T)
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- tool := gjson.Parse(test.tool)
+ tool := ojson.Parse(test.tool)
description := Description(tool)
if strings.Contains(description, "This is a FREEFORM tool") || strings.Contains(description, "do not wrap the patch in JSON") {
t.Fatalf("freeform wrapper instruction leaked: %q", description)
@@ -196,7 +195,7 @@ func TestApplyPatchToolDescriptionPreservesGrammar(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- description := Description(gjson.Parse(test.tool))
+ description := Description(ojson.Parse(test.tool))
if !strings.HasSuffix(description, test.wantGrammar) {
t.Fatalf("original grammar was not appended verbatim: %q", description)
}
@@ -209,7 +208,7 @@ func TestApplyPatchToolDescriptionPreservesGrammar(t *testing.T) {
}
func TestApplyPatchToolDescriptionEnvironmentGrammar(t *testing.T) {
- tool := gjson.Parse(`{"format":{"definition":"start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n"}}`)
+ tool := ojson.Parse(`{"format":{"definition":"start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n"}}`)
description := Description(tool)
grammar := "start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID:\" /[^\\n]+/\n"
if !strings.HasSuffix(description, grammar) {
diff --git a/internal/client/codex/live/client_secret.go b/internal/client/codex/live/client_secret.go
index ef9811042c7..cb094ee98b3 100644
--- a/internal/client/codex/live/client_secret.go
+++ b/internal/client/codex/live/client_secret.go
@@ -2,7 +2,6 @@ package live
import (
"crypto/rand"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -12,6 +11,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
)
diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go
index d15faca40e1..24d428775d7 100644
--- a/internal/client/codex/models/models.go
+++ b/internal/client/codex/models/models.go
@@ -573,6 +573,9 @@ func nullCodexClientRequiredOptions(entry map[string]any) {
entry["tool_mode"] = "direct"
entry["use_responses_lite"] = false
entry["supports_reasoning_effort_updates"] = false
+ if _, exists := entry["supports_image_detail_original"]; exists {
+ entry["supports_image_detail_original"] = false
+ }
entry["upgrade"] = nil
entry["availability_nux"] = nil
}
@@ -755,7 +758,7 @@ func isCodexClientImageOrVideoModel(id string) bool {
target = strings.TrimSpace(target[idx+1:])
}
switch target {
- case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "grok-imagine-image", "grok-imagine-image-2.0", "grok-imagine-video", "grok-imagine-video-1.5", "grok-imagine-video-1.5-preview", "grok-tts", "grok-voice-tts-1.0":
+ case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "gpt-image-2.6-flare", "gpt-image-2.6-sunburst", "gpt-image-2.6", "grok-imagine-image", "grok-imagine-image-2.0", "grok-imagine-video", "grok-imagine-video-1.5", "grok-imagine-video-1.5-preview", "grok-tts", "grok-voice-tts-1.0":
return true
default:
return false
@@ -772,7 +775,7 @@ func applyCodexClientInputModalitiesMetadata(entry map[string]any, modalities []
}
entry["input_modalities"] = codexModalities
if hasImageModality(codexModalities) {
- entry["supports_image_detail_original"] = true
+ entry["supports_image_detail_original"] = false
} else {
delete(entry, "supports_image_detail_original")
}
diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go
index 843784790ea..5709114f8b5 100644
--- a/internal/client/codex/models/models_test.go
+++ b/internal/client/codex/models/models_test.go
@@ -86,8 +86,8 @@ func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) {
if got, _ := modalities[1].(string); got != "image" {
t.Fatalf("input_modalities[1] = %q, want image", got)
}
- if got, ok := visionEntry["supports_image_detail_original"].(bool); !ok || !got {
- t.Fatalf("supports_image_detail_original = %#v, want true", visionEntry["supports_image_detail_original"])
+ if got, ok := visionEntry["supports_image_detail_original"].(bool); !ok || got {
+ t.Fatalf("supports_image_detail_original = %#v, want false for non-Codex model", visionEntry["supports_image_detail_original"])
}
if textOnlyEntry == nil {
@@ -117,8 +117,8 @@ func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) {
if got, _ := mixedModalities[1].(string); got != "image" {
t.Fatalf("mixed input_modalities[1] = %q, want image", got)
}
- if got, ok := mixedEntry["supports_image_detail_original"].(bool); !ok || !got {
- t.Fatalf("mixed supports_image_detail_original = %#v, want true", mixedEntry["supports_image_detail_original"])
+ if got, ok := mixedEntry["supports_image_detail_original"].(bool); !ok || got {
+ t.Fatalf("mixed supports_image_detail_original = %#v, want false for non-Codex model", mixedEntry["supports_image_detail_original"])
}
if imageEntry == nil {
@@ -956,8 +956,8 @@ func TestCodexClientModelsResponse_UnrelatedCustomProviderRetainsFallback(t *tes
if !okMods || len(mods) != 2 {
t.Fatalf("unrelated model input_modalities = %#v, want generic [text image]", entry["input_modalities"])
}
- if got, _ := entry["supports_image_detail_original"].(bool); !got {
- t.Errorf("supports_image_detail_original = %v, want true for generic fallback", got)
+ if got, ok := entry["supports_image_detail_original"].(bool); !ok || got {
+ t.Errorf("supports_image_detail_original = %#v, want false for generic fallback", entry["supports_image_detail_original"])
}
}
@@ -1874,3 +1874,112 @@ func TestCodexClientModelsResponse_DoesNotLeakResponsesLiteOrCodeModeToNonCodexR
}
}
}
+
+func TestCodexClientModelsResponse_DoesNotAdvertiseImageDetailOriginalForClaudeOrNonCodexRoutes_Issue7209(t *testing.T) {
+ modelRegistry := registry.GetGlobalRegistry()
+ claudeClientID := "codex-claude-image-detail-original-test"
+ codexClientID := "codex-native-image-detail-original-test"
+ claudeAlias := "claude-backed-astra-issue7209"
+ mixedAlias := "mixed-claude-codex-astra-issue7209"
+
+ modelRegistry.RegisterClient(claudeClientID, "claude", []*registry.ModelInfo{
+ {
+ ID: "claude-opus-5-5",
+ DisplayName: "Claude Opus 5.5",
+ SupportedInputModalities: []string{"text", "image"},
+ },
+ {
+ ID: "claude-sonnet-5-5",
+ DisplayName: "Claude Sonnet 5.5",
+ SupportedInputModalities: []string{"text", "image"},
+ },
+ {
+ ID: "claude-haiku-5-5",
+ DisplayName: "Claude Haiku 5.5",
+ SupportedInputModalities: []string{"text", "image"},
+ },
+ {
+ ID: "claude-fable-5-1",
+ DisplayName: "Claude Fable 5.1",
+ SupportedInputModalities: []string{"text", "image"},
+ },
+ {
+ ID: claudeAlias,
+ MetadataModelID: "gpt-6-astra",
+ DisplayName: "Claude Backed Astra",
+ SupportedInputModalities: []string{"text", "image"},
+ },
+ {
+ ID: mixedAlias,
+ MetadataModelID: "gpt-6-astra",
+ DisplayName: "Mixed Claude Codex Astra",
+ SupportedInputModalities: []string{"text", "image"},
+ },
+ })
+ modelRegistry.RegisterClient(codexClientID, "codex", []*registry.ModelInfo{
+ {
+ ID: "gpt-6-astra",
+ MetadataModelID: "gpt-6-astra",
+ DisplayName: "GPT 6.0 Astra",
+ },
+ {
+ ID: mixedAlias,
+ MetadataModelID: "gpt-6-astra",
+ DisplayName: "Mixed Claude Codex Astra",
+ },
+ })
+ t.Cleanup(func() {
+ modelRegistry.UnregisterClient(claudeClientID)
+ modelRegistry.UnregisterClient(codexClientID)
+ })
+
+ available := []map[string]any{
+ {"id": "gpt-6-astra"},
+ {"id": "claude-opus-5-5"},
+ {"id": "claude-sonnet-5-5"},
+ {"id": "claude-haiku-5-5"},
+ {"id": "claude-fable-5-1"},
+ {"id": claudeAlias},
+ {"id": mixedAlias},
+ }
+
+ resp := BuildResponseForClient(available, modelRegistry.GetModelProviders, false, "0.166.0")
+ models, ok := resp["models"].([]map[string]any)
+ if !ok || len(models) != len(available) {
+ t.Fatalf("models = %#v, want %d entries", resp["models"], len(available))
+ }
+
+ bySlug := make(map[string]map[string]any, len(models))
+ for _, entry := range models {
+ bySlug[stringModelValue(entry, "slug")] = entry
+ }
+
+ native := bySlug["gpt-6-astra"]
+ if native == nil {
+ t.Fatal("missing gpt-6-astra entry")
+ }
+ if got, ok := native["supports_image_detail_original"].(bool); !ok || !got {
+ t.Errorf("gpt-6-astra supports_image_detail_original = %#v, want true", native["supports_image_detail_original"])
+ }
+
+ for _, slug := range []string{
+ "claude-opus-5-5",
+ "claude-sonnet-5-5",
+ "claude-haiku-5-5",
+ "claude-fable-5-1",
+ claudeAlias,
+ mixedAlias,
+ } {
+ entry := bySlug[slug]
+ if entry == nil {
+ t.Fatalf("missing %s entry", slug)
+ }
+ modalities, okMods := entry["input_modalities"].([]any)
+ if !okMods || !hasImageModality(modalities) {
+ t.Errorf("%s input_modalities = %#v, want image modality preserved", slug, entry["input_modalities"])
+ }
+ if got, ok := entry["supports_image_detail_original"].(bool); !ok || got {
+ t.Errorf("%s supports_image_detail_original = %#v, want false", slug, entry["supports_image_detail_original"])
+ }
+ }
+}
diff --git a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go
index 2fda7f71dcc..df4672c3e19 100644
--- a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go
+++ b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2.go
@@ -11,13 +11,13 @@ import (
"strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -69,12 +69,28 @@ func RewriteCodexSpawnAgentDescription(ctx context.Context, headers http.Header,
// items to portable message/user input and proactively removes non-standard metadata
// fields (author, recipient, internal_chat_message_metadata_passthrough).
func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config, isCompat ...bool) []byte {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !RewriteCodexMultiAgentV2InputDoc(doc, ctx, headers, cfg, isCompat...) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// RewriteCodexMultiAgentV2InputDoc converts official Codex multi-agent input in doc
+// into standard Responses API messages in place.
+func RewriteCodexMultiAgentV2InputDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config, isCompat ...bool) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
compatMode := len(isCompat) > 0 && isCompat[0]
optimizeEnabled := compatMode || (cfg != nil && cfg.Client.Codex.OptimizeMultiAgentV2 && isCodexMultiAgentClient(codexClientUserAgent(ctx, headers)))
if !compatMode && !optimizeEnabled {
- return payload
+ return false
}
- return rewriteCodexAgentMessageInput(payload, optimizeEnabled, compatMode)
+ return rewriteCodexAgentMessageInputDoc(doc, optimizeEnabled, compatMode)
}
// RewriteCodexOrphanDelegationInputForConfig applies RewriteCodexOrphanDelegationInput
@@ -86,6 +102,15 @@ func RewriteCodexOrphanDelegationInputForConfig(ctx context.Context, headers htt
return RewriteCodexOrphanDelegationInput(ctx, headers, payload, true)
}
+// RewriteCodexOrphanDelegationInputForConfigDoc applies RewriteCodexOrphanDelegationInputDoc
+// in place on doc based on cfg.Codex.OrphanDelegationCompatibility and the X-Openai-Subagent header.
+func RewriteCodexOrphanDelegationInputForConfigDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config) bool {
+ if cfg == nil || !cfg.Codex.OrphanDelegationCompatibility {
+ return false
+ }
+ return RewriteCodexOrphanDelegationInputDoc(doc, ctx, headers, true)
+}
+
// TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
// input before translating it to a non-Codex target protocol.
func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte {
@@ -100,10 +125,16 @@ func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Hea
// TranslateRequestEnvelopeWithCodexMultiAgentV2 normalizes official Codex
// multi-agent input while preserving request-scoped translation metadata.
func TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, req sdktranslator.RequestEnvelope) sdktranslator.RequestEnvelope {
- if from == sdktranslator.FormatOpenAIResponse {
- req.Body = RewriteCodexOrphanDelegationInputForConfig(ctx, headers, req.Body, cfg)
+ if from == sdktranslator.FormatOpenAIResponse && len(req.Body) > 0 {
+ doc := ojson.NewDoc(req.Body)
+ changed := RewriteCodexOrphanDelegationInputForConfigDoc(doc, ctx, headers, cfg)
if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
- req.Body = RewriteCodexMultiAgentV2Input(ctx, headers, req.Body, cfg)
+ if RewriteCodexMultiAgentV2InputDoc(doc, ctx, headers, cfg) {
+ changed = true
+ }
+ }
+ if changed {
+ req.Body = doc.Bytes()
}
}
return sdktranslator.TranslateRequestEnvelope(ctx, from, to, req)
@@ -112,17 +143,38 @@ func TranslateRequestEnvelopeWithCodexMultiAgentV2(ctx context.Context, headers
// PrepareCodexMultiAgentV2Tools prepares collaboration tool definitions at the
// Responses API boundary without changing the collaboration namespace.
func PrepareCodexMultiAgentV2Tools(ctx context.Context, headers http.Header, payload []byte, enabled, homeEnabled bool) ([]byte, bool) {
+ if len(payload) == 0 {
+ return payload, codexMultiAgentV2ClientEnabled(ctx, headers, enabled)
+ }
+ doc := ojson.NewDoc(payload)
+ changed, prepared := prepareCodexMultiAgentV2ToolsDoc(doc, ctx, headers, enabled, homeEnabled)
+ if !changed {
+ return payload, prepared
+ }
+ return doc.Bytes(), prepared
+}
+
+// PrepareCodexMultiAgentV2ToolsDoc prepares collaboration tool definitions in doc at the
+// Responses API boundary without changing the collaboration namespace.
+func PrepareCodexMultiAgentV2ToolsDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, enabled, homeEnabled bool) (bool, bool) {
+ return prepareCodexMultiAgentV2ToolsDoc(doc, ctx, headers, enabled, homeEnabled)
+}
+
+func prepareCodexMultiAgentV2ToolsDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, enabled, homeEnabled bool) (bool, bool) {
if !codexMultiAgentV2ClientEnabled(ctx, headers, enabled) {
- return payload, false
+ return false, false
+ }
+ if doc == nil || doc.Len() == 0 {
+ return false, true
}
- toolPaths := codexSpawnAgentToolPaths(payload)
- messageToolPaths := codexCollaborationMessageToolPaths(payload)
+ toolPaths := codexSpawnAgentToolPathsDoc(doc)
+ messageToolPaths := codexCollaborationMessageToolPathsDoc(doc)
if len(toolPaths) == 0 && len(messageToolPaths) == 0 {
- return payload, true
+ return false, true
}
- if hasCodexOptimizedCollaborationConflict(payload) {
- return removeCodexCollaborationMessageEncryption(payload, messageToolPaths), true
+ if hasCodexOptimizedCollaborationConflictDoc(doc) {
+ return removeCodexCollaborationMessageEncryptionDoc(doc, messageToolPaths), true
}
var models []codexSpawnAgentModel
@@ -131,27 +183,53 @@ func PrepareCodexMultiAgentV2Tools(ctx context.Context, headers http.Header, pay
models, formattedMarkdown = codexSpawnAgentModelsAndMarkdownForRequest(ctx, headers, homeEnabled)
}
- updated := rewriteCodexCollaborationTools(payload, messageToolPaths, toolPaths, models, formattedMarkdown)
- return updated, true
+ return rewriteCodexCollaborationToolsDoc(doc, messageToolPaths, toolPaths, models, formattedMarkdown), true
}
// OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and
// reports whether the collaboration namespace was renamed for upstream use.
func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) {
- if !codexMultiAgentV2Enabled(ctx, headers, cfg) {
+ if len(payload) == 0 {
+ return payload, false
+ }
+ doc := ojson.NewDoc(payload)
+ changed, optimized := optimizeCodexMultiAgentV2RequestDoc(doc, ctx, headers, cfg)
+ if !changed {
return payload, false
}
- updated := rewriteCodexAgentMessageContent(payload)
+ return doc.Bytes(), optimized
+}
+
+// OptimizeCodexMultiAgentV2RequestDoc rewrites an eligible spawn_agent request in doc
+// and reports whether the collaboration namespace was renamed for upstream use.
+func OptimizeCodexMultiAgentV2RequestDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config) bool {
+ _, optimized := optimizeCodexMultiAgentV2RequestDoc(doc, ctx, headers, cfg)
+ return optimized
+}
+
+func optimizeCodexMultiAgentV2RequestDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config) (bool, bool) {
+ if doc == nil || doc.Len() == 0 || !codexMultiAgentV2Enabled(ctx, headers, cfg) {
+ return false, false
+ }
+ changed := rewriteCodexAgentMessageContentDoc(doc)
if codexMultiAgentV2ToolsPrepared(ctx) {
- updated = removeCodexCollaborationMessageEncryption(updated, codexCollaborationMessageToolPaths(updated))
+ if removeCodexCollaborationMessageEncryptionDoc(doc, codexCollaborationMessageToolPathsDoc(doc)) {
+ changed = true
+ }
} else {
- updated, _ = PrepareCodexMultiAgentV2Tools(ctx, headers, updated, cfg.Client.Codex.OptimizeMultiAgentV2, cfg.Home.Enabled)
+ if toolsChanged, _ := prepareCodexMultiAgentV2ToolsDoc(doc, ctx, headers, cfg.Client.Codex.OptimizeMultiAgentV2, cfg.Home.Enabled); toolsChanged {
+ changed = true
+ }
+ }
+ toolPaths := codexSpawnAgentToolPathsDoc(doc)
+ if len(toolPaths) == 0 || hasCodexOptimizedCollaborationConflictDoc(doc) {
+ return changed, false
}
- toolPaths := codexSpawnAgentToolPaths(updated)
- if len(toolPaths) == 0 || hasCodexOptimizedCollaborationConflict(updated) {
- return updated, false
+ optimized := optimizeCodexCollaborationNamespaceDoc(doc, toolPaths)
+ if optimized {
+ changed = true
}
- return optimizeCodexCollaborationNamespace(updated, toolPaths)
+ return changed, optimized
}
func codexMultiAgentV2Enabled(ctx context.Context, headers http.Header, cfg *config.Config) bool {
@@ -619,39 +697,48 @@ func rewriteCodexSpawnAgentTools(payload []byte, toolPaths []string, models []co
}
func rewriteCodexCollaborationTools(payload []byte, messageToolPaths, spawnAgentToolPaths []string, models []codexSpawnAgentModel, modelList string) []byte {
- if len(messageToolPaths) == 0 && len(spawnAgentToolPaths) == 0 {
+ if len(payload) == 0 || (len(messageToolPaths) == 0 && len(spawnAgentToolPaths) == 0) {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !rewriteCodexCollaborationToolsDoc(doc, messageToolPaths, spawnAgentToolPaths, models, modelList) {
return payload
}
+ return doc.Bytes()
+}
+
+func rewriteCodexCollaborationToolsDoc(doc *ojson.Doc, messageToolPaths, spawnAgentToolPaths []string, models []codexSpawnAgentModel, modelList string) bool {
+ if doc == nil || (len(messageToolPaths) == 0 && len(spawnAgentToolPaths) == 0) {
+ return false
+ }
if modelList == "" && len(models) > 0 {
modelList = formatCodexSpawnAgentModels(models)
}
- updated := payload
+ changed := false
for _, toolPath := range spawnAgentToolPaths {
descriptionPath := toolPath + ".description"
- description := gjson.GetBytes(updated, descriptionPath)
- if description.Type == gjson.String && modelList != "" {
+ description := doc.Get(descriptionPath)
+ if description.Type == ojson.String && modelList != "" {
rewritten := replaceCodexSpawnAgentModels(description.String(), modelList)
if rewritten != description.String() {
- var errSet error
- updated, errSet = sjson.SetBytes(updated, descriptionPath, rewritten)
- if errSet != nil {
- return payload
+ if errSet := doc.Set(descriptionPath, rewritten); errSet != nil {
+ return false
}
+ changed = true
}
}
}
for _, toolPath := range messageToolPaths {
encryptedPath := toolPath + ".parameters.properties.message.encrypted"
- if gjson.GetBytes(updated, encryptedPath).Exists() {
- var errDelete error
- updated, errDelete = sjson.DeleteBytes(updated, encryptedPath)
- if errDelete != nil {
- return payload
+ if doc.Get(encryptedPath).Exists() {
+ if errDelete := doc.Delete(encryptedPath); errDelete != nil {
+ return false
}
+ changed = true
}
}
- return updated
+ return changed
}
// HasCodexMultiAgentV2NamespaceConflict reports whether the request defines
@@ -660,11 +747,17 @@ func HasCodexMultiAgentV2NamespaceConflict(payload []byte) bool {
return hasCodexOptimizedCollaborationConflict(payload)
}
+// HasCodexMultiAgentV2NamespaceConflictDoc reports whether doc defines
+// the reserved optimized namespace, which must remain untouched.
+func HasCodexMultiAgentV2NamespaceConflictDoc(doc *ojson.Doc) bool {
+ return hasCodexOptimizedCollaborationConflictDoc(doc)
+}
+
func hasCodexOptimizedCollaborationConflict(payload []byte) bool {
- if codexToolsHaveOptimizedCollaborationConflict(gjson.GetBytes(payload, "tools")) {
+ if codexToolsHaveOptimizedCollaborationConflict(util.GetGJSONBytesNoCopy(payload, "tools")) {
return true
}
- input := gjson.GetBytes(payload, "input")
+ input := util.GetGJSONBytesNoCopy(payload, "input")
if !input.IsArray() {
return false
}
@@ -676,7 +769,26 @@ func hasCodexOptimizedCollaborationConflict(payload []byte) bool {
return false
}
-func codexToolsHaveOptimizedCollaborationConflict(tools gjson.Result) bool {
+func hasCodexOptimizedCollaborationConflictDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ if codexToolsHaveOptimizedCollaborationConflict(doc.Get("tools")) {
+ return true
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
+ for _, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) == "additional_tools" && codexToolsHaveOptimizedCollaborationConflict(item.Get("tools")) {
+ return true
+ }
+ }
+ return false
+}
+
+func codexToolsHaveOptimizedCollaborationConflict(tools ojson.Result) bool {
if !tools.IsArray() {
return false
}
@@ -693,7 +805,20 @@ func codexToolsHaveOptimizedCollaborationConflict(tools gjson.Result) bool {
}
func optimizeCodexCollaborationNamespace(payload []byte, toolPaths []string) ([]byte, bool) {
- updated := payload
+ if len(payload) == 0 || len(toolPaths) == 0 {
+ return payload, false
+ }
+ doc := ojson.NewDoc(payload)
+ if !optimizeCodexCollaborationNamespaceDoc(doc, toolPaths) {
+ return payload, false
+ }
+ return doc.Bytes(), true
+}
+
+func optimizeCodexCollaborationNamespaceDoc(doc *ojson.Doc, toolPaths []string) bool {
+ if doc == nil || len(toolPaths) == 0 {
+ return false
+ }
optimized := false
for _, toolPath := range toolPaths {
separatorIndex := strings.LastIndex(toolPath, ".tools.")
@@ -701,24 +826,22 @@ func optimizeCodexCollaborationNamespace(payload []byte, toolPaths []string) ([]
continue
}
namespacePath := toolPath[:separatorIndex]
- namespace := gjson.GetBytes(updated, namespacePath)
+ namespace := doc.Get(namespacePath)
if strings.TrimSpace(namespace.Get("type").String()) != "namespace" || strings.TrimSpace(namespace.Get("name").String()) != codexCollaborationNamespace {
continue
}
- var errSet error
- updated, errSet = sjson.SetBytes(updated, namespacePath+".name", codexOptimizedCollaborationNamespace)
- if errSet != nil {
- return payload, false
+ if errSet := doc.Set(namespacePath+".name", codexOptimizedCollaborationNamespace); errSet != nil {
+ return false
}
optimized = true
}
- return updated, optimized
+ return optimized
}
// RestoreCodexMultiAgentV2Response restores optimized collaboration namespace
// values before an upstream response is translated and returned to the client.
func RestoreCodexMultiAgentV2Response(payload []byte, optimized bool) []byte {
- if !optimized || len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if !optimized || len(payload) == 0 || !ojson.ValidBytes(payload) {
return payload
}
@@ -786,58 +909,91 @@ func restoreCodexCollaborationValue(value any) bool {
}
func rewriteCodexAgentMessageInput(payload []byte, optimizeEnabled bool, compatMode bool) []byte {
- input := gjson.GetBytes(payload, "input")
- if !input.IsArray() {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !rewriteCodexAgentMessageInputDoc(doc, optimizeEnabled, compatMode) {
return payload
}
+ return doc.Bytes()
+}
+
+func rewriteCodexAgentMessageInputDoc(doc *ojson.Doc, optimizeEnabled bool, compatMode bool) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
- updated := payload
+ changed := false
if optimizeEnabled {
- updated = rewriteCodexAgentMessageContent(payload)
+ if rewriteCodexAgentMessageContentInItemsDoc(doc, input) {
+ changed = true
+ }
}
for itemIndex, item := range input.Array() {
itemType := strings.TrimSpace(item.Get("type").String())
itemPath := fmt.Sprintf("input.%d", itemIndex)
if itemType == "agent_message" && optimizeEnabled {
- var errSet error
- updated, errSet = sjson.SetBytes(updated, itemPath+".role", "user")
- if errSet != nil {
- return payload
+ if errSet := doc.Set(itemPath+".role", "user"); errSet != nil {
+ return false
}
- updated, errSet = sjson.SetBytes(updated, itemPath+".type", "message")
- if errSet != nil {
- return payload
+ if errSet := doc.Set(itemPath+".type", "message"); errSet != nil {
+ return false
}
+ changed = true
}
if compatMode {
- var errDelete error
if item.Get("author").Exists() {
- if updated, errDelete = sjson.DeleteBytes(updated, itemPath+".author"); errDelete != nil {
- return payload
+ if errDelete := doc.Delete(itemPath + ".author"); errDelete != nil {
+ return false
}
+ changed = true
}
if item.Get("recipient").Exists() {
- if updated, errDelete = sjson.DeleteBytes(updated, itemPath+".recipient"); errDelete != nil {
- return payload
+ if errDelete := doc.Delete(itemPath + ".recipient"); errDelete != nil {
+ return false
}
+ changed = true
}
if item.Get("internal_chat_message_metadata_passthrough").Exists() {
- if updated, errDelete = sjson.DeleteBytes(updated, itemPath+".internal_chat_message_metadata_passthrough"); errDelete != nil {
- return payload
+ if errDelete := doc.Delete(itemPath + ".internal_chat_message_metadata_passthrough"); errDelete != nil {
+ return false
}
+ changed = true
}
}
}
- return updated
+ return changed
}
func rewriteCodexAgentMessageContent(payload []byte) []byte {
- input := gjson.GetBytes(payload, "input")
- if !input.IsArray() {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !rewriteCodexAgentMessageContentDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func rewriteCodexAgentMessageContentDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
+ return rewriteCodexAgentMessageContentInItemsDoc(doc, input)
+}
- updated := payload
+func rewriteCodexAgentMessageContentInItemsDoc(doc *ojson.Doc, input ojson.Result) bool {
+ changed := false
for itemIndex, item := range input.Array() {
if strings.TrimSpace(item.Get("type").String()) != "agent_message" {
continue
@@ -851,32 +1007,33 @@ func rewriteCodexAgentMessageContent(payload []byte) []byte {
continue
}
encryptedContent := part.Get("encrypted_content")
- if encryptedContent.Type != gjson.String {
+ if encryptedContent.Type != ojson.String {
continue
}
partPath := fmt.Sprintf("input.%d.content.%d", itemIndex, partIndex)
- var errSet error
- updated, errSet = sjson.SetBytes(updated, partPath+".type", "input_text")
- if errSet != nil {
- return payload
+ if errSet := doc.Set(partPath+".type", "input_text"); errSet != nil {
+ return false
}
- updated, errSet = sjson.SetBytes(updated, partPath+".text", encryptedContent.String())
- if errSet != nil {
- return payload
+ if errSet := doc.Set(partPath+".text", encryptedContent.String()); errSet != nil {
+ return false
}
- updated, errSet = sjson.DeleteBytes(updated, partPath+".encrypted_content")
- if errSet != nil {
- return payload
+ if errDelete := doc.Delete(partPath + ".encrypted_content"); errDelete != nil {
+ return false
}
+ changed = true
}
}
- return updated
+ return changed
}
func codexSpawnAgentToolPaths(payload []byte) []string {
return codexToolPathsByNames(payload, map[string]struct{}{"spawn_agent": {}})
}
+func codexSpawnAgentToolPathsDoc(doc *ojson.Doc) []string {
+ return codexToolPathsByNamesDoc(doc, map[string]struct{}{"spawn_agent": {}})
+}
+
// codexCollaborationMessageToolPaths discovers function tools named
// spawn_agent, send_message, or followup_task inside top-level tools arrays and
// input[].additional_tools arrays, including nested namespace tools.
@@ -884,11 +1041,15 @@ func codexCollaborationMessageToolPaths(payload []byte) []string {
return codexToolPathsByNames(payload, codexCollaborationMessageTools)
}
+func codexCollaborationMessageToolPathsDoc(doc *ojson.Doc) []string {
+ return codexToolPathsByNamesDoc(doc, codexCollaborationMessageTools)
+}
+
func codexToolPathsByNames(payload []byte, names map[string]struct{}) []string {
paths := make([]string, 0, len(names))
- collectCodexToolPathsByNames(gjson.GetBytes(payload, "tools"), "tools", &paths, names)
+ collectCodexToolPathsByNames(util.GetGJSONBytesNoCopy(payload, "tools"), "tools", &paths, names)
- input := gjson.GetBytes(payload, "input")
+ input := util.GetGJSONBytesNoCopy(payload, "input")
if input.IsArray() {
for index, item := range input.Array() {
if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
@@ -900,7 +1061,26 @@ func codexToolPathsByNames(payload []byte, names map[string]struct{}) []string {
return paths
}
-func collectCodexToolPathsByNames(tools gjson.Result, path string, paths *[]string, names map[string]struct{}) {
+func codexToolPathsByNamesDoc(doc *ojson.Doc, names map[string]struct{}) []string {
+ if doc == nil {
+ return nil
+ }
+ paths := make([]string, 0, len(names))
+ collectCodexToolPathsByNames(doc.Get("tools"), "tools", &paths, names)
+
+ input := doc.Get("input")
+ if input.IsArray() {
+ for index, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
+ continue
+ }
+ collectCodexToolPathsByNames(item.Get("tools"), fmt.Sprintf("input.%d.tools", index), &paths, names)
+ }
+ }
+ return paths
+}
+
+func collectCodexToolPathsByNames(tools ojson.Result, path string, paths *[]string, names map[string]struct{}) {
if !tools.IsArray() {
return
}
@@ -922,19 +1102,32 @@ func collectCodexToolPathsByNames(tools gjson.Result, path string, paths *[]stri
// parameters.properties.message.encrypted field from each discovered
// collaboration message tool so the proxy can read the plaintext message.
func removeCodexCollaborationMessageEncryption(payload []byte, toolPaths []string) []byte {
- updated := payload
+ if len(payload) == 0 || len(toolPaths) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !removeCodexCollaborationMessageEncryptionDoc(doc, toolPaths) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func removeCodexCollaborationMessageEncryptionDoc(doc *ojson.Doc, toolPaths []string) bool {
+ if doc == nil || len(toolPaths) == 0 {
+ return false
+ }
+ changed := false
for _, toolPath := range toolPaths {
encryptedPath := toolPath + ".parameters.properties.message.encrypted"
- if !gjson.GetBytes(updated, encryptedPath).Exists() {
+ if !doc.Get(encryptedPath).Exists() {
continue
}
- var errDelete error
- updated, errDelete = sjson.DeleteBytes(updated, encryptedPath)
- if errDelete != nil {
- return payload
+ if errDelete := doc.Delete(encryptedPath); errDelete != nil {
+ return false
}
+ changed = true
}
- return updated
+ return changed
}
func formatCodexSpawnAgentModels(models []codexSpawnAgentModel) string {
diff --git a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go
index 14080b6a6f4..ff4c72e69f1 100644
--- a/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go
+++ b/internal/client/codex/optimize-multi-agent-v2/optimize_multi_agent_v2_test.go
@@ -7,12 +7,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestIsCodexMultiAgentClient(t *testing.T) {
@@ -190,7 +190,7 @@ func TestRewriteCodexSpawnAgentDescriptionNormalizesModelList(t *testing.T) {
}
got := rewriteCodexSpawnAgentDescription(payload, models)
- description := gjson.GetBytes(got, "input.0.tools.0.tools.1.description").String()
+ description := ojson.GetBytesNoCopy(got, "input.0.tools.0.tools.1.description").String()
wantAlpha := "- `model-alpha`: Alpha model. Reasoning efforts: low, medium (default), high. Service tiers: priority."
wantBeta := "- `model-beta`: Beta model. Reasoning efforts: low (default), high."
if !strings.Contains(description, wantAlpha) || !strings.Contains(description, wantBeta) {
@@ -207,10 +207,10 @@ func TestRewriteCodexSpawnAgentDescriptionNormalizesModelList(t *testing.T) {
if strings.Index(description, "`model-beta`") > strings.Index(description, codexSpawnAgentDescriptionMarker) {
t.Fatalf("model list was not inserted before spawn instructions: %q", description)
}
- if gotDescription := gjson.GetBytes(got, "input.0.tools.0.tools.0.description").String(); gotDescription != "unchanged" {
+ if gotDescription := ojson.GetBytesNoCopy(got, "input.0.tools.0.tools.0.description").String(); gotDescription != "unchanged" {
t.Fatalf("non-spawn tool description = %q, want unchanged", gotDescription)
}
- if encrypted := gjson.GetBytes(got, "input.0.tools.0.tools.1.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, "input.0.tools.0.tools.1.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("spawn_agent message encrypted was not removed: %s", encrypted.Raw)
}
}
@@ -226,7 +226,7 @@ func TestRewriteCodexSpawnAgentDescriptionTopLevelWithoutMarker(t *testing.T) {
defaultReasoningEffort: "medium",
}}
got := rewriteCodexSpawnAgentDescription(payload, models)
- description := gjson.GetBytes(got, "tools.0.tools.0.description").String()
+ description := ojson.GetBytesNoCopy(got, "tools.0.tools.0.description").String()
wantSuffix := codexSpawnAgentModelsHeading + "\n- `model-a`: Model A. Reasoning efforts: medium (default)."
if !strings.HasPrefix(description, "Create a worker.\n\n") || !strings.HasSuffix(description, wantSuffix) {
@@ -289,7 +289,7 @@ func TestOptimizeCodexCollaborationNamespaceWithoutModels(t *testing.T) {
if !optimized {
t.Fatal("collaboration namespace was not optimized")
}
- if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace {
t.Fatalf("namespace = %q, want collaboration-optimize", namespace)
}
}
@@ -299,10 +299,10 @@ func TestRewriteCodexSpawnAgentDescriptionWithoutModelsStillRemovesEncrypted(t *
payload := []byte(`{"tools":[{"type":"function","name":"spawn_agent","description":"unchanged","parameters":{"properties":{"message":{"encrypted":true}}}}]}`)
got := rewriteCodexSpawnAgentDescription(payload, nil)
- if description := gjson.GetBytes(got, "tools.0.description").String(); description != "unchanged" {
+ if description := ojson.GetBytesNoCopy(got, "tools.0.description").String(); description != "unchanged" {
t.Fatalf("description = %q, want unchanged", description)
}
- if encrypted := gjson.GetBytes(got, "tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, "tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("message encrypted was not removed: %s", encrypted.Raw)
}
}
@@ -338,15 +338,15 @@ func TestRewriteCodexSpawnAgentDescriptionEnabledOptimizesTool(t *testing.T) {
if !optimized {
t.Fatal("collaboration namespace was not marked optimized")
}
- if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace {
t.Fatalf("namespace = %q, want %q", namespace, codexOptimizedCollaborationNamespace)
}
- description := gjson.GetBytes(got, "tools.0.tools.0.description").String()
+ description := ojson.GetBytesNoCopy(got, "tools.0.tools.0.description").String()
want := "- `" + modelID + "`: Test agent model. Reasoning efforts: low, medium (default), high."
if !strings.Contains(description, want) {
t.Fatalf("description does not contain dynamic model metadata: %q", description)
}
- if encrypted := gjson.GetBytes(got, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("spawn_agent message encrypted was not removed: %s", encrypted.Raw)
}
}
@@ -370,14 +370,14 @@ func TestPrepareCodexMultiAgentV2ToolsOnlyPreparesToolDefinitions(t *testing.T)
if !prepared {
t.Fatal("Codex CLI request was not marked prepared")
}
- if messageType := gjson.GetBytes(got, "input.0.content.0.type").String(); messageType != "encrypted_content" {
+ if messageType := ojson.GetBytesNoCopy(got, "input.0.content.0.type").String(); messageType != "encrypted_content" {
t.Fatalf("agent_message content type = %q, want encrypted_content", messageType)
}
- if namespace := gjson.GetBytes(got, "input.1.tools.0.name").String(); namespace != codexCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "input.1.tools.0.name").String(); namespace != codexCollaborationNamespace {
t.Fatalf("namespace = %q, want %q", namespace, codexCollaborationNamespace)
}
for _, path := range []string{"input.1.tools.0.tools.0", "input.1.tools.0.tools.1"} {
- if encrypted := gjson.GetBytes(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s message.encrypted was not removed: %s", path, encrypted.Raw)
}
}
@@ -401,10 +401,10 @@ Spawns an agent.","parameters":{"properties":{"message":{"encrypted":true}}}}]}]
if !optimized {
t.Fatal("collaboration namespace was not optimized")
}
- if description := gjson.GetBytes(got, "tools.0.tools.0.description").String(); !strings.Contains(description, "old-model") {
+ if description := ojson.GetBytesNoCopy(got, "tools.0.tools.0.description").String(); !strings.Contains(description, "old-model") {
t.Fatalf("prepared spawn_agent description was refreshed: %q", description)
}
- if encrypted := gjson.GetBytes(got, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("message.encrypted was not removed: %s", encrypted.Raw)
}
}
@@ -419,7 +419,7 @@ func TestOptimizeCodexMultiAgentV2RequestNormalizesAgentMessageContentOnly(t *te
if namespaceOptimized {
t.Fatal("payload without spawn_agent unexpectedly optimized a namespace")
}
- message := gjson.GetBytes(got, "input.0")
+ message := ojson.GetBytesNoCopy(got, "input.0")
if message.Get("type").String() != "agent_message" || message.Get("role").Exists() {
t.Fatalf("outer agent message changed: %s", got)
}
@@ -465,22 +465,22 @@ func TestRestoreCodexMultiAgentV2Response(t *testing.T) {
}
}`)
got := RestoreCodexMultiAgentV2Response(payload, true)
- if namespace := gjson.GetBytes(got, "response.output.0.namespace").String(); namespace != codexCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "response.output.0.namespace").String(); namespace != codexCollaborationNamespace {
t.Fatalf("function namespace = %q, want collaboration", namespace)
}
- if name := gjson.GetBytes(got, "response.output.1.name").String(); name != "collaboration__send_message" {
+ if name := ojson.GetBytesNoCopy(got, "response.output.1.name").String(); name != "collaboration__send_message" {
t.Fatalf("qualified function name = %q, want collaboration__send_message", name)
}
- if name := gjson.GetBytes(got, "response.tools.0.name").String(); name != codexCollaborationNamespace {
+ if name := ojson.GetBytesNoCopy(got, "response.tools.0.name").String(); name != codexCollaborationNamespace {
t.Fatalf("namespace tool name = %q, want collaboration", name)
}
- if namespace := gjson.GetBytes(got, "response.output.0.arguments.namespace").String(); namespace != codexOptimizedCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "response.output.0.arguments.namespace").String(); namespace != codexOptimizedCollaborationNamespace {
t.Fatalf("opaque arguments namespace was unexpectedly rewritten: %q", namespace)
}
- if namespace := gjson.GetBytes(got, "response.output.2.namespace").String(); namespace != codexOptimizedCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "response.output.2.namespace").String(); namespace != codexOptimizedCollaborationNamespace {
t.Fatalf("ordinary namespace field was unexpectedly rewritten: %q", namespace)
}
- if name := gjson.GetBytes(got, "response.output.2.name").String(); name != "collaboration-optimize__plain" {
+ if name := ojson.GetBytesNoCopy(got, "response.output.2.name").String(); name != "collaboration-optimize__plain" {
t.Fatalf("ordinary name field was unexpectedly rewritten: %q", name)
}
if unchanged := RestoreCodexMultiAgentV2Response(payload, false); string(unchanged) != string(payload) {
@@ -509,16 +509,16 @@ func TestRestoreCodexMultiAgentV2ResponseRestoresDottedFlatToolName(t *testing.T
}
}`)
got := RestoreCodexMultiAgentV2Response(payload, true)
- if namespace := gjson.GetBytes(got, "response.output.0.namespace").String(); namespace != codexCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "response.output.0.namespace").String(); namespace != codexCollaborationNamespace {
t.Fatalf("output 0 namespace = %q, want %q", namespace, codexCollaborationNamespace)
}
- if name := gjson.GetBytes(got, "response.output.0.name").String(); name != "spawn_agent" {
+ if name := ojson.GetBytesNoCopy(got, "response.output.0.name").String(); name != "spawn_agent" {
t.Fatalf("output 0 name = %q, want spawn_agent", name)
}
- if namespace := gjson.GetBytes(got, "response.output.1.namespace").String(); namespace != codexCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "response.output.1.namespace").String(); namespace != codexCollaborationNamespace {
t.Fatalf("output 1 namespace = %q, want %q", namespace, codexCollaborationNamespace)
}
- if name := gjson.GetBytes(got, "response.output.1.name").String(); name != "list_agents" {
+ if name := ojson.GetBytesNoCopy(got, "response.output.1.name").String(); name != "list_agents" {
t.Fatalf("output 1 name = %q, want list_agents", name)
}
}
@@ -541,27 +541,35 @@ func TestRewriteCodexMultiAgentV2InputRewritesAgentMessage(t *testing.T) {
cfg := &config.Config{SDKConfig: config.SDKConfig{Client: config.ClientConfig{Codex: config.CodexClientConfig{OptimizeMultiAgentV2: true}}}}
got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, cfg)
- if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "message" {
+ if messageType := ojson.GetBytesNoCopy(got, "input.0.type").String(); messageType != "message" {
t.Fatalf("type = %q, want message; payload=%s", messageType, got)
}
- if role := gjson.GetBytes(got, "input.0.role").String(); role != "user" {
+ if role := ojson.GetBytesNoCopy(got, "input.0.role").String(); role != "user" {
t.Fatalf("role = %q, want user; payload=%s", role, got)
}
- if partType := gjson.GetBytes(got, "input.0.content.1.type").String(); partType != "input_text" {
+ if partType := ojson.GetBytesNoCopy(got, "input.0.content.1.type").String(); partType != "input_text" {
t.Fatalf("content[1].type = %q, want input_text; payload=%s", partType, got)
}
- if text := gjson.GetBytes(got, "input.0.content.1.text").String(); text != "请出一道四则运算题,并给出答案。全程使用简体中文,题目简洁。" {
+ if text := ojson.GetBytesNoCopy(got, "input.0.content.1.text").String(); text != "请出一道四则运算题,并给出答案。全程使用简体中文,题目简洁。" {
t.Fatalf("content[1].text = %q; payload=%s", text, got)
}
- if encrypted := gjson.GetBytes(got, "input.0.content.1.encrypted_content"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, "input.0.content.1.encrypted_content"); encrypted.Exists() {
t.Fatalf("content[1].encrypted_content was preserved: %s", got)
}
- if author := gjson.GetBytes(got, "input.0.author").String(); author != "/root" {
+ if author := ojson.GetBytesNoCopy(got, "input.0.author").String(); author != "/root" {
t.Fatalf("author = %q, want /root", author)
}
- if turnID := gjson.GetBytes(got, "input.0.internal_chat_message_metadata_passthrough.turn_id").String(); turnID != "019f92ae-7eae-7371-957e-8f6f734edddc" {
+ if turnID := ojson.GetBytesNoCopy(got, "input.0.internal_chat_message_metadata_passthrough.turn_id").String(); turnID != "019f92ae-7eae-7371-957e-8f6f734edddc" {
t.Fatalf("turn_id = %q", turnID)
}
+
+ doc := ojson.NewDoc(payload)
+ if !RewriteCodexMultiAgentV2InputDoc(doc, context.Background(), headers, cfg) {
+ t.Fatal("expected RewriteCodexMultiAgentV2InputDoc to report changes")
+ }
+ if string(doc.Bytes()) != string(got) {
+ t.Fatalf("RewriteCodexMultiAgentV2InputDoc mismatch:\ngot: %s\nwant: %s", string(doc.Bytes()), string(got))
+ }
}
func TestRewriteCodexMultiAgentV2Input_StripsAuthorAndRecipient_Issue6136(t *testing.T) {
@@ -597,41 +605,41 @@ func TestRewriteCodexMultiAgentV2Input_StripsAuthorAndRecipient_Issue6136(t *tes
got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, cfg, true)
// Item 0: agent_message -> message/user
- if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "message" {
+ if messageType := ojson.GetBytesNoCopy(got, "input.0.type").String(); messageType != "message" {
t.Fatalf("input.0.type = %q, want message", messageType)
}
- if role := gjson.GetBytes(got, "input.0.role").String(); role != "user" {
+ if role := ojson.GetBytesNoCopy(got, "input.0.role").String(); role != "user" {
t.Fatalf("input.0.role = %q, want user", role)
}
- if author := gjson.GetBytes(got, "input.0.author"); author.Exists() {
+ if author := ojson.GetBytesNoCopy(got, "input.0.author"); author.Exists() {
t.Fatalf("input.0.author was not stripped; got=%s", author.String())
}
- if recipient := gjson.GetBytes(got, "input.0.recipient"); recipient.Exists() {
+ if recipient := ojson.GetBytesNoCopy(got, "input.0.recipient"); recipient.Exists() {
t.Fatalf("input.0.recipient was not stripped; got=%s", recipient.String())
}
- if passthrough := gjson.GetBytes(got, "input.0.internal_chat_message_metadata_passthrough"); passthrough.Exists() {
+ if passthrough := ojson.GetBytesNoCopy(got, "input.0.internal_chat_message_metadata_passthrough"); passthrough.Exists() {
t.Fatalf("input.0.internal_chat_message_metadata_passthrough was not stripped; got=%s", passthrough.String())
}
// Item 1: regular message with non-standard fields -> cleaned
- if messageType := gjson.GetBytes(got, "input.1.type").String(); messageType != "message" {
+ if messageType := ojson.GetBytesNoCopy(got, "input.1.type").String(); messageType != "message" {
t.Fatalf("input.1.type = %q, want message", messageType)
}
- if role := gjson.GetBytes(got, "input.1.role").String(); role != "user" {
+ if role := ojson.GetBytesNoCopy(got, "input.1.role").String(); role != "user" {
t.Fatalf("input.1.role = %q, want user", role)
}
- if author := gjson.GetBytes(got, "input.1.author"); author.Exists() {
+ if author := ojson.GetBytesNoCopy(got, "input.1.author"); author.Exists() {
t.Fatalf("input.1.author was not stripped; got=%s", author.String())
}
- if recipient := gjson.GetBytes(got, "input.1.recipient"); recipient.Exists() {
+ if recipient := ojson.GetBytesNoCopy(got, "input.1.recipient"); recipient.Exists() {
t.Fatalf("input.1.recipient was not stripped; got=%s", recipient.String())
}
- if passthrough := gjson.GetBytes(got, "input.1.internal_chat_message_metadata_passthrough"); passthrough.Exists() {
+ if passthrough := ojson.GetBytesNoCopy(got, "input.1.internal_chat_message_metadata_passthrough"); passthrough.Exists() {
t.Fatalf("input.1.internal_chat_message_metadata_passthrough was not stripped; got=%s", passthrough.String())
}
// Item 2: clean message remains intact
- if content := gjson.GetBytes(got, "input.2.content").String(); content != "clean assistant message" {
+ if content := ojson.GetBytesNoCopy(got, "input.2.content").String(); content != "clean assistant message" {
t.Fatalf("input.2.content = %q", content)
}
})
@@ -641,19 +649,19 @@ func TestRewriteCodexMultiAgentV2Input_StripsAuthorAndRecipient_Issue6136(t *tes
cfg := &config.Config{SDKConfig: config.SDKConfig{Client: config.ClientConfig{Codex: config.CodexClientConfig{OptimizeMultiAgentV2: true}}}}
got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, cfg, false)
- if author := gjson.GetBytes(got, "input.0.author").String(); author != "/root" {
+ if author := ojson.GetBytesNoCopy(got, "input.0.author").String(); author != "/root" {
t.Fatalf("input.0.author = %q, want /root", author)
}
- if recipient := gjson.GetBytes(got, "input.0.recipient").String(); recipient != "/root/worker" {
+ if recipient := ojson.GetBytesNoCopy(got, "input.0.recipient").String(); recipient != "/root/worker" {
t.Fatalf("input.0.recipient = %q, want /root/worker", recipient)
}
- if passthrough := gjson.GetBytes(got, "input.0.internal_chat_message_metadata_passthrough.turn_id").String(); passthrough != "turn_1" {
+ if passthrough := ojson.GetBytesNoCopy(got, "input.0.internal_chat_message_metadata_passthrough.turn_id").String(); passthrough != "turn_1" {
t.Fatalf("input.0 passthrough = %q", passthrough)
}
- if author := gjson.GetBytes(got, "input.1.author").String(); author != "/root/worker" {
+ if author := ojson.GetBytesNoCopy(got, "input.1.author").String(); author != "/root/worker" {
t.Fatalf("input.1.author = %q, want /root/worker", author)
}
- if recipient := gjson.GetBytes(got, "input.1.recipient").String(); recipient != "/root" {
+ if recipient := ojson.GetBytesNoCopy(got, "input.1.recipient").String(); recipient != "/root" {
t.Fatalf("input.1.recipient = %q, want /root", recipient)
}
})
@@ -681,33 +689,33 @@ func TestRewriteCodexMultiAgentV2Input_CompatModeWithoutOptimize_Issue6233(t *te
got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, cfg, true)
// Item 0: must be converted to message with role user
- messageType := gjson.GetBytes(got, "input.0.type").String()
+ messageType := ojson.GetBytesNoCopy(got, "input.0.type").String()
if messageType != "message" {
t.Fatalf("input.0.type = %q, want message (must not stay agent_message without author)", messageType)
}
- role := gjson.GetBytes(got, "input.0.role").String()
+ role := ojson.GetBytesNoCopy(got, "input.0.role").String()
if role != "user" {
t.Fatalf("input.0.role = %q, want user", role)
}
// Encrypted content must be normalized to input_text
- contentType := gjson.GetBytes(got, "input.0.content.1.type").String()
+ contentType := ojson.GetBytesNoCopy(got, "input.0.content.1.type").String()
if contentType != "input_text" {
t.Fatalf("input.0.content.1.type = %q, want input_text", contentType)
}
- contentText := gjson.GetBytes(got, "input.0.content.1.text").String()
+ contentText := ojson.GetBytesNoCopy(got, "input.0.content.1.text").String()
if contentText != "test task payload" {
t.Fatalf("input.0.content.1.text = %q, want test task payload", contentText)
}
// Non-standard metadata must be stripped
- if author := gjson.GetBytes(got, "input.0.author"); author.Exists() {
+ if author := ojson.GetBytesNoCopy(got, "input.0.author"); author.Exists() {
t.Fatalf("input.0.author was not stripped; got=%s", author.String())
}
- if recipient := gjson.GetBytes(got, "input.0.recipient"); recipient.Exists() {
+ if recipient := ojson.GetBytesNoCopy(got, "input.0.recipient"); recipient.Exists() {
t.Fatalf("input.0.recipient was not stripped; got=%s", recipient.String())
}
- if passthrough := gjson.GetBytes(got, "input.0.internal_chat_message_metadata_passthrough"); passthrough.Exists() {
+ if passthrough := ojson.GetBytesNoCopy(got, "input.0.internal_chat_message_metadata_passthrough"); passthrough.Exists() {
t.Fatalf("input.0.internal_chat_message_metadata_passthrough was not stripped; got=%s", passthrough.String())
}
})
@@ -756,7 +764,7 @@ func TestRewriteCodexMultiAgentV2InputConditions(t *testing.T) {
t.Parallel()
headers := http.Header{"User-Agent": []string{tt.userAgent}}
got := RewriteCodexMultiAgentV2Input(context.Background(), headers, payload, tt.cfg)
- if rewritten := gjson.GetBytes(got, "input.0.type").String() == "message"; rewritten != tt.want {
+ if rewritten := ojson.GetBytesNoCopy(got, "input.0.type").String() == "message"; rewritten != tt.want {
t.Fatalf("rewritten = %v, want %v; payload=%s", rewritten, tt.want, got)
}
})
@@ -784,7 +792,7 @@ func TestTranslateRequestWithCodexMultiAgentV2Conditions(t *testing.T) {
for _, tt := range translations {
t.Run(tt.name, func(t *testing.T) {
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, enabledCfg, sdktranslator.FormatOpenAIResponse, tt.to, tt.model, payload, false)
- if value := gjson.GetBytes(got, tt.path).String(); value != tt.want {
+ if value := ojson.GetBytesNoCopy(got, tt.path).String(); value != tt.want {
t.Fatalf("%s = %q, want %q; output=%s", tt.path, value, tt.want, got)
}
})
@@ -792,27 +800,27 @@ func TestTranslateRequestWithCodexMultiAgentV2Conditions(t *testing.T) {
t.Run("disabled optimization", func(t *testing.T) {
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, &config.Config{}, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "chat-model", payload, false)
- if count := gjson.GetBytes(got, "messages.#").Int(); count != 0 {
+ if count := ojson.GetBytesNoCopy(got, "messages.#").Int(); count != 0 {
t.Fatalf("disabled optimization translated agent_message; output=%s", got)
}
})
t.Run("unrelated client", func(t *testing.T) {
headers := http.Header{"User-Agent": []string{"curl/8.7.1"}}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), headers, enabledCfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "chat-model", payload, false)
- if count := gjson.GetBytes(got, "messages.#").Int(); count != 0 {
+ if count := ojson.GetBytesNoCopy(got, "messages.#").Int(); count != 0 {
t.Fatalf("unrelated client agent_message was translated; output=%s", got)
}
})
t.Run("non-Responses source", func(t *testing.T) {
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, enabledCfg, sdktranslator.FormatOpenAI, sdktranslator.FormatOpenAI, "test-model", payload, false)
- if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "agent_message" {
+ if messageType := ojson.GetBytesNoCopy(got, "input.0.type").String(); messageType != "agent_message" {
t.Fatalf("non-Responses source changed agent_message; output=%s", got)
}
})
for _, target := range []sdktranslator.Format{sdktranslator.FormatCodex, sdktranslator.FormatOpenAIResponse} {
t.Run("excluded target "+target.String(), func(t *testing.T) {
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), eligibleHeaders, enabledCfg, sdktranslator.FormatOpenAIResponse, target, "test-model", payload, false)
- if messageType := gjson.GetBytes(got, "input.0.type").String(); messageType != "agent_message" {
+ if messageType := ojson.GetBytesNoCopy(got, "input.0.type").String(); messageType != "agent_message" {
t.Fatalf("target %s changed agent_message; output=%s", target, got)
}
})
@@ -931,10 +939,10 @@ func TestRemoveCodexCollaborationMessageEncryptionAllTools(t *testing.T) {
"tools.0.tools.1",
"tools.0.tools.2",
} {
- if encrypted := gjson.GetBytes(got, toolPath+".parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, toolPath+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s.parameters.properties.message.encrypted was not removed: %s", toolPath, encrypted.Raw)
}
- if msgType := gjson.GetBytes(got, toolPath+".parameters.properties.message.type").String(); msgType != "string" {
+ if msgType := ojson.GetBytesNoCopy(got, toolPath+".parameters.properties.message.type").String(); msgType != "string" {
t.Fatalf("%s.parameters.properties.message.type changed: %q", toolPath, msgType)
}
}
@@ -952,13 +960,13 @@ func TestRemoveCodexCollaborationMessageEncryptionPreservesUnrelatedEncryptedFie
paths := codexCollaborationMessageToolPaths(payload)
got := removeCodexCollaborationMessageEncryption(payload, paths)
- if encrypted := gjson.GetBytes(got, "tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, "tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("send_message message.encrypted was not removed: %s", encrypted.Raw)
}
- if dataEncrypted := gjson.GetBytes(got, "tools.0.parameters.properties.data.encrypted").String(); dataEncrypted != "keep-me" {
+ if dataEncrypted := ojson.GetBytesNoCopy(got, "tools.0.parameters.properties.data.encrypted").String(); dataEncrypted != "keep-me" {
t.Fatalf("unrelated data.encrypted was changed: %q", dataEncrypted)
}
- if unrelatedEncrypted := gjson.GetBytes(got, "tools.1.parameters.properties.message.encrypted"); !unrelatedEncrypted.Exists() {
+ if unrelatedEncrypted := ojson.GetBytesNoCopy(got, "tools.1.parameters.properties.message.encrypted"); !unrelatedEncrypted.Exists() {
t.Fatalf("unrelated tool message.encrypted was removed: %s", got)
}
}
@@ -982,7 +990,7 @@ func TestOptimizeCodexMultiAgentV2RequestRemovesEncryptionWithoutSpawnAgent(t *t
t.Fatal("namespace was unexpectedly optimized without spawn_agent")
}
for _, path := range []string{"tools.0.tools.0", "tools.0.tools.1"} {
- if encrypted := gjson.GetBytes(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s.parameters.properties.message.encrypted was not removed: %s", path, encrypted.Raw)
}
}
@@ -1006,7 +1014,7 @@ func TestOptimizeCodexMultiAgentV2RequestRemovesEncryptionInAdditionalTools(t *t
got, _ := OptimizeCodexMultiAgentV2Request(context.Background(), headers, payload, cfg)
for _, path := range []string{"input.0.tools.0.tools.0", "input.0.tools.0.tools.1"} {
- if encrypted := gjson.GetBytes(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s.parameters.properties.message.encrypted was not removed: %s", path, encrypted.Raw)
}
}
@@ -1032,11 +1040,11 @@ func TestOptimizeCodexMultiAgentV2RequestRemovesEncryptionFromAllThreeToolsWithS
t.Fatal("collaboration namespace was not optimized with spawn_agent present")
}
for _, path := range []string{"tools.0.tools.0", "tools.0.tools.1", "tools.0.tools.2"} {
- if encrypted := gjson.GetBytes(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s.parameters.properties.message.encrypted was not removed: %s", path, encrypted.Raw)
}
}
- if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace {
+ if namespace := ojson.GetBytesNoCopy(got, "tools.0.name").String(); namespace != codexOptimizedCollaborationNamespace {
t.Fatalf("namespace = %q, want %q", namespace, codexOptimizedCollaborationNamespace)
}
}
diff --git a/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go
index 8013b651334..f8330d2dec3 100644
--- a/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go
+++ b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation.go
@@ -6,9 +6,8 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -28,10 +27,23 @@ func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header,
if !enabled || len(payload) == 0 || !isCodexCollabSpawnSubagent(ctx, headers) {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !RewriteCodexOrphanDelegationInputDoc(doc, ctx, headers, enabled) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// RewriteCodexOrphanDelegationInputDoc converts orphan Codex delegation outputs in doc
+// into standard user messages in place.
+func RewriteCodexOrphanDelegationInputDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, enabled bool) bool {
+ if !enabled || doc == nil || doc.Len() == 0 || !isCodexCollabSpawnSubagent(ctx, headers) {
+ return false
+ }
- input := gjson.GetBytes(payload, "input")
+ input := doc.Get("input")
if !input.IsArray() {
- return payload
+ return false
}
inputItems := input.Array()
@@ -45,7 +57,7 @@ func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header,
}
}
- updated := payload
+ changed := false
for itemIndex, item := range inputItems {
if item.Get("type").String() != "function_call_output" {
continue
@@ -65,15 +77,17 @@ func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header,
// Orphan delegation output: downgrade to user message preserving exact output.
itemPath := fmt.Sprintf("input.%d", itemIndex)
- userMessage := buildCodexOrphanUserMessage(toolLabel, item.Get("output"))
- var errSet error
- updated, errSet = sjson.SetRawBytes(updated, itemPath, userMessage)
- if errSet != nil {
- return payload
+ fullText := buildCodexOrphanUserMessageText(toolLabel, item.Get("output"))
+ if errSet := doc.SetRawBytes(itemPath, []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)); errSet != nil {
+ return false
}
+ if errSet := doc.Set(itemPath+".content.0.text", fullText); errSet != nil {
+ return false
+ }
+ changed = true
}
- return updated
+ return changed
}
func codexSubagentHeader(ctx context.Context, headers http.Header) string {
@@ -89,7 +103,7 @@ func isCodexCollabSpawnSubagent(ctx context.Context, headers http.Header) bool {
return strings.EqualFold(codexSubagentHeader(ctx, headers), codexCollabSpawnSubagent)
}
-func matchCodexDelegationTool(item gjson.Result) (string, bool) {
+func matchCodexDelegationTool(item ojson.Result) (string, bool) {
if item.Get("namespace").String() != codexAppNamespace {
return "", false
}
@@ -104,19 +118,15 @@ func matchCodexDelegationTool(item gjson.Result) (string, bool) {
}
}
-func buildCodexOrphanUserMessage(toolLabel string, output gjson.Result) []byte {
+func buildCodexOrphanUserMessageText(toolLabel string, output ojson.Result) string {
outputText := ""
if output.Exists() {
- if output.Type == gjson.String {
+ if output.Type == ojson.String {
outputText = output.String()
} else {
outputText = output.Raw
}
}
- fullText := fmt.Sprintf("Tool output from %s:\n%s", toolLabel, outputText)
-
- msg := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)
- msg, _ = sjson.SetBytes(msg, "content.0.text", fullText)
- return msg
+ return fmt.Sprintf("Tool output from %s:\n%s", toolLabel, outputText)
}
diff --git a/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go
index c82e4ee4ca8..964baf96bfe 100644
--- a/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go
+++ b/internal/client/codex/optimize-multi-agent-v2/orphan_delegation_test.go
@@ -6,14 +6,21 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func testRewriteCodexOrphan(payload []byte, enabled bool) []byte {
headers := http.Header{"X-Openai-Subagent": []string{"collab_spawn"}}
- return RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, enabled)
+ out := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, enabled)
+ doc := ojson.NewDoc(payload)
+ changed := RewriteCodexOrphanDelegationInputDoc(doc, context.Background(), headers, enabled)
+ if changed && string(doc.Bytes()) != string(out) {
+ panic(fmt.Sprintf("RewriteCodexOrphanDelegationInputDoc mismatch: %s != %s", string(doc.Bytes()), string(out)))
+ }
+ return out
}
func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
@@ -90,7 +97,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" {
@@ -124,7 +131,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
}`)
headers := http.Header{"x-openai-subagent": []string{"COLLAB_SPAWN"}}
got := RewriteCodexOrphanDelegationInput(context.Background(), headers, payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
@@ -146,7 +153,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
@@ -179,7 +186,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item1 := parsed.Get("input.1")
if item1.Get("type").String() != "function_call_output" {
@@ -209,7 +216,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.0.type").String() != "function_call_output" {
t.Fatalf("automation_update should not be rewritten: %s", parsed.Get("input.0").Raw)
@@ -232,7 +239,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
@@ -265,7 +272,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.1.type").String() != "message" {
t.Fatalf("call_id with whitespace mismatch should be treated as orphan: %s", parsed.Get("input.1").Raw)
@@ -286,7 +293,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
@@ -319,7 +326,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.0.type").String() != "function_call_output" {
t.Fatalf("output should remain paired function_call_output: %s", parsed.Get("input.0").Raw)
@@ -357,7 +364,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.1.type").String() != "function_call_output" {
t.Fatalf("first output should pair with call: %s", parsed.Get("input.1").Raw)
@@ -387,7 +394,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.1.type").String() != "message" {
t.Fatalf("function_call_output should not pair with custom_tool_call: %s", parsed.Get("input.1").Raw)
@@ -413,7 +420,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.1.type").String() != "message" {
t.Fatalf("function_call_output should not pair with assistant message tool_calls: %s", parsed.Get("input.1").Raw)
@@ -444,7 +451,7 @@ func TestRewriteCodexOrphanDelegationInput(t *testing.T) {
]
}`)
got := testRewriteCodexOrphan(payload, true)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
for i := 0; i < 3; i++ {
if itemType := parsed.Get(fmt.Sprintf("input.%d.type", i)).String(); itemType != "function_call_output" {
@@ -481,7 +488,7 @@ func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), http.Header{}, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "function_call_output" {
@@ -497,7 +504,7 @@ func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
}
headers := http.Header{"X-Openai-Subagent": []string{"other_agent"}}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), headers, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "function_call_output" {
@@ -512,7 +519,7 @@ func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "message" || item0.Get("role").String() != "user" {
@@ -531,7 +538,7 @@ func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "test-model", payload, false)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
messages := parsed.Get("messages").Array()
if len(messages) < 2 {
@@ -560,7 +567,7 @@ func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", payload, false)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
item0 := parsed.Get("input.0")
if item0.Get("type").String() != "function_call_output" {
@@ -598,7 +605,7 @@ func TestTranslateRequestWithCodexMultiAgentV2OrphanDelegation(t *testing.T) {
},
}
got := TranslateRequestWithCodexMultiAgentV2(context.Background(), collabHeaders, cfg, sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAIResponse, "test-model", interleaved, false)
- parsed := gjson.ParseBytes(got)
+ parsed := util.ParseGJSONBytesNoCopy(got)
if parsed.Get("input.0.type").String() != "function_call" {
t.Fatalf("input.0 should remain function_call")
diff --git a/internal/client/codex/tool-schema/tool_schema.go b/internal/client/codex/tool-schema/tool_schema.go
index 21e0a477b96..ccdc404bc5d 100644
--- a/internal/client/codex/tool-schema/tool_schema.go
+++ b/internal/client/codex/tool-schema/tool_schema.go
@@ -1,15 +1,37 @@
package toolschema
import (
+ "bytes"
+ "encoding/json"
+ "errors"
"fmt"
+ "io"
+ "math/big"
"net/http"
+ "strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+)
+
+const (
+ // codexComplexUnionBranchThreshold is the minimum number of union branches (oneOf / anyOf)
+ // required before considering a pure constant union eligible for semantic enum normalization.
+ codexComplexUnionBranchThreshold = 8
)
+// codexNumericBoundKeywords lists the JSON Schema numeric bound keywords that
+// the Codex backend rejects with HTTP 500 when set to an integer outside int64.
+var codexNumericBoundKeywords = [...]string{
+ "minimum",
+ "maximum",
+ "exclusiveMinimum",
+ "exclusiveMaximum",
+}
+
// IsCodexUserAgent reports whether headers contain a User-Agent indicating a Codex client.
func IsCodexUserAgent(headers http.Header) bool {
if headers == nil {
@@ -160,14 +182,13 @@ func matchCodexTargetTool(toolName string) map[string]struct{} {
return codexClientToolIntegerFields[baseName]
}
-func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]struct{}) ([]byte, bool) {
- if len(targetFields) == 0 || len(rawParams) == 0 {
- return rawParams, false
+func normalizeCodexToolFieldTypesDoc(doc *ojson.Doc, paramsPath string, params ojson.Result, targetFields map[string]struct{}) bool {
+ if doc == nil || len(targetFields) == 0 {
+ return false
}
- params := gjson.ParseBytes(rawParams)
properties := params.Get("properties")
if !properties.Exists() || !properties.IsObject() {
- return rawParams, false
+ return false
}
changed := false
for fieldName := range targetFields {
@@ -179,10 +200,9 @@ func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]stru
if !typeVal.Exists() {
continue
}
- typePath := "properties." + fieldName + ".type"
- if typeVal.Type == gjson.String && typeVal.String() == "number" {
- if updated, errSet := sjson.SetBytes(rawParams, typePath, "integer"); errSet == nil {
- rawParams = updated
+ typePath := paramsPath + ".properties." + fieldName + ".type"
+ if typeVal.Type == ojson.String && typeVal.String() == "number" {
+ if errSet := doc.Set(typePath, "integer"); errSet == nil {
changed = true
}
} else if typeVal.IsArray() {
@@ -202,14 +222,13 @@ func normalizeCodexToolFieldTypes(rawParams []byte, targetFields map[string]stru
}
}
if hasNumber {
- if updated, errSet := sjson.SetBytes(rawParams, typePath, newTypes); errSet == nil {
- rawParams = updated
+ if errSet := doc.Set(typePath, newTypes); errSet == nil {
changed = true
}
}
}
}
- return rawParams, changed
+ return changed
}
// NormalizeCodexToolIntegerTypes normalizes specified tool parameter declarations
@@ -219,33 +238,40 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
if len(body) == 0 || !IsCodexUserAgent(headers) {
return body
}
+ doc := ojson.NewDoc(body)
+ if !NormalizeCodexToolIntegerTypesDoc(doc, headers) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+// NormalizeCodexToolIntegerTypesDoc normalizes specified tool parameter declarations
+// from number to integer for Codex clients directly on doc in place.
+func NormalizeCodexToolIntegerTypesDoc(doc *ojson.Doc, headers http.Header) bool {
+ if doc == nil || doc.Len() == 0 || !IsCodexUserAgent(headers) {
+ return false
+ }
changed := false
// 1. Process top-level tools
- toolsResult := gjson.GetBytes(body, "tools")
+ toolsResult := doc.Get("tools")
if toolsResult.IsArray() {
- if updated, ok := normalizeToolIntegerTypesInArray(toolsResult, ""); ok {
- if out, errSet := sjson.SetRawBytes(body, "tools", updated); errSet == nil {
- body = out
- changed = true
- }
+ if normalizeToolIntegerTypesInArrayDoc(doc, toolsResult, "tools", "") {
+ changed = true
}
}
// 2. Process input[].additional_tools
- inputResult := gjson.GetBytes(body, "input")
+ inputResult := doc.Get("input")
if inputResult.IsArray() {
for idx, item := range inputResult.Array() {
if item.Get("type").String() == "additional_tools" {
addTools := item.Get("tools")
if addTools.IsArray() {
- if updated, ok := normalizeToolIntegerTypesInArray(addTools, ""); ok {
- path := fmt.Sprintf("input.%d.tools", idx)
- if out, errSet := sjson.SetRawBytes(body, path, updated); errSet == nil {
- body = out
- changed = true
- }
+ path := fmt.Sprintf("input.%d.tools", idx)
+ if normalizeToolIntegerTypesInArrayDoc(doc, addTools, path, "") {
+ changed = true
}
}
}
@@ -255,71 +281,48 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
if changed {
log.Debugf("codex: normalized target tool number types to integer")
}
- return body
+ return changed
}
-func normalizeToolIntegerTypesInArray(tools gjson.Result, namespace string) ([]byte, bool) {
+func normalizeToolIntegerTypesInArrayDoc(doc *ojson.Doc, tools ojson.Result, toolsPath, namespace string) bool {
if !tools.IsArray() {
- return nil, false
+ return false
}
- var out []byte
- offset := 0
- tools.ForEach(func(_, tool gjson.Result) bool {
- updated, changed := normalizeToolIntegerTypesInElement(tool, namespace)
- if !changed {
- return true
- }
- if out == nil {
- out = make([]byte, 0, len(tools.Raw))
+ changed := false
+ idx := 0
+ tools.ForEach(func(_, tool ojson.Result) bool {
+ toolPath := fmt.Sprintf("%s.%d", toolsPath, idx)
+ idx++
+ if normalizeToolIntegerTypesInElementDoc(doc, tool, toolPath, namespace) {
+ changed = true
}
- start := tool.Index - tools.Index
- out = append(out, tools.Raw[offset:start]...)
- out = append(out, updated...)
- offset = start + len(tool.Raw)
return true
})
- if out == nil {
- return nil, false
- }
- return append(out, tools.Raw[offset:]...), true
+ return changed
}
-func normalizeToolIntegerTypesInElement(tool gjson.Result, namespace string) ([]byte, bool) {
- toolRaw := []byte(tool.Raw)
- changed := false
-
+func normalizeToolIntegerTypesInElementDoc(doc *ojson.Doc, tool ojson.Result, toolPath, namespace string) bool {
// Handle namespace tools
if tool.Get("type").String() == "namespace" {
if namespace != "" {
- return nil, false
+ return false
}
namespace = tool.Get("name").String()
if namespace == "" {
- return nil, false
+ return false
}
nested := tool.Get("tools")
if nested.IsArray() {
- if updated, ok := normalizeToolIntegerTypesInArray(nested, namespace); ok {
- if out, errSet := sjson.SetRawBytes(toolRaw, "tools", updated); errSet == nil {
- toolRaw = out
- changed = true
- }
- }
+ return normalizeToolIntegerTypesInArrayDoc(doc, nested, toolPath+".tools", namespace)
}
- return toolRaw, changed
+ return false
}
// Handle Gemini function declarations
for _, declKey := range []string{"function_declarations", "functionDeclarations"} {
decls := tool.Get(declKey)
if decls.IsArray() {
- if updated, ok := normalizeToolIntegerTypesInArray(decls, namespace); ok {
- if out, errSet := sjson.SetRawBytes(toolRaw, declKey, updated); errSet == nil {
- toolRaw = out
- changed = true
- }
- }
- return toolRaw, changed
+ return normalizeToolIntegerTypesInArrayDoc(doc, decls, toolPath+"."+declKey, namespace)
}
}
@@ -342,7 +345,7 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result, namespace string) ([]
paramPath = "parametersJsonSchema"
params = jsonSchema
} else {
- return nil, false
+ return false
}
}
@@ -351,17 +354,463 @@ func normalizeToolIntegerTypesInElement(tool gjson.Result, namespace string) ([]
}
targetFields := matchCodexTargetTool(toolName)
if len(targetFields) == 0 {
- return nil, false
+ return false
}
- updatedParams, paramsChanged := normalizeCodexToolFieldTypes([]byte(params.Raw), targetFields)
- if !paramsChanged {
- return nil, false
+ return normalizeCodexToolFieldTypesDoc(doc, toolPath+"."+paramPath, params, targetFields)
+}
+
+// NormalizeCodexToolSchemas inspects function tools in a Codex request payload
+// and simplifies pure constant union combinations (e.g. large oneOf branch sets
+// representing enums with descriptions, as emitted by MCP servers) into semantically
+// equivalent enum lists, and sanitizes unsupported schema patterns.
+// It does not normalize tool integer types because upstream OpenAI Codex models
+// strictly validate reserved tool schemas (e.g. collaboration.wait_agent).
+func NormalizeCodexToolSchemas(body []byte) []byte {
+ if len(body) == 0 {
+ return body
}
+ doc := ojson.NewDoc(body)
+ if !NormalizeCodexToolSchemasDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
- updatedTool, errSet := sjson.SetRawBytes(toolRaw, paramPath, updatedParams)
- if errSet != nil {
+// NormalizeCodexToolSchemasDoc inspects function tools in doc and simplifies
+// pure constant union combinations and sanitizes unsupported schema patterns in place.
+func NormalizeCodexToolSchemasDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ tools := doc.Get("tools")
+ if !tools.IsArray() {
+ return false
+ }
+ changed := normalizeCodexToolListDoc(doc, tools, "tools")
+ if changed {
+ log.Debugf("codex: normalized tool schemas to prevent upstream failure")
+ }
+ return changed
+}
+
+func normalizeCodexToolListDoc(doc *ojson.Doc, tools ojson.Result, toolsPath string) bool {
+ if !tools.IsArray() {
+ return false
+ }
+ changed := false
+ idx := 0
+ tools.ForEach(func(_, tool ojson.Result) bool {
+ toolPath := fmt.Sprintf("%s.%d", toolsPath, idx)
+ idx++
+ if normalizeCodexToolDoc(doc, tool, toolPath) {
+ changed = true
+ }
+ return true
+ })
+ return changed
+}
+
+// NormalizeSingleCodexTool normalizes a single non-namespace tool declaration.
+func NormalizeSingleCodexTool(tool ojson.Result) ([]byte, bool) {
+ if !tool.IsObject() {
+ return nil, false
+ }
+ doc := ojson.NewDocString(tool.Raw)
+ if !normalizeCodexToolDoc(doc, tool, "") {
return nil, false
}
- return updatedTool, true
+ return doc.Bytes(), true
+}
+
+func normalizeCodexToolDoc(doc *ojson.Doc, tool ojson.Result, toolPath string) bool {
+ toolType := tool.Get("type").String()
+ // Handle namespace tools (e.g. multi-agent nested tools)
+ if toolType == "namespace" {
+ nestedPath := "tools"
+ if toolPath != "" {
+ nestedPath = toolPath + ".tools"
+ }
+ return normalizeCodexToolListDoc(doc, tool.Get("tools"), nestedPath)
+ }
+
+ if toolType != "function" && toolType != "custom" {
+ return false
+ }
+
+ params := tool.Get("parameters")
+ if !params.Exists() || !params.IsObject() {
+ return false
+ }
+
+ paramsPath := "parameters"
+ if toolPath != "" {
+ paramsPath = toolPath + ".parameters"
+ }
+ if !normalizeCodexParametersDoc(doc, paramsPath, params) {
+ return false
+ }
+
+ log.Debugf("codex: normalized schema for tool %s to avoid upstream abort", tool.Get("name").String())
+ return true
+}
+
+func normalizeCodexParametersDoc(doc *ojson.Doc, paramsPath string, params ojson.Result) bool {
+ changed := false
+
+ if sanitizedParams, patternChanged := stripIncompatiblePatternsFromJSON(params.Raw); patternChanged {
+ if errSet := doc.SetRaw(paramsPath, sanitizedParams); errSet == nil {
+ changed = true
+ params = ojson.Parse(sanitizedParams)
+ }
+ }
+
+ properties := params.Get("properties")
+ if properties.Exists() && properties.IsObject() {
+ properties.ForEach(func(propKey, propVal ojson.Result) bool {
+ propPath := paramsPath + ".properties." + escapeCodexSjsonKey(propKey.String())
+ if normalizeCodexPropertySchemaDoc(doc, propPath, propVal) {
+ changed = true
+ }
+ return true
+ })
+ }
+
+ return changed
+}
+
+func stripIncompatiblePatternsFromJSON(rawStr string) (string, bool) {
+ if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) &&
+ !strings.Contains(rawStr, `\\0`) && !strings.Contains(rawStr, "inimum") && !strings.Contains(rawStr, "aximum") {
+ return rawStr, false
+ }
+ var root any
+ dec := json.NewDecoder(strings.NewReader(rawStr))
+ dec.UseNumber()
+ if errDecode := dec.Decode(&root); errDecode != nil || root == nil {
+ return rawStr, false
+ }
+ var dummy any
+ if errEOF := dec.Decode(&dummy); errEOF != io.EOF {
+ return rawStr, false
+ }
+ if !stripIncompatiblePatterns(root) {
+ return rawStr, false
+ }
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ if errEncode := enc.Encode(root); errEncode != nil {
+ return rawStr, false
+ }
+ return strings.TrimSpace(buf.String()), true
+}
+
+func isNumericBoundOutOfInt64(number json.Number) bool {
+ raw := strings.TrimSpace(number.String())
+ if raw == "" {
+ return false
+ }
+ if !strings.ContainsAny(raw, ".eE") {
+ _, errParse := strconv.ParseInt(raw, 10, 64)
+ return errors.Is(errParse, strconv.ErrRange)
+ }
+
+ s := raw
+ negative := false
+ if strings.HasPrefix(s, "-") {
+ negative = true
+ s = s[1:]
+ } else if strings.HasPrefix(s, "+") {
+ s = s[1:]
+ }
+
+ mantissa := s
+ var exp int64
+ if idx := strings.IndexAny(s, "eE"); idx >= 0 {
+ mantissa = s[:idx]
+ expStr := s[idx+1:]
+ parsedExp, errExp := strconv.ParseInt(expStr, 10, 64)
+ if errExp != nil {
+ if errors.Is(errExp, strconv.ErrRange) {
+ return !strings.HasPrefix(expStr, "-") && strings.Trim(mantissa, "0.") != ""
+ }
+ return false
+ }
+ exp = parsedExp
+ }
+
+ intPart := mantissa
+ fracPart := ""
+ if dotIdx := strings.IndexByte(mantissa, '.'); dotIdx >= 0 {
+ intPart = mantissa[:dotIdx]
+ fracPart = mantissa[dotIdx+1:]
+ if strings.IndexByte(fracPart, '.') >= 0 {
+ return false
+ }
+ }
+ if intPart == "" && fracPart == "" {
+ return false
+ }
+ for i := 0; i < len(intPart); i++ {
+ if intPart[i] < '0' || intPart[i] > '9' {
+ return false
+ }
+ }
+ for i := 0; i < len(fracPart); i++ {
+ if fracPart[i] < '0' || fracPart[i] > '9' {
+ return false
+ }
+ }
+
+ allDigits := intPart + fracPart
+ sig := strings.TrimLeft(allDigits, "0")
+ if sig == "" {
+ return false
+ }
+ if exp < -int64(len(raw)) {
+ return false
+ }
+ if exp > int64(len(raw))+20 {
+ return true
+ }
+ trimmed := strings.TrimRight(sig, "0")
+ trailingZeros := int64(len(sig) - len(trimmed))
+ effectiveExp := exp - int64(len(fracPart)) + trailingZeros
+ if effectiveExp < 0 {
+ return false
+ }
+ if effectiveExp > 19 || int64(len(trimmed))+effectiveExp > 19 {
+ return true
+ }
+
+ intStr := trimmed + strings.Repeat("0", int(effectiveExp))
+ if negative {
+ intStr = "-" + intStr
+ }
+ _, errParse := strconv.ParseInt(intStr, 10, 64)
+ return errors.Is(errParse, strconv.ErrRange)
+}
+
+func stripIncompatiblePatterns(v any) bool {
+ changed := false
+ switch schema := v.(type) {
+ case map[string]any:
+ if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
+ delete(schema, "pattern")
+ changed = true
+ }
+ for _, boundKey := range codexNumericBoundKeywords {
+ if boundVal, ok := schema[boundKey].(json.Number); ok && isNumericBoundOutOfInt64(boundVal) {
+ delete(schema, boundKey)
+ changed = true
+ }
+ }
+
+ // Inspect regex keys under patternProperties
+ if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
+ for patternKey, subSchema := range patternProps {
+ if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
+ delete(patternProps, patternKey)
+ changed = true
+ } else if stripIncompatiblePatterns(subSchema) {
+ changed = true
+ }
+ }
+ }
+
+ for _, mapKey := range util.SchemaMapKeywords {
+ if mapKey == "patternProperties" {
+ continue
+ }
+ if subMap, ok := schema[mapKey].(map[string]any); ok {
+ for _, subSchema := range subMap {
+ if stripIncompatiblePatterns(subSchema) {
+ changed = true
+ }
+ }
+ }
+ }
+
+ for _, valKey := range util.SchemaValueKeywords {
+ if val, exists := schema[valKey]; exists {
+ switch sub := val.(type) {
+ case map[string]any:
+ if stripIncompatiblePatterns(sub) {
+ changed = true
+ }
+ case []any:
+ for _, item := range sub {
+ if stripIncompatiblePatterns(item) {
+ changed = true
+ }
+ }
+ }
+ }
+ }
+ case []any:
+ for _, item := range schema {
+ if stripIncompatiblePatterns(item) {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
+
+func normalizeCodexPropertySchemaDoc(doc *ojson.Doc, propPath string, prop ojson.Result) bool {
+ if !prop.IsObject() {
+ return false
+ }
+
+ hasOneOf := prop.Get("oneOf").Exists()
+ hasAnyOf := prop.Get("anyOf").Exists()
+ // If both oneOf and anyOf are present on the same property, leave untouched to preserve compound constraints
+ if hasOneOf && hasAnyOf {
+ return false
+ }
+
+ unionName := ""
+ if hasOneOf {
+ unionName = "oneOf"
+ } else if hasAnyOf {
+ unionName = "anyOf"
+ } else {
+ return false
+ }
+
+ union := prop.Get(unionName)
+ if !union.IsArray() || len(union.Array()) < codexComplexUnionBranchThreshold {
+ return false
+ }
+
+ branches := union.Array()
+ constRawValues := make([]string, 0, len(branches))
+ constSemanticKeys := make([]string, 0, len(branches))
+ seenSemanticKeys := make(map[string]struct{}, len(branches))
+ pureConsts := true
+
+ for _, branch := range branches {
+ canonicalKey, rawJSON, ok := isPureConstBranch(branch)
+ if !ok {
+ pureConsts = false
+ break
+ }
+ if _, seen := seenSemanticKeys[canonicalKey]; seen {
+ // Duplicate semantic value in oneOf violates exclusivity; keep original schema
+ pureConsts = false
+ break
+ }
+ seenSemanticKeys[canonicalKey] = struct{}{}
+ constSemanticKeys = append(constSemanticKeys, canonicalKey)
+ constRawValues = append(constRawValues, rawJSON)
+ }
+
+ // Only transform if every branch is proven to be a pure, unique const definition
+ if !pureConsts || len(constRawValues) == 0 {
+ return false
+ }
+
+ existingEnum := prop.Get("enum")
+ if existingEnum.Exists() && existingEnum.IsArray() {
+ existingEnumKeys := make([]string, 0, len(existingEnum.Array()))
+ for _, v := range existingEnum.Array() {
+ key, ok := canonicalJSONValueKey(v)
+ if !ok {
+ return false
+ }
+ existingEnumKeys = append(existingEnumKeys, key)
+ }
+ // Only remove the redundant union if existing enum is proven semantically identical
+ if equalCanonicalSets(existingEnumKeys, constSemanticKeys) {
+ return doc.Delete(propPath+"."+unionName) == nil
+ }
+ return false
+ }
+
+ // Migrate the pure const union to an enum using raw JSON tokens to avoid any numeric precision loss
+ rawEnumJSON := "[" + strings.Join(constRawValues, ",") + "]"
+ if errEnum := doc.SetRaw(propPath+".enum", rawEnumJSON); errEnum != nil {
+ return false
+ }
+ if errDel := doc.Delete(propPath + "." + unionName); errDel != nil {
+ return false
+ }
+ return true
+}
+
+func isPureConstBranch(branch ojson.Result) (canonicalKey string, rawJSON string, ok bool) {
+ if !branch.IsObject() {
+ return "", "", false
+ }
+ constVal := branch.Get("const")
+ if !constVal.Exists() {
+ return "", "", false
+ }
+ // Verify no other schema validation constraints exist in this branch
+ valid := true
+ branch.ForEach(func(k, _ ojson.Result) bool {
+ key := k.String()
+ if key != "const" && key != "description" && key != "title" {
+ valid = false
+ return false
+ }
+ return true
+ })
+ if !valid {
+ return "", "", false
+ }
+ canonicalKey, ok = canonicalJSONValueKey(constVal)
+ if !ok {
+ return "", "", false
+ }
+ return canonicalKey, constVal.Raw, true
+}
+
+func canonicalJSONValueKey(val ojson.Result) (string, bool) {
+ switch val.Type {
+ case ojson.String:
+ return "s:" + val.String(), true
+ case ojson.Number:
+ raw := strings.TrimSpace(val.Raw)
+ var r big.Rat
+ if _, ok := r.SetString(raw); ok {
+ return "n:" + r.RatString(), true
+ }
+ return "n:" + raw, true
+ case ojson.True:
+ return "b:true", true
+ case ojson.False:
+ return "b:false", true
+ case ojson.Null:
+ return "null", true
+ default:
+ return "", false
+ }
+}
+
+func equalCanonicalSets(a []string, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ setA := make(map[string]struct{}, len(a))
+ for _, v := range a {
+ setA[v] = struct{}{}
+ }
+ for _, v := range b {
+ if _, ok := setA[v]; !ok {
+ return false
+ }
+ }
+ return len(setA) == len(a)
+}
+
+// escapeCodexSjsonKey escapes dots, colons, and backslashes in property keys so that sjson treats
+// keys containing dots (e.g. "my.field") or colons (e.g. ":action") as a single literal key rather
+// than nested paths or control syntax.
+func escapeCodexSjsonKey(key string) string {
+ key = strings.ReplaceAll(key, `\`, `\\`)
+ key = strings.ReplaceAll(key, `.`, `\.`)
+ key = strings.ReplaceAll(key, `:`, `\:`)
+ return key
}
diff --git a/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go b/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go
index 580e4813b48..2c56bcc2a8b 100644
--- a/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go
+++ b/internal/client/codex/tool-schema/tool_schema_integer_fields_test.go
@@ -8,8 +8,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestNormalizeCodexToolIntegerTypesSourceFields(t *testing.T) {
@@ -55,10 +54,11 @@ func TestNormalizeCodexToolIntegerTypesSourceFields(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, typeJSON := range []string{`"number"`, `["number","integer","null"]`, `"integer"`, `"string"`} {
- input := []byte(fmt.Sprintf(`{"tools":[{"type":"function","name":%q,"parameters":{"type":"object","properties":{"unrelated":{"type":"number","default":1.5}}},"output_schema":{"properties":{"wall_time_seconds":{"type":"number"}}}}],"input":[{"type":"function_call","arguments":"{\"limit\":1.5}"}]}`, tt.name))
+ inputDoc := ojson.NewDoc([]byte(fmt.Sprintf(`{"tools":[{"type":"function","name":%q,"parameters":{"type":"object","properties":{"unrelated":{"type":"number","default":1.5}}},"output_schema":{"properties":{"wall_time_seconds":{"type":"number"}}}}],"input":[{"type":"function_call","arguments":"{\"limit\":1.5}"}]}`, tt.name)))
for _, field := range tt.fields {
- input = setIntegerTestRaw(t, input, "tools.0.parameters.properties."+field, `{"type":`+typeJSON+`,"description":"Keep metadata","minimum":0,"default":1}`)
+ setIntegerTestRawDoc(t, inputDoc, "tools.0.parameters.properties."+field, `{"type":`+typeJSON+`,"description":"Keep metadata","minimum":0,"default":1}`)
}
+ input := inputDoc.Bytes()
for _, ua := range []string{"codex-tui/0.154.0", "curl/8.7.1", ""} {
var headers http.Header
if ua != "" {
@@ -72,9 +72,11 @@ func TestNormalizeCodexToolIntegerTypesSourceFields(t *testing.T) {
} else if typeJSON == `["number","integer","null"]` {
wantType = `["integer","null"]`
}
+ wantDoc := ojson.NewDoc(want)
for _, field := range tt.fields {
- want = setIntegerTestRaw(t, want, "tools.0.parameters.properties."+field+".type", wantType)
+ setIntegerTestRawDoc(t, wantDoc, "tools.0.parameters.properties."+field+".type", wantType)
}
+ want = wantDoc.Bytes()
}
out := NormalizeCodexToolIntegerTypes(input, headers)
if !bytes.Equal(out, want) {
@@ -107,8 +109,10 @@ func TestNormalizeCodexToolIntegerTypesNamespaceFormats(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := []byte(fmt.Sprintf(tt.body, schema))
- want := setIntegerTestRaw(t, input, tt.path+".properties.line_offset.type", `"integer"`)
- want = setIntegerTestRaw(t, want, tt.path+".properties.max_lines.type", `["integer","null"]`)
+ wantDoc := ojson.NewDoc(input)
+ setIntegerTestRawDoc(t, wantDoc, tt.path+".properties.line_offset.type", `"integer"`)
+ setIntegerTestRawDoc(t, wantDoc, tt.path+".properties.max_lines.type", `["integer","null"]`)
+ want := wantDoc.Bytes()
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"Codex/1.0"}})
if !bytes.Equal(out, want) {
t.Fatalf("got %s, want %s", out, want)
@@ -144,7 +148,7 @@ func TestNormalizeCodexToolIntegerTypesPreservesUnprovenFields(t *testing.T) {
input := []byte(`{"tools":[{"name":"test_sync_tool","parameters":{"properties":{"barrier.participants":{"type":"number"},"other":{"properties":{"participants":{"type":"number"}}},"barrier":{"properties":{"participants":{"type":"number"},"ratio":{"type":"number"}}}}}}]}`)
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}})
want := setIntegerTestRaw(t, input, "tools.0.parameters.properties.barrier.properties.participants.type", `"integer"`)
- if !bytes.Equal(out, want) || !gjson.ValidBytes(out) {
+ if !bytes.Equal(out, want) || !ojson.ValidBytes(out) {
t.Fatalf("nested path changed unrelated fields: %s", out)
}
}
@@ -155,10 +159,10 @@ func TestNormalizeCodexToolIntegerTypesHistoryNotesSchemas(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if !gjson.ValidBytes(fixture) {
+ if !ojson.ValidBytes(fixture) {
t.Fatal("invalid source schema fixture")
}
- for _, namespace := range gjson.GetBytes(fixture, "tools").Array() {
+ for _, namespace := range ojson.GetBytesNoCopy(fixture, "tools").Array() {
tool := namespace.Get("tools.0")
name := namespace.Get("name").String() + "__" + tool.Get("name").String()
t.Run(name, func(t *testing.T) {
@@ -201,8 +205,10 @@ func TestNormalizeCodexToolIntegerTypesHistoryNotesSchemas(t *testing.T) {
func TestNormalizeCodexToolIntegerTypesNotesExplicitUnionPaths(t *testing.T) {
input := []byte(`{"tools":[{"name":"notes__read_file","parameters":{"type":"object","properties":{"start_line":{"anyOf":[{"type":"number"},{"type":"null"}],"default":-3},"stop_line":{"anyOf":[{"type":"number"},{"type":"number"}],"default":-1},"ratio":{"anyOf":[{"type":"number"},{"type":"null"}]},"other":{"properties":{"start_line":{"anyOf":[{"type":"number"}]}}}},"required":["path"]}}],"input":[{"type":"function_call","arguments":"{\"start_line\":-3,\"stop_line\":-1}"}]}`)
- want := setIntegerTestRaw(t, input, "tools.0.parameters.properties.start_line.anyOf.0.type", `"integer"`)
- want = setIntegerTestRaw(t, want, "tools.0.parameters.properties.stop_line.anyOf.0.type", `"integer"`)
+ wantDoc := ojson.NewDoc(input)
+ setIntegerTestRawDoc(t, wantDoc, "tools.0.parameters.properties.start_line.anyOf.0.type", `"integer"`)
+ setIntegerTestRawDoc(t, wantDoc, "tools.0.parameters.properties.stop_line.anyOf.0.type", `"integer"`)
+ want := wantDoc.Bytes()
out := NormalizeCodexToolIntegerTypes(input, http.Header{"User-Agent": []string{"codex"}})
if !bytes.Equal(out, want) {
t.Fatalf("explicit union paths changed unrelated branches or signed values: %s", out)
@@ -211,9 +217,16 @@ func TestNormalizeCodexToolIntegerTypesNotesExplicitUnionPaths(t *testing.T) {
func setIntegerTestRaw(t *testing.T, body []byte, path, value string) []byte {
t.Helper()
- out, errSet := sjson.SetRawBytes(body, path, []byte(value))
+ out, errSet := ojson.SetRawBytes(body, path, []byte(value))
if errSet != nil {
t.Fatal(errSet)
}
return out
}
+
+func setIntegerTestRawDoc(t *testing.T, doc *ojson.Doc, path, value string) {
+ t.Helper()
+ if errSet := doc.SetRaw(path, value); errSet != nil {
+ t.Fatal(errSet)
+ }
+}
diff --git a/internal/client/codex/tool-schema/tool_schema_test.go b/internal/client/codex/tool-schema/tool_schema_test.go
index cee2efe90c0..90e4e488903 100644
--- a/internal/client/codex/tool-schema/tool_schema_test.go
+++ b/internal/client/codex/tool-schema/tool_schema_test.go
@@ -4,7 +4,7 @@ import (
"net/http"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
@@ -122,18 +122,18 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
t.Run("non-codex user agent preserves numbers", func(t *testing.T) {
headers := http.Header{"User-Agent": []string{"curl/8.7.1"}}
out := NormalizeCodexToolIntegerTypes(input, headers)
- if got := gjson.GetBytes(out, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected number for non-codex, got: %s", got)
}
})
t.Run("nil or empty headers leaves payload untouched", func(t *testing.T) {
outNil := NormalizeCodexToolIntegerTypes(input, nil)
- if got := gjson.GetBytes(outNil, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytesNoCopy(outNil, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected nil headers to preserve number, got: %s", got)
}
outEmpty := NormalizeCodexToolIntegerTypes(input, http.Header{})
- if got := gjson.GetBytes(outEmpty, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytesNoCopy(outEmpty, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected empty headers to preserve number, got: %s", got)
}
})
@@ -142,8 +142,8 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0 (Mac OS 26.5.2; arm64)"}}
out := NormalizeCodexToolIntegerTypes(input, headers)
- toolMap := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(out, "tools").Array() {
+ toolMap := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytesNoCopy(out, "tools").Array() {
toolMap[tool.Get("name").String()] = tool
}
@@ -180,7 +180,7 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
}
// Check additional_tools under input
- addToolType := gjson.GetBytes(out, "input.0.tools.0.parameters.properties.yield_time_ms.type").Array()
+ addToolType := ojson.GetBytesNoCopy(out, "input.0.tools.0.parameters.properties.yield_time_ms.type").Array()
if len(addToolType) != 2 || addToolType[0].String() != "integer" || addToolType[1].String() != "null" {
t.Errorf("additional_tools yield_time_ms type = %v, want [integer null]", addToolType)
}
@@ -233,8 +233,8 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(mcpInput, headers)
- toolMap := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(out, "tools").Array() {
+ toolMap := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytesNoCopy(out, "tools").Array() {
toolMap[tool.Get("name").String()] = tool
}
// mcp__server__sleep must remain number
@@ -272,7 +272,7 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(dupInput, headers)
- arr := gjson.GetBytes(out, "tools.0.parameters.properties.duration_ms.type").Array()
+ arr := ojson.GetBytesNoCopy(out, "tools.0.parameters.properties.duration_ms.type").Array()
if len(arr) != 2 || arr[0].String() != "integer" || arr[1].String() != "null" {
t.Errorf("deduplicated type = %v, want [integer null]", arr)
}
@@ -295,10 +295,10 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(claudeInput, headers)
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.yield_time_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.input_schema.properties.yield_time_ms.type").String(); got != "integer" {
t.Errorf("claude tool yield_time_ms type = %q, want integer", got)
}
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.timeout_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.input_schema.properties.timeout_ms.type").String(); got != "integer" {
t.Errorf("claude tool timeout_ms type = %q, want integer", got)
}
})
@@ -323,7 +323,7 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
}`)
headers := http.Header{"User-Agent": []string{"codex-desktop/0.159.0"}}
out := NormalizeCodexToolIntegerTypes(geminiInput, headers)
- if got := gjson.GetBytes(out, "tools.0.function_declarations.0.parameters.properties.duration_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function_declarations.0.parameters.properties.duration_ms.type").String(); got != "integer" {
t.Errorf("gemini tool duration_ms type = %q, want integer", got)
}
})
@@ -348,8 +348,64 @@ func TestNormalizeCodexToolIntegerTypes(t *testing.T) {
}`)
headers := http.Header{"User-Agent": []string{"codex-desktop/0.159.0"}}
out := NormalizeCodexToolIntegerTypes(geminiInput, headers)
- if got := gjson.GetBytes(out, "tools.0.functionDeclarations.0.parametersJsonSchema.properties.yield_time_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.functionDeclarations.0.parametersJsonSchema.properties.yield_time_ms.type").String(); got != "integer" {
t.Errorf("gemini parametersJsonSchema yield_time_ms type = %q, want integer", got)
}
})
}
+
+func TestNormalizeCodexToolSchemasDocCrossMethod(t *testing.T) {
+ raw := []byte(`{
+ "model": "gpt-5.4(high)",
+ "tools": [{
+ "type": "function",
+ "name": "choose_action",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "string",
+ "oneOf": [
+ {"const": "a", "description": "A"},
+ {"const": "b", "description": "B"},
+ {"const": "c", "description": "C"},
+ {"const": "d", "description": "D"},
+ {"const": "e", "description": "E"},
+ {"const": "f", "description": "F"},
+ {"const": "g", "description": "G"},
+ {"const": "h", "description": "H"}
+ ]
+ },
+ "field": {
+ "type": "string",
+ "pattern": "^[^\\p{Cc}]+$"
+ }
+ }
+ }
+ }]
+ }`)
+
+ doc := ojson.NewDoc(raw)
+ if errSet := doc.Set("model", "gpt-5.4"); errSet != nil {
+ t.Fatalf("doc.Set(model) error = %v", errSet)
+ }
+ if !NormalizeCodexToolSchemasDoc(doc) {
+ t.Fatal("expected NormalizeCodexToolSchemasDoc to report changes")
+ }
+ got := doc.Bytes()
+
+ if gotModel := ojson.GetBytesNoCopy(got, "model").String(); gotModel != "gpt-5.4" {
+ t.Fatalf("model = %q, want gpt-5.4", gotModel)
+ }
+ actionProp := ojson.GetBytesNoCopy(got, "tools.0.parameters.properties.action")
+ if actionProp.Get("oneOf").Exists() {
+ t.Fatalf("expected action.oneOf to be removed: %s", actionProp.Raw)
+ }
+ if enums := actionProp.Get("enum").Array(); len(enums) != 8 {
+ t.Fatalf("action.enum len = %d, want 8", len(enums))
+ }
+ fieldProp := ojson.GetBytesNoCopy(got, "tools.0.parameters.properties.field")
+ if fieldProp.Get("pattern").Exists() {
+ t.Fatalf("expected field.pattern to be removed: %s", fieldProp.Raw)
+ }
+}
diff --git a/internal/client/grokbuild/keepalive.go b/internal/client/grokbuild/keepalive.go
index d7318c822e7..2718e7c5a15 100644
--- a/internal/client/grokbuild/keepalive.go
+++ b/internal/client/grokbuild/keepalive.go
@@ -7,8 +7,8 @@ import (
"slices"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
)
var keepaliveSSEComment = []byte(": keepalive\n\n")
@@ -53,7 +53,7 @@ func IsGrokClientContext(ctx context.Context, headers http.Header) bool {
// IsKeepalivePayload reports whether a JSON payload has type "keepalive".
func IsKeepalivePayload(payload []byte) bool {
- return gjson.GetBytes(payload, "type").String() == "keepalive"
+ return ojson.GetBytesNoCopy(payload, "type").String() == "keepalive"
}
// IsKeepaliveSSELine reports whether an SSE line represents a keepalive event or data frame.
diff --git a/internal/clienterror/client_error.go b/internal/clienterror/client_error.go
index 124518e4c87..ce23abaa4e3 100644
--- a/internal/clienterror/client_error.go
+++ b/internal/clienterror/client_error.go
@@ -2,13 +2,13 @@
package clienterror
import (
+ "bytes"
"context"
- "encoding/json"
"errors"
"net/http"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// StatusClientClosedRequest is the nginx-style status used when the client
@@ -101,6 +101,17 @@ func IsRequestFault(status int, err error) bool {
if IsClaudeThreadNotFound(status, err) {
return true
}
+ // Devin's MCP tool configuration and content-policy rejections are caused by
+ // the request payload rather than credential permissions.
+ if IsDevinRequestPermissionDenied(status, err) {
+ return true
+ }
+ // Anthropic reports an exhausted API credit balance as 400 with an
+ // invalid_request_error body. Preserve credential cooldown and rotation
+ // without weakening generic 400 request-fault handling.
+ if IsCreditBalanceTooLow(status, err) {
+ return false
+ }
// Model not found indicates a credential-model capability mismatch rather than
// a caller request error. Preserve rotation and cooldown for the model.
if hasModelNotFoundErrorBody(err) {
@@ -109,7 +120,7 @@ func IsRequestFault(status int, err error) bool {
if hasRequestFaultBody(err) {
return true
}
- if err != nil && IsItemNotPersisted(err.Error()) {
+ if err != nil && (IsItemNotPersisted(err.Error()) || IsGeminiFilterBlockError(err) || IsClaudePolicyRefusalError(err)) {
return true
}
switch status {
@@ -138,6 +149,165 @@ func IsItemNotPersisted(message string) bool {
strings.Contains(lower, "items are not persisted when `store` is set to false")
}
+// GeminiFilterBlockMessagePrefix is the leading text of Gemini's mid-stream content filter block frame.
+const GeminiFilterBlockMessagePrefix = "This request was blocked by Gemini's filters."
+
+// GeminiFilterBlockPolicyURL is the policy URL embedded in Gemini's mid-stream content filter block frame.
+const GeminiFilterBlockPolicyURL = "https://policies.google.com/terms/generative-ai/use-policy"
+
+// GeminiFilterBlockMessage inspects a raw Gemini / Antigravity JSON payload and returns the filter
+// block message when the payload is an injected mid-stream Gemini content filter frame.
+//
+// Upstream injects this frame inside an HTTP 200 SSE stream with only candidates[0].content.parts[0].text
+// and without content.role, responseId, modelVersion, or usageMetadata.
+func GeminiFilterBlockMessage(payload []byte) (string, bool) {
+ if len(payload) == 0 || !bytes.Contains(payload, []byte(GeminiFilterBlockMessagePrefix)) || !ojson.ValidBytes(payload) {
+ return "", false
+ }
+ for _, path := range []string{"responseId", "response.responseId", "modelVersion", "response.modelVersion", "usageMetadata", "response.usageMetadata", "cpaUsageMetadata", "response.cpaUsageMetadata"} {
+ if ojson.GetBytesNoCopy(payload, path).Exists() {
+ return "", false
+ }
+ }
+ for _, cPath := range []string{"candidates", "response.candidates"} {
+ candidates := ojson.GetBytesNoCopy(payload, cPath)
+ if !candidates.Exists() || candidates.Get("1").Exists() {
+ continue
+ }
+ candidate := candidates.Get("0")
+ if !candidate.Exists() {
+ continue
+ }
+ if role := strings.TrimSpace(candidate.Get("content.role").String()); role != "" {
+ return "", false
+ }
+ if finishReason := strings.TrimSpace(candidate.Get("finishReason").String()); finishReason != "" {
+ return "", false
+ }
+ if candidate.Get("index").Exists() || candidate.Get("safetyRatings").Exists() || candidate.Get("citationMetadata").Exists() {
+ return "", false
+ }
+ parts := candidate.Get("content.parts")
+ if !parts.Exists() || parts.Get("1").Exists() {
+ return "", false
+ }
+ part := parts.Get("0")
+ if part.Get("thought").Exists() || part.Get("thoughtSignature").Exists() || part.Get("functionCall").Exists() {
+ return "", false
+ }
+ text := strings.TrimSpace(part.Get("text").String())
+ if isGeminiFilterBlockText(text) {
+ return text, true
+ }
+ }
+ return "", false
+}
+
+// IsGeminiFilterBlockError reports whether err carries a Gemini mid-stream content filter block message.
+func IsGeminiFilterBlockError(err error) bool {
+ if err == nil {
+ return false
+ }
+ if isGeminiFilterBlockText(err.Error()) {
+ return true
+ }
+ type responseBodyProvider interface {
+ ResponseBody() []byte
+ }
+ var responseBody responseBodyProvider
+ if errors.As(err, &responseBody) && responseBody != nil && len(responseBody.ResponseBody()) > 0 {
+ if _, ok := GeminiFilterBlockMessage(responseBody.ResponseBody()); ok {
+ return true
+ }
+ if isGeminiFilterBlockText(string(responseBody.ResponseBody())) {
+ return true
+ }
+ }
+ return false
+}
+
+func isGeminiFilterBlockText(text string) bool {
+ trimmed := strings.TrimSpace(text)
+ return strings.HasPrefix(trimmed, GeminiFilterBlockMessagePrefix) &&
+ strings.Contains(trimmed, GeminiFilterBlockPolicyURL)
+}
+
+// ClaudePolicyRefusalMessage inspects a Claude Messages JSON payload (either a non-stream
+// message object or a streaming message_delta event) and returns the refusal explanation
+// when stop_details indicates a policy/cyber refusal block.
+func ClaudePolicyRefusalMessage(payload []byte) (string, bool) {
+ if len(payload) == 0 || !bytes.Contains(payload, []byte("stop_details")) || !ojson.ValidBytes(payload) {
+ return "", false
+ }
+ for _, prefix := range []string{"delta", ""} {
+ stopReasonPath := "stop_reason"
+ stopDetailsPath := "stop_details"
+ if prefix != "" {
+ stopReasonPath = prefix + ".stop_reason"
+ stopDetailsPath = prefix + ".stop_details"
+ }
+ stopReason := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(payload, stopReasonPath).String()))
+ details := ojson.GetBytesNoCopy(payload, stopDetailsPath)
+ if !details.Exists() || details.Type != ojson.JSON {
+ continue
+ }
+ detailsType := strings.ToLower(strings.TrimSpace(details.Get("type").String()))
+ category := strings.ToLower(strings.TrimSpace(details.Get("category").String()))
+ explanation := strings.TrimSpace(details.Get("explanation").String())
+ if stopReason == "refusal" || detailsType == "refusal" || category == "cyber" {
+ if explanation != "" {
+ if category != "" {
+ return "claude refusal (" + category + "): " + explanation, true
+ }
+ return "claude refusal: " + explanation, true
+ }
+ if category != "" {
+ return "claude refusal (" + category + ")", true
+ }
+ if raw := strings.TrimSpace(string(details.Raw)); raw != "" {
+ return "claude refusal: " + raw, true
+ }
+ return "claude refusal", true
+ }
+ }
+ return "", false
+}
+
+// IsClaudePolicyRefusalError reports whether err represents a Claude policy/cyber refusal block.
+func IsClaudePolicyRefusalError(err error) bool {
+ if err == nil {
+ return false
+ }
+ if isClaudePolicyRefusalText(err.Error()) {
+ return true
+ }
+ if _, ok := ClaudePolicyRefusalMessage([]byte(err.Error())); ok {
+ return true
+ }
+ type responseBodyProvider interface {
+ ResponseBody() []byte
+ }
+ var responseBody responseBodyProvider
+ if errors.As(err, &responseBody) && responseBody != nil && len(responseBody.ResponseBody()) > 0 {
+ if _, ok := ClaudePolicyRefusalMessage(responseBody.ResponseBody()); ok {
+ return true
+ }
+ if isClaudePolicyRefusalText(string(responseBody.ResponseBody())) {
+ return true
+ }
+ }
+ return false
+}
+
+func isClaudePolicyRefusalText(text string) bool {
+ lower := strings.ToLower(strings.TrimSpace(text))
+ return lower == "claude refusal" ||
+ strings.HasPrefix(lower, "claude refusal (") ||
+ strings.HasPrefix(lower, "claude refusal:") ||
+ (strings.Contains(lower, "triggered restrictions on violative cyber content") &&
+ strings.Contains(lower, "anthropic's usage policy"))
+}
+
// IsClaudeThreadNotFound reports the Claude 404 response for a stale
// previous_message_id continuation from either a structured JSON response
// or a plain-text error message.
@@ -156,25 +326,95 @@ func IsClaudeThreadNotFound(status int, err error) bool {
if body == "" {
return false
}
- if json.Valid([]byte(body)) {
- return strings.EqualFold(strings.TrimSpace(gjson.Get(body, "error.type").String()), "not_found_error") &&
- strings.Contains(strings.ToLower(gjson.Get(body, "error.message").String()), "thread state") &&
- strings.Contains(strings.ToLower(gjson.Get(body, "error.message").String()), "previous_message_id")
+ if ojson.Valid(body) {
+ msg := strings.ToLower(ojson.Get(body, "error.message").String())
+ return strings.EqualFold(strings.TrimSpace(ojson.Get(body, "error.type").String()), "not_found_error") &&
+ strings.Contains(msg, "thread state") &&
+ strings.Contains(msg, "previous_message_id")
}
lower := strings.ToLower(body)
return strings.Contains(lower, "thread state") && strings.Contains(lower, "previous_message_id")
}
+// IsDevinRequestPermissionDenied reports whether a 403 error from Devin is
+// deterministically caused by the request payload (such as the MCP tool
+// configuration filter or the prompt content policy filter) rather than
+// credential permissions.
+func IsDevinRequestPermissionDenied(status int, err error) bool {
+ if status <= 0 && err != nil {
+ status = HTTPStatusFromError(err)
+ }
+ if status != http.StatusForbidden || err == nil {
+ return false
+ }
+ if isDevinRequestPermissionDeniedText(err.Error()) {
+ return true
+ }
+ type responseBodyProvider interface {
+ ResponseBody() []byte
+ }
+ var responseBody responseBodyProvider
+ if errors.As(err, &responseBody) && responseBody != nil && len(responseBody.ResponseBody()) > 0 {
+ return isDevinRequestPermissionDeniedText(string(responseBody.ResponseBody()))
+ }
+ return false
+}
+
+func isDevinRequestPermissionDeniedText(text string) bool {
+ lower := strings.ToLower(strings.TrimSpace(text))
+ if lower == "" || !strings.Contains(lower, "permission_denied") {
+ return false
+ }
+ return strings.Contains(lower, "unable to process request due to an mcp configuration issue") ||
+ strings.Contains(lower, "blocked by our content policy")
+}
+
+// IsCreditBalanceTooLow reports the Anthropic 400 response returned when a
+// credential's API credit balance is exhausted.
+func IsCreditBalanceTooLow(status int, err error) bool {
+ if status <= 0 && err != nil {
+ status = HTTPStatusFromError(err)
+ }
+ if status != http.StatusBadRequest || err == nil {
+ return false
+ }
+ return hasCreditBalanceTooLowBody(err)
+}
+
+func hasCreditBalanceTooLowBody(err error) bool {
+ if err == nil {
+ return false
+ }
+ body := strings.TrimSpace(err.Error())
+ type responseBodyProvider interface {
+ ResponseBody() []byte
+ }
+ var responseBody responseBodyProvider
+ if errors.As(err, &responseBody) && responseBody != nil && len(responseBody.ResponseBody()) > 0 {
+ body = strings.TrimSpace(string(responseBody.ResponseBody()))
+ }
+ if body == "" || !ojson.Valid(body) {
+ return false
+ }
+ for _, path := range []string{"error.message", "message", "response.error.message", "body.error.message"} {
+ msg := strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String()))
+ if strings.HasPrefix(msg, "your credit balance is too low") {
+ return true
+ }
+ }
+ return false
+}
+
func hasModelNotFoundErrorBody(err error) bool {
if err == nil {
return false
}
body := strings.TrimSpace(err.Error())
- if body == "" || !json.Valid([]byte(body)) {
+ if body == "" || !ojson.Valid(body) {
return false
}
for _, path := range []string{"error.code", "code", "response.error.code", "body.error.code"} {
- code := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String()))
+ code := strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String()))
if code == "model_not_found" || code == "model_not_found_error" {
return true
}
@@ -187,11 +427,11 @@ func hasAuthenticationErrorBody(err error) bool {
return false
}
body := strings.TrimSpace(err.Error())
- if body == "" || !json.Valid([]byte(body)) {
+ if body == "" || !ojson.Valid(body) {
return false
}
for _, path := range []string{"error.type", "type", "response.error.type", "body.error.type"} {
- if errType := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String())); errType == "authentication_error" {
+ if errType := strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String())); errType == "authentication_error" {
return true
}
}
@@ -203,17 +443,17 @@ func hasRequestFaultBody(err error) bool {
return false
}
body := strings.TrimSpace(err.Error())
- if body == "" || !json.Valid([]byte(body)) {
+ if body == "" || !ojson.Valid(body) {
return false
}
for _, path := range []string{"error.code", "code", "response.error.code", "body.error.code"} {
- code := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String()))
+ code := strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String()))
if _, ok := requestFaultCodes[code]; ok {
return true
}
}
for _, path := range []string{"error.type", "type", "response.error.type", "body.error.type"} {
- errType := strings.ToLower(strings.TrimSpace(gjson.Get(body, path).String()))
+ errType := strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String()))
if _, ok := requestFaultTypes[errType]; ok {
return true
}
diff --git a/internal/clienterror/client_error_test.go b/internal/clienterror/client_error_test.go
index 0b57bbfe225..83ad7c87a56 100644
--- a/internal/clienterror/client_error_test.go
+++ b/internal/clienterror/client_error_test.go
@@ -102,6 +102,52 @@ func TestHTTPStatusFromError(t *testing.T) {
}
}
+func TestGeminiFilterBlockMessage(t *testing.T) {
+ filterFrame := []byte(`{"response": {"candidates": [{"content": {"parts": [{"text": "This request was blocked by Gemini's filters. They can occasionally trigger by mistake on safe coding, security, or biology-related queries. Please try rephrasing your prompt. You can [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting#file-bug) or read more about [our policies here](https://policies.google.com/terms/generative-ai/use-policy)."}]}}]}}`)
+ msg, blocked := GeminiFilterBlockMessage(filterFrame)
+ if !blocked || msg == "" {
+ t.Fatalf("GeminiFilterBlockMessage(filterFrame) = (%q, %v), want non-empty true", msg, blocked)
+ }
+
+ normalModelFrame := []byte(`{"response": {"candidates": [{"content": {"role": "model", "parts": [{"text": "This request was blocked by Gemini's filters. You can read more at https://policies.google.com/terms/generative-ai/use-policy."}]}}], "usageMetadata": {"promptTokenCount": 10}, "modelVersion": "gemini-4-argon-b", "responseId": "resp-1"}}`)
+ if gotMsg, gotBlocked := GeminiFilterBlockMessage(normalModelFrame); gotBlocked {
+ t.Fatalf("GeminiFilterBlockMessage(normalModelFrame) = (%q, true), want false when role/metadata present", gotMsg)
+ }
+
+ renamedUsageFrame := []byte(`{"response": {"candidates": [{"content": {"parts": [{"text": "This request was blocked by Gemini's filters. You can read more at https://policies.google.com/terms/generative-ai/use-policy."}]}}], "cpaUsageMetadata": {"promptTokenCount": 10}}}`)
+ if gotMsg, gotBlocked := GeminiFilterBlockMessage(renamedUsageFrame); gotBlocked {
+ t.Fatalf("GeminiFilterBlockMessage(renamedUsageFrame) = (%q, true), want false when cpaUsageMetadata present", gotMsg)
+ }
+}
+
+func TestClaudePolicyRefusalMessage(t *testing.T) {
+ deltaFrame := []byte(`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"cyber","explanation":"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback."},"container":null},"usage":{"input_tokens":4,"output_tokens":0}}`)
+ msg, blocked := ClaudePolicyRefusalMessage(deltaFrame)
+ if !blocked || msg == "" {
+ t.Fatalf("ClaudePolicyRefusalMessage(deltaFrame) = (%q, %v), want non-empty true", msg, blocked)
+ }
+ if !IsRequestFault(http.StatusBadGateway, errors.New(msg)) {
+ t.Fatalf("IsRequestFault() = false for %q, want true", msg)
+ }
+
+ plainRefusalWithoutDetails := []byte(`{"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":2}}`)
+ if gotMsg, gotBlocked := ClaudePolicyRefusalMessage(plainRefusalWithoutDetails); gotBlocked {
+ t.Fatalf("ClaudePolicyRefusalMessage(plainRefusalWithoutDetails) = (%q, true), want false when stop_details absent", gotMsg)
+ }
+
+ sparseRefusal := []byte(`{"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"type":"refusal"}},"usage":{"output_tokens":0}}`)
+ sparseMsg, sparseBlocked := ClaudePolicyRefusalMessage(sparseRefusal)
+ if !sparseBlocked || sparseMsg == "" {
+ t.Fatalf("ClaudePolicyRefusalMessage(sparseRefusal) = (%q, %v), want non-empty true", sparseMsg, sparseBlocked)
+ }
+ if !IsClaudePolicyRefusalError(errors.New(sparseMsg)) {
+ t.Fatalf("IsClaudePolicyRefusalError(%q) = false, want true", sparseMsg)
+ }
+ if !IsRequestFault(http.StatusBadGateway, errors.New(sparseMsg)) {
+ t.Fatalf("IsRequestFault() = false for sparse refusal %q, want true", sparseMsg)
+ }
+}
+
type statusAndUnwrapError struct {
status int
body string
@@ -166,6 +212,12 @@ func TestIsRequestFault(t *testing.T) {
err: errors.New(`{"error":{"type":"invalid_request","code":"cyber_policy","message":"blocked"}}`),
want: true,
},
+ {
+ name: "gemini filter block frame message",
+ status: http.StatusBadGateway,
+ err: errors.New("This request was blocked by Gemini's filters. They can occasionally trigger by mistake on safe coding, security, or biology-related queries. Please try rephrasing your prompt. You can [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting#file-bug) or read more about [our policies here](https://policies.google.com/terms/generative-ai/use-policy)."),
+ want: true,
+ },
{
name: "context length behind internal error",
status: http.StatusInternalServerError,
@@ -234,6 +286,26 @@ func TestIsRequestFault(t *testing.T) {
err: errors.New(`{"error":{"message":"Insufficient Balance","type":"unknown_error","param":null,"code":"invalid_request_error"}}`),
want: false,
},
+ {
+ name: "anthropic credit balance too low is payment failure",
+ status: http.StatusBadRequest,
+ err: errors.New(`{"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits."}}`),
+ want: false,
+ },
+ {
+ name: "anthropic credit balance too low in response body is payment failure",
+ err: responseBodyError{
+ status: http.StatusBadRequest,
+ body: `{"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits."}}`,
+ },
+ want: false,
+ },
+ {
+ name: "anthropic ordinary invalid_request_error remains request fault",
+ status: http.StatusBadRequest,
+ err: errors.New(`{"type":"error","error":{"type":"invalid_request_error","message":"messages: at least one message is required"}}`),
+ want: true,
+ },
{
name: "structured model_not_found is not request fault",
status: http.StatusBadRequest,
@@ -246,6 +318,32 @@ func TestIsRequestFault(t *testing.T) {
err: errors.New(`{"error":{"message":"Rate Limit Reached","type":"unknown_error","param":null,"code":"invalid_request_error"}}`),
want: false,
},
+ {
+ name: "devin MCP configuration issue is request fault",
+ status: http.StatusForbidden,
+ err: errors.New("devin upstream error (permission_denied): Unable to process request due to an MCP configuration issue."),
+ want: true,
+ },
+ {
+ name: "devin MCP configuration issue from statusError",
+ err: statusError{
+ status: http.StatusForbidden,
+ body: "devin upstream error (permission_denied): Unable to process request due to an MCP configuration issue.",
+ },
+ want: true,
+ },
+ {
+ name: "devin content policy rejection is request fault",
+ status: http.StatusForbidden,
+ err: errors.New("devin upstream error (permission_denied): Your request was blocked by our content policy. Please remove sensitive or unsafe content from your prompt, memories, and other settings and try again."),
+ want: true,
+ },
+ {
+ name: "devin account model access permission_denied remains credential failure",
+ status: http.StatusForbidden,
+ err: errors.New("devin upstream error (permission_denied): model access is not allowed for this account"),
+ want: false,
+ },
{name: "quota", status: http.StatusTooManyRequests, err: errors.New("quota")},
{name: "transport", status: http.StatusBadGateway, err: errors.New("unexpected EOF")},
{name: "invalid JSON body", status: http.StatusBadGateway, err: errors.New(`{"error":`)},
diff --git a/internal/config/claude_commentary_test.go b/internal/config/claude_commentary_test.go
new file mode 100644
index 00000000000..3c06ede61a2
--- /dev/null
+++ b/internal/config/claude_commentary_test.go
@@ -0,0 +1,43 @@
+package config
+
+import (
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestClaudeCommentaryConfigV8(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ yaml string
+ want bool
+ }{
+ {"default", "config-version: 8\n", false},
+ {"enabled", "config-version: 8\nclient:\n claude:\n show-codex-commentary: true\n", true},
+ {"disabled", "config-version: 8\nclient:\n claude:\n show-codex-commentary: false\n", false},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ if errValidate := ValidateV8Config([]byte(test.yaml)); errValidate != nil {
+ t.Fatalf("ValidateV8Config: %v", errValidate)
+ }
+ var cfg Config
+ if err := yaml.Unmarshal([]byte(test.yaml), &cfg); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Client.Claude.ShowCodexCommentary != test.want {
+ t.Fatalf("show-codex-commentary = %v, want %v", cfg.Client.Claude.ShowCodexCommentary, test.want)
+ }
+ encoded, err := yaml.Marshal(&cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var roundTrip Config
+ if err := yaml.Unmarshal(encoded, &roundTrip); err != nil {
+ t.Fatal(err)
+ }
+ if roundTrip.Client.Claude.ShowCodexCommentary != test.want {
+ t.Fatal("commentary setting lost in config round-trip")
+ }
+ })
+ }
+}
diff --git a/internal/config/claude_header_defaults_test.go b/internal/config/claude_header_defaults_test.go
index 3ec0245e173..a52459750df 100644
--- a/internal/config/claude_header_defaults_test.go
+++ b/internal/config/claude_header_defaults_test.go
@@ -63,6 +63,7 @@ func TestParseConfigBytes_ClaudeHeaderDefaultsOperatorOverrides(t *testing.T) {
cfg, err := ParseConfigBytes([]byte(`upstream:
claude:
header-defaults:
+ native-entrypoints: [" T3-Code ", "", "t3-code", "custom-ide"]
anthropic-beta-strip: [" cache-diagnosis-2026-04-07 ", "", "cache-diagnosis-2026-04-07"]
anthropic-beta-append: ["context-1m-2025-08-07", " context-1m-2025-08-07 ", "advisor-tool-2026-03-01"]
inject-diagnostics: false
@@ -72,6 +73,9 @@ func TestParseConfigBytes_ClaudeHeaderDefaultsOperatorOverrides(t *testing.T) {
t.Fatalf("ParseConfigBytes() error = %v", err)
}
hd := cfg.ClaudeHeaderDefaults
+ if want := []string{"t3-code", "custom-ide"}; !reflect.DeepEqual(hd.NativeEntrypoints, want) {
+ t.Fatalf("NativeEntrypoints = %q, want %q", hd.NativeEntrypoints, want)
+ }
if want := []string{"cache-diagnosis-2026-04-07"}; !reflect.DeepEqual(hd.AnthropicBetaStrip, want) {
t.Fatalf("AnthropicBetaStrip = %q, want %q", hd.AnthropicBetaStrip, want)
}
diff --git a/internal/config/clone_test.go b/internal/config/clone_test.go
index a9be0c65b33..45a94a72fea 100644
--- a/internal/config/clone_test.go
+++ b/internal/config/clone_test.go
@@ -344,3 +344,25 @@ func keyForPath(key reflect.Value) string {
}
return key.Type().String()
}
+
+func TestParseConfigBytes_ProxyConnectionPool(t *testing.T) {
+ cfg, err := ParseConfigBytes([]byte("api-keys:\n codex:\n - name: pool-group\n base-url: https://ollama.com/v1\n connection-pool:\n enabled: true\n idle-conn-timeout: 15s\n max-idle-conns-per-host: 4\n keys:\n - api-key: pool-key\n"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(cfg.CodexKey) != 1 {
+ t.Fatalf("codex entries = %d, want 1", len(cfg.CodexKey))
+ }
+ pool := cfg.CodexKey[0].ConnectionPool
+ if pool == nil || pool.Enabled == nil || !*pool.Enabled || pool.IdleConnTimeout != "15s" || pool.MaxIdleConnsPerHost == nil || *pool.MaxIdleConnsPerHost != 4 {
+ t.Fatalf("unexpected pool config: %+v", pool)
+ }
+ clone := cfg.CloneForRuntime()
+ if clone.CodexKey[0].ConnectionPool == pool {
+ t.Fatal("clone shares the pool block")
+ }
+ *clone.CodexKey[0].ConnectionPool.Enabled = false
+ if !*cfg.CodexKey[0].ConnectionPool.Enabled {
+ t.Fatal("clone shares enabled pointer")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index faa54336355..dad8d4354d5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -66,6 +66,9 @@ type Config struct {
// Discovery configures local network mDNS / DNS-SD service advertising.
Discovery DiscoveryConfig `yaml:"discovery" json:"discovery"`
+ // KVStore configures the local bbolt key-value store.
+ KVStore KVStoreConfig `yaml:"kv-store" json:"kv-store"`
+
// CommercialMode disables high-overhead request logging and HTTP middleware features to minimize per-request memory usage.
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
@@ -131,6 +134,13 @@ type Config struct {
AntigravitySignatureBypassStrict *bool `yaml:"antigravity-signature-bypass-strict,omitempty" json:"antigravity-signature-bypass-strict,omitempty"`
+ // ProxyPoolPublisher identifies the runtime that published this snapshot, so a
+ // reload in one runtime never retires another runtime's snapshots.
+ ProxyPoolPublisher uint64 `yaml:"-" json:"-"`
+
+ // ProxyPoolGeneration fences obsolete runtime snapshots from the shared transport cache.
+ ProxyPoolGeneration uint64 `yaml:"-" json:"-"`
+
// Antigravity configures provider-wide Antigravity request behavior.
Antigravity AntigravityConfig `yaml:"antigravity" json:"antigravity"`
diff --git a/internal/config/config_defaults.go b/internal/config/config_defaults.go
index e07e22b822c..4bb5629e1ef 100644
--- a/internal/config/config_defaults.go
+++ b/internal/config/config_defaults.go
@@ -5,4 +5,5 @@ const (
DefaultPprofAddr = "127.0.0.1:8316"
DefaultAuthDir = "~/.cli-proxy-api"
DefaultDiscoveryServiceType = "_ai-gateway._tcp"
+ DefaultKVStorePath = "kv.db"
)
diff --git a/internal/config/config_load.go b/internal/config/config_load.go
index baca01e92e6..c4ea79e5a35 100644
--- a/internal/config/config_load.go
+++ b/internal/config/config_load.go
@@ -264,6 +264,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.Pprof.Addr = DefaultPprofAddr
}
+ cfg.KVStore.Path = strings.TrimSpace(cfg.KVStore.Path)
+
if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0
}
diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go
index aed0feaf811..163f300a112 100644
--- a/internal/config/config_normalization.go
+++ b/internal/config/config_normalization.go
@@ -2,6 +2,7 @@ package config
import (
"sort"
+ "strconv"
"strings"
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginstore"
@@ -57,12 +58,14 @@ func (cfg *Config) SanitizeCodexHeaderDefaults() {
}
// SanitizeClaudeHeaderDefaults trims surrounding whitespace from the
-// configured Claude fingerprint baseline values, normalizes the beta override
-// lists, and resets an unknown client-request-id mode to the default.
+// configured Claude fingerprint baseline values, normalizes the native
+// entrypoint and beta override lists, and resets an unknown client-request-id
+// mode to the default.
func (cfg *Config) SanitizeClaudeHeaderDefaults() {
if cfg == nil {
return
}
+ cfg.ClaudeHeaderDefaults.NativeEntrypoints = normalizeClaudeEntrypointNames(cfg.ClaudeHeaderDefaults.NativeEntrypoints)
cfg.ClaudeHeaderDefaults.AnthropicBetaStrip = normalizeClaudeBetaNames(cfg.ClaudeHeaderDefaults.AnthropicBetaStrip)
cfg.ClaudeHeaderDefaults.AnthropicBetaAppend = normalizeClaudeBetaNames(cfg.ClaudeHeaderDefaults.AnthropicBetaAppend)
mode := strings.ToLower(strings.TrimSpace(cfg.ClaudeHeaderDefaults.ClientRequestID))
@@ -82,6 +85,31 @@ func (cfg *Config) SanitizeClaudeHeaderDefaults() {
cfg.ClaudeHeaderDefaults.Timezone = strings.TrimSpace(cfg.ClaudeHeaderDefaults.Timezone)
}
+// normalizeClaudeEntrypointNames trims and lowercases entrypoint names,
+// dropping empty and duplicate entries while keeping the configured order.
+func normalizeClaudeEntrypointNames(names []string) []string {
+ if len(names) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(names))
+ seen := make(map[string]struct{}, len(names))
+ for _, name := range names {
+ name = strings.ToLower(strings.TrimSpace(name))
+ if name == "" {
+ continue
+ }
+ if _, ok := seen[name]; ok {
+ continue
+ }
+ seen[name] = struct{}{}
+ out = append(out, name)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
// normalizeClaudeBetaNames trims beta names and drops empty and duplicate
// entries while keeping the configured order.
func normalizeClaudeBetaNames(names []string) []string {
@@ -432,6 +460,28 @@ func formatGeminiKeyDedupID(entry GeminiKey) string {
b.WriteString(entry.Prefix)
b.WriteByte(0)
b.WriteString(FormatSortedHeaders(entry.Headers))
+ b.WriteByte(0)
+ b.WriteString(FormatConnectionPoolDedupID(entry.ConnectionPool))
+ return b.String()
+}
+
+// FormatConnectionPoolDedupID serializes a per-credential connection-pool override
+// deterministically for credential deduplication and auth-index mapping.
+func FormatConnectionPoolDedupID(pool *ProxyConnectionPoolConfig) string {
+ if pool == nil {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("pool:")
+ if pool.Enabled != nil {
+ b.WriteString(strconv.FormatBool(*pool.Enabled))
+ }
+ b.WriteByte(0)
+ b.WriteString(strings.TrimSpace(pool.IdleConnTimeout))
+ b.WriteByte(0)
+ if pool.MaxIdleConnsPerHost != nil {
+ b.WriteString(strconv.Itoa(*pool.MaxIdleConnsPerHost))
+ }
return b.String()
}
diff --git a/internal/config/config_types.go b/internal/config/config_types.go
index 8a7f10309cc..7c77b7cdabf 100644
--- a/internal/config/config_types.go
+++ b/internal/config/config_types.go
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"math"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -122,9 +123,12 @@ type ClaudeConfig struct {
// catalog. Default false keeps the translation that predates tool_search support.
ToolSearch bool `yaml:"tool-search" json:"tool-search"`
// SessionDatePinDays retains the cloaked currentDate pin per session for
- // this many days, independently of continuity expiry and credential
- // switches. A nil value (unset) defaults to 7 days; an explicit 0 keeps
- // the continuity-scoped pin (1h lifetime).
+ // this many days in in-memory mode (server.kv-store.disabled: true without
+ // Home), independently of continuity expiry and credential switches. A nil
+ // value (unset) defaults to 7 days; an explicit 0 keeps the
+ // continuity-scoped pin (1h lifetime). When a KV store (local bbolt or Home
+ // KV) is active, session date pins are persisted permanently and
+ // session-date-pin-days is ignored.
SessionDatePinDays *int `yaml:"session-date-pin-days" json:"session-date-pin-days"`
}
@@ -156,6 +160,7 @@ func (c ClaudeConfig) SessionDatePinRetention() time.Duration {
// Claude Code, package, and runtime versions exactly match this baseline; unmeasured
// versions use the configured values. Timeout remains a fallback. Stabilized profiles
// also pin OS and Arch and never learn newer software versions automatically.
+// NativeEntrypoints extends the built-in native Claude Code entrypoint allowlist.
// AnthropicBetaStrip and AnthropicBetaAppend are exact-name operator overrides applied
// to the final Anthropic-Beta set; InjectDiagnostics and ClientRequestID tune the
// first-party Claude Code CLI request shape.
@@ -168,6 +173,7 @@ type ClaudeHeaderDefaults struct {
Timeout string `yaml:"timeout" json:"timeout"`
Timezone string `yaml:"timezone" json:"timezone"`
StabilizeDeviceProfile *bool `yaml:"stabilize-device-profile,omitempty" json:"stabilize-device-profile,omitempty"`
+ NativeEntrypoints []string `yaml:"native-entrypoints,omitempty" json:"native-entrypoints,omitempty"`
AnthropicBetaStrip []string `yaml:"anthropic-beta-strip,omitempty" json:"anthropic-beta-strip,omitempty"`
AnthropicBetaAppend []string `yaml:"anthropic-beta-append,omitempty" json:"anthropic-beta-append,omitempty"`
InjectDiagnostics *bool `yaml:"inject-diagnostics,omitempty" json:"inject-diagnostics,omitempty"`
@@ -202,6 +208,33 @@ type DevinConfig struct {
SensitiveWords []string `yaml:"sensitive-words,omitempty" json:"sensitive-words,omitempty"`
}
+// ProxyConnectionPoolConfig configures upstream connection reuse for one api-key
+// credential (group level or per key).
+//
+// Pools are per credential: two API keys never share a connection, and OAuth
+// credentials are not poolable at all (Antigravity keeps its own switch). The
+// transport cache is bounded, so a deployment with very many keys degrades to the
+// historical one-transport-per-request behavior instead of growing without limit.
+type ProxyConnectionPoolConfig struct {
+ // Enabled controls whether connection reuse is active for this credential.
+ // Writing the connection-pool block is the opt-in, so an omitted or true
+ // value enables pooling; an explicit false opts a key out of its group pool.
+ Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
+
+ // IdleConnTimeout is how long an idle pooled connection survives before it
+ // is closed. Defaults to "300s". Measured against ollama.com, an idle
+ // connection was still reused after 600s, so 300s sits inside the upstream
+ // window while covering the multi-minute gaps between interactive requests.
+ // Zero or a negative value disables keep-alives, so every request dials
+ // afresh.
+ IdleConnTimeout string `yaml:"idle-conn-timeout,omitempty" json:"idle-conn-timeout,omitempty"`
+
+ // MaxIdleConnsPerHost caps idle connections kept per upstream host.
+ // Defaults to 2, matching net/http's DefaultMaxIdleConnsPerHost. Zero or a
+ // negative value disables keep-alives as well.
+ MaxIdleConnsPerHost *int `yaml:"max-idle-conns-per-host,omitempty" json:"max-idle-conns-per-host,omitempty"`
+}
+
// AntigravityConfig configures provider-wide Antigravity request behavior.
type AntigravityConfig struct {
// SensitiveWords is a list of words to obfuscate with zero-width characters in system instructions.
@@ -396,6 +429,47 @@ type DiscoveryInterfacesConfig struct {
Exclude []string `yaml:"exclude" json:"exclude"`
}
+// KVStoreConfig holds configuration for the local persistent bbolt KV store.
+type KVStoreConfig struct {
+ // Disabled turns off the local bbolt KV store when true (fallback to in-memory).
+ // Defaults to false (bbolt enabled by default in non-Home mode).
+ Disabled bool `yaml:"disabled" json:"disabled"`
+
+ // Path is the file system path to the bbolt database file.
+ // When empty, it defaults to "/kv.db".
+ Path string `yaml:"path,omitempty" json:"path,omitempty"`
+}
+
+// EffectivePath returns the trimmed Path or DefaultKVStorePath ("kv.db") when empty.
+func (c KVStoreConfig) EffectivePath() string {
+ if trimmed := strings.TrimSpace(c.Path); trimmed != "" {
+ return trimmed
+ }
+ return DefaultKVStorePath
+}
+
+// EffectivePathForAuthDir returns the trimmed Path when explicitly configured,
+// or joins the given authDir (falling back to DefaultAuthDir when authDir is empty)
+// with DefaultKVStorePath ("/kv.db").
+func (c KVStoreConfig) EffectivePathForAuthDir(authDir string) string {
+ if trimmed := strings.TrimSpace(c.Path); trimmed != "" {
+ return trimmed
+ }
+ baseDir := strings.TrimSpace(authDir)
+ if baseDir == "" {
+ baseDir = DefaultAuthDir
+ }
+ return filepath.Join(baseDir, DefaultKVStorePath)
+}
+
+// EffectiveKVStorePath returns the configured KVStore.Path or "/kv.db" when empty.
+func (cfg *Config) EffectiveKVStorePath() string {
+ if cfg == nil {
+ return filepath.Join(DefaultAuthDir, DefaultKVStorePath)
+ }
+ return cfg.KVStore.EffectivePathForAuthDir(cfg.AuthDir)
+}
+
// DiscoveryConfig controls local network mDNS / DNS-SD service advertising.
type DiscoveryConfig struct {
// Enabled toggles mDNS service advertising on the local network (default: false).
@@ -656,6 +730,11 @@ type ClaudeKey struct {
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
+ // ConnectionPool configures upstream connection reuse for this credential.
+ // Omitted disables pooling (preserving the fresh-transport-per-request default);
+ // pools stay per credential, so opting in never shares connections with another key.
+ ConnectionPool *ProxyConnectionPoolConfig `yaml:"connection-pool,omitempty" json:"connection-pool,omitempty"`
+
// Models defines upstream model names and aliases for request routing.
Models []ClaudeModel `yaml:"models" json:"models"`
@@ -782,6 +861,11 @@ type CodexKey struct {
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
+ // ConnectionPool configures upstream connection reuse for this credential.
+ // Omitted disables pooling (preserving the fresh-transport-per-request default);
+ // pools stay per credential, so opting in never shares connections with another key.
+ ConnectionPool *ProxyConnectionPoolConfig `yaml:"connection-pool,omitempty" json:"connection-pool,omitempty"`
+
// Models defines upstream model names and aliases for request routing.
Models []CodexModel `yaml:"models" json:"models"`
@@ -893,6 +977,11 @@ type GeminiKey struct {
// ProxyURL optionally overrides the global proxy for this API key.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
+ // ConnectionPool configures upstream connection reuse for this credential.
+ // Omitted disables pooling (preserving the fresh-transport-per-request default);
+ // pools stay per credential, so opting in never shares connections with another key.
+ ConnectionPool *ProxyConnectionPoolConfig `yaml:"connection-pool,omitempty" json:"connection-pool,omitempty"`
+
// Models defines upstream model names and aliases for request routing.
Models []GeminiModel `yaml:"models,omitempty" json:"models,omitempty"`
@@ -980,6 +1069,10 @@ type OpenAICompatibility struct {
// APIKeyEntries defines API keys with optional per-key proxy configuration.
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
+ // ConnectionPool is the provider-group default for upstream connection reuse.
+ // A per-key connection-pool entry overrides it field by field; omitted disables pooling.
+ ConnectionPool *ProxyConnectionPoolConfig `yaml:"connection-pool,omitempty" json:"connection-pool,omitempty"`
+
// Models defines the model configurations including aliases for routing.
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
@@ -1012,6 +1105,11 @@ type OpenAICompatibilityAPIKey struct {
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
+
+ // ConnectionPool configures upstream connection reuse for this key, overriding
+ // the provider-group default field by field. Pools stay per credential, so
+ // opting in never shares connections with another key.
+ ConnectionPool *ProxyConnectionPoolConfig `yaml:"connection-pool,omitempty" json:"connection-pool,omitempty"`
}
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
diff --git a/internal/config/config_v8.go b/internal/config/config_v8.go
index 4a50f516271..b484a47dd86 100644
--- a/internal/config/config_v8.go
+++ b/internal/config/config_v8.go
@@ -75,7 +75,7 @@ func buildV8Paths() []configPath {
prefixes := []configPath{
{"host", "server.host"}, {"port", "server.port"}, {"max-request-body-bytes", "server.max-request-body-bytes"}, {"trusted-proxies", "server.trusted-proxies"},
{"github-token", "server.github-token"},
- {"tls", "server.tls"}, {"commercial-mode", "server.commercial-mode"}, {"discovery", "server.discovery"},
+ {"tls", "server.tls"}, {"commercial-mode", "server.commercial-mode"}, {"discovery", "server.discovery"}, {"kv-store", "server.kv-store"},
{"remote-management", "management"}, {"api-keys", "access.api-keys"},
{"credential-concurrency", "credentials.concurrency"}, {"credential-in-flight", "credentials.in-flight"},
{"force-model-prefix", "routing.force-model-prefix"},
@@ -385,6 +385,9 @@ var sharedKeyFields = map[string]bool{
"priority": true, "prefix": true, "proxy-url": true, "headers": true,
"models": true, "excluded-models": true, "disable-cooling": true,
"request-retry": true, "request-scoped-errors": true,
+ // connection-pool follows proxy-url: a group-level default that each key may
+ // override, so an operator can pool one provider without touching the others.
+ "connection-pool": true,
}
func expandV8Groups(groups *yaml.Node, provider string) (*yaml.Node, error) {
@@ -438,12 +441,22 @@ func expandV8Groups(groups *yaml.Node, provider string) (*yaml.Node, error) {
}
}
for i := 0; i < len(key.Content); i += 2 {
- if key.Content[i].Value == "auth_index" || key.Content[i].Value == "auth-index" {
+ field := key.Content[i].Value
+ if field == "auth_index" || field == "auth-index" {
continue
}
- if key.Content[i+1].Tag != "!!null" {
- setYAMLPath(item, key.Content[i].Value, key.Content[i+1])
+ if key.Content[i+1].Tag == "!!null" {
+ continue
+ }
+ if field == "connection-pool" {
+ // A per-key block merges onto the group block so a key that overrides
+ // one field keeps inheriting the rest, matching the runtime resolution
+ // where an omitted field inherits the outer value. Replacing the whole
+ // block would silently drop the group's enabled: true.
+ setYAMLPath(item, field, mergeConnectionPoolNode(yamlPath(group, field), key.Content[i+1]))
+ continue
}
+ setYAMLPath(item, field, key.Content[i+1])
}
out.Content = append(out.Content, item)
}
@@ -451,6 +464,22 @@ func expandV8Groups(groups *yaml.Node, provider string) (*yaml.Node, error) {
return out, nil
}
+// mergeConnectionPoolNode merges a per-key connection-pool block onto the group block.
+// Missing sub-keys fall back to the group value; the result is always a fresh node.
+func mergeConnectionPoolNode(base, override *yaml.Node) *yaml.Node {
+ merged := deepCopyNode(base)
+ if merged == nil || merged.Kind != yaml.MappingNode || override == nil || override.Kind != yaml.MappingNode {
+ return deepCopyNode(override)
+ }
+ for i := 0; i < len(override.Content); i += 2 {
+ if override.Content[i+1].Tag == "!!null" {
+ continue
+ }
+ setYAMLPath(merged, override.Content[i].Value, override.Content[i+1])
+ }
+ return merged
+}
+
// groupLegacyKeys deliberately keeps one group per legacy entry: equal endpoints
// do not imply equal routing, headers, models, or credential policies.
func groupLegacyKeys(keys *yaml.Node, provider string) *yaml.Node {
diff --git a/internal/config/config_yaml.go b/internal/config/config_yaml.go
index 95cf138af90..2ee6c1db287 100644
--- a/internal/config/config_yaml.go
+++ b/internal/config/config_yaml.go
@@ -369,6 +369,19 @@ func appendPath(path []string, key string) []string {
// isKnownDefaultValue returns true if the given node at the specified path
// represents a known default value that should not be written to the config file.
// This prevents non-zero defaults from polluting the config.
+//
+// Credential connection-pool blocks (enabled, idle-conn-timeout,
+// max-idle-conns-per-host) are deliberately absent from the pointer-backed
+// zero-value whitelist below. The pruning here only applies to keys that are new
+// to the destination document: a block already present on disk is merged into its
+// existing node and survives. So an empty block (connection-pool: {} is the opt-in
+// for the default pool), an explicit enabled: false, and an explicit
+// max-idle-conns-per-host: 0 all persist when the operator wrote them by hand or
+// through the v8 management API, which writes the YAML tree directly. The
+// remaining gap is a block that only exists in an in-memory Config, for example one
+// an SDK embedder adds through sdk/config.SaveConfigPreserveComments to a file that
+// never carried it: pruning drops those zero-valued sub-keys. No management
+// endpoint mutates ProxyConnectionPool, so that path is reachable only from code.
func isKnownDefaultValue(path []string, node *yaml.Node) bool {
if isPluginConfigsSubtreePath(path) {
return false
@@ -844,6 +857,12 @@ func pruneMissingMapKeys(dstMap, srcMap *yaml.Node) {
// This is strictly scoped to credential-nested mappings such as "cloak" and "headers" under
// known credential sequence paths to prevent stale deleted keys from persisting while leaving
// all other mappings and root sections unaffected.
+//
+// Credential connection-pool blocks stay out of this list on purpose. Pruning only runs while
+// merging a document from an in-memory Config, and a removed pool sub-key can mean either
+// "delete it" or "inherit the group value" depending on the caller; guessing wrong would silently
+// change pooling for a credential. Hand-edited files and v8 management saves therefore keep a
+// sub-key until the whole block is removed.
func shouldPruneNestedMappingKeys(path []string) bool {
if len(path) < 2 {
return false
diff --git a/internal/config/connection_pool_entry_test.go b/internal/config/connection_pool_entry_test.go
new file mode 100644
index 00000000000..5820a407bc1
--- /dev/null
+++ b/internal/config/connection_pool_entry_test.go
@@ -0,0 +1,233 @@
+package config_test
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+// TestPerEntryConnectionPoolAccepted pins the per-credential opt-in shape: the
+// global pool stays off while a single api-keys entry turns pooling on for itself.
+func TestPerEntryConnectionPoolAccepted(t *testing.T) {
+ raw := []byte(`config-version: 8
+api-keys:
+ codex:
+ - name: ollama
+ base-url: "https://ollama.com/v1"
+ proxy-url: "direct"
+ connection-pool:
+ enabled: true
+ idle-conn-timeout: "300s"
+ max-idle-conns-per-host: 2
+ keys:
+ - api-key: "ollama-key"
+`)
+ if err := config.ValidateV8Config(raw); err != nil {
+ t.Fatalf("ValidateV8Config: %v", err)
+ }
+ cfg, err := config.ParseConfigBytes(raw)
+ if err != nil {
+ t.Fatalf("ParseConfigBytes: %v", err)
+ }
+ if len(cfg.CodexKey) != 1 || cfg.CodexKey[0].ConnectionPool == nil {
+ t.Fatalf("per-entry connection-pool not parsed: %+v", cfg.CodexKey)
+ }
+ pool := cfg.CodexKey[0].ConnectionPool
+ if pool.Enabled == nil || !*pool.Enabled {
+ t.Fatalf("enabled not parsed: %+v", pool)
+ }
+ if pool.IdleConnTimeout != "300s" {
+ t.Fatalf("idle-conn-timeout = %q, want 300s", pool.IdleConnTimeout)
+ }
+ if pool.MaxIdleConnsPerHost == nil || *pool.MaxIdleConnsPerHost != 2 {
+ t.Fatalf("max-idle-conns-per-host not parsed: %+v", pool)
+ }
+ if errLegacy := config.ValidateV8Config([]byte("config-version: 8\nrequests:\n proxy-connection-pool:\n enabled: true\n")); errLegacy == nil {
+ t.Fatal("a global pooling switch must not exist")
+ }
+}
+
+// TestPerEntryConnectionPoolRejectsUnknownKey keeps KnownFields strict inside the
+// new nested block.
+func TestPerEntryConnectionPoolRejectsUnknownKey(t *testing.T) {
+ raw := []byte(`config-version: 8
+api-keys:
+ codex:
+ - name: k
+ keys:
+ - api-key: "k"
+ connection-pool:
+ enabled: true
+ idle-timeout: "30s"
+`)
+ if err := config.ValidateV8Config(raw); err == nil {
+ t.Fatal("unknown nested key must be rejected")
+ }
+}
+
+// TestEmptyConnectionPoolBlockStaysOptIn pins that a block with no fields is still an
+// opt-in (it asks for the defaults), including when it stands alone. A nil result here
+// silently disabled pooling for `connection-pool: {}`.
+func TestEmptyConnectionPoolBlockStaysOptIn(t *testing.T) {
+ raw := []byte(`config-version: 8
+api-keys:
+ codex:
+ - name: empty
+ base-url: "https://ollama.com/v1"
+ connection-pool: {}
+ keys:
+ - api-key: "empty-group"
+ - api-key: "empty-key"
+ connection-pool: {}
+`)
+ cfg, err := config.ParseConfigBytes(raw)
+ if err != nil {
+ t.Fatalf("ParseConfigBytes: %v", err)
+ }
+ if len(cfg.CodexKey) != 2 {
+ t.Fatalf("codex keys = %d, want 2", len(cfg.CodexKey))
+ }
+ for _, key := range cfg.CodexKey {
+ pool := key.ConnectionPool
+ if pool == nil {
+ t.Fatalf("key %q lost its empty connection-pool block", key.APIKey)
+ }
+ if pool.Enabled != nil || pool.IdleConnTimeout != "" || pool.MaxIdleConnsPerHost != nil {
+ t.Fatalf("key %q empty block = %+v, want unset fields", key.APIKey, pool)
+ }
+ }
+}
+
+// TestPerKeyConnectionPoolOverridesGroup pins per-key control inside one provider
+// group: keys may switch pooling on or off independently, and a partial override keeps
+// inheriting the group values it does not mention.
+func TestPerKeyConnectionPoolOverridesGroup(t *testing.T) {
+ raw := []byte(`config-version: 8
+api-keys:
+ codex:
+ - name: ollama
+ base-url: "https://ollama.com/v1"
+ connection-pool:
+ enabled: true
+ idle-conn-timeout: "300s"
+ max-idle-conns-per-host: 2
+ keys:
+ - api-key: "inherits-group"
+ - api-key: "opts-out"
+ connection-pool:
+ enabled: false
+ - api-key: "partial"
+ connection-pool:
+ idle-conn-timeout: "60s"
+ - api-key: "null-subkey"
+ connection-pool:
+ idle-conn-timeout: null
+ max-idle-conns-per-host: 4
+ - api-key: "empty-block"
+ connection-pool: {}
+`)
+ cfg, err := config.ParseConfigBytes(raw)
+ if err != nil {
+ t.Fatalf("ParseConfigBytes: %v", err)
+ }
+ byKey := map[string]*config.ProxyConnectionPoolConfig{}
+ for i := range cfg.CodexKey {
+ byKey[cfg.CodexKey[i].APIKey] = cfg.CodexKey[i].ConnectionPool
+ }
+
+ inherits := byKey["inherits-group"]
+ if inherits == nil || inherits.Enabled == nil || !*inherits.Enabled || inherits.IdleConnTimeout != "300s" {
+ t.Fatalf("key without an override must inherit the group block, got %+v", inherits)
+ }
+
+ optedOut := byKey["opts-out"]
+ if optedOut == nil || optedOut.Enabled == nil || *optedOut.Enabled {
+ t.Fatalf("per-key opt-out not honoured, got %+v", optedOut)
+ }
+
+ partial := byKey["partial"]
+ if partial == nil || partial.Enabled == nil || !*partial.Enabled {
+ t.Fatalf("partial override must keep the group's enabled value, got %+v", partial)
+ }
+ if partial.IdleConnTimeout != "60s" {
+ t.Fatalf("partial override idle = %q, want 60s", partial.IdleConnTimeout)
+ }
+ if partial.MaxIdleConnsPerHost == nil || *partial.MaxIdleConnsPerHost != 2 {
+ t.Fatalf("partial override must inherit max-idle-conns-per-host, got %+v", partial)
+ }
+
+ // An empty per-key block only overrides nothing, so it inherits the whole group block.
+ empty := byKey["empty-block"]
+ if empty == nil || empty.Enabled == nil || !*empty.Enabled || empty.IdleConnTimeout != "300s" {
+ t.Fatalf("empty per-key block must inherit the group block, got %+v", empty)
+ }
+
+ nullSubkey := byKey["null-subkey"]
+ if nullSubkey == nil || nullSubkey.Enabled == nil || !*nullSubkey.Enabled {
+ t.Fatalf("null-subkey override must inherit group enabled, got %+v", nullSubkey)
+ }
+ if nullSubkey.IdleConnTimeout != "300s" {
+ t.Fatalf("null sub-key must inherit group idle-conn-timeout 300s, got %q", nullSubkey.IdleConnTimeout)
+ }
+ if nullSubkey.MaxIdleConnsPerHost == nil || *nullSubkey.MaxIdleConnsPerHost != 4 {
+ t.Fatalf("null-subkey override max-idle-conns-per-host = %v, want 4", nullSubkey.MaxIdleConnsPerHost)
+ }
+}
+
+func TestCredentialDeduplicationPreservesDistinctConnectionPools(t *testing.T) {
+ raw := []byte(`gemini-api-key:
+ - api-key: "shared-gemini-key"
+ base-url: "https://example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 2
+ - api-key: "shared-gemini-key"
+ base-url: "https://example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 5
+ - api-key: "shared-gemini-key"
+ base-url: "https://example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 5
+vertex-api-key:
+ - api-key: "shared-vertex-key"
+ base-url: "https://vertex.example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 2
+ - api-key: "shared-vertex-key"
+ base-url: "https://vertex.example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 6
+ - api-key: "shared-vertex-key"
+ base-url: "https://vertex.example.invalid"
+ connection-pool:
+ enabled: true
+ max-idle-conns-per-host: 6
+`)
+ cfg, err := config.ParseConfigBytes(raw)
+ if err != nil {
+ t.Fatalf("ParseConfigBytes: %v", err)
+ }
+ if len(cfg.GeminiKey) != 2 {
+ t.Fatalf("len(GeminiKey) = %d, want 2", len(cfg.GeminiKey))
+ }
+ if cfg.GeminiKey[0].ConnectionPool == nil || cfg.GeminiKey[0].ConnectionPool.MaxIdleConnsPerHost == nil || *cfg.GeminiKey[0].ConnectionPool.MaxIdleConnsPerHost != 2 {
+ t.Fatalf("GeminiKey[0].ConnectionPool = %+v, want max-idle-conns-per-host=2", cfg.GeminiKey[0].ConnectionPool)
+ }
+ if cfg.GeminiKey[1].ConnectionPool == nil || cfg.GeminiKey[1].ConnectionPool.MaxIdleConnsPerHost == nil || *cfg.GeminiKey[1].ConnectionPool.MaxIdleConnsPerHost != 5 {
+ t.Fatalf("GeminiKey[1].ConnectionPool = %+v, want max-idle-conns-per-host=5", cfg.GeminiKey[1].ConnectionPool)
+ }
+ if len(cfg.VertexCompatAPIKey) != 2 {
+ t.Fatalf("len(VertexCompatAPIKey) = %d, want 2", len(cfg.VertexCompatAPIKey))
+ }
+ if cfg.VertexCompatAPIKey[0].ConnectionPool == nil || cfg.VertexCompatAPIKey[0].ConnectionPool.MaxIdleConnsPerHost == nil || *cfg.VertexCompatAPIKey[0].ConnectionPool.MaxIdleConnsPerHost != 2 {
+ t.Fatalf("VertexCompatAPIKey[0].ConnectionPool = %+v, want max-idle-conns-per-host=2", cfg.VertexCompatAPIKey[0].ConnectionPool)
+ }
+ if cfg.VertexCompatAPIKey[1].ConnectionPool == nil || cfg.VertexCompatAPIKey[1].ConnectionPool.MaxIdleConnsPerHost == nil || *cfg.VertexCompatAPIKey[1].ConnectionPool.MaxIdleConnsPerHost != 6 {
+ t.Fatalf("VertexCompatAPIKey[1].ConnectionPool = %+v, want max-idle-conns-per-host=6", cfg.VertexCompatAPIKey[1].ConnectionPool)
+ }
+}
diff --git a/internal/config/kv_store_config_test.go b/internal/config/kv_store_config_test.go
new file mode 100644
index 00000000000..0ae19dbccd1
--- /dev/null
+++ b/internal/config/kv_store_config_test.go
@@ -0,0 +1,129 @@
+package config
+
+import (
+ "path/filepath"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestParseConfigBytes_ServerKVStoreV8AndLegacy(t *testing.T) {
+ v8YAML := []byte("config-version: 8\nserver:\n kv-store:\n disabled: true\n path: ./data/kv.db\n")
+ if errValidate := ValidateV8Config(v8YAML); errValidate != nil {
+ t.Fatalf("ValidateV8Config() error = %v", errValidate)
+ }
+ cfg, errParse := ParseConfigBytes(v8YAML)
+ if errParse != nil {
+ t.Fatalf("ParseConfigBytes(v8) error = %v", errParse)
+ }
+ if !cfg.KVStore.Disabled || cfg.KVStore.Path != "./data/kv.db" {
+ t.Fatalf("cfg.KVStore = %+v, want {Disabled:true Path:./data/kv.db}", cfg.KVStore)
+ }
+
+ cloned := cfg.CloneForRuntime()
+ if !cloned.KVStore.Disabled || cloned.KVStore.Path != "./data/kv.db" {
+ t.Fatalf("cloned.KVStore = %+v, want {Disabled:true Path:./data/kv.db}", cloned.KVStore)
+ }
+
+ legacyYAML := []byte("kv-store:\n disabled: true\n path: /var/lib/cpa/kv.db\n")
+ migrated, changed, errNorm := NormalizeConfigLayout(legacyYAML, true)
+ if errNorm != nil || !changed {
+ t.Fatalf("NormalizeConfigLayout() changed=%v err=%v", changed, errNorm)
+ }
+ var tree map[string]any
+ if errUnmarshal := yaml.Unmarshal(migrated, &tree); errUnmarshal != nil {
+ t.Fatalf("yaml.Unmarshal(migrated) error = %v", errUnmarshal)
+ }
+ serverMap, okServer := tree["server"].(map[string]any)
+ if !okServer {
+ t.Fatalf("migrated server section missing: %#v", tree)
+ }
+ kvMap, okKV := serverMap["kv-store"].(map[string]any)
+ if !okKV || kvMap["disabled"] != true || kvMap["path"] != "/var/lib/cpa/kv.db" {
+ t.Fatalf("migrated server.kv-store = %#v", serverMap["kv-store"])
+ }
+}
+
+func TestParseConfigBytes_ServerKVStoreDefaultsAndEffectivePath(t *testing.T) {
+ wantDefaultAuthPath := filepath.Join(DefaultAuthDir, DefaultKVStorePath)
+
+ // Omitted kv-store section defaults to Disabled=false and Path="" (resolving to /kv.db).
+ omittedCfg, errOmitted := ParseConfigBytes([]byte("config-version: 8\nserver:\n port: 8317\n"))
+ if errOmitted != nil {
+ t.Fatalf("ParseConfigBytes(omitted) error = %v", errOmitted)
+ }
+ if omittedCfg.KVStore.Disabled || omittedCfg.KVStore.Path != "" {
+ t.Fatalf("omittedCfg.KVStore = %+v, want {Disabled:false Path:\"\"}", omittedCfg.KVStore)
+ }
+ if got := omittedCfg.KVStore.EffectivePath(); got != DefaultKVStorePath {
+ t.Fatalf("omittedCfg.KVStore.EffectivePath() = %q, want %q", got, DefaultKVStorePath)
+ }
+ if got := omittedCfg.EffectiveKVStorePath(); got != wantDefaultAuthPath {
+ t.Fatalf("omittedCfg.EffectiveKVStorePath() = %q, want %q", got, wantDefaultAuthPath)
+ }
+
+ // Custom auth-dir with omitted kv-store.path resolves to /kv.db.
+ customAuthCfg, errCustomAuth := ParseConfigBytes([]byte("config-version: 8\noauth:\n auth-dir: /tmp/custom-auth\n"))
+ if errCustomAuth != nil {
+ t.Fatalf("ParseConfigBytes(custom auth-dir) error = %v", errCustomAuth)
+ }
+ wantCustomAuthPath := filepath.Join("/tmp/custom-auth", DefaultKVStorePath)
+ if got := customAuthCfg.EffectiveKVStorePath(); got != wantCustomAuthPath {
+ t.Fatalf("customAuthCfg.EffectiveKVStorePath() = %q, want %q", got, wantCustomAuthPath)
+ }
+
+ // Explicit kv-store.path overrides auth-dir.
+ explicitPathCfg, errExplicitPath := ParseConfigBytes([]byte("config-version: 8\noauth:\n auth-dir: /tmp/custom-auth\nserver:\n kv-store:\n path: /var/data/custom.db\n"))
+ if errExplicitPath != nil {
+ t.Fatalf("ParseConfigBytes(explicit path) error = %v", errExplicitPath)
+ }
+ if explicitPathCfg.KVStore.Disabled || explicitPathCfg.KVStore.Path != "/var/data/custom.db" {
+ t.Fatalf("explicitPathCfg.KVStore = %+v, want {Disabled:false Path:/var/data/custom.db}", explicitPathCfg.KVStore)
+ }
+ if got := explicitPathCfg.EffectiveKVStorePath(); got != "/var/data/custom.db" {
+ t.Fatalf("explicitPathCfg.EffectiveKVStorePath() = %q, want /var/data/custom.db", got)
+ }
+
+ // Whitespace-only path is trimmed to "" (not overwritten with "kv.db") in ParseConfigBytes and LoadConfigOptional.
+ blankYAML := "config-version: 8\noauth:\n auth-dir: /tmp/blank-auth\nserver:\n kv-store:\n disabled: false\n path: \" \"\n"
+ blankCfg, errBlank := ParseConfigBytes([]byte(blankYAML))
+ if errBlank != nil {
+ t.Fatalf("ParseConfigBytes(blank path) error = %v", errBlank)
+ }
+ if blankCfg.KVStore.Disabled || blankCfg.KVStore.Path != "" {
+ t.Fatalf("blankCfg.KVStore = %+v, want {Disabled:false Path:\"\"}", blankCfg.KVStore)
+ }
+ wantBlankAuthPath := filepath.Join("/tmp/blank-auth", DefaultKVStorePath)
+ if got := blankCfg.EffectiveKVStorePath(); got != wantBlankAuthPath {
+ t.Fatalf("blankCfg.EffectiveKVStorePath() = %q, want %q", got, wantBlankAuthPath)
+ }
+
+ configFile := writeUploadTestConfig(t, blankYAML)
+ loadedCfg, errLoad := LoadConfigOptional(configFile, false)
+ if errLoad != nil {
+ t.Fatalf("LoadConfigOptional(blank path) error = %v", errLoad)
+ }
+ if loadedCfg.KVStore.Disabled || loadedCfg.KVStore.Path != "" {
+ t.Fatalf("loadedCfg.KVStore = %+v, want {Disabled:false Path:\"\"}", loadedCfg.KVStore)
+ }
+ if got := loadedCfg.EffectiveKVStorePath(); got != wantBlankAuthPath {
+ t.Fatalf("loadedCfg.EffectiveKVStorePath() = %q, want %q", got, wantBlankAuthPath)
+ }
+
+ // Nil *Config EffectiveKVStorePath fallback.
+ var nilCfg *Config
+ if got := nilCfg.EffectiveKVStorePath(); got != wantDefaultAuthPath {
+ t.Fatalf("nilCfg.EffectiveKVStorePath() = %q, want %q", got, wantDefaultAuthPath)
+ }
+
+ // EffectivePath and EffectivePathForAuthDir helpers.
+ if got := (KVStoreConfig{Path: " "}).EffectivePath(); got != DefaultKVStorePath {
+ t.Fatalf("EffectivePath(blank) = %q, want %q", got, DefaultKVStorePath)
+ }
+ if got := (KVStoreConfig{Path: " ./custom/kv.db "}).EffectivePath(); got != "./custom/kv.db" {
+ t.Fatalf("EffectivePath(custom) = %q, want %q", got, "./custom/kv.db")
+ }
+ if got := (KVStoreConfig{Path: " ./custom/kv.db "}).EffectivePathForAuthDir("/ignored"); got != "./custom/kv.db" {
+ t.Fatalf("EffectivePathForAuthDir(custom) = %q, want %q", got, "./custom/kv.db")
+ }
+}
diff --git a/internal/config/parse.go b/internal/config/parse.go
index c3442be1dd5..bf12a836793 100644
--- a/internal/config/parse.go
+++ b/internal/config/parse.go
@@ -84,6 +84,8 @@ func ParseConfigBytes(data []byte) (*Config, error) {
cfg.Pprof.Addr = DefaultPprofAddr
}
+ cfg.KVStore.Path = strings.TrimSpace(cfg.KVStore.Path)
+
if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0
}
diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go
index 2ba55cd1110..45919170e8c 100644
--- a/internal/config/sdk_config.go
+++ b/internal/config/sdk_config.go
@@ -127,7 +127,15 @@ func (c *SDKConfig) TLSHandshakeTimeout() time.Duration {
// ClientConfig configures client-facing compatibility behavior.
type ClientConfig struct {
- Codex CodexClientConfig `yaml:"codex" json:"codex"`
+ Codex CodexClientConfig `yaml:"codex" json:"codex"`
+ Claude ClaudeClientConfig `yaml:"claude" json:"claude"`
+}
+
+// ClaudeClientConfig controls client-facing Claude protocol compatibility.
+type ClaudeClientConfig struct {
+ // ShowCodexCommentary mirrors public Codex progress into visible Claude text.
+ // Default false preserves the existing hidden-commentary behavior.
+ ShowCodexCommentary bool `yaml:"show-codex-commentary" json:"show-codex-commentary"`
}
// CodexClientConfig configures Codex client compatibility and the model catalog.
diff --git a/internal/config/vertex_compat.go b/internal/config/vertex_compat.go
index 4cd900bbe5d..8cbaa86e65c 100644
--- a/internal/config/vertex_compat.go
+++ b/internal/config/vertex_compat.go
@@ -39,6 +39,11 @@ type VertexCompatKey struct {
// ProxyURL optionally overrides the global proxy for this API key.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
+ // ConnectionPool configures upstream connection reuse for this credential.
+ // Omitted disables pooling (preserving the fresh-transport-per-request default);
+ // pools stay per credential, so opting in never shares connections with another key.
+ ConnectionPool *ProxyConnectionPoolConfig `yaml:"connection-pool,omitempty" json:"connection-pool,omitempty"`
+
// Headers optionally adds extra HTTP headers for requests sent with this key.
// Commonly used for cookies, user-agent, and other authentication headers.
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
@@ -136,8 +141,8 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
}
entry.Models = sanitizedModels
- // Use API key + base URL as uniqueness key
- uniqueKey := entry.APIKey + "|" + entry.BaseURL
+ // Use API key + base URL + connection-pool settings as uniqueness key
+ uniqueKey := entry.APIKey + "|" + entry.BaseURL + "\x00" + FormatConnectionPoolDedupID(entry.ConnectionPool)
if _, exists := seen[uniqueKey]; exists {
continue
}
diff --git a/internal/home/certificate.go b/internal/home/certificate.go
index 716ab9d9f7f..a226c8b3eb3 100644
--- a/internal/home/certificate.go
+++ b/internal/home/certificate.go
@@ -9,7 +9,6 @@ import (
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
- "encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
@@ -23,6 +22,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
)
diff --git a/internal/home/client.go b/internal/home/client.go
index 30b7c5e58d1..8934fbe170c 100644
--- a/internal/home/client.go
+++ b/internal/home/client.go
@@ -23,6 +23,7 @@ import (
"github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginstore"
log "github.com/sirupsen/logrus"
)
@@ -97,7 +98,7 @@ var (
ErrPluginSyncUnsupported = errors.New("home plugin sync is unsupported")
ErrDispatchFenced = errors.New("home auth dispatch is fenced")
// ErrCompareAndSwapUnsupported reports that this Home predates the CAS command.
- ErrCompareAndSwapUnsupported = errors.New("home compare-and-swap is unsupported")
+ ErrCompareAndSwapUnsupported = kvstore.ErrCompareAndSwapUnsupported
)
// isHomeCommandUnsupported reports whether Home rejected a command it does not
@@ -155,12 +156,7 @@ type PluginTask struct {
UpdatedAt time.Time `json:"updated_at"`
}
-type KVSetOptions struct {
- EX time.Duration
- PX time.Duration
- NX bool
- XX bool
-}
+type KVSetOptions = kvstore.KVSetOptions
type subscriptionCloser interface {
Close() error
@@ -1042,41 +1038,11 @@ func (c *Client) GetModels(ctx context.Context, headers http.Header, query url.V
}
func buildKVSetArgs(key string, value []byte, opts KVSetOptions) ([]any, error) {
- key = strings.TrimSpace(key)
- if key == "" {
- return nil, fmt.Errorf("home kv: key is empty")
- }
- if opts.EX > 0 && opts.PX > 0 {
- return nil, fmt.Errorf("home kv: EX and PX are mutually exclusive")
- }
- if opts.EX < 0 || opts.PX < 0 {
- return nil, fmt.Errorf("home kv: ttl must not be negative")
- }
- if opts.NX && opts.XX {
- return nil, fmt.Errorf("home kv: NX and XX are mutually exclusive")
- }
-
- args := []any{key, append([]byte(nil), value...)}
- if opts.EX > 0 {
- args = append(args, "EX", durationCeil(opts.EX, time.Second))
- }
- if opts.PX > 0 {
- args = append(args, "PX", durationCeil(opts.PX, time.Millisecond))
- }
- if opts.NX {
- args = append(args, "NX")
- }
- if opts.XX {
- args = append(args, "XX")
- }
- return args, nil
+ return kvstore.BuildSetArgs(key, value, opts)
}
func durationCeil(value time.Duration, unit time.Duration) int64 {
- if value <= 0 || unit <= 0 {
- return 0
- }
- return int64((value + unit - 1) / unit)
+ return kvstore.DurationCeil(value, unit)
}
func (c *Client) KVGet(ctx context.Context, key string) ([]byte, bool, error) {
diff --git a/internal/home/concurrency_release.go b/internal/home/concurrency_release.go
index 2eb1c1695cc..e8250a123cb 100644
--- a/internal/home/concurrency_release.go
+++ b/internal/home/concurrency_release.go
@@ -25,6 +25,7 @@ type releaseState struct {
type releaseFlusher struct {
mu sync.Mutex
groups map[executionregistry.ReleaseGroup]releaseState
+ pending map[executionregistry.ReleaseGroup]int64
flushInterval time.Duration
maxBackoff time.Duration
configProvider func() internalconfig.CredentialConcurrencyConfig
@@ -36,6 +37,7 @@ type releaseFlusher struct {
func newReleaseFlusher(flushInterval, maxBackoff time.Duration, send func(context.Context, ConcurrencyReleaseFrame) error) *releaseFlusher {
return &releaseFlusher{
groups: make(map[executionregistry.ReleaseGroup]releaseState),
+ pending: make(map[executionregistry.ReleaseGroup]int64),
flushInterval: flushInterval,
maxBackoff: maxBackoff,
send: send,
@@ -93,6 +95,9 @@ func (f *releaseFlusher) MarkDirty(group executionregistry.ReleaseGroup, sequenc
state.Latest = sequence
}
f.groups[group] = state
+ if current, ok := f.pending[group]; !ok || sequence > current {
+ f.pending[group] = sequence
+ }
}
f.mu.Unlock()
f.signal()
@@ -110,6 +115,7 @@ func (f *releaseFlusher) Run(ctx context.Context) {
timer := time.NewTimer(0)
defer timer.Stop()
+ timerC := timer.C
delay := f.timings().flushInterval
backingOff := false
for {
@@ -119,16 +125,28 @@ func (f *releaseFlusher) Run(ctx context.Context) {
case <-f.wake:
if !backingOff {
resetReleaseTimer(timer, 0)
+ timerC = timer.C
}
case forceCtx := <-f.force:
resetReleaseTimer(timer, 0)
+ timerC = timer.C
failed := f.flush(forceCtx)
delay, backingOff = f.nextDelay(delay, failed)
- resetReleaseTimer(timer, delay)
- case <-timer.C:
+ if f.idle() && !backingOff {
+ timerC = nil
+ } else {
+ resetReleaseTimer(timer, delay)
+ timerC = timer.C
+ }
+ case <-timerC:
failed := f.flush(ctx)
delay, backingOff = f.nextDelay(delay, failed)
- timer.Reset(delay)
+ if f.idle() && !backingOff {
+ timerC = nil
+ } else {
+ timer.Reset(delay)
+ timerC = timer.C
+ }
}
}
}
@@ -190,12 +208,14 @@ func (f *releaseFlusher) flush(ctx context.Context) bool {
}
f.mu.Lock()
+ if len(f.pending) == 0 {
+ f.mu.Unlock()
+ return false
+ }
send := f.send
- pending := make(map[executionregistry.ReleaseGroup]int64, len(f.groups))
- for group, state := range f.groups {
- if state.Latest > state.Acked {
- pending[group] = state.Latest
- }
+ pending := make(map[executionregistry.ReleaseGroup]int64, len(f.pending))
+ for group, sequence := range f.pending {
+ pending[group] = sequence
}
f.mu.Unlock()
if send == nil {
@@ -209,11 +229,12 @@ func (f *releaseFlusher) flush(ctx context.Context) bool {
Model: group.Model,
ReleaseSeq: sequence,
})
+ f.mu.Lock()
if errSend != nil {
failed = true
+ f.mu.Unlock()
continue
}
- f.mu.Lock()
state := f.groups[group]
if sequence > state.Acked {
state.Acked = sequence
@@ -227,6 +248,9 @@ func (f *releaseFlusher) flush(ctx context.Context) bool {
}
}
f.groups[group] = state
+ if current, ok := f.pending[group]; ok && current <= state.Acked {
+ delete(f.pending, group)
+ }
f.mu.Unlock()
}
return failed
@@ -258,12 +282,7 @@ func (f *releaseFlusher) Flush(ctx context.Context) error {
func (f *releaseFlusher) idle() bool {
f.mu.Lock()
defer f.mu.Unlock()
- for _, state := range f.groups {
- if state.Latest > state.Acked {
- return false
- }
- }
- return true
+ return len(f.pending) == 0
}
func (f *releaseFlusher) signal() {
diff --git a/internal/home/concurrency_release_test.go b/internal/home/concurrency_release_test.go
index 64a7c9b7464..e0062649bf9 100644
--- a/internal/home/concurrency_release_test.go
+++ b/internal/home/concurrency_release_test.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"os"
"path/filepath"
"sync"
@@ -99,6 +100,56 @@ func (s *recordingReleaseSender) WaitForSequence(sequence int64, timeout time.Du
}
}
+func TestReleaseFlusherIdleFlushDoesNotAllocate(t *testing.T) {
+ flusher := newReleaseFlusher(time.Hour, time.Hour, func(context.Context, ConcurrencyReleaseFrame) error {
+ return nil
+ })
+ // Simulate a long-lived pool: every credential/model pair has been acked and
+ // remains in the state map. An idle flush must not walk (or allocate for) it.
+ for i := 0; i < 1000; i++ {
+ group := executionregistry.ReleaseGroup{CredentialID: fmt.Sprintf("cred-%d", i), Model: "gpt"}
+ flusher.groups[group] = releaseState{Latest: 1, Acked: 1}
+ }
+
+ var failed bool
+ allocs := testing.AllocsPerRun(100, func() {
+ failed = flusher.flush(context.Background())
+ })
+ if failed {
+ t.Fatal("idle flush reported a failure")
+ }
+ if allocs != 0 {
+ t.Fatalf("idle flush allocated %.1f objects, want 0", allocs)
+ }
+}
+
+func TestReleaseFlusherClearsPendingAfterAck(t *testing.T) {
+ sender := &recordingReleaseSender{sent: make(chan struct{}, 4)}
+ flusher := newReleaseFlusher(time.Millisecond, 10*time.Millisecond, sender.Send)
+ group := executionregistry.ReleaseGroup{CredentialID: "cred-1", Model: "gpt"}
+ flusher.MarkDirty(group, 1)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go flusher.Run(ctx)
+
+ if !sender.WaitForSequence(1, time.Second) {
+ t.Fatal("release sequence 1 was not acknowledged")
+ }
+
+ deadline := time.NewTimer(time.Second)
+ defer deadline.Stop()
+ ticker := time.NewTicker(time.Millisecond)
+ defer ticker.Stop()
+ for !flusher.idle() {
+ select {
+ case <-ticker.C:
+ case <-deadline.C:
+ t.Fatal("pending release was not cleared after acknowledgement")
+ }
+ }
+}
+
func TestReleaseFlusherRetriesLatestCumulativeSequence(t *testing.T) {
sender := &recordingReleaseSender{failures: 1, sent: make(chan struct{}, 8)}
flusher := newReleaseFlusher(10*time.Millisecond, 40*time.Millisecond, sender.Send)
diff --git a/internal/home/kv_helpers.go b/internal/home/kv_helpers.go
index 7ca21700015..2eb51b21ff0 100644
--- a/internal/home/kv_helpers.go
+++ b/internal/home/kv_helpers.go
@@ -2,22 +2,17 @@ package home
import (
"context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
"fmt"
- "strings"
"time"
- log "github.com/sirupsen/logrus"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
)
-func HashKeyPart(value string) string {
- sum := sha256.Sum256([]byte(value))
- return hex.EncodeToString(sum[:])
+func init() {
+ kvstore.RegisterHomeProvider(currentHomeKVClient)
}
-func CurrentKVClient() (*Client, bool, error) {
+func currentHomeKVClient() (kvstore.KVClient, bool, error) {
client := Current()
if client == nil {
return nil, false, nil
@@ -31,159 +26,58 @@ func CurrentKVClient() (*Client, bool, error) {
return client, true, nil
}
+func HashKeyPart(value string) string {
+ return kvstore.HashKeyPart(value)
+}
+
+func CurrentKVClient() (kvstore.KVClient, bool, error) {
+ return kvstore.CurrentKVClient()
+}
+
func KVGetJSONRequired(ctx context.Context, key string, out any) (bool, bool, error) {
- client, homeMode, errClient := CurrentKVClient()
- if !homeMode || errClient != nil {
- return homeMode, false, errClient
- }
- raw, found, errGet := client.KVGet(ctx, key)
- if errGet != nil || !found {
- return true, false, errGet
- }
- if errUnmarshal := json.Unmarshal(raw, out); errUnmarshal != nil {
- return true, false, errUnmarshal
- }
- return true, true, nil
+ return kvstore.KVGetJSONRequired(ctx, key, out)
}
func KVSetJSONRequired(ctx context.Context, key string, value any, ttl time.Duration) (bool, error) {
- raw, errMarshal := json.Marshal(value)
- if errMarshal != nil {
- return false, errMarshal
- }
- return KVSetBytesRequired(ctx, key, raw, ttl)
+ return kvstore.KVSetJSONRequired(ctx, key, value, ttl)
}
func KVSetBytesRequired(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
- client, homeMode, errClient := CurrentKVClient()
- if !homeMode || errClient != nil {
- return homeMode, errClient
- }
- written, errSet := client.KVSet(ctx, key, value, kvSetOptionsForTTL(ttl))
- if errSet != nil {
- return true, errSet
- }
- if !written {
- return true, fmt.Errorf("home kv store unavailable")
- }
- return true, nil
+ return kvstore.KVSetBytesRequired(ctx, key, value, ttl)
}
func KVSetNXRequired(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, bool, error) {
- client, homeMode, errClient := CurrentKVClient()
- if !homeMode || errClient != nil {
- return homeMode, false, errClient
- }
- written, errSet := client.KVSetNX(ctx, key, value, ttl)
- return true, written, errSet
+ return kvstore.KVSetNXRequired(ctx, key, value, ttl)
}
func KVDelRequired(ctx context.Context, keys ...string) (bool, int64, error) {
- client, homeMode, errClient := CurrentKVClient()
- if !homeMode || errClient != nil {
- return homeMode, 0, errClient
- }
- deleted, errDel := client.KVDel(ctx, keys...)
- return true, deleted, errDel
+ return kvstore.KVDelRequired(ctx, keys...)
}
func KVExpireRequired(ctx context.Context, key string, ttl time.Duration) (bool, error) {
- client, homeMode, errClient := CurrentKVClient()
- if !homeMode || errClient != nil {
- return homeMode, errClient
- }
- _, errExpire := client.KVExpire(ctx, key, ttl)
- return true, errExpire
+ return kvstore.KVExpireRequired(ctx, key, ttl)
}
func KVGetJSONBestEffort(ctx context.Context, key string, out any) (bool, bool) {
- homeMode, found, errGet := KVGetJSONRequired(ctx, key, out)
- if errGet != nil {
- log.Errorf("home kv best-effort get failed prefix=%s: %v", kvLogPrefix(key), errGet)
- return homeMode, false
- }
- return homeMode, found
+ return kvstore.KVGetJSONBestEffort(ctx, key, out)
}
func KVSetJSONBestEffort(ctx context.Context, key string, value any, ttl time.Duration) bool {
- raw, errMarshal := json.Marshal(value)
- if errMarshal != nil {
- log.Errorf("home kv best-effort set failed prefix=%s: %v", kvLogPrefix(key), errMarshal)
- return false
- }
- return KVSetBytesBestEffort(ctx, key, raw, ttl)
+ return kvstore.KVSetJSONBestEffort(ctx, key, value, ttl)
}
func KVSetBytesBestEffort(ctx context.Context, key string, value []byte, ttl time.Duration) bool {
- homeMode, errSet := KVSetBytesRequired(ctx, key, value, ttl)
- if !homeMode {
- return false
- }
- if errSet != nil {
- log.Errorf("home kv best-effort set failed prefix=%s: %v", kvLogPrefix(key), errSet)
- return false
- }
- return true
+ return kvstore.KVSetBytesBestEffort(ctx, key, value, ttl)
}
func KVSetNXBestEffort(ctx context.Context, key string, value []byte, ttl time.Duration) bool {
- homeMode, written, errSet := KVSetNXRequired(ctx, key, value, ttl)
- if !homeMode {
- return false
- }
- if errSet != nil {
- log.Errorf("home kv best-effort setnx failed prefix=%s: %v", kvLogPrefix(key), errSet)
- return false
- }
- return written
+ return kvstore.KVSetNXBestEffort(ctx, key, value, ttl)
}
func KVDelBestEffort(ctx context.Context, keys ...string) bool {
- homeMode, _, errDel := KVDelRequired(ctx, keys...)
- if !homeMode {
- return false
- }
- if errDel != nil {
- log.Errorf("home kv best-effort del failed prefix=%s: %v", kvLogPrefix(firstKVKey(keys)), errDel)
- return false
- }
- return true
+ return kvstore.KVDelBestEffort(ctx, keys...)
}
func KVExpireBestEffort(ctx context.Context, key string, ttl time.Duration) bool {
- homeMode, errExpire := KVExpireRequired(ctx, key, ttl)
- if !homeMode {
- return false
- }
- if errExpire != nil {
- log.Errorf("home kv best-effort expire failed prefix=%s: %v", kvLogPrefix(key), errExpire)
- return false
- }
- return true
-}
-
-func kvSetOptionsForTTL(ttl time.Duration) KVSetOptions {
- if ttl <= 0 {
- return KVSetOptions{}
- }
- return KVSetOptions{EX: ttl}
-}
-
-func kvLogPrefix(key string) string {
- key = strings.TrimSpace(key)
- if key == "" {
- return "unknown"
- }
- parts := strings.Split(key, ":")
- if len(parts) >= 2 {
- return parts[0] + ":" + parts[1] + ":*"
- }
- return parts[0] + ":*"
-}
-
-func firstKVKey(keys []string) string {
- if len(keys) == 0 {
- return ""
- }
- return keys[0]
+ return kvstore.KVExpireBestEffort(ctx, key, ttl)
}
diff --git a/internal/home/kv_helpers_test.go b/internal/home/kv_helpers_test.go
index 60a08550e22..819a1cf8021 100644
--- a/internal/home/kv_helpers_test.go
+++ b/internal/home/kv_helpers_test.go
@@ -3,10 +3,12 @@ package home
import (
"bytes"
"context"
+ "path/filepath"
"strings"
"testing"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
log "github.com/sirupsen/logrus"
)
@@ -108,3 +110,40 @@ func TestKVBestEffortWriteSwallowsErrorAndRedactsLog(t *testing.T) {
t.Fatalf("log leaked key or value: %q", logText)
}
}
+
+func TestCurrentKVClientBridgesLocalBoltStoreAndPrioritizesHome(t *testing.T) {
+ ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ t.Cleanup(func() {
+ ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ })
+
+ dbPath := filepath.Join(t.TempDir(), "bridge.db")
+ if errCfg := kvstore.ConfigureLocal(true, dbPath); errCfg != nil {
+ t.Fatalf("kvstore.ConfigureLocal(true, %q) error = %v", dbPath, errCfg)
+ }
+ // Close the store before TempDir cleanup runs (cleanups are LIFO); Windows
+ // cannot remove a database file that is still open.
+ t.Cleanup(func() { _ = kvstore.ShutdownLocal() })
+
+ client, kvMode, errClient := CurrentKVClient()
+ if errClient != nil || !kvMode || client == nil {
+ t.Fatalf("CurrentKVClient() with local enabled = (%v, %v, %v), want (*BoltStore, true, nil)", client, kvMode, errClient)
+ }
+ if _, ok := client.(*kvstore.BoltStore); !ok {
+ t.Fatalf("CurrentKVClient() type = %T, want *kvstore.BoltStore", client)
+ }
+
+ // When Home is active, CurrentKVClient must return the Home client even while local bbolt is enabled.
+ homeClient, _ := newRedisCommandTestClient(t, func(args []string) string {
+ return "+OK\r\n"
+ })
+ homeClient.heartbeatOK.Store(true)
+ SetCurrent(homeClient)
+
+ activeClient, activeMode, errActive := CurrentKVClient()
+ if errActive != nil || !activeMode || activeClient != homeClient {
+ t.Fatalf("CurrentKVClient() with active Home = (%v, %v, %v), want (%v, true, nil)", activeClient, activeMode, errActive, homeClient)
+ }
+}
diff --git a/internal/kvstore/bolt_store.go b/internal/kvstore/bolt_store.go
new file mode 100644
index 00000000000..4ea8211a8bb
--- /dev/null
+++ b/internal/kvstore/bolt_store.go
@@ -0,0 +1,829 @@
+package kvstore
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+ "go.etcd.io/bbolt"
+)
+
+const (
+ envelopeVersionV1 byte = 0x01
+ envelopeHeaderSize = 9
+ defaultSweepInterval = 5 * time.Minute
+ defaultSweepBatchSize = 256
+ defaultBoltOpenTimeout = 5 * time.Second
+)
+
+var (
+ boltKVBucket = []byte("cpa_kv")
+ errStoreClosed = errors.New("kvstore: bolt store is not open")
+
+ _ KVClient = (*BoltStore)(nil)
+)
+
+func encodeEnvelope(value []byte, expiresAtUnixNano int64) []byte {
+ buf := make([]byte, envelopeHeaderSize+len(value))
+ buf[0] = envelopeVersionV1
+ binary.BigEndian.PutUint64(buf[1:envelopeHeaderSize], uint64(expiresAtUnixNano))
+ copy(buf[envelopeHeaderSize:], value)
+ return buf
+}
+
+func decodeEnvelope(raw []byte, nowUnixNano int64) (payload []byte, expiresAt int64, expired bool, err error) {
+ if len(raw) < envelopeHeaderSize {
+ return nil, 0, false, fmt.Errorf("kvstore: corrupted entry (len=%d)", len(raw))
+ }
+ if raw[0] != envelopeVersionV1 {
+ return nil, 0, false, fmt.Errorf("kvstore: unsupported envelope version %d", raw[0])
+ }
+ exp := int64(binary.BigEndian.Uint64(raw[1:envelopeHeaderSize]))
+ if exp > 0 && nowUnixNano >= exp {
+ return nil, exp, true, nil
+ }
+ return raw[envelopeHeaderSize:], exp, false, nil
+}
+
+// BoltStoreOption configures optional behavior on BoltStore.
+type BoltStoreOption func(*BoltStore)
+
+// WithNowFunc overrides the clock function used for TTL evaluation.
+func WithNowFunc(fn func() time.Time) BoltStoreOption {
+ return func(s *BoltStore) {
+ if fn != nil {
+ s.nowFunc = fn
+ }
+ }
+}
+
+// WithSweepInterval sets the background expired-key sweep interval.
+// A non-positive interval disables the background sweeper goroutine.
+func WithSweepInterval(interval time.Duration) BoltStoreOption {
+ return func(s *BoltStore) {
+ s.sweepInterval = interval
+ }
+}
+
+// WithSweepBatchSize sets the maximum number of expired keys deleted per sweep batch.
+func WithSweepBatchSize(batchSize int) BoltStoreOption {
+ return func(s *BoltStore) {
+ if batchSize > 0 {
+ s.sweepBatchSize = batchSize
+ }
+ }
+}
+
+// BoltStore implements KVClient backed by a local bbolt database file.
+type BoltStore struct {
+ db *bbolt.DB
+ path string
+ nowFunc func() time.Time
+ sweepInterval time.Duration
+ sweepBatchSize int
+ stopCh chan struct{}
+ doneCh chan struct{}
+ closeOnce sync.Once
+ closeErr error
+ runUpdate func(func(*bbolt.Tx) error) error
+ beforeSweepDelete func(batch [][]byte)
+}
+
+// OpenBoltStore resolves path, creates parent directories, opens the bbolt file,
+// ensures the KV bucket exists, and starts the background expiration sweeper.
+func OpenBoltStore(path string, opts ...BoltStoreOption) (*BoltStore, error) {
+ resolvedPath, errResolve := ResolvePath(path)
+ if errResolve != nil {
+ return nil, errResolve
+ }
+ if errMkdir := os.MkdirAll(filepath.Dir(resolvedPath), 0o755); errMkdir != nil {
+ return nil, fmt.Errorf("kvstore: create parent directory: %w", errMkdir)
+ }
+ db, errOpen := bbolt.Open(resolvedPath, 0o600, &bbolt.Options{Timeout: defaultBoltOpenTimeout})
+ if errOpen != nil {
+ return nil, fmt.Errorf("kvstore: open bolt db: %w", errOpen)
+ }
+ if errInit := db.Update(func(tx *bbolt.Tx) error {
+ _, errBucket := tx.CreateBucketIfNotExists(boltKVBucket)
+ return errBucket
+ }); errInit != nil {
+ _ = db.Close()
+ return nil, fmt.Errorf("kvstore: initialize bucket: %w", errInit)
+ }
+
+ store := &BoltStore{
+ db: db,
+ path: resolvedPath,
+ nowFunc: time.Now,
+ sweepInterval: defaultSweepInterval,
+ sweepBatchSize: defaultSweepBatchSize,
+ stopCh: make(chan struct{}),
+ doneCh: make(chan struct{}),
+ runUpdate: db.Update,
+ }
+ for _, opt := range opts {
+ if opt != nil {
+ opt(store)
+ }
+ }
+ if store.nowFunc == nil {
+ store.nowFunc = time.Now
+ }
+ if store.runUpdate == nil {
+ store.runUpdate = db.Update
+ }
+ if store.sweepBatchSize <= 0 {
+ store.sweepBatchSize = defaultSweepBatchSize
+ }
+ if store.sweepInterval > 0 {
+ go store.runSweeper()
+ } else {
+ close(store.doneCh)
+ }
+ return store, nil
+}
+
+// Path returns the resolved absolute file path of the underlying bbolt database.
+func (s *BoltStore) Path() string {
+ if s == nil {
+ return ""
+ }
+ return s.path
+}
+
+// Close stops the background sweeper and closes the underlying bbolt database.
+// Calling Close multiple times is safe and idempotent.
+func (s *BoltStore) Close() error {
+ if s == nil {
+ return nil
+ }
+ s.closeOnce.Do(func() {
+ close(s.stopCh)
+ <-s.doneCh
+ if s.db != nil {
+ s.closeErr = s.db.Close()
+ }
+ })
+ return s.closeErr
+}
+
+func (s *BoltStore) now() time.Time {
+ if s != nil && s.nowFunc != nil {
+ return s.nowFunc()
+ }
+ return time.Now()
+}
+
+func (s *BoltStore) view(fn func(tx *bbolt.Tx, now time.Time) error) error {
+ return s.db.View(func(tx *bbolt.Tx) error {
+ return fn(tx, s.now())
+ })
+}
+
+func (s *BoltStore) update(fn func(tx *bbolt.Tx, now time.Time) error) error {
+ runner := s.db.Update
+ if s.runUpdate != nil {
+ runner = s.runUpdate
+ }
+ return runner(func(tx *bbolt.Tx) error {
+ return fn(tx, s.now())
+ })
+}
+
+func checkContext(ctx context.Context) error {
+ if ctx != nil && ctx.Err() != nil {
+ return ctx.Err()
+ }
+ return nil
+}
+
+func ceilDurationTTL(value time.Duration, unit time.Duration) (time.Duration, error) {
+ if value <= 0 || unit <= 0 {
+ return 0, nil
+ }
+ units := DurationCeil(value, unit)
+ if units > math.MaxInt64/int64(unit) {
+ return 0, fmt.Errorf("kvstore: ttl duration overflows int64 nanoseconds")
+ }
+ return time.Duration(units * int64(unit)), nil
+}
+
+func computeExpiresAtUnixNano(now time.Time, ttl time.Duration) (int64, error) {
+ if ttl <= 0 {
+ return 0, nil
+ }
+ if now.IsZero() {
+ return 0, fmt.Errorf("kvstore: current time is zero")
+ }
+ sec := now.Unix()
+ nsec := int64(now.Nanosecond())
+ const nsPerSec = int64(time.Second)
+ if sec > math.MaxInt64/nsPerSec || sec < math.MinInt64/nsPerSec {
+ return 0, fmt.Errorf("kvstore: current time overflows unix nanoseconds")
+ }
+ secNano := sec * nsPerSec
+ if (nsec > 0 && secNano > math.MaxInt64-nsec) || (nsec < 0 && secNano < math.MinInt64-nsec) {
+ return 0, fmt.Errorf("kvstore: current time overflows unix nanoseconds")
+ }
+ nowNano := secNano + nsec
+ ttlNano := int64(ttl)
+ if nowNano > 0 && ttlNano > math.MaxInt64-nowNano {
+ return 0, fmt.Errorf("kvstore: expiration time overflows int64 nanoseconds")
+ }
+ expiresAt := nowNano + ttlNano
+ if expiresAt <= 0 {
+ return 0, fmt.Errorf("kvstore: expiration time must be positive unix nanoseconds")
+ }
+ return expiresAt, nil
+}
+
+// KVGet retrieves the value for key if it exists and has not expired.
+func (s *BoltStore) KVGet(ctx context.Context, key string) ([]byte, bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return nil, false, errCtx
+ }
+ if s == nil || s.db == nil {
+ return nil, false, errStoreClosed
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return nil, false, fmt.Errorf("home kv: key is empty")
+ }
+ var value []byte
+ var found bool
+ errView := s.view(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ raw := b.Get([]byte(key))
+ if raw == nil {
+ return nil
+ }
+ payload, _, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ return nil
+ }
+ value = bytes.Clone(payload)
+ if value == nil {
+ value = []byte{}
+ }
+ found = true
+ return nil
+ })
+ if errView != nil {
+ return nil, false, errView
+ }
+ return value, found, nil
+}
+
+// KVSet stores key and value with optional TTL and NX/XX conditions.
+func (s *BoltStore) KVSet(ctx context.Context, key string, value []byte, opts KVSetOptions) (bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return false, errCtx
+ }
+ if s == nil || s.db == nil {
+ return false, errStoreClosed
+ }
+ if _, errArgs := BuildSetArgs(key, value, opts); errArgs != nil {
+ return false, errArgs
+ }
+ key = strings.TrimSpace(key)
+
+ var ttl time.Duration
+ if opts.EX > 0 {
+ var errTTL error
+ ttl, errTTL = ceilDurationTTL(opts.EX, time.Second)
+ if errTTL != nil {
+ return false, errTTL
+ }
+ } else if opts.PX > 0 {
+ var errTTL error
+ ttl, errTTL = ceilDurationTTL(opts.PX, time.Millisecond)
+ if errTTL != nil {
+ return false, errTTL
+ }
+ }
+
+ keyBytes := []byte(key)
+ var written bool
+ errUpdate := s.update(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ expiresAt, errExp := computeExpiresAtUnixNano(now, ttl)
+ if errExp != nil {
+ return errExp
+ }
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return fmt.Errorf("kvstore: bucket not found")
+ }
+ var exists bool
+ if raw := b.Get(keyBytes); raw != nil {
+ _, _, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ if errDel := b.Delete(keyBytes); errDel != nil {
+ return errDel
+ }
+ exists = false
+ } else {
+ exists = true
+ }
+ }
+ if opts.NX && exists {
+ return nil
+ }
+ if opts.XX && !exists {
+ return nil
+ }
+ if errPut := b.Put(keyBytes, encodeEnvelope(value, expiresAt)); errPut != nil {
+ return errPut
+ }
+ written = true
+ return nil
+ })
+ if errUpdate != nil {
+ return false, errUpdate
+ }
+ return written, nil
+}
+
+// KVSetNX stores value only when key does not currently exist (or has expired).
+func (s *BoltStore) KVSetNX(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return false, errCtx
+ }
+ return s.KVSet(ctx, key, value, KVSetOptions{NX: true, EX: ttl})
+}
+
+// KVCompareAndSwap atomically updates key to value if its current state matches expectedExists and expected.
+func (s *BoltStore) KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return false, errCtx
+ }
+ if s == nil || s.db == nil {
+ return false, errStoreClosed
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return false, fmt.Errorf("home kv: key is empty")
+ }
+ if ttl < 0 {
+ return false, fmt.Errorf("home kv: ttl must not be negative")
+ }
+
+ var roundedTTL time.Duration
+ if ttl > 0 {
+ var errTTL error
+ roundedTTL, errTTL = ceilDurationTTL(ttl, time.Millisecond)
+ if errTTL != nil {
+ return false, errTTL
+ }
+ }
+
+ keyBytes := []byte(key)
+ var swapped bool
+ errUpdate := s.update(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ expiresAt, errExp := computeExpiresAtUnixNano(now, roundedTTL)
+ if errExp != nil {
+ return errExp
+ }
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return fmt.Errorf("kvstore: bucket not found")
+ }
+ var currentExists bool
+ var currentPayload []byte
+ if raw := b.Get(keyBytes); raw != nil {
+ payload, _, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ if errDel := b.Delete(keyBytes); errDel != nil {
+ return errDel
+ }
+ } else {
+ currentExists = true
+ currentPayload = payload
+ }
+ }
+ if currentExists != expectedExists || (expectedExists && !bytes.Equal(currentPayload, expected)) {
+ return nil
+ }
+ if errPut := b.Put(keyBytes, encodeEnvelope(value, expiresAt)); errPut != nil {
+ return errPut
+ }
+ swapped = true
+ return nil
+ })
+ if errUpdate != nil {
+ return false, errUpdate
+ }
+ return swapped, nil
+}
+
+// KVDel deletes one or more keys and returns the number of unexpired keys removed.
+func (s *BoltStore) KVDel(ctx context.Context, keys ...string) (int64, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return 0, errCtx
+ }
+ if s == nil || s.db == nil {
+ return 0, errStoreClosed
+ }
+ if len(keys) == 0 {
+ return 0, nil
+ }
+ var deleted int64
+ errUpdate := s.update(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ for _, rawKey := range keys {
+ key := strings.TrimSpace(rawKey)
+ if key == "" {
+ continue
+ }
+ keyBytes := []byte(key)
+ raw := b.Get(keyBytes)
+ if raw == nil {
+ continue
+ }
+ _, _, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode == nil && !expired {
+ deleted++
+ }
+ if errDel := b.Delete(keyBytes); errDel != nil {
+ return errDel
+ }
+ }
+ return nil
+ })
+ if errUpdate != nil {
+ return 0, errUpdate
+ }
+ return deleted, nil
+}
+
+// KVExpire updates the TTL of an existing unexpired key, or immediately deletes it if ttl <= 0.
+func (s *BoltStore) KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return false, errCtx
+ }
+ if s == nil || s.db == nil {
+ return false, errStoreClosed
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return false, fmt.Errorf("home kv: key is empty")
+ }
+ keyBytes := []byte(key)
+ var updated bool
+ errUpdate := s.update(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ raw := b.Get(keyBytes)
+ if raw == nil {
+ return nil
+ }
+ payload, _, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ return b.Delete(keyBytes)
+ }
+ if ttl <= 0 {
+ if errDel := b.Delete(keyBytes); errDel != nil {
+ return errDel
+ }
+ updated = true
+ return nil
+ }
+ expiresAt, errExp := computeExpiresAtUnixNano(now, ttl)
+ if errExp != nil {
+ return errExp
+ }
+ if errPut := b.Put(keyBytes, encodeEnvelope(payload, expiresAt)); errPut != nil {
+ return errPut
+ }
+ updated = true
+ return nil
+ })
+ if errUpdate != nil {
+ return false, errUpdate
+ }
+ return updated, nil
+}
+
+// KVTTL returns the remaining time-to-live of key.
+// If key does not exist or has expired, it returns (0, false, nil).
+// If key exists and has no expiration, it returns (0, true, nil).
+func (s *BoltStore) KVTTL(ctx context.Context, key string) (time.Duration, bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return 0, false, errCtx
+ }
+ if s == nil || s.db == nil {
+ return 0, false, errStoreClosed
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return 0, false, fmt.Errorf("home kv: key is empty")
+ }
+ var remaining time.Duration
+ var found bool
+ errView := s.view(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ raw := b.Get([]byte(key))
+ if raw == nil {
+ return nil
+ }
+ _, expiresAt, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ return nil
+ }
+ found = true
+ if expiresAt > 0 {
+ remaining = time.Duration(expiresAt - nowNano)
+ }
+ return nil
+ })
+ if errView != nil {
+ return 0, false, errView
+ }
+ return remaining, found, nil
+}
+
+// KVIncrBy atomically increments the base-10 integer value of key by delta.
+func (s *BoltStore) KVIncrBy(ctx context.Context, key string, delta int64) (int64, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return 0, errCtx
+ }
+ if s == nil || s.db == nil {
+ return 0, errStoreClosed
+ }
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return 0, fmt.Errorf("home kv: key is empty")
+ }
+ keyBytes := []byte(key)
+ var next int64
+ errUpdate := s.update(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return fmt.Errorf("kvstore: bucket not found")
+ }
+ var current int64
+ var expiresAt int64
+ if raw := b.Get(keyBytes); raw != nil {
+ payload, exp, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ if errDel := b.Delete(keyBytes); errDel != nil {
+ return errDel
+ }
+ } else {
+ parsed, errParse := strconv.ParseInt(string(payload), 10, 64)
+ if errParse != nil {
+ return fmt.Errorf("kvstore: value is not an integer or out of range: %w", errParse)
+ }
+ current = parsed
+ expiresAt = exp
+ }
+ }
+ if (delta > 0 && current > math.MaxInt64-delta) || (delta < 0 && current < math.MinInt64-delta) {
+ return fmt.Errorf("kvstore: increment or decrement would overflow")
+ }
+ next = current + delta
+ encoded := encodeEnvelope([]byte(strconv.FormatInt(next, 10)), expiresAt)
+ return b.Put(keyBytes, encoded)
+ })
+ if errUpdate != nil {
+ return 0, errUpdate
+ }
+ return next, nil
+}
+
+// KVMGet retrieves multiple keys in a single read transaction.
+func (s *BoltStore) KVMGet(ctx context.Context, keys ...string) ([][]byte, []bool, error) {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return nil, nil, errCtx
+ }
+ if s == nil || s.db == nil {
+ return nil, nil, errStoreClosed
+ }
+ if len(keys) == 0 {
+ return nil, nil, nil
+ }
+ values := make([][]byte, len(keys))
+ found := make([]bool, len(keys))
+ errView := s.view(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ for i, rawKey := range keys {
+ key := strings.TrimSpace(rawKey)
+ if key == "" {
+ continue
+ }
+ raw := b.Get([]byte(key))
+ if raw == nil {
+ continue
+ }
+ payload, _, expired, errDecode := decodeEnvelope(raw, nowNano)
+ if errDecode != nil {
+ return errDecode
+ }
+ if expired {
+ continue
+ }
+ val := bytes.Clone(payload)
+ if val == nil {
+ val = []byte{}
+ }
+ values[i] = val
+ found[i] = true
+ }
+ return nil
+ })
+ if errView != nil {
+ return nil, nil, errView
+ }
+ return values, found, nil
+}
+
+// KVMSet writes multiple non-expiring key-value pairs in a single write transaction.
+func (s *BoltStore) KVMSet(ctx context.Context, pairs map[string][]byte) error {
+ if errCtx := checkContext(ctx); errCtx != nil {
+ return errCtx
+ }
+ if s == nil || s.db == nil {
+ return errStoreClosed
+ }
+ if len(pairs) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(pairs))
+ for k := range pairs {
+ if strings.TrimSpace(k) == "" {
+ return fmt.Errorf("home kv: key is empty")
+ }
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return s.update(func(tx *bbolt.Tx, _ time.Time) error {
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return fmt.Errorf("kvstore: bucket not found")
+ }
+ for _, rawKey := range keys {
+ key := strings.TrimSpace(rawKey)
+ if errPut := b.Put([]byte(key), encodeEnvelope(pairs[rawKey], 0)); errPut != nil {
+ return errPut
+ }
+ }
+ return nil
+ })
+}
+
+func (s *BoltStore) isStopped() bool {
+ select {
+ case <-s.stopCh:
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *BoltStore) runSweeper() {
+ defer close(s.doneCh)
+ ticker := time.NewTicker(s.sweepInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-s.stopCh:
+ return
+ case <-ticker.C:
+ if errSweep := s.sweepExpired(); errSweep != nil {
+ log.Warnf("kvstore: background sweep failed: %v", errSweep)
+ }
+ }
+ }
+}
+
+func (s *BoltStore) sweepExpired() error {
+ if s == nil || s.db == nil {
+ return errStoreClosed
+ }
+ batchSize := s.sweepBatchSize
+ if batchSize <= 0 {
+ batchSize = defaultSweepBatchSize
+ }
+
+ var lastKey []byte
+ for !s.isStopped() {
+ var expiredKeys [][]byte
+ errView := s.view(func(tx *bbolt.Tx, now time.Time) error {
+ nowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ c := b.Cursor()
+ var k, v []byte
+ if lastKey == nil {
+ k, v = c.First()
+ } else {
+ k, v = c.Seek(lastKey)
+ if k != nil && bytes.Equal(k, lastKey) {
+ k, v = c.Next()
+ }
+ }
+ for ; k != nil; k, v = c.Next() {
+ _, _, expired, errDecode := decodeEnvelope(v, nowNano)
+ if errDecode == nil && expired {
+ keyCopy := bytes.Clone(k)
+ expiredKeys = append(expiredKeys, keyCopy)
+ if len(expiredKeys) >= batchSize {
+ lastKey = keyCopy
+ break
+ }
+ }
+ }
+ return nil
+ })
+ if errView != nil {
+ return errView
+ }
+ if len(expiredKeys) == 0 {
+ return nil
+ }
+
+ if s.beforeSweepDelete != nil {
+ s.beforeSweepDelete(expiredKeys)
+ }
+
+ errUpdate := s.update(func(tx *bbolt.Tx, now time.Time) error {
+ deleteNowNano := now.UnixNano()
+ b := tx.Bucket(boltKVBucket)
+ if b == nil {
+ return nil
+ }
+ for _, keyBytes := range expiredKeys {
+ raw := b.Get(keyBytes)
+ if raw == nil {
+ continue
+ }
+ _, _, expired, errDecode := decodeEnvelope(raw, deleteNowNano)
+ if errDecode == nil && expired {
+ if errDel := b.Delete(keyBytes); errDel != nil {
+ return errDel
+ }
+ }
+ }
+ return nil
+ })
+ if errUpdate != nil {
+ return errUpdate
+ }
+ if len(expiredKeys) < batchSize {
+ return nil
+ }
+ }
+ return nil
+}
diff --git a/internal/kvstore/bolt_store_test.go b/internal/kvstore/bolt_store_test.go
new file mode 100644
index 00000000000..17f5638b86c
--- /dev/null
+++ b/internal/kvstore/bolt_store_test.go
@@ -0,0 +1,762 @@
+package kvstore
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "errors"
+ "math"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "go.etcd.io/bbolt"
+)
+
+func TestResolvePath_DefaultRelativeTildeAndAbsolute(t *testing.T) {
+ cwd, errGetwd := os.Getwd()
+ if errGetwd != nil {
+ t.Fatalf("os.Getwd() error = %v", errGetwd)
+ }
+ wantDefault := filepath.Clean(filepath.Join(cwd, config.DefaultKVStorePath))
+
+ for _, raw := range []string{"", " ", "\t\n"} {
+ got, errResolve := ResolvePath(raw)
+ if errResolve != nil {
+ t.Fatalf("ResolvePath(%q) error = %v", raw, errResolve)
+ }
+ if got != wantDefault {
+ t.Fatalf("ResolvePath(%q) = %q, want %q", raw, got, wantDefault)
+ }
+ }
+
+ gotRel, errRel := ResolvePath(" ./data/nested/../custom.db ")
+ if errRel != nil {
+ t.Fatalf("ResolvePath(relative) error = %v", errRel)
+ }
+ wantRel := filepath.Clean(filepath.Join(cwd, "data", "custom.db"))
+ if gotRel != wantRel {
+ t.Fatalf("ResolvePath(relative) = %q, want %q", gotRel, wantRel)
+ }
+
+ homeDir, errHome := os.UserHomeDir()
+ if errHome != nil {
+ t.Fatalf("os.UserHomeDir() error = %v", errHome)
+ }
+ gotTilde, errTilde := ResolvePath("~/custom/kv.db")
+ if errTilde != nil {
+ t.Fatalf("ResolvePath(tilde) error = %v", errTilde)
+ }
+ wantTilde := filepath.Clean(filepath.Join(homeDir, "custom", "kv.db"))
+ if gotTilde != wantTilde {
+ t.Fatalf("ResolvePath(tilde) = %q, want %q", gotTilde, wantTilde)
+ }
+
+ gotHomeOnly, errHomeOnly := ResolvePath("~")
+ if errHomeOnly != nil {
+ t.Fatalf("ResolvePath(~) error = %v", errHomeOnly)
+ }
+ if gotHomeOnly != filepath.Clean(homeDir) {
+ t.Fatalf("ResolvePath(~) = %q, want %q", gotHomeOnly, filepath.Clean(homeDir))
+ }
+
+ absInput := filepath.Join(t.TempDir(), "sub", "kv.db")
+ gotAbs, errAbs := ResolvePath(absInput)
+ if errAbs != nil {
+ t.Fatalf("ResolvePath(abs) error = %v", errAbs)
+ }
+ if gotAbs != filepath.Clean(absInput) {
+ t.Fatalf("ResolvePath(abs) = %q, want %q", gotAbs, filepath.Clean(absInput))
+ }
+
+ gotDefaultAuth, errDefaultAuth := ResolvePathWithAuthDir("", "")
+ if errDefaultAuth != nil {
+ t.Fatalf("ResolvePathWithAuthDir('', '') error = %v", errDefaultAuth)
+ }
+ wantDefaultAuth := filepath.Clean(filepath.Join(homeDir, ".cli-proxy-api", config.DefaultKVStorePath))
+ if gotDefaultAuth != wantDefaultAuth {
+ t.Fatalf("ResolvePathWithAuthDir('', '') = %q, want %q", gotDefaultAuth, wantDefaultAuth)
+ }
+
+ customAuthDir := filepath.Join(t.TempDir(), "auth-dir")
+ gotCustomAuth, errCustomAuth := ResolvePathWithAuthDir(" ", customAuthDir)
+ if errCustomAuth != nil {
+ t.Fatalf("ResolvePathWithAuthDir(blank, customAuthDir) error = %v", errCustomAuth)
+ }
+ wantCustomAuth := filepath.Clean(filepath.Join(customAuthDir, config.DefaultKVStorePath))
+ if gotCustomAuth != wantCustomAuth {
+ t.Fatalf("ResolvePathWithAuthDir(blank, customAuthDir) = %q, want %q", gotCustomAuth, wantCustomAuth)
+ }
+
+ explicitOverride := filepath.Join(t.TempDir(), "explicit", "override.db")
+ gotOverride, errOverride := ResolvePathWithAuthDir(explicitOverride, customAuthDir)
+ if errOverride != nil {
+ t.Fatalf("ResolvePathWithAuthDir(explicit, customAuthDir) error = %v", errOverride)
+ }
+ if gotOverride != filepath.Clean(explicitOverride) {
+ t.Fatalf("ResolvePathWithAuthDir(explicit, customAuthDir) = %q, want %q", gotOverride, filepath.Clean(explicitOverride))
+ }
+}
+
+func TestBuildSetArgsAndDurationCeil(t *testing.T) {
+ if got := DurationCeil(0, time.Second); got != 0 {
+ t.Fatalf("DurationCeil(0) = %d, want 0", got)
+ }
+ if got := DurationCeil(1001*time.Millisecond, time.Second); got != 2 {
+ t.Fatalf("DurationCeil(1001ms, 1s) = %d, want 2", got)
+ }
+ if got := DurationCeil(time.Duration(math.MaxInt64), time.Second); got <= 0 {
+ t.Fatalf("DurationCeil(MaxInt64, 1s) = %d, want positive int64 without overflow", got)
+ }
+ if got := DurationCeil(time.Duration(math.MaxInt64), 1); got != math.MaxInt64 {
+ t.Fatalf("DurationCeil(MaxInt64, 1ns) = %d, want MaxInt64", got)
+ }
+
+ args, errArgs := BuildSetArgs("key", []byte("value"), KVSetOptions{EX: 2 * time.Second, NX: true})
+ if errArgs != nil {
+ t.Fatalf("BuildSetArgs(EX NX) error = %v", errArgs)
+ }
+ want := []any{"key", []byte("value"), "EX", int64(2), "NX"}
+ if !reflect.DeepEqual(args, want) {
+ t.Fatalf("BuildSetArgs(EX NX) = %#v, want %#v", args, want)
+ }
+
+ args, errArgs = BuildSetArgs("key", []byte("value"), KVSetOptions{PX: 1500 * time.Millisecond, XX: true})
+ if errArgs != nil {
+ t.Fatalf("BuildSetArgs(PX XX) error = %v", errArgs)
+ }
+ want = []any{"key", []byte("value"), "PX", int64(1500), "XX"}
+ if !reflect.DeepEqual(args, want) {
+ t.Fatalf("BuildSetArgs(PX XX) = %#v, want %#v", args, want)
+ }
+
+ if _, errEmpty := BuildSetArgs(" ", []byte("v"), KVSetOptions{}); errEmpty == nil || errEmpty.Error() != "home kv: key is empty" {
+ t.Fatalf("BuildSetArgs(empty key) error = %v, want 'home kv: key is empty'", errEmpty)
+ }
+ if _, errExPx := BuildSetArgs("k", []byte("v"), KVSetOptions{EX: time.Second, PX: time.Millisecond}); errExPx == nil || errExPx.Error() != "home kv: EX and PX are mutually exclusive" {
+ t.Fatalf("BuildSetArgs(EX PX) error = %v, want 'home kv: EX and PX are mutually exclusive'", errExPx)
+ }
+ if _, errNeg := BuildSetArgs("k", []byte("v"), KVSetOptions{EX: -time.Second}); errNeg == nil || errNeg.Error() != "home kv: ttl must not be negative" {
+ t.Fatalf("BuildSetArgs(negative EX) error = %v, want 'home kv: ttl must not be negative'", errNeg)
+ }
+ if _, errNxXx := BuildSetArgs("k", []byte("v"), KVSetOptions{NX: true, XX: true}); errNxXx == nil || errNxXx.Error() != "home kv: NX and XX are mutually exclusive" {
+ t.Fatalf("BuildSetArgs(NX XX) error = %v, want 'home kv: NX and XX are mutually exclusive'", errNxXx)
+ }
+ if ErrCompareAndSwapUnsupported == nil || ErrCompareAndSwapUnsupported.Error() != "kvstore: compare-and-swap command is not supported" {
+ t.Fatalf("ErrCompareAndSwapUnsupported = %v, want 'kvstore: compare-and-swap command is not supported'", ErrCompareAndSwapUnsupported)
+ }
+}
+
+func TestBoltStore_EnvelopeAndBasicOperations(t *testing.T) {
+ now := time.Unix(1700000000, 0).UTC()
+ var clockMu sync.RWMutex
+ currentNow := func() time.Time {
+ clockMu.RLock()
+ defer clockMu.RUnlock()
+ return now
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "nested", "dir", "kv.db")
+ store, errOpen := OpenBoltStore(dbPath, WithNowFunc(currentNow), WithSweepInterval(0))
+ if errOpen != nil {
+ t.Fatalf("OpenBoltStore() error = %v", errOpen)
+ }
+ t.Cleanup(func() {
+ _ = store.Close()
+ })
+
+ if store.Path() != filepath.Clean(dbPath) {
+ t.Fatalf("store.Path() = %q, want %q", store.Path(), filepath.Clean(dbPath))
+ }
+
+ ctx := context.Background()
+
+ // Basic Set & Get
+ written, errSet := store.KVSet(ctx, "cpa:test:k1", []byte("val1"), KVSetOptions{})
+ if errSet != nil || !written {
+ t.Fatalf("KVSet(k1) = %v, %v; want true, nil", written, errSet)
+ }
+ // Direct 9-byte binary envelope layout assertions (version == 0x01, big-endian expiresAtUnixNano, raw payload)
+ if _, errSetTTL := store.KVSet(ctx, "cpa:test:env-ttl", []byte("payload-bytes"), KVSetOptions{EX: 3 * time.Second}); errSetTTL != nil {
+ t.Fatalf("KVSet(env-ttl) error = %v", errSetTTL)
+ }
+ errInspectEnvelope := store.db.View(func(tx *bbolt.Tx) error {
+ b := tx.Bucket(boltKVBucket)
+ rawNoTTL := b.Get([]byte("cpa:test:k1"))
+ if len(rawNoTTL) != envelopeHeaderSize+len("val1") {
+ t.Fatalf("rawNoTTL len = %d, want %d", len(rawNoTTL), envelopeHeaderSize+len("val1"))
+ }
+ if rawNoTTL[0] != envelopeVersionV1 {
+ t.Fatalf("rawNoTTL version = 0x%02x, want 0x%02x", rawNoTTL[0], envelopeVersionV1)
+ }
+ if exp := binary.BigEndian.Uint64(rawNoTTL[1:envelopeHeaderSize]); exp != 0 {
+ t.Fatalf("rawNoTTL expiresAt = %d, want 0", exp)
+ }
+ if string(rawNoTTL[envelopeHeaderSize:]) != "val1" {
+ t.Fatalf("rawNoTTL payload = %q, want val1", rawNoTTL[envelopeHeaderSize:])
+ }
+
+ rawTTL := b.Get([]byte("cpa:test:env-ttl"))
+ wantExp := uint64(now.Add(3 * time.Second).UnixNano())
+ if rawTTL[0] != envelopeVersionV1 {
+ t.Fatalf("rawTTL version = 0x%02x, want 0x%02x", rawTTL[0], envelopeVersionV1)
+ }
+ if gotExp := binary.BigEndian.Uint64(rawTTL[1:envelopeHeaderSize]); gotExp != wantExp {
+ t.Fatalf("rawTTL expiresAt = %d, want %d", gotExp, wantExp)
+ }
+ if string(rawTTL[envelopeHeaderSize:]) != "payload-bytes" {
+ t.Fatalf("rawTTL payload = %q, want payload-bytes", rawTTL[envelopeHeaderSize:])
+ }
+ return nil
+ })
+ if errInspectEnvelope != nil {
+ t.Fatalf("inspect binary envelope error = %v", errInspectEnvelope)
+ }
+
+ // Explicit KVSetNX and KVCompareAndSwap match/mismatch branches on unexpired keys
+ nxOK, errSetNX1 := store.KVSetNX(ctx, "cpa:test:setnx", []byte("first"), 10*time.Second)
+ if errSetNX1 != nil || !nxOK {
+ t.Fatalf("KVSetNX(first) = %v, %v; want true, nil", nxOK, errSetNX1)
+ }
+ nxDup, errSetNX2 := store.KVSetNX(ctx, "cpa:test:setnx", []byte("second"), 10*time.Second)
+ if errSetNX2 != nil || nxDup {
+ t.Fatalf("KVSetNX(duplicate) = %v, %v; want false, nil", nxDup, errSetNX2)
+ }
+ if casMismatchVal, errCASMismatchVal := store.KVCompareAndSwap(ctx, "cpa:test:setnx", []byte("wrong"), true, []byte("updated"), 10*time.Second); errCASMismatchVal != nil || casMismatchVal {
+ t.Fatalf("KVCompareAndSwap(value mismatch) = %v, %v; want false, nil", casMismatchVal, errCASMismatchVal)
+ }
+ if casMismatchExist, errCASMismatchExist := store.KVCompareAndSwap(ctx, "cpa:test:setnx", nil, false, []byte("updated"), 10*time.Second); errCASMismatchExist != nil || casMismatchExist {
+ t.Fatalf("KVCompareAndSwap(exists mismatch) = %v, %v; want false, nil", casMismatchExist, errCASMismatchExist)
+ }
+ if casMatch, errCASMatch := store.KVCompareAndSwap(ctx, "cpa:test:setnx", []byte("first"), true, []byte("updated"), 10*time.Second); errCASMatch != nil || !casMatch {
+ t.Fatalf("KVCompareAndSwap(match) = %v, %v; want true, nil", casMatch, errCASMatch)
+ }
+ if gotCAS, foundCAS, errGetCAS := store.KVGet(ctx, "cpa:test:setnx"); errGetCAS != nil || !foundCAS || string(gotCAS) != "updated" {
+ t.Fatalf("KVGet(setnx) after CAS = %q, %v, %v; want updated, true, nil", gotCAS, foundCAS, errGetCAS)
+ }
+ got, found, errGet := store.KVGet(ctx, "cpa:test:k1")
+ if errGet != nil || !found || string(got) != "val1" {
+ t.Fatalf("KVGet(k1) = %q, %v, %v; want val1, true, nil", got, found, errGet)
+ }
+ // Mutating returned slice must not mutate stored value
+ got[0] = 'X'
+ gotAgain, foundAgain, errGetAgain := store.KVGet(ctx, "cpa:test:k1")
+ if errGetAgain != nil || !foundAgain || string(gotAgain) != "val1" {
+ t.Fatalf("KVGet(k1) after caller slice mutation = %q, %v, %v; want val1, true, nil", gotAgain, foundAgain, errGetAgain)
+ }
+
+ // Missing key
+ missVal, missFound, errMiss := store.KVGet(ctx, "cpa:test:missing")
+ if errMiss != nil || missFound || missVal != nil {
+ t.Fatalf("KVGet(missing) = %v, %v, %v; want nil, false, nil", missVal, missFound, errMiss)
+ }
+
+ // KVMSet & KVMGet
+ if errMSet := store.KVMSet(ctx, map[string][]byte{
+ "cpa:test:m1": []byte("mv1"),
+ "cpa:test:m2": []byte("mv2"),
+ }); errMSet != nil {
+ t.Fatalf("KVMSet() error = %v", errMSet)
+ }
+ mvals, mfound, errMGet := store.KVMGet(ctx, "cpa:test:m1", "cpa:test:missing", "cpa:test:m2")
+ if errMGet != nil {
+ t.Fatalf("KVMGet() error = %v", errMGet)
+ }
+ if len(mvals) != 3 || len(mfound) != 3 {
+ t.Fatalf("KVMGet() lengths = %d, %d; want 3, 3", len(mvals), len(mfound))
+ }
+ if !mfound[0] || string(mvals[0]) != "mv1" || mfound[1] || mvals[1] != nil || !mfound[2] || string(mvals[2]) != "mv2" {
+ t.Fatalf("KVMGet() = %q / %v; unexpected values", mvals, mfound)
+ }
+
+ // KVDel
+ deleted, errDel := store.KVDel(ctx, "cpa:test:m1", "cpa:test:m1", "cpa:test:missing")
+ if errDel != nil || deleted != 1 {
+ t.Fatalf("KVDel() = %d, %v; want 1, nil", deleted, errDel)
+ }
+ _, foundAfterDel, errAfterDel := store.KVGet(ctx, "cpa:test:m1")
+ if errAfterDel != nil || foundAfterDel {
+ t.Fatalf("KVGet(m1) after KVDel = %v, %v; want false, nil", foundAfterDel, errAfterDel)
+ }
+
+ // KVIncrBy & overflow defense
+ valIncr, errIncr := store.KVIncrBy(ctx, "cpa:test:counter", 5)
+ if errIncr != nil || valIncr != 5 {
+ t.Fatalf("KVIncrBy(new, 5) = %d, %v; want 5, nil", valIncr, errIncr)
+ }
+ valIncr, errIncr = store.KVIncrBy(ctx, "cpa:test:counter", -2)
+ if errIncr != nil || valIncr != 3 {
+ t.Fatalf("KVIncrBy(existing, -2) = %d, %v; want 3, nil", valIncr, errIncr)
+ }
+ if _, errNonInt := store.KVIncrBy(ctx, "cpa:test:k1", 1); errNonInt == nil {
+ t.Fatal("KVIncrBy(non-integer) error = nil, want error")
+ }
+ if _, errMaxSet := store.KVSet(ctx, "cpa:test:max", []byte("9223372036854775807"), KVSetOptions{}); errMaxSet != nil {
+ t.Fatalf("KVSet(max int64) error = %v", errMaxSet)
+ }
+ if _, errOverflow := store.KVIncrBy(ctx, "cpa:test:max", 1); errOverflow == nil {
+ t.Fatal("KVIncrBy(positive overflow) error = nil, want overflow error")
+ }
+ if _, errMinSet := store.KVSet(ctx, "cpa:test:min", []byte("-9223372036854775808"), KVSetOptions{}); errMinSet != nil {
+ t.Fatalf("KVSet(min int64) error = %v", errMinSet)
+ }
+ if _, errUnderflow := store.KVIncrBy(ctx, "cpa:test:min", -1); errUnderflow == nil || math.MinInt64 == 0 {
+ t.Fatal("KVIncrBy(negative overflow) error = nil, want overflow error")
+ }
+
+ // Corrupted envelope defense: length < 9 and unsupported version != 0x01
+ errCorruptWrite := store.db.Update(func(tx *bbolt.Tx) error {
+ b := tx.Bucket(boltKVBucket)
+ if errPut := b.Put([]byte("cpa:test:short"), []byte{0x01, 0x02, 0x03}); errPut != nil {
+ return errPut
+ }
+ badVer := make([]byte, envelopeHeaderSize+3)
+ badVer[0] = 0x02
+ copy(badVer[envelopeHeaderSize:], "abc")
+ return b.Put([]byte("cpa:test:badver"), badVer)
+ })
+ if errCorruptWrite != nil {
+ t.Fatalf("inject corrupted entries error = %v", errCorruptWrite)
+ }
+
+ if _, _, errShort := store.KVGet(ctx, "cpa:test:short"); errShort == nil || !strings.Contains(errShort.Error(), "corrupted entry") {
+ t.Fatalf("KVGet(short envelope) error = %v, want corrupted entry error", errShort)
+ }
+ if _, _, errBadVer := store.KVGet(ctx, "cpa:test:badver"); errBadVer == nil || !strings.Contains(errBadVer.Error(), "unsupported envelope version") {
+ t.Fatalf("KVGet(bad version envelope) error = %v, want unsupported envelope version error", errBadVer)
+ }
+ if _, _, errTTLShort := store.KVTTL(ctx, "cpa:test:short"); errTTLShort == nil {
+ t.Fatal("KVTTL(short envelope) error = nil, want error")
+ }
+ if _, errIncrShort := store.KVIncrBy(ctx, "cpa:test:short", 1); errIncrShort == nil {
+ t.Fatal("KVIncrBy(short envelope) error = nil, want error")
+ }
+
+ // Context cancellation check across all KV methods
+ canceledCtx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if _, _, errKVGet := store.KVGet(canceledCtx, "cpa:test:k1"); !errors.Is(errKVGet, context.Canceled) {
+ t.Fatalf("KVGet(canceledCtx) error = %v, want context.Canceled", errKVGet)
+ }
+ if _, errKVSet := store.KVSet(canceledCtx, "cpa:test:k1", []byte("v"), KVSetOptions{}); !errors.Is(errKVSet, context.Canceled) {
+ t.Fatalf("KVSet(canceledCtx) error = %v, want context.Canceled", errKVSet)
+ }
+ if _, errKVSetNX := store.KVSetNX(canceledCtx, "cpa:test:k1", []byte("v"), time.Second); !errors.Is(errKVSetNX, context.Canceled) {
+ t.Fatalf("KVSetNX(canceledCtx) error = %v, want context.Canceled", errKVSetNX)
+ }
+ if _, errKVCAS := store.KVCompareAndSwap(canceledCtx, "cpa:test:k1", nil, false, []byte("v"), 0); !errors.Is(errKVCAS, context.Canceled) {
+ t.Fatalf("KVCompareAndSwap(canceledCtx) error = %v, want context.Canceled", errKVCAS)
+ }
+ if _, errKVDel := store.KVDel(canceledCtx, "cpa:test:k1"); !errors.Is(errKVDel, context.Canceled) {
+ t.Fatalf("KVDel(canceledCtx) error = %v, want context.Canceled", errKVDel)
+ }
+ if _, errKVExpire := store.KVExpire(canceledCtx, "cpa:test:k1", time.Second); !errors.Is(errKVExpire, context.Canceled) {
+ t.Fatalf("KVExpire(canceledCtx) error = %v, want context.Canceled", errKVExpire)
+ }
+ if _, _, errKVTTL := store.KVTTL(canceledCtx, "cpa:test:k1"); !errors.Is(errKVTTL, context.Canceled) {
+ t.Fatalf("KVTTL(canceledCtx) error = %v, want context.Canceled", errKVTTL)
+ }
+ if _, errKVIncrBy := store.KVIncrBy(canceledCtx, "cpa:test:k1", 1); !errors.Is(errKVIncrBy, context.Canceled) {
+ t.Fatalf("KVIncrBy(canceledCtx) error = %v, want context.Canceled", errKVIncrBy)
+ }
+ if _, _, errKVMGet := store.KVMGet(canceledCtx, "cpa:test:k1"); !errors.Is(errKVMGet, context.Canceled) {
+ t.Fatalf("KVMGet(canceledCtx) error = %v, want context.Canceled", errKVMGet)
+ }
+ if errKVMSet := store.KVMSet(canceledCtx, map[string][]byte{"cpa:test:k1": []byte("v")}); !errors.Is(errKVMSet, context.Canceled) {
+ t.Fatalf("KVMSet(canceledCtx) error = %v, want context.Canceled", errKVMSet)
+ }
+
+ // Idempotent Close
+ if errClose1 := store.Close(); errClose1 != nil {
+ t.Fatalf("first store.Close() error = %v", errClose1)
+ }
+ if errClose2 := store.Close(); errClose2 != nil {
+ t.Fatalf("second store.Close() error = %v", errClose2)
+ }
+}
+
+func TestBoltStore_TTLLazyExpiryAndWriteTimeOverwrite(t *testing.T) {
+ now := time.Unix(1700000000, 0).UTC()
+ var clockMu sync.RWMutex
+ currentNow := func() time.Time {
+ clockMu.RLock()
+ defer clockMu.RUnlock()
+ return now
+ }
+ advanceClock := func(d time.Duration) {
+ clockMu.Lock()
+ now = now.Add(d)
+ clockMu.Unlock()
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "ttl.db")
+ store, errOpen := OpenBoltStore(dbPath, WithNowFunc(currentNow), WithSweepInterval(0))
+ if errOpen != nil {
+ t.Fatalf("OpenBoltStore() error = %v", errOpen)
+ }
+ t.Cleanup(func() {
+ _ = store.Close()
+ })
+
+ ctx := context.Background()
+
+ rawExists := func(key string) bool {
+ var exists bool
+ _ = store.db.View(func(tx *bbolt.Tx) error {
+ b := tx.Bucket(boltKVBucket)
+ if b != nil && b.Get([]byte(key)) != nil {
+ exists = true
+ }
+ return nil
+ })
+ return exists
+ }
+
+ // 1. EX and PX expiry on KVGet & KVTTL
+ if _, errKVSet := store.KVSet(ctx, "ex-key", []byte("ex-val"), KVSetOptions{EX: 10 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(ex-key) error = %v", errKVSet)
+ }
+ if _, errKVSet := store.KVSet(ctx, "px-key", []byte("px-val"), KVSetOptions{PX: 1500 * time.Millisecond}); errKVSet != nil {
+ t.Fatalf("KVSet(px-key) error = %v", errKVSet)
+ }
+ if _, errKVSet := store.KVSet(ctx, "persist-key", []byte("p-val"), KVSetOptions{}); errKVSet != nil {
+ t.Fatalf("KVSet(persist-key) error = %v", errKVSet)
+ }
+
+ ttlPersist, okPersist, errTTLPersist := store.KVTTL(ctx, "persist-key")
+ if errTTLPersist != nil || !okPersist || ttlPersist != 0 {
+ t.Fatalf("KVTTL(persist-key) = %v, %v, %v; want 0, true, nil", ttlPersist, okPersist, errTTLPersist)
+ }
+
+ advanceClock(1 * time.Second)
+ ttlPX, okPX, errTTLPX := store.KVTTL(ctx, "px-key")
+ if errTTLPX != nil || !okPX || ttlPX != 500*time.Millisecond {
+ t.Fatalf("KVTTL(px-key) after 1s = %v, %v, %v; want 500ms, true, nil", ttlPX, okPX, errTTLPX)
+ }
+
+ advanceClock(500 * time.Millisecond)
+ if val, found, errKVGet := store.KVGet(ctx, "px-key"); errKVGet != nil || found || val != nil {
+ t.Fatalf("KVGet(px-key) at expiry = %v, %v, %v; want nil, false, nil", val, found, errKVGet)
+ }
+ if ttl, found, errKVTTL := store.KVTTL(ctx, "px-key"); errKVTTL != nil || found || ttl != 0 {
+ t.Fatalf("KVTTL(px-key) at expiry = %v, %v, %v; want 0, false, nil", ttl, found, errKVTTL)
+ }
+
+ advanceClock(9 * time.Second) // total 10.5s
+ if val, found, errKVGet := store.KVGet(ctx, "ex-key"); errKVGet != nil || found || val != nil {
+ t.Fatalf("KVGet(ex-key) after expiry = %v, %v, %v; want nil, false, nil", val, found, errKVGet)
+ }
+ if ttl, found, errKVTTL := store.KVTTL(ctx, "ex-key"); errKVTTL != nil || found || ttl != 0 {
+ t.Fatalf("KVTTL(ex-key) after expiry = %v, %v, %v; want 0, false, nil", ttl, found, errKVTTL)
+ }
+
+ // 2. Expired key physically exists -> KVSet(..., NX: true) returns true and writes new value
+ if !rawExists("ex-key") {
+ t.Fatal("expected expired ex-key to still physically exist before write")
+ }
+ nxWritten, errNX := store.KVSet(ctx, "ex-key", []byte("nx-new"), KVSetOptions{NX: true, EX: 5 * time.Second})
+ if errNX != nil || !nxWritten {
+ t.Fatalf("KVSet(expired, NX: true) = %v, %v; want true, nil", nxWritten, errNX)
+ }
+ if got, found, errKVGet := store.KVGet(ctx, "ex-key"); errKVGet != nil || !found || string(got) != "nx-new" {
+ t.Fatalf("KVGet(ex-key) after NX = %q, %v, %v; want nx-new, true, nil", got, found, errKVGet)
+ }
+
+ // 3. Expired key physically exists -> KVSet(..., XX: true) returns false and deletes physical record
+ if !rawExists("px-key") {
+ t.Fatal("expected expired px-key to still physically exist before XX write")
+ }
+ xxWritten, errXX := store.KVSet(ctx, "px-key", []byte("xx-new"), KVSetOptions{XX: true})
+ if errXX != nil || xxWritten {
+ t.Fatalf("KVSet(expired, XX: true) = %v, %v; want false, nil", xxWritten, errXX)
+ }
+ if rawExists("px-key") {
+ t.Fatal("expected KVSet(expired, XX: true) to delete physical expired record")
+ }
+
+ // 4. Expired key physically exists -> KVCompareAndSwap(..., expectedExists: false) returns true
+ advanceClock(6 * time.Second) // ex-key (5s TTL) is now expired
+ if !rawExists("ex-key") {
+ t.Fatal("expected ex-key to physically exist before CAS")
+ }
+ casSwapped, errCAS := store.KVCompareAndSwap(ctx, "ex-key", nil, false, []byte("cas-val"), 4*time.Second)
+ if errCAS != nil || !casSwapped {
+ t.Fatalf("KVCompareAndSwap(expired, expectedExists=false) = %v, %v; want true, nil", casSwapped, errCAS)
+ }
+ if got, found, errKVGet := store.KVGet(ctx, "ex-key"); errKVGet != nil || !found || string(got) != "cas-val" {
+ t.Fatalf("KVGet(ex-key) after CAS = %q, %v, %v; want cas-val, true, nil", got, found, errKVGet)
+ }
+
+ // 5. Expired key physically exists -> KVIncrBy starts from 0 and sets expiresAt == 0
+ if _, errKVSet := store.KVSet(ctx, "incr-ttl", []byte("100"), KVSetOptions{EX: 3 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(incr-ttl) error = %v", errKVSet)
+ }
+ advanceClock(4 * time.Second) // incr-ttl is now expired
+ nextVal, errIncr := store.KVIncrBy(ctx, "incr-ttl", 7)
+ if errIncr != nil || nextVal != 7 {
+ t.Fatalf("KVIncrBy(expired, 7) = %d, %v; want 7, nil", nextVal, errIncr)
+ }
+ ttlIncr, foundIncr, errTTLIncr := store.KVTTL(ctx, "incr-ttl")
+ if errTTLIncr != nil || !foundIncr || ttlIncr != 0 {
+ t.Fatalf("KVTTL(incr-ttl) after expired KVIncrBy = %v, %v, %v; want 0 (no expiry), true, nil", ttlIncr, foundIncr, errTTLIncr)
+ }
+
+ // 6. KVExpire semantics:
+ // - unexpired key -> returns true and extends TTL
+ // - expired/non-existent key -> returns false (and deletes physical expired record)
+ // - ttl <= 0 -> immediately deletes key and returns true
+ if _, errKVSet := store.KVSet(ctx, "exp-target", []byte("v"), KVSetOptions{EX: 5 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(exp-target) error = %v", errKVSet)
+ }
+ if ok, errKVExpire := store.KVExpire(ctx, "exp-target", 20*time.Second); errKVExpire != nil || !ok {
+ t.Fatalf("KVExpire(unexpired, 20s) = %v, %v; want true, nil", ok, errKVExpire)
+ }
+ if ttl, found, errKVTTL := store.KVTTL(ctx, "exp-target"); errKVTTL != nil || !found || ttl != 20*time.Second {
+ t.Fatalf("KVTTL(exp-target) = %v, %v, %v; want 20s, true, nil", ttl, found, errKVTTL)
+ }
+
+ if _, errKVSet := store.KVSet(ctx, "exp-stale", []byte("v"), KVSetOptions{EX: 2 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(exp-stale) error = %v", errKVSet)
+ }
+ advanceClock(3 * time.Second)
+ if ok, errKVExpire := store.KVExpire(ctx, "exp-stale", 10*time.Second); errKVExpire != nil || ok {
+ t.Fatalf("KVExpire(expired) = %v, %v; want false, nil", ok, errKVExpire)
+ }
+ if rawExists("exp-stale") {
+ t.Fatal("expected KVExpire(expired) to delete physical expired record")
+ }
+ if ok, errKVExpire := store.KVExpire(ctx, "non-existent", 10*time.Second); errKVExpire != nil || ok {
+ t.Fatalf("KVExpire(non-existent) = %v, %v; want false, nil", ok, errKVExpire)
+ }
+
+ if ok, errKVExpire := store.KVExpire(ctx, "exp-target", 0); errKVExpire != nil || !ok {
+ t.Fatalf("KVExpire(ttl=0) = %v, %v; want true, nil", ok, errKVExpire)
+ }
+ if _, found, errKVGet := store.KVGet(ctx, "exp-target"); errKVGet != nil || found {
+ t.Fatalf("KVGet(exp-target) after KVExpire(0) = %v, %v; want false, nil", found, errKVGet)
+ }
+ if rawExists("exp-target") {
+ t.Fatal("expected KVExpire(ttl<=0) to delete physical record")
+ }
+}
+
+func TestBoltStore_BackgroundSweeperPreservesConcurrentlyRefreshedKeys(t *testing.T) {
+ now := time.Unix(1700000000, 0).UTC()
+ var clockMu sync.RWMutex
+ currentNow := func() time.Time {
+ clockMu.RLock()
+ defer clockMu.RUnlock()
+ return now
+ }
+ advanceClock := func(d time.Duration) {
+ clockMu.Lock()
+ now = now.Add(d)
+ clockMu.Unlock()
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "sweeper.db")
+ store, errOpen := OpenBoltStore(
+ dbPath,
+ WithNowFunc(currentNow),
+ WithSweepBatchSize(2),
+ WithSweepInterval(0),
+ )
+ if errOpen != nil {
+ t.Fatalf("OpenBoltStore() error = %v", errOpen)
+ }
+ t.Cleanup(func() {
+ _ = store.Close()
+ })
+
+ ctx := context.Background()
+
+ // Write 4 short-TTL keys and 2 long/persistent keys
+ for _, k := range []string{"k1", "k2", "k3", "k4"} {
+ if _, errKVSet := store.KVSet(ctx, k, []byte("val-"+k), KVSetOptions{EX: 5 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(%s) error = %v", k, errKVSet)
+ }
+ }
+ if _, errKVSet := store.KVSet(ctx, "k5-valid", []byte("val-k5"), KVSetOptions{EX: 60 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(k5-valid) error = %v", errKVSet)
+ }
+ if _, errKVSet := store.KVSet(ctx, "k6-persist", []byte("val-k6"), KVSetOptions{}); errKVSet != nil {
+ t.Fatalf("KVSet(k6-persist) error = %v", errKVSet)
+ }
+
+ // Advance clock by 10s so k1..k4 are expired, while k5-valid and k6-persist remain valid
+ advanceClock(10 * time.Second)
+
+ // Simulate concurrent refresh of k2 between scan (db.View) and delete (db.Update)
+ var refreshedOnce sync.Once
+ var batchesScanned int
+ store.beforeSweepDelete = func(batch [][]byte) {
+ batchesScanned++
+ for _, keyBytes := range batch {
+ if bytes.Equal(keyBytes, []byte("k2")) {
+ refreshedOnce.Do(func() {
+ _, _ = store.KVSet(ctx, "k2", []byte("val-k2-refreshed"), KVSetOptions{EX: 30 * time.Second})
+ })
+ }
+ }
+ }
+
+ if errSweep := store.sweepExpired(); errSweep != nil {
+ t.Fatalf("sweepExpired() error = %v", errSweep)
+ }
+ if batchesScanned < 2 {
+ t.Fatalf("batchesScanned = %d, want >= 2 (multi-batch sweep with batchSize=2)", batchesScanned)
+ }
+
+ // Verify k1, k3, k4 are physically deleted from bbolt bucket
+ errCheck := store.db.View(func(tx *bbolt.Tx) error {
+ b := tx.Bucket(boltKVBucket)
+ for _, deletedKey := range []string{"k1", "k3", "k4"} {
+ if b.Get([]byte(deletedKey)) != nil {
+ t.Errorf("expected expired key %q to be physically deleted by sweeper", deletedKey)
+ }
+ }
+ for _, keptKey := range []string{"k2", "k5-valid", "k6-persist"} {
+ if b.Get([]byte(keptKey)) == nil {
+ t.Errorf("expected key %q to be preserved in bucket", keptKey)
+ }
+ }
+ return nil
+ })
+ if errCheck != nil {
+ t.Fatalf("db.View check error = %v", errCheck)
+ }
+
+ // Verify k2 has the refreshed value
+ gotK2, foundK2, errK2 := store.KVGet(ctx, "k2")
+ if errK2 != nil || !foundK2 || string(gotK2) != "val-k2-refreshed" {
+ t.Fatalf("KVGet(k2) = %q, %v, %v; want val-k2-refreshed, true, nil", gotK2, foundK2, errK2)
+ }
+}
+
+func TestBoltStore_InTransactionTimeSamplingAndTTLOverflowGuard(t *testing.T) {
+ now := time.Unix(1700000000, 0).UTC()
+ var clockMu sync.RWMutex
+ var holdingWriteLock atomic.Bool
+ var sampledWhileWriteLockHeld atomic.Bool
+
+ currentNow := func() time.Time {
+ if holdingWriteLock.Load() {
+ sampledWhileWriteLockHeld.Store(true)
+ }
+ clockMu.RLock()
+ defer clockMu.RUnlock()
+ return now
+ }
+ advanceClock := func(d time.Duration) {
+ clockMu.Lock()
+ now = now.Add(d)
+ clockMu.Unlock()
+ }
+
+ dbPath := filepath.Join(t.TempDir(), "tx-time-and-overflow.db")
+ store, errOpen := OpenBoltStore(dbPath, WithNowFunc(currentNow), WithSweepInterval(0))
+ if errOpen != nil {
+ t.Fatalf("OpenBoltStore() error = %v", errOpen)
+ }
+ t.Cleanup(func() {
+ _ = store.Close()
+ })
+
+ ctx := context.Background()
+
+ // 1. Write a key with 5s TTL at t0.
+ if _, errKVSet := store.KVSet(ctx, "contended-key", []byte("old-val"), KVSetOptions{EX: 5 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(contended-key) error = %v", errKVSet)
+ }
+
+ // 2. Hold db.Update lock in tx1 and invoke KVSetNX.
+ // When s.update invokes store.runUpdate(txFn), all code in KVSetNX, KVSet, and s.update
+ // outside the transaction callback txFn has already completed while tx1 holds the lock at +0s,
+ // and txFn (which samples s.now()) has not started yet.
+ txLocked := make(chan struct{})
+ releaseTx := make(chan struct{})
+ holderDone := make(chan error, 1)
+ go func() {
+ holderDone <- store.db.Update(func(tx *bbolt.Tx) error {
+ holdingWriteLock.Store(true)
+ close(txLocked)
+ <-releaseTx
+ holdingWriteLock.Store(false)
+ return nil
+ })
+ }()
+
+ <-txLocked
+ origRunUpdate := store.runUpdate
+ store.runUpdate = func(txFn func(*bbolt.Tx) error) error {
+ // At this exact point, KVSetNX has already invoked s.update -> runUpdate(txFn),
+ // so any pre-transaction code has ALREADY executed while tx1 holds the lock and clock is +0s.
+ // Now advance the clock by 6s and release tx1 before entering bbolt's db.Update(txFn).
+ advanceClock(6 * time.Second)
+ close(releaseTx)
+ return origRunUpdate(txFn)
+ }
+ contenderWritten, contenderErr := store.KVSetNX(ctx, "contended-key", []byte("fresh"), 10*time.Second)
+ store.runUpdate = origRunUpdate
+
+ if errHolder := <-holderDone; errHolder != nil {
+ t.Fatalf("holder db.Update error = %v", errHolder)
+ }
+ if sampledWhileWriteLockHeld.Load() {
+ t.Fatal("nowFunc was sampled outside db.Update while another write transaction held the lock")
+ }
+ if contenderErr != nil || !contenderWritten {
+ t.Fatalf("contender KVSetNX = %v, %v; want true, nil (sampled post-lock time)", contenderWritten, contenderErr)
+ }
+ if gotFresh, foundFresh, errGetFresh := store.KVGet(ctx, "contended-key"); errGetFresh != nil || !foundFresh || string(gotFresh) != "fresh" {
+ t.Fatalf("KVGet(contended-key) = %q, %v, %v; want fresh, true, nil", gotFresh, foundFresh, errGetFresh)
+ }
+ if ttlAfter, foundAfter, errTTLAfter := store.KVTTL(ctx, "contended-key"); errTTLAfter != nil || !foundAfter || ttlAfter != 10*time.Second {
+ t.Fatalf("KVTTL(contended-key) = %v, %v, %v; want 10s, true, nil", ttlAfter, foundAfter, errTTLAfter)
+ }
+
+ // 2b. Verify time.Unix(0, 0) (UnixNano == 0) is accepted when computing positive expiresAt.
+ clockMu.Lock()
+ savedNow := now
+ now = time.Unix(0, 0).UTC()
+ clockMu.Unlock()
+ if writtenEpoch, errEpoch := store.KVSet(ctx, "epoch-key", []byte("epoch-val"), KVSetOptions{EX: 5 * time.Second}); errEpoch != nil || !writtenEpoch {
+ t.Fatalf("KVSet(at Unix(0,0)) = %v, %v; want true, nil", writtenEpoch, errEpoch)
+ }
+ if ttlEpoch, foundEpoch, errTTLEpoch := store.KVTTL(ctx, "epoch-key"); errTTLEpoch != nil || !foundEpoch || ttlEpoch != 5*time.Second {
+ t.Fatalf("KVTTL(epoch-key at Unix(0,0)) = %v, %v, %v; want 5s, true, nil", ttlEpoch, foundEpoch, errTTLEpoch)
+ }
+ clockMu.Lock()
+ now = savedNow
+ clockMu.Unlock()
+
+ // 3. TTL arithmetic overflow guard: huge TTLs must return an error and never become permanent (expiresAt <= 0).
+ if _, errOverflowEX := store.KVSet(ctx, "overflow-ex", []byte("v"), KVSetOptions{EX: time.Duration(math.MaxInt64)}); errOverflowEX == nil {
+ t.Fatal("KVSet(EX=MaxInt64) error = nil, want overflow error")
+ }
+ maxWholeSecondsTTL := time.Duration((math.MaxInt64 / int64(time.Second)) * int64(time.Second))
+ if _, errOverflowAddEX := store.KVSet(ctx, "overflow-add-ex", []byte("v"), KVSetOptions{EX: maxWholeSecondsTTL}); errOverflowAddEX == nil {
+ t.Fatal("KVSet(now+EX > MaxInt64) error = nil, want overflow error")
+ }
+ if _, errOverflowPX := store.KVSet(ctx, "overflow-px", []byte("v"), KVSetOptions{PX: time.Duration(math.MaxInt64)}); errOverflowPX == nil {
+ t.Fatal("KVSet(PX=MaxInt64) error = nil, want overflow error")
+ }
+ if _, errOverflowCAS := store.KVCompareAndSwap(ctx, "overflow-cas", nil, false, []byte("v"), time.Duration(math.MaxInt64)); errOverflowCAS == nil {
+ t.Fatal("KVCompareAndSwap(ttl=MaxInt64) error = nil, want overflow error")
+ }
+ if _, errOverflowAddCAS := store.KVCompareAndSwap(ctx, "overflow-add-cas", nil, false, []byte("v"), maxWholeSecondsTTL); errOverflowAddCAS == nil {
+ t.Fatal("KVCompareAndSwap(now+ttl > MaxInt64) error = nil, want overflow error")
+ }
+ if _, errKVSet := store.KVSet(ctx, "overflow-exp", []byte("v"), KVSetOptions{EX: 10 * time.Second}); errKVSet != nil {
+ t.Fatalf("KVSet(overflow-exp) error = %v", errKVSet)
+ }
+ if _, errOverflowExpire := store.KVExpire(ctx, "overflow-exp", time.Duration(math.MaxInt64)); errOverflowExpire == nil {
+ t.Fatal("KVExpire(ttl=MaxInt64) error = nil, want overflow error")
+ }
+}
diff --git a/internal/kvstore/controller.go b/internal/kvstore/controller.go
new file mode 100644
index 00000000000..e209a6a5b9d
--- /dev/null
+++ b/internal/kvstore/controller.go
@@ -0,0 +1,126 @@
+package kvstore
+
+import (
+ "fmt"
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+)
+
+var (
+ controllerMu sync.RWMutex
+ homeProvider func() (KVClient, bool, error)
+ localEnabled bool
+ localStore *BoltStore
+ localPath string
+ localErr error
+)
+
+// RegisterHomeProvider registers the callback used by CurrentKVClient to inspect
+// whether Home mode is active and obtain the active Home KV client.
+func RegisterHomeProvider(fn func() (KVClient, bool, error)) {
+ controllerMu.Lock()
+ homeProvider = fn
+ controllerMu.Unlock()
+}
+
+// ConfigureLocal enables, reconfigures, or disables the local bbolt-backed KV store.
+func ConfigureLocal(enabled bool, rawPath string) error {
+ controllerMu.Lock()
+ defer controllerMu.Unlock()
+
+ if !enabled {
+ return shutdownLocalLocked()
+ }
+
+ resolvedPath, errResolve := ResolvePath(rawPath)
+ if errResolve != nil {
+ if localStore != nil {
+ if errClose := localStore.Close(); errClose != nil {
+ log.Errorf("local kv store close failed: %v", errClose)
+ }
+ localStore = nil
+ localPath = ""
+ }
+ localEnabled = true
+ localErr = errResolve
+ log.Errorf("local kv store resolve path failed: %v", errResolve)
+ return errResolve
+ }
+
+ if localStore != nil && localPath == resolvedPath && localErr == nil {
+ localEnabled = true
+ return nil
+ }
+
+ if localStore != nil {
+ if errClose := localStore.Close(); errClose != nil {
+ log.Errorf("local kv store close failed: %v", errClose)
+ }
+ localStore = nil
+ localPath = ""
+ }
+
+ store, errOpen := OpenBoltStore(resolvedPath)
+ if errOpen != nil {
+ localEnabled = true
+ localErr = errOpen
+ log.Errorf("local kv store open failed (path=%s): %v", resolvedPath, errOpen)
+ return errOpen
+ }
+
+ localEnabled = true
+ localStore = store
+ localPath = resolvedPath
+ localErr = nil
+ return nil
+}
+
+// ShutdownLocal closes any open local BoltStore and resets local KV state to disabled.
+func ShutdownLocal() error {
+ controllerMu.Lock()
+ defer controllerMu.Unlock()
+ return shutdownLocalLocked()
+}
+
+func shutdownLocalLocked() error {
+ var errClose error
+ if localStore != nil {
+ errClose = localStore.Close()
+ }
+ localEnabled = false
+ localStore = nil
+ localPath = ""
+ localErr = nil
+ return errClose
+}
+
+// CurrentKVClient returns the active KVClient using the three-tier routing order:
+// 1. Home KV when Home mode is active (never falling back to local bbolt).
+// 2. Local BoltStore when server.kv-store.disabled is false.
+// 3. (nil, false, nil) for default in-memory storage.
+func CurrentKVClient() (KVClient, bool, error) {
+ controllerMu.RLock()
+ provider := homeProvider
+ controllerMu.RUnlock()
+
+ if provider != nil {
+ if client, homeMode, errHome := provider(); homeMode {
+ return client, true, errHome
+ }
+ }
+
+ controllerMu.RLock()
+ defer controllerMu.RUnlock()
+
+ if !localEnabled {
+ return nil, false, nil
+ }
+ if localErr != nil {
+ return nil, true, fmt.Errorf("local kv store unavailable: %w", localErr)
+ }
+ if localStore == nil {
+ return nil, true, fmt.Errorf("local kv store unavailable: %w", errStoreClosed)
+ }
+ return localStore, true, nil
+}
diff --git a/internal/kvstore/controller_test.go b/internal/kvstore/controller_test.go
new file mode 100644
index 00000000000..0e324e33cdc
--- /dev/null
+++ b/internal/kvstore/controller_test.go
@@ -0,0 +1,222 @@
+package kvstore
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func resetControllerForTest(t *testing.T) {
+ t.Helper()
+ _ = ShutdownLocal()
+ RegisterHomeProvider(nil)
+ t.Cleanup(func() {
+ _ = ShutdownLocal()
+ RegisterHomeProvider(nil)
+ })
+}
+
+func TestControllerDefaultDisabledReturnsNilClient(t *testing.T) {
+ resetControllerForTest(t)
+
+ client, kvMode, errClient := CurrentKVClient()
+ if errClient != nil {
+ t.Fatalf("CurrentKVClient() error = %v, want nil", errClient)
+ }
+ if kvMode {
+ t.Fatalf("CurrentKVClient() kvMode = true, want false")
+ }
+ if client != nil {
+ t.Fatalf("CurrentKVClient() client = %#v, want nil", client)
+ }
+}
+
+func TestControllerConfigureLocalLifecycleAndReuse(t *testing.T) {
+ resetControllerForTest(t)
+
+ dir := t.TempDir()
+ dbPath1 := filepath.Join(dir, "kv1.db")
+ dbPath2 := filepath.Join(dir, "kv2.db")
+
+ if errCfg := ConfigureLocal(true, dbPath1); errCfg != nil {
+ t.Fatalf("ConfigureLocal(true, dbPath1) error = %v", errCfg)
+ }
+
+ client1, kvMode1, errClient1 := CurrentKVClient()
+ if errClient1 != nil || !kvMode1 || client1 == nil {
+ t.Fatalf("CurrentKVClient() = (%v, %v, %v), want (*BoltStore, true, nil)", client1, kvMode1, errClient1)
+ }
+ bolt1, ok := client1.(*BoltStore)
+ if !ok {
+ t.Fatalf("CurrentKVClient() type = %T, want *BoltStore", client1)
+ }
+
+ // Reconfiguring with the same resolved path must reuse the same *BoltStore instance.
+ if errCfgAgain := ConfigureLocal(true, dbPath1); errCfgAgain != nil {
+ t.Fatalf("ConfigureLocal(true, dbPath1) second call error = %v", errCfgAgain)
+ }
+ client1Again, kvMode1Again, errClient1Again := CurrentKVClient()
+ if errClient1Again != nil || !kvMode1Again {
+ t.Fatalf("CurrentKVClient() after idempotent ConfigureLocal = (%v, %v, %v)", client1Again, kvMode1Again, errClient1Again)
+ }
+ if client1Again != bolt1 {
+ t.Fatalf("ConfigureLocal with identical path did not reuse *BoltStore instance: got %p, want %p", client1Again, bolt1)
+ }
+
+ // Switching to a new path must close the old store and open the new one.
+ if errCfg2 := ConfigureLocal(true, dbPath2); errCfg2 != nil {
+ t.Fatalf("ConfigureLocal(true, dbPath2) error = %v", errCfg2)
+ }
+ client2, kvMode2, errClient2 := CurrentKVClient()
+ if errClient2 != nil || !kvMode2 || client2 == nil {
+ t.Fatalf("CurrentKVClient() after path switch = (%v, %v, %v)", client2, kvMode2, errClient2)
+ }
+ bolt2, ok := client2.(*BoltStore)
+ if !ok {
+ t.Fatalf("CurrentKVClient() type = %T, want *BoltStore", client2)
+ }
+ if bolt2 == bolt1 {
+ t.Fatalf("ConfigureLocal with new path returned old *BoltStore instance")
+ }
+ if bolt2.Path() != dbPath2 {
+ t.Fatalf("bolt2.Path() = %q, want %q", bolt2.Path(), dbPath2)
+ }
+ // Old store should be closed; operations on bolt1 must fail.
+ if _, _, errOld := bolt1.KVGet(context.Background(), "key"); errOld == nil {
+ t.Fatalf("expected old BoltStore to be closed after switching path")
+ }
+
+ // Disabling local KV closes the store and resets state to memory mode.
+ if errDisable := ConfigureLocal(false, ""); errDisable != nil {
+ t.Fatalf("ConfigureLocal(false, \"\") error = %v", errDisable)
+ }
+ clientDisabled, kvModeDisabled, errDisabled := CurrentKVClient()
+ if errDisabled != nil || kvModeDisabled || clientDisabled != nil {
+ t.Fatalf("CurrentKVClient() after disable = (%v, %v, %v), want (nil, false, nil)", clientDisabled, kvModeDisabled, errDisabled)
+ }
+}
+
+func TestControllerConfigureLocalInvalidPathReportsUnavailableError(t *testing.T) {
+ resetControllerForTest(t)
+
+ // Passing an existing directory as the database file path causes bbolt.Open to fail.
+ invalidPath := t.TempDir()
+ errCfg := ConfigureLocal(true, invalidPath)
+ if errCfg == nil {
+ t.Fatalf("ConfigureLocal(true, directory) error = nil, want error")
+ }
+
+ client, kvMode, errClient := CurrentKVClient()
+ if !kvMode {
+ t.Fatalf("CurrentKVClient() kvMode = false, want true when enabled but open failed")
+ }
+ if client != nil {
+ t.Fatalf("CurrentKVClient() client = %#v, want nil on open failure", client)
+ }
+ if errClient == nil {
+ t.Fatalf("CurrentKVClient() error = nil, want unavailable error")
+ }
+}
+
+func TestControllerPrioritizesHomeProviderOverLocalBoltStore(t *testing.T) {
+ resetControllerForTest(t)
+
+ localPath := filepath.Join(t.TempDir(), "local.db")
+ if errCfg := ConfigureLocal(true, localPath); errCfg != nil {
+ t.Fatalf("ConfigureLocal(true, localPath) error = %v", errCfg)
+ }
+ // Close the store before TempDir cleanup runs (cleanups are LIFO); Windows
+ // cannot remove a database file that is still open.
+ t.Cleanup(func() { _ = ShutdownLocal() })
+
+ homeStorePath := filepath.Join(t.TempDir(), "home-mock.db")
+ homeStore, errOpen := OpenBoltStore(homeStorePath, WithSweepInterval(0))
+ if errOpen != nil {
+ t.Fatalf("OpenBoltStore(homeStorePath) error = %v", errOpen)
+ }
+ t.Cleanup(func() {
+ _ = homeStore.Close()
+ })
+
+ RegisterHomeProvider(func() (KVClient, bool, error) {
+ return homeStore, true, nil
+ })
+
+ client, kvMode, errClient := CurrentKVClient()
+ if errClient != nil || !kvMode {
+ t.Fatalf("CurrentKVClient() with active Home provider = (%v, %v, %v), want (homeStore, true, nil)", client, kvMode, errClient)
+ }
+ if client != homeStore {
+ t.Fatalf("CurrentKVClient() returned %p, want Home provider client %p", client, homeStore)
+ }
+
+ // When Home provider reports homeMode=true with an error, CurrentKVClient must return that error instead of falling back to local BoltStore.
+ expectedErr := errors.New("home kv store unavailable: not connected")
+ RegisterHomeProvider(func() (KVClient, bool, error) {
+ return nil, true, expectedErr
+ })
+ clientErr, kvModeErr, errHome := CurrentKVClient()
+ if !kvModeErr || clientErr != nil || !errors.Is(errHome, expectedErr) {
+ t.Fatalf("CurrentKVClient() with unavailable Home = (%v, %v, %v), want (nil, true, %v)", clientErr, kvModeErr, errHome, expectedErr)
+ }
+
+ // When Home provider reports homeMode=false, CurrentKVClient falls back to the enabled local BoltStore.
+ RegisterHomeProvider(func() (KVClient, bool, error) {
+ return nil, false, nil
+ })
+ clientLocal, kvModeLocal, errLocal := CurrentKVClient()
+ if errLocal != nil || !kvModeLocal || clientLocal == nil {
+ t.Fatalf("CurrentKVClient() with inactive Home = (%v, %v, %v), want local BoltStore", clientLocal, kvModeLocal, errLocal)
+ }
+ if clientLocal == homeStore {
+ t.Fatalf("CurrentKVClient() unexpectedly returned homeStore when homeMode=false")
+ }
+}
+
+func TestControllerHelpersWithLocalBoltStore(t *testing.T) {
+ resetControllerForTest(t)
+
+ dbPath := filepath.Join(t.TempDir(), "helpers.db")
+ if errCfg := ConfigureLocal(true, dbPath); errCfg != nil {
+ t.Fatalf("ConfigureLocal(true, dbPath) error = %v", errCfg)
+ }
+ // Close the store before TempDir cleanup runs (cleanups are LIFO); Windows
+ // cannot remove a database file that is still open.
+ t.Cleanup(func() { _ = ShutdownLocal() })
+
+ ctx := context.Background()
+ type payload struct {
+ Name string `json:"name"`
+ }
+
+ kvMode, errSet := KVSetJSONRequired(ctx, "cpa:test:json", payload{Name: "alice"}, 10*time.Minute)
+ if errSet != nil || !kvMode {
+ t.Fatalf("KVSetJSONRequired() = (%v, %v), want (true, nil)", kvMode, errSet)
+ }
+
+ var got payload
+ kvMode, found, errGet := KVGetJSONRequired(ctx, "cpa:test:json", &got)
+ if errGet != nil || !kvMode || !found || got.Name != "alice" {
+ t.Fatalf("KVGetJSONRequired() = (%v, %v, %v, %+v), want (true, true, nil, alice)", kvMode, found, errGet, got)
+ }
+
+ if !KVSetNXBestEffort(ctx, "cpa:test:nx", []byte("1"), 5*time.Minute) {
+ t.Fatalf("first KVSetNXBestEffort() = false, want true")
+ }
+ if KVSetNXBestEffort(ctx, "cpa:test:nx", []byte("2"), 5*time.Minute) {
+ t.Fatalf("second KVSetNXBestEffort() = true, want false")
+ }
+
+ if !KVExpireBestEffort(ctx, "cpa:test:json", 20*time.Minute) {
+ t.Fatalf("KVExpireBestEffort() = false, want true")
+ }
+ if !KVDelBestEffort(ctx, "cpa:test:json", "cpa:test:nx") {
+ t.Fatalf("KVDelBestEffort() = false, want true")
+ }
+ var afterDel payload
+ if _, foundAfterDel := KVGetJSONBestEffort(ctx, "cpa:test:json", &afterDel); foundAfterDel {
+ t.Fatalf("KVGetJSONBestEffort() found deleted key")
+ }
+}
diff --git a/internal/kvstore/helpers.go b/internal/kvstore/helpers.go
new file mode 100644
index 00000000000..b568c221c23
--- /dev/null
+++ b/internal/kvstore/helpers.go
@@ -0,0 +1,188 @@
+package kvstore
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// HashKeyPart returns the lowercase hex-encoded SHA-256 digest of value.
+func HashKeyPart(value string) string {
+ sum := sha256.Sum256([]byte(value))
+ return hex.EncodeToString(sum[:])
+}
+
+// KVGetJSONRequired fetches a JSON value from the active KV store if KV mode is enabled.
+func KVGetJSONRequired(ctx context.Context, key string, out any) (bool, bool, error) {
+ client, kvMode, errClient := CurrentKVClient()
+ if !kvMode || errClient != nil {
+ return kvMode, false, errClient
+ }
+ raw, found, errGet := client.KVGet(ctx, key)
+ if errGet != nil || !found {
+ return true, false, errGet
+ }
+ if errUnmarshal := json.Unmarshal(raw, out); errUnmarshal != nil {
+ return true, false, errUnmarshal
+ }
+ return true, true, nil
+}
+
+// KVSetJSONRequired marshals value as JSON and writes it to the active KV store if KV mode is enabled.
+func KVSetJSONRequired(ctx context.Context, key string, value any, ttl time.Duration) (bool, error) {
+ raw, errMarshal := json.Marshal(value)
+ if errMarshal != nil {
+ return false, errMarshal
+ }
+ return KVSetBytesRequired(ctx, key, raw, ttl)
+}
+
+// KVSetBytesRequired writes raw bytes to the active KV store if KV mode is enabled.
+func KVSetBytesRequired(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
+ client, kvMode, errClient := CurrentKVClient()
+ if !kvMode || errClient != nil {
+ return kvMode, errClient
+ }
+ written, errSet := client.KVSet(ctx, key, value, kvSetOptionsForTTL(ttl))
+ if errSet != nil {
+ return true, errSet
+ }
+ if !written {
+ return true, fmt.Errorf("home kv store unavailable")
+ }
+ return true, nil
+}
+
+// KVSetNXRequired conditionally writes value only if key does not exist in the active KV store.
+func KVSetNXRequired(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, bool, error) {
+ client, kvMode, errClient := CurrentKVClient()
+ if !kvMode || errClient != nil {
+ return kvMode, false, errClient
+ }
+ written, errSet := client.KVSetNX(ctx, key, value, ttl)
+ return true, written, errSet
+}
+
+// KVDelRequired deletes keys from the active KV store if KV mode is enabled.
+func KVDelRequired(ctx context.Context, keys ...string) (bool, int64, error) {
+ client, kvMode, errClient := CurrentKVClient()
+ if !kvMode || errClient != nil {
+ return kvMode, 0, errClient
+ }
+ deleted, errDel := client.KVDel(ctx, keys...)
+ return true, deleted, errDel
+}
+
+// KVExpireRequired updates the expiration of key in the active KV store if KV mode is enabled.
+func KVExpireRequired(ctx context.Context, key string, ttl time.Duration) (bool, error) {
+ client, kvMode, errClient := CurrentKVClient()
+ if !kvMode || errClient != nil {
+ return kvMode, errClient
+ }
+ _, errExpire := client.KVExpire(ctx, key, ttl)
+ return true, errExpire
+}
+
+// KVGetJSONBestEffort fetches a JSON value from the active KV store, logging and swallowing errors.
+func KVGetJSONBestEffort(ctx context.Context, key string, out any) (bool, bool) {
+ kvMode, found, errGet := KVGetJSONRequired(ctx, key, out)
+ if errGet != nil {
+ log.Errorf("home kv best-effort get failed prefix=%s: %v", kvLogPrefix(key), errGet)
+ return kvMode, false
+ }
+ return kvMode, found
+}
+
+// KVSetJSONBestEffort marshals and stores value in the active KV store, logging and swallowing errors.
+func KVSetJSONBestEffort(ctx context.Context, key string, value any, ttl time.Duration) bool {
+ raw, errMarshal := json.Marshal(value)
+ if errMarshal != nil {
+ log.Errorf("home kv best-effort set failed prefix=%s: %v", kvLogPrefix(key), errMarshal)
+ return false
+ }
+ return KVSetBytesBestEffort(ctx, key, raw, ttl)
+}
+
+// KVSetBytesBestEffort stores raw bytes in the active KV store, logging and swallowing errors.
+func KVSetBytesBestEffort(ctx context.Context, key string, value []byte, ttl time.Duration) bool {
+ kvMode, errSet := KVSetBytesRequired(ctx, key, value, ttl)
+ if !kvMode {
+ return false
+ }
+ if errSet != nil {
+ log.Errorf("home kv best-effort set failed prefix=%s: %v", kvLogPrefix(key), errSet)
+ return false
+ }
+ return true
+}
+
+// KVSetNXBestEffort conditionally stores value if key does not exist, logging and swallowing errors.
+func KVSetNXBestEffort(ctx context.Context, key string, value []byte, ttl time.Duration) bool {
+ kvMode, written, errSet := KVSetNXRequired(ctx, key, value, ttl)
+ if !kvMode {
+ return false
+ }
+ if errSet != nil {
+ log.Errorf("home kv best-effort setnx failed prefix=%s: %v", kvLogPrefix(key), errSet)
+ return false
+ }
+ return written
+}
+
+// KVDelBestEffort deletes keys from the active KV store, logging and swallowing errors.
+func KVDelBestEffort(ctx context.Context, keys ...string) bool {
+ kvMode, _, errDel := KVDelRequired(ctx, keys...)
+ if !kvMode {
+ return false
+ }
+ if errDel != nil {
+ log.Errorf("home kv best-effort del failed prefix=%s: %v", kvLogPrefix(firstKVKey(keys)), errDel)
+ return false
+ }
+ return true
+}
+
+// KVExpireBestEffort updates key expiration in the active KV store, logging and swallowing errors.
+func KVExpireBestEffort(ctx context.Context, key string, ttl time.Duration) bool {
+ kvMode, errExpire := KVExpireRequired(ctx, key, ttl)
+ if !kvMode {
+ return false
+ }
+ if errExpire != nil {
+ log.Errorf("home kv best-effort expire failed prefix=%s: %v", kvLogPrefix(key), errExpire)
+ return false
+ }
+ return true
+}
+
+func kvSetOptionsForTTL(ttl time.Duration) KVSetOptions {
+ if ttl <= 0 {
+ return KVSetOptions{}
+ }
+ return KVSetOptions{EX: ttl}
+}
+
+func kvLogPrefix(key string) string {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return "unknown"
+ }
+ parts := strings.Split(key, ":")
+ if len(parts) >= 2 {
+ return parts[0] + ":" + parts[1] + ":*"
+ }
+ return parts[0] + ":*"
+}
+
+func firstKVKey(keys []string) string {
+ if len(keys) == 0 {
+ return ""
+ }
+ return keys[0]
+}
diff --git a/internal/kvstore/path.go b/internal/kvstore/path.go
new file mode 100644
index 00000000000..53362e8d6ca
--- /dev/null
+++ b/internal/kvstore/path.go
@@ -0,0 +1,48 @@
+package kvstore
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+// ResolvePath normalizes the configured bbolt database file path into an absolute file path.
+// Empty values default to config.DefaultKVStorePath ("kv.db") in the current working directory.
+func ResolvePath(raw string) (string, error) {
+ resolved := strings.TrimSpace(raw)
+ if resolved == "" {
+ resolved = config.DefaultKVStorePath
+ }
+ if strings.HasPrefix(resolved, "~") {
+ homeDir, errUserHomeDir := os.UserHomeDir()
+ if errUserHomeDir != nil {
+ return "", fmt.Errorf("kvstore: resolve user home directory: %w", errUserHomeDir)
+ }
+ remainder := strings.TrimPrefix(resolved, "~")
+ remainder = strings.TrimLeft(remainder, "/\\")
+ if remainder == "" {
+ resolved = filepath.Clean(homeDir)
+ } else {
+ normalized := strings.ReplaceAll(remainder, "\\", "/")
+ resolved = filepath.Join(homeDir, filepath.FromSlash(normalized))
+ }
+ }
+ resolved = filepath.Clean(resolved)
+ if !filepath.IsAbs(resolved) {
+ absPath, errAbs := filepath.Abs(resolved)
+ if errAbs != nil {
+ return "", fmt.Errorf("kvstore: resolve absolute path: %w", errAbs)
+ }
+ resolved = filepath.Clean(absPath)
+ }
+ return resolved, nil
+}
+
+// ResolvePathWithAuthDir resolves rawPath when non-empty, or falls back to
+// "/kv.db" (using config.DefaultAuthDir when authDir is empty).
+func ResolvePathWithAuthDir(rawPath, authDir string) (string, error) {
+ return ResolvePath((config.KVStoreConfig{Path: rawPath}).EffectivePathForAuthDir(authDir))
+}
diff --git a/internal/kvstore/types.go b/internal/kvstore/types.go
new file mode 100644
index 00000000000..466b292a75f
--- /dev/null
+++ b/internal/kvstore/types.go
@@ -0,0 +1,81 @@
+// Package kvstore provides a unified key-value storage abstraction and a local
+// bbolt-backed implementation with binary TTL envelopes.
+package kvstore
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "time"
+)
+
+// ErrCompareAndSwapUnsupported reports that the backing KV store does not support CAS.
+var ErrCompareAndSwapUnsupported = errors.New("kvstore: compare-and-swap command is not supported")
+
+// KVSetOptions configures optional expiration and conditional write behavior for KVSet.
+type KVSetOptions struct {
+ EX time.Duration
+ PX time.Duration
+ NX bool
+ XX bool
+}
+
+// KVClient defines the key-value storage contract shared by Home KV and local BoltStore.
+type KVClient interface {
+ KVGet(ctx context.Context, key string) ([]byte, bool, error)
+ KVSet(ctx context.Context, key string, value []byte, opts KVSetOptions) (bool, error)
+ KVSetNX(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)
+ KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error)
+ KVDel(ctx context.Context, keys ...string) (int64, error)
+ KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error)
+ KVTTL(ctx context.Context, key string) (time.Duration, bool, error)
+ KVIncrBy(ctx context.Context, key string, delta int64) (int64, error)
+ KVMGet(ctx context.Context, keys ...string) ([][]byte, []bool, error)
+ KVMSet(ctx context.Context, pairs map[string][]byte) error
+}
+
+// BuildSetArgs validates KVSet arguments and builds the canonical Redis-style argument list.
+func BuildSetArgs(key string, value []byte, opts KVSetOptions) ([]any, error) {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return nil, fmt.Errorf("home kv: key is empty")
+ }
+ if opts.EX > 0 && opts.PX > 0 {
+ return nil, fmt.Errorf("home kv: EX and PX are mutually exclusive")
+ }
+ if opts.EX < 0 || opts.PX < 0 {
+ return nil, fmt.Errorf("home kv: ttl must not be negative")
+ }
+ if opts.NX && opts.XX {
+ return nil, fmt.Errorf("home kv: NX and XX are mutually exclusive")
+ }
+
+ args := []any{key, append([]byte(nil), value...)}
+ if opts.EX > 0 {
+ args = append(args, "EX", DurationCeil(opts.EX, time.Second))
+ }
+ if opts.PX > 0 {
+ args = append(args, "PX", DurationCeil(opts.PX, time.Millisecond))
+ }
+ if opts.NX {
+ args = append(args, "NX")
+ }
+ if opts.XX {
+ args = append(args, "XX")
+ }
+ return args, nil
+}
+
+// DurationCeil divides value by unit, rounding positive remainders up to the next integer unit.
+func DurationCeil(value time.Duration, unit time.Duration) int64 {
+ if value <= 0 || unit <= 0 {
+ return 0
+ }
+ q := int64(value / unit)
+ if value%unit != 0 && q < math.MaxInt64 {
+ q++
+ }
+ return q
+}
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index 8f06618cbad..550a0ae26fc 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -118,6 +118,9 @@ type StreamingLogWriter interface {
// - error: An error if writing fails, nil otherwise
WriteAPIWebsocketTimeline(apiWebsocketTimeline []byte) error
+ // WriteAPIResponseErrors writes upstream API error entries to the log.
+ WriteAPIResponseErrors(errors []*interfaces.ErrorMessage) error
+
// SetFirstChunkTimestamp sets the TTFB timestamp captured when first chunk was received.
//
// Parameters:
diff --git a/internal/logging/request_logger_home.go b/internal/logging/request_logger_home.go
index c29865ea6ff..7aa6c0207ce 100644
--- a/internal/logging/request_logger_home.go
+++ b/internal/logging/request_logger_home.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
)
type homeRequestLogClient interface {
@@ -91,16 +92,17 @@ type homeStreamingLogWriter struct {
chunkChan chan []byte
doneChan chan struct{}
- responseStatus int
- statusWritten bool
- responseHeaders map[string][]string
- responseBody bytes.Buffer
- apiRequest []byte
- apiResponse []byte
- apiWebsocketTime []byte
- requestID string
- apiResponseTS time.Time
- firstChunkTS time.Time
+ responseStatus int
+ statusWritten bool
+ responseHeaders map[string][]string
+ responseBody bytes.Buffer
+ apiRequest []byte
+ apiResponse []byte
+ apiWebsocketTime []byte
+ apiResponseErrors []*interfaces.ErrorMessage
+ requestID string
+ apiResponseTS time.Time
+ firstChunkTS time.Time
}
func newHomeStreamingLogWriter(url, method string, headers map[string][]string, body []byte, requestID string) *homeStreamingLogWriter {
@@ -187,6 +189,14 @@ func (w *homeStreamingLogWriter) WriteAPIWebsocketTimeline(apiWebsocketTimeline
return nil
}
+func (w *homeStreamingLogWriter) WriteAPIResponseErrors(errors []*interfaces.ErrorMessage) error {
+ if w == nil || len(errors) == 0 {
+ return nil
+ }
+ w.apiResponseErrors = append([]*interfaces.ErrorMessage(nil), errors...)
+ return nil
+}
+
func (w *homeStreamingLogWriter) SetFirstChunkTimestamp(timestamp time.Time) {
if w == nil {
return
@@ -226,6 +236,9 @@ func (w *homeStreamingLogWriter) Close() error {
if errWrite := writeAPISection(&buf, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest, time.Time{}); errWrite != nil {
return errWrite
}
+ if errWrite := writeAPIErrorResponses(&buf, w.apiResponseErrors); errWrite != nil {
+ return errWrite
+ }
if errWrite := writeAPISection(&buf, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse, w.apiResponseTS); errWrite != nil {
return errWrite
}
diff --git a/internal/logging/request_logger_home_test.go b/internal/logging/request_logger_home_test.go
index a08e2a6ca8e..9ed4c294160 100644
--- a/internal/logging/request_logger_home_test.go
+++ b/internal/logging/request_logger_home_test.go
@@ -4,12 +4,15 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
)
type stubHomeRequestLogClient struct {
@@ -349,6 +352,70 @@ func TestFileRequestLogger_HomeEnabled_ForwardsStreamingRequestID(t *testing.T)
}
}
+func TestFileRequestLogger_HomeEnabled_ForwardsStreamingAPIErrorResponses(t *testing.T) {
+ original := currentHomeRequestLogClient
+ defer func() {
+ currentHomeRequestLogClient = original
+ }()
+
+ stub := &stubHomeRequestLogClient{heartbeatOK: true}
+ currentHomeRequestLogClient = func() homeRequestLogClient {
+ return stub
+ }
+
+ logsDir := t.TempDir()
+ logger := NewFileRequestLogger(true, logsDir, "", 0)
+ logger.SetHomeEnabled(true)
+
+ writer, errLog := logger.LogStreamingRequest(
+ "/v1/messages",
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ []byte(`{"messages":[{"role":"user","content":"test"}]}`),
+ "stream-req-err-1",
+ )
+ if errLog != nil {
+ t.Fatalf("LogStreamingRequest error: %v", errLog)
+ }
+
+ if errStatus := writer.WriteStatus(http.StatusOK, map[string][]string{"Content-Type": {"text/event-stream"}}); errStatus != nil {
+ t.Fatalf("WriteStatus(200) error: %v", errStatus)
+ }
+ writer.WriteChunkAsync([]byte("event: message_delta\ndata: {\"delta\":{\"stop_reason\":\"refusal\"}}\n\n"))
+ if errStatus := writer.WriteStatus(http.StatusBadRequest, nil); errStatus != nil {
+ t.Fatalf("WriteStatus(400) error: %v", errStatus)
+ }
+ if errAPI := writer.WriteAPIResponseErrors([]*interfaces.ErrorMessage{
+ {
+ StatusCode: http.StatusBadRequest,
+ Error: errors.New("claude refusal (cyber): blocked by policy"),
+ },
+ }); errAPI != nil {
+ t.Fatalf("WriteAPIResponseErrors error: %v", errAPI)
+ }
+ if errClose := writer.Close(); errClose != nil {
+ t.Fatalf("Close error: %v", errClose)
+ }
+
+ if len(stub.pushed) != 1 {
+ t.Fatalf("home pushed records = %d, want 1", len(stub.pushed))
+ }
+
+ var got homeRequestLogPayload
+ if errUnmarshal := json.Unmarshal(stub.pushed[0], &got); errUnmarshal != nil {
+ t.Fatalf("unmarshal payload: %v", errUnmarshal)
+ }
+ if !strings.Contains(got.RequestLog, "=== API ERROR RESPONSE ===") {
+ t.Fatalf("forwarded request_log missing API ERROR RESPONSE section: %s", got.RequestLog)
+ }
+ if !strings.Contains(got.RequestLog, "claude refusal (cyber): blocked by policy") {
+ t.Fatalf("forwarded request_log missing classified refusal message: %s", got.RequestLog)
+ }
+ if !strings.Contains(got.RequestLog, "Status: 400") {
+ t.Fatalf("forwarded request_log missing overridden Status: 400: %s", got.RequestLog)
+ }
+}
+
func TestFileRequestLogger_HomeEnabled_DoesNotForwardForcedErrorLogsWhenRequestLogDisabled(t *testing.T) {
original := currentHomeRequestLogClient
defer func() {
diff --git a/internal/logging/request_logger_streaming.go b/internal/logging/request_logger_streaming.go
index 089843b2689..7c3505d4d60 100644
--- a/internal/logging/request_logger_streaming.go
+++ b/internal/logging/request_logger_streaming.go
@@ -3,9 +3,12 @@ package logging
import (
"bytes"
"fmt"
+ "net/http"
"os"
+ "strings"
"time"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
log "github.com/sirupsen/logrus"
)
@@ -73,6 +76,9 @@ type FileStreamingLogWriter struct {
// apiWebsocketTimeline stores the upstream websocket event timeline.
apiWebsocketTimeline []byte
+ // apiResponseErrors stores upstream API response error entries.
+ apiResponseErrors []*interfaces.ErrorMessage
+
// apiResponseTimestamp captures when the API response was received.
apiResponseTimestamp time.Time
}
@@ -187,6 +193,15 @@ func (w *FileStreamingLogWriter) WriteAPIWebsocketTimeline(apiWebsocketTimeline
return nil
}
+// WriteAPIResponseErrors buffers upstream API error entries for final writing.
+func (w *FileStreamingLogWriter) WriteAPIResponseErrors(errors []*interfaces.ErrorMessage) error {
+ if len(errors) == 0 {
+ return nil
+ }
+ w.apiResponseErrors = append([]*interfaces.ErrorMessage(nil), errors...)
+ return nil
+}
+
func (w *FileStreamingLogWriter) SetFirstChunkTimestamp(timestamp time.Time) {
if !timestamp.IsZero() {
w.apiResponseTimestamp = timestamp
@@ -221,6 +236,9 @@ func (w *FileStreamingLogWriter) Close() error {
w.cleanupTempFiles()
return nil
}
+ if w.responseStatus >= http.StatusBadRequest && w.responseStatus != 499 && !strings.HasPrefix(w.logFilename, "error-") {
+ w.logFilename = "error-" + w.logFilename
+ }
logFile, _, errOpen := createUniqueLogFile(w.logsDir, w.logFilename)
if errOpen != nil {
@@ -286,6 +304,9 @@ func (w *FileStreamingLogWriter) writeFinalLog(logFile *os.File) error {
if errWrite := writePreformattedAPISectionWithSource(logFile, "=== API REQUEST ===\n", "=== API REQUEST", w.apiRequest, w.apiRequestSource, time.Time{}); errWrite != nil {
return errWrite
}
+ if errWrite := writeAPIErrorResponses(logFile, w.apiResponseErrors); errWrite != nil {
+ return errWrite
+ }
if errWrite := writePreformattedAPISectionWithSource(logFile, "=== API RESPONSE ===\n", "=== API RESPONSE", w.apiResponse, w.apiResponseSource, w.apiResponseTimestamp); errWrite != nil {
return errWrite
}
@@ -374,6 +395,10 @@ func (w *NoOpStreamingLogWriter) WriteAPIWebsocketTimeline(_ []byte) error {
return nil
}
+func (w *NoOpStreamingLogWriter) WriteAPIResponseErrors(_ []*interfaces.ErrorMessage) error {
+ return nil
+}
+
func (w *NoOpStreamingLogWriter) SetFirstChunkTimestamp(_ time.Time) {}
// Close is a no-op implementation that does nothing and always returns nil.
diff --git a/internal/logging/request_logger_writer.go b/internal/logging/request_logger_writer.go
index b2bb16ddfb5..8d6c446d4c9 100644
--- a/internal/logging/request_logger_writer.go
+++ b/internal/logging/request_logger_writer.go
@@ -111,7 +111,7 @@ func (l *FileRequestLogger) logRequestWithSources(url, method string, requestHea
// Generate filename with request ID
filename := l.generateFilename(url, requestID)
- if force && !l.enabled {
+ if (force && !l.enabled) || (statusCode >= 400 && statusCode != 499) {
filename = l.generateErrorFilename(url, requestID)
}
diff --git a/internal/misc/antigravity_version.go b/internal/misc/antigravity_version.go
index 1e77c2d57a5..c417fca9a4f 100644
--- a/internal/misc/antigravity_version.go
+++ b/internal/misc/antigravity_version.go
@@ -20,7 +20,7 @@ const (
// manifest has not been fetched yet or cannot be reached. Cloud Code rejects
// newer models for clients below 2.23.0, so this floor must stay at or above
// that version.
- antigravityFallbackVersion = "2.24.0"
+ antigravityFallbackVersion = "2.29.0"
antigravityHubPlatform = "os_type=darwin; arch=arm64"
antigravityVersionCacheTTL = 6 * time.Hour
antigravityFetchTimeout = 10 * time.Second
@@ -39,6 +39,7 @@ var (
var antigravityHubChangelists = map[string]string{
"2.23.0": "998057262",
"2.24.0": "1000335682",
+ "2.29.0": "1013060670",
}
type antigravityHubUpdaterManifest struct {
diff --git a/internal/misc/antigravity_version_test.go b/internal/misc/antigravity_version_test.go
index fc0d8847351..4abd2fe10e6 100644
--- a/internal/misc/antigravity_version_test.go
+++ b/internal/misc/antigravity_version_test.go
@@ -69,7 +69,8 @@ func TestAntigravityUserAgentUsesHubFamily(t *testing.T) {
}{
{"2.23.0", "antigravity/hub/2.23.0 (aidev_client; os_type=darwin; arch=arm64; cl=998057262)"},
{"2.24.0", "antigravity/hub/2.24.0 (aidev_client; os_type=darwin; arch=arm64; cl=1000335682)"},
- {"2.25.0", "antigravity/hub/2.25.0 (aidev_client; os_type=darwin; arch=arm64)"},
+ {"2.29.0", "antigravity/hub/2.29.0 (aidev_client; os_type=darwin; arch=arm64; cl=1013060670)"},
+ {"2.30.0", "antigravity/hub/2.30.0 (aidev_client; os_type=darwin; arch=arm64)"},
} {
t.Run(tc.version, func(t *testing.T) {
restore := overrideAntigravityVersionCacheForTest(t, tc.version, time.Now().Add(time.Hour))
diff --git a/internal/modelconfig/model_info_test.go b/internal/modelconfig/model_info_test.go
index 72ce739e82a..8d9e9e41387 100644
--- a/internal/modelconfig/model_info_test.go
+++ b/internal/modelconfig/model_info_test.go
@@ -3,10 +3,10 @@ package modelconfig
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
- "github.com/tidwall/gjson"
)
func TestResolveModelInfoUsesSuffixFreeStaticCapabilities(t *testing.T) {
@@ -70,7 +70,7 @@ func TestResolveModelInfoConfiguredThinkingKeepsCatalogDisableType(t *testing.T)
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo: %v", err)
}
- if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
+ if got := ojson.GetBytesNoCopy(out, "thinking").Raw; got != `{"type":"between_tools"}` {
t.Fatalf("thinking = %s, want {\"type\":\"between_tools\"}; body=%s", got, out)
}
diff --git a/internal/pluginhost/adapters.go b/internal/pluginhost/adapters.go
index 164a3b48322..eec740d2321 100644
--- a/internal/pluginhost/adapters.go
+++ b/internal/pluginhost/adapters.go
@@ -363,9 +363,16 @@ func (h *Host) authModelProviderMatches(record capabilityRecord, provider string
if caps.ModelProvider == nil || h.isPluginFused(record.id) || !executorScopeAllowsOAuthModels(caps) {
return false
}
- if caps.AuthProvider != nil {
- identifier, ok := h.callAuthProviderIdentifier(record.id, caps.AuthProvider)
- return ok && normalizeProviderID(identifier) == provider
+ if hasRecordAuthProvider(record) {
+ identifiers, ok := h.callRecordAuthProviderIdentifiers(record)
+ if !ok {
+ return false
+ }
+ for _, identifier := range identifiers {
+ if normalizeProviderID(identifier) == provider {
+ return true
+ }
+ }
}
candidates := h.modelProvidersForPlugin(record.id)
if len(candidates) > 0 {
diff --git a/internal/pluginhost/adapters_disable_type_test.go b/internal/pluginhost/adapters_disable_type_test.go
index 710eb64d760..cfc6cd1892a 100644
--- a/internal/pluginhost/adapters_disable_type_test.go
+++ b/internal/pluginhost/adapters_disable_type_test.go
@@ -4,11 +4,11 @@ import (
"encoding/json"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
)
func TestPluginThinkingSupportCarriesDisableTypeBothWays(t *testing.T) {
@@ -50,7 +50,7 @@ func TestPluginRegisteredSonnet55TurnsThinkingOffWithBetweenTools(t *testing.T)
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
+ if got := ojson.GetBytesNoCopy(out, "thinking").Raw; got != `{"type":"between_tools"}` {
t.Fatalf("thinking = %s, want between_tools; body=%s", got, out)
}
}
diff --git a/internal/pluginhost/adapters_executors.go b/internal/pluginhost/adapters_executors.go
index 593a0840f5e..e0419bb37f6 100644
--- a/internal/pluginhost/adapters_executors.go
+++ b/internal/pluginhost/adapters_executors.go
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
@@ -19,7 +20,6 @@ import (
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type executorManager interface {
@@ -139,6 +139,24 @@ func (h *Host) RegisterExecutors(manager executorManager, modelRegistry modelPro
})
nextModelClients[clientID] = struct{}{}
}
+ if executorScopeAllowsOAuthModels(record.plugin.Capabilities) && hasRecordAuthProvider(record) {
+ if authIDs, okAuthIDs := h.callRecordAuthProviderIdentifiers(record); okAuthIDs {
+ for _, authID := range authIDs {
+ if authID == "" || authID == provider {
+ continue
+ }
+ if _, seenAuthProvider := seenProviders[authID]; seenAuthProvider {
+ continue
+ }
+ seenProviders[authID] = struct{}{}
+ if h.providerHasNativeExecutor(manager, authID) {
+ continue
+ }
+ nextProviders[authID] = struct{}{}
+ executorRegistrations = append(executorRegistrations, newExecutorAdapterRegistration(h, record, authID, executor))
+ }
+ }
+ }
}
h.commitExecutorState(snap, manager, modelRegistry, providerModels, executorRegistrations, nextProviders, modelClientRegistrations, nextModelClients)
}
@@ -404,6 +422,15 @@ func (h *Host) HasExecutorCandidateProvider(provider string) bool {
if okCandidate && candidate == provider {
return true
}
+ if executorScopeAllowsOAuthModels(record.plugin.Capabilities) && hasRecordAuthProvider(record) {
+ if authIDs, okAuthIDs := h.callRecordAuthProviderIdentifiers(record); okAuthIDs {
+ for _, authID := range authIDs {
+ if authID == provider {
+ return true
+ }
+ }
+ }
+ }
}
return false
}
@@ -526,13 +553,14 @@ func (a *executorAdapter) prepareExecutorCall(req coreexecutor.Request, opts cor
hasPluginThinkingTarget := modelInfo != nil ||
registry.LookupModelInfo(baseModel, a.provider) != nil ||
thinking.GetProviderApplier(a.provider) != nil
- if hasPluginThinkingTarget && baseModel != "" && gjson.GetBytes(nativeReq.Payload, "model").Exists() {
- nativeReq.Payload = helps.SetStringIfDifferent(nativeReq.Payload, "model", baseModel)
- }
originalTranslated := nativeReq.Payload
- if len(nativeReq.Payload) > 0 {
- body, errThinking := helps.ApplyRequestThinkingWithModelInfo(
- nativeReq.Payload,
+ doc := ojson.NewDoc(nativeReq.Payload)
+ if hasPluginThinkingTarget && baseModel != "" && doc.Get("model").Exists() {
+ _ = helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ }
+ if doc.Len() > 0 {
+ if errThinking := helps.ApplyRequestThinkingWithModelInfoDoc(
+ doc,
req,
opts,
inputRequested.String(),
@@ -540,11 +568,9 @@ func (a *executorAdapter) prepareExecutorCall(req coreexecutor.Request, opts cor
a.provider,
modelInfo,
updatesChanged,
- )
- if errThinking != nil {
+ ); errThinking != nil {
return preparedExecutorCall{}, errThinking
}
- nativeReq.Payload = body
}
// User payload rules are the last host-side mutation before the plugin sees
// the request, matching on the suffix-free model like the built-in executors
@@ -563,9 +589,12 @@ func (a *executorAdapter) prepareExecutorCall(req coreexecutor.Request, opts cor
root = "request"
}
finalize := helps.NewPayloadFinalizer(cfg, a.provider, baseModel, inputFormat.String(), root, originalTranslated, req, finalizeOpts)
- nativeReq.Payload = finalize(nativeReq.Payload)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(context.Background(), finalize), doc)
}
}
+ if doc.Len() > 0 {
+ nativeReq.Payload = doc.Bytes()
+ }
if hasPluginThinkingTarget {
nativeReq.Model = baseModel
}
@@ -686,6 +715,27 @@ func (h *Host) hasResponseTranslator() bool {
return false
}
+// hasResponseTranslationHooks reports whether any active plugin can rewrite a
+// streaming response through the translator registry hooks (before/after
+// normalizers or a response translator). When none exists, a same-format
+// stream without a registered native translator is forwarded unchanged instead
+// of entering TranslateStream only to fall back to the original payload.
+func (h *Host) hasResponseTranslationHooks() bool {
+ if h == nil {
+ return false
+ }
+ for _, record := range h.activeRecords() {
+ if h.isPluginFused(record.id) {
+ continue
+ }
+ caps := record.plugin.Capabilities
+ if caps.ResponseTranslator != nil || caps.ResponseBeforeTranslator != nil || caps.ResponseAfterTranslator != nil {
+ return true
+ }
+ }
+ return false
+}
+
func executorNativeStreamResponseTranslatorExists(from, to sdktranslator.Format) bool {
if from == "" || to == "" || from == to {
return true
@@ -723,15 +773,40 @@ func (a *executorAdapter) translateExecutorResponse(ctx context.Context, prepare
}
func (a *executorAdapter) translateExecutorStreamChunks(ctx context.Context, prepared preparedExecutorCall, in <-chan pluginapi.ExecutorStreamChunk) <-chan pluginapi.ExecutorStreamChunk {
- if prepared.requestedFormat == "" || (prepared.outputFormat == prepared.requestedFormat && prepared.requestedFormat != sdktranslator.FormatOpenAIResponse) {
+ // Same-format streams still go through stream translation when a registered
+ // same-format translator exists: it normalizes SSE framing (for example
+ // stripping the "data:" prefix and dropping "[DONE]" for chat-completions),
+ // matching what the built-in executors produce. Returning the channel
+ // unchanged for those formats let framed plugin chunks reach handlers that
+ // add their own "data:" prefix, emitting a doubled "data: data:" prefix.
+ // That case first passes through an SSE deframer so input may be split or
+ // coalesced at arbitrary byte boundaries; see deframeExecutorSSEChunks.
+ //
+ // Other same-format streams must not enter TranslateStream unconditionally:
+ // its plugin hooks (response before/after normalizers and, without a native
+ // translator, the plugin response translator) run even when no native
+ // translator exists, so the payload would no longer be forwarded verbatim.
+ // When no such hook capability is installed, those streams keep the
+ // previous untouched behavior; when one is installed, or for every
+ // cross-format stream, translation runs as before.
+ if prepared.requestedFormat == "" {
return in
}
if in == nil {
return nil
}
+ sameFormat := prepared.outputFormat == prepared.requestedFormat
+ // Responses always enters translation so its usage-details normalization
+ // in translateExecutorStreamPayload keeps running for same-format streams.
+ if sameFormat && prepared.requestedFormat != sdktranslator.FormatOpenAIResponse && !sdktranslator.HasStreamResponseTransformer(prepared.outputFormat, prepared.requestedFormat) && !a.host.hasResponseTranslationHooks() {
+ return in
+ }
if ctx == nil {
ctx = context.Background()
}
+ if sameFormat && sdktranslator.HasStreamResponseTransformer(prepared.outputFormat, prepared.requestedFormat) {
+ in = deframeExecutorSSEChunks(ctx, in)
+ }
out := make(chan pluginapi.ExecutorStreamChunk)
go func() {
defer close(out)
@@ -762,13 +837,6 @@ func (a *executorAdapter) translateExecutorStreamChunks(ctx context.Context, pre
}
func (a *executorAdapter) translateExecutorStreamPayload(ctx context.Context, prepared preparedExecutorCall, payload []byte, param *any) [][]byte {
- if prepared.requestedFormat != "" && prepared.outputFormat == prepared.requestedFormat {
- out := payload
- if prepared.requestedFormat == sdktranslator.FormatOpenAIResponse {
- out = helps.EnsureResponsesUsageDetails(out)
- }
- return [][]byte{out}
- }
originalRequest := prepared.opts.OriginalRequest
if len(originalRequest) == 0 {
originalRequest = prepared.req.Payload
@@ -1049,8 +1117,8 @@ func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (ref
if a == nil || a.executor == nil || a.host.isPluginFused(a.pluginID) || !a.host.pluginIdentityCurrent(a.pluginID, a.path, a.version) {
return nil, fmt.Errorf("plugin executor %s is unavailable", a.Identifier())
}
- record := a.host.authProviderRecord(authProvider(auth))
- if record == nil || record.plugin.Capabilities.AuthProvider == nil {
+ record, authProv := a.host.authProviderCredentialEntryForAuth(auth)
+ if record == nil || authProv == nil {
return auth.Clone(), nil
}
defer func() {
@@ -1061,7 +1129,7 @@ func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (ref
}
}()
- pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
+ pluginResp, errRefresh := authProv.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
StorageJSON: storageJSONFromAuth(auth),
@@ -1108,7 +1176,9 @@ func (a *executorAdapter) Refresh(ctx context.Context, auth *coreauth.Auth) (ref
}
data.Attributes = attributes
}
+ preserveAuthOAuthProviderMetadata(&data, auth)
preserveFileAuthPriority(&data, auth)
+ preserveAuthDisabledState(&data, auth)
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
@@ -1178,7 +1248,7 @@ func (a *executorAdapter) CountTokens(ctx context.Context, auth *coreauth.Auth,
func pluginExecutorTokenCount(format sdktranslator.Format, payload []byte) (int64, bool) {
for _, path := range []string{"choices", "content", "candidates", "output", "response.output"} {
- if gjson.GetBytes(payload, path).Exists() {
+ if ojson.GetBytesNoCopy(payload, path).Exists() {
return 0, false
}
}
@@ -1188,8 +1258,8 @@ func pluginExecutorTokenCount(format sdktranslator.Format, payload []byte) (int6
paths = []string{"totalTokens", "usageMetadata.promptTokenCount", "input_tokens", "total_tokens"}
}
for _, path := range paths {
- value := gjson.GetBytes(payload, path)
- if value.Exists() && value.Type == gjson.Number {
+ value := ojson.GetBytesNoCopy(payload, path)
+ if value.Exists() && value.Type == ojson.Number {
count, errParse := strconv.ParseInt(value.Raw, 10, 64)
if errParse == nil && count >= 0 {
return count, true
diff --git a/internal/pluginhost/adapters_executors_deframe.go b/internal/pluginhost/adapters_executors_deframe.go
new file mode 100644
index 00000000000..08e8debd6bb
--- /dev/null
+++ b/internal/pluginhost/adapters_executors_deframe.go
@@ -0,0 +1,302 @@
+package pluginhost
+
+import (
+ "bytes"
+ "context"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+)
+
+// This file normalizes plugin executor SSE streams before stream translation.
+//
+// Same-format stream translators assume each incoming payload is exactly one
+// complete event. A passthrough plugin relaying raw host HTTP stream bytes
+// does not guarantee that: chunks may hold several coalesced events or split
+// one event at an arbitrary byte. deframeExecutorSSEChunks rebuilds event
+// boundaries so the translators always receive exactly one event at a time.
+//
+// Payloads that are not SSE at all (bare JSON objects, the stock example's
+// unterminated "plugin-example" text) are never parsed: they are forwarded
+// with their original chunk boundary intact, so consecutive bare objects
+// without line breaks are not concatenated, and multi-line bare JSON is not
+// split per line.
+
+// executorSSEDeframer accumulates incoming plugin stream chunks only while
+// they can still belong to an incomplete SSE event. Once an event boundary
+// (a blank line) is reached the event is emitted whole. Chunk bytes that can
+// no longer belong to an SSE event are released verbatim with their chunk
+// boundary intact instead of being buffered or split. A partial prefix such
+// as "da" is buffered because later bytes may complete a "data:" line.
+//
+// The deframer also remembers whether the stream has ever produced an SSE
+// event (sawSSE). Established SSE streams treat blank lines between events
+// as framing even when a blank line arrives in a later chunk, and bare
+// lines are emitted one line at a time so a following SSE event can still
+// be recognized. Streams that have never produced an SSE event stay in
+// bare mode, where payloads are forwarded with their chunk boundaries
+// intact and never split or merged.
+type executorSSEDeframer struct {
+ buffer []byte
+ sawSSE bool
+}
+
+func (d *executorSSEDeframer) feed(payload []byte) [][]byte {
+ if len(payload) == 0 {
+ return nil
+ }
+ // Fast path: nothing pending, no SSE event seen yet, and the chunk
+ // cannot begin an SSE event. Emit the chunk untouched so bare payloads
+ // keep their boundaries, including bare content that opens with a
+ // newline.
+ if len(d.buffer) == 0 && !d.sawSSE && !canBeginSSEEvent(payload) {
+ return [][]byte{payload}
+ }
+ d.buffer = append(d.buffer, payload...)
+
+ var chunks [][]byte
+ for {
+ if len(d.buffer) == 0 {
+ break
+ }
+ // Leading blank lines are the unmatched second half of an earlier
+ // event boundary and are dropped. In bare mode this only runs after
+ // an SSE event started in an earlier chunk (the fast path above
+ // keeps newline-led bare payloads untouched).
+ dropBlankSSELine := false
+ if idx := bytes.IndexByte(d.buffer, '\n'); idx >= 0 && isBlankSSELine(d.buffer[:idx]) {
+ d.advance(idx + 1)
+ dropBlankSSELine = true
+ }
+ if dropBlankSSELine {
+ continue
+ }
+ if len(d.buffer) == 0 {
+ break
+ }
+ if !sseDataLinePrefix(d.buffer) && !bytes.HasPrefix(d.buffer, []byte("event:")) && !bytes.HasPrefix(d.buffer, []byte(":")) {
+ // Leftover bytes do not begin a new SSE event. A partial
+ // "data:"/"event:" prefix stays buffered. In SSE mode only the
+ // first complete line is released so an SSE event that follows
+ // in a later chunk is still deframed; in bare mode the whole
+ // leftover is released so bare content keeps its chunk
+ // boundary (no line-based splitting or merging).
+ if isSSEFieldPrefixPartial(d.buffer) {
+ break
+ }
+ if d.sawSSE {
+ idx := bytes.IndexByte(d.buffer, '\n')
+ if idx < 0 {
+ break
+ }
+ chunks = append(chunks, bytes.Clone(d.buffer[:idx+1]))
+ d.advance(idx + 1)
+ continue
+ }
+ chunks = append(chunks, bytes.Clone(d.buffer))
+ d.buffer = nil
+ break
+ }
+ event, complete := d.takeEvent()
+ if !complete {
+ break
+ }
+ d.sawSSE = true
+ if len(event) > 0 {
+ chunks = append(chunks, event)
+ }
+ }
+ return chunks
+}
+
+// flush returns any buffered partial event at end of stream.
+func (d *executorSSEDeframer) flush() []byte {
+ pending := d.buffer
+ d.buffer = nil
+ // Trailing CR/LF bytes are line framing only: the partial content before
+ // them is what remains to be delivered, so a final "data: [DONE]\r" still
+ // reads as the terminal marker. Interior whitespace (including a
+ // leading newline) belongs to the payload and is preserved.
+ pending = bytes.TrimRight(pending, "\r\n")
+ if len(pending) == 0 {
+ return nil
+ }
+ return pending
+}
+
+// canBeginSSEEvent reports whether payload could start an SSE event: a data
+// line, an event line, a comment line, or a strict prefix of the "data:" or
+// "event:" field names that later bytes may complete. A leading newline
+// alone is not enough: bare JSON may open with a newline and must stay one
+// untouched payload.
+func canBeginSSEEvent(b []byte) bool {
+ if len(b) == 0 {
+ return false
+ }
+ switch b[0] {
+ case ':':
+ return true
+ case 'd':
+ return sseFieldPrefixCandidate(b, "data:")
+ case 'e':
+ return sseFieldPrefixCandidate(b, "event:")
+ }
+ return false
+}
+
+func sseFieldPrefixCandidate(b []byte, field string) bool {
+ if len(b) <= len(field) {
+ return bytes.HasPrefix([]byte(field), b)
+ }
+ return bytes.HasPrefix(b, []byte(field))
+}
+
+// isSSEFieldPrefixPartial reports whether b is a strict prefix of "data:" or
+// "event:": the field name may be completed by a later chunk, so these bytes
+// must stay buffered instead of being released.
+func isSSEFieldPrefixPartial(b []byte) bool {
+ if len(b) == 0 || len(b) >= len("event:") {
+ return false
+ }
+ return bytes.HasPrefix([]byte("data:"), b) || bytes.HasPrefix([]byte("event:"), b)
+}
+
+// sseDataLinePrefix reports whether b begins with an SSE data line. The
+// space after the colon is optional in SSE, so any payload bytes after
+// "data:" count as a data line.
+func sseDataLinePrefix(b []byte) bool {
+ return bytes.HasPrefix(b, []byte("data:"))
+}
+
+// takeEvent extracts the next complete SSE event when one is available.
+// Comment-only blocks are consumed but not returned: they are SSE
+// keep-alives with no data semantics, matching how built-in executors ignore
+// ":" lines instead of forwarding them as payloads.
+func (d *executorSSEDeframer) takeEvent() ([]byte, bool) {
+ if len(d.buffer) == 0 {
+ return nil, false
+ }
+ idx := indexSSEFrameBoundary(d.buffer)
+ if idx < 0 {
+ return nil, false
+ }
+ end := idx + 1
+ event := bytes.Clone(d.buffer[:end])
+ d.advance(end)
+ if isSSECommentOnlyEvent(event) {
+ return nil, true
+ }
+ return event, true
+}
+
+func (d *executorSSEDeframer) advance(n int) {
+ rest := d.buffer[n:]
+ if len(rest) == 0 {
+ d.buffer = nil
+ return
+ }
+ d.buffer = append(d.buffer[:0], rest...)
+}
+
+// deframeExecutorSSEChunks reassembles full SSE events from arbitrary chunk
+// boundaries and passes bare payloads through with their chunk boundaries
+// intact. It only runs for same-format streams with a registered native
+// stream translator.
+//
+// It never synthesizes a terminal marker: emitTranslatedExecutorStreamTail
+// in the translation goroutine owns that responsibility (it feeds one
+// [DONE] through TranslateStream at channel close for [DONE]-terminated
+// formats). Emitting a second synthetic marker here would run plugin
+// response hooks twice and could surface two terminal frames.
+func deframeExecutorSSEChunks(ctx context.Context, in <-chan pluginapi.ExecutorStreamChunk) <-chan pluginapi.ExecutorStreamChunk {
+ if in == nil {
+ return nil
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ out := make(chan pluginapi.ExecutorStreamChunk)
+ go func() {
+ defer close(out)
+ deframer := &executorSSEDeframer{}
+
+ emit := func(payloads [][]byte) bool {
+ for _, payload := range payloads {
+ if len(payload) == 0 {
+ continue
+ }
+ if !sendExecutorPluginStreamChunk(ctx, out, pluginapi.ExecutorStreamChunk{Payload: payload}) {
+ return false
+ }
+ }
+ return true
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case chunk, ok := <-in:
+ if !ok {
+ if tail := deframer.flush(); len(tail) > 0 {
+ _ = emit([][]byte{tail})
+ }
+ return
+ }
+ if chunk.Err != nil {
+ // Buffered partial events belong to the failed prefix;
+ // emit them before the error is forwarded.
+ if tail := deframer.flush(); len(tail) > 0 {
+ if !emit([][]byte{tail}) {
+ return
+ }
+ }
+ _ = sendExecutorPluginStreamChunk(ctx, out, chunk)
+ continue
+ }
+ if !emit(deframer.feed(chunk.Payload)) {
+ return
+ }
+ }
+ }
+ }()
+ return out
+}
+
+// indexSSEFrameBoundary scans complete lines and returns the index of the LF
+// ending the blank line that terminates an SSE event, or -1 while the event
+// is incomplete. Handling line by line keeps LF, CRLF, and mixed endings
+// correct, including a CRLF blank line split across chunk boundaries.
+func indexSSEFrameBoundary(b []byte) int {
+ pos := 0
+ for {
+ idx := bytes.IndexByte(b[pos:], '\n')
+ if idx < 0 {
+ return -1
+ }
+ lineEnd := pos + idx
+ if isBlankSSELine(b[pos:lineEnd]) {
+ return lineEnd
+ }
+ pos = lineEnd + 1
+ }
+}
+
+func isBlankSSELine(line []byte) bool {
+ line = bytes.TrimSuffix(line, []byte{'\r'})
+ return len(line) == 0
+}
+
+func isSSECommentOnlyEvent(event []byte) bool {
+ sawComment := false
+ for _, line := range bytes.Split(event, []byte("\n")) {
+ trimmed := bytes.TrimSpace(line)
+ if len(trimmed) == 0 {
+ continue
+ }
+ if !bytes.HasPrefix(trimmed, []byte(":")) {
+ return false
+ }
+ sawComment = true
+ }
+ return sawComment
+}
diff --git a/internal/pluginhost/adapters_executors_deframe_test.go b/internal/pluginhost/adapters_executors_deframe_test.go
new file mode 100644
index 00000000000..99eae0d2ba8
--- /dev/null
+++ b/internal/pluginhost/adapters_executors_deframe_test.go
@@ -0,0 +1,926 @@
+package pluginhost
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// This file covers same-format plugin executor stream normalization beyond
+// the single exact-frame case: arbitrary chunk boundaries, the same-format
+// passthrough fast path, response-hook interaction, and terminal seeding.
+
+// collectDeframerOutput feeds chunks (split at the given cut points when
+// non-empty) through a deframer and returns the emitted payloads plus the
+// final flush result.
+func collectDeframerOutput(t *testing.T, input string, cuts []int) ([]string, string) {
+ t.Helper()
+ deframer := &executorSSEDeframer{}
+ var got []string
+ prev := 0
+ boundaries := append(append([]int{}, cuts...), len(input))
+ for _, end := range boundaries {
+ if end < prev || end > len(input) {
+ t.Fatalf("invalid cut point %d for %q", end, input)
+ }
+ for _, chunk := range deframer.feed([]byte(input[prev:end])) {
+ got = append(got, string(chunk))
+ }
+ prev = end
+ }
+ return got, string(deframer.flush())
+}
+
+func assertStringsEqual(t *testing.T, label string, got, want []string) {
+ t.Helper()
+ if len(got) != len(want) {
+ t.Fatalf("%s: got %d chunks %q, want %d %q", label, len(got), got, len(want), want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("%s chunk %d = %q, want %q", label, i, got[i], want[i])
+ }
+ }
+}
+
+func TestExecutorSSEDeframerBoundaries(t *testing.T) {
+ frame1 := "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"
+ frame2 := "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"
+ frameDone := "data: [DONE]\n\n"
+ full := frame1 + frame2 + frameDone
+ wantEvents := []string{frame1, frame2, frameDone}
+
+ t.Run("whole chunk", func(t *testing.T) {
+ got, tail := collectDeframerOutput(t, full, nil)
+ assertStringsEqual(t, "whole", got, wantEvents)
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("arbitrary splits", func(t *testing.T) {
+ // Split inside the data prefix, mid-JSON, inside the blank-line
+ // boundary, and several bytes at a time.
+ var cuts []int
+ for i := 3; i < len(full); i += 7 {
+ cuts = append(cuts, i)
+ }
+ got, tail := collectDeframerOutput(t, full, cuts)
+ assertStringsEqual(t, "splits", got, wantEvents)
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("coalesced done in same chunk", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ got := deframer.feed([]byte(frame1 + frameDone))
+ want := []string{frame1, frameDone}
+ if len(got) != len(want) {
+ t.Fatalf("feed returned %d chunks %q, want %d %q", len(got), got, len(want), want)
+ }
+ for i := range want {
+ if string(got[i]) != want[i] {
+ t.Errorf("chunk %d = %q, want %q", i, got[i], want[i])
+ }
+ }
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("crlf split across chunks", func(t *testing.T) {
+ crlf := "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\r\n\r\n" + "data: [DONE]\r\n\r\n"
+ // Cut between the CR and LF of the blank line terminating event one.
+ cut := strings.Index(crlf, "}\r\n\r") + len("}\r\n\r")
+ got, tail := collectDeframerOutput(t, crlf, []int{cut})
+ want := []string{
+ "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\r\n\r\n",
+ "data: [DONE]\r\n\r\n",
+ }
+ assertStringsEqual(t, "crlf", got, want)
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("mixed line endings", func(t *testing.T) {
+ mixed := "data: {\"a\":1}\r\n\n" + "data: {\"b\":2}\n\r\n"
+ got, tail := collectDeframerOutput(t, mixed, nil)
+ want := []string{"data: {\"a\":1}\r\n\n", "data: {\"b\":2}\n\r\n"}
+ assertStringsEqual(t, "mixed", got, want)
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("event line framing", func(t *testing.T) {
+ input := "event: message\ndata: {\"a\":1}\n\nevent: stop\ndata: {\"b\":2}\n\n"
+ got, tail := collectDeframerOutput(t, input, nil)
+ want := []string{
+ "event: message\ndata: {\"a\":1}\n\n",
+ "event: stop\ndata: {\"b\":2}\n\n",
+ }
+ assertStringsEqual(t, "event", got, want)
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("comment only events dropped", func(t *testing.T) {
+ input := ": ping\n\n" + frame1 + ": ping\n: pong\n\n" + frameDone
+ got, tail := collectDeframerOutput(t, input, nil)
+ assertStringsEqual(t, "comments", got, []string{frame1, frameDone})
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("leading blank lines dropped after an sse start", func(t *testing.T) {
+ // Once a stream is in SSE mode (here: the first chunk begins an event
+ // that is still incomplete when the blanks arrive), unmatched blank
+ // lines are stale event boundaries and are dropped.
+ deframer := &executorSSEDeframer{}
+ if got := deframer.feed([]byte("data: {\"choices\":")); len(got) != 0 {
+ t.Fatalf("partial event emitted %q, want buffered", got)
+ }
+ var got []string
+ for _, chunk := range deframer.feed([]byte("[{\"delta\":{\"content\":\"Hello\"}}]}\n\n" + "\n\r\n" + frameDone + "\n")) {
+ got = append(got, string(chunk))
+ }
+ assertStringsEqual(t, "blanks", got, []string{frame1, frameDone})
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("leading newline bare payload stays whole", func(t *testing.T) {
+ // A leading newline alone does not make a chunk SSE: bare JSON that
+ // opens with a newline must stay one untouched payload, never split
+ // per line.
+ input := "\n{\n\"choices\":[]\n}\n"
+ got, tail := collectDeframerOutput(t, input, nil)
+ assertStringsEqual(t, "bare leading newline", got, []string{input})
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("blank line between events across chunks", func(t *testing.T) {
+ // A blank line that arrives in a later chunk, between two SSE
+ // events, is framing: the following event (itself split mid-JSON)
+ // must still be reassembled instead of leaking through with its
+ // SSE prefix intact.
+ deframer := &executorSSEDeframer{}
+ first := deframer.feed([]byte("data: {\"choices\":[]}\n\n"))
+ if len(first) != 1 || string(first[0]) != "data: {\"choices\":[]}\n\n" {
+ t.Fatalf("first feed = %q, want the first event", first)
+ }
+ if got := deframer.feed([]byte("\ndata:{\"choices\":")); len(got) != 0 {
+ t.Fatalf("inter-event blank + partial emitted %q, want buffered", got)
+ }
+ last := deframer.feed([]byte("[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"))
+ if len(last) != 1 || string(last[0]) != "data:{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" {
+ t.Fatalf("last feed = %q, want one reassembled event", last)
+ }
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("blank line between events across chunks crlf", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ _ = deframer.feed([]byte("data: {\"choices\":[]}\r\n\r\n"))
+ if got := deframer.feed([]byte("\r\ndata: {\"choices\":")); len(got) != 0 {
+ t.Fatalf("crlf inter-event blank + partial emitted %q, want buffered", got)
+ }
+ last := deframer.feed([]byte("[{\"delta\":{},\"finish_reason\":\"stop\"}]}\r\n\r\n"))
+ if len(last) != 1 || string(last[0]) != "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\r\n\r\n" {
+ t.Fatalf("last feed = %q, want one reassembled event", last)
+ }
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("bare line after sse event keeps next event framed", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ _ = deframer.feed([]byte("data: {\"choices\":[]}\n\n"))
+ var got []string
+ for _, chunk := range deframer.feed([]byte("{\"bare\":true}\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")) {
+ got = append(got, string(chunk))
+ }
+ assertStringsEqual(t, "sse-then-bare-then-sse", got, []string{
+ "{\"bare\":true}\n",
+ "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
+ })
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("data prefix without space is sse", func(t *testing.T) {
+ // The space after "data:" is optional. A no-space event split across
+ // chunks must be buffered and reassembled, not released early as a
+ // bare payload.
+ deframer := &executorSSEDeframer{}
+ if got := deframer.feed([]byte("data:{\"choices\":")); len(got) != 0 {
+ t.Fatalf("no-space partial event emitted %q, want buffered", got)
+ }
+ got := deframer.feed([]byte("[]}\n\n"))
+ if len(got) != 1 || string(got[0]) != "data:{\"choices\":[]}\n\n" {
+ t.Fatalf("feed = %q, want one reassembled event", got)
+ }
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("bare lines in one chunk stay one payload", func(t *testing.T) {
+ // Bare payloads keep their original chunk boundary: lines arriving in
+ // a single chunk are delivered as that chunk, not split per line.
+ input := "{\"a\":1}\n{\"b\":2}\n"
+ got, tail := collectDeframerOutput(t, input, nil)
+ assertStringsEqual(t, "bare", got, []string{"{\"a\":1}\n{\"b\":2}\n"})
+ if tail != "" {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("bare line then event in separate chunks", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ var got []string
+ for _, chunk := range deframer.feed([]byte("{\"a\":1}\n")) {
+ got = append(got, string(chunk))
+ }
+ for _, chunk := range deframer.feed([]byte(frame1)) {
+ got = append(got, string(chunk))
+ }
+ assertStringsEqual(t, "bare+event", got, []string{"{\"a\":1}\n", frame1})
+ })
+
+ t.Run("event then bare line", func(t *testing.T) {
+ input := frame1 + "{\"b\":2}\n"
+ got, _ := collectDeframerOutput(t, input, nil)
+ assertStringsEqual(t, "event+bare", got, []string{frame1, "{\"b\":2}\n"})
+ })
+
+ t.Run("bare line after sse event across chunks", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ _ = deframer.feed([]byte(frame1))
+ got := deframer.feed([]byte("{\"b\":2}\n"))
+ if len(got) != 1 || string(got[0]) != "{\"b\":2}\n" {
+ t.Fatalf("feed after event = %q, want the bare line", got)
+ }
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("unterminated bare chunk passes through immediately", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ got := deframer.feed([]byte("plugin-example"))
+ if len(got) != 1 || string(got[0]) != "plugin-example" {
+ t.Fatalf("feed(%q) = %q, want the chunk itself", "plugin-example", got)
+ }
+ if tail := deframer.flush(); len(tail) != 0 {
+ t.Errorf("flush = %q, want empty", tail)
+ }
+ })
+
+ t.Run("flush strips trailing line framing", func(t *testing.T) {
+ deframer := &executorSSEDeframer{}
+ _ = deframer.feed([]byte("data: [DONE]\r"))
+ if tail := string(deframer.flush()); tail != "data: [DONE]" {
+ t.Errorf("flush = %q, want %q", tail, "data: [DONE]")
+ }
+ blank := &executorSSEDeframer{}
+ _ = blank.feed([]byte("\r\n"))
+ if tail := blank.flush(); len(tail) != 0 {
+ t.Errorf("blank flush = %q, want empty", tail)
+ }
+ })
+}
+
+func collectStreamChunkPayloads(t *testing.T, chunks <-chan coreexecutor.StreamChunk) ([]string, error) {
+ t.Helper()
+ var got []string
+ var streamErr error
+ for chunk := range chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ }
+ if len(chunk.Payload) > 0 {
+ got = append(got, string(chunk.Payload))
+ }
+ }
+ return got, streamErr
+}
+
+func streamAdapterForTest(t *testing.T, format sdktranslator.Format, payloads ...[]byte) *executorAdapter {
+ t.Helper()
+ record := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin-stream-test"})
+ host := newHostWithRecords(record)
+ streamChunks := make(chan pluginapi.ExecutorStreamChunk, len(payloads))
+ for _, payload := range payloads {
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: payload}
+ }
+ close(streamChunks)
+ exec := &fakeExecutor{
+ identifier: "plugin-provider-stream-test",
+ executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{Chunks: streamChunks}, nil
+ },
+ }
+ adapter := newExecutorAdapterForRecordForTest(host, record, exec,
+ []sdktranslator.Format{format},
+ []sdktranslator.Format{format},
+ )
+ adapter.provider = "plugin-provider-stream-test"
+ return adapter
+}
+
+func executeSameFormatStream(t *testing.T, adapter *executorAdapter, format sdktranslator.Format) ([]string, error) {
+ t.Helper()
+ auth := &coreauth.Auth{ID: "auth-stream-test-1", Provider: "plugin-provider-stream-test"}
+ req := coreexecutor.Request{Model: "test-model", Payload: []byte(`{"model":"test-model","stream":true}`)}
+ opts := coreexecutor.Options{SourceFormat: format, ResponseFormat: format, Stream: true}
+ streamRes, err := adapter.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatalf("ExecuteStream returned unexpected error: %v", err)
+ }
+ return collectStreamChunkPayloads(t, streamRes.Chunks)
+}
+
+// TestExecutorAdapterExecuteStreamSameFormatArbitrarySplits covers the raw
+// HTTP relay case: one SSE event is split mid-JSON across two chunks and the
+// [DONE] marker shares a chunk with the final data frame. The adapter output
+// must equal exactly the bare JSON payloads with a single terminal marker
+// dropped, and re-framing them the way the OpenAI HTTP handler does must
+// produce exactly one "data: " prefix per frame and one [DONE].
+func TestExecutorAdapterExecuteStreamSameFormatArbitrarySplits(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hel"),
+ []byte("lo\"}}]}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"),
+ )
+
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ want := []string{
+ `{"choices":[{"delta":{"content":"Hello"}}]}`,
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ }
+ assertStringsEqual(t, "split", got, want)
+
+ var wire bytes.Buffer
+ for _, payload := range got {
+ fmt.Fprintf(&wire, "data: %s\n\n", payload)
+ }
+ wire.WriteString("data: [DONE]\n\n")
+ wantWire := "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n" +
+ "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" +
+ "data: [DONE]\n\n"
+ if wire.String() != wantWire {
+ t.Errorf("handler-framed wire =\n%q\nwant:\n%q", wire.String(), wantWire)
+ }
+ if strings.Contains(wire.String(), "data: data:") {
+ t.Errorf("wire contains doubled framing: %q", wire.String())
+ }
+ if strings.Count(wire.String(), "[DONE]") != 1 {
+ t.Errorf("wire = %q, want exactly one [DONE]", wire.String())
+ }
+}
+
+// TestExecutorAdapterExecuteStreamSameFormatMissingDoneIsSeededAndDropped
+// covers a stream that ends without [DONE]: the translation tail feeds one
+// terminal marker through the translator so it can finalize, and the marker
+// itself never reaches adapter callers (the HTTP handler writes the only
+// [DONE]).
+func TestExecutorAdapterExecuteStreamSameFormatBlankLineBetweenEvents(t *testing.T) {
+ // A blank line landing between two SSE events in a later chunk must
+ // not push the stream back into bare mode: the following event (split
+ // mid-JSON) must be reassembled and stripped like any other.
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"),
+ []byte("\ndata:{\"choices\":"),
+ []byte("[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "blank between events", got, []string{
+ `{"choices":[{"delta":{"content":"Hello"}}]}`,
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ })
+}
+
+func TestExecutorAdapterExecuteStreamSameFormatNoSpaceDataPrefix(t *testing.T) {
+ // The space after "data:" is optional in SSE. A same-format event using
+ // the no-space form and split mid-payload across chunks must still be
+ // reassembled into one event and stripped to bare JSON.
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("data:{\"choices\":"),
+ []byte("[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata:[DONE]\n\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "no space", got, []string{
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ })
+}
+
+func TestExecutorAdapterExecuteStreamSameFormatMissingDoneIsSeededAndDropped(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "seeded", got, []string{
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ })
+}
+
+// TestExecutorAdapterExecuteStreamGeminiSameFormatStripsSSEFraming covers the
+// second registered same-format normalizer (Gemini to Gemini) under split
+// and coalesced chunk delivery. Gemini streams have no [DONE] marker, so
+// nothing extra is seeded when the channel closes.
+func TestExecutorAdapterExecuteStreamGeminiSameFormatStripsSSEFraming(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatGemini,
+ []byte("data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hi\"}]}}"),
+ []byte("]}\n\ndata: {\"candidates\":[{\"finishReason\":\"STOP\"}]}\n\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatGemini)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ want := []string{
+ `{"candidates":[{"content":{"parts":[{"text":"Hi"}]}}]}`,
+ `{"candidates":[{"finishReason":"STOP"}]}`,
+ }
+ assertStringsEqual(t, "gemini", got, want)
+}
+
+// TestExecutorAdapterExecuteStreamSameFormatBareJSONUnchanged covers plugins
+// that already emit bare payloads (as the documented examples do): they must
+// pass through both the deframer and the same-format translator untouched.
+func TestExecutorAdapterExecuteStreamSameFormatBareJSONUnchanged(t *testing.T) {
+ t.Run("no trailing newline", func(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI, []byte("plugin-example"))
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "bare", got, []string{"plugin-example"})
+ })
+
+ t.Run("bare objects keep chunk boundaries without newlines", func(t *testing.T) {
+ // Plugins that emit bare JSON objects without line breaks rely on
+ // one chunk per object: the objects must not be concatenated into
+ // one invalid payload across chunk boundaries.
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte(`{"choices":[{"delta":{"content":"Hi"}}]}`),
+ []byte(`{"choices":[{"delta":{},"finish_reason":"stop"}]}`),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "bare objects", got, []string{
+ `{"choices":[{"delta":{"content":"Hi"}}]}`,
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ })
+ })
+
+ t.Run("leading newline bare payload stays whole", func(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI, []byte("\n{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n"))
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "bare leading newline", got, []string{
+ "\n{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n",
+ })
+ })
+
+ t.Run("partial sse prefix stays buffered", func(t *testing.T) {
+ // A first chunk holding only "da" must not be released as a bare
+ // payload: later bytes complete the SSE data line and the deframer
+ // must rebuild the whole event before translation.
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("da"),
+ []byte("ta: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "partial prefix", got, []string{
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ })
+ })
+
+ t.Run("bare json line", func(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI, []byte("{\"choices\":[]}\n"))
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "bare line", got, []string{"{\"choices\":[]}\n"})
+ })
+
+ t.Run("leading newline bare payloads across chunks stay whole", func(t *testing.T) {
+ // In bare mode (no SSE event ever seen), consecutive newline-led
+ // chunks must keep their boundaries: they are never dropped as
+ // blank lines and never merged.
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("\n{\"a\":1}"),
+ []byte("\n{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "bare newline chunks", got, []string{
+ "\n{\"a\":1}",
+ "\n{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}",
+ })
+ })
+
+ t.Run("bare lines with finish reason feed the handler", func(t *testing.T) {
+ // A plugin that emits bare JSON lines (no SSE framing anywhere) must
+ // still deliver the finish_reason frame to callers: the OpenAI HTTP
+ // handler writes its only [DONE] marker only after some chunk
+ // carried a finish_reason, and otherwise fails the stream as
+ // truncated. The [DONE] fed through the tail path is dropped by the
+ // same-format translator and never appears as an adapter payload.
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAI,
+ []byte("{\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n"),
+ []byte("{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAI)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "bare finish", got, []string{
+ "{\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n",
+ "{\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n",
+ })
+
+ var wire bytes.Buffer
+ for _, payload := range got {
+ fmt.Fprintf(&wire, "data: %s\n\n", payload)
+ }
+ wire.WriteString("data: [DONE]\n\n")
+ if strings.Count(wire.String(), "[DONE]") != 1 {
+ t.Errorf("wire = %q, want exactly one [DONE]", wire.String())
+ }
+ if strings.Contains(wire.String(), "data: data:") {
+ t.Errorf("wire contains doubled framing: %q", wire.String())
+ }
+ })
+}
+
+// TestExecutorAdapterExecuteStreamSameFormatFramedPassthrough covers formats
+// without a registered same-format stream translator and with no response
+// hooks installed: SSE framing (including event lines, partial frames, and
+// the [DONE] marker for responses) must reach callers byte for byte because
+// those handlers consume verbatim frames.
+func TestExecutorAdapterExecuteStreamSameFormatFramedPassthrough(t *testing.T) {
+ t.Run("claude event frames unchanged", func(t *testing.T) {
+ payload := []byte("event: message\ndata: {\"type\":\"message\"}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
+ adapter := streamAdapterForTest(t, sdktranslator.FormatClaude, payload)
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatClaude)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "claude", got, []string{string(payload)})
+ })
+
+ t.Run("responses frames unchanged apart from usage details", func(t *testing.T) {
+ frame := []byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":5}}}\n\n")
+ adapter := streamAdapterForTest(t, sdktranslator.FormatOpenAIResponse, frame)
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatOpenAIResponse)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("got %d chunks %q, want 1", len(got), got)
+ }
+ if !strings.HasPrefix(got[0], "data: ") {
+ t.Errorf("responses frame lost its SSE prefix: %q", got[0])
+ }
+ if !strings.Contains(got[0], "\"input_tokens_details\"") || !strings.Contains(got[0], "\"output_tokens_details\"") {
+ t.Errorf("responses usage details not normalized: %q", got[0])
+ }
+ if strings.Contains(got[0], "[DONE]") {
+ t.Errorf("responses stream gained a chat [DONE] marker: %q", got[0])
+ }
+ })
+
+ t.Run("partial frames unchanged", func(t *testing.T) {
+ adapter := streamAdapterForTest(t, sdktranslator.FormatClaude,
+ []byte("event: message\ndata: {\"ty"),
+ []byte("pe\":\"message\"}\n\n"),
+ )
+ got, err := executeSameFormatStream(t, adapter, sdktranslator.FormatClaude)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "partial", got, []string{
+ "event: message\ndata: {\"ty",
+ "pe\":\"message\"}\n\n",
+ })
+ })
+}
+
+// TestExecutorAdapterExecuteStreamCrossFormatSplitKeepsUsageAndData covers
+// the safety net around the changes above: cross-format plugin streams never
+// enter the same-format deframer or passthrough gate, so a cross-format
+// split stream must still be translated frame by frame and its usage must
+// still be observed from the raw plugin chunks.
+func TestExecutorAdapterExecuteStreamCrossFormatKeepsUsageAndData(t *testing.T) {
+ plugin := newTestUsageCapturePlugin("plugin-provider-cross-split")
+ registerTestUsagePlugin(t, "test-executor-adapter-cross-split", plugin)
+
+ record := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin-cross-split"})
+ host := newHostWithRecords(record)
+
+ streamChunks := make(chan pluginapi.ExecutorStreamChunk, 3)
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")}
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")}
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":8,\"total_tokens\":15}}\n\ndata: [DONE]\n\n")}
+ close(streamChunks)
+
+ exec := &fakeExecutor{
+ identifier: "plugin-provider-cross-split",
+ executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{Chunks: streamChunks}, nil
+ },
+ }
+ adapter := newExecutorAdapterForRecordForTest(host, record, exec,
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ )
+ adapter.provider = "plugin-provider-cross-split"
+
+ auth := &coreauth.Auth{ID: "auth-cross-split-1", Provider: "plugin-provider-cross-split"}
+ req := coreexecutor.Request{Model: "test-model", Payload: []byte(`{"model":"test-model","stream":true}`)}
+ opts := coreexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatClaude,
+ Stream: true,
+ }
+ streamRes, err := adapter.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatalf("ExecuteStream returned unexpected error: %v", err)
+ }
+ got, streamErr := collectStreamChunkPayloads(t, streamRes.Chunks)
+ if streamErr != nil {
+ t.Fatalf("stream returned error: %v", streamErr)
+ }
+ if len(got) == 0 {
+ t.Fatal("expected translated claude frames, got none")
+ }
+ joined := strings.Join(got, "")
+ if !strings.Contains(joined, "Hello") {
+ t.Errorf("translated claude frames lost the content delta: %q", joined)
+ }
+ if !strings.Contains(joined, "message_start") || !strings.Contains(joined, "message_stop") {
+ t.Errorf("translated claude stream is missing start/stop events: %q", joined)
+ }
+ if strings.Contains(joined, "data: data:") {
+ t.Errorf("cross-format output contains doubled framing: %q", joined)
+ }
+ rec := plugin.waitRecord(t, 500*time.Millisecond)
+ if rec.Detail.InputTokens != 7 || rec.Detail.OutputTokens != 8 || rec.Detail.TotalTokens != 15 {
+ t.Errorf("got usage %+v, want prompt=7 completion=8 total=15", rec.Detail)
+ }
+}
+
+// TestExecutorAdapterExecuteStreamSameFormatHooksStillApplied covers the hook
+// path of the same-format gate: when an active plugin provides response
+// translation hooks, a same-format stream without a native translator still
+// goes through TranslateStream, so hook rewrites apply. Without the hooks it
+// remains verbatim (covered by TestExecutorAdapterExecuteStreamSameFormatFramedPassthrough).
+func TestExecutorAdapterExecuteStreamSameFormatHooksStillApplied(t *testing.T) {
+ hookRecord := normalizeTestCapabilityRecord(capabilityRecord{
+ id: "response-hook-plugin",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ ResponseBeforeTranslator: &testResponseNormalizerFunc{
+ normalize: func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
+ return pluginapi.PayloadResponse{Body: bytes.ReplaceAll(req.Body, []byte("want"), []byte("hook"))}, nil
+ },
+ },
+ ResponseAfterTranslator: &testResponseNormalizerFunc{
+ normalize: func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
+ return pluginapi.PayloadResponse{Body: append(bytes.Clone(req.Body), 'X')}, nil
+ },
+ },
+ }},
+ })
+ executorRecord := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin-hook-stream"})
+ host := newHostWithRecords(executorRecord, hookRecord)
+
+ sdktranslator.SetPluginHooks(host)
+ t.Cleanup(func() {
+ sdktranslator.SetPluginHooks(nil)
+ })
+
+ streamChunks := make(chan pluginapi.ExecutorStreamChunk, 1)
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("event: message\ndata: {\"type\":\"message\",\"content\":\"want\"}\n\n")}
+ close(streamChunks)
+ exec := &fakeExecutor{
+ identifier: "plugin-provider-hook-stream",
+ executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{Chunks: streamChunks}, nil
+ },
+ }
+ adapter := newExecutorAdapterForRecordForTest(host, executorRecord, exec,
+ []sdktranslator.Format{sdktranslator.FormatClaude},
+ []sdktranslator.Format{sdktranslator.FormatClaude},
+ )
+ adapter.provider = "plugin-provider-hook-stream"
+
+ auth := &coreauth.Auth{ID: "auth-hook-stream-1", Provider: "plugin-provider-hook-stream"}
+ req := coreexecutor.Request{Model: "test-model", Payload: []byte(`{"model":"test-model","stream":true}`)}
+ opts := coreexecutor.Options{SourceFormat: sdktranslator.FormatClaude, ResponseFormat: sdktranslator.FormatClaude, Stream: true}
+ streamRes, err := adapter.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatalf("ExecuteStream returned unexpected error: %v", err)
+ }
+ got, err := collectStreamChunkPayloads(t, streamRes.Chunks)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "hooks", got, []string{
+ "event: message\ndata: {\"type\":\"message\",\"content\":\"hook\"}\n\nX",
+ })
+}
+
+// testResponseNormalizerFunc adapts a function to pluginapi.ResponseNormalizer.
+type testResponseNormalizerFunc struct {
+ normalize func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error)
+}
+
+func (f *testResponseNormalizerFunc) NormalizeResponse(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
+ return f.normalize(ctx, req)
+}
+
+// TestDeframeExecutorSSEChunksErrorFlushesPartial covers the channel-level
+// contract directly: a partial event is emitted before an error chunk, the
+// error is forwarded unchanged, and no terminal marker is synthesized at
+// this layer (the translation tail owns that).
+func TestDeframeExecutorSSEChunksErrorFlushesPartial(t *testing.T) {
+ wantErr := errors.New("stream failed")
+ in := make(chan pluginapi.ExecutorStreamChunk, 2)
+ in <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"a\"")}
+ in <- pluginapi.ExecutorStreamChunk{Err: wantErr}
+ close(in)
+
+ out := deframeExecutorSSEChunks(context.Background(), in)
+ var got []pluginapi.ExecutorStreamChunk
+ for chunk := range out {
+ got = append(got, chunk)
+ }
+ if len(got) != 2 {
+ t.Fatalf("got %d chunks %+v, want partial then error", len(got), got)
+ }
+ if string(got[0].Payload) != "data: {\"a\"" {
+ t.Errorf("first chunk = %q, want partial payload", got[0].Payload)
+ }
+ if !errors.Is(got[1].Err, wantErr) {
+ t.Errorf("second chunk error = %v, want %v", got[1].Err, wantErr)
+ }
+}
+
+// TestExecutorAdapterExecuteStreamSameFormatHooksSeeTerminalOnce covers the
+// terminal-marker ownership contract: a same-format chat stream missing its
+// [DONE] marker feeds exactly one synthetic marker through translation
+// (from emitTranslatedExecutorStreamTail), so plugin response hooks observe
+// exactly one terminal payload and cannot duplicate terminal frames. The
+// deframer must not add a second one.
+func TestExecutorAdapterExecuteStreamSameFormatHooksSeeTerminalOnce(t *testing.T) {
+ var bodies [][]byte
+ hookRecord := normalizeTestCapabilityRecord(capabilityRecord{
+ id: "response-hook-plugin-terminal",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ ResponseBeforeTranslator: &testResponseNormalizerFunc{
+ normalize: func(ctx context.Context, req pluginapi.ResponseTransformRequest) (pluginapi.PayloadResponse, error) {
+ bodies = append(bodies, bytes.Clone(req.Body))
+ return pluginapi.PayloadResponse{Body: req.Body}, nil
+ },
+ },
+ }},
+ })
+ executorRecord := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin-terminal-stream"})
+ host := newHostWithRecords(executorRecord, hookRecord)
+
+ sdktranslator.SetPluginHooks(host)
+ t.Cleanup(func() {
+ sdktranslator.SetPluginHooks(nil)
+ })
+
+ streamChunks := make(chan pluginapi.ExecutorStreamChunk, 2)
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")}
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")}
+ close(streamChunks)
+ exec := &fakeExecutor{
+ identifier: "plugin-provider-terminal-stream",
+ executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{Chunks: streamChunks}, nil
+ },
+ }
+ adapter := newExecutorAdapterForRecordForTest(host, executorRecord, exec,
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ )
+ adapter.provider = "plugin-provider-terminal-stream"
+
+ auth := &coreauth.Auth{ID: "auth-terminal-stream-1", Provider: "plugin-provider-terminal-stream"}
+ req := coreexecutor.Request{Model: "test-model", Payload: []byte(`{"model":"test-model","stream":true}`)}
+ opts := coreexecutor.Options{SourceFormat: sdktranslator.FormatOpenAI, ResponseFormat: sdktranslator.FormatOpenAI, Stream: true}
+ streamRes, err := adapter.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatalf("ExecuteStream returned unexpected error: %v", err)
+ }
+ got, err := collectStreamChunkPayloads(t, streamRes.Chunks)
+ if err != nil {
+ t.Fatalf("stream returned error: %v", err)
+ }
+ assertStringsEqual(t, "terminal", got, []string{
+ `{"choices":[{"delta":{"content":"Hello"}}]}`,
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ })
+
+ doneCount := 0
+ for _, body := range bodies {
+ if bytes.Contains(body, []byte("[DONE]")) {
+ doneCount++
+ }
+ }
+ if doneCount != 1 {
+ t.Errorf("response hook saw [DONE] %d times, want exactly once; bodies=%q", doneCount, bodies)
+ }
+ var wire bytes.Buffer
+ for _, payload := range got {
+ fmt.Fprintf(&wire, "data: %s\n\n", payload)
+ }
+ wire.WriteString("data: [DONE]\n\n")
+ if strings.Count(wire.String(), "[DONE]") != 1 {
+ t.Errorf("wire = %q, want exactly one [DONE]", wire.String())
+ }
+}
+
+// TestDeframeExecutorSSEChunksNeverSynthesizesDone asserts the channel-level
+// contract that the deframer forwards terminal markers verbatim but never
+// invents one: emitTranslatedExecutorStreamTail is the single place that
+// feeds a synthetic [DONE] through translation, exactly once.
+func TestDeframeExecutorSSEChunksNeverSynthesizesDone(t *testing.T) {
+ run := func(payload string) []string {
+ t.Helper()
+ in := make(chan pluginapi.ExecutorStreamChunk, 1)
+ in <- pluginapi.ExecutorStreamChunk{Payload: []byte(payload)}
+ close(in)
+ out := deframeExecutorSSEChunks(context.Background(), in)
+ var got []string
+ for chunk := range out {
+ got = append(got, string(chunk.Payload))
+ }
+ return got
+ }
+
+ t.Run("missing done is not invented", func(t *testing.T) {
+ got := run("data: {\"choices\":[]}\n\n")
+ assertStringsEqual(t, "no seed", got, []string{
+ "data: {\"choices\":[]}\n\n",
+ })
+ })
+
+ t.Run("explicit done forwarded once", func(t *testing.T) {
+ got := run("data: {\"choices\":[]}\n\ndata: [DONE]\n\n")
+ if n := strings.Count(strings.Join(got, "|"), "[DONE]"); n != 1 {
+ t.Errorf("got %q, want exactly one [DONE]", got)
+ }
+ })
+
+ t.Run("bare payloads pass through untouched", func(t *testing.T) {
+ got := run("plugin-example")
+ assertStringsEqual(t, "bare", got, []string{"plugin-example"})
+ })
+}
diff --git a/internal/pluginhost/adapters_executors_payload_rules_test.go b/internal/pluginhost/adapters_executors_payload_rules_test.go
index b1db3d8ffe1..c0107e4b74e 100644
--- a/internal/pluginhost/adapters_executors_payload_rules_test.go
+++ b/internal/pluginhost/adapters_executors_payload_rules_test.go
@@ -9,13 +9,12 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// payloadUpstream records the JSON bodies a plugin sends through host.http.
@@ -90,16 +89,16 @@ func TestExecutorAdapterAppliesPayloadRulesAfterTranslation(t *testing.T) {
if gotModel != "claude-model(high)" {
t.Errorf("plugin request model = %q, want unchanged claude-model(high)", gotModel)
}
- if got := gjson.GetBytes(pluginPayload, "temperature").Float(); got != 0.2 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "temperature").Float(); got != 0.2 {
t.Errorf("temperature = %v, want default 0.2: %s", got, pluginPayload)
}
- if !gjson.GetBytes(pluginPayload, "defaulted").Bool() {
+ if !ojson.GetBytesNoCopy(pluginPayload, "defaulted").Bool() {
t.Errorf("defaulted rule not applied: %s", pluginPayload)
}
- if got := gjson.GetBytes(pluginPayload, "max_tokens").Int(); got != 64 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "max_tokens").Int(); got != 64 {
t.Errorf("max_tokens = %d, want overridden 64: %s", got, pluginPayload)
}
- if gjson.GetBytes(pluginPayload, "secret").Exists() {
+ if ojson.GetBytesNoCopy(pluginPayload, "secret").Exists() {
t.Errorf("filtered secret remains in plugin payload: %s", pluginPayload)
}
}
@@ -133,10 +132,10 @@ func TestExecutorAdapterAppliesPayloadRulesToStreams(t *testing.T) {
}
for range result.Chunks {
}
- if got := gjson.GetBytes(pluginPayload, "max_tokens").Int(); got != 64 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "max_tokens").Int(); got != 64 {
t.Errorf("stream max_tokens = %d, want override 64: %s", got, pluginPayload)
}
- if gjson.GetBytes(pluginPayload, "secret").Exists() {
+ if ojson.GetBytesNoCopy(pluginPayload, "secret").Exists() {
t.Errorf("filtered field remained in stream plugin payload: %s", pluginPayload)
}
}
@@ -170,13 +169,13 @@ func TestExecutorAdapterAppliesPayloadRulesToCountTokens(t *testing.T) {
if gotModel != "claude-model(high)" {
t.Errorf("plugin count_tokens model = %q, want unchanged claude-model(high)", gotModel)
}
- if got := gjson.GetBytes(pluginPayload, "temperature").Float(); got != 0.2 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "temperature").Float(); got != 0.2 {
t.Errorf("count_tokens temperature = %v, want default 0.2: %s", got, pluginPayload)
}
- if got := gjson.GetBytes(pluginPayload, "max_tokens").Int(); got != 64 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "max_tokens").Int(); got != 64 {
t.Errorf("count_tokens max_tokens = %d, want overridden 64: %s", got, pluginPayload)
}
- if gjson.GetBytes(pluginPayload, "secret").Exists() {
+ if ojson.GetBytesNoCopy(pluginPayload, "secret").Exists() {
t.Errorf("filtered secret remains in count_tokens payload: %s", pluginPayload)
}
}
@@ -207,7 +206,7 @@ func TestExecutorAdapterPayloadRulesCanonicalizeSourceFormatAlias(t *testing.T)
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(pluginPayload, "max_tokens").Int(); got != 64 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "max_tokens").Int(); got != 64 {
t.Errorf("max_tokens = %d, want 64: rule gated on from-protocol claude was skipped for alias source", got)
}
}
@@ -236,10 +235,10 @@ func TestExecutorAdapterPayloadRulesUseAntigravityRequestRoot(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(pluginPayload, "request.generationConfig.maxOutputTokens").Int(); got != 64 {
+ if got := ojson.GetBytesNoCopy(pluginPayload, "request.generationConfig.maxOutputTokens").Int(); got != 64 {
t.Errorf("request.generationConfig.maxOutputTokens = %d, want 64: %s", got, pluginPayload)
}
- if gjson.GetBytes(pluginPayload, "generationConfig").Exists() {
+ if ojson.GetBytesNoCopy(pluginPayload, "generationConfig").Exists() {
t.Errorf("rule created a top-level generationConfig beside request: %s", pluginPayload)
}
}
@@ -255,9 +254,9 @@ func TestExecutorAdapterAppliesArrayIndexFilterOnlyOnceAcrossHostHTTP(t *testing
adapter := newCurrentExecutorAdapterForTest(host, "payload-once-plugin", &fakeExecutor{
identifier: "plugin-provider",
execute: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
- body, errSet := sjson.SetBytes(req.Payload, "user", "plugin-added")
+ body, errSet := ojson.SetBytes(req.Payload, "user", "plugin-added")
if errSet != nil {
- t.Fatalf("sjson.SetBytes() error = %v", errSet)
+ t.Fatalf("ojson.SetBytes() error = %v", errSet)
}
callbackID, closeCallback := host.openCallbackContext(ctx)
defer closeCallback()
@@ -281,10 +280,10 @@ func TestExecutorAdapterAppliesArrayIndexFilterOnlyOnceAcrossHostHTTP(t *testing
t.Fatalf("Execute() error = %v", errExecute)
}
sent := upstream.last(t)
- if got := len(gjson.GetBytes(sent, "messages").Array()); got != 1 {
+ if got := len(ojson.GetBytesNoCopy(sent, "messages").Array()); got != 1 {
t.Fatalf("messages count = %d, want 1: %s", got, sent)
}
- if got := gjson.GetBytes(sent, "messages.0.content").String(); got != "keep" {
+ if got := ojson.GetBytesNoCopy(sent, "messages.0.content").String(); got != "keep" {
t.Errorf("messages.0.content = %q, want keep: %s", got, sent)
}
}
diff --git a/internal/pluginhost/adapters_executors_thinking_test.go b/internal/pluginhost/adapters_executors_thinking_test.go
index 714a4b0b276..ffaad1471ec 100644
--- a/internal/pluginhost/adapters_executors_thinking_test.go
+++ b/internal/pluginhost/adapters_executors_thinking_test.go
@@ -6,13 +6,13 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type recordingThinkingApplier struct {
@@ -112,13 +112,13 @@ func TestExecutorAdapterAppliesCanonicalThinkingWithPluginApplier(t *testing.T)
if gotExecuteReq.Model != "my-model" {
t.Fatalf("Execute req.Model = %q, want %q", gotExecuteReq.Model, "my-model")
}
- if got := gjson.GetBytes(gotExecuteReq.Payload, "model").String(); got != "my-model" {
+ if got := ojson.GetBytesNoCopy(gotExecuteReq.Payload, "model").String(); got != "my-model" {
t.Fatalf("Execute payload model = %q, want %q; payload=%s", got, "my-model", gotExecuteReq.Payload)
}
- if got := gjson.GetBytes(gotExecuteReq.Payload, "applied_by_plugin").String(); got != provider {
+ if got := ojson.GetBytesNoCopy(gotExecuteReq.Payload, "applied_by_plugin").String(); got != provider {
t.Fatalf("Execute payload applied_by_plugin = %q, want %q; payload=%s", got, provider, gotExecuteReq.Payload)
}
- if got := gjson.GetBytes(gotExecuteReq.Payload, "thinking_level").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(gotExecuteReq.Payload, "thinking_level").String(); got != "high" {
t.Fatalf("Execute payload thinking_level = %q, want high (suffix overrides body); payload=%s", got, gotExecuteReq.Payload)
}
@@ -139,7 +139,7 @@ func TestExecutorAdapterAppliesCanonicalThinkingWithPluginApplier(t *testing.T)
if gotStreamReq.Model != "my-model" {
t.Fatalf("ExecuteStream req.Model = %q, want %q", gotStreamReq.Model, "my-model")
}
- if got := gjson.GetBytes(gotStreamReq.Payload, "thinking_budget").Int(); got != 8192 {
+ if got := ojson.GetBytesNoCopy(gotStreamReq.Payload, "thinking_budget").Int(); got != 8192 {
t.Fatalf("ExecuteStream payload thinking_budget = %d, want 8192; payload=%s", got, gotStreamReq.Payload)
}
@@ -157,7 +157,7 @@ func TestExecutorAdapterAppliesCanonicalThinkingWithPluginApplier(t *testing.T)
if gotCountReq.Model != "my-model" {
t.Fatalf("CountTokens req.Model = %q, want %q", gotCountReq.Model, "my-model")
}
- if got := gjson.GetBytes(gotCountReq.Payload, "thinking_level").String(); got != "medium" {
+ if got := ojson.GetBytesNoCopy(gotCountReq.Payload, "thinking_level").String(); got != "medium" {
t.Fatalf("CountTokens payload thinking_level = %q, want medium; payload=%s", got, gotCountReq.Payload)
}
if len(applier.calls) != 3 {
@@ -219,7 +219,7 @@ func TestExecutorAdapterCallsPluginThinkingApplierWithoutModelThinkingMetadata(t
if gotReq.Model != "my-model" {
t.Fatalf("req.Model = %q, want %q", gotReq.Model, "my-model")
}
- if got := gjson.GetBytes(gotReq.Payload, "thinking_level").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(gotReq.Payload, "thinking_level").String(); got != "high" {
t.Fatalf("payload thinking_level = %q, want high; payload=%s", got, gotReq.Payload)
}
}
@@ -270,10 +270,10 @@ func TestExecutorAdapterAppliesModelThinkingMetadataWithoutPluginApplier(t *test
if gotReq.Model != "my-model" {
t.Fatalf("req.Model = %q, want %q", gotReq.Model, "my-model")
}
- if got := gjson.GetBytes(gotReq.Payload, "model").String(); got != "my-model" {
+ if got := ojson.GetBytesNoCopy(gotReq.Payload, "model").String(); got != "my-model" {
t.Fatalf("payload model = %q, want %q; payload=%s", got, "my-model", gotReq.Payload)
}
- if got := gjson.GetBytes(gotReq.Payload, "reasoning_effort").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(gotReq.Payload, "reasoning_effort").String(); got != "high" {
t.Fatalf("payload reasoning_effort = %q, want high; payload=%s", got, gotReq.Payload)
}
@@ -288,7 +288,7 @@ func TestExecutorAdapterAppliesModelThinkingMetadataWithoutPluginApplier(t *test
if errNoThinking != nil {
t.Fatalf("Execute(no-thinking-model) error = %v", errNoThinking)
}
- if gjson.GetBytes(gotReq.Payload, "reasoning_effort").Exists() {
+ if ojson.GetBytesNoCopy(gotReq.Payload, "reasoning_effort").Exists() {
t.Fatalf("expected reasoning_effort to be stripped for non-thinking model; payload=%s", gotReq.Payload)
}
}
@@ -387,7 +387,7 @@ func TestExecutorAdapterValidatesThinkingAndClampsOrRejectsOutOfRange(t *testing
if errSuffixClamp != nil {
t.Fatalf("Execute(my-model(9000)) error = %v", errSuffixClamp)
}
- if got := gjson.GetBytes(gotReq.Payload, "thinking.budget_tokens").Int(); got != 8192 {
+ if got := ojson.GetBytesNoCopy(gotReq.Payload, "thinking.budget_tokens").Int(); got != 8192 {
t.Fatalf("clamped budget_tokens = %d, want 8192; payload=%s", got, gotReq.Payload)
}
}
@@ -491,13 +491,13 @@ func TestExecutorAdapterAppliesThinkingBeforePayloadRules(t *testing.T) {
if got.Model != "my-model" {
t.Fatalf("%s req.Model = %q, want my-model", name, got.Model)
}
- if level := gjson.GetBytes(got.Payload, "thinking_level").String(); level != "low" {
+ if level := ojson.GetBytesNoCopy(got.Payload, "thinking_level").String(); level != "low" {
t.Fatalf("%s thinking_level = %q, want payload override low after thinking applier; payload=%s", name, level, got.Payload)
}
- if gjson.GetBytes(got.Payload, "applied_by_plugin").Exists() {
+ if ojson.GetBytesNoCopy(got.Payload, "applied_by_plugin").Exists() {
t.Fatalf("%s applied_by_plugin was not removed by payload filter; payload=%s", name, got.Payload)
}
- if temp := gjson.GetBytes(got.Payload, "temperature").Float(); temp != 0.25 {
+ if temp := ojson.GetBytesNoCopy(got.Payload, "temperature").Float(); temp != 0.25 {
t.Fatalf("%s temperature = %v, want payload default 0.25; payload=%s", name, temp, got.Payload)
}
}
diff --git a/internal/pluginhost/adapters_executors_usage_test.go b/internal/pluginhost/adapters_executors_usage_test.go
index 214c36004ac..0eff2528dc3 100644
--- a/internal/pluginhost/adapters_executors_usage_test.go
+++ b/internal/pluginhost/adapters_executors_usage_test.go
@@ -389,6 +389,76 @@ func TestExecutorAdapterExecuteStreamPublishesUsage(t *testing.T) {
}
}
+// TestExecutorAdapterExecuteStreamSameFormatStripsSSEFraming pins the contract
+// that same-format plugin executor streams are normalized like built-in
+// executor streams: SSE-framed chunks ("data: {...}\n\n") must reach callers
+// as bare JSON payloads without the "[DONE]" marker, because the HTTP handlers
+// add their own "data:" prefix and terminal marker. Forwarding the framed
+// chunks unchanged produced a doubled "data: data:" prefix on the wire.
+func TestExecutorAdapterExecuteStreamSameFormatStripsSSEFraming(t *testing.T) {
+ executorRecord := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin-stream-framing"})
+ host := newHostWithRecords(executorRecord)
+
+ streamChunks := make(chan pluginapi.ExecutorStreamChunk, 4)
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n")}
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")}
+ streamChunks <- pluginapi.ExecutorStreamChunk{Payload: []byte("data: [DONE]\n\n")}
+ close(streamChunks)
+
+ exec := &fakeExecutor{
+ identifier: "plugin-provider-stream-framing",
+ executeStream: func(ctx context.Context, req pluginapi.ExecutorRequest) (pluginapi.ExecutorStreamResponse, error) {
+ return pluginapi.ExecutorStreamResponse{Chunks: streamChunks}, nil
+ },
+ }
+
+ adapter := newExecutorAdapterForRecordForTest(host, executorRecord, exec,
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ )
+ adapter.provider = "plugin-provider-stream-framing"
+
+ auth := &coreauth.Auth{ID: "auth-stream-framing-1", Provider: "plugin-provider-stream-framing"}
+ req := coreexecutor.Request{
+ Model: "test-model",
+ Payload: []byte(`{"model":"test-model","stream":true}`),
+ }
+ opts := coreexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAI,
+ ResponseFormat: sdktranslator.FormatOpenAI,
+ Stream: true,
+ }
+
+ streamRes, errStream := adapter.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream returned unexpected error: %v", errStream)
+ }
+
+ var received []string
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected chunk error: %v", chunk.Err)
+ }
+ received = append(received, string(chunk.Payload))
+ }
+
+ want := []string{
+ `{"choices":[{"delta":{"content":"Hello"}}]}`,
+ `{"choices":[{"delta":{},"finish_reason":"stop"}]}`,
+ }
+ if len(received) != len(want) {
+ t.Fatalf("received %d chunks %q, want %d %q", len(received), received, len(want), want)
+ }
+ for i := range want {
+ if received[i] != want[i] {
+ t.Errorf("chunk %d = %q, want %q", i, received[i], want[i])
+ }
+ if strings.HasPrefix(received[i], "data:") {
+ t.Errorf("chunk %d still carries SSE framing: %q", i, received[i])
+ }
+ }
+}
+
func TestExecutorAdapterExecuteStreamThroughAuthManagerPublishesUsage(t *testing.T) {
plugin := newTestUsageCapturePlugin("plugin-provider-stream-mgr")
registerTestUsagePlugin(t, "test-executor-adapter-auth-manager-stream-usage", plugin)
diff --git a/internal/pluginhost/adapters_test.go b/internal/pluginhost/adapters_test.go
index db951931ad1..90cbd557bfd 100644
--- a/internal/pluginhost/adapters_test.go
+++ b/internal/pluginhost/adapters_test.go
@@ -3926,17 +3926,32 @@ func (panicFrontendAuthProvider) Authenticate(ctx context.Context, req pluginapi
}
type fakeAuthProvider struct {
- identifier string
- parseAuth func(context.Context, pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error)
- startLogin func(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error)
- pollLogin func(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error)
- refreshAuth func(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error)
+ identifier string
+ identifiers []string
+ entries []pluginapi.AuthProviderEntry
+ credentialProviders []string
+ parseAuth func(context.Context, pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error)
+ startLogin func(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error)
+ pollLogin func(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error)
+ refreshAuth func(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error)
}
func (p fakeAuthProvider) Identifier() string {
return p.identifier
}
+func (p fakeAuthProvider) Identifiers() []string {
+ return append([]string(nil), p.identifiers...)
+}
+
+func (p fakeAuthProvider) AuthEntries() []pluginapi.AuthProviderEntry {
+ return append([]pluginapi.AuthProviderEntry(nil), p.entries...)
+}
+
+func (p fakeAuthProvider) CredentialProviders() []string {
+ return append([]string(nil), p.credentialProviders...)
+}
+
func (p fakeAuthProvider) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
if p.parseAuth == nil {
return pluginapi.AuthParseResponse{}, nil
@@ -4480,6 +4495,59 @@ func TestExecutorAdapterRefresh_MergesAttributesAndPreservesPath_Issue6119(t *te
}
}
+func TestExecutorAdapterRefresh_PreservesDisabledState_Issue7225(t *testing.T) {
+ authProvider := fakeAuthProvider{
+ identifier: "plugin-provider",
+ refreshAuth: func(ctx context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ return pluginapi.AuthRefreshResponse{
+ Auth: pluginapi.AuthData{
+ Metadata: map[string]any{"token": "new"},
+ },
+ }, nil
+ },
+ }
+ executorRecord := normalizeTestCapabilityRecord(capabilityRecord{id: "executor-plugin"})
+ host := newHostWithRecords(
+ capabilityRecord{
+ id: "auth-plugin",
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: authProvider,
+ },
+ },
+ },
+ executorRecord,
+ )
+
+ exec := &fakeExecutor{identifier: "ignored-by-adapter"}
+ adapter := newExecutorAdapterForRecordForTest(host, executorRecord, exec,
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ []sdktranslator.Format{sdktranslator.FormatOpenAI},
+ )
+
+ auth := &coreauth.Auth{
+ ID: "auth-1",
+ Provider: "plugin-provider",
+ Disabled: true,
+ Status: coreauth.StatusDisabled,
+ Metadata: map[string]any{"token": "old"},
+ }
+
+ refreshed, errRefresh := adapter.Refresh(context.Background(), auth)
+ if errRefresh != nil {
+ t.Fatalf("Refresh() error = %v", errRefresh)
+ }
+ if refreshed == nil {
+ t.Fatal("Refresh() = nil, want refreshed auth")
+ }
+ if !refreshed.Disabled || refreshed.Status != coreauth.StatusDisabled {
+ t.Fatalf("Refresh() disabled=%t status=%q, want disabled auth to stay disabled", refreshed.Disabled, refreshed.Status)
+ }
+ if got := refreshed.Metadata["token"]; got != "new" {
+ t.Fatalf("Refresh() token = %q, want new", got)
+ }
+}
+
func TestUsageAdapterPropagatesRequestIDTraceIDAndSelectionID(t *testing.T) {
var captured pluginapi.UsageRecord
record := normalizeTestCapabilityRecord(capabilityRecord{
diff --git a/internal/pluginhost/adapters_usage_translation.go b/internal/pluginhost/adapters_usage_translation.go
index 77236eb2103..2dcab50abb0 100644
--- a/internal/pluginhost/adapters_usage_translation.go
+++ b/internal/pluginhost/adapters_usage_translation.go
@@ -6,6 +6,7 @@ import (
"fmt"
"runtime/debug"
"strings"
+ "sync"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -129,12 +130,330 @@ type thinkingAdapter struct {
applier pluginapi.ThinkingApplier
}
-func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) {
- if a == nil {
+const (
+ defaultMaxPendingPluginUsageRecords = 4096
+ maxPluginUsageReplayAttempts = 2
+)
+
+type pendingPluginUsageRecord struct {
+ ctx context.Context
+ record pluginapi.UsageRecord
+ attempts int
+}
+
+type pluginUsageState struct {
+ mu sync.Mutex
+ cond *sync.Cond
+ inFlight int
+ quiescing bool
+ replaying bool
+ asyncDraining bool
+ closed bool
+ quiesceWaiters int
+ waitingProducers int
+ pending []pendingPluginUsageRecord
+}
+
+func (h *Host) usageStateForPlugin(pluginID string) *pluginUsageState {
+ pluginID = strings.TrimSpace(pluginID)
+ if h == nil || pluginID == "" {
+ return nil
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.usageStates == nil {
+ h.usageStates = make(map[string]*pluginUsageState)
+ }
+ state := h.usageStates[pluginID]
+ if state == nil {
+ state = &pluginUsageState{}
+ state.cond = sync.NewCond(&state.mu)
+ h.usageStates[pluginID] = state
+ }
+ return state
+}
+
+func (h *Host) pendingUsageLimit() int {
+ if h == nil {
+ return defaultMaxPendingPluginUsageRecords
+ }
+ h.mu.Lock()
+ limit := h.maxPendingUsageRecords
+ h.mu.Unlock()
+ if limit <= 0 {
+ return defaultMaxPendingPluginUsageRecords
+ }
+ return limit
+}
+
+func contextDone(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ select {
+ case <-ctx.Done():
+ return true
+ default:
+ return false
+ }
+}
+
+func (h *Host) beginUsageReplacement(ctx context.Context, pluginID string) bool {
+ state := h.usageStateForPlugin(pluginID)
+ if state == nil {
+ return true
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ h.mu.Lock()
+ waitHook := h.usageDrainWaitHook
+ h.mu.Unlock()
+
+ state.mu.Lock()
+ state.closed = false
+ state.quiescing = true
+ if state.inFlight == 0 && !state.asyncDraining {
+ state.mu.Unlock()
+ return !contextDone(ctx)
+ }
+ stopWake := context.AfterFunc(ctx, func() {
+ state.mu.Lock()
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ })
+ defer stopWake()
+ notifiedWait := false
+ for (state.inFlight > 0 || state.asyncDraining) && !contextDone(ctx) {
+ if !notifiedWait && waitHook != nil {
+ notifiedWait = true
+ waitHook(pluginID)
+ }
+ state.cond.Wait()
+ }
+ ok := state.inFlight == 0 && !state.asyncDraining && !contextDone(ctx)
+ state.mu.Unlock()
+ return ok
+}
+
+func (h *Host) finishUsageReplacement(pluginID string) {
+ state := h.usageStateForPlugin(pluginID)
+ if state == nil || h.pluginNeedsUsageRecovery(pluginID) {
return
}
- plugin := a.host.currentUsagePlugin(a.pluginID)
- if plugin == nil {
+ state.mu.Lock()
+ if !state.quiescing {
+ state.mu.Unlock()
+ return
+ }
+ state.quiescing = false
+ state.replaying = true
+ remainingSync := len(state.pending) + state.quiesceWaiters
+ for remainingSync > 0 {
+ if state.closed {
+ state.pending = nil
+ state.quiescing = false
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ if len(state.pending) == 0 {
+ if state.quiesceWaiters > 0 {
+ state.cond.Broadcast()
+ state.cond.Wait()
+ continue
+ }
+ break
+ }
+ plugin := h.currentUsagePlugin(pluginID)
+ if plugin == nil {
+ if h.pluginNeedsUsageRecovery(pluginID) {
+ state.quiescing = true
+ } else {
+ state.pending = nil
+ }
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ item := state.pending[0]
+ state.pending[0] = pendingPluginUsageRecord{}
+ state.pending = state.pending[1:]
+ remainingSync--
+ state.cond.Broadcast()
+
+ state.inFlight++
+ state.mu.Unlock()
+
+ errDeliver := h.invokeUsagePlugin(item.ctx, pluginID, plugin, item.record)
+
+ state.mu.Lock()
+ state.inFlight--
+ if state.inFlight == 0 {
+ state.cond.Broadcast()
+ }
+ if errDeliver != nil && !state.closed {
+ if state.quiescing || h.pluginNeedsUsageRecovery(pluginID) {
+ state.pending = append([]pendingPluginUsageRecord{item}, state.pending...)
+ state.quiescing = true
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ item.attempts++
+ if item.attempts < maxPluginUsageReplayAttempts && h.currentUsagePlugin(pluginID) != nil {
+ state.pending = append([]pendingPluginUsageRecord{item}, state.pending...)
+ break
+ }
+ }
+ }
+
+ if state.closed {
+ state.pending = nil
+ state.quiescing = false
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ if len(state.pending) == 0 && state.waitingProducers == 0 {
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ if !state.asyncDraining {
+ state.asyncDraining = true
+ go h.drainPostReplayUsage(pluginID, state)
+ }
+ state.mu.Unlock()
+}
+
+func (h *Host) drainPostReplayUsage(pluginID string, state *pluginUsageState) {
+ if h == nil || state == nil {
+ return
+ }
+ state.mu.Lock()
+ for {
+ if state.closed || state.quiescing {
+ state.asyncDraining = false
+ if state.closed {
+ state.pending = nil
+ state.replaying = false
+ }
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ if len(state.pending) == 0 {
+ if state.waitingProducers > 0 {
+ state.cond.Broadcast()
+ state.cond.Wait()
+ continue
+ }
+ state.replaying = false
+ state.asyncDraining = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ plugin := h.currentUsagePlugin(pluginID)
+ if plugin == nil {
+ if h.pluginNeedsUsageRecovery(pluginID) {
+ state.quiescing = true
+ } else {
+ state.pending = nil
+ }
+ state.replaying = false
+ state.asyncDraining = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ item := state.pending[0]
+ state.pending[0] = pendingPluginUsageRecord{}
+ state.pending = state.pending[1:]
+ state.cond.Broadcast()
+
+ state.inFlight++
+ state.mu.Unlock()
+
+ errDeliver := h.invokeUsagePlugin(item.ctx, pluginID, plugin, item.record)
+
+ state.mu.Lock()
+ state.inFlight--
+ if state.inFlight == 0 {
+ state.cond.Broadcast()
+ }
+ if errDeliver != nil && !state.closed {
+ if state.quiescing || h.pluginNeedsUsageRecovery(pluginID) {
+ state.pending = append([]pendingPluginUsageRecord{item}, state.pending...)
+ state.quiescing = true
+ state.replaying = false
+ state.asyncDraining = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ item.attempts++
+ if item.attempts < maxPluginUsageReplayAttempts && h.currentUsagePlugin(pluginID) != nil {
+ state.pending = append([]pendingPluginUsageRecord{item}, state.pending...)
+ }
+ }
+ }
+}
+
+func (h *Host) clearUsageReplacement(pluginID string) {
+ pluginID = strings.TrimSpace(pluginID)
+ if h == nil || pluginID == "" {
+ return
+ }
+ h.mu.Lock()
+ state := h.usageStates[pluginID]
+ delete(h.usageStates, pluginID)
+ h.mu.Unlock()
+ if state == nil {
+ return
+ }
+ state.mu.Lock()
+ state.closed = true
+ state.pending = nil
+ state.quiescing = false
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+}
+
+func (h *Host) clearAllUsageReplacements() {
+ if h == nil {
+ return
+ }
+ h.mu.Lock()
+ states := make([]*pluginUsageState, 0, len(h.usageStates))
+ for _, state := range h.usageStates {
+ if state != nil {
+ states = append(states, state)
+ }
+ }
+ h.usageStates = make(map[string]*pluginUsageState)
+ h.mu.Unlock()
+ for _, state := range states {
+ state.mu.Lock()
+ state.closed = true
+ state.pending = nil
+ state.quiescing = false
+ state.replaying = false
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ }
+}
+
+func (h *Host) dispatchPluginUsageRecord(ctx context.Context, pluginID string, record pluginapi.UsageRecord) {
+ state := h.usageStateForPlugin(pluginID)
+ if state == nil {
return
}
if ctx == nil {
@@ -142,11 +461,104 @@ func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record)
} else {
ctx = context.WithoutCancel(ctx)
}
+ limit := h.pendingUsageLimit()
+ h.mu.Lock()
+ backpressureHook := h.usageBackpressureWaitHook
+ h.mu.Unlock()
+
+ state.mu.Lock()
+ if state.closed {
+ state.mu.Unlock()
+ return
+ }
+ if state.quiescing || state.replaying || len(state.pending) > 0 {
+ quiesceWaiter := state.quiescing
+ if quiesceWaiter {
+ state.quiesceWaiters++
+ }
+ state.waitingProducers++
+ notifiedBackpressure := false
+ for !state.closed && (state.quiescing || state.replaying || len(state.pending) > 0) && len(state.pending) >= limit {
+ if !notifiedBackpressure && backpressureHook != nil {
+ notifiedBackpressure = true
+ backpressureHook(pluginID)
+ }
+ state.cond.Wait()
+ }
+ if quiesceWaiter {
+ state.quiesceWaiters--
+ }
+ state.waitingProducers--
+ if state.closed {
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ if state.quiescing || state.replaying || len(state.pending) > 0 {
+ state.pending = append(state.pending, pendingPluginUsageRecord{
+ ctx: ctx,
+ record: record,
+ })
+ state.cond.Broadcast()
+ state.mu.Unlock()
+ return
+ }
+ state.cond.Broadcast()
+ }
+
+ plugin := h.currentUsagePlugin(pluginID)
+ if plugin == nil {
+ state.mu.Unlock()
+ return
+ }
+ state.inFlight++
+ state.mu.Unlock()
+
+ var errDeliver error
+ defer func() {
+ state.mu.Lock()
+ if errDeliver != nil && !state.closed && (state.quiescing || state.replaying) {
+ state.pending = append([]pendingPluginUsageRecord{{
+ ctx: ctx,
+ record: record,
+ }}, state.pending...)
+ }
+ state.inFlight--
+ if state.inFlight == 0 {
+ state.cond.Broadcast()
+ }
+ state.mu.Unlock()
+ }()
+
+ errDeliver = h.invokeUsagePlugin(ctx, pluginID, plugin, record)
+}
+
+func (h *Host) invokeUsagePlugin(ctx context.Context, pluginID string, plugin pluginapi.UsagePlugin, record pluginapi.UsageRecord) (err error) {
+ if h == nil || plugin == nil {
+ return nil
+ }
defer func() {
if recovered := recover(); recovered != nil {
- a.host.fusePlugin(a.pluginID, "UsagePlugin.HandleUsage", recovered)
+ h.fusePlugin(pluginID, "UsagePlugin.HandleUsage", recovered)
+ err = nil
}
}()
+ if rpcAdapter, ok := plugin.(*rpcPluginAdapter); ok {
+ return rpcAdapter.deliverUsage(ctx, record)
+ }
+ plugin.HandleUsage(ctx, record)
+ return nil
+}
+
+func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record) {
+ if a == nil || a.host == nil {
+ return
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ } else {
+ ctx = context.WithoutCancel(ctx)
+ }
sessionID := strings.TrimSpace(record.SessionID)
parentSessionID := strings.TrimSpace(record.ParentSessionID)
if sessionID == "" {
@@ -187,7 +599,7 @@ func (a *usageAdapter) HandleUsage(ctx context.Context, record coreusage.Record)
if apiKey == "" {
apiKey = strings.TrimSpace(coreusage.APIKeyFromContext(ctx))
}
- plugin.HandleUsage(ctx, pluginapi.UsageRecord{
+ a.host.dispatchPluginUsageRecord(ctx, a.pluginID, pluginapi.UsageRecord{
RequestID: requestID,
TraceID: traceID,
SelectionID: selectionID,
diff --git a/internal/pluginhost/auth_provider.go b/internal/pluginhost/auth_provider.go
index d316e05e182..116e51b81fa 100644
--- a/internal/pluginhost/auth_provider.go
+++ b/internal/pluginhost/auth_provider.go
@@ -128,18 +128,77 @@ func isRelativeWithinAuthDir(rel string, errRel error) bool {
return !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
+func hasAuthProviderCap(caps pluginapi.Capabilities) bool {
+ if caps.AuthProvider != nil {
+ return true
+ }
+ for _, p := range caps.AuthProviders {
+ if p != nil {
+ return true
+ }
+ }
+ return false
+}
+
+func hasRecordAuthProvider(record capabilityRecord) bool {
+ return hasAuthProviderCap(record.plugin.Capabilities)
+}
+
+func recordAuthProviders(record capabilityRecord) []pluginapi.AuthProvider {
+ caps := record.plugin.Capabilities
+ out := make([]pluginapi.AuthProvider, 0, 1+len(caps.AuthProviders))
+ if caps.AuthProvider != nil {
+ out = append(out, caps.AuthProvider)
+ }
+ for _, p := range caps.AuthProviders {
+ if p != nil {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func normalizeAuthProviderIdentifiers(values ...string) []string {
+ var out []string
+ seen := make(map[string]struct{}, len(values))
+ for _, raw := range values {
+ for _, part := range strings.Split(raw, ",") {
+ id := normalizeProviderID(part)
+ if id == "" {
+ continue
+ }
+ if _, exists := seen[id]; exists {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ }
+ return out
+}
+
func (h *Host) AuthProviderIdentifiers() []string {
if h == nil {
return nil
}
out := make([]string, 0)
+ seen := make(map[string]struct{})
for _, record := range h.activeRecords() {
- provider := record.plugin.Capabilities.AuthProvider
- if provider == nil || h.isPluginFused(record.id) {
+ if !hasRecordAuthProvider(record) || h.isPluginFused(record.id) {
+ continue
+ }
+ identifiers, okIdentifiers := h.callRecordAuthProviderIdentifiers(record)
+ if !okIdentifiers {
continue
}
- identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, provider)
- if okIdentifier && identifier != "" {
+ for _, identifier := range identifiers {
+ if identifier == "" {
+ continue
+ }
+ if _, exists := seen[identifier]; exists {
+ continue
+ }
+ seen[identifier] = struct{}{}
out = append(out, identifier)
}
}
@@ -150,37 +209,286 @@ func (h *Host) HasAuthProvider(provider string) bool {
return h.authProviderRecord(provider) != nil
}
+// SameAuthProvider reports whether providerA and providerB belong to the same active plugin auth provider.
+func (h *Host) SameAuthProvider(providerA, providerB string) bool {
+ providerA = normalizeProviderID(providerA)
+ providerB = normalizeProviderID(providerB)
+ if h == nil || providerA == "" || providerB == "" {
+ return false
+ }
+ if providerA == providerB {
+ return true
+ }
+ for _, record := range h.activeRecords() {
+ if !hasRecordAuthProvider(record) || h.isPluginFused(record.id) {
+ continue
+ }
+ _, okA := h.recordOwnsCredentialProvider(record, providerA)
+ _, okB := h.recordOwnsCredentialProvider(record, providerB)
+ if okA && okB {
+ return true
+ }
+ }
+ return false
+}
+
func (h *Host) authProviderRecord(provider string) *capabilityRecord {
+ record, _ := h.authProviderEntry(provider)
+ return record
+}
+
+func (h *Host) authProviderEntry(provider string) (*capabilityRecord, pluginapi.AuthProvider) {
provider = normalizeProviderID(provider)
if h == nil || provider == "" {
- return nil
+ return nil, nil
}
- for _, record := range h.activeRecords() {
- authProvider := record.plugin.Capabilities.AuthProvider
- if authProvider == nil || h.isPluginFused(record.id) {
- continue
+ records := h.activeRecords()
+ for _, record := range records {
+ authProv, _, okMatch := h.recordAuthProviderForTarget(record, provider)
+ if okMatch && authProv != nil {
+ copyRecord := record
+ return ©Record, authProv
}
- identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider)
- if okIdentifier && identifier == provider {
+ }
+ for _, record := range records {
+ authProv, okOwn := h.recordOwnsCredentialProvider(record, provider)
+ if okOwn && authProv != nil {
copyRecord := record
- return ©Record
+ return ©Record, authProv
}
}
- return nil
+ return nil, nil
}
-func (h *Host) callAuthProviderIdentifier(pluginID string, provider pluginapi.AuthProvider) (identifier string, ok bool) {
+func (h *Host) authProviderCredentialEntry(provider string) (*capabilityRecord, pluginapi.AuthProvider) {
+ return h.authProviderEntry(provider)
+}
+
+func (h *Host) recordOwnsCredentialProvider(record capabilityRecord, provider string) (pluginapi.AuthProvider, bool) {
+ provider = normalizeProviderID(provider)
+ if h == nil || provider == "" || !hasRecordAuthProvider(record) || h.isPluginFused(record.id) {
+ return nil, false
+ }
+ if authProv, _, okMatch := h.recordAuthProviderForTarget(record, provider); okMatch && authProv != nil {
+ return authProv, true
+ }
+ if portalID, okRecorded := h.recordedCredentialProviderPortal(record.id, provider); okRecorded {
+ if portalID != "" {
+ if authProv, _, okPortal := h.recordAuthProviderForTarget(record, portalID); okPortal && authProv != nil {
+ return authProv, true
+ }
+ }
+ authProv, _, okPrimary := h.recordAuthProviderForTarget(record, "")
+ return authProv, okPrimary && authProv != nil
+ }
+ matched := false
+ for _, candidate := range h.modelProvidersForPlugin(record.id) {
+ if normalizeProviderID(candidate) == provider {
+ matched = true
+ break
+ }
+ }
+ if !matched && record.plugin.Capabilities.Executor != nil {
+ if candidate, okExec := h.executorProvider(record, record.plugin.Capabilities.Executor); okExec && candidate == provider {
+ matched = true
+ }
+ }
+ if matched {
+ authProv, _, okPrimary := h.recordAuthProviderForTarget(record, "")
+ return authProv, okPrimary && authProv != nil
+ }
+ for _, prov := range recordAuthProviders(record) {
+ credIDs, okCredIDs := h.callAuthProviderCredentialProviders(record.id, prov)
+ if !okCredIDs {
+ return nil, false
+ }
+ for _, credID := range credIDs {
+ if credID == provider {
+ return prov, true
+ }
+ }
+ }
+ return nil, false
+}
+
+func (h *Host) recordAuthProviderForTarget(record capabilityRecord, target string) (pluginapi.AuthProvider, string, bool) {
+ if h == nil || !hasRecordAuthProvider(record) || h.isPluginFused(record.id) {
+ return nil, "", false
+ }
+ providers := recordAuthProviders(record)
+ if len(providers) == 0 {
+ return nil, "", false
+ }
+ target = normalizeProviderID(target)
+ if target == "" {
+ identifiers, okIDs := h.callAuthProviderIdentifiers(record.id, providers[0])
+ if !okIDs {
+ return nil, "", false
+ }
+ primary := ""
+ if len(identifiers) > 0 {
+ primary = identifiers[0]
+ }
+ return providers[0], primary, true
+ }
+ for _, prov := range providers {
+ identifiers, okIDs := h.callAuthProviderIdentifiers(record.id, prov)
+ if !okIDs {
+ return nil, "", false
+ }
+ for _, identifier := range identifiers {
+ if identifier == target {
+ return prov, identifier, true
+ }
+ }
+ }
+ return nil, "", false
+}
+
+func (h *Host) callRecordAuthProviderIdentifiers(record capabilityRecord) ([]string, bool) {
+ identifiers, _, ok := h.callRecordAuthProviderMetadata(record)
+ return identifiers, ok
+}
+
+func (h *Host) callRecordAuthProviderMetadata(record capabilityRecord) ([]string, []pluginapi.AuthProviderEntry, bool) {
+ if h == nil || !hasRecordAuthProvider(record) || h.isPluginFused(record.id) {
+ return nil, nil, false
+ }
+ var out []string
+ seen := make(map[string]struct{})
+ byID := make(map[string]pluginapi.AuthProviderEntry)
+ hasDeclaredEntries := false
+ for _, prov := range recordAuthProviders(record) {
+ identifiers, entries, okIDs := h.callAuthProviderMetadata(record.id, prov)
+ if !okIDs {
+ return nil, nil, false
+ }
+ for _, identifier := range identifiers {
+ if identifier == "" {
+ continue
+ }
+ if _, exists := seen[identifier]; exists {
+ continue
+ }
+ seen[identifier] = struct{}{}
+ out = append(out, identifier)
+ }
+ for _, entry := range entries {
+ if entry.Identifier == "" {
+ continue
+ }
+ hasDeclaredEntries = true
+ existing, exists := byID[entry.Identifier]
+ if !exists {
+ byID[entry.Identifier] = entry
+ continue
+ }
+ if existing.Name == "" && entry.Name != "" {
+ existing.Name = entry.Name
+ }
+ if existing.Logo == "" && entry.Logo != "" {
+ existing.Logo = entry.Logo
+ }
+ byID[entry.Identifier] = existing
+ }
+ }
+ if !hasDeclaredEntries || len(out) == 0 {
+ return out, nil, true
+ }
+ defaultName := strings.TrimSpace(record.meta.Name)
+ if defaultName == "" {
+ defaultName = strings.TrimSpace(record.plugin.Metadata.Name)
+ }
+ defaultLogo := strings.TrimSpace(record.meta.Logo)
+ if defaultLogo == "" {
+ defaultLogo = strings.TrimSpace(record.plugin.Metadata.Logo)
+ }
+ entries := make([]pluginapi.AuthProviderEntry, 0, len(out))
+ for _, id := range out {
+ entry := byID[id]
+ entry.Identifier = id
+ if entry.Name == "" {
+ entry.Name = defaultName
+ }
+ if entry.Logo == "" {
+ entry.Logo = defaultLogo
+ }
+ entries = append(entries, entry)
+ }
+ return out, entries, true
+}
+
+func (h *Host) callAuthProviderIdentifiers(pluginID string, provider pluginapi.AuthProvider) (identifiers []string, ok bool) {
+ identifiers, _, ok = h.callAuthProviderMetadata(pluginID, provider)
+ return identifiers, ok
+}
+
+func (h *Host) callAuthProviderMetadata(pluginID string, provider pluginapi.AuthProvider) (identifiers []string, entries []pluginapi.AuthProviderEntry, ok bool) {
if h == nil || provider == nil || h.isPluginFused(pluginID) {
- return "", false
+ return nil, nil, false
}
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(pluginID, "AuthProvider.Identifier", recovered)
- identifier = ""
+ identifiers = nil
+ entries = nil
ok = false
}
}()
- return normalizeProviderID(provider.Identifier()), true
+ raw := []string{provider.Identifier()}
+ if multi, okMulti := provider.(interface{ Identifiers() []string }); okMulti && multi != nil {
+ raw = append(raw, multi.Identifiers()...)
+ }
+ if withEntries, okEntries := provider.(interface {
+ AuthEntries() []pluginapi.AuthProviderEntry
+ }); okEntries && withEntries != nil {
+ for _, entry := range withEntries.AuthEntries() {
+ raw = append(raw, entry.Identifier)
+ ids := normalizeAuthProviderIdentifiers(entry.Identifier)
+ if len(ids) == 0 {
+ continue
+ }
+ name := strings.TrimSpace(entry.Name)
+ logo := strings.TrimSpace(entry.Logo)
+ for _, id := range ids {
+ entries = append(entries, pluginapi.AuthProviderEntry{
+ Identifier: id,
+ Name: name,
+ Logo: logo,
+ })
+ }
+ }
+ }
+ return normalizeAuthProviderIdentifiers(raw...), entries, true
+}
+
+func (h *Host) callAuthProviderCredentialProviders(pluginID string, provider pluginapi.AuthProvider) (providers []string, ok bool) {
+ if h == nil || provider == nil || h.isPluginFused(pluginID) {
+ return nil, false
+ }
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ h.fusePlugin(pluginID, "AuthProvider.Identifier", recovered)
+ providers = nil
+ ok = false
+ }
+ }()
+ cov, okCov := provider.(interface{ CredentialProviders() []string })
+ if !okCov || cov == nil {
+ return nil, true
+ }
+ return normalizeAuthProviderIdentifiers(cov.CredentialProviders()...), true
+}
+
+func (h *Host) callAuthProviderIdentifier(pluginID string, provider pluginapi.AuthProvider) (identifier string, ok bool) {
+ identifiers, ok := h.callAuthProviderIdentifiers(pluginID, provider)
+ if !ok {
+ return "", false
+ }
+ if len(identifiers) == 0 {
+ return "", true
+ }
+ return identifiers[0], true
}
func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*coreauth.Auth, bool, error) {
@@ -191,19 +499,44 @@ func (h *Host) ParseAuth(ctx context.Context, req pluginapi.AuthParseRequest) (*
return auths[0], true, nil
}
+func extractRawJSONOAuthProviderHint(raw []byte) string {
+ if len(bytes.TrimSpace(raw)) == 0 {
+ return ""
+ }
+ var payload map[string]any
+ if errUnmarshal := json.Unmarshal(raw, &payload); errUnmarshal != nil || payload == nil {
+ return ""
+ }
+ for _, key := range []string{"oauth_provider", "portal"} {
+ if value, ok := payload[key].(string); ok {
+ if normalized := normalizeProviderID(value); normalized != "" {
+ return normalized
+ }
+ }
+ }
+ return ""
+}
+
func (h *Host) ParseAuths(ctx context.Context, req pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) {
if h == nil {
return nil, false, nil
}
if strings.TrimSpace(req.Provider) != "" {
- record := h.authProviderRecord(req.Provider)
+ if hint := extractRawJSONOAuthProviderHint(req.RawJSON); hint != "" {
+ if hintRecord, hintProv := h.authProviderEntry(hint); hintRecord != nil && hintProv != nil {
+ if owner, _ := h.authProviderEntry(req.Provider); owner == nil || owner.id == hintRecord.id {
+ return h.callSingleProviderParseAuths(ctx, *hintRecord, hintProv, req)
+ }
+ }
+ }
+ record, _ := h.authProviderCredentialEntry(req.Provider)
if record == nil {
return nil, false, nil
}
return h.callParseAuths(ctx, *record, req)
}
for _, record := range h.activeRecords() {
- if record.plugin.Capabilities.AuthProvider == nil || h.isPluginFused(record.id) {
+ if !hasRecordAuthProvider(record) || h.isPluginFused(record.id) {
continue
}
auths, handled, errParse := h.callParseAuths(ctx, record, req)
@@ -223,10 +556,43 @@ func (h *Host) callParseAuth(ctx context.Context, record capabilityRecord, req p
}
func (h *Host) callParseAuths(ctx context.Context, record capabilityRecord, req pluginapi.AuthParseRequest) (auths []*coreauth.Auth, handled bool, err error) {
- provider := record.plugin.Capabilities.AuthProvider
+ if h == nil || !hasRecordAuthProvider(record) || h.isPluginFused(record.id) || !h.recordCurrent(record) {
+ return nil, false, nil
+ }
+ var candidates []pluginapi.AuthProvider
+ if hint := extractRawJSONOAuthProviderHint(req.RawJSON); hint != "" {
+ if matched, _, okMatch := h.recordAuthProviderForTarget(record, hint); okMatch && matched != nil {
+ candidates = []pluginapi.AuthProvider{matched}
+ }
+ }
+ if len(candidates) == 0 {
+ if target := normalizeProviderID(req.Provider); target != "" {
+ if matched, _, okMatch := h.recordAuthProviderForTarget(record, target); okMatch && matched != nil {
+ candidates = []pluginapi.AuthProvider{matched}
+ } else if owned, okOwn := h.recordOwnsCredentialProvider(record, target); okOwn && owned != nil {
+ candidates = []pluginapi.AuthProvider{owned}
+ }
+ } else {
+ candidates = recordAuthProviders(record)
+ }
+ }
+ for _, provider := range candidates {
+ auths, handled, err = h.callSingleProviderParseAuths(ctx, record, provider, req)
+ if err != nil || handled {
+ return auths, handled, err
+ }
+ }
+ return nil, false, nil
+}
+
+func (h *Host) callSingleProviderParseAuths(ctx context.Context, record capabilityRecord, provider pluginapi.AuthProvider, req pluginapi.AuthParseRequest) (auths []*coreauth.Auth, handled bool, err error) {
if h == nil || provider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return nil, false, nil
}
+ primaryIdentifier, okID := h.callAuthProviderIdentifier(record.id, provider)
+ if !okID {
+ return nil, false, fmt.Errorf("auth provider identifier panic")
+ }
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.ParseAuth", recovered)
@@ -235,12 +601,13 @@ func (h *Host) callParseAuths(ctx context.Context, record capabilityRecord, req
err = fmt.Errorf("auth provider panic: %v", recovered)
}
}()
+ portalIdentifier := h.resolveParsePortalIdentifier(record.id, provider, req.Provider, req.RawJSON, primaryIdentifier)
if req.Host.AuthDir == "" {
req.Host = h.hostConfigSummary()
}
req.Provider = normalizeProviderID(req.Provider)
if req.Provider == "" {
- req.Provider = normalizeProviderID(provider.Identifier())
+ req.Provider = portalIdentifier
}
req.RawJSON = bytes.Clone(req.RawJSON)
resp, errParse := provider.ParseAuth(ctx, req)
@@ -257,20 +624,83 @@ func (h *Host) callParseAuths(ctx context.Context, record capabilityRecord, req
data.Provider = req.Provider
}
if strings.TrimSpace(data.Provider) == "" {
- data.Provider = normalizeProviderID(provider.Identifier())
+ data.Provider = primaryIdentifier
}
if normalizeProviderID(data.Provider) == "" {
return nil, true, fmt.Errorf("auth provider %s returned auth without provider", record.id)
}
+ effectivePortal := firstNonEmpty(authDataPortalHint(&data), portalIdentifier)
+ stampAuthDataOAuthProvider(&data, effectivePortal)
parsed := h.AuthDataToCoreAuth(data, req.Path, req.FileName)
if parsed == nil {
return nil, true, fmt.Errorf("auth provider %s returned invalid auth data", record.id)
}
+ h.recordCredentialProvider(record.id, req.Provider, effectivePortal)
+ h.recordCredentialProvider(record.id, data.Provider, effectivePortal)
auths = append(auths, parsed)
}
return auths, true, nil
}
+func (h *Host) resolveParsePortalIdentifier(pluginID string, provider pluginapi.AuthProvider, reqProvider string, rawJSON []byte, fallback string) string {
+ if identifiers, ok := h.callAuthProviderIdentifiers(pluginID, provider); ok {
+ matches := func(candidate string) bool {
+ candidate = normalizeProviderID(candidate)
+ if candidate == "" {
+ return false
+ }
+ for _, id := range identifiers {
+ if id == candidate {
+ return true
+ }
+ }
+ return false
+ }
+ if hint := extractRawJSONOAuthProviderHint(rawJSON); matches(hint) {
+ return normalizeProviderID(hint)
+ }
+ if matches(reqProvider) {
+ return normalizeProviderID(reqProvider)
+ }
+ }
+ return normalizeProviderID(fallback)
+}
+
+func (h *Host) recordCredentialProvider(pluginID, provider, portalIdentifier string) {
+ pluginID = strings.TrimSpace(pluginID)
+ provider = normalizeProviderID(provider)
+ portalIdentifier = normalizeProviderID(portalIdentifier)
+ if h == nil || pluginID == "" || provider == "" {
+ return
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.authCredentialProviders == nil {
+ h.authCredentialProviders = make(map[string]map[string]string)
+ }
+ set := h.authCredentialProviders[pluginID]
+ if set == nil {
+ set = make(map[string]string)
+ h.authCredentialProviders[pluginID] = set
+ }
+ if _, exists := set[provider]; !exists || set[provider] == "" {
+ set[provider] = portalIdentifier
+ }
+}
+
+func (h *Host) recordedCredentialProviderPortal(pluginID, provider string) (string, bool) {
+ pluginID = strings.TrimSpace(pluginID)
+ provider = normalizeProviderID(provider)
+ if h == nil || pluginID == "" || provider == "" {
+ return "", false
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ set := h.authCredentialProviders[pluginID]
+ portal, ok := set[provider]
+ return portal, ok
+}
+
func pluginAuthParseResponseAuths(resp pluginapi.AuthParseResponse) []pluginapi.AuthData {
if len(resp.Auths) > 0 {
return append([]pluginapi.AuthData(nil), resp.Auths...)
@@ -313,8 +743,11 @@ func (h *Host) StartLogin(ctx context.Context, provider string, baseURL string,
}
func (h *Host) callStartLogin(ctx context.Context, record capabilityRecord, provider string, baseURL string, metadata map[string]any) (resp pluginapi.AuthLoginStartResponse, handled bool, err error) {
- authProvider := record.plugin.Capabilities.AuthProvider
- if h == nil || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
+ authProvider, _, okProvider := h.recordAuthProviderForTarget(record, provider)
+ if !okProvider || authProvider == nil {
+ authProvider, okProvider = h.recordOwnsCredentialProvider(record, provider)
+ }
+ if h == nil || !okProvider || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.AuthLoginStartResponse{}, false, nil
}
defer func() {
@@ -357,10 +790,16 @@ func (h *Host) PollLogin(ctx context.Context, provider, state string, metadata .
}
func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provider, state string, metadata map[string]any) (resp pluginapi.AuthLoginPollResponse, handled bool, err error) {
- authProvider := record.plugin.Capabilities.AuthProvider
- if h == nil || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
+ authProvider, matchedPortal, okProvider := h.recordAuthProviderForTarget(record, provider)
+ if !okProvider || authProvider == nil {
+ authProvider, okProvider = h.recordOwnsCredentialProvider(record, provider)
+ }
+ if h == nil || !okProvider || authProvider == nil || h.isPluginFused(record.id) || !h.recordCurrent(record) {
return pluginapi.AuthLoginPollResponse{}, false, nil
}
+ if matchedPortal == "" {
+ matchedPortal, _ = h.callAuthProviderIdentifier(record.id, authProvider)
+ }
defer func() {
if recovered := recover(); recovered != nil {
h.fusePlugin(record.id, "AuthProvider.PollLogin", recovered)
@@ -385,15 +824,127 @@ func (h *Host) callPollLogin(ctx context.Context, record capabilityRecord, provi
if errPoll != nil {
return pluginapi.AuthLoginPollResponse{}, true, errPoll
}
+ if resp.Status == pluginapi.AuthLoginStatusSuccess {
+ for index := range resp.Auths {
+ stampAuthDataOAuthProvider(&resp.Auths[index], matchedPortal)
+ h.recordCredentialProvider(record.id, resp.Auths[index].Provider, matchedPortal)
+ }
+ stampAuthDataOAuthProvider(&resp.Auth, matchedPortal)
+ h.recordCredentialProvider(record.id, resp.Auth.Provider, matchedPortal)
+ }
return resp, true, nil
}
+func authDataPortalHint(data *pluginapi.AuthData) string {
+ if data == nil {
+ return ""
+ }
+ for _, key := range []string{"oauth_provider", "portal"} {
+ if data.Attributes != nil {
+ if v := normalizeProviderID(data.Attributes[key]); v != "" {
+ return v
+ }
+ }
+ if data.Metadata != nil {
+ if raw, ok := data.Metadata[key].(string); ok {
+ if v := normalizeProviderID(raw); v != "" {
+ return v
+ }
+ }
+ }
+ }
+ return ""
+}
+
+func stampAuthDataOAuthProvider(data *pluginapi.AuthData, portalIdentifier string) {
+ if data == nil {
+ return
+ }
+ portalIdentifier = firstNonEmpty(authDataPortalHint(data), normalizeProviderID(portalIdentifier))
+ if portalIdentifier == "" {
+ return
+ }
+ credProvider := normalizeProviderID(data.Provider)
+ if credProvider == "" || credProvider == portalIdentifier {
+ return
+ }
+ if data.Metadata == nil {
+ data.Metadata = make(map[string]any)
+ }
+ if _, exists := data.Metadata["oauth_provider"]; !exists {
+ data.Metadata["oauth_provider"] = portalIdentifier
+ }
+ if data.Attributes == nil {
+ data.Attributes = make(map[string]string)
+ }
+ if strings.TrimSpace(data.Attributes["oauth_provider"]) == "" {
+ data.Attributes["oauth_provider"] = portalIdentifier
+ }
+}
+
+func authPortalHints(auth *coreauth.Auth) []string {
+ if auth == nil {
+ return nil
+ }
+ out := make([]string, 0, 2)
+ add := func(v string) {
+ v = normalizeProviderID(v)
+ if v == "" {
+ return
+ }
+ for _, existing := range out {
+ if existing == v {
+ return
+ }
+ }
+ out = append(out, v)
+ }
+ for _, key := range []string{"oauth_provider", "portal"} {
+ if auth.Attributes != nil {
+ add(auth.Attributes[key])
+ }
+ if auth.Metadata != nil {
+ if raw, ok := auth.Metadata[key].(string); ok {
+ add(raw)
+ }
+ }
+ }
+ return out
+}
+
+func (h *Host) authProviderCredentialEntryForAuth(auth *coreauth.Auth) (*capabilityRecord, pluginapi.AuthProvider) {
+ if h == nil || auth == nil {
+ return nil, nil
+ }
+ credProv := normalizeProviderID(authProvider(auth))
+ for _, hint := range authPortalHints(auth) {
+ for _, record := range h.activeRecords() {
+ portalAuthProv, _, okPortal := h.recordAuthProviderForTarget(record, hint)
+ if !okPortal || portalAuthProv == nil {
+ continue
+ }
+ if credProv != "" && credProv != hint {
+ if explicitOwner, _ := h.authProviderEntry(credProv); explicitOwner != nil && explicitOwner.id != record.id {
+ continue
+ }
+ h.recordCredentialProvider(record.id, credProv, hint)
+ }
+ copyRecord := record
+ return ©Record, portalAuthProv
+ }
+ }
+ if credProv != "" {
+ return h.authProviderCredentialEntry(credProv)
+ }
+ return nil, nil
+}
+
func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed *coreauth.Auth, handled bool, err error) {
if h == nil || auth == nil {
return nil, false, nil
}
- record := h.authProviderRecord(authProvider(auth))
- if record == nil || record.plugin.Capabilities.AuthProvider == nil {
+ record, authProv := h.authProviderCredentialEntryForAuth(auth)
+ if record == nil || authProv == nil {
return nil, false, nil
}
if !h.recordCurrent(*record) {
@@ -408,7 +959,7 @@ func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed
}
}()
- pluginResp, errRefresh := record.plugin.Capabilities.AuthProvider.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
+ pluginResp, errRefresh := authProv.RefreshAuth(ctx, pluginapi.AuthRefreshRequest{
AuthID: authID(auth),
AuthProvider: authProvider(auth),
StorageJSON: storageJSONFromAuth(auth),
@@ -455,7 +1006,9 @@ func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed
}
data.Attributes = attributes
}
+ preserveAuthOAuthProviderMetadata(&data, auth)
preserveFileAuthPriority(&data, auth)
+ preserveAuthDisabledState(&data, auth)
if len(data.StorageJSON) == 0 {
data.StorageJSON = storageJSONFromAuth(auth)
}
@@ -478,6 +1031,33 @@ func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (refreshed
return next, true, nil
}
+func preserveAuthOAuthProviderMetadata(data *pluginapi.AuthData, auth *coreauth.Auth) {
+ if data == nil || auth == nil || len(auth.Metadata) == 0 {
+ return
+ }
+ for _, key := range []string{"oauth_provider", "portal"} {
+ raw, ok := auth.Metadata[key].(string)
+ if !ok || strings.TrimSpace(raw) == "" {
+ continue
+ }
+ if data.Metadata == nil {
+ data.Metadata = make(map[string]any)
+ }
+ if _, exists := data.Metadata[key]; !exists {
+ data.Metadata[key] = raw
+ }
+ }
+}
+
+func preserveAuthDisabledState(data *pluginapi.AuthData, auth *coreauth.Auth) {
+ if data == nil || auth == nil {
+ return
+ }
+ if auth.Disabled || auth.Status == coreauth.StatusDisabled {
+ data.Disabled = true
+ }
+}
+
func preserveFileAuthPriority(data *pluginapi.AuthData, auth *coreauth.Auth) {
if data == nil || auth == nil || auth.Attributes[coreauth.AttributeSourceBackend] != coreauth.AuthSourceFile {
return
diff --git a/internal/pluginhost/auth_provider_test.go b/internal/pluginhost/auth_provider_test.go
index 361f528afd1..bea1cab3aed 100644
--- a/internal/pluginhost/auth_provider_test.go
+++ b/internal/pluginhost/auth_provider_test.go
@@ -465,6 +465,116 @@ func TestRefreshAuthAllowsPluginPriorityWithoutFilePriority(t *testing.T) {
}
}
+func TestRefreshAuthPreservesDisabledStateWhenPluginOmitsFlag_Issue7225(t *testing.T) {
+ host := newHostWithRecords(capabilityRecord{
+ id: "auth-plugin",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: "plugin-provider",
+ refreshAuth: func(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ return pluginapi.AuthRefreshResponse{Auth: pluginapi.AuthData{
+ Metadata: map[string]any{"access_token": "new-token"},
+ }}, nil
+ },
+ },
+ }},
+ })
+ auth := &coreauth.Auth{
+ ID: "auth-1",
+ Provider: "plugin-provider",
+ Disabled: true,
+ Status: coreauth.StatusDisabled,
+ Metadata: map[string]any{"access_token": "old-token"},
+ }
+ refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), auth)
+ if errRefresh != nil || !handled || refreshed == nil {
+ t.Fatalf("RefreshAuth() auth = %#v, handled = %t, error = %v", refreshed, handled, errRefresh)
+ }
+ if !refreshed.Disabled || refreshed.Status != coreauth.StatusDisabled {
+ t.Fatalf("RefreshAuth() disabled=%t status=%q, want disabled auth to stay disabled", refreshed.Disabled, refreshed.Status)
+ }
+ if got := refreshed.Metadata["access_token"]; got != "new-token" {
+ t.Fatalf("RefreshAuth() access_token = %q, want new-token", got)
+ }
+}
+
+func TestRefreshAuthPreservesDisabledStatusWithoutFlag_Issue7225(t *testing.T) {
+ host := newHostWithRecords(capabilityRecord{
+ id: "auth-plugin",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: "plugin-provider",
+ refreshAuth: func(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ return pluginapi.AuthRefreshResponse{Auth: pluginapi.AuthData{
+ Metadata: map[string]any{"access_token": "new-token"},
+ }}, nil
+ },
+ },
+ }},
+ })
+ auth := &coreauth.Auth{
+ ID: "auth-1",
+ Provider: "plugin-provider",
+ Status: coreauth.StatusDisabled,
+ Metadata: map[string]any{"access_token": "old-token"},
+ }
+ refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), auth)
+ if errRefresh != nil || !handled || refreshed == nil {
+ t.Fatalf("RefreshAuth() auth = %#v, handled = %t, error = %v", refreshed, handled, errRefresh)
+ }
+ if !refreshed.Disabled || refreshed.Status != coreauth.StatusDisabled {
+ t.Fatalf("RefreshAuth() disabled=%t status=%q, want disabled auth to stay disabled", refreshed.Disabled, refreshed.Status)
+ }
+}
+
+func TestRefreshAuthDisabledStateOneWayPreservation_Issue7225(t *testing.T) {
+ tests := []struct {
+ name string
+ pluginDisabled bool
+ wantDisabled bool
+ }{
+ {name: "enabled auth stays enabled when plugin omits flag", pluginDisabled: false, wantDisabled: false},
+ {name: "enabled auth becomes disabled when plugin returns disabled", pluginDisabled: true, wantDisabled: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ host := newHostWithRecords(capabilityRecord{
+ id: "auth-plugin",
+ plugin: pluginapi.Plugin{Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: "plugin-provider",
+ refreshAuth: func(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ return pluginapi.AuthRefreshResponse{Auth: pluginapi.AuthData{
+ Disabled: tt.pluginDisabled,
+ Metadata: map[string]any{"access_token": "new-token"},
+ }}, nil
+ },
+ },
+ }},
+ })
+ auth := &coreauth.Auth{
+ ID: "auth-1",
+ Provider: "plugin-provider",
+ Metadata: map[string]any{"access_token": "old-token"},
+ }
+ refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), auth)
+ if errRefresh != nil || !handled || refreshed == nil {
+ t.Fatalf("RefreshAuth() auth = %#v, handled = %t, error = %v", refreshed, handled, errRefresh)
+ }
+ if refreshed.Disabled != tt.wantDisabled {
+ t.Fatalf("RefreshAuth() disabled=%t, want %t", refreshed.Disabled, tt.wantDisabled)
+ }
+ wantStatus := coreauth.StatusActive
+ if tt.wantDisabled {
+ wantStatus = coreauth.StatusDisabled
+ }
+ if refreshed.Status != wantStatus {
+ t.Fatalf("RefreshAuth() status=%q, want %q", refreshed.Status, wantStatus)
+ }
+ })
+ }
+}
+
func TestPluginTokenStorageRemovesDeletedPriority(t *testing.T) {
storage := &pluginTokenStorage{
provider: "plugin-provider",
@@ -710,3 +820,544 @@ func TestRefreshAuth_MergesAttributesAndPreservesPath_Issue6119(t *testing.T) {
})
}
}
+
+func TestAuthProviderMultiPortalRegistrationAndRouting(t *testing.T) {
+ t.Run("multi_identifier_provider_with_shared_executor", func(t *testing.T) {
+ var startedProviders []string
+ var polledProviders []string
+ var refreshedProviders []string
+ host := newHostWithRecords(capabilityRecord{
+ id: "omc-zcode-oauth",
+ priority: 10,
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: " ZCode-ZAI ",
+ identifiers: []string{" ZCode-ZAI ", "zcode-bigmodel", "zcode-zai"},
+ parseAuth: func(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ return pluginapi.AuthParseResponse{
+ Handled: true,
+ Auth: pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "zcode-auth-1",
+ },
+ }, nil
+ },
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ startedProviders = append(startedProviders, req.Provider)
+ return pluginapi.AuthLoginStartResponse{
+ Provider: req.Provider,
+ URL: "https://portal.example/" + req.Provider,
+ State: "state-" + req.Provider,
+ }, nil
+ },
+ pollLogin: func(_ context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ polledProviders = append(polledProviders, req.Provider)
+ return pluginapi.AuthLoginPollResponse{
+ Status: pluginapi.AuthLoginStatusSuccess,
+ Auth: pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "zcode-auth-" + req.Provider,
+ },
+ }, nil
+ },
+ refreshAuth: func(_ context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ refreshedProviders = append(refreshedProviders, req.AuthProvider)
+ return pluginapi.AuthRefreshResponse{
+ Auth: pluginapi.AuthData{
+ Provider: req.AuthProvider,
+ ID: req.AuthID,
+ Metadata: map[string]any{"access_token": "refreshed"},
+ },
+ }, nil
+ },
+ },
+ Executor: &fakeExecutor{identifier: "zcode"},
+ ExecutorModelScope: pluginapi.ExecutorModelScopeOAuth,
+ ModelProvider: modelProviderFunc{
+ modelsForAuth: func(_ context.Context, req pluginapi.AuthModelRequest) (pluginapi.ModelResponse, error) {
+ return pluginapi.ModelResponse{
+ Provider: req.AuthProvider,
+ Models: []pluginapi.ModelInfo{{ID: "glm-5"}},
+ }, nil
+ },
+ },
+ },
+ },
+ })
+
+ infos := host.RegisteredPlugins()
+ if len(infos) != 1 {
+ t.Fatalf("RegisteredPlugins() len = %d, want 1", len(infos))
+ }
+ if !infos[0].SupportsOAuth || infos[0].OAuthProvider != "zcode-zai" {
+ t.Fatalf("RegisteredPlugins()[0] = %#v, want SupportsOAuth=true OAuthProvider=zcode-zai", infos[0])
+ }
+ wantProviders := []string{"zcode-zai", "zcode-bigmodel"}
+ if !reflect.DeepEqual(infos[0].OAuthProviders, wantProviders) {
+ t.Fatalf("RegisteredPlugins()[0].OAuthProviders = %#v, want %#v", infos[0].OAuthProviders, wantProviders)
+ }
+ if infos[0].OAuthProviderEntries != nil {
+ t.Fatalf("RegisteredPlugins()[0].OAuthProviderEntries = %#v, want nil when AuthEntries is not declared", infos[0].OAuthProviderEntries)
+ }
+ if got := host.AuthProviderIdentifiers(); !reflect.DeepEqual(got, wantProviders) {
+ t.Fatalf("AuthProviderIdentifiers() = %#v, want %#v", got, wantProviders)
+ }
+ for _, portal := range wantProviders {
+ if !host.HasAuthProvider(portal) {
+ t.Fatalf("HasAuthProvider(%q) = false, want true", portal)
+ }
+ startResp, handled, errStart := host.StartLogin(context.Background(), portal, "http://127.0.0.1:8317/v8/management/oauth/callback")
+ if errStart != nil || !handled || startResp.Provider != portal {
+ t.Fatalf("StartLogin(%q) = (%#v, %v, %v), want handled response", portal, startResp, handled, errStart)
+ }
+ pollResp, handled, errPoll := host.PollLogin(context.Background(), portal, startResp.State)
+ if errPoll != nil || !handled || pollResp.Status != pluginapi.AuthLoginStatusSuccess {
+ t.Fatalf("PollLogin(%q) = (%#v, %v, %v), want success", portal, pollResp, handled, errPoll)
+ }
+ }
+ if !reflect.DeepEqual(startedProviders, wantProviders) || !reflect.DeepEqual(polledProviders, wantProviders) {
+ t.Fatalf("started=%#v polled=%#v, want %#v", startedProviders, polledProviders, wantProviders)
+ }
+ if !host.SameAuthProvider("zcode", "zcode-bigmodel") || !host.SameAuthProvider("zcode-zai", "zcode-bigmodel") {
+ t.Fatal("SameAuthProvider() = false, want true for providers owned by the same plugin")
+ }
+ for _, credProvider := range []string{"zcode", "zcode-zai", "zcode-bigmodel"} {
+ if !host.HasAuthModelProvider(credProvider) {
+ t.Fatalf("HasAuthModelProvider(%q) = false, want true", credProvider)
+ }
+ refreshed, handled, errRefresh := host.RefreshAuth(context.Background(), &coreauth.Auth{
+ ID: "auth-" + credProvider,
+ Provider: credProvider,
+ })
+ if errRefresh != nil || !handled || refreshed == nil {
+ t.Fatalf("RefreshAuth(%q) = (%#v, %v, %v), want handled refresh", credProvider, refreshed, handled, errRefresh)
+ }
+ }
+ })
+
+ t.Run("plural_auth_providers_capability", func(t *testing.T) {
+ var calledPortal string
+ host := newHostWithRecords(capabilityRecord{
+ id: "multi-cap-plugin",
+ priority: 10,
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProviders: []pluginapi.AuthProvider{
+ fakeAuthProvider{
+ identifier: "portal-one",
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ calledPortal = "one:" + req.Provider
+ return pluginapi.AuthLoginStartResponse{Provider: req.Provider, URL: "https://one.example", State: "state-1"}, nil
+ },
+ },
+ fakeAuthProvider{
+ identifier: "portal-two",
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ calledPortal = "two:" + req.Provider
+ return pluginapi.AuthLoginStartResponse{Provider: req.Provider, URL: "https://two.example", State: "state-2"}, nil
+ },
+ },
+ },
+ },
+ },
+ })
+
+ infos := host.RegisteredPlugins()
+ if len(infos) != 1 || !infos[0].SupportsOAuth || infos[0].OAuthProvider != "portal-one" {
+ t.Fatalf("RegisteredPlugins() = %#v, want portal-one primary", infos)
+ }
+ if !reflect.DeepEqual(infos[0].OAuthProviders, []string{"portal-one", "portal-two"}) {
+ t.Fatalf("OAuthProviders = %#v, want [portal-one portal-two]", infos[0].OAuthProviders)
+ }
+ _, handled, errStart := host.StartLogin(context.Background(), "portal-two", "http://127.0.0.1:8317/callback")
+ if errStart != nil || !handled || calledPortal != "two:portal-two" {
+ t.Fatalf("StartLogin(portal-two) handled=%v err=%v calledPortal=%q, want two:portal-two", handled, errStart, calledPortal)
+ }
+ })
+
+ t.Run("plural_auth_providers_interleaved_shared_credential_refresh_and_parse", func(t *testing.T) {
+ refreshedBy := ""
+ parsedBy := ""
+ host := newHostWithRecords(capabilityRecord{
+ id: "multi-handler-plugin",
+ priority: 10,
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProviders: []pluginapi.AuthProvider{
+ fakeAuthProvider{
+ identifier: "portal-one",
+ pollLogin: func(_ context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{
+ Status: pluginapi.AuthLoginStatusSuccess,
+ Auth: pluginapi.AuthData{
+ Provider: "vendor-shared",
+ ID: "cred-one",
+ },
+ }, nil
+ },
+ parseAuth: func(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ parsedBy = "one"
+ return pluginapi.AuthParseResponse{
+ Handled: true,
+ Auth: pluginapi.AuthData{Provider: "vendor-shared", ID: "cred-one"},
+ }, nil
+ },
+ refreshAuth: func(_ context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ refreshedBy = "one"
+ return pluginapi.AuthRefreshResponse{Auth: pluginapi.AuthData{Provider: req.AuthProvider, ID: req.AuthID}}, nil
+ },
+ },
+ fakeAuthProvider{
+ identifier: "portal-two",
+ pollLogin: func(_ context.Context, req pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{
+ Status: pluginapi.AuthLoginStatusSuccess,
+ Auth: pluginapi.AuthData{
+ Provider: "vendor-shared",
+ ID: "cred-two",
+ },
+ }, nil
+ },
+ parseAuth: func(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ parsedBy = "two"
+ return pluginapi.AuthParseResponse{
+ Handled: true,
+ Auth: pluginapi.AuthData{Provider: "vendor-shared", ID: "cred-two"},
+ }, nil
+ },
+ refreshAuth: func(_ context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ refreshedBy = "two"
+ return pluginapi.AuthRefreshResponse{Auth: pluginapi.AuthData{Provider: req.AuthProvider, ID: req.AuthID}}, nil
+ },
+ },
+ },
+ },
+ },
+ })
+
+ // Log in via portal-one first, then portal-two; portal-two must NOT overwrite portal-one's credential handler.
+ pollOne, handled, errPoll := host.PollLogin(context.Background(), "portal-one", "state-1")
+ if errPoll != nil || !handled || pollOne.Status != pluginapi.AuthLoginStatusSuccess {
+ t.Fatalf("PollLogin(portal-one) = (%#v, %v, %v), want success", pollOne, handled, errPoll)
+ }
+ pollTwo, handled, errPoll := host.PollLogin(context.Background(), "portal-two", "state-2")
+ if errPoll != nil || !handled || pollTwo.Status != pluginapi.AuthLoginStatusSuccess {
+ t.Fatalf("PollLogin(portal-two) = (%#v, %v, %v), want success", pollTwo, handled, errPoll)
+ }
+ credOne := host.AuthDataToCoreAuth(pollOne.Auth, "", "cred-one.json")
+ credTwo := host.AuthDataToCoreAuth(pollTwo.Auth, "", "cred-two.json")
+
+ _, handled, errRefresh := host.RefreshAuth(context.Background(), credOne)
+ if errRefresh != nil || !handled || refreshedBy != "one" {
+ t.Fatalf("RefreshAuth(credOne) handled=%v err=%v refreshedBy=%q, want one", handled, errRefresh, refreshedBy)
+ }
+ _, handled, errRefresh = host.RefreshAuth(context.Background(), credTwo)
+ if errRefresh != nil || !handled || refreshedBy != "two" {
+ t.Fatalf("RefreshAuth(credTwo) handled=%v err=%v refreshedBy=%q, want two", handled, errRefresh, refreshedBy)
+ }
+ parsedOne, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
+ Provider: "vendor-shared",
+ RawJSON: []byte(`{"type":"vendor-shared","oauth_provider":"portal-one"}`),
+ })
+ if errParse != nil || !handled || parsedBy != "one" || parsedOne == nil {
+ t.Fatalf("ParseAuth(portal-one) handled=%v err=%v parsedBy=%q auth=%#v, want one", handled, errParse, parsedBy, parsedOne)
+ }
+ parsedTwo, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
+ Provider: "vendor-shared",
+ RawJSON: []byte(`{"type":"vendor-shared","oauth_provider":"portal-two"}`),
+ })
+ if errParse != nil || !handled || parsedBy != "two" || parsedTwo == nil {
+ t.Fatalf("ParseAuth(portal-two) handled=%v err=%v parsedBy=%q auth=%#v, want two", handled, errParse, parsedBy, parsedTwo)
+ }
+ refreshedBy = ""
+ _, handled, errRefresh = host.RefreshAuth(context.Background(), parsedOne)
+ if errRefresh != nil || !handled || refreshedBy != "one" {
+ t.Fatalf("RefreshAuth(parsedOne) handled=%v err=%v refreshedBy=%q, want one", handled, errRefresh, refreshedBy)
+ }
+ refreshedBy = ""
+ _, handled, errRefresh = host.RefreshAuth(context.Background(), parsedTwo)
+ if errRefresh != nil || !handled || refreshedBy != "two" {
+ t.Fatalf("RefreshAuth(parsedTwo) handled=%v err=%v refreshedBy=%q, want two", handled, errRefresh, refreshedBy)
+ }
+ })
+
+ t.Run("no_prefix_hijack_or_unrelated_parse_broadcast", func(t *testing.T) {
+ unrelatedParseCalled := false
+ host := newHostWithRecords(capabilityRecord{
+ id: "vendor-cn-plugin",
+ priority: 10,
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: "vendor-cn",
+ parseAuth: func(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ unrelatedParseCalled = true
+ return pluginapi.AuthParseResponse{Handled: true, Auth: pluginapi.AuthData{Provider: req.Provider, ID: "hijacked"}}, nil
+ },
+ },
+ },
+ },
+ })
+
+ // "vendor-cn" must NOT implicitly claim "vendor" via prefix guessing
+ if host.HasAuthProvider("vendor") {
+ t.Fatal("HasAuthProvider(vendor) = true for vendor-cn plugin, want false")
+ }
+ if host.SameAuthProvider("vendor", "vendor-cn") {
+ t.Fatal("SameAuthProvider(vendor, vendor-cn) = true without ownership declaration, want false")
+ }
+ // ParseAuth with an explicit unrelated provider ("claude" or "vendor") must NOT broadcast to vendor-cn
+ _, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
+ Provider: "claude",
+ RawJSON: []byte(`{"type":"claude","access_token":"tok"}`),
+ })
+ if errParse != nil || handled || unrelatedParseCalled {
+ t.Fatalf("ParseAuth(claude) = (handled=%v, err=%v), unrelatedParseCalled=%v, want not called", handled, errParse, unrelatedParseCalled)
+ }
+ })
+
+ t.Run("cold_start_parse_and_refresh_without_executor", func(t *testing.T) {
+ // Simulate a fresh Host after restart where an auth-only plugin has portal identifiers
+ // ("zai", "bigmodel") and either persisted oauth_provider metadata in RawJSON or declared CredentialProviders.
+ refreshed := false
+ host := newHostWithRecords(capabilityRecord{
+ id: "omc-zcode-oauth",
+ priority: 10,
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: "zai",
+ identifiers: []string{"zai", "bigmodel"},
+ parseAuth: func(_ context.Context, req pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ if req.Provider != "zcode" {
+ return pluginapi.AuthParseResponse{Handled: false}, nil
+ }
+ return pluginapi.AuthParseResponse{
+ Handled: true,
+ Auth: pluginapi.AuthData{
+ Provider: "zcode",
+ ID: "zcode-cold.json",
+ Metadata: map[string]any{"oauth_provider": "bigmodel"},
+ },
+ }, nil
+ },
+ refreshAuth: func(_ context.Context, req pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ refreshed = true
+ return pluginapi.AuthRefreshResponse{
+ Auth: pluginapi.AuthData{
+ Provider: req.AuthProvider,
+ ID: req.AuthID,
+ },
+ }, nil
+ },
+ },
+ },
+ },
+ })
+
+ auth, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
+ Provider: "zcode",
+ FileName: "zcode-cold.json",
+ RawJSON: []byte(`{"type":"zcode","oauth_provider":"bigmodel","token":"abc"}`),
+ })
+ if errParse != nil || !handled || auth == nil || auth.Provider != "zcode" {
+ t.Fatalf("cold-start ParseAuth(zcode) = (%#v, %v, %v), want handled zcode auth", auth, handled, errParse)
+ }
+ authNoRawHint, handled, errParse := host.ParseAuth(context.Background(), pluginapi.AuthParseRequest{
+ Provider: "zcode",
+ FileName: "zcode-cold-nohint.json",
+ RawJSON: []byte(`{"type":"zcode","token":"abc"}`),
+ })
+ if errParse != nil || !handled || authNoRawHint == nil {
+ t.Fatalf("cold-start ParseAuth without raw hint = (%#v, %v, %v), want handled", authNoRawHint, handled, errParse)
+ }
+ if authNoRawHint.Metadata["oauth_provider"] != "bigmodel" || authNoRawHint.Attributes["oauth_provider"] != "bigmodel" {
+ t.Fatalf("cold-start ParseAuth without raw hint attribution = (meta=%#v, attr=%q), want both bigmodel", authNoRawHint.Metadata["oauth_provider"], authNoRawHint.Attributes["oauth_provider"])
+ }
+ if !host.SameAuthProvider("zcode", "zai") || !host.SameAuthProvider("zcode", "bigmodel") {
+ t.Fatal("SameAuthProvider after cold-start ParseAuth = false, want true")
+ }
+ _, handled, errRefresh := host.RefreshAuth(context.Background(), auth)
+ if errRefresh != nil || !handled || !refreshed {
+ t.Fatalf("RefreshAuth(zcode) after cold-start ParseAuth = (%v, %v), refreshed=%v", handled, errRefresh, refreshed)
+ }
+ })
+
+ t.Run("auth_entries_projection_and_metadata_fallback", func(t *testing.T) {
+ var started []string
+ host := newHostWithRecords(capabilityRecord{
+ id: "omc-zcode-oauth",
+ priority: 10,
+ meta: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Logo: "https://default.example/zcode.svg",
+ },
+ plugin: pluginapi.Plugin{
+ Metadata: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Logo: "https://default.example/zcode.svg",
+ },
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: " ZCode-ZAI ",
+ entries: []pluginapi.AuthProviderEntry{
+ {
+ Identifier: " ZCode-ZAI ",
+ Name: "Z.ai",
+ Logo: "https://chat.z.ai/logo.svg",
+ },
+ {
+ Identifier: " ZCode-BigModel ",
+ Name: "BigModel",
+ Logo: "",
+ },
+ {
+ Identifier: "zcode-trial",
+ Name: "",
+ Logo: "https://trial.example/logo.svg",
+ },
+ },
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ started = append(started, req.Provider)
+ return pluginapi.AuthLoginStartResponse{
+ Provider: req.Provider,
+ URL: "https://portal.example/" + req.Provider,
+ State: "state-" + req.Provider,
+ }, nil
+ },
+ },
+ },
+ },
+ })
+
+ infos := host.RegisteredPlugins()
+ if len(infos) != 1 {
+ t.Fatalf("RegisteredPlugins() len = %d, want 1", len(infos))
+ }
+ wantProviders := []string{"zcode-zai", "zcode-bigmodel", "zcode-trial"}
+ if infos[0].OAuthProvider != "zcode-zai" || !reflect.DeepEqual(infos[0].OAuthProviders, wantProviders) {
+ t.Fatalf("OAuthProvider=%q OAuthProviders=%#v, want %#v", infos[0].OAuthProvider, infos[0].OAuthProviders, wantProviders)
+ }
+ wantEntries := []pluginapi.AuthProviderEntry{
+ {
+ Identifier: "zcode-zai",
+ Name: "Z.ai",
+ Logo: "https://chat.z.ai/logo.svg",
+ },
+ {
+ Identifier: "zcode-bigmodel",
+ Name: "BigModel",
+ Logo: "https://default.example/zcode.svg",
+ },
+ {
+ Identifier: "zcode-trial",
+ Name: "OMC ZCode OAuth",
+ Logo: "https://trial.example/logo.svg",
+ },
+ }
+ if !reflect.DeepEqual(infos[0].OAuthProviderEntries, wantEntries) {
+ t.Fatalf("OAuthProviderEntries = %#v, want %#v", infos[0].OAuthProviderEntries, wantEntries)
+ }
+ for _, portal := range wantProviders {
+ if !host.HasAuthProvider(portal) {
+ t.Fatalf("HasAuthProvider(%q) = false, want true", portal)
+ }
+ resp, handled, errStart := host.StartLogin(context.Background(), portal, "http://127.0.0.1:8317/callback")
+ if errStart != nil || !handled || resp.Provider != portal {
+ t.Fatalf("StartLogin(%q) = (%#v, %v, %v), want handled", portal, resp, handled, errStart)
+ }
+ }
+ if !reflect.DeepEqual(started, wantProviders) {
+ t.Fatalf("started = %#v, want %#v", started, wantProviders)
+ }
+ })
+
+ t.Run("plural_auth_providers_mixed_entries_and_legacy_type_without_auth_entries", func(t *testing.T) {
+ host := newHostWithRecords(
+ capabilityRecord{
+ id: "mixed-cap-plugin",
+ priority: 20,
+ meta: pluginapi.Metadata{
+ Name: "Mixed Portal Plugin",
+ Logo: "https://default.example/mixed.svg",
+ },
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProviders: []pluginapi.AuthProvider{
+ fakeAuthProvider{
+ identifier: "portal-one",
+ entries: []pluginapi.AuthProviderEntry{
+ {Identifier: " , "},
+ {Identifier: "Portal-One", Name: "", Logo: "https://one.example/logo.svg"},
+ {Identifier: "portal-one", Name: "Portal One Filled", Logo: ""},
+ },
+ },
+ legacySingleAuthProvider{identifier: "portal-two"},
+ },
+ },
+ },
+ },
+ capabilityRecord{
+ id: "strict-legacy-plugin",
+ priority: 10,
+ meta: pluginapi.Metadata{
+ Name: "Strict Legacy Plugin",
+ Logo: "https://default.example/legacy.svg",
+ },
+ plugin: pluginapi.Plugin{
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: legacySingleAuthProvider{identifier: "strict-legacy"},
+ },
+ },
+ },
+ )
+
+ infos := host.RegisteredPlugins()
+ if len(infos) != 2 {
+ t.Fatalf("RegisteredPlugins() len = %d, want 2", len(infos))
+ }
+ wantMixedEntries := []pluginapi.AuthProviderEntry{
+ {
+ Identifier: "portal-one",
+ Name: "Portal One Filled",
+ Logo: "https://one.example/logo.svg",
+ },
+ {
+ Identifier: "portal-two",
+ Name: "Mixed Portal Plugin",
+ Logo: "https://default.example/mixed.svg",
+ },
+ }
+ if !reflect.DeepEqual(infos[0].OAuthProviderEntries, wantMixedEntries) {
+ t.Fatalf("mixed plugin OAuthProviderEntries = %#v, want %#v", infos[0].OAuthProviderEntries, wantMixedEntries)
+ }
+ if infos[1].OAuthProvider != "strict-legacy" || infos[1].OAuthProviderEntries != nil {
+ t.Fatalf("strict legacy plugin = %#v, want OAuthProvider=strict-legacy and OAuthProviderEntries=nil", infos[1])
+ }
+ })
+}
+
+type legacySingleAuthProvider struct {
+ identifier string
+}
+
+func (p legacySingleAuthProvider) Identifier() string {
+ return p.identifier
+}
+
+func (legacySingleAuthProvider) ParseAuth(context.Context, pluginapi.AuthParseRequest) (pluginapi.AuthParseResponse, error) {
+ return pluginapi.AuthParseResponse{}, nil
+}
+
+func (legacySingleAuthProvider) StartLogin(context.Context, pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ return pluginapi.AuthLoginStartResponse{}, nil
+}
+
+func (legacySingleAuthProvider) PollLogin(context.Context, pluginapi.AuthLoginPollRequest) (pluginapi.AuthLoginPollResponse, error) {
+ return pluginapi.AuthLoginPollResponse{}, nil
+}
+
+func (legacySingleAuthProvider) RefreshAuth(context.Context, pluginapi.AuthRefreshRequest) (pluginapi.AuthRefreshResponse, error) {
+ return pluginapi.AuthRefreshResponse{}, nil
+}
diff --git a/internal/pluginhost/host.go b/internal/pluginhost/host.go
index d1c8b5420d4..f95b69b5e60 100644
--- a/internal/pluginhost/host.go
+++ b/internal/pluginhost/host.go
@@ -28,6 +28,7 @@ type loadedPlugin struct {
configYAML []byte
plugin pluginapi.Plugin
registered bool
+ needsRecovery bool
client pluginClient
callbackInstance *hostCallbackInstance
}
@@ -79,6 +80,7 @@ type Host struct {
executorModelClientIDs map[string]struct{}
modelProviders map[string]string
modelProviderSets map[string][]string
+ authCredentialProviders map[string]map[string]string
modelRegistrations map[string]pluginModelRegistration
pluginModelRegistrations map[string][]pluginModelRegistration
providerModels map[string][]*registryModelInfo
@@ -93,9 +95,15 @@ type Host struct {
httpOperations *hostHTTPOperationBridge
modelStreams *modelStreamBridge
callbackContexts *callbackContextRegistry
+ usageStates map[string]*pluginUsageState
+ maxPendingUsageRecords int
snapshot atomic.Value
// loadCleanupDoneHook, set by tests, runs after a late load cleanup dropped its load token.
loadCleanupDoneHook func()
+ // usageDrainWaitHook, set by tests, runs when beginUsageReplacement waits for admitted usage calls to drain.
+ usageDrainWaitHook func(pluginID string)
+ // usageBackpressureWaitHook, set by tests, runs when dispatchPluginUsageRecord blocks on a full pending queue.
+ usageBackpressureWaitHook func(pluginID string)
}
func New() *Host {
@@ -114,6 +122,7 @@ func New() *Host {
executorModelClientIDs: make(map[string]struct{}),
modelProviders: make(map[string]string),
modelProviderSets: make(map[string][]string),
+ authCredentialProviders: make(map[string]map[string]string),
modelRegistrations: make(map[string]pluginModelRegistration),
pluginModelRegistrations: make(map[string][]pluginModelRegistration),
providerModels: make(map[string][]*registryModelInfo),
@@ -128,6 +137,7 @@ func New() *Host {
httpOperations: newHostHTTPOperationBridge(),
modelStreams: newModelStreamBridge(),
callbackContexts: newCallbackContextRegistry(),
+ usageStates: make(map[string]*pluginUsageState),
}
h.snapshot.Store(emptySnapshot())
return h
@@ -260,6 +270,12 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
records := make([]capabilityRecord, 0, len(files))
loadedFiles := make([]pluginFile, 0, len(files))
hotReloadLogs := make([]log.Fields, 0)
+ readyUsagePluginIDs := make([]string, 0)
+ defer func() {
+ for _, id := range readyUsagePluginIDs {
+ h.finishUsageReplacement(id)
+ }
+ }()
for _, file := range files {
item, ok := rc.Items[file.ID]
if !ok {
@@ -296,12 +312,31 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.mu.Unlock()
if replaced != nil {
- h.callQuiesce(ctx, replaced)
+ if !h.beginUsageReplacement(ctx, file.ID) {
+ h.clearLoadingRequest(file.ID, request)
+ if !h.isPluginUnrecovered(replaced) {
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ }
+ return
+ }
+ _, proceedQuiesce := h.callQuiesceWithOutcome(ctx, replaced)
if errContext := ctx.Err(); errContext != nil {
h.clearLoadingRequest(file.ID, request)
- _, _, _ = h.rollbackReplacement(replaced, item)
+ if _, _, okRollback := h.rollbackReplacement(replaced, item); okRollback {
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ }
return
}
+ if !proceedQuiesce {
+ h.clearLoadingRequest(file.ID, request)
+ if rollbackRecord, rollbackFile, okRollback := h.rollbackReplacement(replaced, item); okRollback {
+ records = append(records, rollbackRecord)
+ loadedFiles = append(loadedFiles, rollbackFile)
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ h.finishUsageReplacement(file.ID)
+ }
+ continue
+ }
}
h.startPluginLoad(ctx, file, item, request)
@@ -311,7 +346,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.cleanupCanceledPluginLoad(file.ID, request)
} else {
h.cleanupCanceledPluginLoadAndWait(file.ID, request)
- _, _, _ = h.rollbackReplacement(replaced, item)
+ if _, _, okRollback := h.rollbackReplacement(replaced, item); okRollback {
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ }
}
return
}
@@ -323,6 +360,8 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
if rollbackRecord, rollbackFile, okRollback := h.rollbackReplacement(replaced, item); okRollback {
records = append(records, rollbackRecord)
loadedFiles = append(loadedFiles, rollbackFile)
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ h.finishUsageReplacement(file.ID)
}
}
if loadResult.err != nil {
@@ -336,7 +375,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.mu.Unlock()
h.discardLoadedPlugin(loadResult.loaded)
if replaced != nil {
- _, _, _ = h.rollbackReplacement(replaced, item)
+ if _, _, okRollback := h.rollbackReplacement(replaced, item); okRollback {
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ }
}
return
}
@@ -346,7 +387,9 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.cleanupPluginLoad(file.ID, request, loadResult.loaded)
} else {
h.cleanupPluginLoadAndWait(file.ID, request, loadResult.loaded)
- _, _, _ = h.rollbackReplacement(replaced, item)
+ if _, _, okRollback := h.rollbackReplacement(replaced, item); okRollback {
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ }
}
return
}
@@ -391,14 +434,17 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
if hotReloadFields != nil {
hotReloadLogs = append(hotReloadLogs, hotReloadFields)
}
- records = append(records, capabilityRecord{
+ record := capabilityRecord{
id: file.ID,
path: file.Path,
version: file.Version,
priority: item.Priority,
meta: plugin.Metadata,
plugin: plugin,
- })
+ }
+ h.updateActivePluginRecord(record)
+ readyUsagePluginIDs = append(readyUsagePluginIDs, file.ID)
+ records = append(records, record)
loadedFiles = append(loadedFiles, file)
}
@@ -411,6 +457,10 @@ func (h *Host) ApplyConfig(ctx context.Context, cfg *config.Config) {
h.rebuildActivePluginMapsLocked(records)
h.snapshot.Store(&Snapshot{enabled: true, records: records, quotaSupportedProviders: make(map[string][]string)})
h.mu.Unlock()
+ for _, id := range readyUsagePluginIDs {
+ h.finishUsageReplacement(id)
+ }
+ readyUsagePluginIDs = nil
h.refreshThinkingProviders(records)
for _, fields := range hotReloadLogs {
log.WithFields(fields).Info("pluginhost: plugin hot reloaded")
@@ -713,6 +763,7 @@ func (h *Host) detachPlugin(ctx context.Context, id string) bool {
h.snapshot.Store(&Snapshot{enabled: enabled, records: records})
h.mu.Unlock()
+ h.clearUsageReplacement(id)
h.closeHostHTTPCallbackInstances(id, instances)
if len(targets) == 0 {
h.closeHostHTTPPluginResources(id, nil)
@@ -846,6 +897,7 @@ func (h *Host) ShutdownAllContext(ctx context.Context) {
h.executorModelClientIDs = make(map[string]struct{})
h.modelProviders = make(map[string]string)
h.modelProviderSets = make(map[string][]string)
+ h.authCredentialProviders = make(map[string]map[string]string)
h.modelRegistrations = make(map[string]pluginModelRegistration)
h.pluginModelRegistrations = make(map[string][]pluginModelRegistration)
h.providerModels = make(map[string][]*registryModelInfo)
@@ -860,6 +912,7 @@ func (h *Host) ShutdownAllContext(ctx context.Context) {
h.snapshot.Store(emptySnapshot())
h.mu.Unlock()
+ h.clearAllUsageReplacements()
for id, instances := range loadingInstances {
h.closeHostHTTPCallbackInstances(id, instances)
}
@@ -872,6 +925,9 @@ func (h *Host) ShutdownAllContext(ctx context.Context) {
if h.httpStreams != nil {
h.httpStreams.closeAll()
}
+ if h.modelStreams != nil {
+ h.modelStreams.closeAll()
+ }
h.refreshThinkingProviders(nil)
h.RegisterFrontendAuthProviders()
for id, request := range loading {
@@ -887,6 +943,9 @@ func (h *Host) ShutdownAllContext(ctx context.Context) {
if h.httpStreams != nil {
h.httpStreams.closeAll()
}
+ if h.modelStreams != nil {
+ h.modelStreams.closeAll()
+ }
}
func (h *Host) lockApply(ctx context.Context) bool {
@@ -1017,6 +1076,7 @@ func (h *Host) removePluginRuntimeStateLocked(id string) {
}
delete(h.modelProviders, id)
delete(h.modelProviderSets, id)
+ delete(h.authCredentialProviders, id)
delete(h.modelRegistrations, id)
delete(h.pluginModelRegistrations, id)
for provider := range affectedProviders {
@@ -1051,17 +1111,75 @@ func (h *Host) rebuildActivePluginMapsLocked(records []capabilityRecord) {
}
func (h *Host) callQuiesce(ctx context.Context, lp *loadedPlugin) bool {
- if h == nil || lp == nil || lp.client == nil {
+ quiesced, _ := h.callQuiesceWithOutcome(ctx, lp)
+ return quiesced
+}
+
+func (h *Host) isPluginUnrecovered(lp *loadedPlugin) bool {
+ if h == nil || lp == nil {
return false
}
- errQuiesce, okCall := h.safePluginAction(ctx, lp.id, pluginabi.MethodPluginQuiesce, func() error {
- _, errCall := callPlugin[rpcEmptyResponse](ctx, lp.client, pluginabi.MethodPluginQuiesce, rpcEmptyResponse{})
+ h.mu.Lock()
+ unrecovered := lp.needsRecovery
+ h.mu.Unlock()
+ return unrecovered
+}
+
+func (h *Host) pluginNeedsUsageRecovery(pluginID string) bool {
+ if h == nil {
+ return false
+ }
+ pluginID = strings.TrimSpace(pluginID)
+ if pluginID == "" {
+ return false
+ }
+ h.mu.Lock()
+ lp := h.loaded[pluginID]
+ unrecovered := lp != nil && lp.needsRecovery
+ h.mu.Unlock()
+ return unrecovered
+}
+
+func (h *Host) setPluginNeedsRecovery(lp *loadedPlugin, needsRecovery bool) {
+ if h == nil || lp == nil {
+ return
+ }
+ h.mu.Lock()
+ lp.needsRecovery = needsRecovery
+ h.mu.Unlock()
+}
+
+func (h *Host) callQuiesceWithOutcome(ctx context.Context, lp *loadedPlugin) (quiesced bool, proceed bool) {
+ if h == nil || lp == nil || lp.client == nil {
+ return false, false
+ }
+ callDone := make(chan struct{})
+ callCtx, finishCall := withPluginCallFinish(ctx, func() {
+ close(callDone)
+ })
+ errQuiesce, okCall := h.safePluginAction(callCtx, lp.id, pluginabi.MethodPluginQuiesce, func() error {
+ defer finishCall()
+ _, errCall := callPlugin[rpcEmptyResponse](callCtx, lp.client, pluginabi.MethodPluginQuiesce, rpcEmptyResponse{})
return errCall
})
+ finishCall()
+ <-callDone
if errQuiesce != nil {
logQuiesceError(lp.id, errQuiesce)
}
- return okCall && errQuiesce == nil
+ if !okCall {
+ h.setPluginNeedsRecovery(lp, true)
+ return false, false
+ }
+ if errQuiesce == nil {
+ h.setPluginNeedsRecovery(lp, true)
+ return true, true
+ }
+ if quiesceUnsupported(errQuiesce) {
+ return false, true
+ }
+ h.setPluginNeedsRecovery(lp, true)
+ return false, false
}
func logQuiesceError(id string, errQuiesce error) {
@@ -1113,6 +1231,7 @@ func (h *Host) rollbackReplacement(lp *loadedPlugin, item runtimeItemConfig) (ca
h.mu.Lock()
configYAML := bytes.Clone(lp.configYAML)
previousPlugin := lp.plugin
+ needsRecovery := lp.needsRecovery
h.mu.Unlock()
if len(configYAML) > 0 {
item.ConfigYAML = configYAML
@@ -1123,26 +1242,112 @@ func (h *Host) rollbackReplacement(lp *loadedPlugin, item runtimeItemConfig) (ca
h.mu.Lock()
delete(h.fused, lp.id)
h.mu.Unlock()
- } else {
+ } else if !needsRecovery {
plugin = previousPlugin
+ } else {
+ h.removeActivePluginRecord(lp.id)
+ return capabilityRecord{}, pluginFile{}, false
}
- if !validPlugin(plugin) {
+ if !validPlugin(plugin) || h.isPluginFused(lp.id) {
+ h.removeActivePluginRecord(lp.id)
return capabilityRecord{}, pluginFile{}, false
}
- return capabilityRecord{
+ record := capabilityRecord{
id: lp.id,
path: lp.path,
version: lp.version,
priority: item.Priority,
meta: plugin.Metadata,
plugin: plugin,
- }, pluginFile{
+ }
+ h.updateActivePluginRecord(record)
+ return record, pluginFile{
ID: lp.id,
Path: lp.path,
Version: lp.version,
}, true
}
+func (h *Host) removeActivePluginRecord(id string) {
+ if h == nil {
+ return
+ }
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if oldPath := h.activePluginPaths[id]; oldPath != "" {
+ delete(h.pluginFileVersions, oldPath)
+ }
+ delete(h.activePluginPaths, id)
+ delete(h.activePluginVersions, id)
+ records, enabled := h.snapshotWithoutPluginLocked(id)
+ h.snapshot.Store(&Snapshot{
+ enabled: enabled,
+ records: records,
+ quotaSupportedProviders: make(map[string][]string),
+ })
+}
+
+func (h *Host) updateActivePluginRecord(record capabilityRecord) {
+ if h == nil {
+ return
+ }
+ id := strings.TrimSpace(record.id)
+ path := cleanPluginPath(record.path)
+ version := strings.TrimSpace(record.version)
+ if id == "" || path == "" {
+ return
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.pluginFileVersions == nil {
+ h.pluginFileVersions = make(map[string]string)
+ }
+ if h.activePluginVersions == nil {
+ h.activePluginVersions = make(map[string]string)
+ }
+ if h.activePluginPaths == nil {
+ h.activePluginPaths = make(map[string]string)
+ }
+ if oldPath := h.activePluginPaths[id]; oldPath != "" && oldPath != path {
+ delete(h.pluginFileVersions, oldPath)
+ }
+ h.pluginFileVersions[path] = version
+ h.activePluginVersions[id] = version
+ h.activePluginPaths[id] = path
+
+ raw := h.snapshot.Load()
+ snap, _ := raw.(*Snapshot)
+ enabled := true
+ var existing []capabilityRecord
+ if snap != nil {
+ enabled = snap.enabled
+ existing = snap.records
+ }
+ updated := make([]capabilityRecord, 0, len(existing)+1)
+ replaced := false
+ for _, item := range existing {
+ if item.id == id {
+ updated = append(updated, record)
+ replaced = true
+ continue
+ }
+ updated = append(updated, item)
+ }
+ if !replaced {
+ updated = append(updated, record)
+ }
+ sortRecords(updated)
+ h.snapshot.Store(&Snapshot{
+ enabled: enabled,
+ records: updated,
+ quotaSupportedProviders: make(map[string][]string),
+ })
+}
+
func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeItemConfig) (pluginapi.Plugin, bool) {
if lp == nil {
return pluginapi.Plugin{}, false
@@ -1156,14 +1361,27 @@ func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeI
method = pluginabi.MethodPluginReconfigure
}
- plugin, okCall := h.safePluginCall(ctx, lp.id, method, func() pluginapi.Plugin {
- plugin, errRegister := registerRPCPlugin(ctx, h, lp.id, lp.client, method, item.ConfigYAML)
+ callDone := make(chan struct{})
+ callCtx := ctx
+ finishCall := func() {}
+ if registered {
+ callCtx, finishCall = withPluginCallFinish(ctx, func() {
+ close(callDone)
+ })
+ } else {
+ close(callDone)
+ }
+ plugin, okCall := h.safePluginCall(callCtx, lp.id, method, func() pluginapi.Plugin {
+ defer finishCall()
+ plugin, errRegister := registerRPCPlugin(callCtx, h, lp.id, lp.client, method, item.ConfigYAML)
if errRegister != nil {
log.Warnf("pluginhost: plugin %s %s failed: %v", lp.id, method, errRegister)
return pluginapi.Plugin{}
}
return plugin
})
+ finishCall()
+ <-callDone
if !okCall {
return pluginapi.Plugin{}, false
}
@@ -1182,6 +1400,7 @@ func (h *Host) callRegister(ctx context.Context, lp *loadedPlugin, item runtimeI
}
lp.configYAML = bytes.Clone(item.ConfigYAML)
lp.plugin = plugin
+ lp.needsRecovery = false
h.mu.Unlock()
return plugin, true
}
@@ -1240,7 +1459,7 @@ func validPlugin(plugin pluginapi.Plugin) bool {
caps := plugin.Capabilities
return caps.ModelRegistrar != nil ||
caps.ModelProvider != nil ||
- caps.AuthProvider != nil ||
+ hasAuthProviderCap(caps) ||
caps.FrontendAuthProvider != nil ||
caps.Scheduler != nil ||
caps.ModelRouter != nil ||
diff --git a/internal/pluginhost/host_callbacks.go b/internal/pluginhost/host_callbacks.go
index 8ab005f3b87..58dd95bfb02 100644
--- a/internal/pluginhost/host_callbacks.go
+++ b/internal/pluginhost/host_callbacks.go
@@ -152,7 +152,7 @@ func (h *Host) callFromPlugin(ctx context.Context, method string, request []byte
case pluginabi.MethodHostModelStreamRead:
return h.callHostModelStreamRead(ctx, request)
case pluginabi.MethodHostModelStreamClose:
- return h.callHostModelStreamClose(request)
+ return h.callHostModelStreamClose(ctx, request)
case pluginabi.MethodHostHTTPDo:
return h.callHostHTTPDo(ctx, request)
case pluginabi.MethodHostHTTPDoStream:
diff --git a/internal/pluginhost/host_callbacks_test.go b/internal/pluginhost/host_callbacks_test.go
index 9ec8d6eaa14..0c43e451fc2 100644
--- a/internal/pluginhost/host_callbacks_test.go
+++ b/internal/pluginhost/host_callbacks_test.go
@@ -1509,6 +1509,188 @@ func hostModelStreamCountForTest(t *testing.T, host *Host) int {
return len(host.modelStreams.streams)
}
+func TestHostModelStreamsArePluginAndInstanceScoped(t *testing.T) {
+ host := New()
+ chunks := make(chan handlers.ModelExecutionChunk, 1)
+ chunks <- handlers.ModelExecutionChunk{Payload: []byte("owner payload")}
+ ownerInstance := &hostCallbackInstance{}
+ otherInstance := &hostCallbackInstance{}
+ streamID := host.modelStreams.open("plugin-a", ownerInstance, "", chunks, nil)
+
+ readReq, errMarshal := json.Marshal(pluginapi.HostModelStreamReadRequest{StreamID: streamID})
+ if errMarshal != nil {
+ t.Fatalf("marshal read request: %v", errMarshal)
+ }
+ closeReq, errMarshal := json.Marshal(pluginapi.HostModelStreamCloseRequest{StreamID: streamID})
+ if errMarshal != nil {
+ t.Fatalf("marshal close request: %v", errMarshal)
+ }
+
+ pluginBCtx := withHostCallbackIdentity(context.Background(), "plugin-b", ownerInstance)
+ if _, errRead := host.callFromPlugin(pluginBCtx, pluginabi.MethodHostModelStreamRead, readReq); errRead == nil {
+ t.Fatal("plugin-b read plugin-a's model stream")
+ }
+ if _, errClose := host.callFromPlugin(pluginBCtx, pluginabi.MethodHostModelStreamClose, closeReq); errClose != nil {
+ t.Fatalf("foreign plugin close callback error = %v", errClose)
+ }
+
+ otherInstanceCtx := withHostCallbackIdentity(context.Background(), "plugin-a", otherInstance)
+ if _, errRead := host.callFromPlugin(otherInstanceCtx, pluginabi.MethodHostModelStreamRead, readReq); errRead == nil {
+ t.Fatal("other callback instance read owner's model stream")
+ }
+ if _, errClose := host.callFromPlugin(otherInstanceCtx, pluginabi.MethodHostModelStreamClose, closeReq); errClose != nil {
+ t.Fatalf("foreign instance close callback error = %v", errClose)
+ }
+
+ ownerCtx := withHostCallbackIdentity(context.Background(), "plugin-a", ownerInstance)
+ rawRead, errRead := host.callFromPlugin(ownerCtx, pluginabi.MethodHostModelStreamRead, readReq)
+ if errRead != nil {
+ t.Fatalf("owner stream read error = %v", errRead)
+ }
+ resp, errDecode := decodeRPCEnvelope[pluginapi.HostModelStreamReadResponse](rawRead)
+ if errDecode != nil {
+ t.Fatalf("decode stream read response: %v", errDecode)
+ }
+ if string(resp.Payload) != "owner payload" || resp.Done {
+ t.Fatalf("owner stream read = %#v, want owner payload without done", resp)
+ }
+ if _, errClose := host.callFromPlugin(ownerCtx, pluginabi.MethodHostModelStreamClose, closeReq); errClose != nil {
+ t.Fatalf("owner stream close error = %v", errClose)
+ }
+ if got := hostModelStreamCountForTest(t, host); got != 0 {
+ t.Fatalf("remaining model streams = %d, want 0", got)
+ }
+}
+
+func TestHostModelStreamStartupRejectsClosedInstanceAndConcurrentTeardown(t *testing.T) {
+ t.Run("direct open on closed instance cancels and rejects", func(t *testing.T) {
+ host := New()
+ instance := &hostCallbackInstance{}
+ instance.closed.Store(true)
+ ctx, cancel := context.WithCancel(context.Background())
+ chunks := make(chan handlers.ModelExecutionChunk, 1)
+ if streamID := host.modelStreams.open("plugin-a", instance, "", chunks, cancel); streamID != "" {
+ t.Fatalf("open on closed instance returned streamID = %q, want empty", streamID)
+ }
+ if ctx.Err() == nil {
+ t.Fatal("expected stream cancel to run when open is rejected on closed instance")
+ }
+ if got := hostModelStreamCountForTest(t, host); got != 0 {
+ t.Fatalf("remaining model streams = %d, want 0", got)
+ }
+ })
+
+ for _, tc := range []struct {
+ name string
+ teardown func(host *Host, instance *hostCallbackInstance)
+ }{
+ {
+ name: "instance teardown during ExecuteModelStream",
+ teardown: func(host *Host, instance *hostCallbackInstance) {
+ host.closeHostHTTPCallbackInstance("plugin-a", instance)
+ },
+ },
+ {
+ name: "plugin teardown with instance during ExecuteModelStream",
+ teardown: func(host *Host, instance *hostCallbackInstance) {
+ host.closeHostHTTPPluginResources("plugin-a", instance)
+ },
+ },
+ {
+ name: "plugin teardown without instance during ExecuteModelStream",
+ teardown: func(host *Host, instance *hostCallbackInstance) {
+ host.closeHostHTTPPluginResources("plugin-a", nil)
+ },
+ },
+ {
+ name: "host shutdown during ExecuteModelStream",
+ teardown: func(host *Host, instance *hostCallbackInstance) {
+ host.ShutdownAllContext(context.Background())
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ host := New()
+ instance := &hostCallbackInstance{}
+ var streamCtx context.Context
+ host.SetModelExecutor(&fakeHostModelExecutor{
+ executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
+ streamCtx = ctx
+ tc.teardown(host, instance)
+ chunks := make(chan handlers.ModelExecutionChunk, 1)
+ return handlers.ModelExecutionStream{
+ StatusCode: http.StatusOK,
+ Chunks: chunks,
+ }, nil
+ },
+ })
+ rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
+ HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
+ EntryProtocol: "openai",
+ ExitProtocol: "openai",
+ Model: "model-1",
+ Stream: true,
+ Body: []byte(`{"stream":true}`),
+ },
+ })
+ if errMarshal != nil {
+ t.Fatalf("marshal request: %v", errMarshal)
+ }
+ callerCtx := withHostCallbackIdentity(context.Background(), "plugin-a", instance)
+ if _, errCall := host.callFromPlugin(callerCtx, pluginabi.MethodHostModelExecuteStream, rawReq); errCall == nil {
+ t.Fatal("expected ExecuteModelStream overlapping teardown to fail")
+ }
+ if streamCtx == nil || streamCtx.Err() == nil {
+ t.Fatalf("streamCtx = %v (err=%v), want canceled context", streamCtx, streamCtx.Err())
+ }
+ if got := hostModelStreamCountForTest(t, host); got != 0 {
+ t.Fatalf("remaining model streams = %d, want 0", got)
+ }
+ })
+ }
+
+ t.Run("callback scope teardown during ExecuteModelStream", func(t *testing.T) {
+ host := New()
+ instance := &hostCallbackInstance{}
+ callbackID, closeCallback := host.openCallbackContextForPluginInstance(context.Background(), "plugin-a", instance)
+ var streamCtx context.Context
+ host.SetModelExecutor(&fakeHostModelExecutor{
+ executeModelStream: func(ctx context.Context, req handlers.ModelExecutionRequest) (handlers.ModelExecutionStream, *interfaces.ErrorMessage) {
+ streamCtx = ctx
+ closeCallback()
+ chunks := make(chan handlers.ModelExecutionChunk, 1)
+ return handlers.ModelExecutionStream{
+ StatusCode: http.StatusOK,
+ Chunks: chunks,
+ }, nil
+ },
+ })
+ rawReq, errMarshal := json.Marshal(rpcHostModelExecutionRequest{
+ HostModelExecutionRequest: pluginapi.HostModelExecutionRequest{
+ EntryProtocol: "openai",
+ ExitProtocol: "openai",
+ Model: "model-1",
+ Stream: true,
+ Body: []byte(`{"stream":true}`),
+ },
+ HostCallbackID: callbackID,
+ })
+ if errMarshal != nil {
+ t.Fatalf("marshal request: %v", errMarshal)
+ }
+ callerCtx := withHostCallbackIdentity(context.Background(), "plugin-a", instance)
+ if _, errCall := host.callFromPlugin(callerCtx, pluginabi.MethodHostModelExecuteStream, rawReq); errCall == nil {
+ t.Fatal("expected ExecuteModelStream overlapping callback scope close to fail")
+ }
+ if streamCtx == nil || streamCtx.Err() == nil {
+ t.Fatalf("streamCtx = %v (err=%v), want canceled context", streamCtx, streamCtx.Err())
+ }
+ if got := hostModelStreamCountForTest(t, host); got != 0 {
+ t.Fatalf("remaining model streams = %d, want 0", got)
+ }
+ })
+}
+
func TestHostLogCallbackRestoresRegisteredRequestContext(t *testing.T) {
host := New()
ctx := logging.WithRequestID(context.Background(), "request-123")
diff --git a/internal/pluginhost/host_model_stream_callbacks.go b/internal/pluginhost/host_model_stream_callbacks.go
index ad3184c4013..33b86fd0764 100644
--- a/internal/pluginhost/host_model_stream_callbacks.go
+++ b/internal/pluginhost/host_model_stream_callbacks.go
@@ -23,6 +23,24 @@ func (h *Host) callHostModelExecuteStream(ctx context.Context, request []byte) (
if executor == nil {
return nil, fmt.Errorf("host model executor is unavailable")
}
+ if h == nil || h.modelStreams == nil {
+ return nil, fmt.Errorf("host model stream bridge is unavailable")
+ }
+ callerPluginID := hostCallbackPluginIDFromContext(ctx)
+ callerInstance := hostCallbackInstanceFromContext(ctx)
+ var callbackPluginID string
+ var callbackInstance *hostCallbackInstance
+ if req.HostCallbackID != "" {
+ var exists bool
+ if _, callbackPluginID, callbackInstance, exists = h.lookupCallbackContext(req.HostCallbackID); exists {
+ if callerPluginID != "" && callbackPluginID != "" && callerPluginID != callbackPluginID {
+ return nil, fmt.Errorf("host callback ID does not belong to the calling plugin")
+ }
+ if callerInstance != nil && callbackInstance != nil && callerInstance != callbackInstance {
+ return nil, fmt.Errorf("host callback ID does not belong to the calling plugin instance")
+ }
+ }
+ }
skipPluginID := h.callbackCallerPluginID(ctx, req.HostCallbackID)
callbackCtx := h.resolveCallbackContext(req.HostCallbackID, ctx)
if callbackCtx == nil {
@@ -30,23 +48,27 @@ func (h *Host) callHostModelExecuteStream(ctx context.Context, request []byte) (
}
// Detach request cancellation while preserving callback values; callback cleanup owns the model stream lifetime.
streamCtx, cancel := newStreamContext(context.WithoutCancel(callbackCtx))
+ inflightID, okBegin := h.modelStreams.begin(callerPluginID, callbackPluginID, callerInstance, callbackInstance, cancel)
+ if !okBegin {
+ return nil, fmt.Errorf("host plugin callback instance is closed")
+ }
stream, errMsg := executor.ExecuteModelStream(streamCtx, modelExecutionRequestFromPlugin(req.HostModelExecutionRequest, skipPluginID))
if errMsg != nil {
- cancel()
+ h.modelStreams.abort(inflightID)
return nil, modelExecutionError(errMsg)
}
- streamID := ""
- if h.modelStreams != nil {
- streamID = h.modelStreams.open(req.HostCallbackID, stream.Chunks, cancel)
- }
+ streamID := h.modelStreams.complete(inflightID, callerPluginID, callerInstance, req.HostCallbackID, stream.Chunks, cancel)
if streamID == "" {
- cancel()
- return nil, fmt.Errorf("host model stream bridge is unavailable")
+ return nil, fmt.Errorf("host model stream closed during startup")
}
if req.HostCallbackID != "" {
- h.addCallbackCleanup(req.HostCallbackID, func() {
- h.modelStreams.close(streamID)
+ stopScopeCleanup, attached := h.addCallbackCleanupHandle(req.HostCallbackID, func() {
+ h.modelStreams.close(callerPluginID, callerInstance, streamID)
})
+ if !attached || !h.modelStreams.setScopeCleanup(callerPluginID, callerInstance, streamID, stopScopeCleanup) {
+ h.modelStreams.close(callerPluginID, callerInstance, streamID)
+ return nil, fmt.Errorf("host callback context closed while opening model stream")
+ }
}
return marshalRPCResult(pluginapi.HostModelStreamResponse{
StatusCode: stream.StatusCode,
@@ -63,7 +85,7 @@ func (h *Host) callHostModelStreamRead(ctx context.Context, request []byte) ([]b
if h == nil || h.modelStreams == nil {
return nil, fmt.Errorf("host model stream bridge is unavailable")
}
- chunk, done, errRead := h.modelStreams.read(ctx, req.StreamID)
+ chunk, done, errRead := h.modelStreams.read(ctx, hostCallbackPluginIDFromContext(ctx), hostCallbackInstanceFromContext(ctx), req.StreamID)
if errRead != nil {
return nil, errRead
}
@@ -78,13 +100,13 @@ func (h *Host) callHostModelStreamRead(ctx context.Context, request []byte) ([]b
return marshalRPCResult(resp)
}
-func (h *Host) callHostModelStreamClose(request []byte) ([]byte, error) {
+func (h *Host) callHostModelStreamClose(ctx context.Context, request []byte) ([]byte, error) {
var req pluginapi.HostModelStreamCloseRequest
if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
return nil, fmt.Errorf("decode host model stream close request: %w", errUnmarshal)
}
if h != nil && h.modelStreams != nil {
- h.modelStreams.close(req.StreamID)
+ h.modelStreams.close(hostCallbackPluginIDFromContext(ctx), hostCallbackInstanceFromContext(ctx), req.StreamID)
}
return marshalRPCResult(rpcEmptyResponse{})
}
diff --git a/internal/pluginhost/host_test.go b/internal/pluginhost/host_test.go
index 11418933451..8b8fcd27790 100644
--- a/internal/pluginhost/host_test.go
+++ b/internal/pluginhost/host_test.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"path/filepath"
+ "reflect"
"slices"
"strings"
"sync"
@@ -14,12 +15,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func enabledPluginConfigs(ids ...string) map[string]config.PluginInstanceConfig {
@@ -477,10 +479,10 @@ func TestHostApplyConfigRegistersPluginThinkingApplier(t *testing.T) {
if errApply != nil {
t.Fatalf("ApplyThinking() error = %v", errApply)
}
- if got := gjson.GetBytes(out, "thinking_budget").Int(); got != 10240 {
+ if got := ojson.GetBytesNoCopy(out, "thinking_budget").Int(); got != 10240 {
t.Fatalf("thinking_budget = %d, want 10240; body=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "plugin").String(); got != "plugin-thinking" {
+ if got := ojson.GetBytesNoCopy(out, "plugin").String(); got != "plugin-thinking" {
t.Fatalf("plugin = %q, want plugin-thinking; body=%s", got, string(out))
}
}
@@ -1065,6 +1067,1082 @@ func TestHostApplyConfigFallsBackWhenQuiesceUnsupported(t *testing.T) {
}
}
+func TestHostApplyConfigAbortsReplacementWhenQuiesceFails(t *testing.T) {
+ events := &lifecycleEventRecorder{}
+ var configMu sync.Mutex
+ var registeredConfig []byte
+ var rollbackConfig []byte
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ events.add("old." + method)
+ switch method {
+ case pluginabi.MethodPluginRegister, pluginabi.MethodPluginReconfigure:
+ var lifecycleRequest rpcLifecycleRequest
+ if errUnmarshal := json.Unmarshal(request, &lifecycleRequest); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ configMu.Lock()
+ if method == pluginabi.MethodPluginRegister {
+ registeredConfig = bytes.Clone(lifecycleRequest.ConfigYAML)
+ } else {
+ rollbackConfig = bytes.Clone(lifecycleRequest.ConfigYAML)
+ }
+ configMu.Unlock()
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ return marshalRPCError("quiesce_failed", "sqlite connection busy"), nil
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ events.add("replacement." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ }}
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ paths["2.0.0"] = writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+
+ if got, want := events.snapshot(), []string{
+ "old." + pluginabi.MethodPluginRegister,
+ "old." + pluginabi.MethodPluginQuiesce,
+ "old." + pluginabi.MethodPluginReconfigure,
+ }; !slices.Equal(got, want) {
+ t.Fatalf("lifecycle events = %v, want %v", got, want)
+ }
+ if loader.calls != 1 {
+ t.Fatalf("loader Open calls = %d, want 1 (replacement must not be opened)", loader.calls)
+ }
+ configMu.Lock()
+ gotRegisteredConfig := bytes.Clone(registeredConfig)
+ gotRollbackConfig := bytes.Clone(rollbackConfig)
+ configMu.Unlock()
+ if !bytes.Equal(gotRollbackConfig, gotRegisteredConfig) {
+ t.Fatalf("rollback config = %q, want original config %q", gotRollbackConfig, gotRegisteredConfig)
+ }
+ if !h.pluginIdentityCurrent("alpha", paths["1.0.0"], "1.0.0") {
+ t.Fatal("old plugin did not remain active after quiesce failure")
+ }
+ if h.pluginIdentityCurrent("alpha", paths["2.0.0"], "2.0.0") {
+ t.Fatal("replacement plugin unexpectedly became active after quiesce failure")
+ }
+ h.mu.Lock()
+ loadingCount := len(h.loading)
+ retiredCount := len(h.retired["alpha"])
+ h.mu.Unlock()
+ if loadingCount != 0 {
+ t.Fatalf("loading plugin count = %d, want 0", loadingCount)
+ }
+ if retiredCount != 0 {
+ t.Fatalf("retired plugin count = %d, want 0", retiredCount)
+ }
+}
+
+func TestHostApplyConfigCoordinatesUsageAcrossQuiesceAndReplacement(t *testing.T) {
+ events := &lifecycleEventRecorder{}
+ inFlightStarted := make(chan struct{})
+ releaseInFlight := make(chan struct{})
+ inFlightDone := make(chan struct{})
+ drainWaitEntered := make(chan struct{})
+ quiesceStarted := make(chan struct{})
+ replacementRegisterStarted := make(chan struct{})
+ releaseReplacementRegister := make(chan struct{})
+
+ var oldQuiesced atomic.Bool
+ var oldRejectedUsage atomic.Int32
+ var oldDelivered []string
+ var replacementDelivered []string
+ var deliveryMu sync.Mutex
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ events.add("old." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ events.add("old." + method)
+ oldQuiesced.Store(true)
+ close(quiesceStarted)
+ return marshalRPCResult(rpcEmptyResponse{})
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if oldQuiesced.Load() {
+ oldRejectedUsage.Add(1)
+ events.add("old.usage.rejected:" + rec.RequestID)
+ return marshalRPCError("quiesced", "database closed during quiesce"), nil
+ }
+ if rec.RequestID == "in-flight-before-quiesce" {
+ close(inFlightStarted)
+ <-releaseInFlight
+ }
+ deliveryMu.Lock()
+ oldDelivered = append(oldDelivered, rec.RequestID)
+ deliveryMu.Unlock()
+ events.add("old.usage.handle:" + rec.RequestID)
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ events.add("replacement." + method)
+ close(replacementRegisterStarted)
+ <-releaseReplacementRegister
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ deliveryMu.Lock()
+ replacementDelivered = append(replacementDelivered, rec.RequestID)
+ deliveryMu.Unlock()
+ events.add("replacement.usage.handle:" + rec.RequestID)
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected replacement plugin method %s", method)
+ }
+ }}
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ var drainWaitOnce sync.Once
+ h.usageDrainWaitHook = func(pluginID string) {
+ if pluginID == "alpha" {
+ drainWaitOnce.Do(func() { close(drainWaitEntered) })
+ }
+ }
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ staleOldPlugin := h.currentUsagePlugin("alpha")
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: staleOldPlugin,
+ }
+
+ go func() {
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "in-flight-before-quiesce"})
+ close(inFlightDone)
+ }()
+ waitForHostTestSignal(t, inFlightStarted, "in-flight usage before quiesce")
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, drainWaitEntered, "usage drain wait before quiesce")
+ select {
+ case <-quiesceStarted:
+ t.Fatal("plugin.quiesce started before already-admitted usage call drained")
+ default:
+ }
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-during-drain"})
+
+ close(releaseInFlight)
+ waitForHostTestSignal(t, inFlightDone, "in-flight usage completion")
+ waitForHostTestSignal(t, quiesceStarted, "plugin quiesce start")
+ waitForHostTestSignal(t, replacementRegisterStarted, "replacement registration start")
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-1"})
+ staleOldPlugin.HandleUsage(context.Background(), pluginapi.UsageRecord{RequestID: "held-2-via-stale-ref"})
+
+ close(releaseReplacementRegister)
+ waitForHostTestSignal(t, applyDone, "replacement ApplyConfig completion")
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "post-reload-3"})
+
+ if got := oldRejectedUsage.Load(); got != 0 {
+ t.Fatalf("usage calls reaching quiesced old plugin = %d, want 0 (events=%v)", got, events.snapshot())
+ }
+ deliveryMu.Lock()
+ gotOld := append([]string(nil), oldDelivered...)
+ gotReplacement := append([]string(nil), replacementDelivered...)
+ deliveryMu.Unlock()
+ if wantOld := []string{"in-flight-before-quiesce"}; !slices.Equal(gotOld, wantOld) {
+ t.Fatalf("old plugin delivered usage = %v, want %v", gotOld, wantOld)
+ }
+ if wantReplacement := []string{"held-during-drain", "held-1", "held-2-via-stale-ref", "post-reload-3"}; !slices.Equal(gotReplacement, wantReplacement) {
+ t.Fatalf("replacement plugin delivered usage = %v, want %v", gotReplacement, wantReplacement)
+ }
+}
+
+func TestHostApplyConfigReplaysHeldUsageToRestoredPluginWhenReplacementFails(t *testing.T) {
+ events := &lifecycleEventRecorder{}
+ replacementRegisterStarted := make(chan struct{})
+ releaseReplacementRegister := make(chan struct{})
+
+ var oldQuiesced atomic.Bool
+ var oldRejectedUsage atomic.Int32
+ var oldDelivered []string
+ var deliveryMu sync.Mutex
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ events.add("old." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ events.add("old." + method)
+ oldQuiesced.Store(true)
+ return marshalRPCResult(rpcEmptyResponse{})
+ case pluginabi.MethodPluginReconfigure:
+ events.add("old." + method)
+ oldQuiesced.Store(false)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if oldQuiesced.Load() {
+ oldRejectedUsage.Add(1)
+ events.add("old.usage.rejected:" + rec.RequestID)
+ return marshalRPCError("quiesced", "database closed during quiesce"), nil
+ }
+ deliveryMu.Lock()
+ oldDelivered = append(oldDelivered, rec.RequestID)
+ deliveryMu.Unlock()
+ events.add("old.usage.handle:" + rec.RequestID)
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{
+ call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ events.add("replacement." + method)
+ if method != pluginabi.MethodPluginRegister {
+ return nil, fmt.Errorf("unexpected replacement plugin method %s", method)
+ }
+ close(replacementRegisterStarted)
+ <-releaseReplacementRegister
+ return nil, fmt.Errorf("replacement registration failed")
+ },
+ shutdown: func() { events.add("replacement.shutdown") },
+ }
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, replacementRegisterStarted, "replacement registration start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-rollback-1"})
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-rollback-2"})
+
+ close(releaseReplacementRegister)
+ waitForHostTestSignal(t, applyDone, "failed replacement ApplyConfig completion")
+
+ if got := oldRejectedUsage.Load(); got != 0 {
+ t.Fatalf("usage calls reaching quiesced old plugin = %d, want 0 (events=%v)", got, events.snapshot())
+ }
+ deliveryMu.Lock()
+ gotOld := append([]string(nil), oldDelivered...)
+ deliveryMu.Unlock()
+ if wantOld := []string{"held-rollback-1", "held-rollback-2"}; !slices.Equal(gotOld, wantOld) {
+ t.Fatalf("restored old plugin delivered usage = %v, want %v (events=%v)", gotOld, wantOld, events.snapshot())
+ }
+ if got, want := events.snapshot(), []string{
+ "old." + pluginabi.MethodPluginRegister,
+ "old." + pluginabi.MethodPluginQuiesce,
+ "replacement." + pluginabi.MethodPluginRegister,
+ "replacement.shutdown",
+ "old." + pluginabi.MethodPluginReconfigure,
+ "old.usage.handle:held-rollback-1",
+ "old.usage.handle:held-rollback-2",
+ }; !slices.Equal(got, want) {
+ t.Fatalf("lifecycle events = %v, want %v", got, want)
+ }
+}
+
+func TestHostApplyConfigReplaysHeldUsageWhenQuiesceFails(t *testing.T) {
+ events := &lifecycleEventRecorder{}
+ quiesceStarted := make(chan struct{})
+ releaseQuiesce := make(chan struct{})
+
+ var oldQuiescing atomic.Bool
+ var oldRejectedUsage atomic.Int32
+ var oldDelivered []string
+ var deliveryMu sync.Mutex
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ events.add("old." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ events.add("old." + method)
+ oldQuiescing.Store(true)
+ close(quiesceStarted)
+ <-releaseQuiesce
+ return marshalRPCError("quiesce_failed", "failed to flush state"), nil
+ case pluginabi.MethodPluginReconfigure:
+ events.add("old." + method)
+ oldQuiescing.Store(false)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if oldQuiescing.Load() {
+ oldRejectedUsage.Add(1)
+ events.add("old.usage.rejected:" + rec.RequestID)
+ return marshalRPCError("quiesced", "database closed during quiesce"), nil
+ }
+ deliveryMu.Lock()
+ oldDelivered = append(oldDelivered, rec.RequestID)
+ deliveryMu.Unlock()
+ events.add("old.usage.handle:" + rec.RequestID)
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ events.add("replacement." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ }}
+
+ backpressureEntered := make(chan struct{})
+ var backpressureOnce sync.Once
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ h.maxPendingUsageRecords = 1
+ h.usageBackpressureWaitHook = func(pluginID string) {
+ if pluginID == "alpha" {
+ backpressureOnce.Do(func() { close(backpressureEntered) })
+ }
+ }
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, quiesceStarted, "plugin quiesce start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-quiesce-fail-1"})
+
+ secondSubmitted := make(chan struct{})
+ go func() {
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-quiesce-fail-2"})
+ close(secondSubmitted)
+ }()
+ waitForHostTestSignal(t, backpressureEntered, "usage backpressure wait")
+ select {
+ case <-secondSubmitted:
+ t.Fatal("second usage event bypassed bounded pending queue backpressure")
+ default:
+ }
+
+ close(releaseQuiesce)
+ waitForHostTestSignal(t, applyDone, "failed quiesce ApplyConfig completion")
+ waitForHostTestSignal(t, secondSubmitted, "backpressured usage submission completion")
+
+ if loader.calls != 1 {
+ t.Fatalf("loader Open calls = %d, want 1", loader.calls)
+ }
+ if got := oldRejectedUsage.Load(); got != 0 {
+ t.Fatalf("usage calls reaching quiescing old plugin = %d, want 0 (events=%v)", got, events.snapshot())
+ }
+ deliveryMu.Lock()
+ gotOld := append([]string(nil), oldDelivered...)
+ deliveryMu.Unlock()
+ if wantOld := []string{"held-quiesce-fail-1", "held-quiesce-fail-2"}; !slices.Equal(gotOld, wantOld) {
+ t.Fatalf("restored old plugin delivered usage = %v, want %v", gotOld, wantOld)
+ }
+}
+
+func TestHostApplyConfigRetainsHeldUsageWhenRollbackFailsUntilRecovery(t *testing.T) {
+ events := &lifecycleEventRecorder{}
+ replacementRegisterStarted := make(chan struct{})
+ releaseReplacementRegister := make(chan struct{})
+
+ var oldQuiesced atomic.Bool
+ var allowReconfigure atomic.Bool
+ var oldRejectedUsage atomic.Int32
+ var oldDelivered []string
+ var deliveryMu sync.Mutex
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ events.add("old." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ events.add("old." + method)
+ oldQuiesced.Store(true)
+ return marshalRPCResult(rpcEmptyResponse{})
+ case pluginabi.MethodPluginReconfigure:
+ events.add("old." + method)
+ if !allowReconfigure.Load() {
+ return marshalRPCError("reconfigure_failed", "sqlite lock still busy"), nil
+ }
+ oldQuiesced.Store(false)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if oldQuiesced.Load() {
+ oldRejectedUsage.Add(1)
+ events.add("old.usage.rejected:" + rec.RequestID)
+ return marshalRPCError("quiesced", "database closed during quiesce"), nil
+ }
+ deliveryMu.Lock()
+ oldDelivered = append(oldDelivered, rec.RequestID)
+ deliveryMu.Unlock()
+ events.add("old.usage.handle:" + rec.RequestID)
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{
+ call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ events.add("replacement." + method)
+ if method != pluginabi.MethodPluginRegister {
+ return nil, fmt.Errorf("unexpected replacement plugin method %s", method)
+ }
+ close(replacementRegisterStarted)
+ <-releaseReplacementRegister
+ return nil, fmt.Errorf("replacement registration failed")
+ },
+ shutdown: func() { events.add("replacement.shutdown") },
+ }
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, replacementRegisterStarted, "replacement registration start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-double-fail-1"})
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-double-fail-2"})
+
+ close(releaseReplacementRegister)
+ waitForHostTestSignal(t, applyDone, "failed replacement and failed rollback ApplyConfig")
+
+ if h.pluginIdentityCurrent("alpha", paths["1.0.0"], "1.0.0") {
+ t.Fatal("quiesced old plugin remained active after rollback reconfigure failed")
+ }
+ if got := oldRejectedUsage.Load(); got != 0 {
+ t.Fatalf("usage calls reaching quiesced old plugin after failed rollback = %d, want 0 (events=%v)", got, events.snapshot())
+ }
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-while-unrecovered-3"})
+ if got := oldRejectedUsage.Load(); got != 0 {
+ t.Fatalf("usage calls reaching quiesced old plugin while unrecovered = %d, want 0", got)
+ }
+
+ allowReconfigure.Store(true)
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+
+ if !h.pluginIdentityCurrent("alpha", paths["1.0.0"], "1.0.0") {
+ t.Fatal("old plugin did not become active after recovery reconfigure")
+ }
+ deliveryMu.Lock()
+ gotOld := append([]string(nil), oldDelivered...)
+ deliveryMu.Unlock()
+ if wantOld := []string{"held-double-fail-1", "held-double-fail-2", "held-while-unrecovered-3"}; !slices.Equal(gotOld, wantOld) {
+ t.Fatalf("recovered old plugin delivered usage = %v, want %v (events=%v)", gotOld, wantOld, events.snapshot())
+ }
+}
+
+func TestHostApplyConfigWaitsForBlockedQuiesceBeforeCanceledRollbackAndReplay(t *testing.T) {
+ events := &lifecycleEventRecorder{}
+ quiesceStarted := make(chan struct{})
+ releaseQuiesce := make(chan struct{})
+ reconfigureStarted := make(chan struct{})
+ var releaseQuiesceOnce sync.Once
+ t.Cleanup(func() {
+ releaseQuiesceOnce.Do(func() { close(releaseQuiesce) })
+ })
+
+ var oldQuiesced atomic.Bool
+ var oldRejectedUsage atomic.Int32
+ var oldDelivered []string
+ var deliveryMu sync.Mutex
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ events.add("old." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ events.add("old.quiesce.start")
+ close(quiesceStarted)
+ <-releaseQuiesce
+ oldQuiesced.Store(true)
+ events.add("old.quiesce.finish")
+ return marshalRPCResult(rpcEmptyResponse{})
+ case pluginabi.MethodPluginReconfigure:
+ var reconfigureOnce sync.Once
+ _ = reconfigureOnce
+ select {
+ case <-reconfigureStarted:
+ default:
+ close(reconfigureStarted)
+ }
+ oldQuiesced.Store(false)
+ events.add("old." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if oldQuiesced.Load() {
+ oldRejectedUsage.Add(1)
+ events.add("old.usage.rejected:" + rec.RequestID)
+ return marshalRPCError("quiesced", "database closed during quiesce"), nil
+ }
+ deliveryMu.Lock()
+ oldDelivered = append(oldDelivered, rec.RequestID)
+ deliveryMu.Unlock()
+ events.add("old.usage.handle:" + rec.RequestID)
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ events.add("replacement." + method)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ }}
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, paths := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ baseCtx, cancel := context.WithCancel(context.Background())
+ cancelObserved := make(chan struct{})
+ var cancelObservedOnce sync.Once
+ ctx := &errObserveContext{
+ Context: baseCtx,
+ onCanceledErr: func() {
+ cancelObservedOnce.Do(func() { close(cancelObserved) })
+ },
+ }
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(ctx, versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, quiesceStarted, "plugin quiesce start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-during-canceled-quiesce"})
+ cancel()
+ waitForHostTestSignal(t, cancelObserved, "quiesce call observed context cancellation")
+
+ releaseQuiesceOnce.Do(func() { close(releaseQuiesce) })
+ waitForHostTestSignal(t, applyDone, "canceled quiesce ApplyConfig completion")
+
+ if !h.pluginIdentityCurrent("alpha", paths["1.0.0"], "1.0.0") {
+ t.Fatal("old plugin did not remain active after canceled quiesce rollback")
+ }
+ if got := oldRejectedUsage.Load(); got != 0 {
+ t.Fatalf("usage calls reaching quiesced old plugin = %d, want 0 (events=%v)", got, events.snapshot())
+ }
+ deliveryMu.Lock()
+ gotOld := append([]string(nil), oldDelivered...)
+ deliveryMu.Unlock()
+ if wantOld := []string{"held-during-canceled-quiesce"}; !slices.Equal(gotOld, wantOld) {
+ t.Fatalf("restored old plugin delivered usage = %v, want %v", gotOld, wantOld)
+ }
+ if got, want := events.snapshot(), []string{
+ "old." + pluginabi.MethodPluginRegister,
+ "old.quiesce.start",
+ "old.quiesce.finish",
+ "old." + pluginabi.MethodPluginReconfigure,
+ "old.usage.handle:held-during-canceled-quiesce",
+ }; !slices.Equal(got, want) {
+ t.Fatalf("lifecycle events = %v, want %v", got, want)
+ }
+}
+
+func TestHostApplyConfigReplayDoesNotStarveUnderContinuousUsageTraffic(t *testing.T) {
+ replacementRegisterStarted := make(chan struct{})
+ releaseReplacementRegister := make(chan struct{})
+ heldReplayStarted := make(chan struct{})
+ releaseHeldReplay := make(chan struct{})
+ postReplayBlocked := make(chan struct{})
+ releasePostReplay := make(chan struct{})
+ postReplayDone := make(chan struct{})
+ var heldReplayOnce sync.Once
+ var postReplayOnce sync.Once
+ var postDoneOnce sync.Once
+
+ var delivered []string
+ var deliveryMu sync.Mutex
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ close(replacementRegisterStarted)
+ <-releaseReplacementRegister
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if rec.RequestID == "held-1" {
+ heldReplayOnce.Do(func() { close(heldReplayStarted) })
+ <-releaseHeldReplay
+ }
+ if rec.RequestID == "post-during-replay" {
+ postReplayOnce.Do(func() { close(postReplayBlocked) })
+ <-releasePostReplay
+ }
+ deliveryMu.Lock()
+ delivered = append(delivered, rec.RequestID)
+ deliveryMu.Unlock()
+ if rec.RequestID == "post-during-replay" {
+ postDoneOnce.Do(func() { close(postReplayDone) })
+ }
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected replacement plugin method %s", method)
+ }
+ }}
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ var releaseHeldOnce sync.Once
+ var releasePostOnce sync.Once
+ t.Cleanup(func() {
+ releaseHeldOnce.Do(func() { close(releaseHeldReplay) })
+ releasePostOnce.Do(func() { close(releasePostReplay) })
+ })
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, replacementRegisterStarted, "replacement registration start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-1"})
+
+ close(releaseReplacementRegister)
+ waitForHostTestSignal(t, heldReplayStarted, "held usage replay start")
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "post-during-replay"})
+
+ releaseHeldOnce.Do(func() { close(releaseHeldReplay) })
+ waitForHostTestSignal(t, applyDone, "ApplyConfig completion without waiting for post-replay usage")
+ waitForHostTestSignal(t, postReplayBlocked, "post-replay usage dispatch")
+ releasePostOnce.Do(func() { close(releasePostReplay) })
+ waitForHostTestSignal(t, postReplayDone, "post-replay usage delivery completion")
+
+ deliveryMu.Lock()
+ gotDelivered := append([]string(nil), delivered...)
+ deliveryMu.Unlock()
+ if wantDelivered := []string{"held-1", "post-during-replay"}; !slices.Equal(gotDelivered, wantDelivered) {
+ t.Fatalf("delivered usage = %v, want %v", gotDelivered, wantDelivered)
+ }
+}
+
+func TestHostApplyConfigRetriesFailedQuiesceOnSubsequentReplacement(t *testing.T) {
+ var quiesceCalls atomic.Int32
+ var replacementRegisters atomic.Int32
+ var failQuiesce atomic.Bool
+ var failReconfigure atomic.Bool
+ failQuiesce.Store(true)
+ failReconfigure.Store(true)
+
+ var deliveryMu sync.Mutex
+ delivered := make([]string, 0, 2)
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ quiesceCalls.Add(1)
+ if failQuiesce.Load() {
+ return marshalRPCError("quiesce_failed", "sqlite connection busy"), nil
+ }
+ return marshalRPCResult(rpcEmptyResponse{})
+ case pluginabi.MethodPluginReconfigure:
+ if failReconfigure.Load() {
+ return marshalRPCError("reconfigure_failed", "sqlite lock still busy"), nil
+ }
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ deliveryMu.Lock()
+ delivered = append(delivered, "old:"+rec.RequestID)
+ deliveryMu.Unlock()
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ replacementRegisters.Add(1)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ deliveryMu.Lock()
+ delivered = append(delivered, "new:"+rec.RequestID)
+ deliveryMu.Unlock()
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected replacement plugin method %s", method)
+ }
+ }}
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "before"})
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ if gotQuiesce := quiesceCalls.Load(); gotQuiesce != 1 {
+ t.Fatalf("quiesce calls after first failed replacement = %d, want 1", gotQuiesce)
+ }
+ if gotRegisters := replacementRegisters.Load(); gotRegisters != 0 {
+ t.Fatalf("replacement register count after first failed replacement = %d, want 0", gotRegisters)
+ }
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-after-failed-quiesce"})
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ if gotQuiesce := quiesceCalls.Load(); gotQuiesce != 2 {
+ t.Fatalf("quiesce calls after second failed replacement = %d, want 2", gotQuiesce)
+ }
+ if gotRegisters := replacementRegisters.Load(); gotRegisters != 0 {
+ t.Fatalf("replacement register count after second failed replacement = %d, want 0 (replacement must not load while quiesce still fails)", gotRegisters)
+ }
+
+ failQuiesce.Store(false)
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ if gotQuiesce := quiesceCalls.Load(); gotQuiesce != 3 {
+ t.Fatalf("quiesce calls after third replacement = %d, want 3", gotQuiesce)
+ }
+ if gotRegisters := replacementRegisters.Load(); gotRegisters != 1 {
+ t.Fatalf("replacement register count after third replacement = %d, want 1", gotRegisters)
+ }
+
+ deliveryMu.Lock()
+ gotDelivered := append([]string(nil), delivered...)
+ deliveryMu.Unlock()
+ if wantDelivered := []string{"old:before", "new:held-after-failed-quiesce"}; !slices.Equal(gotDelivered, wantDelivered) {
+ t.Fatalf("delivered usage = %v, want %v", gotDelivered, wantDelivered)
+ }
+}
+
+func TestHostApplyConfigRecoversFromTransientUsageReplayErrorWithoutReapply(t *testing.T) {
+ replacementRegisterStarted := make(chan struct{})
+ releaseReplacementRegister := make(chan struct{})
+ heldReplayStarted := make(chan struct{})
+ releaseHeldReplay := make(chan struct{})
+ asyncErrorObserved := make(chan struct{})
+ allDelivered := make(chan struct{})
+ var heldReplayOnce sync.Once
+ var asyncErrorOnce sync.Once
+ var deliveredOnce sync.Once
+ var failFirstHeldAttempt atomic.Bool
+ var failFirstAsyncAttempt atomic.Bool
+ failFirstHeldAttempt.Store(true)
+ failFirstAsyncAttempt.Store(true)
+
+ var deliveryMu sync.Mutex
+ delivered := make([]string, 0, 3)
+
+ oldClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ replacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, request []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ close(replacementRegisterStarted)
+ <-releaseReplacementRegister
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodUsageHandle:
+ var rec pluginapi.UsageRecord
+ if errUnmarshal := json.Unmarshal(request, &rec); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ if rec.RequestID == "held-1" {
+ heldReplayOnce.Do(func() { close(heldReplayStarted) })
+ <-releaseHeldReplay
+ if failFirstHeldAttempt.CompareAndSwap(true, false) {
+ return marshalRPCError("transient_error", "temporary sync replay failure"), nil
+ }
+ }
+ if rec.RequestID == "async-1" && failFirstAsyncAttempt.CompareAndSwap(true, false) {
+ asyncErrorOnce.Do(func() { close(asyncErrorObserved) })
+ return marshalRPCError("transient_error", "temporary write failure"), nil
+ }
+ deliveryMu.Lock()
+ delivered = append(delivered, rec.RequestID)
+ if len(delivered) == 3 {
+ deliveredOnce.Do(func() { close(allDelivered) })
+ }
+ deliveryMu.Unlock()
+ return marshalRPCResult(rpcEmptyResponse{})
+ default:
+ return nil, fmt.Errorf("unexpected replacement plugin method %s", method)
+ }
+ }}
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, replacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ var releaseHeldOnce sync.Once
+ t.Cleanup(func() {
+ releaseHeldOnce.Do(func() { close(releaseHeldReplay) })
+ })
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ adapter := &usageAdapter{
+ host: h,
+ pluginID: "alpha",
+ plugin: h.currentUsagePlugin("alpha"),
+ }
+
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+ applyDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ close(applyDone)
+ }()
+
+ waitForHostTestSignal(t, replacementRegisterStarted, "replacement registration start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "held-1"})
+ close(releaseReplacementRegister)
+
+ waitForHostTestSignal(t, heldReplayStarted, "held usage replay start")
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "async-1"})
+ releaseHeldOnce.Do(func() { close(releaseHeldReplay) })
+
+ waitForHostTestSignal(t, applyDone, "ApplyConfig completion")
+ waitForHostTestSignal(t, asyncErrorObserved, "first async replay transient error")
+
+ adapter.HandleUsage(context.Background(), coreusage.Record{RequestID: "after-1"})
+ waitForHostTestSignal(t, allDelivered, "retry of async-1 and delivery of after-1 without re-applying config")
+
+ deliveryMu.Lock()
+ gotDelivered := append([]string(nil), delivered...)
+ deliveryMu.Unlock()
+ if wantDelivered := []string{"held-1", "async-1", "after-1"}; !slices.Equal(gotDelivered, wantDelivered) {
+ t.Fatalf("delivered usage = %v, want %v", gotDelivered, wantDelivered)
+ }
+}
+
+func TestHostApplyConfigWaitsForCanceledRecoveryReconfigureAndQuiescesAgainOnNextReplacement(t *testing.T) {
+ var quiesceCalls atomic.Int32
+ var reconfigureCalls atomic.Int32
+ var resourceOpen atomic.Bool
+ var overlapDetected atomic.Bool
+
+ recoveryReconfigureStarted := make(chan struct{})
+ releaseRecoveryReconfigure := make(chan struct{})
+ var recoveryStartedOnce sync.Once
+ var releaseRecoveryOnce sync.Once
+
+ oldClient := &lifecycleTestClient{call: func(ctx context.Context, method string, _ []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ resourceOpen.Store(true)
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ case pluginabi.MethodPluginQuiesce:
+ quiesceCalls.Add(1)
+ resourceOpen.Store(false)
+ return marshalRPCResult(rpcEmptyResponse{})
+ case pluginabi.MethodPluginReconfigure:
+ callNum := reconfigureCalls.Add(1)
+ resourceOpen.Store(true)
+ if callNum == 1 {
+ return marshalRPCError("reconfigure_failed", "failed after reopening db"), nil
+ }
+ recoveryStartedOnce.Do(func() { close(recoveryReconfigureStarted) })
+ <-releaseRecoveryReconfigure
+ if errCtx := ctx.Err(); errCtx != nil {
+ return nil, errCtx
+ }
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ default:
+ return nil, fmt.Errorf("unexpected old plugin method %s", method)
+ }
+ }}
+ failingReplacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ if method != pluginabi.MethodPluginRegister {
+ return nil, fmt.Errorf("unexpected failing replacement method %s", method)
+ }
+ return nil, fmt.Errorf("first replacement registration failed")
+ }}
+ workingReplacementClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ if method != pluginabi.MethodPluginRegister {
+ return nil, fmt.Errorf("unexpected working replacement method %s", method)
+ }
+ if resourceOpen.Load() {
+ overlapDetected.Store(true)
+ }
+ return lifecycleRegistrationResult(validTestPlugin("alpha"))
+ }}
+
+ loader := &sequencePluginLoader{clients: []pluginClient{oldClient, failingReplacementClient, workingReplacementClient}}
+ h := NewForTest(loader)
+ t.Cleanup(h.ShutdownAll)
+ t.Cleanup(func() {
+ releaseRecoveryOnce.Do(func() { close(releaseRecoveryReconfigure) })
+ })
+ pluginsDir, _ := makeVersionedPluginDir(t, "alpha", "1.0.0")
+
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ writeVersionedPluginFile(t, pluginsDir, "alpha", "2.0.0")
+
+ // Attempt 1: quiesce succeeds, replacement fails, rollback reconfigure reopens resource and fails.
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ if got := quiesceCalls.Load(); got != 1 {
+ t.Fatalf("quiesce calls after first replacement = %d, want 1", got)
+ }
+ if !resourceOpen.Load() {
+ t.Fatal("expected rollback reconfigure to leave simulated resource open after partial failure")
+ }
+
+ // Attempt 2: same-path recovery reconfigure is canceled while blocked in native call.
+ recoveryCtx, cancelRecovery := context.WithCancel(context.Background())
+ recoveryDone := make(chan struct{})
+ go func() {
+ h.ApplyConfig(recoveryCtx, versionedPluginHostConfig(t, pluginsDir, "1.0.0"))
+ close(recoveryDone)
+ }()
+ waitForHostTestSignal(t, recoveryReconfigureStarted, "recovery reconfigure start")
+ cancelRecovery()
+
+ select {
+ case <-recoveryDone:
+ t.Fatal("ApplyConfig returned before canceled recovery reconfigure finished executing")
+ case <-time.After(50 * time.Millisecond):
+ }
+ releaseRecoveryOnce.Do(func() { close(releaseRecoveryReconfigure) })
+ waitForHostTestSignal(t, recoveryDone, "canceled recovery ApplyConfig completion")
+
+ // Attempt 3: replacement must call quiesce again before loading workingReplacementClient.
+ h.ApplyConfig(context.Background(), versionedPluginHostConfig(t, pluginsDir, "2.0.0"))
+ if got := quiesceCalls.Load(); got != 2 {
+ t.Fatalf("quiesce calls after second replacement = %d, want 2", got)
+ }
+ if overlapDetected.Load() {
+ t.Fatal("replacement registered while old plugin still held reopened resource")
+ }
+}
+
func TestHostCallQuiesceClassifiesErrors(t *testing.T) {
var out bytes.Buffer
originalOut := log.StandardLogger().Out
@@ -1490,11 +2568,62 @@ func TestRegisteredPluginsIncludesMetadataAndOAuthCapability(t *testing.T) {
if infos[0].OAuthProvider != "alpha" {
t.Fatalf("RegisteredPlugins()[0].OAuthProvider = %q, want alpha; infos=%#v", infos[0].OAuthProvider, infos)
}
+ if !reflect.DeepEqual(infos[0].OAuthProviders, []string{"alpha"}) {
+ t.Fatalf("RegisteredPlugins()[0].OAuthProviders = %#v, want [alpha]; infos=%#v", infos[0].OAuthProviders, infos)
+ }
if infos[0].Metadata.Logo == "" || len(infos[0].Metadata.ConfigFields) != 1 {
t.Fatalf("RegisteredPlugins()[0].Metadata = %#v, want logo and config fields", infos[0].Metadata)
}
}
+func TestRegisteredPluginsExposesMultipleOAuthProvidersViaRPC(t *testing.T) {
+ loader := newTestSymbolLoader()
+ plugin := &testPlugin{
+ registerResult: validTestPlugin("zcode"),
+ reconfigureResult: validTestPlugin("zcode"),
+ }
+ authProv := fakeAuthProvider{
+ identifier: "zcode-zai",
+ identifiers: []string{"zcode-zai", "zcode-bigmodel"},
+ startLogin: func(_ context.Context, req pluginapi.AuthLoginStartRequest) (pluginapi.AuthLoginStartResponse, error) {
+ return pluginapi.AuthLoginStartResponse{
+ Provider: req.Provider,
+ URL: "https://login.example/" + req.Provider,
+ State: "state-" + req.Provider,
+ }, nil
+ },
+ }
+ plugin.registerResult.Capabilities.AuthProvider = authProv
+ plugin.reconfigureResult.Capabilities.AuthProvider = authProv
+ loader.lookups["zcode"] = newTestSymbolLookup(plugin)
+ h := NewForTest(loader)
+
+ h.ApplyConfig(context.Background(), &config.Config{
+ Plugins: config.PluginsConfig{
+ Enabled: true,
+ Dir: makePluginDir(t, "zcode"),
+ Configs: enabledPluginConfigs("zcode"),
+ },
+ })
+
+ infos := h.RegisteredPlugins()
+ if len(infos) != 1 {
+ t.Fatalf("RegisteredPlugins() len = %d, want 1; infos=%#v", len(infos), infos)
+ }
+ if !infos[0].SupportsOAuth || infos[0].OAuthProvider != "zcode-zai" {
+ t.Fatalf("RegisteredPlugins()[0] = %#v, want SupportsOAuth=true OAuthProvider=zcode-zai", infos[0])
+ }
+ if !reflect.DeepEqual(infos[0].OAuthProviders, []string{"zcode-zai", "zcode-bigmodel"}) {
+ t.Fatalf("RegisteredPlugins()[0].OAuthProviders = %#v, want [zcode-zai zcode-bigmodel]", infos[0].OAuthProviders)
+ }
+ for _, portal := range []string{"zcode-zai", "zcode-bigmodel"} {
+ resp, handled, errStart := h.StartLogin(context.Background(), portal, "http://127.0.0.1:8317/callback")
+ if errStart != nil || !handled || resp.Provider != portal {
+ t.Fatalf("StartLogin(%q) = (%#v, %v, %v), want handled", portal, resp, handled, errStart)
+ }
+ }
+}
+
func TestHostApplyConfig_InvalidMetadataOrNoCapabilitiesSkipped(t *testing.T) {
loader := newTestSymbolLoader()
loader.lookups["empty-name"] = newTestSymbolLookup(&testPlugin{
@@ -2071,6 +3200,19 @@ type cancelOnErrContext struct {
cancel sync.Once
}
+type errObserveContext struct {
+ context.Context
+ onCanceledErr func()
+}
+
+func (c *errObserveContext) Err() error {
+ err := c.Context.Err()
+ if err != nil && c.onCanceledErr != nil {
+ c.onCanceledErr()
+ }
+ return err
+}
+
func (c *cancelOnErrContext) Done() <-chan struct{} {
return c.done
}
diff --git a/internal/pluginhost/http_bridge.go b/internal/pluginhost/http_bridge.go
index 1a23fa2302e..d5b8f906f55 100644
--- a/internal/pluginhost/http_bridge.go
+++ b/internal/pluginhost/http_bridge.go
@@ -5,7 +5,6 @@ import (
"bytes"
"context"
"crypto/tls"
- "encoding/base64"
"errors"
"fmt"
"io"
@@ -16,6 +15,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/httpwire"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
diff --git a/internal/pluginhost/http_bridge_test.go b/internal/pluginhost/http_bridge_test.go
index 391f9858f54..254f5e56f04 100644
--- a/internal/pluginhost/http_bridge_test.go
+++ b/internal/pluginhost/http_bridge_test.go
@@ -4,7 +4,6 @@ import (
"bufio"
"context"
"crypto/tls"
- "encoding/base64"
"io"
"net"
"net/http"
@@ -15,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
diff --git a/internal/pluginhost/http_operation_bridge.go b/internal/pluginhost/http_operation_bridge.go
index 682e7301cb9..12dc6363185 100644
--- a/internal/pluginhost/http_operation_bridge.go
+++ b/internal/pluginhost/http_operation_bridge.go
@@ -271,6 +271,9 @@ func (h *Host) closeHostHTTPCallbackInstance(pluginID string, instance *hostCall
if h.httpStreams != nil {
h.httpStreams.closeInstance(pluginID, instance)
}
+ if h.modelStreams != nil {
+ h.modelStreams.closeInstance(pluginID, instance)
+ }
}
func (h *Host) closeHostHTTPPluginResources(pluginID string, instance *hostCallbackInstance) {
@@ -284,6 +287,9 @@ func (h *Host) closeHostHTTPPluginResources(pluginID string, instance *hostCallb
if h.httpStreams != nil {
h.httpStreams.closePlugin(pluginID)
}
+ if h.modelStreams != nil {
+ h.modelStreams.closePlugin(pluginID)
+ }
}
func (h *Host) openHostHTTPOperation(ctx context.Context, callbackID string) (string, error) {
diff --git a/internal/pluginhost/issue6332_delivery_test.go b/internal/pluginhost/issue6332_delivery_test.go
index c81b0ad4bce..5abc1a2643b 100644
--- a/internal/pluginhost/issue6332_delivery_test.go
+++ b/internal/pluginhost/issue6332_delivery_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -22,8 +23,6 @@ import (
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginabi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type issue6332NestedLifecycle struct {
@@ -127,7 +126,7 @@ func TestIssue6332PluginNestedDelivery(t *testing.T) {
}
var callID string
for _, line := range strings.Split(output, "\n") {
- event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ event := ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
for _, item := range event.Get("response.output").Array() {
if item.Get("type").String() == "function_call" {
callID = item.Get("call_id").String()
@@ -138,8 +137,10 @@ func TestIssue6332PluginNestedDelivery(t *testing.T) {
result <- fmt.Errorf("missing function call: %s", output)
return
}
- second, _ := sjson.Set(first, "input.-1", map[string]any{"type": "function_call", "call_id": callID, "name": "lookup", "arguments": `{"key":"value"}`})
- second, _ = sjson.Set(second, "input.-1", map[string]any{"type": "function_call_output", "call_id": callID, "output": "found"})
+ doc := ojson.NewDocString(first)
+ _ = doc.Set("input.-1", map[string]any{"type": "function_call", "call_id": callID, "name": "lookup", "arguments": `{"key":"value"}`})
+ _ = doc.Set("input.-1", map[string]any{"type": "function_call_output", "call_id": callID, "output": "found"})
+ second := doc.String()
_, errCall = call(second)
result <- errCall
}()
diff --git a/internal/pluginhost/loader_unix.go b/internal/pluginhost/loader_unix.go
index 636413a88d9..d31f7b77804 100644
--- a/internal/pluginhost/loader_unix.go
+++ b/internal/pluginhost/loader_unix.go
@@ -8,6 +8,7 @@ package pluginhost
#include
#include
#include
+#include
typedef struct {
void* ptr;
@@ -75,22 +76,82 @@ static void cliproxy_set_host_api(cliproxy_host_api* api, uint32_t abi_version,
api->free_buffer = cliproxyHostFree;
}
+static int cliproxy_probe_host_call(void* host_ctx, const char* method, const uint8_t* request, size_t request_len, cliproxy_buffer* response) {
+ (void)host_ctx;
+ (void)method;
+ (void)request;
+ (void)request_len;
+ if (response != NULL) {
+ response->ptr = NULL;
+ response->len = 0;
+ }
+ return 1;
+}
+
+static void cliproxy_probe_host_free(void* ptr, size_t len) {
+ (void)ptr;
+ (void)len;
+}
+
+static int cliproxy_probe_init_and_shutdown(void* fn, uint32_t abi_version) {
+ static cliproxy_host_api host_api;
+ cliproxy_plugin_api plugin_api = {0};
+ host_api.abi_version = abi_version;
+ host_api.host_ctx = NULL;
+ host_api.call = cliproxy_probe_host_call;
+ host_api.free_buffer = cliproxy_probe_host_free;
+ int rc = ((cliproxy_plugin_init_fn)fn)(&host_api, &plugin_api);
+ if (rc == 0 && plugin_api.shutdown != NULL) {
+ plugin_api.shutdown();
+ }
+ return rc;
+}
+
+static void cliproxy_probe_exit(int code) {
+ _exit(code);
+}
+
*/
import "C"
import (
+ "bytes"
"context"
+ "errors"
"fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
"sync"
"sync/atomic"
+ "syscall"
"unsafe"
)
+const (
+ pluginDlopenProbeEnv = "CLIPROXY_PLUGIN_DLOPEN_PROBE"
+ pluginDlopenProbeStatusEnv = "CLIPROXY_PLUGIN_DLOPEN_PROBE_STATUS"
+ probeStatusStarted = "started"
+ probeStatusLoaded = "loaded"
+ probeStatusDlopenNull = "dlopen_null"
+ maxPluginProbeOutputBytes = 64 * 1024
+)
+
var (
- hostCallbackID atomic.Uintptr
- hostCallbackEntries sync.Map
+ hostCallbackID atomic.Uintptr
+ hostCallbackEntries sync.Map
+ probedPluginShadows sync.Map
+ pluginProbeExecutable = os.Executable
)
+func init() {
+ if probePath := os.Getenv(pluginDlopenProbeEnv); probePath != "" {
+ runPluginDlopenProbe(probePath)
+ }
+}
+
type dynamicLibraryLoader struct{}
type dynamicLibraryClient struct {
@@ -113,6 +174,10 @@ func (dynamicLibraryLoader) Open(file pluginFile, host *Host) (pluginClient, err
if errShadow != nil {
return nil, errShadow
}
+ if errProbe := probePluginLibrary(file.Path, loadPath); errProbe != nil {
+ removeShadowPlugin(loadPath)
+ return nil, errProbe
+ }
cPath := C.CString(loadPath)
defer C.free(unsafe.Pointer(cPath))
@@ -266,3 +331,239 @@ func dlerrorString() string {
}
return C.GoString(errText)
}
+
+func runPluginDlopenProbe(probePath string) {
+ statusPath := os.Getenv(pluginDlopenProbeStatusEnv)
+ _ = os.Unsetenv(pluginDlopenProbeEnv)
+ _ = os.Unsetenv(pluginDlopenProbeStatusEnv)
+ writeProbeStatus(statusPath, probeStatusStarted)
+ cPath := C.CString(probePath)
+ handle := C.cliproxy_dlopen(cPath)
+ C.free(unsafe.Pointer(cPath))
+ if handle == nil {
+ writeProbeStatus(statusPath, probeStatusDlopenNull)
+ C.cliproxy_probe_exit(0)
+ return
+ }
+ cSymbol := C.CString("cliproxy_plugin_init")
+ initSymbol := C.cliproxy_dlsym(handle, cSymbol)
+ C.free(unsafe.Pointer(cSymbol))
+ if initSymbol != nil {
+ _ = C.cliproxy_probe_init_and_shutdown(initSymbol, C.uint32_t(pluginHostABIVersion))
+ }
+ writeProbeStatus(statusPath, probeStatusLoaded)
+ C.cliproxy_probe_exit(0)
+}
+
+func writeProbeStatus(statusPath, status string) {
+ if statusPath == "" {
+ return
+ }
+ _ = os.WriteFile(statusPath, []byte(status), 0o600)
+}
+
+type limitedProbeBuffer struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+ limit int
+}
+
+func newLimitedProbeBuffer(limit int) *limitedProbeBuffer {
+ if limit <= 0 {
+ limit = maxPluginProbeOutputBytes
+ }
+ return &limitedProbeBuffer{limit: limit}
+}
+
+func (b *limitedProbeBuffer) Write(p []byte) (int, error) {
+ total := len(p)
+ if b == nil || b.limit <= 0 {
+ return total, nil
+ }
+ b.mu.Lock()
+ remaining := b.limit - b.buf.Len()
+ if remaining > 0 {
+ if len(p) > remaining {
+ p = p[:remaining]
+ }
+ _, _ = b.buf.Write(p)
+ }
+ b.mu.Unlock()
+ return total, nil
+}
+
+func (b *limitedProbeBuffer) String() string {
+ if b == nil {
+ return ""
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
+func probePluginLibrary(sourcePath, loadPath string) error {
+ cacheKey := filepath.Base(loadPath)
+ if cached, ok := probedPluginShadows.Load(cacheKey); ok {
+ if detail, _ := cached.(string); detail != "" {
+ return fmt.Errorf("dlopen %s: %s", sourcePath, detail)
+ }
+ return nil
+ }
+
+ execPath, errExec := pluginProbeExecutable()
+ if errExec != nil {
+ return fmt.Errorf("dlopen %s: resolve preflight probe executable: %w", sourcePath, errExec)
+ }
+ if strings.TrimSpace(execPath) == "" {
+ execPath = os.Args[0]
+ }
+
+ statusFile, errStatus := os.CreateTemp(filepath.Dir(loadPath), ".cliproxy-probe-*.status")
+ if errStatus != nil {
+ return fmt.Errorf("dlopen %s: create preflight probe status file: %w", sourcePath, errStatus)
+ }
+ statusPath := statusFile.Name()
+ _ = statusFile.Close()
+ defer func() {
+ _ = os.Remove(statusPath)
+ }()
+
+ devNull, errDevNull := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
+ if errDevNull != nil {
+ return fmt.Errorf("dlopen %s: open %s: %w", sourcePath, os.DevNull, errDevNull)
+ }
+ defer func() {
+ _ = devNull.Close()
+ }()
+
+ // Use an explicit os.Pipe (*os.File) for Stderr so os/exec does not spawn
+ // its own uncancelable pipe-drain goroutine. Closing stderrReader as soon
+ // as cmd.Wait() returns ensures a grandchild process that inherited Stderr
+ // neither blocks Wait() nor writes unbounded data to disk.
+ stderrReader, stderrWriter, errPipe := os.Pipe()
+ if errPipe != nil {
+ return fmt.Errorf("dlopen %s: create preflight probe pipe: %w", sourcePath, errPipe)
+ }
+ stderrBuf := newLimitedProbeBuffer(maxPluginProbeOutputBytes)
+ stderrDone := make(chan struct{})
+ go func() {
+ defer close(stderrDone)
+ _, _ = io.Copy(stderrBuf, stderrReader)
+ }()
+
+ cmd := exec.Command(execPath)
+ cmd.Env = append(
+ os.Environ(),
+ pluginDlopenProbeEnv+"="+loadPath,
+ pluginDlopenProbeStatusEnv+"="+statusPath,
+ )
+ cmd.Stdout = devNull
+ cmd.Stderr = stderrWriter
+ errStart := cmd.Start()
+ _ = stderrWriter.Close()
+ if errStart != nil {
+ _ = stderrReader.Close()
+ <-stderrDone
+ return fmt.Errorf("dlopen %s: preflight probe failed: %w", sourcePath, errStart)
+ }
+ errRun := cmd.Wait()
+ _ = stderrReader.Close()
+ <-stderrDone
+
+ statusBytes, _ := os.ReadFile(statusPath)
+ statusText := strings.TrimSpace(string(statusBytes))
+ stderrText := stderrBuf.String()
+
+ if errRun == nil {
+ switch statusText {
+ case probeStatusLoaded:
+ probedPluginShadows.Store(cacheKey, "")
+ return nil
+ case probeStatusDlopenNull:
+ return nil
+ case probeStatusStarted:
+ detail := "preflight probe exited prematurely before completing initialization"
+ if firstLine := firstNonEmptyLine(stderrText); firstLine != "" {
+ detail = fmt.Sprintf("%s: %s", detail, firstLine)
+ }
+ probedPluginShadows.Store(cacheKey, detail)
+ return fmt.Errorf("dlopen %s: %s", sourcePath, detail)
+ default:
+ return fmt.Errorf("dlopen %s: preflight probe did not complete", sourcePath)
+ }
+ }
+
+ detail := describePluginProbeFailure(errRun, stderrText)
+ if statusText == probeStatusStarted && isCacheablePluginProbeFailure(errRun, stderrText) {
+ probedPluginShadows.Store(cacheKey, detail)
+ }
+ return fmt.Errorf("dlopen %s: %s", sourcePath, detail)
+}
+
+func isCacheablePluginProbeFailure(errRun error, stderrText string) bool {
+ var exitErr *exec.ExitError
+ if errors.As(errRun, &exitErr) {
+ if status, okStatus := exitErr.Sys().(syscall.WaitStatus); okStatus && status.Signaled() {
+ switch status.Signal() {
+ case syscall.SIGILL, syscall.SIGSEGV, syscall.SIGBUS, syscall.SIGFPE, syscall.SIGABRT, syscall.SIGTRAP, syscall.SIGSYS:
+ return true
+ default:
+ return false
+ }
+ }
+ }
+ lowerStderr := strings.ToLower(stderrText)
+ for _, token := range []string{
+ "sigill",
+ "illegal instruction",
+ "sigsegv",
+ "segmentation violation",
+ "sigbus",
+ "bus error",
+ "sigfpe",
+ "floating-point exception",
+ "sigabrt",
+ "sigtrap",
+ "panic:",
+ "fatal error:",
+ } {
+ if strings.Contains(lowerStderr, token) {
+ return true
+ }
+ }
+ return false
+}
+
+func describePluginProbeFailure(errRun error, stderrText string) string {
+ firstLine := firstNonEmptyLine(stderrText)
+ lowerStderr := strings.ToLower(stderrText)
+ var exitErr *exec.ExitError
+ if errors.As(errRun, &exitErr) {
+ if status, okStatus := exitErr.Sys().(syscall.WaitStatus); okStatus && status.Signaled() {
+ sig := status.Signal()
+ if sig == syscall.SIGILL {
+ return "preflight probe terminated by SIGILL: illegal instruction (possible CPU instruction set mismatch)"
+ }
+ if firstLine != "" {
+ return fmt.Sprintf("preflight probe terminated by signal %s: %s", sig, firstLine)
+ }
+ return fmt.Sprintf("preflight probe terminated by signal %s", sig)
+ }
+ }
+ if strings.Contains(stderrText, "SIGILL") || strings.Contains(lowerStderr, "illegal instruction") {
+ return "preflight probe terminated by SIGILL: illegal instruction (possible CPU instruction set mismatch)"
+ }
+ if firstLine != "" {
+ return fmt.Sprintf("preflight probe failed (%v): %s", errRun, firstLine)
+ }
+ return fmt.Sprintf("preflight probe failed: %v", errRun)
+}
+
+func firstNonEmptyLine(text string) string {
+ for _, line := range strings.Split(text, "\n") {
+ if trimmed := strings.TrimSpace(line); trimmed != "" {
+ return trimmed
+ }
+ }
+ return ""
+}
diff --git a/internal/pluginhost/loader_unix_test.go b/internal/pluginhost/loader_unix_test.go
index 9bfd515a1af..e6246587caa 100644
--- a/internal/pluginhost/loader_unix_test.go
+++ b/internal/pluginhost/loader_unix_test.go
@@ -4,12 +4,18 @@ package pluginhost
import (
"context"
+ "errors"
+ "fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
+ "strings"
+ "syscall"
"testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
)
func testHostCallbackID(t *testing.T, client *dynamicLibraryClient) uintptr {
@@ -30,11 +36,8 @@ func buildReloadTestPlugin(t *testing.T, version int, outPath string) {
cmd := exec.Command(goTool, "build", "-buildmode=c-shared", "-o", outPath, ".")
cmd.Dir = sourceDir
cflags := "-DPLUGIN_VERSION=1"
- switch version {
- case 2:
- cflags = "-DPLUGIN_VERSION=2"
- case 3:
- cflags = "-DPLUGIN_VERSION=3"
+ if version >= 1 {
+ cflags = fmt.Sprintf("-DPLUGIN_VERSION=%d", version)
}
cmd.Env = append(os.Environ(), "CGO_ENABLED=1", "CGO_CFLAGS="+cflags)
if output, errBuild := cmd.CombinedOutput(); errBuild != nil {
@@ -198,3 +201,257 @@ func TestUnixPluginOpenFailureKeepsImageUsableOnRetry(t *testing.T) {
}
client.Shutdown()
}
+
+func TestUnixPluginLoaderIsolatesSigillOnDlopen(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "broken"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 4, pluginPath)
+
+ host := New()
+ file := pluginFile{ID: "broken", Path: pluginPath, Version: "4"}
+ loader := dynamicLibraryLoader{}
+ _, errOpen := loader.Open(file, host)
+ if errOpen == nil {
+ t.Fatal("Open() with SIGILL plugin succeeded, want error")
+ }
+ if !strings.Contains(errOpen.Error(), "preflight probe terminated by SIGILL: illegal instruction") {
+ t.Fatalf("Open() error = %v, want preflight SIGILL diagnostic", errOpen)
+ }
+
+ // Retrying the same crashing plugin binary must fail cleanly without
+ // crashing the host process.
+ _, errRetry := loader.Open(file, host)
+ if errRetry == nil {
+ t.Fatal("Open() retry with SIGILL plugin succeeded, want error")
+ }
+ if !strings.Contains(errRetry.Error(), "preflight probe terminated by SIGILL: illegal instruction") {
+ t.Fatalf("Open() retry error = %v, want cached preflight SIGILL diagnostic", errRetry)
+ }
+
+ // Applying host config with the crashing plugin enabled must isolate
+ // the failure and keep the host process alive.
+ host.ApplyConfig(context.Background(), &config.Config{
+ Plugins: config.PluginsConfig{
+ Enabled: true,
+ Dir: dir,
+ Configs: enabledPluginConfigs("broken"),
+ },
+ })
+ if host.PluginLoaded("broken") {
+ t.Fatal("PluginLoaded(broken) = true, want false after preflight SIGILL failure")
+ }
+
+ // Replacing the broken binary with a valid build under the same path
+ // must succeed without needing to restart the host process.
+ buildReloadTestPlugin(t, 1, pluginPath)
+ file.Version = "1"
+ client, errFixed := loader.Open(file, host)
+ if errFixed != nil {
+ t.Fatalf("Open() fixed plugin after SIGILL error = %v", errFixed)
+ }
+ defer client.Shutdown()
+ resp, errCall := client.Call(context.Background(), "test.marker", nil)
+ if errCall != nil {
+ t.Fatalf("Call() fixed plugin error = %v", errCall)
+ }
+ if string(resp) != "v1" {
+ t.Fatalf("Call() fixed plugin response = %q, want v1", resp)
+ }
+}
+
+func TestUnixPluginLoaderDoesNotCacheTransientProbeStartFailure(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "transient"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 1, pluginPath)
+ probedPluginShadows.Range(func(key, _ any) bool {
+ probedPluginShadows.Delete(key)
+ return true
+ })
+
+ origProbeExec := pluginProbeExecutable
+ t.Cleanup(func() {
+ pluginProbeExecutable = origProbeExec
+ })
+ pluginProbeExecutable = func() (string, error) {
+ return "", errors.New("transient executable lookup failure")
+ }
+
+ host := New()
+ file := pluginFile{ID: "transient", Path: pluginPath, Version: "1"}
+ loader := dynamicLibraryLoader{}
+ if _, errOpen := loader.Open(file, host); errOpen == nil {
+ t.Fatal("Open() with transient probe executable failure succeeded, want error")
+ }
+
+ // Also verify a spawn failure (non-existent executable) is not cached.
+ pluginProbeExecutable = func() (string, error) {
+ return filepath.Join(dir, "nonexistent-probe-binary"), nil
+ }
+ if _, errOpen := loader.Open(file, host); errOpen == nil {
+ t.Fatal("Open() with missing probe binary succeeded, want error")
+ }
+
+ // Once the transient failure resolves, the same plugin binary must load
+ // without needing a content change or process restart.
+ pluginProbeExecutable = origProbeExec
+ client, errOpen := loader.Open(file, host)
+ if errOpen != nil {
+ t.Fatalf("Open() after transient probe failure recovered = %v, want nil", errOpen)
+ }
+ defer client.Shutdown()
+ resp, errCall := client.Call(context.Background(), "test.marker", nil)
+ if errCall != nil {
+ t.Fatalf("Call() recovered plugin error = %v", errCall)
+ }
+ if string(resp) != "v1" {
+ t.Fatalf("Call() recovered plugin response = %q, want v1", resp)
+ }
+}
+
+func TestUnixPluginLoaderDoesNotCacheUnloadedProbeSuccess(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "unloaded"+testPluginLibraryExtension())
+ if errWrite := os.WriteFile(pluginPath, []byte("not-a-valid-shared-library"), 0o644); errWrite != nil {
+ t.Fatalf("WriteFile() error = %v", errWrite)
+ }
+ file := pluginFile{ID: "unloaded", Path: pluginPath, Version: "1"}
+ loadPath, errShadow := shadowCopyPlugin(file)
+ if errShadow != nil {
+ t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
+ }
+ cacheKey := filepath.Base(loadPath)
+ removeShadowPlugin(loadPath)
+ probedPluginShadows.Delete(cacheKey)
+
+ host := New()
+ loader := dynamicLibraryLoader{}
+ if _, errOpen := loader.Open(file, host); errOpen == nil {
+ t.Fatal("Open() with invalid shared library succeeded, want error")
+ }
+ if _, cached := probedPluginShadows.Load(cacheKey); cached {
+ t.Fatalf("probedPluginShadows cached %q after dlopen returned NULL; subsequent retry would skip preflight probe", cacheKey)
+ }
+}
+
+func TestUnixPluginLoaderRejectsPrematureExitZeroInConstructor(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "early-exit"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 5, pluginPath)
+
+ host := New()
+ file := pluginFile{ID: "early-exit", Path: pluginPath, Version: "5"}
+ loader := dynamicLibraryLoader{}
+ _, errOpen := loader.Open(file, host)
+ if errOpen == nil {
+ t.Fatal("Open() with premature _exit(0) plugin succeeded, want error")
+ }
+ if !strings.Contains(errOpen.Error(), "preflight probe exited prematurely before completing initialization") {
+ t.Fatalf("Open() error = %v, want premature exit diagnostic", errOpen)
+ }
+}
+
+func TestUnixPluginLoaderDoesNotCacheSigkillDuringProbe(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "killed"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 6, pluginPath)
+
+ file := pluginFile{ID: "killed", Path: pluginPath, Version: "6"}
+ loadPath, errShadow := shadowCopyPlugin(file)
+ if errShadow != nil {
+ t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
+ }
+ cacheKey := filepath.Base(loadPath)
+ removeShadowPlugin(loadPath)
+ probedPluginShadows.Delete(cacheKey)
+
+ host := New()
+ loader := dynamicLibraryLoader{}
+ if _, errOpen := loader.Open(file, host); errOpen == nil {
+ t.Fatal("Open() with SIGKILL plugin succeeded, want error")
+ }
+ if _, cached := probedPluginShadows.Load(cacheKey); cached {
+ t.Fatalf("probedPluginShadows cached %q after SIGKILL; transient external kill must not be permanently cached", cacheKey)
+ }
+}
+
+func TestUnixPluginProbeDoesNotBlockOnInheritedStdioGrandchild(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "grandchild"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 7, pluginPath)
+
+ pidFile := filepath.Join(dir, "grandchild.pid")
+ t.Setenv("CLIPROXY_TEST_GRANDCHILD_PID_FILE", pidFile)
+ t.Cleanup(func() {
+ raw, errRead := os.ReadFile(pidFile)
+ if errRead != nil {
+ return
+ }
+ pid, errParse := strconv.Atoi(strings.TrimSpace(string(raw)))
+ if errParse == nil && pid > 0 {
+ _ = syscall.Kill(pid, syscall.SIGKILL)
+ }
+ })
+
+ file := pluginFile{ID: "grandchild", Path: pluginPath, Version: "7"}
+ loadPath, errShadow := shadowCopyPlugin(file)
+ if errShadow != nil {
+ t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
+ }
+ t.Cleanup(func() {
+ removeShadowPlugin(loadPath)
+ })
+ cacheKey := filepath.Base(loadPath)
+ probedPluginShadows.Delete(cacheKey)
+
+ if errProbe := probePluginLibrary(file.Path, loadPath); errProbe != nil {
+ t.Fatalf("probePluginLibrary() error = %v, want nil without blocking on grandchild stdio", errProbe)
+ }
+ raw, errRead := os.ReadFile(pidFile)
+ if errRead != nil {
+ t.Fatalf("ReadFile(%s) error = %v, want grandchild pid recorded", pidFile, errRead)
+ }
+ pid, errParse := strconv.Atoi(strings.TrimSpace(string(raw)))
+ if errParse != nil || pid <= 0 {
+ t.Fatalf("grandchild pid = %q (%v), want positive pid", string(raw), errParse)
+ }
+}
+
+func TestUnixPluginProbeStoredHostCallbackAndLargeStderrSubprocess(t *testing.T) {
+ dir := t.TempDir()
+ pluginPath := filepath.Join(dir, "noisy-shutdown"+testPluginLibraryExtension())
+ buildReloadTestPlugin(t, 8, pluginPath)
+
+ file := pluginFile{ID: "noisy-shutdown", Path: pluginPath, Version: "8"}
+ loadPath, errShadow := shadowCopyPlugin(file)
+ if errShadow != nil {
+ t.Fatalf("shadowCopyPlugin() error = %v", errShadow)
+ }
+ t.Cleanup(func() {
+ removeShadowPlugin(loadPath)
+ })
+ cacheKey := filepath.Base(loadPath)
+ probedPluginShadows.Delete(cacheKey)
+
+ errProbe := probePluginLibrary(file.Path, loadPath)
+ if errProbe == nil {
+ t.Fatal("probePluginLibrary() with noisy _exit(1) shutdown succeeded, want error")
+ }
+ if len(errProbe.Error()) > maxPluginProbeOutputBytes+512 {
+ t.Fatalf("probePluginLibrary() error length = %d, exceeds bounded limit", len(errProbe.Error()))
+ }
+}
+
+func TestLimitedProbeBufferCapsRetainedOutputWithoutBlocking(t *testing.T) {
+ buf := newLimitedProbeBuffer(16)
+ n1, err1 := buf.Write([]byte("0123456789"))
+ if err1 != nil || n1 != 10 {
+ t.Fatalf("first Write() = (%d, %v), want (10, nil)", n1, err1)
+ }
+ n2, err2 := buf.Write([]byte("abcdefghijklmnop"))
+ if err2 != nil || n2 != 16 {
+ t.Fatalf("second Write() = (%d, %v), want (16, nil)", n2, err2)
+ }
+ if got := buf.String(); got != "0123456789abcdef" {
+ t.Fatalf("String() = %q, want %q", got, "0123456789abcdef")
+ }
+}
diff --git a/internal/pluginhost/model_stream_bridge.go b/internal/pluginhost/model_stream_bridge.go
index 9f2c69db2aa..c88116a5776 100644
--- a/internal/pluginhost/model_stream_bridge.go
+++ b/internal/pluginhost/model_stream_bridge.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
+ "strings"
"sync"
"sync/atomic"
@@ -11,31 +12,142 @@ import (
)
type modelStreamBridge struct {
- next atomic.Uint64
- mu sync.Mutex
- streams map[string]modelStreamEntry
+ next atomic.Uint64
+ mu sync.Mutex
+ streams map[string]modelStreamEntry
+ inflight map[uint64]modelStreamInflight
+}
+
+type modelStreamInflight struct {
+ pluginID string
+ callbackPluginID string
+ instance *hostCallbackInstance
+ callbackInstance *hostCallbackInstance
+ cancel context.CancelFunc
}
type modelStreamEntry struct {
- ownerCallbackID string
- chunks <-chan handlers.ModelExecutionChunk
- cancel context.CancelFunc
+ pluginID string
+ callbackPluginID string
+ instance *hostCallbackInstance
+ callbackInstance *hostCallbackInstance
+ ownerCallbackID string
+ chunks <-chan handlers.ModelExecutionChunk
+ cancel context.CancelFunc
+ stopScopeCleanup func()
}
func newModelStreamBridge() *modelStreamBridge {
- return &modelStreamBridge{streams: make(map[string]modelStreamEntry)}
+ return &modelStreamBridge{
+ streams: make(map[string]modelStreamEntry),
+ inflight: make(map[uint64]modelStreamInflight),
+ }
+}
+
+func (b *modelStreamBridge) begin(pluginID, callbackPluginID string, instance, callbackInstance *hostCallbackInstance, cancel context.CancelFunc) (uint64, bool) {
+ if b == nil {
+ if cancel != nil {
+ cancel()
+ }
+ return 0, false
+ }
+ pluginID = strings.TrimSpace(pluginID)
+ callbackPluginID = strings.TrimSpace(callbackPluginID)
+ b.mu.Lock()
+ if (instance != nil && instance.closed.Load()) || (callbackInstance != nil && callbackInstance.closed.Load()) {
+ b.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+ return 0, false
+ }
+ if b.inflight == nil {
+ b.inflight = make(map[uint64]modelStreamInflight)
+ }
+ id := b.next.Add(1)
+ b.inflight[id] = modelStreamInflight{
+ pluginID: pluginID,
+ callbackPluginID: callbackPluginID,
+ instance: instance,
+ callbackInstance: callbackInstance,
+ cancel: cancel,
+ }
+ b.mu.Unlock()
+ return id, true
+}
+
+func (b *modelStreamBridge) abort(inflightID uint64) {
+ if b == nil || inflightID == 0 {
+ return
+ }
+ b.mu.Lock()
+ entry, ok := b.inflight[inflightID]
+ delete(b.inflight, inflightID)
+ b.mu.Unlock()
+ if ok && entry.cancel != nil {
+ entry.cancel()
+ }
}
-func (b *modelStreamBridge) open(ownerCallbackID string, chunks <-chan handlers.ModelExecutionChunk, cancel context.CancelFunc) string {
+func (b *modelStreamBridge) complete(inflightID uint64, pluginID string, instance *hostCallbackInstance, ownerCallbackID string, chunks <-chan handlers.ModelExecutionChunk, cancel context.CancelFunc) string {
if b == nil || chunks == nil {
+ b.abort(inflightID)
if cancel != nil {
cancel()
}
return ""
}
id := strconv.FormatUint(b.next.Add(1), 10)
+ pluginID = strings.TrimSpace(pluginID)
b.mu.Lock()
+ inflightEntry, stillInflight := b.inflight[inflightID]
+ delete(b.inflight, inflightID)
+ if !stillInflight || (instance != nil && instance.closed.Load()) || (inflightEntry.callbackInstance != nil && inflightEntry.callbackInstance.closed.Load()) {
+ b.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+ return ""
+ }
+ if b.streams == nil {
+ b.streams = make(map[string]modelStreamEntry)
+ }
+ b.streams[id] = modelStreamEntry{
+ pluginID: pluginID,
+ callbackPluginID: inflightEntry.callbackPluginID,
+ instance: instance,
+ callbackInstance: inflightEntry.callbackInstance,
+ ownerCallbackID: ownerCallbackID,
+ chunks: chunks,
+ cancel: cancel,
+ }
+ b.mu.Unlock()
+ return id
+}
+
+func (b *modelStreamBridge) open(pluginID string, instance *hostCallbackInstance, ownerCallbackID string, chunks <-chan handlers.ModelExecutionChunk, cancel context.CancelFunc) string {
+ if b == nil || chunks == nil {
+ if cancel != nil {
+ cancel()
+ }
+ return ""
+ }
+ id := strconv.FormatUint(b.next.Add(1), 10)
+ pluginID = strings.TrimSpace(pluginID)
+ b.mu.Lock()
+ if instance != nil && instance.closed.Load() {
+ b.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+ return ""
+ }
+ if b.streams == nil {
+ b.streams = make(map[string]modelStreamEntry)
+ }
b.streams[id] = modelStreamEntry{
+ pluginID: pluginID,
+ instance: instance,
ownerCallbackID: ownerCallbackID,
chunks: chunks,
cancel: cancel,
@@ -44,47 +156,173 @@ func (b *modelStreamBridge) open(ownerCallbackID string, chunks <-chan handlers.
return id
}
-func (b *modelStreamBridge) read(ctx context.Context, id string) (handlers.ModelExecutionChunk, bool, error) {
+func (b *modelStreamBridge) setScopeCleanup(pluginID string, instance *hostCallbackInstance, id string, stop func()) bool {
+ if b == nil || stop == nil {
+ if stop != nil {
+ stop()
+ }
+ return false
+ }
+ id = strings.TrimSpace(id)
+ pluginID = strings.TrimSpace(pluginID)
+ b.mu.Lock()
+ entry, ok := b.streams[id]
+ if ok && entry.pluginID == pluginID && entry.instance == instance && entry.stopScopeCleanup == nil {
+ entry.stopScopeCleanup = stop
+ b.streams[id] = entry
+ b.mu.Unlock()
+ return true
+ }
+ b.mu.Unlock()
+ stop()
+ return false
+}
+
+func (b *modelStreamBridge) read(ctx context.Context, pluginID string, instance *hostCallbackInstance, id string) (handlers.ModelExecutionChunk, bool, error) {
if b == nil {
return handlers.ModelExecutionChunk{}, true, fmt.Errorf("model stream bridge is unavailable")
}
+ id = strings.TrimSpace(id)
if id == "" {
return handlers.ModelExecutionChunk{}, true, fmt.Errorf("model stream id is required")
}
+ pluginID = strings.TrimSpace(pluginID)
b.mu.Lock()
entry, ok := b.streams[id]
b.mu.Unlock()
if !ok || entry.chunks == nil {
return handlers.ModelExecutionChunk{}, true, nil
}
+ if entry.pluginID != pluginID || entry.instance != instance {
+ return handlers.ModelExecutionChunk{}, true, fmt.Errorf("model stream %s is not open", id)
+ }
if ctx == nil {
ctx = context.Background()
}
select {
case <-ctx.Done():
- b.close(id)
+ b.close(pluginID, instance, id)
return handlers.ModelExecutionChunk{}, true, ctx.Err()
case chunk, okRead := <-entry.chunks:
if !okRead {
- b.close(id)
+ b.close(pluginID, instance, id)
return handlers.ModelExecutionChunk{}, true, nil
}
if chunk.Err != nil {
- b.close(id)
+ b.close(pluginID, instance, id)
return chunk, true, nil
}
return chunk, false, nil
}
}
-func (b *modelStreamBridge) close(id string) {
- if b == nil || id == "" {
+func (b *modelStreamBridge) close(pluginID string, instance *hostCallbackInstance, id string) {
+ if b == nil {
+ return
+ }
+ id = strings.TrimSpace(id)
+ if id == "" {
return
}
+ pluginID = strings.TrimSpace(pluginID)
b.mu.Lock()
- entry := b.streams[id]
+ entry, ok := b.streams[id]
+ if !ok || entry.pluginID != pluginID || entry.instance != instance {
+ b.mu.Unlock()
+ return
+ }
delete(b.streams, id)
b.mu.Unlock()
+ closeModelStreamEntry(entry)
+}
+
+func (b *modelStreamBridge) closeInstance(pluginID string, instance *hostCallbackInstance) {
+ if instance == nil {
+ b.closePlugin(pluginID)
+ return
+ }
+ instance.closed.Store(true)
+ if b == nil {
+ return
+ }
+ b.closeMatching(pluginID, instance, false)
+}
+
+func (b *modelStreamBridge) closePlugin(pluginID string) {
+ if b == nil || strings.TrimSpace(pluginID) == "" {
+ return
+ }
+ b.closeMatching(pluginID, nil, false)
+}
+
+func (b *modelStreamBridge) closeAll() {
+ if b == nil {
+ return
+ }
+ b.closeMatching("", nil, true)
+}
+
+func matchesModelStreamOwner(pluginID, callbackPluginID string, instance, callbackInstance *hostCallbackInstance, targetPluginID string, targetInstance *hostCallbackInstance) bool {
+ pluginMatch := pluginID == targetPluginID || (pluginID == "" && callbackPluginID == targetPluginID)
+ if !pluginMatch {
+ return false
+ }
+ if targetInstance == nil {
+ return true
+ }
+ return instance == targetInstance || (instance == nil && callbackInstance == targetInstance)
+}
+
+func (b *modelStreamBridge) closeMatching(pluginID string, instance *hostCallbackInstance, all bool) {
+ pluginID = strings.TrimSpace(pluginID)
+ b.mu.Lock()
+ entries := make([]modelStreamEntry, 0)
+ for id, entry := range b.streams {
+ if !all && !matchesModelStreamOwner(entry.pluginID, entry.callbackPluginID, entry.instance, entry.callbackInstance, pluginID, instance) {
+ continue
+ }
+ if all {
+ if entry.instance != nil {
+ entry.instance.closed.Store(true)
+ }
+ if entry.callbackInstance != nil {
+ entry.callbackInstance.closed.Store(true)
+ }
+ }
+ delete(b.streams, id)
+ entries = append(entries, entry)
+ }
+ inflightCancels := make([]context.CancelFunc, 0)
+ for id, entry := range b.inflight {
+ if !all && !matchesModelStreamOwner(entry.pluginID, entry.callbackPluginID, entry.instance, entry.callbackInstance, pluginID, instance) {
+ continue
+ }
+ if all {
+ if entry.instance != nil {
+ entry.instance.closed.Store(true)
+ }
+ if entry.callbackInstance != nil {
+ entry.callbackInstance.closed.Store(true)
+ }
+ }
+ delete(b.inflight, id)
+ if entry.cancel != nil {
+ inflightCancels = append(inflightCancels, entry.cancel)
+ }
+ }
+ b.mu.Unlock()
+ for _, cancel := range inflightCancels {
+ cancel()
+ }
+ for _, entry := range entries {
+ closeModelStreamEntry(entry)
+ }
+}
+
+func closeModelStreamEntry(entry modelStreamEntry) {
+ if entry.stopScopeCleanup != nil {
+ entry.stopScopeCleanup()
+ }
if entry.cancel != nil {
entry.cancel()
}
diff --git a/internal/pluginhost/plugin_refresh_compat_executor.go b/internal/pluginhost/plugin_refresh_compat_executor.go
index 4ba61fceb52..c7a2f5242dd 100644
--- a/internal/pluginhost/plugin_refresh_compat_executor.go
+++ b/internal/pluginhost/plugin_refresh_compat_executor.go
@@ -57,6 +57,10 @@ func UnwrapPluginRefreshCompatExecutor(executor coreauth.ProviderExecutor) (core
return wrapper.inner, true
}
+func (e *pluginRefreshCompatExecutor) UsesConfig(cfg *config.Config) bool {
+ return e != nil && e.cfg == cfg
+}
+
func (e *pluginRefreshCompatExecutor) Identifier() string {
if e == nil {
return ""
diff --git a/internal/pluginhost/quota_provider.go b/internal/pluginhost/quota_provider.go
index a596dc7d12c..93f214f41dd 100644
--- a/internal/pluginhost/quota_provider.go
+++ b/internal/pluginhost/quota_provider.go
@@ -158,9 +158,11 @@ func (h *Host) QuotaProviderPluginIDs(ctx context.Context) map[string]string {
addCandidate(identifier, record.id)
}
addCandidate(normalizeProviderID(record.id), record.id)
- if record.plugin.Capabilities.AuthProvider != nil {
- if authID, okAuth := h.callAuthProviderIdentifier(record.id, record.plugin.Capabilities.AuthProvider); okAuth {
- addCandidate(authID, record.id)
+ if hasRecordAuthProvider(record) {
+ if authIDs, okAuth := h.callRecordAuthProviderIdentifiers(record); okAuth {
+ for _, authID := range authIDs {
+ addCandidate(authID, record.id)
+ }
}
}
}
@@ -219,10 +221,14 @@ func (h *Host) quotaProviderRecord(ctx context.Context, provider string) *capabi
r := record
return &r
}
- if record.plugin.Capabilities.AuthProvider != nil {
- if authID, okAuth := h.callAuthProviderIdentifier(record.id, record.plugin.Capabilities.AuthProvider); okAuth && authID == provider {
- r := record
- return &r
+ if hasRecordAuthProvider(record) {
+ if authIDs, okAuth := h.callRecordAuthProviderIdentifiers(record); okAuth {
+ for _, authID := range authIDs {
+ if authID == provider {
+ r := record
+ return &r
+ }
+ }
}
}
}
diff --git a/internal/pluginhost/rpc_client.go b/internal/pluginhost/rpc_client.go
index 495bb63f72f..332674bfdf5 100644
--- a/internal/pluginhost/rpc_client.go
+++ b/internal/pluginhost/rpc_client.go
@@ -25,7 +25,10 @@ type rpcPluginAdapter struct {
type rpcAuthProvider struct {
*rpcPluginAdapter
- identifier string
+ identifier string
+ identifiers []string
+ credentialProviders []string
+ entries []pluginapi.AuthProviderEntry
}
type rpcFrontendAuthProvider struct {
@@ -56,6 +59,10 @@ func (e rpcError) Error() string {
return e.message
}
+func (e rpcError) ErrorCode() string {
+ return e.Code
+}
+
func (e rpcError) StatusCode() int {
return e.statusCode
}
@@ -100,8 +107,9 @@ func registerRPCPlugin(ctx context.Context, host *Host, id string, client plugin
return pluginapi.Plugin{}, fmt.Errorf("plugin client is nil")
}
resp, errCall := callPlugin[rpcRegistration](ctx, client, method, rpcLifecycleRequest{
- ConfigYAML: bytes.Clone(configYAML),
- SchemaVersion: pluginabi.SchemaVersion,
+ ConfigYAML: bytes.Clone(configYAML),
+ SchemaVersion: pluginabi.SchemaVersion,
+ MultiAuthProviders: pluginabi.SchemaVersion >= pluginabi.SchemaVersionMultiAuthProviders,
})
if errCall != nil {
return pluginapi.Plugin{}, errCall
@@ -135,13 +143,16 @@ func registerRPCPlugin(ctx context.Context, host *Host, id string, client plugin
if err := ctx.Err(); err != nil {
return pluginapi.Plugin{}, err
}
- identifier := callPluginIdentifier(ctx, adapter.client, pluginabi.MethodAuthIdentifier)
+ identifier, identifiers, credentialProviders, entries := callPluginAuthIdentifiers(ctx, adapter.client)
if err := ctx.Err(); err != nil {
return pluginapi.Plugin{}, err
}
plugin.Capabilities.AuthProvider = rpcAuthProvider{
- rpcPluginAdapter: adapter,
- identifier: identifier,
+ rpcPluginAdapter: adapter,
+ identifier: identifier,
+ identifiers: identifiers,
+ credentialProviders: credentialProviders,
+ entries: entries,
}
}
if resp.Capabilities.FrontendAuthProvider {
@@ -503,10 +514,80 @@ func callPluginIdentifier(ctx context.Context, client pluginClient, method strin
return strings.TrimSpace(resp.Identifier)
}
+func callPluginAuthIdentifiers(ctx context.Context, client pluginClient) (string, []string, []string, []pluginapi.AuthProviderEntry) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ resp, errCall := callPlugin[rpcIdentifierResponse](ctx, client, pluginabi.MethodAuthIdentifier, rpcEmptyResponse{})
+ if errCall != nil {
+ return "", nil, nil, nil
+ }
+ primary := strings.TrimSpace(resp.Identifier)
+ identifiers := make([]string, 0, 1+len(resp.Identifiers)+len(resp.Entries))
+ if primary != "" {
+ identifiers = append(identifiers, primary)
+ }
+ for _, item := range resp.Identifiers {
+ trimmed := strings.TrimSpace(item)
+ if trimmed == "" {
+ continue
+ }
+ if primary == "" {
+ primary = trimmed
+ }
+ identifiers = append(identifiers, trimmed)
+ }
+ var entries []pluginapi.AuthProviderEntry
+ if len(resp.Entries) > 0 {
+ entries = make([]pluginapi.AuthProviderEntry, 0, len(resp.Entries))
+ for _, item := range resp.Entries {
+ normalizedIDs := normalizeAuthProviderIdentifiers(item.Identifier)
+ if len(normalizedIDs) == 0 {
+ continue
+ }
+ name := strings.TrimSpace(item.Name)
+ logo := strings.TrimSpace(item.Logo)
+ for _, id := range normalizedIDs {
+ if primary == "" {
+ primary = id
+ }
+ identifiers = append(identifiers, id)
+ entries = append(entries, pluginapi.AuthProviderEntry{
+ Identifier: id,
+ Name: name,
+ Logo: logo,
+ })
+ }
+ }
+ if len(entries) == 0 {
+ entries = nil
+ }
+ }
+ credentialProviders := make([]string, 0, len(resp.CredentialProviders))
+ for _, item := range resp.CredentialProviders {
+ if trimmed := strings.TrimSpace(item); trimmed != "" {
+ credentialProviders = append(credentialProviders, trimmed)
+ }
+ }
+ return primary, identifiers, credentialProviders, entries
+}
+
func (a rpcAuthProvider) Identifier() string {
return a.identifier
}
+func (a rpcAuthProvider) Identifiers() []string {
+ return append([]string(nil), a.identifiers...)
+}
+
+func (a rpcAuthProvider) AuthEntries() []pluginapi.AuthProviderEntry {
+ return append([]pluginapi.AuthProviderEntry(nil), a.entries...)
+}
+
+func (a rpcAuthProvider) CredentialProviders() []string {
+ return append([]string(nil), a.credentialProviders...)
+}
+
func (a rpcFrontendAuthProvider) Identifier() string {
return callPluginIdentifier(context.Background(), a.client, pluginabi.MethodFrontendAuthIdentifier)
}
@@ -663,9 +744,25 @@ func (a rpcThinkingApplier) ApplyThinking(ctx context.Context, req pluginapi.Thi
}
func (a *rpcPluginAdapter) HandleUsage(ctx context.Context, record pluginapi.UsageRecord) {
+ if a == nil {
+ return
+ }
+ if a.host != nil {
+ a.host.dispatchPluginUsageRecord(ctx, a.id, record)
+ return
+ }
+ _ = a.deliverUsage(ctx, record)
+}
+
+func (a *rpcPluginAdapter) deliverUsage(ctx context.Context, record pluginapi.UsageRecord) error {
+ if a == nil {
+ return nil
+ }
if _, errCall := callPlugin[rpcEmptyResponse](ctx, a.client, pluginabi.MethodUsageHandle, record); errCall != nil {
log.Debugf("pluginhost: usage.handle to %s failed: %v", a.id, errCall)
+ return errCall
}
+ return nil
}
func (a *rpcPluginAdapter) RegisterCommandLine(ctx context.Context, req pluginapi.CommandLineRegistrationRequest) (pluginapi.CommandLineRegistrationResponse, error) {
diff --git a/internal/pluginhost/rpc_client_error_test.go b/internal/pluginhost/rpc_client_error_test.go
index bc965ad0e0c..8baefe6f9ff 100644
--- a/internal/pluginhost/rpc_client_error_test.go
+++ b/internal/pluginhost/rpc_client_error_test.go
@@ -398,3 +398,97 @@ func TestStreamCallbacksKeepPlainErrorStrings(t *testing.T) {
t.Fatalf("plain stream error = %v, want boom", chunk.Err)
}
}
+
+func TestPluginCountTokensNotImplementedDoesNotTriggerCooldown(t *testing.T) {
+ coreauth.SetQuotaCooldownDisabled(false)
+
+ _, errDecode := decodeEnvelopeResult[pluginapi.ExecutorResponse](pluginabi.Envelope{
+ OK: false,
+ Error: &pluginabi.Error{
+ Code: "not_implemented",
+ Message: "count-tokens/http-request are not implemented yet",
+ HTTPStatus: http.StatusNotImplemented,
+ },
+ })
+ if errDecode == nil {
+ t.Fatal("decodeEnvelopeResult returned nil error")
+ }
+
+ const (
+ provider = "kiro-plugin-count-tokens-501"
+ model = "claude-opus-5.5"
+ authID = "kiro-auth-count-tokens-501"
+ )
+ executorRecord := normalizeTestCapabilityRecord(capabilityRecord{id: "kiro-plugin-count-tokens-501"})
+ host := newHostWithRecords(executorRecord)
+ exec := &fakeExecutor{
+ identifier: provider,
+ countTokens: func(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
+ return pluginapi.ExecutorResponse{}, errDecode
+ },
+ execute: func(context.Context, pluginapi.ExecutorRequest) (pluginapi.ExecutorResponse, error) {
+ return pluginapi.ExecutorResponse{Payload: []byte(`{"id":"msg_1","type":"message","content":[{"type":"text","text":"ok"}]}`)}, nil
+ },
+ }
+ adapter := newExecutorAdapterForRecordForTest(host, executorRecord, exec,
+ []sdktranslator.Format{sdktranslator.FormatClaude},
+ []sdktranslator.Format{sdktranslator.FormatClaude},
+ )
+ adapter.provider = provider
+
+ authMgr := coreauth.NewManager(nil, nil, nil)
+ authMgr.RegisterExecutor(adapter)
+ auth := &coreauth.Auth{
+ ID: authID,
+ Provider: provider,
+ Status: coreauth.StatusActive,
+ FileName: "kiro-auth-count-tokens-501.json",
+ Attributes: map[string]string{"type": "oauth"},
+ }
+ if _, errReg := authMgr.Register(context.Background(), auth); errReg != nil {
+ t.Fatalf("Register auth: %v", errReg)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ req := coreexecutor.Request{
+ Model: model,
+ Payload: []byte(`{"model":"claude-opus-5.5","messages":[{"role":"user","content":"hi"}]}`),
+ }
+ opts := coreexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ ResponseFormat: sdktranslator.FormatClaude,
+ }
+
+ for attempt := 1; attempt <= 2; attempt++ {
+ _, errCount := authMgr.ExecuteCount(context.Background(), []string{provider}, req, opts)
+ if errCount == nil {
+ t.Fatalf("attempt %d: ExecuteCount returned nil error, want 501", attempt)
+ }
+ var sc interface{ StatusCode() int }
+ if !errors.As(errCount, &sc) || sc.StatusCode() != http.StatusNotImplemented {
+ t.Fatalf("attempt %d: ExecuteCount status = %v, want 501 (%v)", attempt, sc, errCount)
+ }
+ }
+
+ updated, okAuth := authMgr.GetByID(authID)
+ if !okAuth || updated == nil {
+ t.Fatalf("GetByID(%q) missing", authID)
+ }
+ if updated.Unavailable {
+ t.Fatalf("auth marked unavailable after count_tokens 501: %+v", updated)
+ }
+ if state := updated.ModelStates[model]; state != nil {
+ t.Fatalf("model state after count_tokens 501 = %+v, want nil", state)
+ }
+
+ resp, errExec := authMgr.Execute(context.Background(), []string{provider}, req, opts)
+ if errExec != nil {
+ t.Fatalf("Execute after count_tokens 501 failed: %v", errExec)
+ }
+ if len(resp.Payload) == 0 {
+ t.Fatal("Execute returned empty payload")
+ }
+}
diff --git a/internal/pluginhost/rpc_schema.go b/internal/pluginhost/rpc_schema.go
index ca56a965595..9d320e763be 100644
--- a/internal/pluginhost/rpc_schema.go
+++ b/internal/pluginhost/rpc_schema.go
@@ -8,8 +8,9 @@ import (
)
type rpcLifecycleRequest struct {
- ConfigYAML []byte `json:"config_yaml"`
- SchemaVersion uint32 `json:"schema_version"`
+ ConfigYAML []byte `json:"config_yaml"`
+ SchemaVersion uint32 `json:"schema_version"`
+ MultiAuthProviders bool `json:"multi_auth_providers,omitempty"`
}
type rpcRegistration struct {
@@ -56,8 +57,17 @@ type rpcCapabilities struct {
QuotaProvider bool `json:"quota_provider"`
}
-type rpcIdentifierResponse struct {
+type rpcAuthProviderEntry struct {
Identifier string `json:"identifier"`
+ Name string `json:"name,omitempty"`
+ Logo string `json:"logo,omitempty"`
+}
+
+type rpcIdentifierResponse struct {
+ Identifier string `json:"identifier"`
+ Identifiers []string `json:"identifiers,omitempty"`
+ CredentialProviders []string `json:"credential_providers,omitempty"`
+ Entries []rpcAuthProviderEntry `json:"entries,omitempty"`
}
type rpcExecutorStreamResponse struct {
@@ -219,7 +229,7 @@ func rpcCapabilitiesFromPlugin(plugin pluginapi.Plugin) rpcCapabilities {
return rpcCapabilities{
ModelRegistrar: caps.ModelRegistrar != nil,
ModelProvider: caps.ModelProvider != nil,
- AuthProvider: caps.AuthProvider != nil,
+ AuthProvider: hasAuthProviderCap(caps),
FrontendAuthProvider: caps.FrontendAuthProvider != nil,
FrontendAuthProviderExclusive: caps.FrontendAuthProvider != nil && caps.FrontendAuthProviderExclusive,
FrontendAuthProviderOmitBody: frontendAuthProviderWantsOmitBody(caps),
diff --git a/internal/pluginhost/rpc_schema_test.go b/internal/pluginhost/rpc_schema_test.go
index cf1350c793a..bf5a1e83b8e 100644
--- a/internal/pluginhost/rpc_schema_test.go
+++ b/internal/pluginhost/rpc_schema_test.go
@@ -486,6 +486,9 @@ func TestRegisterRPCPluginSendsHostSchemaVersion(t *testing.T) {
if lookup.lastLifecycle.SchemaVersion != pluginabi.SchemaVersion {
t.Fatalf("lifecycle schema_version = %d, want %d", lookup.lastLifecycle.SchemaVersion, pluginabi.SchemaVersion)
}
+ if !lookup.lastLifecycle.MultiAuthProviders {
+ t.Fatal("lifecycle multi_auth_providers = false, want true")
+ }
if registered.SchemaVersion != pluginabi.SchemaVersion {
t.Fatalf("registered SchemaVersion = %d, want %d", registered.SchemaVersion, pluginabi.SchemaVersion)
}
@@ -494,6 +497,96 @@ func TestRegisterRPCPluginSendsHostSchemaVersion(t *testing.T) {
}
}
+func TestRPCAuthIdentifierEntriesAndMetadataFallback(t *testing.T) {
+ lookup := newTestSymbolLookup(&testPlugin{
+ registerResult: pluginapi.Plugin{
+ Metadata: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Version: "1.0.0",
+ Author: "test",
+ GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
+ Logo: "https://default.example/zcode.svg",
+ },
+ Capabilities: pluginapi.Capabilities{
+ AuthProvider: fakeAuthProvider{
+ identifier: " ZCode-ZAI ",
+ entries: []pluginapi.AuthProviderEntry{
+ {Identifier: " ZCode-ZAI ", Name: "Z.ai", Logo: "https://chat.z.ai/logo.svg"},
+ {Identifier: "zcode-bigmodel", Name: "", Logo: ""},
+ },
+ },
+ },
+ },
+ })
+
+ plugin, errRegister := registerRPCPlugin(context.Background(), nil, "omc-zcode-oauth", lookup, pluginabi.MethodPluginRegister, nil)
+ if errRegister != nil {
+ t.Fatalf("registerRPCPlugin() error = %v", errRegister)
+ }
+ host := newHostWithRecords(capabilityRecord{
+ id: "omc-zcode-oauth",
+ priority: 10,
+ meta: plugin.Metadata,
+ plugin: plugin,
+ })
+ infos := host.RegisteredPlugins()
+ if len(infos) != 1 {
+ t.Fatalf("RegisteredPlugins() len = %d, want 1", len(infos))
+ }
+ if infos[0].OAuthProvider != "zcode-zai" {
+ t.Fatalf("OAuthProvider = %q, want zcode-zai", infos[0].OAuthProvider)
+ }
+ wantProviders := []string{"zcode-zai", "zcode-bigmodel"}
+ if !reflect.DeepEqual(infos[0].OAuthProviders, wantProviders) {
+ t.Fatalf("OAuthProviders = %#v, want %#v", infos[0].OAuthProviders, wantProviders)
+ }
+ wantEntries := []pluginapi.AuthProviderEntry{
+ {Identifier: "zcode-zai", Name: "Z.ai", Logo: "https://chat.z.ai/logo.svg"},
+ {Identifier: "zcode-bigmodel", Name: "OMC ZCode OAuth", Logo: "https://default.example/zcode.svg"},
+ }
+ if !reflect.DeepEqual(infos[0].OAuthProviderEntries, wantEntries) {
+ t.Fatalf("OAuthProviderEntries = %#v, want %#v", infos[0].OAuthProviderEntries, wantEntries)
+ }
+
+ // Verify wire-level tolerance when auth.identifier returns only entries (empty identifier/identifiers) and unknown keys.
+ rawWireClient := &lifecycleTestClient{call: func(_ context.Context, method string, _ []byte) ([]byte, error) {
+ switch method {
+ case pluginabi.MethodPluginRegister:
+ return marshalRPCResult(rpcRegistration{
+ SchemaVersion: pluginabi.SchemaVersion,
+ Metadata: pluginapi.Metadata{
+ Name: "OMC ZCode OAuth",
+ Version: "1.0.0",
+ Author: "test",
+ GitHubRepository: "https://github.com/router-for-me/CLIProxyAPI",
+ Logo: "https://default.example/zcode.svg",
+ },
+ Capabilities: rpcCapabilities{
+ AuthProvider: true,
+ },
+ })
+ case pluginabi.MethodAuthIdentifier:
+ return []byte(`{"ok":true,"result":{"identifier":"","entries":[{"identifier":" zcode-zai ","name":"Z.ai","logo":"https://chat.z.ai/logo.svg","extra_future_field":true},{"identifier":"zcode-bigmodel","unknown_key":"ignored"}],"future_top_level":"ignored"}}`), nil
+ default:
+ return marshalRPCResult(rpcEmptyResponse{})
+ }
+ }}
+ wirePlugin, errWire := registerRPCPlugin(context.Background(), nil, "omc-wire", rawWireClient, pluginabi.MethodPluginRegister, nil)
+ if errWire != nil {
+ t.Fatalf("registerRPCPlugin(wirePlugin) error = %v", errWire)
+ }
+ wireHost := newHostWithRecords(capabilityRecord{
+ id: "omc-wire",
+ priority: 10,
+ meta: wirePlugin.Metadata,
+ plugin: wirePlugin,
+ })
+ wireInfos := wireHost.RegisteredPlugins()
+ if len(wireInfos) != 1 || wireInfos[0].OAuthProvider != "zcode-zai" || !reflect.DeepEqual(wireInfos[0].OAuthProviders, wantProviders) || !reflect.DeepEqual(wireInfos[0].OAuthProviderEntries, wantEntries) {
+ t.Fatalf("wireInfos = %#v, want primary=zcode-zai providers=%#v entries=%#v", wireInfos, wantProviders, wantEntries)
+ }
+}
+
func TestRegisterRPCPluginRejectsFutureSchemaVersion(t *testing.T) {
lookup := newTestSymbolLookup(&testPlugin{
registerResult: validTestPlugin("future-schema"),
diff --git a/internal/pluginhost/snapshot.go b/internal/pluginhost/snapshot.go
index f5c7e9ad399..85ae6d60862 100644
--- a/internal/pluginhost/snapshot.go
+++ b/internal/pluginhost/snapshot.go
@@ -27,14 +27,16 @@ type Snapshot struct {
// RegisteredPluginInfo describes a plugin that is active in the current runtime snapshot.
type RegisteredPluginInfo struct {
- ID string
- Priority int
- Metadata pluginapi.Metadata
- SupportsOAuth bool
- OAuthProvider string
- SupportsQuota bool
- QuotaProvider string
- Menus []RegisteredPluginMenu
+ ID string
+ Priority int
+ Metadata pluginapi.Metadata
+ SupportsOAuth bool
+ OAuthProvider string
+ OAuthProviders []string
+ OAuthProviderEntries []pluginapi.AuthProviderEntry
+ SupportsQuota bool
+ QuotaProvider string
+ Menus []RegisteredPluginMenu
}
// RegisteredPluginMenu describes a plugin-owned resource menu entry.
@@ -76,11 +78,15 @@ func (h *Host) RegisteredPlugins() []RegisteredPluginInfo {
menusByPlugin := h.registeredPluginMenus()
out := make([]RegisteredPluginInfo, 0, len(records))
for _, record := range records {
- authProvider := record.plugin.Capabilities.AuthProvider
+ hasOAuth := hasRecordAuthProvider(record)
oauthProvider := ""
- if authProvider != nil && !h.isPluginFused(record.id) {
- if identifier, okIdentifier := h.callAuthProviderIdentifier(record.id, authProvider); okIdentifier {
- oauthProvider = identifier
+ var oauthProviders []string
+ var oauthProviderEntries []pluginapi.AuthProviderEntry
+ if hasOAuth && !h.isPluginFused(record.id) {
+ if identifiers, entries, okIdentifiers := h.callRecordAuthProviderMetadata(record); okIdentifiers && len(identifiers) > 0 {
+ oauthProvider = identifiers[0]
+ oauthProviders = identifiers
+ oauthProviderEntries = entries
}
}
quotaProvider := record.plugin.Capabilities.QuotaProvider
@@ -91,14 +97,16 @@ func (h *Host) RegisteredPlugins() []RegisteredPluginInfo {
}
}
out = append(out, RegisteredPluginInfo{
- ID: record.id,
- Priority: record.priority,
- Metadata: clonePluginMetadata(record.meta),
- SupportsOAuth: authProvider != nil,
- OAuthProvider: oauthProvider,
- SupportsQuota: quotaProvider != nil,
- QuotaProvider: quotaIdentifier,
- Menus: menusByPlugin[record.id],
+ ID: record.id,
+ Priority: record.priority,
+ Metadata: clonePluginMetadata(record.meta),
+ SupportsOAuth: hasOAuth,
+ OAuthProvider: oauthProvider,
+ OAuthProviders: oauthProviders,
+ OAuthProviderEntries: oauthProviderEntries,
+ SupportsQuota: quotaProvider != nil,
+ QuotaProvider: quotaIdentifier,
+ Menus: menusByPlugin[record.id],
})
}
return out
diff --git a/internal/pluginhost/test_helpers_test.go b/internal/pluginhost/test_helpers_test.go
index b359918e4b6..5ec3b926992 100644
--- a/internal/pluginhost/test_helpers_test.go
+++ b/internal/pluginhost/test_helpers_test.go
@@ -54,6 +54,8 @@ func (l *testSymbolLookup) Call(ctx context.Context, method string, request []by
switch method {
case pluginabi.MethodPluginRegister:
return l.callLifecycle(request, false)
+ case pluginabi.MethodPluginQuiesce:
+ return marshalRPCError("unknown_method", "unknown method "+method), nil
case pluginabi.MethodPluginReconfigure:
return l.callLifecycle(request, true)
case pluginabi.MethodThinkingIdentifier:
@@ -156,10 +158,68 @@ func (l *testSymbolLookup) Call(ctx context.Context, method string, request []by
}
return marshalRPCResult(resp)
case pluginabi.MethodAuthIdentifier:
+ if l.active.Capabilities.AuthProvider == nil && len(l.active.Capabilities.AuthProviders) == 0 {
+ return nil, fmt.Errorf("missing auth provider")
+ }
+ var primary string
+ var identifiers []string
+ var entries []rpcAuthProviderEntry
+ if l.active.Capabilities.AuthProvider != nil {
+ primary = l.active.Capabilities.AuthProvider.Identifier()
+ if multi, okMulti := l.active.Capabilities.AuthProvider.(interface{ Identifiers() []string }); okMulti {
+ identifiers = append(identifiers, multi.Identifiers()...)
+ }
+ if withEntries, okEntries := l.active.Capabilities.AuthProvider.(interface {
+ AuthEntries() []pluginapi.AuthProviderEntry
+ }); okEntries {
+ for _, entry := range withEntries.AuthEntries() {
+ entries = append(entries, rpcAuthProviderEntry{
+ Identifier: entry.Identifier,
+ Name: entry.Name,
+ Logo: entry.Logo,
+ })
+ }
+ }
+ }
+ for _, prov := range l.active.Capabilities.AuthProviders {
+ if prov == nil {
+ continue
+ }
+ if primary == "" {
+ primary = prov.Identifier()
+ }
+ if id := prov.Identifier(); id != "" {
+ identifiers = append(identifiers, id)
+ }
+ if multi, okMulti := prov.(interface{ Identifiers() []string }); okMulti {
+ identifiers = append(identifiers, multi.Identifiers()...)
+ }
+ if withEntries, okEntries := prov.(interface {
+ AuthEntries() []pluginapi.AuthProviderEntry
+ }); okEntries {
+ for _, entry := range withEntries.AuthEntries() {
+ entries = append(entries, rpcAuthProviderEntry{
+ Identifier: entry.Identifier,
+ Name: entry.Name,
+ Logo: entry.Logo,
+ })
+ }
+ }
+ }
+ return marshalRPCResult(rpcIdentifierResponse{Identifier: primary, Identifiers: identifiers, Entries: entries})
+ case pluginabi.MethodAuthLoginStart:
if l.active.Capabilities.AuthProvider == nil {
return nil, fmt.Errorf("missing auth provider")
}
- return marshalRPCResult(rpcIdentifierResponse{Identifier: l.active.Capabilities.AuthProvider.Identifier()})
+ var req pluginapi.AuthLoginStartRequest
+ if errUnmarshal := json.Unmarshal(request, &req); errUnmarshal != nil {
+ return nil, errUnmarshal
+ }
+ resp, errStart := l.active.Capabilities.AuthProvider.StartLogin(ctx, req)
+ if errStart != nil {
+ return nil, errStart
+ }
+ return marshalRPCResult(resp)
case pluginabi.MethodFrontendAuthIdentifier:
if l.active.Capabilities.FrontendAuthProvider == nil {
return nil, fmt.Errorf("missing frontend auth provider")
diff --git a/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go b/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go
index 686ad2c6b84..4921c4c5559 100644
--- a/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go
+++ b/internal/pluginhost/testdata/loader_unix_reload_plugin/main.go
@@ -3,6 +3,7 @@ package main
/*
#include
#include
+#include
typedef struct {
void* ptr;
@@ -40,6 +41,43 @@ extern void cliproxyPluginShutdown(void);
#define PLUGIN_VERSION 1
#endif
+#if PLUGIN_VERSION == 4
+#include
+__attribute__((constructor)) static void cliproxyTestSigillOnDlopen(void) {
+ raise(SIGILL);
+}
+#elif PLUGIN_VERSION == 5
+#include
+__attribute__((constructor)) static void cliproxyTestPrematureExitZeroOnDlopen(void) {
+ _exit(0);
+}
+#elif PLUGIN_VERSION == 6
+#include
+__attribute__((constructor)) static void cliproxyTestSigkillOnDlopen(void) {
+ raise(SIGKILL);
+}
+#elif PLUGIN_VERSION == 7
+#include
+#include
+__attribute__((constructor)) static void cliproxyTestInheritedStdioGrandchildOnDlopen(void) {
+ pid_t pid = fork();
+ if (pid == 0) {
+ pause();
+ _exit(0);
+ }
+ if (pid > 0) {
+ const char* pid_path = getenv("CLIPROXY_TEST_GRANDCHILD_PID_FILE");
+ if (pid_path != NULL && pid_path[0] != '\0') {
+ FILE* fp = fopen(pid_path, "w");
+ if (fp != NULL) {
+ fprintf(fp, "%d\n", (int)pid);
+ fclose(fp);
+ }
+ }
+ }
+}
+#endif
+
static const char* cliproxyTestMarker(void) {
#if PLUGIN_VERSION == 2
return "v2";
@@ -55,6 +93,30 @@ static int cliproxyTestFailInit(void) {
return 0;
#endif
}
+
+static const cliproxy_host_api* cliproxy_test_stored_host = NULL;
+
+static void cliproxyTestStoreHost(const cliproxy_host_api* host) {
+ cliproxy_test_stored_host = host;
+}
+
+static void cliproxyTestOnShutdown(void) {
+#if PLUGIN_VERSION == 8
+ if (cliproxy_test_stored_host != NULL && cliproxy_test_stored_host->call != NULL) {
+ cliproxy_buffer resp = {0};
+ (void)cliproxy_test_stored_host->call(cliproxy_test_stored_host->host_ctx, "host.log", NULL, 0, &resp);
+ }
+ char chunk[4096];
+ for (size_t i = 0; i < sizeof(chunk); i++) {
+ chunk[i] = 'E';
+ }
+ chunk[sizeof(chunk) - 1] = '\n';
+ for (int i = 0; i < 32; i++) {
+ (void)write(2, chunk, sizeof(chunk));
+ }
+ _exit(1);
+#endif
+}
*/
import "C"
@@ -73,15 +135,14 @@ func cliproxy_plugin_init(host *C.cliproxy_host_api, plugin *C.cliproxy_plugin_a
if plugin == nil {
return 1
}
+ C.cliproxyTestStoreHost(host)
if failInit {
- _ = host
return 7
}
plugin.abi_version = 1
plugin.call = C.cliproxy_plugin_call_fn(C.cliproxyPluginCall)
plugin.free_buffer = C.cliproxy_plugin_free_fn(C.cliproxyPluginFree)
plugin.shutdown = C.cliproxy_plugin_shutdown_fn(C.cliproxyPluginShutdown)
- _ = host
return 0
}
@@ -119,6 +180,7 @@ func cliproxyPluginFree(ptr unsafe.Pointer, _ C.size_t) {
//export cliproxyPluginShutdown
func cliproxyPluginShutdown() {
shutdownCalls.Add(1)
+ C.cliproxyTestOnShutdown()
}
func main() {}
diff --git a/internal/pluginstore/auth.go b/internal/pluginstore/auth.go
index f01c6c6fe5b..e91abcaf794 100644
--- a/internal/pluginstore/auth.go
+++ b/internal/pluginstore/auth.go
@@ -1,7 +1,6 @@
package pluginstore
import (
- "encoding/base64"
"fmt"
"net/http"
"net/url"
@@ -9,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/githubauth"
)
diff --git a/internal/pluginstore/manifest.go b/internal/pluginstore/manifest.go
index 0ed6683394f..bf778bb3464 100644
--- a/internal/pluginstore/manifest.go
+++ b/internal/pluginstore/manifest.go
@@ -7,22 +7,23 @@ import (
)
type Manifest struct {
- SchemaVersion int `yaml:"schema-version,omitempty" json:"schema_version,omitempty"`
- ID string `yaml:"id,omitempty" json:"id,omitempty"`
- Name string `yaml:"name,omitempty" json:"name,omitempty"`
- Description string `yaml:"description,omitempty" json:"description,omitempty"`
- Author string `yaml:"author,omitempty" json:"author,omitempty"`
- Version string `yaml:"version,omitempty" json:"version,omitempty"`
- ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"`
- Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
- Logo string `yaml:"logo,omitempty" json:"logo,omitempty"`
- Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
- License string `yaml:"license,omitempty" json:"license,omitempty"`
- Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
- SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"`
- SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"`
- SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"`
- Install InstallPlan `yaml:"install,omitempty" json:"install,omitempty"`
+ SchemaVersion int `yaml:"schema-version,omitempty" json:"schema_version,omitempty"`
+ ID string `yaml:"id,omitempty" json:"id,omitempty"`
+ Name string `yaml:"name,omitempty" json:"name,omitempty"`
+ Description string `yaml:"description,omitempty" json:"description,omitempty"`
+ Author string `yaml:"author,omitempty" json:"author,omitempty"`
+ Version string `yaml:"version,omitempty" json:"version,omitempty"`
+ ReleaseTag string `yaml:"release-tag,omitempty" json:"release_tag,omitempty"`
+ Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
+ Logo string `yaml:"logo,omitempty" json:"logo,omitempty"`
+ Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
+ License string `yaml:"license,omitempty" json:"license,omitempty"`
+ Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
+ Locales map[string]PluginLocale `yaml:"locales,omitempty" json:"locales,omitempty"`
+ SourceID string `yaml:"source-id,omitempty" json:"source_id,omitempty"`
+ SourceName string `yaml:"source-name,omitempty" json:"source_name,omitempty"`
+ SourceURL string `yaml:"source-url,omitempty" json:"source_url,omitempty"`
+ Install InstallPlan `yaml:"install,omitempty" json:"install,omitempty"`
}
func ManifestFromRelease(source Source, plugin Plugin, release Release) (Manifest, error) {
@@ -69,6 +70,7 @@ func manifestFromPlugin(source Source, plugin Plugin, base Manifest) Manifest {
base.Homepage = strings.TrimSpace(plugin.Homepage)
base.License = strings.TrimSpace(plugin.License)
base.Tags = append([]string(nil), plugin.Tags...)
+ base.Locales = NormalizePluginLocales(plugin.Locales)
base.SourceID = strings.TrimSpace(source.ID)
base.SourceName = strings.TrimSpace(source.Name)
base.SourceURL = strings.TrimSpace(source.URL)
@@ -87,6 +89,7 @@ func (m Manifest) Plugin() Plugin {
Homepage: strings.TrimSpace(m.Homepage),
License: strings.TrimSpace(m.License),
Tags: append([]string(nil), m.Tags...),
+ Locales: NormalizePluginLocales(m.Locales),
Install: NormalizeInstallPlan(m.Install),
}
}
diff --git a/internal/pluginstore/registry.go b/internal/pluginstore/registry.go
index 1b46a64beee..5d09da5411d 100644
--- a/internal/pluginstore/registry.go
+++ b/internal/pluginstore/registry.go
@@ -8,6 +8,7 @@ import (
"fmt"
"net/url"
"regexp"
+ "sort"
"strings"
)
@@ -37,19 +38,29 @@ type Registry struct {
}
type Plugin struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Author string `json:"author"`
- Version string `json:"version"`
- Versions []Version `json:"versions,omitempty"`
- Repository string `json:"repository,omitempty"`
- Logo string `json:"logo,omitempty"`
- Homepage string `json:"homepage,omitempty"`
- License string `json:"license,omitempty"`
- Tags []string `json:"tags,omitempty"`
- Install InstallPlan `json:"install,omitempty"`
- AuthRequired bool `json:"auth_required,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Author string `json:"author"`
+ Version string `json:"version"`
+ Versions []Version `json:"versions,omitempty"`
+ Repository string `json:"repository,omitempty"`
+ Logo string `json:"logo,omitempty"`
+ Homepage string `json:"homepage,omitempty"`
+ License string `json:"license,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Locales map[string]PluginLocale `json:"locales,omitempty"`
+ Install InstallPlan `json:"install,omitempty"`
+ AuthRequired bool `json:"auth_required,omitempty"`
+}
+
+type PluginLocale struct {
+ Name string `yaml:"name,omitempty" json:"name,omitempty"`
+ Description string `yaml:"description,omitempty" json:"description,omitempty"`
+ Author string `yaml:"author,omitempty" json:"author,omitempty"`
+ Homepage string `yaml:"homepage,omitempty" json:"homepage,omitempty"`
+ License string `yaml:"license,omitempty" json:"license,omitempty"`
+ Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
}
type Version struct {
@@ -160,7 +171,165 @@ func normalizeRegistry(registry *Registry) {
for tagIndex := range plugin.Tags {
plugin.Tags[tagIndex] = strings.TrimSpace(plugin.Tags[tagIndex])
}
+ plugin.Locales = NormalizePluginLocales(plugin.Locales)
+ }
+}
+
+func NormalizePluginLocales(locales map[string]PluginLocale) map[string]PluginLocale {
+ if len(locales) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(locales))
+ for locale := range locales {
+ keys = append(keys, locale)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ left := canonicalPluginLocaleKey(keys[i])
+ right := canonicalPluginLocaleKey(keys[j])
+ if left == right {
+ leftScore := canonicalPluginLocaleScore(keys[i])
+ rightScore := canonicalPluginLocaleScore(keys[j])
+ if leftScore != rightScore {
+ return leftScore < rightScore
+ }
+ leftRaw := normalizedPluginLocaleRawKey(keys[i])
+ rightRaw := normalizedPluginLocaleRawKey(keys[j])
+ if leftRaw == rightRaw {
+ return keys[i] < keys[j]
+ }
+ return leftRaw < rightRaw
+ }
+ return left < right
+ })
+ out := make(map[string]PluginLocale, len(locales))
+ for _, rawLocale := range keys {
+ locale := canonicalPluginLocaleKey(rawLocale)
+ if locale == "" {
+ continue
+ }
+ if _, exists := out[locale]; exists {
+ continue
+ }
+ value := locales[rawLocale]
+ value.Name = strings.TrimSpace(value.Name)
+ value.Description = strings.TrimSpace(value.Description)
+ value.Author = strings.TrimSpace(value.Author)
+ value.Homepage = strings.TrimSpace(value.Homepage)
+ value.License = strings.TrimSpace(value.License)
+ if len(value.Tags) > 0 {
+ tags := make([]string, 0, len(value.Tags))
+ for _, tag := range value.Tags {
+ if trimmed := strings.TrimSpace(tag); trimmed != "" {
+ tags = append(tags, trimmed)
+ }
+ }
+ value.Tags = tags
+ } else {
+ value.Tags = nil
+ }
+ if isEmptyPluginLocale(value) {
+ continue
+ }
+ out[locale] = value
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+func isEmptyPluginLocale(locale PluginLocale) bool {
+ return locale.Name == "" &&
+ locale.Description == "" &&
+ locale.Author == "" &&
+ locale.Homepage == "" &&
+ locale.License == "" &&
+ len(locale.Tags) == 0
+}
+
+func canonicalPluginLocaleKey(locale string) string {
+ raw := normalizedPluginLocaleRawKey(locale)
+ if raw == "" {
+ return ""
+ }
+ parts := strings.Split(raw, "-")
+ for _, part := range parts {
+ if part == "" {
+ return ""
+ }
+ }
+ inExtension := false
+ for index, part := range parts {
+ if index > 0 && len(part) == 1 && isASCIIAlphaNumeric(part) {
+ inExtension = true
+ parts[index] = strings.ToLower(part)
+ continue
+ }
+ if inExtension {
+ parts[index] = strings.ToLower(part)
+ continue
+ }
+ switch {
+ case index == 0:
+ parts[index] = strings.ToLower(part)
+ case (len(part) == 2 || len(part) == 3) && isASCIIAlpha(part):
+ parts[index] = strings.ToUpper(part)
+ case len(part) == 4 && isASCIIAlpha(part):
+ parts[index] = strings.ToUpper(part[:1]) + strings.ToLower(part[1:])
+ default:
+ parts[index] = strings.ToLower(part)
+ }
+ }
+ return strings.Join(parts, "-")
+}
+
+func canonicalPluginLocaleScore(locale string) int {
+ trimmed := strings.TrimSpace(locale)
+ canonical := canonicalPluginLocaleKey(locale)
+ if canonical == "" {
+ return 1 << 30
+ }
+ score := 0
+ if strings.Contains(trimmed, "_") {
+ score += 4
+ }
+ if trimmed != locale {
+ score += 2
+ }
+ if strings.ReplaceAll(trimmed, "_", "-") != canonical {
+ score += 3
+ }
+ return score
+}
+
+func isASCIIAlpha(value string) bool {
+ for _, ch := range value {
+ if (ch < 'A' || ch > 'Z') && (ch < 'a' || ch > 'z') {
+ return false
+ }
+ }
+ return value != ""
+}
+
+func isASCIIAlphaNumeric(value string) bool {
+ for _, ch := range value {
+ if (ch < 'A' || ch > 'Z') && (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') {
+ return false
+ }
+ }
+ return value != ""
+}
+
+func normalizedPluginLocaleRawKey(locale string) string {
+ locale = strings.TrimSpace(locale)
+ if locale == "" {
+ return ""
+ }
+ locale = strings.ReplaceAll(locale, "_", "-")
+ if strings.ContainsAny(locale, " \t\r\n") {
+ return ""
}
+ return locale
}
func ValidateRegistry(registry Registry) error {
diff --git a/internal/pluginstore/registry_test.go b/internal/pluginstore/registry_test.go
index da0a2ce8c6a..3588562fed4 100644
--- a/internal/pluginstore/registry_test.go
+++ b/internal/pluginstore/registry_test.go
@@ -68,6 +68,48 @@ func TestParseRegistryNormalizesPluginFields(t *testing.T) {
}
}
+func TestParseRegistryNormalizesPluginLocales(t *testing.T) {
+ t.Parallel()
+
+ registry, errParse := ParseRegistry([]byte(`{
+ "schema_version": 1,
+ "plugins": [{
+ "id": "sample-provider",
+ "name": "Sample Provider",
+ "description": "Adds sample provider support.",
+ "author": "author-name",
+ "repository": "https://github.com/author-name/cliproxy-sample-provider-plugin",
+ "locales": {
+ " zh_cn ": {
+ "description": " 增加示例提供商支持。 "
+ },
+ "ja": {
+ "description": " サンプルプロバイダーを追加します。 "
+ },
+ "empty": {
+ "description": " "
+ }
+ }
+ }]
+ }`))
+ if errParse != nil {
+ t.Fatalf("ParseRegistry() error = %v", errParse)
+ }
+ plugin, ok := registry.PluginByID("sample-provider")
+ if !ok {
+ t.Fatal("PluginByID(sample-provider) missing")
+ }
+ if len(plugin.Locales) != 2 {
+ t.Fatalf("plugin locales len = %d, want 2: %#v", len(plugin.Locales), plugin.Locales)
+ }
+ if plugin.Locales["zh-CN"].Description != "增加示例提供商支持。" {
+ t.Fatalf("zh-CN locale = %#v, want trimmed description", plugin.Locales["zh-CN"])
+ }
+ if plugin.Locales["ja"].Description != "サンプルプロバイダーを追加します。" {
+ t.Fatalf("ja locale = %#v, want trimmed description", plugin.Locales["ja"])
+ }
+}
+
func TestValidateRegistryAllowsMissingVersion(t *testing.T) {
t.Parallel()
diff --git a/internal/redisqueue/plugin.go b/internal/redisqueue/plugin.go
index c922a73c01b..a479a4778b2 100644
--- a/internal/redisqueue/plugin.go
+++ b/internal/redisqueue/plugin.go
@@ -122,6 +122,8 @@ func (p *usageQueuePlugin) HandleUsage(ctx context.Context, record coreusage.Rec
VideoInputTokens: coreusage.CloneInt64Ptr(usageDetail.VideoInputTokens),
FileInputTokens: coreusage.CloneInt64Ptr(usageDetail.FileInputTokens),
CachedTextTokens: coreusage.CloneInt64Ptr(usageDetail.CachedTextTokens),
+ CachedImageTokens: coreusage.CloneInt64Ptr(usageDetail.CachedImageTokens),
+ CachedAudioTokens: coreusage.CloneInt64Ptr(usageDetail.CachedAudioTokens),
TextOutputTokens: coreusage.CloneInt64Ptr(usageDetail.TextOutputTokens),
ImageOutputTokens: coreusage.CloneInt64Ptr(usageDetail.ImageOutputTokens),
AudioOutputTokens: coreusage.CloneInt64Ptr(usageDetail.AudioOutputTokens),
@@ -282,6 +284,8 @@ type tokenStats struct {
VideoInputTokens *int64 `json:"video_input_tokens,omitempty"`
FileInputTokens *int64 `json:"file_input_tokens,omitempty"`
CachedTextTokens *int64 `json:"cached_text_tokens,omitempty"`
+ CachedImageTokens *int64 `json:"cached_image_tokens,omitempty"`
+ CachedAudioTokens *int64 `json:"cached_audio_tokens,omitempty"`
TextOutputTokens *int64 `json:"text_output_tokens,omitempty"`
ImageOutputTokens *int64 `json:"image_output_tokens,omitempty"`
AudioOutputTokens *int64 `json:"audio_output_tokens,omitempty"`
diff --git a/internal/registry/codex_client_models.go b/internal/registry/codex_client_models.go
index 46445a8cc6e..6af557f3c97 100644
--- a/internal/registry/codex_client_models.go
+++ b/internal/registry/codex_client_models.go
@@ -20,9 +20,11 @@ type codexClientModelsPayload struct {
}
type codexClientModelsStore struct {
- mu sync.RWMutex
- data []byte
- revision uint64
+ mu sync.RWMutex
+ data []byte
+ revision uint64
+ responsesLiteMap map[string]bool
+ thinkingLevelsMap map[string][]string
}
var codexClientCatalogStore = &codexClientModelsStore{}
@@ -54,8 +56,37 @@ func GetCodexClientModelsSnapshot() ([]byte, uint64) {
return append([]byte(nil), codexClientCatalogStore.data...), codexClientCatalogStore.revision
}
+// CodexClientModelUsesResponsesLite reports whether slug is marked
+// use_responses_lite in the active Codex client model catalog.
+func CodexClientModelUsesResponsesLite(slug string) bool {
+ slug = strings.ToLower(strings.TrimSpace(slug))
+ if slug == "" {
+ return false
+ }
+ codexClientCatalogStore.mu.RLock()
+ defer codexClientCatalogStore.mu.RUnlock()
+ return codexClientCatalogStore.responsesLiteMap[slug]
+}
+
+// CodexClientModelThinkingLevels returns a copy of the supported reasoning
+// levels for slug in the active Codex client model catalog, or nil if absent.
+func CodexClientModelThinkingLevels(slug string) []string {
+ slug = strings.ToLower(strings.TrimSpace(slug))
+ if slug == "" {
+ return nil
+ }
+ codexClientCatalogStore.mu.RLock()
+ defer codexClientCatalogStore.mu.RUnlock()
+ levels := codexClientCatalogStore.thinkingLevelsMap[slug]
+ if len(levels) == 0 {
+ return nil
+ }
+ return append([]string(nil), levels...)
+}
+
func loadCodexClientModelsFromBytes(data []byte, source string) (bool, error) {
- if err := ValidateCodexClientModelsJSON(data); err != nil {
+ responsesLiteMap, thinkingLevelsMap, err := parseAndValidateCodexClientModelsJSON(data)
+ if err != nil {
return false, fmt.Errorf("%s: %w", source, err)
}
@@ -66,6 +97,8 @@ func loadCodexClientModelsFromBytes(data []byte, source string) (bool, error) {
return false, nil
}
codexClientCatalogStore.data = cloned
+ codexClientCatalogStore.responsesLiteMap = responsesLiteMap
+ codexClientCatalogStore.thinkingLevelsMap = thinkingLevelsMap
codexClientCatalogStore.revision++
return true, nil
}
@@ -73,30 +106,54 @@ func loadCodexClientModelsFromBytes(data []byte, source string) (bool, error) {
// ValidateCodexClientModelsJSON validates the fields required to serve a
// complete Codex client model catalog.
func ValidateCodexClientModelsJSON(data []byte) error {
+ _, _, err := parseAndValidateCodexClientModelsJSON(data)
+ return err
+}
+
+func parseAndValidateCodexClientModelsJSON(data []byte) (map[string]bool, map[string][]string, error) {
var payload codexClientModelsPayload
if err := json.Unmarshal(data, &payload); err != nil {
- return fmt.Errorf("decode Codex client model catalog: %w", err)
+ return nil, nil, fmt.Errorf("decode Codex client model catalog: %w", err)
}
if len(payload.Models) == 0 {
- return fmt.Errorf("Codex client model catalog has no models")
+ return nil, nil, fmt.Errorf("Codex client model catalog has no models")
}
seen := make(map[string]struct{}, len(payload.Models))
+ responsesLiteMap := make(map[string]bool, len(payload.Models))
+ thinkingLevelsMap := make(map[string][]string, len(payload.Models))
for i, model := range payload.Models {
slug, err := requiredCodexClientModelString(model, "slug")
if err != nil {
- return fmt.Errorf("Codex client model catalog models[%d]: %w", i, err)
+ return nil, nil, fmt.Errorf("Codex client model catalog models[%d]: %w", i, err)
}
if _, exists := seen[slug]; exists {
- return fmt.Errorf("Codex client model catalog contains duplicate slug %q", slug)
+ return nil, nil, fmt.Errorf("Codex client model catalog contains duplicate slug %q", slug)
}
seen[slug] = struct{}{}
if err = validateCodexClientModel(model); err != nil {
- return fmt.Errorf("Codex client model catalog model %q: %w", slug, err)
+ return nil, nil, fmt.Errorf("Codex client model catalog model %q: %w", slug, err)
+ }
+ lowerSlug := strings.ToLower(slug)
+ if lite, ok := model["use_responses_lite"].(bool); ok && lite {
+ responsesLiteMap[lowerSlug] = true
+ }
+ if rawLevels, ok := model["supported_reasoning_levels"].([]any); ok {
+ levels := make([]string, 0, len(rawLevels))
+ for _, rawLevel := range rawLevels {
+ if entry, okMap := rawLevel.(map[string]any); okMap {
+ if effort, okEffort := entry["effort"].(string); okEffort && strings.TrimSpace(effort) != "" {
+ levels = append(levels, strings.TrimSpace(effort))
+ }
+ }
+ }
+ if len(levels) > 0 {
+ thinkingLevelsMap[lowerSlug] = levels
+ }
}
}
- return nil
+ return responsesLiteMap, thinkingLevelsMap, nil
}
func validateCodexClientModel(model map[string]any) error {
diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go
index 3d988b8b77b..f8885c104e6 100644
--- a/internal/registry/model_definitions.go
+++ b/internal/registry/model_definitions.go
@@ -12,12 +12,16 @@ const (
codexBuiltinImage25FlareModelID = "gpt-image-2.5-flare"
codexBuiltinImage25SunburstModelID = "gpt-image-2.5-sunburst"
codexBuiltinImage25ModelID = "gpt-image-2.5"
+ codexBuiltinImage26FlareModelID = "gpt-image-2.6-flare"
+ codexBuiltinImage26SunburstModelID = "gpt-image-2.6-sunburst"
+ codexBuiltinImage26ModelID = "gpt-image-2.6"
xaiBuiltinImageModelID = "grok-imagine-image"
xaiBuiltinImageQualityModelID = "grok-imagine-image-quality"
xaiBuiltinImage20ModelID = "grok-imagine-image-2.0"
xaiBuiltinVideoModelID = "grok-imagine-video"
xaiBuiltinVideo15ModelID = "grok-imagine-video-1.5"
xaiBuiltinVideo15PreviewID = "grok-imagine-video-1.5-preview"
+ xaiBuiltinVideo15LiteModelID = "grok-imagine-video-1.5-lite"
xaiBuiltinSpeechModelID = "grok-tts"
xaiBuiltinSpeechVoiceModelID = "grok-voice-tts-1.0"
)
@@ -254,13 +258,16 @@ func WithCodexBuiltins(models []*ModelInfo) []*ModelInfo {
codexBuiltinImage25FlareModelInfo(),
codexBuiltinImage25SunburstModelInfo(),
codexBuiltinImage25ModelInfo(),
+ codexBuiltinImage26FlareModelInfo(),
+ codexBuiltinImage26SunburstModelInfo(),
+ codexBuiltinImage26ModelInfo(),
)
}
// WithXAIBuiltins injects hard-coded xAI image, video, and speech model definitions
// that should not depend on remote models.json updates.
func WithXAIBuiltins(models []*ModelInfo) []*ModelInfo {
- return upsertModelInfos(models, xaiBuiltinImageModelInfo(), xaiBuiltinImageQualityModelInfo(), xaiBuiltinImage20ModelInfo(), xaiBuiltinVideoModelInfo(), xaiBuiltinVideo15ModelInfo(), xaiBuiltinVideo15PreviewModelInfo(), xaiBuiltinSpeechModelInfo(), xaiBuiltinSpeechVoiceModelInfo())
+ return upsertModelInfos(models, xaiBuiltinImageModelInfo(), xaiBuiltinImageQualityModelInfo(), xaiBuiltinImage20ModelInfo(), xaiBuiltinVideoModelInfo(), xaiBuiltinVideo15ModelInfo(), xaiBuiltinVideo15PreviewModelInfo(), xaiBuiltinVideo15LiteModelInfo(), xaiBuiltinSpeechModelInfo(), xaiBuiltinSpeechVoiceModelInfo())
}
func normalizeAntigravityCapabilityModelID(modelID string) string {
@@ -331,6 +338,42 @@ func codexBuiltinImage25ModelInfo() *ModelInfo {
}
}
+func codexBuiltinImage26FlareModelInfo() *ModelInfo {
+ return &ModelInfo{
+ ID: codexBuiltinImage26FlareModelID,
+ Object: "model",
+ Created: 1704067200, // 2024-01-01
+ OwnedBy: "openai",
+ Type: "openai",
+ DisplayName: "GPT Image 2.6 Flare",
+ Version: codexBuiltinImage26FlareModelID,
+ }
+}
+
+func codexBuiltinImage26SunburstModelInfo() *ModelInfo {
+ return &ModelInfo{
+ ID: codexBuiltinImage26SunburstModelID,
+ Object: "model",
+ Created: 1704067200, // 2024-01-01
+ OwnedBy: "openai",
+ Type: "openai",
+ DisplayName: "GPT Image 2.6 Sunburst",
+ Version: codexBuiltinImage26SunburstModelID,
+ }
+}
+
+func codexBuiltinImage26ModelInfo() *ModelInfo {
+ return &ModelInfo{
+ ID: codexBuiltinImage26ModelID,
+ Object: "model",
+ Created: 1704067200, // 2024-01-01
+ OwnedBy: "openai",
+ Type: "openai",
+ DisplayName: "GPT Image 2.6",
+ Version: codexBuiltinImage26ModelID,
+ }
+}
+
func xaiBuiltinImageModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinImageModelID,
@@ -409,6 +452,21 @@ func xaiBuiltinVideo15PreviewModelInfo() *ModelInfo {
}
}
+func xaiBuiltinVideo15LiteModelInfo() *ModelInfo {
+ return &ModelInfo{
+ ID: xaiBuiltinVideo15LiteModelID,
+ Object: "model",
+ Created: 1787702400, // 2026-08-26
+ OwnedBy: "xai",
+ Type: "xai",
+ DisplayName: "Grok Imagine Video 1.5 Lite",
+ Name: xaiBuiltinVideo15LiteModelID,
+ Description: "xAI Grok Imagine Video 1.5 Lite supports text-to-video and image-to-video generation. Output pricing per second: $0.02 at 480p, $0.03 at 720p, and $0.14 at 1080p.",
+ SupportedInputModalities: []string{"text", "image"},
+ SupportedOutputModalities: []string{"video"},
+ }
+}
+
func xaiBuiltinSpeechModelInfo() *ModelInfo {
return &ModelInfo{
ID: xaiBuiltinSpeechModelID,
diff --git a/internal/registry/model_definitions_test.go b/internal/registry/model_definitions_test.go
index b5a73d9494e..282e118ef67 100644
--- a/internal/registry/model_definitions_test.go
+++ b/internal/registry/model_definitions_test.go
@@ -2,6 +2,7 @@ package registry
import (
"encoding/json"
+ "reflect"
"strings"
"testing"
)
@@ -180,6 +181,94 @@ func TestWithXAIBuiltinsIncludesVideo15GAAndPreviewAlias(t *testing.T) {
}
}
+func TestGetXAIModelsPreservesVideo15LiteAfterCatalogRefresh(t *testing.T) {
+ const liteID = "grok-imagine-video-1.5-lite"
+ original := getModels()
+ t.Cleanup(func() {
+ modelsCatalogStore.mu.Lock()
+ modelsCatalogStore.data = original
+ modelsCatalogStore.mu.Unlock()
+ })
+
+ refreshed := *original
+ remoteModel := &ModelInfo{ID: "refreshed-xai-model", OwnedBy: "xai", Type: "xai", DisplayName: "Refreshed model"}
+ refreshed.XAI = []*ModelInfo{remoteModel}
+ payload, err := json.Marshal(&refreshed)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := publishCatalogBytes(payload); err != nil {
+ t.Fatalf("publish catalog without Lite: %v", err)
+ }
+
+ models := GetXAIModels()
+ foundLite, foundRemote := false, false
+ for _, model := range models {
+ if model.ID == liteID {
+ foundLite = true
+ if model.OwnedBy != "xai" || model.Type != "xai" ||
+ !reflect.DeepEqual(model.SupportedInputModalities, []string{"text", "image"}) ||
+ !reflect.DeepEqual(model.SupportedOutputModalities, []string{"video"}) {
+ t.Fatalf("Lite lost xAI video capabilities after catalog refresh: %#v", model)
+ }
+ }
+ if model.ID == remoteModel.ID {
+ foundRemote = true
+ if !reflect.DeepEqual(model, remoteModel) {
+ t.Fatalf("refreshed model = %#v, want %#v", model, remoteModel)
+ }
+ }
+ }
+ if !foundLite || !foundRemote {
+ t.Fatalf("catalog lost Lite or refreshed model: Lite=%v, refreshed=%v", foundLite, foundRemote)
+ }
+
+ const clientID = "test-xai-lite-catalog-refresh"
+ registryRef := GetGlobalRegistry()
+ registryRef.RegisterClient(clientID, "xai", models)
+ t.Cleanup(func() { registryRef.UnregisterClient(clientID) })
+ if !registryRef.ClientSupportsModel(clientID, liteID) {
+ t.Fatal("xAI credential registration lost Lite support after catalog refresh")
+ }
+ if providers := registryRef.GetModelProviders(liteID); !reflect.DeepEqual(providers, []string{"xai"}) {
+ t.Fatalf("Lite providers = %v, want [xai]", providers)
+ }
+}
+
+func TestWithXAIBuiltinsReplacesVideo15LiteWithoutMutatingCatalog(t *testing.T) {
+ const liteID = "grok-imagine-video-1.5-lite"
+ stale := &ModelInfo{ID: liteID, DisplayName: "Stale Lite", SupportedInputModalities: []string{"text"}}
+ duplicate := &ModelInfo{ID: " GROK-IMAGINE-VIDEO-1.5-LITE ", DisplayName: "Duplicate Lite"}
+ custom := &ModelInfo{ID: "custom-xai-model", DisplayName: "Custom model"}
+ input := []*ModelInfo{stale, custom, duplicate}
+ for range 2 {
+ models := WithXAIBuiltins(input)
+ liteCount := 0
+ foundCustom := false
+ for _, model := range models {
+ if strings.EqualFold(strings.TrimSpace(model.ID), liteID) {
+ liteCount++
+ if model.ID != liteID || model.DisplayName != "Grok Imagine Video 1.5 Lite" ||
+ !reflect.DeepEqual(model.SupportedInputModalities, []string{"text", "image"}) {
+ t.Fatalf("Lite builtin did not replace stale metadata: %#v", model)
+ }
+ model.SupportedInputModalities[0] = "mutated"
+ }
+ if model.ID == custom.ID {
+ foundCustom = reflect.DeepEqual(model, custom)
+ }
+ }
+ if liteCount != 1 || !foundCustom {
+ t.Fatalf("Lite count = %d, custom preserved = %v, want 1 and true", liteCount, foundCustom)
+ }
+ }
+ if input[0] != stale || input[1] != custom || input[2] != duplicate ||
+ stale.DisplayName != "Stale Lite" || !reflect.DeepEqual(stale.SupportedInputModalities, []string{"text"}) ||
+ duplicate.ID != " GROK-IMAGINE-VIDEO-1.5-LITE " || duplicate.DisplayName != "Duplicate Lite" {
+ t.Fatal("builtin merge mutated the input catalog")
+ }
+}
+
func TestAntigravityWebSearchModelForRequiresRequestedModelCapability(t *testing.T) {
registryRef := GetGlobalRegistry()
registryRef.RegisterClient("test-antigravity-websearch-route", "antigravity", []*ModelInfo{
@@ -247,12 +336,15 @@ func TestValidateModelsCatalog_Meta(t *testing.T) {
}
}
-func TestWithCodexBuiltinsIncludesImage25Models(t *testing.T) {
+func TestWithCodexBuiltinsIncludesImage25And26Models(t *testing.T) {
models := WithCodexBuiltins(nil)
expectedModels := map[string]string{
"gpt-image-2.5-flare": "GPT Image 2.5 Flare",
"gpt-image-2.5-sunburst": "GPT Image 2.5 Sunburst",
"gpt-image-2.5": "GPT Image 2.5",
+ "gpt-image-2.6-flare": "GPT Image 2.6 Flare",
+ "gpt-image-2.6-sunburst": "GPT Image 2.6 Sunburst",
+ "gpt-image-2.6": "GPT Image 2.6",
}
found := make(map[string]*ModelInfo)
diff --git a/internal/registry/models/devin_models.json b/internal/registry/models/devin_models.json
index da5508962ce..63e2de3d1fa 100644
--- a/internal/registry/models/devin_models.json
+++ b/internal/registry/models/devin_models.json
@@ -23,6 +23,28 @@
]
}
},
+ {
+ "id": "swe-2-flash",
+ "object": "model",
+ "type": "devin",
+ "owned_by": "cognition",
+ "display_name": "SWE-2 Flash",
+ "context_length": 262144,
+ "max_completion_tokens": 64000,
+ "supportedInputModalities": [
+ "text"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "thinking": {
+ "levels": [
+ "medium",
+ "high",
+ "max"
+ ]
+ }
+ },
{
"id": "claude-fable-5-1",
"object": "model",
@@ -611,6 +633,31 @@
]
}
},
+ {
+ "id": "gpt-6-1-terra",
+ "object": "model",
+ "type": "devin",
+ "owned_by": "openai",
+ "display_name": "GPT-6.1 Terra",
+ "context_length": 1000000,
+ "max_completion_tokens": 64000,
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ }
+ },
{
"id": "glm-5-2-1m",
"object": "model",
@@ -706,6 +753,29 @@
]
}
},
+ {
+ "id": "nemotron-3-5-super",
+ "object": "model",
+ "type": "devin",
+ "owned_by": "nvidia",
+ "display_name": "Nemotron 3.5 Super",
+ "context_length": 1048576,
+ "max_completion_tokens": 64000,
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "thinking": {
+ "levels": [
+ "none",
+ "medium",
+ "high"
+ ]
+ }
+ },
{
"id": "glm-5-3-flash",
"object": "model",
diff --git a/internal/registry/models/models.json b/internal/registry/models/models.json
index eccedeeb8ac..d8e576221be 100644
--- a/internal/registry/models/models.json
+++ b/internal/registry/models/models.json
@@ -2593,6 +2593,41 @@
}
],
"codex-free": [
+ {
+ "id": "gpt-6-chat-thinking",
+ "object": "model",
+ "created": 1790035200,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT-6 Chat Thinking",
+ "version": "gpt-6.0",
+ "description": "GPT-6 Chat Thinking for the paid CCA experiment.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "native_capabilities": {
+ "web_search": true
+ },
+ "support_configuration_update": true
+ },
{
"id": "gpt-6-luna",
"object": "model",
@@ -2947,6 +2982,41 @@
},
"support_configuration_update": true
},
+ {
+ "id": "gpt-6-chat-thinking",
+ "object": "model",
+ "created": 1790035200,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT-6 Chat Thinking",
+ "version": "gpt-6.0",
+ "description": "GPT-6 Chat Thinking for the paid CCA experiment.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "native_capabilities": {
+ "web_search": true
+ },
+ "support_configuration_update": true
+ },
{
"id": "gpt-5.6-sol",
"object": "model",
@@ -3265,6 +3335,41 @@
},
"support_configuration_update": true
},
+ {
+ "id": "gpt-6-chat-thinking",
+ "object": "model",
+ "created": 1790035200,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT-6 Chat Thinking",
+ "version": "gpt-6.0",
+ "description": "GPT-6 Chat Thinking for the paid CCA experiment.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "native_capabilities": {
+ "web_search": true
+ },
+ "support_configuration_update": true
+ },
{
"id": "gpt-5.6-sol",
"object": "model",
@@ -3583,6 +3688,41 @@
},
"support_configuration_update": true
},
+ {
+ "id": "gpt-6-chat-thinking",
+ "object": "model",
+ "created": 1790035200,
+ "owned_by": "openai",
+ "type": "openai",
+ "display_name": "GPT-6 Chat Thinking",
+ "version": "gpt-6.0",
+ "description": "GPT-6 Chat Thinking for the paid CCA experiment.",
+ "context_length": 272000,
+ "max_completion_tokens": 128000,
+ "supported_parameters": [
+ "tools"
+ ],
+ "thinking": {
+ "levels": [
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max"
+ ]
+ },
+ "supportedInputModalities": [
+ "text",
+ "image"
+ ],
+ "supportedOutputModalities": [
+ "text"
+ ],
+ "native_capabilities": {
+ "web_search": true
+ },
+ "support_configuration_update": true
+ },
{
"id": "gpt-5.6-sol",
"object": "model",
@@ -4096,7 +4236,12 @@
"min": 1024,
"max": 64000,
"zero_allowed": true,
- "dynamic_allowed": true
+ "dynamic_allowed": true,
+ "levels": [
+ "low",
+ "medium",
+ "high"
+ ]
},
"supportedInputModalities": [
"text",
@@ -4120,7 +4265,12 @@
"min": 1024,
"max": 64000,
"zero_allowed": true,
- "dynamic_allowed": true
+ "dynamic_allowed": true,
+ "levels": [
+ "low",
+ "medium",
+ "high"
+ ]
},
"supportedInputModalities": [
"text",
diff --git a/internal/runtime/executor/aistudio_executor.go b/internal/runtime/executor/aistudio_executor.go
index f2dc2b6b332..a827a4e911d 100644
--- a/internal/runtime/executor/aistudio_executor.go
+++ b/internal/runtime/executor/aistudio_executor.go
@@ -13,6 +13,7 @@ import (
"net/url"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
@@ -22,8 +23,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// AIStudioExecutor routes AI Studio requests through a websocket-backed transport.
@@ -544,7 +543,7 @@ func (e *AIStudioExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.A
if resp.Status < 200 || resp.Status >= 300 {
return cliproxyexecutor.Response{}, statusErr{code: resp.Status, msg: string(resp.Body)}
}
- totalTokens := gjson.GetBytes(resp.Body, "totalTokens").Int()
+ totalTokens := ojson.GetBytesNoCopy(resp.Body, "totalTokens").Int()
if totalTokens <= 0 {
return cliproxyexecutor.Response{}, fmt.Errorf("wsrelay: totalTokens missing in response")
}
@@ -582,16 +581,16 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe
if err != nil {
return nil, translatedPayload{}, err
}
- payload, err = helps.ApplyThinkingWithSourcePayload(payload, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(payload)
+ if err = helps.ApplyThinkingWithSourcePayloadDoc(doc, req.Payload, originalPayloadSource, req.Model, from.String(), to.String(), e.Identifier()); err != nil {
return nil, translatedPayload{}, err
}
- payload = fixGeminiImageAspectRatio(baseModel, payload)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- payload, _ = sjson.DeleteBytes(payload, "generationConfig.maxOutputTokens")
- payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseMimeType")
- payload, _ = sjson.DeleteBytes(payload, "generationConfig.responseJsonSchema")
+ _ = doc.Delete("generationConfig.maxOutputTokens")
+ _ = doc.Delete("generationConfig.responseMimeType")
+ _ = doc.Delete("generationConfig.responseJsonSchema")
metadataAction := "generateContent"
if req.Metadata != nil {
if action, _ := req.Metadata["action"].(string); action == "countTokens" {
@@ -602,19 +601,20 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe
if stream && action != "countTokens" {
action = "streamGenerateContent"
}
- payload, _ = sjson.DeleteBytes(payload, "session_id")
- payload = helps.EnsureGeminiLeadingUserContent(payload, "contents")
+ _ = doc.Delete("session_id")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
if action != "countTokens" {
- payload = helps.EnsureGeminiTrailingUserContent(payload, "contents")
+ helps.EnsureGeminiTrailingUserContentDoc(doc, "contents")
}
- payload = normalizeAIStudioThinkingLevel(payload)
- payload = internalsignature.SanitizeNonVertexGeminiRequestThoughtSignatures(payload, "contents")
+ normalizeAIStudioThinkingLevelDoc(doc)
+ internalsignature.SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc, "contents")
if action == "countTokens" {
- payload, _ = sjson.DeleteBytes(payload, "generationConfig")
- payload, _ = sjson.DeleteBytes(payload, "tools")
- payload, _ = sjson.DeleteBytes(payload, "safetySettings")
+ _ = doc.Delete("generationConfig")
+ _ = doc.Delete("tools")
+ _ = doc.Delete("safetySettings")
}
- payload = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", payload, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ payload = doc.Bytes()
return payload, translatedPayload{payload: payload, action: action, toFormat: to}, nil
}
@@ -622,27 +622,34 @@ func (e *AIStudioExecutor) translateRequest(ctx context.Context, req cliproxyexe
// The AI Studio upstream validates generationConfig.thinkingConfig.thinkingLevel case-sensitively
// and rejects lowercase values with a 400 invalid argument error.
func normalizeAIStudioThinkingLevel(payload []byte) []byte {
- const path = "generationConfig.thinkingConfig.thinkingLevel"
- level := gjson.GetBytes(payload, path)
- if level.Type != gjson.String {
+ doc := ojson.NewDoc(payload)
+ if !normalizeAIStudioThinkingLevelDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func normalizeAIStudioThinkingLevelDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ const path = "generationConfig.thinkingConfig.thinkingLevel"
+ level := doc.Get(path)
+ if level.Type != ojson.String {
+ return false
+ }
normalized := strings.ToUpper(level.String())
switch normalized {
case "MINIMAL", "LOW", "MEDIUM", "HIGH":
default:
- return payload
+ return false
}
if normalized == level.String() {
- return payload
+ return false
}
- result, err := sjson.SetBytes(payload, path, normalized)
- if err != nil {
- return payload
- }
- return result
+ return doc.Set(path, normalized) == nil
}
func (e *AIStudioExecutor) buildEndpoint(model, action, alt string) string {
diff --git a/internal/runtime/executor/aistudio_executor_test.go b/internal/runtime/executor/aistudio_executor_test.go
index 755fc5f1889..a0657dab488 100644
--- a/internal/runtime/executor/aistudio_executor_test.go
+++ b/internal/runtime/executor/aistudio_executor_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -19,7 +20,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing.T) {
@@ -36,7 +36,7 @@ func TestAIStudioTranslateRequestPreservesSummaryFromOriginalRequest(t *testing.
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
- if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() {
+ if !ojson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() {
t.Fatalf("original request summary intent was lost: %s", payload)
}
}
@@ -57,10 +57,10 @@ func TestAIStudioTranslateRequestNormalizesThinkingLevel(t *testing.T) {
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
- if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "HIGH" {
+ if got := ojson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "HIGH" {
t.Fatalf("thinkingLevel = %q, want HIGH; payload=%s", got, payload)
}
- if !gjson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() {
+ if !ojson.GetBytes(payload, "generationConfig.thinkingConfig.includeThoughts").Bool() {
t.Fatalf("includeThoughts was lost: %s", payload)
}
}
@@ -80,7 +80,7 @@ func TestAIStudioTranslateRequestPayloadOverrideWinsThinkingNormalization(t *tes
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
- if got := gjson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "medium" {
+ if got := ojson.GetBytes(payload, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "medium" {
t.Fatalf("thinkingLevel = %q, want medium; payload=%s", got, payload)
}
}
@@ -108,7 +108,7 @@ func TestNormalizeAIStudioThinkingLevel(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
input := []byte(fmt.Sprintf(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":%s}},"marker":true}`, test.levelJSON))
output := normalizeAIStudioThinkingLevel(input)
- level := gjson.GetBytes(output, "generationConfig.thinkingConfig.thinkingLevel")
+ level := ojson.GetBytes(output, "generationConfig.thinkingConfig.thinkingLevel")
if got := level.String(); got != test.want {
t.Fatalf("thinkingLevel = %q, want %q; payload=%s", got, test.want, output)
}
@@ -128,7 +128,7 @@ func TestAIStudioTranslateRequestPrependsLeadingUserForIssue4959ResponsesHistory
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
- assertIssue4959LeadingUserContents(t, gjson.GetBytes(body.payload, "contents").Array())
+ assertIssue4959LeadingUserContents(t, ojson.GetBytes(body.payload, "contents").Array())
}
func TestAIStudioTranslateRequestAppendsTrailingUserForTrailingModelTurn(t *testing.T) {
@@ -143,7 +143,7 @@ func TestAIStudioTranslateRequestAppendsTrailingUserForTrailingModelTurn(t *test
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
- contents := gjson.GetBytes(body.payload, "contents").Array()
+ contents := ojson.GetBytes(body.payload, "contents").Array()
if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" || contents[2].Get("role").String() != "user" {
t.Fatalf("contents roles malformed: %s", body.payload)
}
@@ -166,7 +166,7 @@ func TestAIStudioTranslateRequestCountTokensPreservesTrailingModelTurn(t *testin
if err != nil {
t.Fatalf("translateRequest() error = %v", err)
}
- contents := gjson.GetBytes(body.payload, "contents").Array()
+ contents := ojson.GetBytes(body.payload, "contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" {
t.Fatalf("countTokens contents roles malformed: %s", body.payload)
}
@@ -645,7 +645,7 @@ func TestAIStudioExecutorClaudeMalformedFunctionCallReturnsRetryableError(t *tes
if !okStatus || status.StatusCode() != http.StatusBadGateway {
t.Fatalf("Execute() error = %T %v, want status 502", errExec, errExec)
}
- if got := gjson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
+ if got := ojson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
t.Fatalf("Execute() error.type = %q, want api_error. Error: %s", got, errExec.Error())
}
assertMalformedFunctionCallFailedUsage(t, capture.records)
diff --git a/internal/runtime/executor/antigravity_caqs_replay_test.go b/internal/runtime/executor/antigravity_caqs_replay_test.go
index 3883acc437f..a31a756dddd 100644
--- a/internal/runtime/executor/antigravity_caqs_replay_test.go
+++ b/internal/runtime/executor/antigravity_caqs_replay_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature/signaturetest"
@@ -18,8 +19,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func TestAntigravityExecutorCAQSTwoTurnReplay(t *testing.T) {
@@ -94,7 +93,7 @@ func TestAntigravityExecutorCAQSTwoTurnReplay(t *testing.T) {
if !bytes.HasPrefix(line, []byte("data:")) {
continue
}
- event := gjson.ParseBytes(bytes.TrimSpace(line[5:]))
+ event := ojson.ParseBytes(bytes.TrimSpace(line[5:]))
switch event.Get("delta.type").String() {
case "signature_delta":
signatureOut += event.Get("delta.signature").String()
@@ -107,24 +106,25 @@ func TestAntigravityExecutorCAQSTwoTurnReplay(t *testing.T) {
if signatureOut != sig {
t.Fatal("stream changed the original signature")
}
- content = []byte(`[{"type":"thinking"},{"type":"text"}]`)
- content, _ = sjson.SetBytes(content, "0.thinking", thinkingOut)
- content, _ = sjson.SetBytes(content, "0.signature", signatureOut)
- content, _ = sjson.SetBytes(content, "1.text", textOut)
+ contentDoc := ojson.NewDoc([]byte(`[{"type":"thinking"},{"type":"text"}]`))
+ _ = contentDoc.Set("0.thinking", thinkingOut)
+ _ = contentDoc.Set("0.signature", signatureOut)
+ _ = contentDoc.Set("1.text", textOut)
+ content = contentDoc.Bytes()
} else {
- content = []byte(gjson.GetBytes(downstream, "content").Raw)
- if gjson.GetBytes(content, "0.signature").String() != sig {
+ content = []byte(ojson.GetBytes(downstream, "content").Raw)
+ if ojson.GetBytes(content, "0.signature").String() != sig {
t.Fatal("nonstream changed original signature")
}
}
second := []byte(`{"model":"claude-opus-5-5-high","max_tokens":4096,"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":"calculate"},{"role":"assistant","content":[]},{"role":"user","content":"plus 17"}]}`)
- second, _ = sjson.SetRawBytes(second, "messages.1.content", content)
+ second, _ = ojson.SetRawBytes(second, "messages.1.content", content)
call(second)
upstream := <-requests
- if gjson.GetBytes(upstream, "model").String() != model {
+ if ojson.GetBytes(upstream, "model").String() != model {
t.Fatal("upstream model changed")
}
- parts := gjson.GetBytes(upstream, "request.contents.1.parts").Array()
+ parts := ojson.GetBytes(upstream, "request.contents.1.parts").Array()
if len(parts) < 2 || !parts[0].Get("thought").Bool() || parts[0].Get("text").String() != "checked calculation" || parts[0].Get("thoughtSignature").String() != sig {
t.Fatal("second turn lost thinking or changed the original signature")
}
@@ -134,7 +134,7 @@ func TestAntigravityExecutorCAQSTwoTurnReplay(t *testing.T) {
t.Cleanup(func() { _ = cache.DeleteCachedSignatureRequired(context.Background(), model, text) })
recovery := []byte(`{"model":"claude-opus-5-5-high","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"caqs-cache-recovery-test"}]},{"role":"user","content":"continue"}]}`)
recovered, _ := antigravityclaude.ConvertClaudeRequestToAntigravity(model, recovery, false)
- if gjson.GetBytes(recovered, "request.contents.0.parts.0.thoughtSignature").String() != sig {
+ if ojson.GetBytes(recovered, "request.contents.0.parts.0.thoughtSignature").String() != sig {
t.Fatal("cache recovery changed original signature")
}
}
diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go
index 5e95a5127a6..bd0c33ecd63 100644
--- a/internal/runtime/executor/antigravity_executor.go
+++ b/internal/runtime/executor/antigravity_executor.go
@@ -15,6 +15,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -27,8 +28,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -73,6 +72,14 @@ func (e *AntigravityExecutor) obfuscateSensitiveWords(payload []byte) []byte {
return helps.ObfuscateSensitiveWordsInSystemInstruction(payload, matcher)
}
+func (e *AntigravityExecutor) obfuscateSensitiveWordsDoc(doc *ojson.Doc) bool {
+ if doc == nil || e == nil || e.cfg == nil || len(e.cfg.Antigravity.SensitiveWords) == 0 {
+ return false
+ }
+ matcher := helps.BuildSensitiveWordMatcher(e.cfg.Antigravity.SensitiveWords)
+ return helps.ObfuscateSensitiveWordsInSystemInstructionDoc(doc, matcher)
+}
+
// Each Antigravity credential gets its own HTTP/1.1 connection pool. Sessions routed
// to the same auth reuse that pool, while different OAuth identities never share a
// TCP/TLS connection, matching the native client's one-credential process model.
@@ -498,6 +505,14 @@ func sanitizeAntigravityGeminiRequestSignatures(modelName string, rawJSON []byte
return normalizeAntigravityGeminiFunctionResponseRoles(rawJSON)
}
+func sanitizeAntigravityGeminiRequestSignaturesDoc(modelName string, doc *ojson.Doc) {
+ if doc == nil || !antigravityUsesReasoningReplayCache(modelName) {
+ return
+ }
+ internalsignature.SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc, "request.contents")
+ normalizeAntigravityGeminiFunctionResponseRolesDoc(doc)
+}
+
// ensureAntigravityGeminiLeadingUserContent prepends a synthetic empty user turn
// after every contents rewrite, including reasoning replay. Claude targets are
// left unchanged because the adapter rejects empty text parts.
@@ -508,6 +523,13 @@ func ensureAntigravityGeminiLeadingUserContent(modelName string, payload []byte)
return helps.EnsureGeminiLeadingUserContent(payload, "request.contents")
}
+func ensureAntigravityGeminiLeadingUserContentDoc(modelName string, doc *ojson.Doc) bool {
+ if doc == nil || strings.Contains(strings.ToLower(modelName), "claude") {
+ return false
+ }
+ return helps.EnsureGeminiLeadingUserContentDoc(doc, "request.contents")
+}
+
// ensureAntigravityGeminiTrailingUserContent appends a synthetic empty user turn
// if the final turn is a model turn. Claude targets are left unchanged because
// the adapter rejects empty text parts.
@@ -518,6 +540,13 @@ func ensureAntigravityGeminiTrailingUserContent(modelName string, payload []byte
return helps.EnsureGeminiTrailingUserContent(payload, "request.contents")
}
+func ensureAntigravityGeminiTrailingUserContentDoc(modelName string, doc *ojson.Doc) bool {
+ if doc == nil || strings.Contains(strings.ToLower(modelName), "claude") {
+ return false
+ }
+ return helps.EnsureGeminiTrailingUserContentDoc(doc, "request.contents")
+}
+
// ensureAntigravityGeminiBoundaryUserContent normalizes both leading and trailing
// turns for Gemini targets. A trailing reasoning-only model turn is dropped first
// so the request does not rely only on a synthetic empty user turn (#6530).
@@ -530,6 +559,17 @@ func ensureAntigravityGeminiBoundaryUserContent(modelName string, payload []byte
return helps.EnsureGeminiBoundaryUserContent(payload, "request.contents")
}
+func ensureAntigravityGeminiBoundaryUserContentDoc(modelName string, doc *ojson.Doc) bool {
+ if doc == nil || strings.Contains(strings.ToLower(modelName), "claude") {
+ return false
+ }
+ changed := helps.DropGeminiTrailingThoughtOnlyContentDoc(doc, "request.contents")
+ if helps.EnsureGeminiBoundaryUserContentDoc(doc, "request.contents") {
+ changed = true
+ }
+ return changed
+}
+
type antigravityContentEdit struct {
index int64
path string
@@ -538,6 +578,26 @@ type antigravityContentEdit struct {
replacement []byte
}
+func normalizeAntigravityGeminiFunctionResponseRolesDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ contents := doc.Get("request.contents")
+ if !contents.IsArray() {
+ return false
+ }
+ wrapped := []byte(`{"request":{"contents":` + contents.Raw + `}}`)
+ normalized := normalizeAntigravityGeminiFunctionResponseRoles(wrapped)
+ if bytes.Equal(normalized, wrapped) {
+ return false
+ }
+ updatedContents := util.GetGJSONBytesNoCopy(normalized, "request.contents")
+ if !updatedContents.IsArray() || updatedContents.Raw == contents.Raw {
+ return false
+ }
+ return doc.SetRawBytes("request.contents", []byte(updatedContents.Raw)) == nil
+}
+
// normalizeAntigravityGeminiFunctionResponseRoles edits each response turn in
// isolation, then splices all changed turns into the request with one body copy.
// Applying SJSON once per field made large histories scale with history size
@@ -556,7 +616,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
edits := make([]antigravityContentEdit, 0)
var pending []functionRef
validOffsets := true
- contents.ForEach(func(contentIndex, content gjson.Result) bool {
+ contents.ForEach(func(contentIndex, content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
pending = nil
@@ -567,7 +627,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
var responseParts, otherParts []json.RawMessage
partCount := 0
hasOtherPart := false
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
partCount++
switch {
case part.Get("functionCall").Exists():
@@ -600,7 +660,7 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
return true
}
- var contentJSON []byte
+ var contentDoc *ojson.Doc
contentChanged := false
if len(pending) > 0 && len(responses) > 0 {
ordered := make([]json.RawMessage, 0, partCount)
@@ -626,9 +686,8 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
ordered = append(ordered, otherParts...)
encoded, errMarshal := json.Marshal(ordered)
if errMarshal == nil && !bytes.Equal(encoded, []byte(parts.Raw)) {
- contentJSON = []byte(content.Raw)
- if updated, errSet := sjson.SetRawBytes(contentJSON, "parts", encoded); errSet == nil {
- contentJSON = updated
+ contentDoc = ojson.NewDoc([]byte(content.Raw))
+ if errSet := contentDoc.SetRawBytes("parts", encoded); errSet == nil {
contentChanged = true
}
}
@@ -636,17 +695,17 @@ func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte {
}
pending = nil
if !hasOtherPart && content.Get("role").String() != "model" {
- if contentJSON == nil {
- contentJSON = []byte(content.Raw)
+ if contentDoc == nil {
+ contentDoc = ojson.NewDoc([]byte(content.Raw))
}
- if updated, errSet := sjson.SetBytes(contentJSON, "role", "model"); errSet == nil {
- contentJSON = updated
+ if errSet := contentDoc.Set("role", "model"); errSet == nil {
contentChanged = true
}
}
- if !contentChanged {
+ if !contentChanged || contentDoc == nil {
return true
}
+ contentJSON := contentDoc.Bytes()
start := content.Index
end := start + len(content.Raw)
@@ -671,7 +730,7 @@ func applyAntigravityIndexedEdits(rawJSON []byte, edits []antigravityContentEdit
if len(edits) == 0 {
return rawJSON
}
- if !validOffsets {
+ if !validOffsets || !ojson.ValidBytes(rawJSON) {
return applyAntigravityContentEditsWithSJSON(rawJSON, edits)
}
@@ -700,17 +759,21 @@ func applyAntigravityIndexedEdits(rawJSON []byte, edits []antigravityContentEdit
// applyAntigravityContentEditsWithSJSON preserves the legacy path semantics if
// a GJSON result cannot be proven to point into the original request bytes.
func applyAntigravityContentEditsWithSJSON(rawJSON []byte, edits []antigravityContentEdit) []byte {
- out := rawJSON
+ doc := ojson.NewDoc(rawJSON)
+ changed := false
for _, edit := range edits {
path := edit.path
if path == "" {
path = fmt.Sprintf("request.contents.%d", edit.index)
}
- if updated, errSet := sjson.SetRawBytes(out, path, edit.replacement); errSet == nil {
- out = updated
+ if errSet := doc.SetRawBytes(path, edit.replacement); errSet == nil {
+ changed = true
}
}
- return out
+ if !changed {
+ return rawJSON
+ }
+ return doc.Bytes()
}
// repairAntigravityGeminiFunctionResponseNames copies missing or placeholder
@@ -722,12 +785,12 @@ func repairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
return rawJSON
}
callIDToName := make(map[string]string)
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return true
}
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
fc := part.Get("functionCall")
if fc.Exists() {
id := strings.TrimSpace(fc.Get("id").String())
@@ -746,12 +809,12 @@ func repairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
edits := make([]antigravityContentEdit, 0)
validOffsets := true
- contents.ForEach(func(contentIdx, content gjson.Result) bool {
+ contents.ForEach(func(contentIdx, content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return true
}
- parts.ForEach(func(partIdx, part gjson.Result) bool {
+ parts.ForEach(func(partIdx, part ojson.Result) bool {
fr := part.Get("functionResponse")
if !fr.Exists() {
return true
@@ -765,7 +828,7 @@ func repairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
if !ok {
return true
}
- updatedPart, errSet := sjson.SetBytes([]byte(part.Raw), "functionResponse.name", realName)
+ updatedPart, errSet := ojson.SetBytes([]byte(part.Raw), "functionResponse.name", realName)
if errSet != nil {
return true
}
@@ -884,12 +947,12 @@ func countClaudeThinkingBlocks(rawJSON []byte) int {
}
count := 0
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
content := message.Get("content")
if !content.IsArray() {
return true
}
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "thinking" {
count++
}
diff --git a/internal/runtime/executor/antigravity_executor_buildrequest_test.go b/internal/runtime/executor/antigravity_executor_buildrequest_test.go
index 69e7a1c03b6..b43078e8bdd 100644
--- a/internal/runtime/executor/antigravity_executor_buildrequest_test.go
+++ b/internal/runtime/executor/antigravity_executor_buildrequest_test.go
@@ -9,7 +9,10 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
)
@@ -359,6 +362,84 @@ func TestAntigravityBuildRequest_RejectsMissingProjectID(t *testing.T) {
}
}
+func TestAntigravityExecutor_Claude55ExplicitThinkingLevelPreserved(t *testing.T) {
+ sseResponse := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"OK\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1,\"candidatesTokenCount\":1,\"totalTokenCount\":2}}}\n\n"
+ for _, model := range []string{"claude-sonnet-5-5-high", "claude-opus-5-5-high"} {
+ for _, level := range []string{"low", "medium", "high"} {
+ for _, stream := range []bool{false, true} {
+ modeName := "non_stream"
+ if stream {
+ modeName = "stream"
+ }
+ t.Run(model+"/"+level+"/"+modeName, func(t *testing.T) {
+ var capturedBody []byte
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ raw, errRead := io.ReadAll(req.Body)
+ if errRead != nil {
+ return nil, errRead
+ }
+ if errClose := req.Body.Close(); errClose != nil {
+ return nil, errClose
+ }
+ capturedBody = raw
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": {"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(sseResponse)),
+ Request: req,
+ }, nil
+ }))
+
+ executor := NewAntigravityExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: "ag-claude-55-test",
+ Provider: "antigravity",
+ Metadata: map[string]any{
+ "access_token": "token",
+ "project_id": "project-1",
+ "expired": time.Now().Add(time.Hour).Format(time.RFC3339),
+ },
+ }
+ payload := []byte(`{"contents":[{"role":"user","parts":[{"text":"Reply OK"}]}],"generationConfig":{"maxOutputTokens":128000,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"` + level + `"}}}`)
+ req := cliproxyexecutor.Request{Model: model, Payload: payload}
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatGemini,
+ ResponseFormat: sdktranslator.FormatGemini,
+ OriginalRequest: payload,
+ Stream: stream,
+ }
+
+ if stream {
+ res, errStream := executor.ExecuteStream(ctx, auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range res.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else {
+ if _, errExec := executor.Execute(ctx, auth, req, opts); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+ }
+
+ if got := ojson.GetBytes(capturedBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != level {
+ t.Fatalf("thinkingLevel = %q, want %q; body=%s", got, level, capturedBody)
+ }
+ if budget := ojson.GetBytes(capturedBody, "request.generationConfig.thinkingConfig.thinkingBudget"); budget.Exists() {
+ t.Fatalf("thinkingBudget should be absent, got %s; body=%s", budget.Raw, capturedBody)
+ }
+ if include := ojson.GetBytes(capturedBody, "request.generationConfig.thinkingConfig.includeThoughts"); !include.Exists() || !include.Bool() {
+ t.Fatalf("includeThoughts = %s, want true; body=%s", include.Raw, capturedBody)
+ }
+ })
+ }
+ }
+ }
+}
+
func assertNonSchemaRequestPreserved(t *testing.T, body map[string]any) {
t.Helper()
diff --git a/internal/runtime/executor/antigravity_executor_compaction_test.go b/internal/runtime/executor/antigravity_executor_compaction_test.go
index 6d65210df72..17cc1bb1fac 100644
--- a/internal/runtime/executor/antigravity_executor_compaction_test.go
+++ b/internal/runtime/executor/antigravity_executor_compaction_test.go
@@ -11,17 +11,17 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestAntigravityCompactionTriggerStreamGemini(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) > 0 {
lastRole := contents[len(contents)-1].Get("role").String()
if lastRole == "model" || lastRole == "assistant" {
@@ -101,7 +101,7 @@ func TestAntigravityCompactionTriggerStreamGemini(t *testing.T) {
func TestAntigravityCompactionTriggerStreamClaude(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) > 0 {
lastRole := contents[len(contents)-1].Get("role").String()
if lastRole == "assistant" || lastRole == "model" {
@@ -175,7 +175,7 @@ func TestAntigravityCompactionTriggerStreamClaude(t *testing.T) {
func TestAntigravityCompactionAltResponsesCompact(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) > 0 {
lastRole := contents[len(contents)-1].Get("role").String()
if lastRole == "model" || lastRole == "assistant" {
@@ -228,22 +228,22 @@ func TestAntigravityCompactionAltResponsesCompact(t *testing.T) {
t.Fatalf("Execute with Alt responses/compact failed: %v", err)
}
- if !gjson.GetBytes(resp.Payload, "output.0.type").Exists() || gjson.GetBytes(resp.Payload, "output.0.type").String() != "compaction" {
+ if !ojson.GetBytes(resp.Payload, "output.0.type").Exists() || ojson.GetBytes(resp.Payload, "output.0.type").String() != "compaction" {
t.Fatalf("expected output.0.type to be compaction, got: %s", string(resp.Payload))
}
- if !strings.HasPrefix(gjson.GetBytes(resp.Payload, "output.0.encrypted_content").String(), "cpa-ag-compact-v1:") {
+ if !strings.HasPrefix(ojson.GetBytes(resp.Payload, "output.0.encrypted_content").String(), "cpa-ag-compact-v1:") {
t.Fatalf("expected sealed encrypted_content, got: %s", string(resp.Payload))
}
}
func TestAntigravityCompactionReplayNextTurn(t *testing.T) {
- var gotContents []gjson.Result
+ var gotContents []ojson.Result
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if strings.Contains(r.URL.Path, "generateContent") {
gotBody = body
- gotContents = gjson.GetBytes(body, "request.contents").Array()
+ gotContents = ojson.GetBytes(body, "request.contents").Array()
}
w.Header().Set("Content-Type", "application/json")
@@ -289,7 +289,7 @@ func TestAntigravityCompactionReplayNextTurn(t *testing.T) {
t.Fatalf("compaction failed: %v", errCompact)
}
- capsule := gjson.GetBytes(compactResp.Payload, "output.0.encrypted_content").String()
+ capsule := ojson.GetBytes(compactResp.Payload, "output.0.encrypted_content").String()
if capsule == "" {
t.Fatalf("empty capsule: %s", string(compactResp.Payload))
}
@@ -328,7 +328,7 @@ func TestAntigravityCompactionReplayNextTurn(t *testing.T) {
}
}
if !foundContext {
- systemText := gjson.GetBytes(gotBody, "request.systemInstruction.parts.0.text").String()
+ systemText := ojson.GetBytes(gotBody, "request.systemInstruction.parts.0.text").String()
if strings.Contains(systemText, "Context summary from previous turns") {
foundContext = true
}
@@ -407,7 +407,7 @@ func TestAntigravityCompactionSequentialCompactionPreservesContext(t *testing.T)
if err1 != nil {
t.Fatalf("first compaction: %v", err1)
}
- capsule1 := gjson.GetBytes(resp1.Payload, "output.0.encrypted_content").String()
+ capsule1 := ojson.GetBytes(resp1.Payload, "output.0.encrypted_content").String()
// 2. Second compaction triggered when input contains: old capsule + new conversation + compaction_trigger
compactPayload2 := []byte(fmt.Sprintf(`{
diff --git a/internal/runtime/executor/antigravity_executor_credits.go b/internal/runtime/executor/antigravity_executor_credits.go
index 193f4e1af63..2e4e6230f6f 100644
--- a/internal/runtime/executor/antigravity_executor_credits.go
+++ b/internal/runtime/executor/antigravity_executor_credits.go
@@ -13,13 +13,12 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"golang.org/x/sync/singleflight"
)
@@ -200,14 +199,21 @@ func injectEnabledCreditTypes(payload []byte) []byte {
if len(payload) == 0 {
return nil
}
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
return nil
}
- updated, err := sjson.SetRawBytes(payload, "enabledCreditTypes", []byte(`["GOOGLE_ONE_AI"]`))
- if err != nil {
+ doc := ojson.NewDoc(payload)
+ if !injectEnabledCreditTypesDoc(doc) {
return nil
}
- return updated
+ return doc.Bytes()
+}
+
+func injectEnabledCreditTypesDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ return doc.SetRawBytes("enabledCreditTypes", []byte(`["GOOGLE_ONE_AI"]`)) == nil
}
func classifyAntigravity429(body []byte) antigravity429Category {
@@ -233,18 +239,18 @@ func decideAntigravity429(body []byte) antigravity429Decision {
decision.retryAfter = retryAfter
}
- status := strings.TrimSpace(gjson.GetBytes(body, "error.status").String())
+ status := strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.status").String())
if !strings.EqualFold(status, "RESOURCE_EXHAUSTED") {
return decision
}
- details := gjson.GetBytes(body, "error.details")
+ details := ojson.GetBytesNoCopy(body, "error.details")
if details.Exists() && details.IsArray() {
for _, detail := range details.Array() {
if detail.Get("@type").String() != "type.googleapis.com/google.rpc.ErrorInfo" {
continue
}
- reason := strings.TrimSpace(detail.Get("reason").String())
+ reason := strings.Clone(strings.TrimSpace(detail.Get("reason").String()))
decision.reason = reason
switch {
case strings.EqualFold(reason, "QUOTA_EXHAUSTED"), strings.EqualFold(reason, "INSUFFICIENT_G1_CREDITS_BALANCE"):
@@ -333,7 +339,7 @@ func antigravityHasExplicitCreditsBalanceExhaustedReason(body []byte) bool {
if len(body) == 0 {
return false
}
- details := gjson.GetBytes(body, "error.details")
+ details := ojson.GetBytesNoCopy(body, "error.details")
if !details.Exists() || !details.IsArray() {
return false
}
@@ -583,9 +589,9 @@ func (e *AntigravityExecutor) updateAntigravityCreditsBalanceForTask(ctx context
clearAntigravityCreditsPermanentlyDisabled(auth)
}
}
- paidTierID := strings.TrimSpace(gjson.GetBytes(bodyBytes, "paidTier.id").String())
+ paidTierID := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(bodyBytes, "paidTier.id").String()))
- credits := gjson.GetBytes(bodyBytes, "paidTier.availableCredits")
+ credits := ojson.GetBytesNoCopy(bodyBytes, "paidTier.availableCredits")
if !credits.IsArray() {
publish(nil, cliproxyauth.AntigravityCreditsHint{
Known: true,
diff --git a/internal/runtime/executor/antigravity_executor_execute.go b/internal/runtime/executor/antigravity_executor_execute.go
index 235c193f69a..bb7ab2fcce4 100644
--- a/internal/runtime/executor/antigravity_executor_execute.go
+++ b/internal/runtime/executor/antigravity_executor_execute.go
@@ -12,6 +12,8 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
@@ -19,8 +21,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Execute performs a non-streaming request to the Antigravity API.
@@ -92,20 +92,15 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
reporter.UpdateAccessTokenFingerprint(auth)
}
- translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(translated)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return resp, err
}
finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts)
- ctx = helps.WithPayloadFinalizer(ctx, func(body []byte) []byte {
- out := finalize(body)
- reporter.SetTranslatedRequestMetadata(out, to.String())
- return out
- })
- translated = e.obfuscateSensitiveWords(translated)
- translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
- reporter.SetTranslatedReasoningEffort(translated, to.String())
+ ctx = helps.WithPayloadFinalizer(ctx, finalize)
+ e.obfuscateSensitiveWordsDoc(doc)
+ sanitizeAntigravityGeminiRequestSignaturesDoc(baseModel, doc)
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
@@ -114,29 +109,33 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
httpClient = reporter.TrackHTTPClient(httpClient)
// Credential retry rounds are owned by the conductor. Perform one upstream
// request per credential so request-retry is not consumed twice.
- requestPayload := translated
if useCredits {
- if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
- requestPayload = cp
+ if injectEnabledCreditTypesDoc(doc) {
helps.MarkCreditsUsed(ctx)
}
}
replayScope := antigravityReasoningReplayScope{}
if antigravityUsesReasoningReplayCache(baseModel) {
var errReplay error
- requestPayload, replayScope, errReplay = prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, requestPayload)
+ replayScope, _, errReplay = prepareAntigravityGeminiReasoningReplayPayloadDoc(ctx, baseModel, req, opts, doc)
if errReplay != nil {
err = errReplay
return resp, err
}
}
- requestPayload = ensureAntigravityGeminiBoundaryUserContent(baseModel, requestPayload)
+ ensureAntigravityGeminiBoundaryUserContentDoc(baseModel, doc)
+ var replayRequestPayload []byte
+ if replayScope.valid() {
+ replayRequestPayload, _ = antigravityReplayRequestEnvelopeFromDoc(doc)
+ }
- httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, false, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
+ httpReq, requestPayload, errReq := e.buildRequestDoc(ctx, auth, token, baseModel, doc, false, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
if errReq != nil {
err = errReq
return resp, err
}
+ translated = requestPayload
+ reporter.SetTranslatedRequestMetadata(requestPayload, to.String())
httpResp, errDo := helps.WithAntigravityHTTPClientTrace(httpClient, auth, "generate").Do(httpReq)
if errDo != nil {
@@ -195,7 +194,10 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
if useCredits {
clearAntigravityCreditsFailureState(auth)
}
- cacheAntigravityReasoningReplayFromResponse(ctx, replayScope, requestPayload, bodyBytes)
+ if len(replayRequestPayload) == 0 {
+ replayRequestPayload = requestPayload
+ }
+ cacheAntigravityReasoningReplayFromResponse(ctx, replayScope, replayRequestPayload, bodyBytes)
bodyBytes = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, bodyBytes)
reporter.ObserveResponseModel(bodyBytes)
var param any
@@ -248,9 +250,9 @@ func (e *AntigravityExecutor) executeCompaction(ctx context.Context, auth *clipr
return resp, fmt.Errorf("seal compaction capsule: %w", errSeal)
}
- inputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens").Int())
- outputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.output_tokens").Int())
- totalTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.total_tokens").Int())
+ inputTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.input_tokens").Int())
+ outputTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.output_tokens").Int())
+ totalTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.total_tokens").Int())
if totalTokens == 0 && inputTokens == 0 {
usage := helps.ParseOpenAIUsage(summaryResp.Payload)
inputTokens = int(usage.InputTokens)
@@ -311,20 +313,15 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
reporter.UpdateAccessTokenFingerprint(auth)
}
- translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(translated)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return resp, err
}
finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts)
- ctx = helps.WithPayloadFinalizer(ctx, func(body []byte) []byte {
- out := finalize(body)
- reporter.SetTranslatedRequestMetadata(out, to.String())
- return out
- })
- translated = e.obfuscateSensitiveWords(translated)
- translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
- reporter.SetTranslatedReasoningEffort(translated, to.String())
+ ctx = helps.WithPayloadFinalizer(ctx, finalize)
+ e.obfuscateSensitiveWordsDoc(doc)
+ sanitizeAntigravityGeminiRequestSignaturesDoc(baseModel, doc)
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
@@ -334,28 +331,32 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
// Credential retry rounds are owned by the conductor. Perform one upstream
// request per credential so request-retry is not consumed twice.
- requestPayload := translated
if useCredits {
- if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
- requestPayload = cp
+ if injectEnabledCreditTypesDoc(doc) {
helps.MarkCreditsUsed(ctx)
}
}
replayScope := antigravityReasoningReplayScope{}
if antigravityUsesReasoningReplayCache(baseModel) {
var errReplay error
- requestPayload, replayScope, errReplay = prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, requestPayload)
+ replayScope, _, errReplay = prepareAntigravityGeminiReasoningReplayPayloadDoc(ctx, baseModel, req, opts, doc)
if errReplay != nil {
err = errReplay
return resp, err
}
}
- requestPayload = ensureAntigravityGeminiBoundaryUserContent(baseModel, requestPayload)
- httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
+ ensureAntigravityGeminiBoundaryUserContentDoc(baseModel, doc)
+ var replayRequestPayload []byte
+ if replayScope.valid() {
+ replayRequestPayload, _ = antigravityReplayRequestEnvelopeFromDoc(doc)
+ }
+ httpReq, requestPayload, errReq := e.buildRequestDoc(ctx, auth, token, baseModel, doc, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
if errReq != nil {
err = errReq
return resp, err
}
+ translated = requestPayload
+ reporter.SetTranslatedRequestMetadata(requestPayload, to.String())
httpResp, errDo := helps.WithAntigravityHTTPClientTrace(httpClient, auth, "generate").Do(httpReq)
if errDo != nil {
@@ -420,8 +421,13 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
if useCredits {
clearAntigravityCreditsFailureState(auth)
}
- replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, requestPayload)
+ if len(replayRequestPayload) == 0 {
+ replayRequestPayload = requestPayload
+ }
+ replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, replayRequestPayload)
var rawPayloads [][]byte
+ var pendingFilterPayload []byte
+ var pendingFilterMsg string
scanner := bufio.NewScanner(httpResp.Body)
scanner.Buffer(nil, streamScannerBuffer)
for scanner.Scan() {
@@ -434,6 +440,17 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
if payload == nil {
continue
}
+ if len(pendingFilterPayload) > 0 {
+ reporter.ObserveResponseModel(pendingFilterPayload)
+ rawPayloads = append(rawPayloads, pendingFilterPayload)
+ pendingFilterPayload = nil
+ pendingFilterMsg = ""
+ }
+ if filterMsg, blocked := clienterror.GeminiFilterBlockMessage(payload); blocked {
+ pendingFilterPayload = bytes.Clone(payload)
+ pendingFilterMsg = filterMsg
+ continue
+ }
reporter.ObserveResponseModel(payload)
rawPayloads = append(rawPayloads, bytes.Clone(payload))
}
@@ -446,6 +463,12 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
reporter.PublishFailure(ctx, scanErr)
return resp, scanErr
}
+ if pendingFilterMsg != "" {
+ filterErr := statusErr{code: http.StatusBadRequest, msg: pendingFilterMsg}
+ helps.RecordAPIResponseError(ctx, e.cfg, filterErr)
+ reporter.PublishFailure(ctx, filterErr)
+ return resp, filterErr
+ }
if replayAccumulator != nil {
replayAccumulator.Commit(ctx)
}
@@ -542,29 +565,29 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
pendingThoughtSig = ""
}
- normalizeRawPart := func(partResult gjson.Result, hasSnakeSig, hasSnakeInline bool, sig string, inlineRaw string) string {
+ normalizeRawPart := func(partResult ojson.Result, hasSnakeSig, hasSnakeInline bool, sig string, inlineRaw string) string {
if !hasSnakeSig && !hasSnakeInline {
return partResult.Raw
}
- raw := []byte(partResult.Raw)
+ partDoc := ojson.NewDoc([]byte(partResult.Raw))
if hasSnakeSig {
if sig != "" {
- raw, _ = sjson.SetBytes(raw, "thoughtSignature", sig)
+ _ = partDoc.Set("thoughtSignature", sig)
}
- raw, _ = sjson.DeleteBytes(raw, "thought_signature")
+ _ = partDoc.Delete("thought_signature")
}
if hasSnakeInline {
if inlineRaw != "" {
- raw, _ = sjson.SetRawBytes(raw, "inlineData", []byte(inlineRaw))
+ _ = partDoc.SetRawBytes("inlineData", []byte(inlineRaw))
}
- raw, _ = sjson.DeleteBytes(raw, "inline_data")
+ _ = partDoc.Delete("inline_data")
}
- return string(raw)
+ return string(partDoc.Bytes())
}
for _, line := range chunks {
trimmed := bytes.TrimSpace(line)
- if len(trimmed) == 0 || !gjson.ValidBytes(trimmed) {
+ if len(trimmed) == 0 || !ojson.ValidBytes(trimmed) {
continue
}
@@ -585,8 +608,8 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
traceID = traceResult.String()
}
- var partsResult gjson.Result
- responseNode.ForEach(func(rk, rv gjson.Result) bool {
+ var partsResult ojson.Result
+ responseNode.ForEach(func(rk, rv ojson.Result) bool {
switch rk.String() {
case "modelVersion":
if rv.String() != "" {
@@ -604,7 +627,7 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
if rv.IsArray() {
cand0 := rv.Get("0")
if cand0.IsObject() {
- cand0.ForEach(func(ck, cv gjson.Result) bool {
+ cand0.ForEach(func(ck, cv ojson.Result) bool {
switch ck.String() {
case "finishReason":
if cv.String() != "" {
@@ -612,7 +635,7 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
}
case "content":
if cv.IsObject() {
- cv.ForEach(func(ctk, ctv gjson.Result) bool {
+ cv.ForEach(func(ctk, ctv ojson.Result) bool {
switch ctk.String() {
case "role":
role = ctv.String()
@@ -639,7 +662,7 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
}
if partsResult.IsArray() {
- partsResult.ForEach(func(_, part gjson.Result) bool {
+ partsResult.ForEach(func(_, part ojson.Result) bool {
var (
hasFunctionCall bool
hasInlineData bool
@@ -651,7 +674,7 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
text string
thought bool
)
- part.ForEach(func(pk, pv gjson.Result) bool {
+ part.ForEach(func(pk, pv ojson.Result) bool {
switch pk.String() {
case "functionCall":
hasFunctionCall = true
@@ -707,40 +730,44 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
}
flushPending()
- var templateBytes []byte
+ var templateDoc *ojson.Doc
if responseNodeRaw == "" {
- templateBytes = []byte(`{"candidates":[{"content":{"role":"model","parts":` + antigravityPartsPlaceholder + `}}]}`)
+ templateDoc = ojson.NewDoc([]byte(`{"candidates":[{"content":{"role":"model","parts":` + antigravityPartsPlaceholder + `}}]}`))
} else if responsePartsRaw != "" {
if idx := responsePartsIndex; idx >= 0 && idx+len(responsePartsRaw) <= len(responseNodeRaw) {
- templateBytes = make([]byte, 0, len(responseNodeRaw)-len(responsePartsRaw)+len(antigravityPartsPlaceholder))
+ templateBytes := make([]byte, 0, len(responseNodeRaw)-len(responsePartsRaw)+len(antigravityPartsPlaceholder))
templateBytes = append(templateBytes, responseNodeRaw[:idx]...)
templateBytes = append(templateBytes, antigravityPartsPlaceholder...)
templateBytes = append(templateBytes, responseNodeRaw[idx+len(responsePartsRaw):]...)
+ templateDoc = ojson.NewDoc(templateBytes)
} else {
- templateBytes, _ = sjson.SetRawBytes([]byte(responseNodeRaw), "candidates.0.content.parts", []byte(antigravityPartsPlaceholder))
+ templateDoc = ojson.NewDoc([]byte(responseNodeRaw))
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts", []byte(antigravityPartsPlaceholder))
}
} else {
- templateBytes, _ = sjson.SetRawBytes([]byte(responseNodeRaw), "candidates.0.content.parts", []byte(antigravityPartsPlaceholder))
+ templateDoc = ojson.NewDoc([]byte(responseNodeRaw))
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts", []byte(antigravityPartsPlaceholder))
}
if role != "" {
- templateBytes, _ = sjson.SetBytes(templateBytes, "candidates.0.content.role", role)
+ _ = templateDoc.Set("candidates.0.content.role", role)
}
if finishReason != "" {
- templateBytes, _ = sjson.SetBytes(templateBytes, "candidates.0.finishReason", finishReason)
+ _ = templateDoc.Set("candidates.0.finishReason", finishReason)
}
if modelVersion != "" {
- templateBytes, _ = sjson.SetBytes(templateBytes, "modelVersion", modelVersion)
+ _ = templateDoc.Set("modelVersion", modelVersion)
}
if responseID != "" {
- templateBytes, _ = sjson.SetBytes(templateBytes, "responseId", responseID)
+ _ = templateDoc.Set("responseId", responseID)
}
if usageRaw != "" {
- templateBytes, _ = sjson.SetRawBytes(templateBytes, "usageMetadata", []byte(usageRaw))
- } else if !gjson.GetBytes(templateBytes, "usageMetadata").Exists() {
- templateBytes, _ = sjson.SetRawBytes(templateBytes, "usageMetadata", []byte(`{"promptTokenCount":0,"candidatesTokenCount":0,"totalTokenCount":0}`))
+ _ = templateDoc.SetRawBytes("usageMetadata", []byte(usageRaw))
+ } else if !templateDoc.Get("usageMetadata").Exists() {
+ _ = templateDoc.SetRawBytes("usageMetadata", []byte(`{"promptTokenCount":0,"candidatesTokenCount":0,"totalTokenCount":0}`))
}
- templateBytes, _ = sjson.DeleteBytes(templateBytes, "cpaUsageMetadata")
+ _ = templateDoc.Delete("cpaUsageMetadata")
+ templateBytes := templateDoc.Bytes()
partsTotalLen := 2
for i, p := range parts {
@@ -751,7 +778,7 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
}
traceJSON, _ := json.Marshal(traceID)
- placeholder := gjson.GetBytes(templateBytes, "candidates.0.content.parts")
+ placeholder := ojson.GetBytesNoCopy(templateBytes, "candidates.0.content.parts")
placeholderIdx := placeholder.Index
if placeholder.Raw != antigravityPartsPlaceholder || placeholderIdx <= 0 {
partsJSON := make([]byte, 0, partsTotalLen)
@@ -763,7 +790,8 @@ func (e *AntigravityExecutor) convertStreamChunksToNonStream(chunks [][]byte) []
partsJSON = append(partsJSON, p...)
}
partsJSON = append(partsJSON, ']')
- templateBytes, _ = sjson.SetRawBytes(templateBytes, "candidates.0.content.parts", partsJSON)
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts", partsJSON)
+ templateBytes = templateDoc.Bytes()
output := make([]byte, 0, len(templateBytes)+len(traceJSON)+28)
output = append(output, `{"response":`...)
output = append(output, templateBytes...)
diff --git a/internal/runtime/executor/antigravity_executor_finish_reason_test.go b/internal/runtime/executor/antigravity_executor_finish_reason_test.go
index 3cbcccd46de..9d37d739cf8 100644
--- a/internal/runtime/executor/antigravity_executor_finish_reason_test.go
+++ b/internal/runtime/executor/antigravity_executor_finish_reason_test.go
@@ -9,11 +9,11 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// antigravityNoFinishReasonSSE mirrors a real upstream stream captured from
@@ -243,10 +243,10 @@ func TestAntigravityStreamDoesNotFinalizeEmptyUpstreamStream(t *testing.T) {
if chunk.Err != nil {
t.Fatalf("unexpected stream error: %v", chunk.Err)
}
- if finish := gjson.GetBytes(chunk.Payload, "candidates.0.finishReason").String(); finish != "" {
+ if finish := ojson.GetBytes(chunk.Payload, "candidates.0.finishReason").String(); finish != "" {
t.Fatalf("empty upstream stream must not synthesize a terminal chunk: %s", chunk.Payload)
}
- if finish := gjson.GetBytes(chunk.Payload, "choices.0.finish_reason").String(); finish != "" {
+ if finish := ojson.GetBytes(chunk.Payload, "choices.0.finish_reason").String(); finish != "" {
t.Fatalf("empty upstream stream must not synthesize a terminal chunk: %s", chunk.Payload)
}
}
@@ -262,28 +262,28 @@ func TestAntigravityStreamSynthesizesGeminiTerminalChunkWithUsage(t *testing.T)
`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
terminal := chunks[len(chunks)-1]
- if fr := gjson.GetBytes(terminal, "candidates.0.finishReason").String(); fr != "STOP" {
+ if fr := ojson.GetBytes(terminal, "candidates.0.finishReason").String(); fr != "STOP" {
t.Fatalf("terminal finishReason = %q, want STOP", fr)
}
- if role := gjson.GetBytes(terminal, "candidates.0.content.role").String(); role != "model" {
+ if role := ojson.GetBytes(terminal, "candidates.0.content.role").String(); role != "model" {
t.Fatalf("terminal content role = %q, want model", role)
}
- if got := gjson.GetBytes(terminal, "usageMetadata.totalTokenCount").Int(); got != 153720 {
+ if got := ojson.GetBytes(terminal, "usageMetadata.totalTokenCount").Int(); got != 153720 {
t.Fatalf("terminal totalTokenCount = %d, want 153720", got)
}
- if got := gjson.GetBytes(terminal, "usageMetadata.promptTokenCount").Int(); got != 153609 {
+ if got := ojson.GetBytes(terminal, "usageMetadata.promptTokenCount").Int(); got != 153609 {
t.Fatalf("terminal promptTokenCount = %d, want 153609", got)
}
- if got := gjson.GetBytes(terminal, "modelVersion").String(); got != "gemini-3.7-flash" {
+ if got := ojson.GetBytes(terminal, "modelVersion").String(); got != "gemini-3.7-flash" {
t.Fatalf("terminal modelVersion = %q, want gemini-3.7-flash", got)
}
- if got := gjson.GetBytes(terminal, "responseId").String(); got != "resp-nofr" {
+ if got := ojson.GetBytes(terminal, "responseId").String(); got != "resp-nofr" {
t.Fatalf("terminal responseId = %q, want resp-nofr", got)
}
var finishReasonCount int
for _, chunk := range chunks {
- if gjson.GetBytes(chunk, "candidates.0.finishReason").String() != "" {
+ if ojson.GetBytes(chunk, "candidates.0.finishReason").String() != "" {
finishReasonCount++
}
}
@@ -300,22 +300,22 @@ func TestAntigravityStreamSynthesizesOpenAIToolCallsFinishReason(t *testing.T) {
`{"model":"gemini-3.7-flash","messages":[{"role":"user","content":"hello"}],"stream":true}`)
terminal := chunks[len(chunks)-1]
- if fr := gjson.GetBytes(terminal, "choices.0.finish_reason").String(); fr != "tool_calls" {
+ if fr := ojson.GetBytes(terminal, "choices.0.finish_reason").String(); fr != "tool_calls" {
t.Fatalf("terminal finish_reason = %q, want tool_calls", fr)
}
- if got := gjson.GetBytes(terminal, "usage.total_tokens").Int(); got != 153720 {
+ if got := ojson.GetBytes(terminal, "usage.total_tokens").Int(); got != 153720 {
t.Fatalf("terminal total_tokens = %d, want 153720", got)
}
- if got := gjson.GetBytes(terminal, "usage.prompt_tokens").Int(); got != 153609 {
+ if got := ojson.GetBytes(terminal, "usage.prompt_tokens").Int(); got != 153609 {
t.Fatalf("terminal prompt_tokens = %d, want 153609", got)
}
- if got := gjson.GetBytes(terminal, "model").String(); got != "gemini-3.7-flash" {
+ if got := ojson.GetBytes(terminal, "model").String(); got != "gemini-3.7-flash" {
t.Fatalf("terminal model = %q, want gemini-3.7-flash", got)
}
var finishReasonCount int
for _, chunk := range chunks {
- if gjson.GetBytes(chunk, "choices.0.finish_reason").String() != "" {
+ if ojson.GetBytes(chunk, "choices.0.finish_reason").String() != "" {
finishReasonCount++
}
}
@@ -334,11 +334,11 @@ func TestAntigravityAssemblyReplacesOnlyContentParts(t *testing.T) {
exec := &AntigravityExecutor{}
first := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hello"}]}}]}}`)
out := exec.convertStreamChunksToNonStream([][]byte{first, raw})
- if !gjson.ValidBytes(out) || gjson.GetBytes(out, "response.candidates.0.content.parts.0.text").String() != "hello" {
+ if !ojson.ValidBytes(out) || ojson.GetBytes(out, "response.candidates.0.content.parts.0.text").String() != "hello" {
t.Fatalf("assembled parts replaced the wrong value: %s", out)
}
- before := gjson.GetBytes(raw, "response.candidates.0")
- after := gjson.GetBytes(out, "response.candidates.0")
+ before := ojson.GetBytes(raw, "response.candidates.0")
+ after := ojson.GetBytes(out, "response.candidates.0")
if before.Get("groundingMetadata").Raw != after.Get("groundingMetadata").Raw ||
before.Get("marker").Raw != after.Get("marker").Raw {
t.Fatalf("assembly changed unrelated metadata: %s", out)
@@ -346,3 +346,25 @@ func TestAntigravityAssemblyReplacesOnlyContentParts(t *testing.T) {
})
}
}
+
+func TestAntigravityStreamPreservesQuotedFilterNoticeWithUsageMetadata(t *testing.T) {
+ quotedNotice := "This request was blocked by Gemini's filters. They can occasionally trigger by mistake on safe coding, security, or biology-related queries. Please try rephrasing your prompt. You can [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting#file-bug) or read more about [our policies here](https://policies.google.com/terms/generative-ai/use-policy)."
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w, `data: {"response":{"candidates":[{"content":{"parts":[{"text":"`+quotedNotice+`"}]}}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":40,"totalTokenCount":52}}}`+"\n\n")
+ if flusher, ok := w.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ }))
+ defer server.Close()
+
+ chunks := collectAntigravityStream(t, server.URL, sdktranslator.FormatGemini, sdktranslator.FormatGemini,
+ `{"contents":[{"role":"user","parts":[{"text":"explain the filter message"}]}]}`)
+ var joined bytes.Buffer
+ for _, chunk := range chunks {
+ joined.Write(chunk)
+ }
+ if !bytes.Contains(joined.Bytes(), []byte(quotedNotice)) {
+ t.Fatalf("expected quoted filter notice in stream output, got %s", joined.String())
+ }
+}
diff --git a/internal/runtime/executor/antigravity_executor_interactions_test.go b/internal/runtime/executor/antigravity_executor_interactions_test.go
index 93471f9eef7..7416a3e0aba 100644
--- a/internal/runtime/executor/antigravity_executor_interactions_test.go
+++ b/internal/runtime/executor/antigravity_executor_interactions_test.go
@@ -9,12 +9,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestAntigravityExecutorExecuteStreamInteractionsEmitsDoneAfterSplitTerminal(t *testing.T) {
@@ -103,7 +103,7 @@ consume:
t.Fatalf("interaction.completed count = %d, want 1. Stream: %q", got, joined)
}
for _, line := range bytes.Split(joined, []byte("\n")) {
- payload := gjson.GetBytes(bytes.TrimSpace(line), "interaction.usage.total_tokens")
+ payload := ojson.GetBytes(bytes.TrimSpace(line), "interaction.usage.total_tokens")
if payload.Exists() && payload.Int() != 33 {
t.Fatalf("completed total_tokens = %d, want 33. Stream: %q", payload.Int(), joined)
}
@@ -262,23 +262,23 @@ func TestAntigravityExecutorExecuteStreamTranslatesInteractionsRequest(t *testin
"request.generationConfig.thinkingLevel",
"request.generationConfig.thinkingSummaries",
} {
- if gjson.GetBytes(upstreamBody, path).Exists() {
+ if ojson.GetBytes(upstreamBody, path).Exists() {
t.Fatalf("%s should not be sent upstream: %s", path, string(upstreamBody))
}
}
- if gjson.GetBytes(upstreamBody, "input").Exists() {
+ if ojson.GetBytes(upstreamBody, "input").Exists() {
t.Fatalf("raw interactions input should not be sent upstream: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytes(upstreamBody, "request.contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
+ if got := ojson.GetBytes(upstreamBody, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
t.Fatalf("request.toolConfig.functionCallingConfig.mode = %q, want AUTO. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
+ if got := ojson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("request.generationConfig.thinkingConfig.thinkingLevel = %q, want high. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
+ if got := ojson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("request.generationConfig.thinkingConfig.includeThoughts = false, want true. Body: %s", string(upstreamBody))
}
}
diff --git a/internal/runtime/executor/antigravity_executor_request.go b/internal/runtime/executor/antigravity_executor_request.go
index bdb56aaacdb..2a7df38a20f 100644
--- a/internal/runtime/executor/antigravity_executor_request.go
+++ b/internal/runtime/executor/antigravity_executor_request.go
@@ -13,6 +13,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -20,13 +21,20 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, payload []byte, stream bool, alt, baseURL string, derivedSessionIDs ...string) (*http.Request, error) {
+ doc := ojson.NewDoc(payload)
+ httpReq, _, err := e.buildRequestDoc(ctx, auth, token, modelName, doc, stream, alt, baseURL, derivedSessionIDs...)
+ return httpReq, err
+}
+
+func (e *AntigravityExecutor) buildRequestDoc(ctx context.Context, auth *cliproxyauth.Auth, token, modelName string, doc *ojson.Doc, stream bool, alt, baseURL string, derivedSessionIDs ...string) (*http.Request, []byte, error) {
if token == "" {
- return nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
+ return nil, nil, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
+ }
+ if doc == nil {
+ doc = ojson.NewDoc(nil)
}
base := strings.TrimSuffix(baseURL, "/")
@@ -54,77 +62,47 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
projectID, errProject := e.projectIDForRequest(ctx, auth, token)
if errProject != nil {
- return nil, errProject
+ return nil, nil, errProject
}
- payload = geminiToAntigravity(modelName, payload, projectID, derivedSessionIDs...)
- payload, promptStructuredOutput := helps.PrepareAntigravityClaudeStructuredOutput(modelName, payload)
+ geminiToAntigravityDoc(doc, modelName, projectID, derivedSessionIDs...)
+ promptStructuredOutput := helps.PrepareAntigravityClaudeStructuredOutputDoc(modelName, doc)
// Cap maxOutputTokens to model's max_completion_tokens from registry
- if maxOut := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxOut.Exists() && maxOut.Type == gjson.Number {
+ if maxOut := doc.Get("request.generationConfig.maxOutputTokens"); maxOut.Exists() && maxOut.Type == ojson.Number {
if modelInfo := registry.LookupModelInfo(modelName, "antigravity"); modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
if int(maxOut.Int()) > modelInfo.MaxCompletionTokens {
- payload, _ = sjson.SetBytes(payload, "request.generationConfig.maxOutputTokens", modelInfo.MaxCompletionTokens)
+ _ = doc.Set("request.generationConfig.maxOutputTokens", modelInfo.MaxCompletionTokens)
}
}
}
isClaudeModel := strings.Contains(modelName, "claude")
useAntigravitySchema := isClaudeModel || strings.Contains(modelName, "gemini-3-pro") || strings.Contains(modelName, "gemini-3.1-pro")
- var (
- bodyReader io.Reader
- payloadLog []byte
- )
- if antigravityRequestNeedsSchemaSanitization(payload) {
- payloadStr := sanitizeAntigravityRequestSchemasForModel(string(payload), useAntigravitySchema, isClaudeModel)
-
- if isClaudeModel {
- if !promptStructuredOutput {
- updated, _ := sjson.SetBytes([]byte(payloadStr), "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
- payloadStr = string(updated)
- }
- } else {
- payloadStr, _ = sjson.Delete(payloadStr, "request.generationConfig.maxOutputTokens")
- }
-
- payloadStrBytes := applyAntigravityNativeSignatureReplayIfNeeded(modelName, []byte(payloadStr))
- payloadStrBytes = helps.FinalizePayload(ctx, payloadStrBytes)
- bodyReader = bytes.NewReader(payloadStrBytes)
- if e.cfg != nil && e.cfg.RequestLog {
- payloadLog = append([]byte(nil), payloadStrBytes...)
+ if antigravityRequestNeedsSchemaSanitizationDoc(doc) {
+ sanitizeAntigravityRequestSchemasForModelDoc(doc, useAntigravitySchema, isClaudeModel)
+ }
+ if isClaudeModel {
+ if !promptStructuredOutput {
+ _ = doc.Set("request.toolConfig.functionCallingConfig.mode", "VALIDATED")
}
} else {
- if isClaudeModel {
- if !promptStructuredOutput {
- payload, _ = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "VALIDATED")
- }
- } else {
- payload, _ = sjson.DeleteBytes(payload, "request.generationConfig.maxOutputTokens")
- }
-
- payload = applyAntigravityNativeSignatureReplayIfNeeded(modelName, payload)
- payload = helps.FinalizePayload(ctx, payload)
- bodyReader = bytes.NewReader(payload)
- if e.cfg != nil && e.cfg.RequestLog {
- payloadLog = append([]byte(nil), payload...)
- }
+ _ = doc.Delete("request.generationConfig.maxOutputTokens")
}
- // if useAntigravitySchema {
- // systemInstructionPartsResult := gjson.Get(payloadStr, "request.systemInstruction.parts")
- // payloadStr, _ = sjson.SetBytes([]byte(payloadStr), "request.systemInstruction.role", "user")
- // payloadStr, _ = sjson.SetBytes([]byte(payloadStr), "request.systemInstruction.parts.0.text", systemInstruction)
- // payloadStr, _ = sjson.SetBytes([]byte(payloadStr), "request.systemInstruction.parts.1.text", fmt.Sprintf("Please ignore following [ignore]%s[/ignore]", systemInstruction))
-
- // if systemInstructionPartsResult.Exists() && systemInstructionPartsResult.IsArray() {
- // for _, partResult := range systemInstructionPartsResult.Array() {
- // payloadStr, _ = sjson.SetRawBytes([]byte(payloadStr), "request.systemInstruction.parts.-1", []byte(partResult.Raw))
- // }
- // }
- // }
+ applyAntigravityNativeSignatureReplayIfNeededDoc(modelName, doc)
+ helps.FinalizePayloadDoc(ctx, doc)
+ finalPayload := doc.Bytes()
+ var (
+ bodyReader io.Reader = bytes.NewReader(finalPayload)
+ payloadLog []byte
+ )
+ if e.cfg != nil && e.cfg.RequestLog {
+ payloadLog = append([]byte(nil), finalPayload...)
+ }
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bodyReader)
if errReq != nil {
- return nil, errReq
+ return nil, nil, errReq
}
// Deliberately no httpReq.Close: the native Antigravity client omits the
// Connection header and keeps its HTTP/1.1 connections alive, so forcing
@@ -160,7 +138,7 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
AuthValue: authValue,
})
- return httpReq, nil
+ return httpReq, finalPayload, nil
}
// sanitizeAntigravityRequestSchemas cleans the JSON schemas carried by an Antigravity request.
@@ -186,6 +164,17 @@ func sanitizeAntigravityRequestSchemasForModel(payloadStr string, useAntigravity
return sanitizeAntigravityGenerationSchemas(payloadStr)
}
+func sanitizeAntigravityRequestSchemasForModelDoc(doc *ojson.Doc, useAntigravitySchema, isClaudeModel bool) bool {
+ if doc == nil {
+ return false
+ }
+ changed := sanitizeAntigravityToolSchemasForModelDoc(doc, useAntigravitySchema, isClaudeModel)
+ if sanitizeAntigravityGenerationSchemasDoc(doc) {
+ changed = true
+ }
+ return changed
+}
+
// sanitizeAntigravityToolSchemas applies the existing declaration rewrites to
// a small document containing only request.tools, then replaces that subtree
// once. This preserves rewrite order and bytes without copying the full request
@@ -195,18 +184,18 @@ func sanitizeAntigravityToolSchemas(payloadStr string, useAntigravitySchema bool
}
func sanitizeAntigravityToolSchemasForModel(payloadStr string, useAntigravitySchema, isClaudeModel bool) string {
- tools := gjson.Get(payloadStr, "request.tools")
+ tools := ojson.Get(payloadStr, "request.tools")
if !tools.IsArray() {
return payloadStr
}
toolDocument := `{"request":{"tools":` + tools.Raw + `}}`
toolDocument = sanitizeAntigravityToolSchemaDocumentForModel(toolDocument, useAntigravitySchema, isClaudeModel)
- cleanedTools := gjson.Get(toolDocument, "request.tools")
+ cleanedTools := ojson.Get(toolDocument, "request.tools")
if !cleanedTools.IsArray() || cleanedTools.Raw == tools.Raw {
return payloadStr
}
- updated, errSet := sjson.SetRawBytes([]byte(payloadStr), "request.tools", []byte(cleanedTools.Raw))
+ updated, errSet := ojson.SetRawBytes([]byte(payloadStr), "request.tools", []byte(cleanedTools.Raw))
if errSet != nil {
log.Debugf("antigravity: failed to write cleaned request.tools: %v", errSet)
return payloadStr
@@ -214,6 +203,28 @@ func sanitizeAntigravityToolSchemasForModel(payloadStr string, useAntigravitySch
return string(updated)
}
+func sanitizeAntigravityToolSchemasForModelDoc(doc *ojson.Doc, useAntigravitySchema, isClaudeModel bool) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("request.tools")
+ if !tools.IsArray() {
+ return false
+ }
+
+ toolDocument := `{"request":{"tools":` + tools.Raw + `}}`
+ toolDocument = sanitizeAntigravityToolSchemaDocumentForModel(toolDocument, useAntigravitySchema, isClaudeModel)
+ cleanedTools := ojson.Get(toolDocument, "request.tools")
+ if !cleanedTools.IsArray() || cleanedTools.Raw == tools.Raw {
+ return false
+ }
+ if errSet := doc.SetRawBytes("request.tools", []byte(cleanedTools.Raw)); errSet != nil {
+ log.Debugf("antigravity: failed to write cleaned request.tools: %v", errSet)
+ return false
+ }
+ return true
+}
+
func sanitizeAntigravityToolSchemaDocument(payloadStr string, useAntigravitySchema bool) string {
return sanitizeAntigravityToolSchemaDocumentForModel(payloadStr, useAntigravitySchema, false)
}
@@ -247,18 +258,19 @@ func sanitizeAntigravityToolSchemaDocumentForModel(payloadStr string, useAntigra
}, schemaRaw)
}
- tools := gjson.Parse(payloadStr).Get("request.tools")
+ tools := ojson.Parse(payloadStr).Get("request.tools")
if !tools.IsArray() {
return payloadStr
}
- elements := make([]gjson.Result, 0)
+ elements := make([]ojson.Result, 0)
edits := make([]antigravityElementEdit, 0)
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
elements = append(elements, tool)
if !tool.IsObject() {
return true
}
- toolRaw := tool.Raw
+ var toolDoc *ojson.Doc
+ toolChanged := false
for _, declKey := range antigravityDeclarationContainers {
decls := tool.Get(declKey)
if !decls.IsArray() {
@@ -268,22 +280,27 @@ func sanitizeAntigravityToolSchemaDocumentForModel(payloadStr string, useAntigra
if !declsChanged {
continue
}
- updated, errSet := sjson.SetRawBytes([]byte(toolRaw), declKey, []byte(cleanedDecls))
- if errSet != nil {
+ if toolDoc == nil {
+ toolDoc = ojson.NewDoc([]byte(tool.Raw))
+ }
+ if errSet := toolDoc.SetRawBytes(declKey, []byte(cleanedDecls)); errSet != nil {
log.Debugf("antigravity: failed to write cleaned %s: %v", declKey, errSet)
continue
}
- toolRaw = string(updated)
+ toolChanged = true
}
- if toolRaw != tool.Raw {
- edits = append(edits, antigravityElementEdit{offset: tool.Index - tools.Index, raw: tool.Raw, replacement: toolRaw})
+ if toolChanged && toolDoc != nil {
+ toolRaw := string(toolDoc.Bytes())
+ if toolRaw != tool.Raw {
+ edits = append(edits, antigravityElementEdit{offset: tool.Index - tools.Index, raw: tool.Raw, replacement: toolRaw})
+ }
}
return true
})
if len(edits) == 0 {
return payloadStr
}
- updated, errSet := sjson.SetRawBytes([]byte(payloadStr), "request.tools", []byte(applyAntigravityElementEdits(tools.Raw, tools.Index, elements, edits)))
+ updated, errSet := ojson.SetRawBytes([]byte(payloadStr), "request.tools", []byte(applyAntigravityElementEdits(tools.Raw, tools.Index, elements, edits)))
if errSet != nil {
log.Debugf("antigravity: failed to write cleaned request.tools: %v", errSet)
return payloadStr
@@ -306,7 +323,7 @@ type antigravityElementEdit struct {
// and semantically identical, just without the original separators. gjson
// reports element offsets into the parsed document, so offsets here are
// relative to the array start.
-func applyAntigravityElementEdits(arrayRaw string, arrayIndex int, elements []gjson.Result, edits []antigravityElementEdit) string {
+func applyAntigravityElementEdits(arrayRaw string, arrayIndex int, elements []ojson.Result, edits []antigravityElementEdit) string {
var rebuilt strings.Builder
rebuilt.Grow(len(arrayRaw))
cursor := 0
@@ -325,7 +342,7 @@ func applyAntigravityElementEdits(arrayRaw string, arrayIndex int, elements []gj
// joinAntigravityElements rebuilds an array by joining element bytes with
// commas, substituting replacements where recorded. It is the fallback for
// applyAntigravityElementEdits when splice offsets cannot be trusted.
-func joinAntigravityElements(arrayIndex int, elements []gjson.Result, edits []antigravityElementEdit) string {
+func joinAntigravityElements(arrayIndex int, elements []ojson.Result, edits []antigravityElementEdit) string {
replacements := make(map[int]string, len(edits))
for _, edit := range edits {
replacements[edit.offset] = edit.replacement
@@ -350,10 +367,10 @@ func joinAntigravityElements(arrayIndex int, elements []gjson.Result, edits []an
// the rename and schema cleaning to each declaration in isolation. It
// reports whether any declaration actually changed so callers can keep the
// original bytes when the array is already clean.
-func sanitizeAntigravityDeclarations(decls gjson.Result, clean func(schemaRaw string, isParameterSchema bool) string) (string, bool) {
- elements := make([]gjson.Result, 0)
+func sanitizeAntigravityDeclarations(decls ojson.Result, clean func(schemaRaw string, isParameterSchema bool) string) (string, bool) {
+ elements := make([]ojson.Result, 0)
edits := make([]antigravityElementEdit, 0)
- decls.ForEach(func(_, decl gjson.Result) bool {
+ decls.ForEach(func(_, decl ojson.Result) bool {
elements = append(elements, decl)
if !decl.IsObject() {
return true
@@ -374,7 +391,7 @@ func sanitizeAntigravityDeclarations(decls gjson.Result, clean func(schemaRaw st
// used to run against the whole tools document: parametersJsonSchema is
// renamed onto parameters first, then every object schema is cleaned in
// place. Failed edits keep the declaration as-is and are only logged.
-func sanitizeAntigravityDeclaration(decl gjson.Result, clean func(schemaRaw string, isParameterSchema bool) string) string {
+func sanitizeAntigravityDeclaration(decl ojson.Result, clean func(schemaRaw string, isParameterSchema bool) string) string {
declRaw := decl.Raw
if decl.Get("parametersJsonSchema").Exists() {
renamed, errRename := util.RenameKey(declRaw, "parametersJsonSchema", "parameters")
@@ -382,9 +399,11 @@ func sanitizeAntigravityDeclaration(decl gjson.Result, clean func(schemaRaw stri
log.Debugf("antigravity: failed to rename parametersJsonSchema: %v", errRename)
} else {
declRaw = renamed
- decl = gjson.Parse(declRaw)
+ decl = ojson.Parse(declRaw)
}
}
+ var declDoc *ojson.Doc
+ changed := false
for _, key := range antigravityDeclarationSchemaKeys {
schema := decl.Get(key)
if !schema.IsObject() {
@@ -394,13 +413,17 @@ func sanitizeAntigravityDeclaration(decl gjson.Result, clean func(schemaRaw stri
if cleanedSchema == schema.Raw {
continue
}
- updated, errSet := sjson.SetRawBytes([]byte(declRaw), key, []byte(cleanedSchema))
- if errSet != nil {
+ if declDoc == nil {
+ declDoc = ojson.NewDoc([]byte(declRaw))
+ }
+ if errSet := declDoc.SetRawBytes(key, []byte(cleanedSchema)); errSet != nil {
log.Debugf("antigravity: failed to write cleaned schema at %s: %v", key, errSet)
continue
}
- declRaw = string(updated)
- decl = gjson.Parse(declRaw)
+ changed = true
+ }
+ if changed && declDoc != nil {
+ return string(declDoc.Bytes())
}
return declRaw
}
@@ -408,14 +431,27 @@ func sanitizeAntigravityDeclaration(decl gjson.Result, clean func(schemaRaw stri
// sanitizeAntigravityGenerationSchemas batches every schema edit within one
// generation config before replacing that config in the full request.
func sanitizeAntigravityGenerationSchemas(payloadStr string) string {
+ doc := ojson.NewDoc([]byte(payloadStr))
+ if !sanitizeAntigravityGenerationSchemasDoc(doc) {
+ return payloadStr
+ }
+ return string(doc.Bytes())
+}
+
+func sanitizeAntigravityGenerationSchemasDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
for _, container := range antigravityGenerationConfigContainers {
- generationConfig := gjson.Get(payloadStr, container)
+ generationConfig := doc.Get(container)
if !generationConfig.IsObject() {
continue
}
- cleanedConfig := generationConfig.Raw
+ var configDoc *ojson.Doc
+ configChanged := false
for _, key := range antigravityGenerationSchemaKeys {
- schema := gjson.Get(cleanedConfig, key)
+ schema := generationConfig.Get(key)
if !schema.IsObject() {
continue
}
@@ -423,24 +459,29 @@ func sanitizeAntigravityGenerationSchemas(payloadStr string) string {
if cleanedSchema == schema.Raw {
continue
}
- updated, errSet := sjson.SetRawBytes([]byte(cleanedConfig), key, []byte(cleanedSchema))
- if errSet != nil {
+ if configDoc == nil {
+ configDoc = ojson.NewDoc([]byte(generationConfig.Raw))
+ }
+ if errSet := configDoc.SetRawBytes(key, []byte(cleanedSchema)); errSet != nil {
log.Debugf("antigravity: failed to write cleaned schema at %s.%s: %v", container, key, errSet)
continue
}
- cleanedConfig = string(updated)
+ configChanged = true
+ }
+ if !configChanged || configDoc == nil {
+ continue
}
- if cleanedConfig == generationConfig.Raw {
+ cleanedConfig := configDoc.Bytes()
+ if string(cleanedConfig) == generationConfig.Raw {
continue
}
- updated, errSet := sjson.SetRawBytes([]byte(payloadStr), container, []byte(cleanedConfig))
- if errSet != nil {
+ if errSet := doc.SetRawBytes(container, cleanedConfig); errSet != nil {
log.Debugf("antigravity: failed to write cleaned %s: %v", container, errSet)
continue
}
- payloadStr = string(updated)
+ changed = true
}
- return payloadStr
+ return changed
}
// isParameterSchemaPath reports whether a declaration schema path holds tool input
@@ -454,8 +495,10 @@ func isParameterSchemaPath(schemaPath string) bool {
}
func cleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, clean func(string) string) string {
+ var doc *ojson.Doc
+ changed := false
for _, schemaPath := range schemaPaths {
- schema := gjson.Get(payloadStr, schemaPath)
+ schema := ojson.Get(payloadStr, schemaPath)
if !schema.Exists() {
continue
}
@@ -463,14 +506,19 @@ func cleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, cle
if cleanedSchema == schema.Raw {
continue
}
- updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(cleanedSchema))
- if errSet != nil {
+ if doc == nil {
+ doc = ojson.NewDoc([]byte(payloadStr))
+ }
+ if errSet := doc.SetRawBytes(schemaPath, []byte(cleanedSchema)); errSet != nil {
log.Debugf("antigravity: failed to write cleaned schema at %s: %v", schemaPath, errSet)
continue
}
- payloadStr = string(updated)
+ changed = true
+ }
+ if !changed || doc == nil {
+ return payloadStr
}
- return payloadStr
+ return string(doc.Bytes())
}
// antigravitySchemaWrapperKey nests a schema during cleaning. It is never sent upstream.
@@ -483,11 +531,11 @@ const antigravitySchemaWrapperKey = "schema"
// cleaning always saw tool schemas nested inside the request, so nesting is reproduced here to keep
// the emitted schema byte-identical to the previous behaviour.
func cleanNestedSchema(clean func(string) string, schemaRaw string) string {
- wrapped, errWrap := sjson.SetRaw("{}", antigravitySchemaWrapperKey, schemaRaw)
+ wrapped, errWrap := ojson.SetRaw("{}", antigravitySchemaWrapperKey, schemaRaw)
if errWrap != nil {
return clean(schemaRaw)
}
- if unwrapped := gjson.Get(clean(wrapped), antigravitySchemaWrapperKey); unwrapped.Exists() {
+ if unwrapped := ojson.Get(clean(wrapped), antigravitySchemaWrapperKey); unwrapped.Exists() {
return unwrapped.Raw
}
return clean(schemaRaw)
@@ -497,7 +545,7 @@ func cleanNestedSchema(clean func(string) string, schemaRaw string) string {
// Both the camelCase and snake_case spellings are accepted because callers reach this executor
// through different translators.
func antigravityFunctionDeclarationPaths(payloadStr string) []string {
- tools := gjson.Get(payloadStr, "request.tools")
+ tools := ojson.Get(payloadStr, "request.tools")
if !tools.IsArray() {
return nil
}
@@ -528,7 +576,7 @@ func antigravityDeclarationSchemaPaths(payloadStr string) []string {
paths := make([]string, 0, 8)
for _, base := range antigravityFunctionDeclarationPaths(payloadStr) {
for _, key := range antigravityDeclarationSchemaKeys {
- if gjson.Get(payloadStr, base+"."+key).IsObject() {
+ if ojson.Get(payloadStr, base+"."+key).IsObject() {
paths = append(paths, base+"."+key)
}
}
@@ -541,7 +589,7 @@ func antigravityGenerationSchemaPaths(payloadStr string) []string {
for _, container := range antigravityGenerationConfigContainers {
for _, key := range antigravityGenerationSchemaKeys {
path := container + "." + key
- if gjson.Get(payloadStr, path).IsObject() {
+ if ojson.Get(payloadStr, path).IsObject() {
paths = append(paths, path)
}
}
@@ -568,12 +616,29 @@ var (
)
func antigravityRequestNeedsSchemaSanitization(payload []byte) bool {
- if gjson.GetBytes(payload, "request.tools.0").Exists() {
+ if ojson.GetBytesNoCopy(payload, "request.tools.0").Exists() {
+ return true
+ }
+ for _, container := range antigravityGenerationConfigContainers {
+ for _, key := range antigravityGenerationSchemaKeys {
+ if ojson.GetBytesNoCopy(payload, container+"."+key).Exists() {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func antigravityRequestNeedsSchemaSanitizationDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ if doc.Get("request.tools.0").Exists() {
return true
}
for _, container := range antigravityGenerationConfigContainers {
for _, key := range antigravityGenerationSchemaKeys {
- if gjson.GetBytes(payload, container+"."+key).Exists() {
+ if doc.Get(container + "." + key).Exists() {
return true
}
}
@@ -651,47 +716,54 @@ func resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string {
}
func geminiToAntigravity(modelName string, payload []byte, projectID string, derivedSessionIDs ...string) []byte {
- template := bytes.Clone(payload)
- template = helps.SetStringIfDifferent(template, "model", modelName)
- template = helps.SetStringIfDifferent(template, "userAgent", "antigravity")
+ doc := ojson.NewDoc(payload)
+ geminiToAntigravityDoc(doc, modelName, projectID, derivedSessionIDs...)
+ return doc.Bytes()
+}
+
+func geminiToAntigravityDoc(doc *ojson.Doc, modelName string, projectID string, derivedSessionIDs ...string) {
+ if doc == nil {
+ return
+ }
+ helps.SetStringIfDifferentDoc(doc, "model", modelName)
+ helps.SetStringIfDifferentDoc(doc, "userAgent", "antigravity")
isImageModel := strings.Contains(modelName, "image")
- reqType := strings.TrimSpace(gjson.GetBytes(template, "requestType").String())
+ reqType := strings.TrimSpace(doc.Get("requestType").String())
if reqType == "" {
if isImageModel {
reqType = "image_gen"
} else {
reqType = "agent"
}
- template, _ = sjson.SetBytes(template, "requestType", reqType)
+ _ = doc.Set("requestType", reqType)
}
if projectID != "" {
- template = helps.SetStringIfDifferent(template, "project", projectID)
+ helps.SetStringIfDifferentDoc(doc, "project", projectID)
} else {
- template, _ = sjson.DeleteBytes(template, "project")
+ _ = doc.Delete("project")
}
if isImageModel {
- template, _ = sjson.SetBytes(template, "requestId", generateImageGenRequestID())
+ _ = doc.Set("requestId", generateImageGenRequestID())
} else if reqType != "web_search" {
- template, _ = sjson.SetBytes(template, "requestId", generateRequestID())
- sessionID := strings.TrimSpace(gjson.GetBytes(template, "request.sessionId").String())
+ _ = doc.Set("requestId", generateRequestID())
+ sessionID := strings.TrimSpace(doc.Get("request.sessionId").String())
if sessionID == "" && len(derivedSessionIDs) > 0 {
sessionID = strings.TrimSpace(derivedSessionIDs[0])
}
if sessionID == "" {
- sessionID = generateStableSessionID(payload)
+ sessionID = generateStableSessionIDDoc(doc)
}
- template, _ = sjson.SetBytes(template, "request.sessionId", sessionID)
+ _ = doc.Set("request.sessionId", sessionID)
}
- template, _ = sjson.DeleteBytes(template, "request.safetySettings")
- if toolConfig := gjson.GetBytes(template, "toolConfig"); toolConfig.Exists() && !gjson.GetBytes(template, "request.toolConfig").Exists() {
- template, _ = sjson.SetRawBytes(template, "request.toolConfig", []byte(toolConfig.Raw))
- template, _ = sjson.DeleteBytes(template, "toolConfig")
+ _ = doc.Delete("request.safetySettings")
+ if toolConfig := doc.Get("toolConfig"); toolConfig.Exists() && !doc.Get("request.toolConfig").Exists() {
+ _ = doc.SetRawBytes("request.toolConfig", []byte(toolConfig.Raw))
+ _ = doc.Delete("toolConfig")
}
- return template
}
func generateRequestID() string {
@@ -710,13 +782,23 @@ func generateSessionID() string {
}
func generateStableSessionID(payload []byte) string {
- contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
+ return generateStableSessionIDFromContents(util.GetGJSONBytesNoCopy(payload, "request.contents"))
+}
+
+func generateStableSessionIDDoc(doc *ojson.Doc) string {
+ if doc == nil {
+ return generateSessionID()
+ }
+ return generateStableSessionIDFromContents(doc.Get("request.contents"))
+}
+
+func generateStableSessionIDFromContents(contents ojson.Result) string {
if !contents.IsArray() {
return generateSessionID()
}
stableID := ""
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
if content.Get("role").String() != "user" {
return true
}
diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go
index c097ae7bea9..bca7bc24bfe 100644
--- a/internal/runtime/executor/antigravity_executor_signature_test.go
+++ b/internal/runtime/executor/antigravity_executor_signature_test.go
@@ -3,9 +3,9 @@ package executor
import (
"bytes"
"context"
- "encoding/base64"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
@@ -13,6 +13,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -23,8 +24,6 @@ import (
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -59,7 +58,7 @@ func issue4959ResponsesModelFirstPayload() []byte {
`]}`)
}
-func contentHasNamedPart(content gjson.Result, partKind, name string) bool {
+func contentHasNamedPart(content ojson.Result, partKind, name string) bool {
for _, part := range content.Get("parts").Array() {
if part.Get(partKind+".name").String() == name {
return true
@@ -68,7 +67,7 @@ func contentHasNamedPart(content gjson.Result, partKind, name string) bool {
return false
}
-func assertIssue4959LeadingUserContents(t *testing.T, contents []gjson.Result) {
+func assertIssue4959LeadingUserContents(t *testing.T, contents []ojson.Result) {
t.Helper()
if len(contents) < 3 {
t.Fatalf("contents too short: %d", len(contents))
@@ -169,22 +168,23 @@ func TestSanitizeAntigravityGeminiRequestSignaturesFinalizesParallelCalls(t *tes
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}}},{"functionCall":{"name":"second","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"first","response":{"result":"ok"}}},{"functionResponse":{"name":"second","response":{"result":"ok"}}}]}]}}`)
+ payloadDoc := ojson.NewDoc([]byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}}},{"functionCall":{"name":"second","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"first","response":{"result":"ok"}}},{"functionResponse":{"name":"second","response":{"result":"ok"}}}]}]}}`))
if tt.firstSignature != "" {
- payload, _ = sjson.SetBytes(payload, "request.contents.0.parts.0.thoughtSignature", tt.firstSignature)
+ _ = payloadDoc.Set("request.contents.0.parts.0.thoughtSignature", tt.firstSignature)
}
if tt.secondSignature != "" {
- payload, _ = sjson.SetBytes(payload, "request.contents.0.parts.1.thoughtSignature", tt.secondSignature)
+ _ = payloadDoc.Set("request.contents.0.parts.1.thoughtSignature", tt.secondSignature)
}
+ payload := payloadDoc.Bytes()
output := sanitizeAntigravityGeminiRequestSignatures("gemini-3.5-flash", payload)
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != tt.wantFirstSignature {
+ if got := ojson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != tt.wantFirstSignature {
t.Fatalf("first signature = %q, want %q; output=%s", got, tt.wantFirstSignature, output)
}
- if signature := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("second parallel call should remain unsigned; output=%s", output)
}
- if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "model" {
+ if got := ojson.GetBytes(output, "request.contents.1.role").String(); got != "model" {
t.Fatalf("functionResponse role = %q, want native Antigravity model role; output=%s", got, output)
}
})
@@ -205,12 +205,12 @@ func TestAntigravityGeminiSignatureNormalizationDoesNotRepeatLogs(t *testing.T)
}
working = sanitizeAntigravityGeminiRequestSignatures(translationReq.Model, working)
- if got := gjson.GetBytes(working, "request.contents.0.parts.0.thoughtSignature").String(); got != issue4959GeminiThoughtSignature() {
+ if got := ojson.GetBytes(working, "request.contents.0.parts.0.thoughtSignature").String(); got != issue4959GeminiThoughtSignature() {
t.Fatalf("native signature changed: %s", working)
}
for _, body := range [][]byte{original, working} {
for _, index := range []int{1, 2} {
- if gjson.GetBytes(body, fmt.Sprintf("request.contents.0.parts.%d.thoughtSignature", index)).Exists() {
+ if ojson.GetBytes(body, fmt.Sprintf("request.contents.0.parts.%d.thoughtSignature", index)).Exists() {
t.Fatalf("sibling bypass was not removed: %s", body)
}
}
@@ -236,10 +236,10 @@ func TestAntigravitySensitiveWordsObfuscatesSystemInstructionOnly(t *testing.T)
payload := []byte(`{"request":{"systemInstruction":{"parts":[{"text":"Use proxy safely"}]},"contents":[{"role":"user","parts":[{"text":"proxy remains unchanged"}]}]}}`)
got := executor.obfuscateSensitiveWords(payload)
- if systemText := gjson.GetBytes(got, "request.systemInstruction.parts.0.text").String(); systemText != "Use p\u200Broxy safely" {
+ if systemText := ojson.GetBytes(got, "request.systemInstruction.parts.0.text").String(); systemText != "Use p\u200Broxy safely" {
t.Fatalf("system instruction = %q, want zero-width obfuscation", systemText)
}
- if contentText := gjson.GetBytes(got, "request.contents.0.parts.0.text").String(); contentText != "proxy remains unchanged" {
+ if contentText := ojson.GetBytes(got, "request.contents.0.parts.0.text").String(); contentText != "proxy remains unchanged" {
t.Fatalf("content text = %q, want unchanged", contentText)
}
}
@@ -287,7 +287,7 @@ func TestAntigravityStreamObfuscatesSensitiveSystemInstruction(t *testing.T) {
}
body := <-captured
- got := gjson.GetBytes(body, "request.systemInstruction.parts.0.text").String()
+ got := ojson.GetBytes(body, "request.systemInstruction.parts.0.text").String()
want := "You are H\u200Bermes Agent, an intelligent AI assistant created by N\u200Bous Research."
if got != want {
t.Fatalf("system instruction = %q, want %q; body=%s", got, want, body)
@@ -343,7 +343,7 @@ func TestAntigravityStreamDoesNotEmitSyntheticTerminalOnReadError(t *testing.T)
streamErr = chunk.Err
continue
}
- if finishReason := gjson.GetBytes(chunk.Payload, "candidates.0.finishReason").String(); finishReason == "STOP" {
+ if finishReason := ojson.GetBytes(chunk.Payload, "candidates.0.finishReason").String(); finishReason == "STOP" {
t.Fatalf("read error must not emit synthetic STOP: %s", chunk.Payload)
}
}
@@ -355,7 +355,7 @@ func TestAntigravityStreamDoesNotEmitSyntheticTerminalOnReadError(t *testing.T)
func TestAntigravityStreamPrependsLeadingUserForGemini(t *testing.T) {
assertLeadingFunctionHistory := func(t *testing.T, body []byte) {
t.Helper()
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) != 3 || contents[0].Get("role").String() != "user" {
t.Fatalf("upstream roles malformed: %s", body)
}
@@ -484,7 +484,7 @@ func TestAntigravityStreamPrependsLeadingUserForIssue4959ResponsesHistory(t *tes
t.Fatalf("stream chunk error = %v", chunk.Err)
}
}
- assertIssue4959LeadingUserContents(t, gjson.GetBytes(<-captured, "request.contents").Array())
+ assertIssue4959LeadingUserContents(t, ojson.GetBytes(<-captured, "request.contents").Array())
}
func issue5358ResponsesTrailingReasoningPayload() []byte {
@@ -506,7 +506,7 @@ func TestAntigravityStreamAppendsTrailingUserForGeminiTrailingModelTurn(t *testi
}
captured <- body
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) > 0 {
lastRole := contents[len(contents)-1].Get("role").String()
if lastRole == "model" || lastRole == "assistant" {
@@ -541,7 +541,7 @@ func TestAntigravityStreamAppendsTrailingUserForGeminiTrailingModelTurn(t *testi
}
}
body := <-captured
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) == 0 {
t.Fatalf("request.contents empty; body=%s", body)
}
@@ -562,7 +562,7 @@ func issue6524ResponsesTrailingToolOutputPayload(output string) []byte {
`],"tools":[{"type":"function","name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}]}`)
}
-func issue6524CaptureStreamContents(t *testing.T, payload []byte) []gjson.Result {
+func issue6524CaptureStreamContents(t *testing.T, payload []byte) []ojson.Result {
t.Helper()
captured := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -597,7 +597,7 @@ func issue6524CaptureStreamContents(t *testing.T, payload []byte) []gjson.Result
}
}
body := <-captured
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) < 2 {
t.Fatalf("request.contents too short; body=%s", body)
}
@@ -678,7 +678,7 @@ func TestAntigravityStreamPrependsLeadingUserAfterReplayInsertsFunctionCall(t *t
}
body := <-captured
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents len = %d, want 3; body=%s", len(contents), body)
}
@@ -754,7 +754,7 @@ func TestAntigravityStreamDoesNotPrependLeadingUserForClaudeTarget(t *testing.T)
}
body := <-captured
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" {
t.Fatalf("Claude target history changed: %s", body)
}
@@ -901,7 +901,7 @@ func TestAntigravityCountTokensMatchesTargetLeadingUserPolicy(t *testing.T) {
t.Fatalf("CountTokens() error = %v", errCount)
}
- contents := gjson.GetBytes(upstreamBody, "request.contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "request.contents").Array()
roles := make([]string, 0, len(contents))
for _, content := range contents {
roles = append(roles, content.Get("role").String())
@@ -957,16 +957,16 @@ func TestAntigravityExecutorCountTokensSanitizesGeminiToolHistory(t *testing.T)
if len(upstreamBody) == 0 {
t.Fatal("countTokens upstream body was not captured")
}
- if got := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0.thoughtSignature").String(); got != nativeSignature {
+ if got := ojson.GetBytes(upstreamBody, "request.contents.1.parts.0.thoughtSignature").String(); got != nativeSignature {
t.Fatalf("first call signature = %q, want native signature; body=%s", got, upstreamBody)
}
- if signature := gjson.GetBytes(upstreamBody, "request.contents.1.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(upstreamBody, "request.contents.1.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("second sibling bypass was not removed: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "request.contents.2.role").String(); got != "model" {
+ if got := ojson.GetBytes(upstreamBody, "request.contents.2.role").String(); got != "model" {
t.Fatalf("functionResponse role = %q, want model; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "request.contents.2.parts.0.functionResponse.id").String(); got != "call-1" {
+ if got := ojson.GetBytes(upstreamBody, "request.contents.2.parts.0.functionResponse.id").String(); got != "call-1" {
t.Fatalf("first functionResponse.id = %q, want call-1; body=%s", got, upstreamBody)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(upstreamBody); errPairing != nil {
@@ -1019,15 +1019,15 @@ func TestAntigravityExecutorCountTokensReconstructsCompactedClaudeToolCall(t *te
if len(upstreamBody) == 0 {
t.Fatal("countTokens upstream body was not captured")
}
- leadingText := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.text")
- if gjson.GetBytes(upstreamBody, "request.contents.0.role").String() != "user" || !leadingText.Exists() || leadingText.String() != "" {
+ leadingText := ojson.GetBytes(upstreamBody, "request.contents.0.parts.0.text")
+ if ojson.GetBytes(upstreamBody, "request.contents.0.role").String() != "user" || !leadingText.Exists() || leadingText.String() != "" {
t.Fatalf("synthetic leading user missing after replay insert: %s", upstreamBody)
}
- call := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0")
+ call := ojson.GetBytes(upstreamBody, "request.contents.1.parts.0")
if call.Get("functionCall.id").String() != nativeID || call.Get("functionCall.name").String() != "Bash" || call.Get("thoughtSignature").String() != nativeSignature {
t.Fatalf("native function call provenance was not reconstructed: %s", upstreamBody)
}
- response := gjson.GetBytes(upstreamBody, "request.contents.2.parts.0.functionResponse")
+ response := ojson.GetBytes(upstreamBody, "request.contents.2.parts.0.functionResponse")
if response.Get("id").String() != nativeID || response.Get("name").String() != "Bash" {
t.Fatalf("native function response provenance was not reconstructed: %s", upstreamBody)
}
@@ -1045,10 +1045,10 @@ func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersMixedParallelRespo
t.Fatal("permuted input unexpectedly passed function call pairing validation")
}
output := normalizeAntigravityGeminiFunctionResponseRoles(payload)
- if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(output, "request.contents.1.role").String(); got != "user" {
t.Fatalf("mixed functionResponse/user content role = %q, want user; output=%s", got, output)
}
- parts := gjson.GetBytes(output, "request.contents.1.parts").Array()
+ parts := ojson.GetBytes(output, "request.contents.1.parts").Array()
if len(parts) != 4 {
t.Fatalf("mixed response parts = %d, want 4; output=%s", len(parts), output)
}
@@ -1072,13 +1072,13 @@ func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersMixedParallelRespo
func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersParallelResponses(t *testing.T) {
payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"read","args":{"file":"one"}}},{"functionCall":{"id":"call-2","name":"read","args":{"file":"two"}}}]},{"role":" Model ","parts":[{"functionResponse":{"id":"call-2","name":"read","response":{"result":"two"}}},{"functionResponse":{"id":"call-1","name":"read","response":{"result":"one"}}}]}]}}`)
output := normalizeAntigravityGeminiFunctionResponseRoles(payload)
- if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "model" {
+ if got := ojson.GetBytes(output, "request.contents.1.role").String(); got != "model" {
t.Fatalf("functionResponse role = %q, want model; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "request.contents.1.parts.0.functionResponse.id").String(); got != "call-1" {
+ if got := ojson.GetBytes(output, "request.contents.1.parts.0.functionResponse.id").String(); got != "call-1" {
t.Fatalf("first functionResponse.id = %q, want call-1; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "request.contents.1.parts.1.functionResponse.id").String(); got != "call-2" {
+ if got := ojson.GetBytes(output, "request.contents.1.parts.1.functionResponse.id").String(); got != "call-2" {
t.Fatalf("second functionResponse.id = %q, want call-2; output=%s", got, output)
}
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(output); errValidate != nil {
@@ -1094,10 +1094,10 @@ func TestNormalizeAntigravityGeminiFunctionResponseRolesDoesNotCrossEmptyContent
} {
payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"read","args":{}}},{"functionCall":{"id":"call-2","name":"read","args":{}}}]},` + boundary + `,{"role":"user","parts":[{"functionResponse":{"id":"call-2","name":"read","response":{"result":"two"}}},{"functionResponse":{"id":"call-1","name":"read","response":{"result":"one"}}}]}]}}`)
output := normalizeAntigravityGeminiFunctionResponseRoles(payload)
- if got := gjson.GetBytes(output, "request.contents.2.role").String(); got != "model" {
+ if got := ojson.GetBytes(output, "request.contents.2.role").String(); got != "model" {
t.Fatalf("pure functionResponse role = %q, want model; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "request.contents.2.parts.0.functionResponse.id").String(); got != "call-2" {
+ if got := ojson.GetBytes(output, "request.contents.2.parts.0.functionResponse.id").String(); got != "call-2" {
t.Fatalf("response crossed content boundary %s and was reordered: first id=%q; output=%s", boundary, got, output)
}
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(output); errValidate == nil {
@@ -1118,7 +1118,7 @@ func TestAntigravityExecutor_GeminiTargetPreservesGeminiThinkingCarrier(t *testi
if err != nil {
t.Fatalf("validateAntigravityRequestSignatures() error = %v", err)
}
- content := gjson.GetBytes(output, "messages.0.content").Array()
+ content := ojson.GetBytes(output, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("content length = %d, want text plus valid Gemini carrier: %s", len(content), output)
}
@@ -1144,7 +1144,7 @@ func TestAntigravityExecutor_StrictBypassStripsInvalidSignature(t *testing.T) {
if err != nil {
t.Fatalf("strict bypass should strip invalid signatures instead of rejecting request: %v", err)
}
- parts := gjson.GetBytes(output, "messages.0.content").Array()
+ parts := ojson.GetBytes(output, "messages.0.content").Array()
if len(parts) != 1 {
t.Fatalf("content length = %d, want 1 after invalid thinking strip: %s", len(parts), output)
}
@@ -1223,7 +1223,7 @@ func TestClaudeExecutor_LogsSanitizedClaudeUpstreamSignatures(t *testing.T) {
}`)
output := sanitizeClaudeMessagesForClaudeUpstreamWithDebug(context.Background(), body, "claude-sonnet-4-5")
- parts := gjson.GetBytes(output, "messages.0.content").Array()
+ parts := ojson.GetBytes(output, "messages.0.content").Array()
if len(parts) != 2 {
t.Fatalf("content length = %d, want 2 after invalid thinking strip: %s", len(parts), output)
}
@@ -1321,13 +1321,13 @@ func TestAntigravityExecutorCountTokensStripsStatefulFields(t *testing.T) {
if len(upstreamBody) == 0 {
t.Fatal("countTokens upstream body was not captured")
}
- if gjson.GetBytes(upstreamBody, "request.toolConfig").Exists() {
+ if ojson.GetBytes(upstreamBody, "request.toolConfig").Exists() {
t.Fatalf("upstream countTokens body retained request.toolConfig: %s", upstreamBody)
}
- if gjson.GetBytes(upstreamBody, "request.labels").Exists() {
+ if ojson.GetBytes(upstreamBody, "request.labels").Exists() {
t.Fatalf("upstream countTokens body retained request.labels: %s", upstreamBody)
}
- if gjson.GetBytes(upstreamBody, "request.sessionId").Exists() {
+ if ojson.GetBytes(upstreamBody, "request.sessionId").Exists() {
t.Fatalf("upstream countTokens body retained request.sessionId: %s", upstreamBody)
}
}
diff --git a/internal/runtime/executor/antigravity_executor_split_usage_test.go b/internal/runtime/executor/antigravity_executor_split_usage_test.go
index 98e0524b3ec..eff7359c62c 100644
--- a/internal/runtime/executor/antigravity_executor_split_usage_test.go
+++ b/internal/runtime/executor/antigravity_executor_split_usage_test.go
@@ -8,12 +8,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type antigravityUsageCapture struct {
@@ -90,7 +90,7 @@ func TestAntigravityStreamFinalizesSplitTerminalUsageOnce(t *testing.T) {
if chunk.Err != nil {
t.Fatalf("unexpected stream error: %v", chunk.Err)
}
- if reason := gjson.GetBytes(chunk.Payload, "choices.0.finish_reason").String(); reason != "" {
+ if reason := ojson.GetBytes(chunk.Payload, "choices.0.finish_reason").String(); reason != "" {
if finishIndex >= 0 {
t.Fatalf("more than one terminal chunk: %s", chunk.Payload)
}
@@ -109,10 +109,10 @@ func TestAntigravityStreamFinalizesSplitTerminalUsageOnce(t *testing.T) {
t.Fatalf("terminal chunk at index %d of %d chunks, want the last one", finishIndex, len(chunks))
}
terminal := chunks[finishIndex]
- if got := gjson.GetBytes(terminal, "usage.total_tokens").Int(); got != 33 {
+ if got := ojson.GetBytes(terminal, "usage.total_tokens").Int(); got != 33 {
t.Fatalf("terminal total_tokens = %d, want 33", got)
}
- if got := gjson.GetBytes(terminal, "usage.prompt_tokens").Int(); got != 11 {
+ if got := ojson.GetBytes(terminal, "usage.prompt_tokens").Int(); got != 11 {
t.Fatalf("terminal prompt_tokens = %d, want 11", got)
}
}
diff --git a/internal/runtime/executor/antigravity_executor_stream.go b/internal/runtime/executor/antigravity_executor_stream.go
index 4b819b7b338..9be7ccf93ce 100644
--- a/internal/runtime/executor/antigravity_executor_stream.go
+++ b/internal/runtime/executor/antigravity_executor_stream.go
@@ -11,6 +11,8 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -18,8 +20,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ExecuteStream performs a streaming request to the Antigravity API.
@@ -91,21 +91,16 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
reporter.UpdateAccessTokenFingerprint(auth)
}
- translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(translated)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return nil, err
}
finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, "antigravity", "request", originalTranslated, req, opts)
- ctx = helps.WithPayloadFinalizer(ctx, func(body []byte) []byte {
- out := finalize(body)
- reporter.SetTranslatedRequestMetadata(out, to.String())
- return out
- })
- translated = e.obfuscateSensitiveWords(translated)
- translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated)
- translated, _ = sjson.DeleteBytes(translated, "request.stream")
- reporter.SetTranslatedReasoningEffort(translated, to.String())
+ ctx = helps.WithPayloadFinalizer(ctx, finalize)
+ e.obfuscateSensitiveWordsDoc(doc)
+ sanitizeAntigravityGeminiRequestSignaturesDoc(baseModel, doc)
+ _ = doc.Delete("request.stream")
useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg)
@@ -115,28 +110,32 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
// Credential retry rounds are owned by the conductor. Perform one upstream
// request per credential so request-retry is not consumed twice.
- requestPayload := translated
if useCredits {
- if cp := injectEnabledCreditTypes(translated); len(cp) > 0 {
- requestPayload = cp
+ if injectEnabledCreditTypesDoc(doc) {
helps.MarkCreditsUsed(ctx)
}
}
replayScope := antigravityReasoningReplayScope{}
if antigravityUsesReasoningReplayCache(baseModel) {
var errReplay error
- requestPayload, replayScope, errReplay = prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, requestPayload)
+ replayScope, _, errReplay = prepareAntigravityGeminiReasoningReplayPayloadDoc(ctx, baseModel, req, opts, doc)
if errReplay != nil {
err = errReplay
return nil, err
}
}
- requestPayload = ensureAntigravityGeminiBoundaryUserContent(baseModel, requestPayload)
- httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
+ ensureAntigravityGeminiBoundaryUserContentDoc(baseModel, doc)
+ var replayRequestPayload []byte
+ if replayScope.valid() {
+ replayRequestPayload, _ = antigravityReplayRequestEnvelopeFromDoc(doc)
+ }
+ httpReq, requestPayload, errReq := e.buildRequestDoc(ctx, auth, token, baseModel, doc, true, opts.Alt, baseURL, helps.DerivedAntigravitySessionID(opts.Metadata, req.Metadata))
if errReq != nil {
err = errReq
return nil, err
}
+ translated = requestPayload
+ reporter.SetTranslatedRequestMetadata(requestPayload, to.String())
httpResp, errDo := helps.WithAntigravityHTTPClientTrace(httpClient, auth, "generate_stream").Do(httpReq)
if errDo != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
@@ -200,7 +199,10 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
if useCredits {
clearAntigravityCreditsFailureState(auth)
}
- replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, requestPayload)
+ if len(replayRequestPayload) == 0 {
+ replayRequestPayload = requestPayload
+ }
+ replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, replayRequestPayload)
out := make(chan cliproxyexecutor.StreamChunk)
stopDeliverySupport := usage.SupportStreamDelivery(ctx)
go func(resp *http.Response) {
@@ -260,6 +262,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
streamUsage.Publish(ctx, reporter)
reporter.EnsurePublished(ctx)
}()
+ var pendingFilterPayload []byte
+ var pendingFilterMsg string
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, ¶m)
for scanner.Scan() {
line := scanner.Bytes()
@@ -271,10 +275,6 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
// Capture accounting before the client-facing filter renames usage.
streamUsage.ObserveAntigravityStream(line)
- // Filter usage metadata for all models
- // Only retain usage statistics in the terminal chunk
- line = helps.FilterSSEUsageMetadata(line)
-
payload := helps.JSONPayload(line)
if len(pendingJSON) > 0 {
trimmedLine := bytes.TrimSpace(line)
@@ -285,19 +285,19 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
pendingJSON = append(pendingJSON, '\n')
pendingJSON = append(pendingJSON, trimmedLine...)
}
- if !gjson.ValidBytes(pendingJSON) {
+ if !ojson.ValidBytes(pendingJSON) {
continue
}
payload = pendingJSON
pendingJSON = nil
- } else if payload != nil && !gjson.ValidBytes(payload) {
+ } else if payload != nil && !ojson.ValidBytes(payload) {
pendingJSON = append([]byte(nil), payload...)
continue
}
if payload == nil {
continue
}
- if errorResult := gjson.GetBytes(payload, "error"); errorResult.Exists() {
+ if errorResult := ojson.GetBytesNoCopy(payload, "error"); errorResult.Exists() {
statusCode := int(errorResult.Get("code").Int())
if statusCode < http.StatusBadRequest || statusCode > 599 {
statusCode = http.StatusBadGateway
@@ -311,71 +311,101 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
}
return
}
- reporter.ObserveResponseModel(payload)
+ processPayload := func(p []byte) bool {
+ // Filter usage metadata for all models after block classification
+ // so only the terminal chunk retains client-visible usage statistics.
+ p = helps.JSONPayload(helps.FilterSSEUsageMetadata(append([]byte("data: "), p...)))
+ if len(p) == 0 {
+ return true
+ }
+ reporter.ObserveResponseModel(p)
- payload = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, payload)
- chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, bytes.Clone(payload), ¶m, claudeInputTokens)
- helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
- var isTerminalChunk bool
- if finishReason := gjson.GetBytes(payload, "candidates.0.finishReason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
- isTerminalChunk = true
- } else if finishReason := gjson.GetBytes(payload, "response.candidates.0.finishReason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
- isTerminalChunk = true
- } else if replayAccumulator != nil && replayAccumulator.terminal {
- isTerminalChunk = true
- }
- if !isTerminalChunk {
- for _, chunk := range chunks {
- for _, cLine := range bytes.Split(chunk, []byte("\n")) {
- trimmed := bytes.TrimSpace(cLine)
- if bytes.Equal(trimmed, []byte("data: [DONE]")) || bytes.Equal(trimmed, []byte("[DONE]")) {
- isTerminalChunk = true
- break
- }
- cPayload := helps.JSONPayload(cLine)
- if len(cPayload) == 0 {
- continue
- }
- cType := gjson.GetBytes(cPayload, "type").String()
- if cType == "response.completed" || cType == "message_stop" {
- isTerminalChunk = true
- break
+ p = e.resolveWebSearchGroundingURLs(ctx, auth, from, originalPayload, translated, p)
+ chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), translated, bytes.Clone(p), ¶m, claudeInputTokens)
+ helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ var isTerminalChunk bool
+ if finishReason := ojson.GetBytesNoCopy(p, "candidates.0.finishReason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
+ isTerminalChunk = true
+ } else if finishReason := ojson.GetBytesNoCopy(p, "response.candidates.0.finishReason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
+ isTerminalChunk = true
+ } else if replayAccumulator != nil && replayAccumulator.terminal {
+ isTerminalChunk = true
+ }
+ if !isTerminalChunk {
+ for _, chunk := range chunks {
+ for _, cLine := range bytes.Split(chunk, []byte("\n")) {
+ trimmed := bytes.TrimSpace(cLine)
+ if bytes.Equal(trimmed, []byte("data: [DONE]")) || bytes.Equal(trimmed, []byte("[DONE]")) {
+ isTerminalChunk = true
+ break
+ }
+ cPayload := helps.JSONPayload(cLine)
+ if len(cPayload) == 0 {
+ continue
+ }
+ cType := ojson.GetBytesNoCopy(cPayload, "type").String()
+ if cType == "response.completed" || cType == "message_stop" {
+ isTerminalChunk = true
+ break
+ }
+ if finishReason := ojson.GetBytesNoCopy(cPayload, "choices.0.finish_reason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
+ isTerminalChunk = true
+ break
+ }
}
- if finishReason := gjson.GetBytes(cPayload, "choices.0.finish_reason"); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
- isTerminalChunk = true
+ if isTerminalChunk {
break
}
}
- if isTerminalChunk {
- break
- }
}
- }
- if replayAccumulator != nil && replayAccumulator.terminal && !replayCommitted && responseFormat == sdktranslator.FormatOpenAIResponse {
- // Wait for the translated completion, not the first finishReason:
- // split usage/signature frames may still extend the replay ledger.
- for _, chunk := range chunks {
- for _, line := range bytes.Split(chunk, []byte("\n")) {
- if gjson.GetBytes(helps.JSONPayload(line), "type").String() == "response.completed" {
- commitReplay()
+ if replayAccumulator != nil && replayAccumulator.terminal && !replayCommitted && responseFormat == sdktranslator.FormatOpenAIResponse {
+ for _, chunk := range chunks {
+ for _, line := range bytes.Split(chunk, []byte("\n")) {
+ if ojson.GetBytesNoCopy(helps.JSONPayload(line), "type").String() == "response.completed" {
+ commitReplay()
+ }
}
}
}
+ for i := range chunks {
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
+ case <-ctx.Done():
+ return false
+ }
+ }
+ if isTerminalChunk {
+ terminalDelivered = true
+ }
+ return !helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
}
- for i := range chunks {
- select {
- case out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}:
- case <-ctx.Done():
+ if len(pendingFilterPayload) > 0 {
+ flushed := pendingFilterPayload
+ pendingFilterPayload = nil
+ pendingFilterMsg = ""
+ if !processPayload(flushed) {
return
}
}
- if isTerminalChunk {
- terminalDelivered = true
+ if filterMsg, blocked := clienterror.GeminiFilterBlockMessage(payload); blocked {
+ pendingFilterPayload = bytes.Clone(payload)
+ pendingFilterMsg = filterMsg
+ continue
}
- if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
+ if !processPayload(payload) {
return
}
}
+ if pendingFilterMsg != "" && scanner.Err() == nil && !terminalDelivered {
+ streamErr = statusErr{code: http.StatusBadRequest, msg: pendingFilterMsg}
+ stopDeliverySupport()
+ helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: streamErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
@@ -453,9 +483,9 @@ func (e *AntigravityExecutor) executeCompactionStream(ctx context.Context, auth
return nil, fmt.Errorf("seal compaction capsule: %w", errSeal)
}
- inputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens").Int())
- outputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.output_tokens").Int())
- totalTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.total_tokens").Int())
+ inputTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.input_tokens").Int())
+ outputTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.output_tokens").Int())
+ totalTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.total_tokens").Int())
if totalTokens == 0 && inputTokens == 0 {
usage := helps.ParseOpenAIUsage(summaryResp.Payload)
inputTokens = int(usage.InputTokens)
diff --git a/internal/runtime/executor/antigravity_executor_terminal_disconnect_test.go b/internal/runtime/executor/antigravity_executor_terminal_disconnect_test.go
index d83088c3c83..79813507efd 100644
--- a/internal/runtime/executor/antigravity_executor_terminal_disconnect_test.go
+++ b/internal/runtime/executor/antigravity_executor_terminal_disconnect_test.go
@@ -8,12 +8,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// TestAntigravityStream_ClientDisconnectAfterTerminalChunkDoesNotReportFailure
@@ -86,7 +86,7 @@ func TestAntigravityStream_ClientDisconnectAfterTerminalChunkDoesNotReportFailur
if chunk.Err != nil {
break
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
terminalReceived = true
// Client received completed response and terminates connection
finishDelivery(context.Canceled)
@@ -176,7 +176,7 @@ func TestAntigravityStream_ClientDisconnectPlainContextAfterTerminalChunkDoesNot
if chunk.Err != nil {
break
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
terminalReceived = true
cancel()
break
@@ -264,7 +264,7 @@ func TestAntigravityStream_OpenAIChatCompletions_ClientDisconnectAfterStopChunkD
if chunk.Err != nil {
break
}
- if gjson.GetBytes(chunk.Payload, "choices.0.finish_reason").String() != "" {
+ if ojson.GetBytes(chunk.Payload, "choices.0.finish_reason").String() != "" {
terminalReceived = true
cancel()
break
diff --git a/internal/runtime/executor/antigravity_executor_tokens.go b/internal/runtime/executor/antigravity_executor_tokens.go
index 9ed35aa641d..a076b45537a 100644
--- a/internal/runtime/executor/antigravity_executor_tokens.go
+++ b/internal/runtime/executor/antigravity_executor_tokens.go
@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
@@ -15,7 +16,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
// CountTokens counts tokens for the given request using the Antigravity API.
@@ -59,25 +59,24 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
return cliproxyexecutor.Response{}, statusErr{code: http.StatusUnauthorized, msg: "missing access token"}
}
- payload, err := helps.ApplyRequestThinking(payload, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
- return cliproxyexecutor.Response{}, err
+ doc := ojson.NewDoc(payload)
+ if errThinking := helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); errThinking != nil {
+ return cliproxyexecutor.Response{}, errThinking
}
- payload = e.obfuscateSensitiveWords(payload)
- payload, _ = helps.PrepareAntigravityClaudeStructuredOutput(baseModel, payload)
- payload = sanitizeAntigravityGeminiRequestSignatures(baseModel, payload)
- preparedPayload, _, errReplay := prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, payload)
- if errReplay != nil {
+ e.obfuscateSensitiveWordsDoc(doc)
+ helps.PrepareAntigravityClaudeStructuredOutputDoc(baseModel, doc)
+ sanitizeAntigravityGeminiRequestSignaturesDoc(baseModel, doc)
+ if _, _, errReplay := prepareAntigravityGeminiReasoningReplayPayloadDoc(ctx, baseModel, req, opts, doc); errReplay != nil {
return cliproxyexecutor.Response{}, errReplay
}
- payload = ensureAntigravityGeminiLeadingUserContent(baseModel, preparedPayload)
+ ensureAntigravityGeminiLeadingUserContentDoc(baseModel, doc)
- payload = helps.DeleteJSONField(payload, "project")
- payload = helps.DeleteJSONField(payload, "model")
- payload = helps.DeleteJSONField(payload, "request.safetySettings")
- payload = helps.DeleteJSONField(payload, "request.toolConfig")
- payload = helps.DeleteJSONField(payload, "request.labels")
- payload = helps.DeleteJSONField(payload, "request.sessionId")
+ _ = doc.Delete("project")
+ _ = doc.Delete("model")
+ _ = doc.Delete("request.safetySettings")
+ _ = doc.Delete("request.toolConfig")
+ _ = doc.Delete("request.labels")
+ _ = doc.Delete("request.sessionId")
base := resolveAntigravityRequestBaseURL(auth)
httpClient := newAntigravityHTTPClient(ctx, e.cfg, auth, 0)
@@ -97,7 +96,9 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
requestURL.WriteString(url.QueryEscape(opts.Alt))
}
- payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "request", originalTranslatedForPayload, req, opts)(payload)
+ ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "request", originalTranslatedForPayload, req, opts))
+ helps.FinalizePayloadDoc(ctx, doc)
+ payload = doc.Bytes()
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
if errReq != nil {
return cliproxyexecutor.Response{}, errReq
@@ -146,7 +147,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut
helps.AppendAPIResponseChunk(ctx, e.cfg, bodyBytes)
if httpResp.StatusCode >= http.StatusOK && httpResp.StatusCode < http.StatusMultipleChoices {
- count := gjson.GetBytes(bodyBytes, "totalTokens").Int()
+ count := ojson.GetBytesNoCopy(bodyBytes, "totalTokens").Int()
translated := sdktranslator.TranslateTokenCount(respCtx, to, responseFormat, count, bodyBytes)
return cliproxyexecutor.Response{Payload: translated, Headers: httpResp.Header.Clone()}, nil
}
diff --git a/internal/runtime/executor/antigravity_executor_trailing_reasoning_test.go b/internal/runtime/executor/antigravity_executor_trailing_reasoning_test.go
index 48d932d1030..ac90d96ebc0 100644
--- a/internal/runtime/executor/antigravity_executor_trailing_reasoning_test.go
+++ b/internal/runtime/executor/antigravity_executor_trailing_reasoning_test.go
@@ -8,18 +8,18 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// issue6530EffectiveContents models the assumed upstream view of
// request.contents: the reporter still gets the 400 although an empty user turn
// is appended, so empty-text-only user turns are treated as absent here.
-func issue6530EffectiveContents(body []byte) []gjson.Result {
- var effective []gjson.Result
- for _, content := range gjson.GetBytes(body, "request.contents").Array() {
+func issue6530EffectiveContents(body []byte) []ojson.Result {
+ var effective []ojson.Result
+ for _, content := range ojson.GetBytes(body, "request.contents").Array() {
if content.Get("role").String() == "user" {
empty := true
for _, part := range content.Get("parts").Array() {
@@ -39,7 +39,7 @@ func issue6530EffectiveContents(body []byte) []gjson.Result {
// issue6530AcceptedTail reports whether upstream accepts the turn as the final
// one: a user turn, or the model-role function response Antigravity expects.
-func issue6530AcceptedTail(content gjson.Result) bool {
+func issue6530AcceptedTail(content ojson.Result) bool {
return content.Get("role").String() == "user" || content.Get("parts.0.functionResponse").Exists()
}
@@ -136,14 +136,14 @@ func TestAntigravityDropsTrailingStructuredReasoningTurn(t *testing.T) {
}
body := <-captured
- contents := gjson.GetBytes(body, "request.contents").Array()
+ contents := ojson.GetBytes(body, "request.contents").Array()
if len(contents) == 0 {
t.Fatalf("request.contents empty; body=%s", body)
}
if last := contents[len(contents)-1]; !issue6530AcceptedTail(last) {
t.Fatalf("trailing turn = %s, want user or function response; body=%s", last.Raw, body)
}
- if strings.Contains(gjson.GetBytes(body, "request.contents").Raw, `"thought":true`) {
+ if strings.Contains(ojson.GetBytes(body, "request.contents").Raw, `"thought":true`) {
t.Fatalf("trailing reasoning-only turn should be dropped; body=%s", body)
}
})
diff --git a/internal/runtime/executor/antigravity_home_model_capabilities_test.go b/internal/runtime/executor/antigravity_home_model_capabilities_test.go
index 90b123a79ce..19398c079ad 100644
--- a/internal/runtime/executor/antigravity_home_model_capabilities_test.go
+++ b/internal/runtime/executor/antigravity_home_model_capabilities_test.go
@@ -9,12 +9,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type antigravityHomeModelCapabilityDispatcher struct {
@@ -90,10 +90,10 @@ func TestAntigravityHomeModelThinkingLevelRemainsLevel(t *testing.T) {
}
upstreamBody := <-requestBodies
- if got := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "medium" {
+ if got := ojson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "medium" {
t.Fatalf("thinkingLevel = %q, want medium; body=%s", got, upstreamBody)
}
- if budget := gjson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingBudget"); budget.Exists() {
+ if budget := ojson.GetBytes(upstreamBody, "request.generationConfig.thinkingConfig.thinkingBudget"); budget.Exists() {
t.Fatalf("thinkingBudget should be absent, got %s; body=%s", budget.Raw, upstreamBody)
}
}
diff --git a/internal/runtime/executor/antigravity_issue6825_test.go b/internal/runtime/executor/antigravity_issue6825_test.go
index d86474d74f2..abaf7e55185 100644
--- a/internal/runtime/executor/antigravity_issue6825_test.go
+++ b/internal/runtime/executor/antigravity_issue6825_test.go
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -19,8 +20,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
_ "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator/builtin"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const issue6825FirstRequest = `{"model":"gemini-3-flash","tool_choice":"auto","messages":[{"role":"user","content":"What time is it?"}],"tools":[{"type":"function","function":{"name":"get_time","parameters":{"type":"object","properties":{"tz":{"type":"string"}}}}}]}`
@@ -37,7 +36,7 @@ func TestIssue6825StreamToolIndicesAcrossChunks(t *testing.T) {
var arguments []string
for _, chunk := range chunks {
for _, out := range sdktranslator.TranslateStream(context.Background(), sdktranslator.FormatAntigravity, sdktranslator.FormatOpenAI, "gemini-3-flash", original, translated, []byte(chunk), ¶m) {
- for _, call := range gjson.GetBytes(out, "choices.0.delta.tool_calls").Array() {
+ for _, call := range ojson.GetBytes(out, "choices.0.delta.tool_calls").Array() {
indices = append(indices, call.Get("index").Int())
arguments = append(arguments, call.Get("function.arguments").String())
}
@@ -118,7 +117,7 @@ func (c *issue6825Conversation) callWithContext(ctx context.Context, payload []b
if errExecute != nil {
c.t.Fatalf("Execute: %v", errExecute)
}
- message := gjson.GetBytes(response.Payload, "choices.0.message")
+ message := ojson.GetBytes(response.Payload, "choices.0.message")
if !message.Exists() {
c.t.Fatalf("missing OpenAI assistant message: %s", response.Payload)
}
@@ -137,7 +136,7 @@ func (c *issue6825Conversation) callWithContext(ctx context.Context, payload []b
}
for _, line := range bytes.Split(chunk.Payload, []byte("\n")) {
line = bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
- delta := gjson.GetBytes(line, "choices.0.delta")
+ delta := ojson.GetBytes(line, "choices.0.delta")
content += delta.Get("content").String()
reasoning += delta.Get("reasoning_content").String()
for _, call := range delta.Get("tool_calls").Array() {
@@ -149,15 +148,17 @@ func (c *issue6825Conversation) callWithContext(ctx context.Context, payload []b
}
}
}
+ msgDoc := ojson.NewDoc(message)
if len(calls) > 0 {
- message, _ = sjson.SetBytes(message, "tool_calls", calls)
+ _ = msgDoc.Set("tool_calls", calls)
}
if content != "" {
- message, _ = sjson.SetBytes(message, "content", content)
+ _ = msgDoc.Set("content", content)
}
if reasoning != "" {
- message, _ = sjson.SetBytes(message, "reasoning_content", reasoning)
+ _ = msgDoc.Set("reasoning_content", reasoning)
}
+ message = msgDoc.Bytes()
return message, <-c.requests
}
@@ -167,29 +168,28 @@ func (c *issue6825Conversation) call(payload []byte, opts cliproxyexecutor.Optio
func issue6825Followup(t *testing.T, assistant []byte) []byte {
t.Helper()
- calls := gjson.GetBytes(assistant, "tool_calls").Array()
+ calls := ojson.GetBytes(assistant, "tool_calls").Array()
if len(calls) == 0 {
t.Fatalf("first round returned no tool calls: %s", assistant)
}
- out, errSet := sjson.SetRawBytes([]byte(issue6825FirstRequest), "messages.-1", assistant)
- if errSet != nil {
+ outDoc := ojson.NewDoc([]byte(issue6825FirstRequest))
+ if errSet := outDoc.SetRawBytes("messages.-1", assistant); errSet != nil {
t.Fatal(errSet)
}
for _, call := range calls {
if call.Get("id").String() == "" || call.Get("function.name").String() != "get_time" || call.Get("function.arguments").String() != `{"tz":"UTC"}` {
t.Fatalf("invalid client-visible tool call: %s", call.Raw)
}
- out, errSet = sjson.SetBytes(out, "messages.-1", map[string]string{"role": "tool", "tool_call_id": call.Get("id").String(), "content": "12:00 UTC"})
- if errSet != nil {
+ if errSet := outDoc.Set("messages.-1", map[string]string{"role": "tool", "tool_call_id": call.Get("id").String(), "content": "12:00 UTC"}); errSet != nil {
t.Fatal(errSet)
}
}
- return out
+ return outDoc.Bytes()
}
func issue6825FirstToolSignature(t *testing.T, request []byte) string {
t.Helper()
- for _, content := range gjson.GetBytes(request, "request.contents").Array() {
+ for _, content := range ojson.GetBytes(request, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("functionCall.name").String() == "get_time" {
return part.Get("thoughtSignature").String()
@@ -209,16 +209,16 @@ func TestIssue6825ToolPolicyKeepsHistoricalSignature(t *testing.T) {
assistant, _ := conversation.call([]byte(issue6825FirstRequest), opts, false, nil)
followup := issue6825Followup(t, assistant)
if policy == "omitted_tools" {
- followup, _ = sjson.DeleteBytes(followup, "tools")
+ followup, _ = ojson.DeleteBytes(followup, "tools")
} else {
- followup, _ = sjson.SetBytes(followup, "tool_choice", policy)
+ followup, _ = ojson.SetBytes(followup, "tool_choice", policy)
}
_, upstream := conversation.call(followup, opts, false, nil)
if policy == "none" {
- if got := gjson.GetBytes(upstream.body, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
+ if got := ojson.GetBytes(upstream.body, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
t.Errorf("function calling mode = %q, want NONE", got)
}
- if got := gjson.GetBytes(upstream.body, "request.tools.0.functionDeclarations.0.name").String(); got != "get_time" {
+ if got := ojson.GetBytes(upstream.body, "request.tools.0.functionDeclarations.0.name").String(); got != "get_time" {
t.Errorf("historical tool declaration = %q, want get_time", got)
}
}
@@ -267,12 +267,12 @@ func TestIssue6825NonStreamTwoTurnSignatureReplay(t *testing.T) {
t.Fatalf("first upstream path = %q, want generateContent", first.path)
}
if fixture.wantCallID != "" {
- if gotID := gjson.GetBytes(assistant, "tool_calls.0.id").String(); gotID != fixture.wantCallID {
+ if gotID := ojson.GetBytes(assistant, "tool_calls.0.id").String(); gotID != fixture.wantCallID {
t.Errorf("round 1 tool_calls[0].id = %q, want %q", gotID, fixture.wantCallID)
}
}
if fixture.stripReasoning {
- assistant, _ = sjson.DeleteBytes(assistant, "reasoning_content")
+ assistant, _ = ojson.DeleteBytes(assistant, "reasoning_content")
}
_, second := conversation.call(issue6825Followup(t, assistant), opts, false, nil)
if !strings.HasSuffix(second.path, ":generateContent") {
@@ -295,22 +295,23 @@ func TestIssue6825PolicyReplayRejectsChangedContext(t *testing.T) {
Metadata: map[string]any{cliproxyexecutor.CallerScopeMetadataKey: session.CallerScope("trusted-caller-A")},
}
assistant, _ := conversation.call([]byte(issue6825FirstRequest), opts, false, nil)
- followup := issue6825Followup(t, assistant)
- followup, _ = sjson.SetBytes(followup, "tool_choice", "none")
+ followupDoc := ojson.NewDoc(issue6825Followup(t, assistant))
+ _ = followupDoc.Set("tool_choice", "none")
switch change {
case "system":
- messages := gjson.GetBytes(followup, "messages").Raw
- followup, _ = sjson.SetRawBytes(followup, "messages", []byte(`[{"role":"system","content":"Different system instructions"},`+messages[1:]))
+ messages := followupDoc.Get("messages").Raw
+ _ = followupDoc.SetRawBytes("messages", []byte(`[{"role":"system","content":"Different system instructions"},`+messages[1:]))
case "history":
- followup, _ = sjson.SetBytes(followup, "messages.0.content", "Different user history")
+ _ = followupDoc.Set("messages.0.content", "Different user history")
case "arguments":
- followup, _ = sjson.SetBytes(followup, "messages.1.tool_calls.0.function.arguments", `{"tz":"Asia/Tokyo"}`)
+ _ = followupDoc.Set("messages.1.tool_calls.0.function.arguments", `{"tz":"Asia/Tokyo"}`)
case "caller":
// Untrusted body metadata must not override the trusted execution scope.
- followup, _ = sjson.SetBytes(followup, "tool_choice", "auto")
- followup, _ = sjson.SetBytes(followup, "metadata.caller_scope", session.CallerScope("trusted-caller-A"))
+ _ = followupDoc.Set("tool_choice", "auto")
+ _ = followupDoc.Set("metadata.caller_scope", session.CallerScope("trusted-caller-A"))
opts.Metadata = map[string]any{cliproxyexecutor.CallerScopeMetadataKey: session.CallerScope("trusted-caller-B")}
}
+ followup := followupDoc.Bytes()
_, second := conversation.call(followup, opts, false, nil)
want := "skip_thought_signature_validator"
if change == "unchanged" {
@@ -334,10 +335,10 @@ func TestIssue6825PayloadRulesRemainFinal(t *testing.T) {
Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "gemini-*"}}, Params: []string{"tools", "contents.1.parts.0.thoughtSignature"}}},
}
_, second := conversation.call(issue6825Followup(t, assistant), opts, false, nil)
- if gjson.GetBytes(second.body, "request.tools").Exists() || gjson.GetBytes(second.body, "request.contents.1.parts.0.thoughtSignature").Exists() {
+ if ojson.GetBytes(second.body, "request.tools").Exists() || ojson.GetBytes(second.body, "request.contents.1.parts.0.thoughtSignature").Exists() {
t.Errorf("payload filters were undone after replay: %s", second.body)
}
- if got := gjson.GetBytes(second.body, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
+ if got := ojson.GetBytes(second.body, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
t.Errorf("configured final mode = %q, want NONE", got)
}
})
diff --git a/internal/runtime/executor/antigravity_issue7000_boundary_test.go b/internal/runtime/executor/antigravity_issue7000_boundary_test.go
index cab5b205738..538e96625de 100644
--- a/internal/runtime/executor/antigravity_issue7000_boundary_test.go
+++ b/internal/runtime/executor/antigravity_issue7000_boundary_test.go
@@ -12,17 +12,16 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const issue7000LiteralSchema = `{"type":"object","properties":{"anthropic":{"type":"string","enum":["anthropic","json_schema"],"const":"anthropic"},"response_schema":{"$ref":"#/$defs/anthropic"}},"$defs":{"anthropic":{"type":"string"}},"required":["anthropic"],"additionalProperties":false}`
-func assertIssue7000PromptSchema(t *testing.T, instruction gjson.Result, schemaJSON string) {
+func assertIssue7000PromptSchema(t *testing.T, instruction ojson.Result, schemaJSON string) {
t.Helper()
var prompt strings.Builder
for _, part := range instruction.Get("parts").Array() {
@@ -50,8 +49,8 @@ func TestIssue7000AntigravityAliasesPreserveOtherFieldsAndToolChoice(t *testing.
for index, schemaKey := range []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} {
for _, mode := range []string{"AUTO", "NONE", "ANY", "VALIDATED"} {
t.Run(container+"/"+schemaKey+"/"+mode, func(t *testing.T) {
- payload := []byte(`{"request":{"generationConfig":{"temperature":0.25},"generation_config":{"stopSequences":["STOP"]},"systemInstruction":{"role":"system","parts":[{"text":"keep system"}]},"contents":[{"role":"user","parts":[{"text":"keep history"}]}],"tools":[{"functionDeclarations":[{"name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}]}]}}`)
- setRaw := func(path, value string) { payload, _ = sjson.SetRawBytes(payload, path, []byte(value)) }
+ payloadDoc := ojson.NewDoc([]byte(`{"request":{"generationConfig":{"temperature":0.25},"generation_config":{"stopSequences":["STOP"]},"systemInstruction":{"role":"system","parts":[{"text":"keep system"}]},"contents":[{"role":"user","parts":[{"text":"keep history"}]}],"tools":[{"functionDeclarations":[{"name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}]}]}}`))
+ setRaw := func(path, value string) { _ = payloadDoc.SetRawBytes(path, []byte(value)) }
setRaw("request."+container+"."+schemaKey, issue7000LiteralSchema)
setRaw("request."+container+".responseMimeType", `"application/json"`)
setRaw("request."+container+".response_mime_type", `"application/json"`)
@@ -62,15 +61,16 @@ func TestIssue7000AntigravityAliasesPreserveOtherFieldsAndToolChoice(t *testing.
systemKey := "systemInstruction"
if container == "generation_config" {
systemKey = "system_instruction"
- setRaw("request."+systemKey, gjson.GetBytes(payload, "request.systemInstruction").Raw)
- payload, _ = sjson.DeleteBytes(payload, "request.systemInstruction")
+ setRaw("request."+systemKey, payloadDoc.Get("request.systemInstruction").Raw)
+ _ = payloadDoc.Delete("request.systemInstruction")
}
+ payload := payloadDoc.Bytes()
body := buildRequestBodyFromRawPayload(t, "claude-opus-5-5-high", payload)
encoded, errMarshal := json.Marshal(body)
if errMarshal != nil {
t.Fatal(errMarshal)
}
- request := gjson.GetBytes(encoded, "request")
+ request := ojson.GetBytes(encoded, "request")
assertIssue7000PromptSchema(t, request.Get(systemKey), issue7000LiteralSchema)
for path, want := range map[string]string{
"generationConfig.temperature": "0.25", "generation_config.stopSequences.0": "STOP",
@@ -92,15 +92,15 @@ func TestIssue7000AntigravityAliasesPreserveOtherFieldsAndToolChoice(t *testing.
if mode == "ANY" || mode == "VALIDATED" {
wantMode = "AUTO"
for _, key := range []string{"allowedFunctionNames", "allowed_function_names"} {
- if gjson.GetBytes(encoded, toolPath+"."+key).Exists() {
+ if ojson.GetBytes(encoded, toolPath+"."+key).Exists() {
t.Errorf("forced-tool allowlist survived AUTO downgrade: %s", key)
}
}
}
- if got := gjson.GetBytes(encoded, toolPath+".mode").String(); got != wantMode {
+ if got := ojson.GetBytes(encoded, toolPath+".mode").String(); got != wantMode {
t.Errorf("tool choice = %q, want %q", got, wantMode)
}
- if !gjson.GetBytes(encoded, toolPath+".streamFunctionCallArguments").Bool() {
+ if !ojson.GetBytes(encoded, toolPath+".streamFunctionCallArguments").Bool() {
t.Error("unrelated tool configuration was removed")
}
if toolContainer == "tool_config" && request.Get("toolConfig").Exists() {
@@ -132,11 +132,11 @@ func TestIssue7000AntigravityModelAndPlainRequestBoundaries(t *testing.T) {
if errMarshal != nil {
t.Fatal(errMarshal)
}
- if got := gjson.GetBytes(encoded, "request.generationConfig.responseSchema").Exists(); got == tc.convert {
+ if got := ojson.GetBytes(encoded, "request.generationConfig.responseSchema").Exists(); got == tc.convert {
t.Errorf("native schema presence = %t, conversion = %t", got, tc.convert)
}
if tc.convert {
- assertIssue7000PromptSchema(t, gjson.GetBytes(encoded, "request.systemInstruction"), `{"type":"object","properties":{"title":{"type":"string"}}}`)
+ assertIssue7000PromptSchema(t, ojson.GetBytes(encoded, "request.systemInstruction"), `{"type":"object","properties":{"title":{"type":"string"}}}`)
}
})
}
@@ -148,10 +148,10 @@ func TestIssue7000AntigravityModelAndPlainRequestBoundaries(t *testing.T) {
if errMarshal != nil {
t.Fatal(errMarshal)
}
- if got := gjson.GetBytes(encoded, "request.systemInstruction.parts.#").Int(); got != 1 {
+ if got := ojson.GetBytes(encoded, "request.systemInstruction.parts.#").Int(); got != 1 {
t.Errorf("plain request gained instructions: %s", encoded)
}
- if got := gjson.GetBytes(encoded, "request.toolConfig.functionCallingConfig.mode").String(); got != "VALIDATED" {
+ if got := ojson.GetBytes(encoded, "request.toolConfig.functionCallingConfig.mode").String(); got != "VALIDATED" {
t.Errorf("unrelated legacy tool behavior changed: %q", got)
}
})
@@ -195,19 +195,20 @@ func TestIssue7000AntigravityExecutionAndFinalPayloadRules(t *testing.T) {
cfg.Payload.Filter = []config.PayloadFilterRule{{Models: models, Params: []string{"systemInstruction", "toolConfig", "contents.0"}}}
}
model := "claude-opus-5-5-high"
- payload := []byte(`{"model":"` + model + `","temperature":0.25,"tool_choice":"auto"}`)
+ payloadDoc := ojson.NewDoc([]byte(`{"model":"` + model + `","temperature":0.25,"tool_choice":"auto"}`))
if format == sdktranslator.FormatOpenAI {
- payload, _ = sjson.SetRawBytes(payload, "messages", []byte(`[{"role":"system","content":"keep anthropic system"},{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]`))
- payload, _ = sjson.SetRawBytes(payload, "response_format", []byte(`{"type":"json_schema","json_schema":{"schema":`+issue7000LiteralSchema+`}}`))
- payload, _ = sjson.SetRawBytes(payload, "tools", []byte(`[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}}]`))
+ _ = payloadDoc.SetRawBytes("messages", []byte(`[{"role":"system","content":"keep anthropic system"},{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]`))
+ _ = payloadDoc.SetRawBytes("response_format", []byte(`{"type":"json_schema","json_schema":{"schema":`+issue7000LiteralSchema+`}}`))
+ _ = payloadDoc.SetRawBytes("tools", []byte(`[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}}]`))
} else {
model = "claude-sonnet-5-5-high"
- payload, _ = sjson.SetBytes(payload, "model", model)
- payload, _ = sjson.SetBytes(payload, "instructions", "keep anthropic system")
- payload, _ = sjson.SetRawBytes(payload, "input", []byte(`[{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]`))
- payload, _ = sjson.SetRawBytes(payload, "text.format", []byte(`{"type":"json_schema","schema":`+issue7000LiteralSchema+`}`))
- payload, _ = sjson.SetRawBytes(payload, "tools", []byte(`[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}]`))
+ _ = payloadDoc.Set("model", model)
+ _ = payloadDoc.Set("instructions", "keep anthropic system")
+ _ = payloadDoc.SetRawBytes("input", []byte(`[{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]`))
+ _ = payloadDoc.SetRawBytes("text.format", []byte(`{"type":"json_schema","schema":`+issue7000LiteralSchema+`}`))
+ _ = payloadDoc.SetRawBytes("tools", []byte(`[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}}]`))
}
+ payload := payloadDoc.Bytes()
exec := NewAntigravityExecutor(cfg)
auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "antigravity", Attributes: map[string]string{"base_url": server.URL}, Metadata: map[string]any{"access_token": "token", "project_id": "project-test", "expired": time.Now().Add(time.Hour).Format(time.RFC3339)}}
req := cliproxyexecutor.Request{Model: model, Payload: payload}
@@ -233,7 +234,7 @@ func TestIssue7000AntigravityExecutionAndFinalPayloadRules(t *testing.T) {
}
}
body := <-captured
- request := gjson.GetBytes(body, "request")
+ request := ojson.GetBytes(body, "request")
if got := request.Get("tools.0.functionDeclarations.0.name").String(); got != "lookup" {
t.Errorf("real tool lost: %s", body)
}
diff --git a/internal/runtime/executor/antigravity_issue7000_test.go b/internal/runtime/executor/antigravity_issue7000_test.go
index 79389e0c77d..702e444ba6b 100644
--- a/internal/runtime/executor/antigravity_issue7000_test.go
+++ b/internal/runtime/executor/antigravity_issue7000_test.go
@@ -7,10 +7,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
_ "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator/builtin"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func TestIssue7000AntigravityClaudeStructuredOutputUsesPromptAndAutoTools(t *testing.T) {
@@ -62,20 +61,19 @@ func TestIssue7000AntigravityClaudeStructuredOutputUsesPromptAndAutoTools(t *tes
} {
for _, withTools := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/%s/tools=%t", model, tc.name, withTools), func(t *testing.T) {
- payload, errSet := sjson.SetBytes([]byte(tc.payload), "model", model)
- if errSet != nil {
+ payloadDoc := ojson.NewDoc([]byte(tc.payload))
+ if errSet := payloadDoc.Set("model", model); errSet != nil {
t.Fatalf("set model: %v", errSet)
}
if withTools {
- payload, errSet = sjson.SetRawBytes(payload, "tools", []byte(tc.tools))
- if errSet != nil {
+ if errSet := payloadDoc.SetRawBytes("tools", []byte(tc.tools)); errSet != nil {
t.Fatalf("set tools: %v", errSet)
}
- payload, errSet = sjson.SetBytes(payload, "tool_choice", "auto")
- if errSet != nil {
+ if errSet := payloadDoc.Set("tool_choice", "auto"); errSet != nil {
t.Fatalf("set tool_choice: %v", errSet)
}
}
+ payload := payloadDoc.Bytes()
translated := sdktranslator.TranslateRequest(tc.format, sdktranslator.FormatAntigravity, model, payload, false)
body := buildRequestBodyFromRawPayload(t, model, translated)
@@ -83,7 +81,7 @@ func TestIssue7000AntigravityClaudeStructuredOutputUsesPromptAndAutoTools(t *tes
if errMarshal != nil {
t.Fatalf("marshal outbound request: %v", errMarshal)
}
- request := gjson.GetBytes(encoded, "request")
+ request := ojson.GetBytes(encoded, "request")
if mode := request.Get("toolConfig.functionCallingConfig.mode").String(); mode != "" && mode != "AUTO" {
t.Errorf("outbound function calling mode = %q, want AUTO or omitted; Claude 5.5 rejects forced tool use", mode)
diff --git a/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go b/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go
index 61a707f03d8..e6234735316 100644
--- a/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go
+++ b/internal/runtime/executor/antigravity_preupstream_rewrite_differential_test.go
@@ -8,9 +8,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func TestNormalizeAntigravityGeminiFunctionResponseRolesMatchesLegacy(t *testing.T) {
@@ -224,7 +223,7 @@ func TestRepairAntigravityGeminiFunctionResponseNames(t *testing.T) {
t.Fatalf("payload changed: got=%s", got)
}
for path, wantName := range test.wantName {
- if gotName := gjson.GetBytes(got, path).String(); gotName != wantName {
+ if gotName := ojson.GetBytes(got, path).String(); gotName != wantName {
t.Fatalf("%s = %q, want %q; output=%s", path, gotName, wantName, got)
}
}
@@ -241,7 +240,7 @@ func TestApplyAntigravityIndexedEditsFallsBackWhenOffsetsInvalid(t *testing.T) {
end: -1,
replacement: replacement,
}}
- want, errSet := sjson.SetRawBytes(payload, "request.contents.0.parts.0", replacement)
+ want, errSet := ojson.SetRawBytes(payload, "request.contents.0.parts.0", replacement)
if errSet != nil {
t.Fatal(errSet)
}
@@ -260,7 +259,7 @@ func TestApplyAntigravityContentEditsWithSJSONFallback(t *testing.T) {
end: -1,
replacement: replacement,
}}
- want, errSet := sjson.SetRawBytes(payload, "request.contents.0", replacement)
+ want, errSet := ojson.SetRawBytes(payload, "request.contents.0", replacement)
if errSet != nil {
t.Fatal(errSet)
}
diff --git a/internal/runtime/executor/antigravity_preupstream_rewrite_legacy_oracle_test.go b/internal/runtime/executor/antigravity_preupstream_rewrite_legacy_oracle_test.go
index fdf9b45c38a..d12e82d31ad 100644
--- a/internal/runtime/executor/antigravity_preupstream_rewrite_legacy_oracle_test.go
+++ b/internal/runtime/executor/antigravity_preupstream_rewrite_legacy_oracle_test.go
@@ -9,10 +9,9 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func legacyAntigravityCountClaudeToolProvenanceIDs(payload []byte) int {
@@ -71,7 +70,7 @@ func legacyNormalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byt
var calls, responses []functionRef
var responseParts, otherParts []json.RawMessage
hasOtherPart := false
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
switch {
case part.Get("functionCall").Exists():
calls = append(calls, functionRef{id: part.Get("functionCall.id").String(), name: part.Get("functionCall.name").String()})
@@ -122,7 +121,7 @@ func legacyNormalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byt
if len(ordered) == len(responseParts) {
ordered = append(ordered, otherParts...)
if encoded, errMarshal := json.Marshal(ordered); errMarshal == nil {
- if updated, errSet := sjson.SetRawBytes(out, fmt.Sprintf("request.contents.%d.parts", contentIndex), encoded); errSet == nil {
+ if updated, errSet := ojson.SetRawBytes(out, fmt.Sprintf("request.contents.%d.parts", contentIndex), encoded); errSet == nil {
out = updated
}
}
@@ -130,7 +129,7 @@ func legacyNormalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byt
}
pending = nil
if !hasOtherPart && content.Get("role").String() != "model" {
- if updated, errSet := sjson.SetBytes(out, fmt.Sprintf("request.contents.%d.role", contentIndex), "model"); errSet == nil {
+ if updated, errSet := ojson.SetBytes(out, fmt.Sprintf("request.contents.%d.role", contentIndex), "model"); errSet == nil {
out = updated
}
}
@@ -144,12 +143,12 @@ func legacyRepairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
return rawJSON
}
callIDToName := make(map[string]string)
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return true
}
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
fc := part.Get("functionCall")
if fc.Exists() {
id := strings.TrimSpace(fc.Get("id").String())
@@ -167,12 +166,12 @@ func legacyRepairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
}
out := rawJSON
- contents.ForEach(func(contentIdx, content gjson.Result) bool {
+ contents.ForEach(func(contentIdx, content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return true
}
- parts.ForEach(func(partIdx, part gjson.Result) bool {
+ parts.ForEach(func(partIdx, part ojson.Result) bool {
fr := part.Get("functionResponse")
if fr.Exists() {
id := strings.TrimSpace(fr.Get("id").String())
@@ -180,7 +179,7 @@ func legacyRepairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
if id != "" && (name == "" || name == "unknown") {
if realName, ok := callIDToName[id]; ok {
path := fmt.Sprintf("request.contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int())
- if updated, errSet := sjson.SetBytes(out, path, realName); errSet == nil {
+ if updated, errSet := ojson.SetBytes(out, path, realName); errSet == nil {
out = updated
}
}
@@ -196,7 +195,7 @@ func legacyRepairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte {
func legacySanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySchema bool) string {
for _, base := range antigravityFunctionDeclarationPaths(payloadStr) {
oldPath := base + ".parametersJsonSchema"
- if !gjson.Get(payloadStr, oldPath).Exists() {
+ if !ojson.Get(payloadStr, oldPath).Exists() {
continue
}
renamed, errRename := util.RenameKey(payloadStr, oldPath, base+".parameters")
@@ -229,11 +228,11 @@ func legacySanitizeAntigravityRequestSchemas(payloadStr string, useAntigravitySc
func legacyCleanAntigravitySchemasAtPaths(payloadStr string, schemaPaths []string, clean func(string) string) string {
for _, schemaPath := range schemaPaths {
- schema := gjson.Get(payloadStr, schemaPath)
+ schema := ojson.Get(payloadStr, schemaPath)
if !schema.Exists() {
continue
}
- updated, errSet := sjson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(clean(schema.Raw)))
+ updated, errSet := ojson.SetRawBytes([]byte(payloadStr), schemaPath, []byte(clean(schema.Raw)))
if errSet != nil {
continue
}
diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go
index ad8d3a63801..24f5c55da88 100644
--- a/internal/runtime/executor/antigravity_reasoning_replay.go
+++ b/internal/runtime/executor/antigravity_reasoning_replay.go
@@ -14,6 +14,7 @@ import (
"reflect"
"strings"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -22,8 +23,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// antigravityReplayLogKey returns a short, non-reversible tag for a replay
@@ -40,14 +39,24 @@ func antigravityReplayLogKey(value string) string {
// antigravityCountClaudeToolProvenanceIDs reports how many reserved
// Claude-facing provenance IDs are still present in a Gemini-shaped payload.
func antigravityCountClaudeToolProvenanceIDs(payload []byte) int {
+ return antigravityCountClaudeToolProvenanceIDsResult(util.GetGJSONBytesNoCopy(payload, "request.contents"))
+}
+
+func antigravityCountClaudeToolProvenanceIDsDoc(doc *ojson.Doc) int {
+ if doc == nil {
+ return 0
+ }
+ return antigravityCountClaudeToolProvenanceIDsResult(doc.Get("request.contents"))
+}
+
+func antigravityCountClaudeToolProvenanceIDsResult(contents ojson.Result) int {
count := 0
- contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
if !contents.IsArray() {
return 0
}
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
- countPart := func(part gjson.Result) {
+ countPart := func(part ojson.Result) {
for _, path := range []string{"functionCall.id", "functionResponse.id"} {
if util.IsGeminiClaudeToolUseID(part.Get(path).String()) {
count++
@@ -55,11 +64,11 @@ func antigravityCountClaudeToolProvenanceIDs(payload []byte) int {
}
}
if parts.IsArray() {
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
countPart(part)
return true
})
- } else if parts.Type != gjson.Null {
+ } else if parts.Type != ojson.Null {
// Result.Array returns a non-array JSON value as one item.
countPart(parts)
}
@@ -98,6 +107,25 @@ func antigravityReasoningReplayScopeFromPayload(modelName string, payload []byte
}
}
+func antigravityReasoningReplayScopeFromDoc(modelName string, doc *ojson.Doc) antigravityReasoningReplayScope {
+ sessionID := antigravityReplaySessionIDFromDoc(doc)
+ if sessionID == "" {
+ if stable := strings.TrimSpace(generateStableSessionIDDoc(doc)); stable != "" {
+ sessionID = strings.TrimPrefix(stable, "-")
+ if sessionID == "" {
+ sessionID = stable
+ }
+ }
+ }
+ if sessionID == "" {
+ return antigravityReasoningReplayScope{}
+ }
+ return antigravityReasoningReplayScope{
+ modelName: strings.TrimSpace(modelName),
+ sessionKey: "session:" + sessionID,
+ }
+}
+
func antigravityReasoningReplayScopeFromRequest(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) antigravityReasoningReplayScope {
// Prefer an explicit downstream session over a provider sessionId synthesized
// from request text. This keeps identical prompts in separate client sessions
@@ -128,6 +156,31 @@ func antigravityReasoningReplayScopeFromRequest(ctx context.Context, modelName s
return scope
}
+func antigravityReasoningReplayScopeFromRequestDoc(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) antigravityReasoningReplayScope {
+ scope := antigravityReasoningReplayScope{modelName: modelName}
+ if sessionKey := antigravityReasoningReplayClientSessionKey(ctx, req, opts); sessionKey != "" {
+ scope.sessionKey = sessionKey
+ } else {
+ scope = antigravityReasoningReplayScopeFromDoc(modelName, doc)
+ if !scope.valid() {
+ scope = antigravityReasoningReplayScopeFromPayload(modelName, req.Payload)
+ }
+ }
+ scope.openAIChat = opts.SourceFormat.String() == "openai"
+ callerScope := metadataString(opts.Metadata, cliproxyexecutor.CallerScopeMetadataKey)
+ if callerScope == "" {
+ callerScope = metadataString(req.Metadata, cliproxyexecutor.CallerScopeMetadataKey)
+ }
+ if callerScope == "" {
+ callerScope = session.CallerScope(helps.APIKeyFromContext(ctx))
+ }
+ if scope.valid() && callerScope != "" {
+ sum := sha256.Sum256([]byte(callerScope))
+ scope.sessionKey = "caller:" + hex.EncodeToString(sum[:]) + ":" + scope.sessionKey
+ }
+ return scope
+}
+
func antigravitySessionHeaderValue(headers http.Header, names ...string) string {
if headers == nil {
return ""
@@ -166,8 +219,8 @@ func antigravityReasoningReplayClientSessionKey(ctx context.Context, req cliprox
continue
}
for _, path := range []string{"session_id", "metadata.session_id"} {
- if value := strings.TrimSpace(gjson.GetBytes(raw, path).String()); value != "" {
- return "responses:" + value
+ if value := strings.TrimSpace(ojson.GetBytesNoCopy(raw, path).String()); value != "" {
+ return "responses:" + strings.Clone(value)
}
}
}
@@ -178,8 +231,8 @@ func antigravityReasoningReplayClientSessionKey(ctx context.Context, req cliprox
return "execution:" + value
}
for _, raw := range [][]byte{opts.OriginalRequest, req.Payload} {
- if value := strings.TrimSpace(gjson.GetBytes(raw, "prompt_cache_key").String()); value != "" {
- return "prompt-cache:" + value
+ if value := strings.TrimSpace(ojson.GetBytesNoCopy(raw, "prompt_cache_key").String()); value != "" {
+ return "prompt-cache:" + strings.Clone(value)
}
}
// Reuse ingress extraction for explicit session aliases before falling back
@@ -238,8 +291,20 @@ func antigravityReplaySessionIDFromPayload(payload []byte) string {
return ""
}
for _, path := range []string{"sessionId", "session_id", "request.sessionId", "request.session_id"} {
- if id := strings.TrimSpace(gjson.GetBytes(payload, path).String()); id != "" {
- return id
+ if id := strings.TrimSpace(ojson.GetBytesNoCopy(payload, path).String()); id != "" {
+ return strings.Clone(id)
+ }
+ }
+ return ""
+}
+
+func antigravityReplaySessionIDFromDoc(doc *ojson.Doc) string {
+ if doc == nil {
+ return ""
+ }
+ for _, path := range []string{"sessionId", "session_id", "request.sessionId", "request.session_id"} {
+ if id := strings.TrimSpace(doc.Get(path).String()); id != "" {
+ return strings.Clone(id)
}
}
return ""
@@ -278,43 +343,79 @@ func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelNa
if !antigravityUsesReasoningReplayCache(modelName) {
return payload, antigravityReasoningReplayScope{}, nil
}
- updated, scope, replayApplied, errReplay := applyAntigravityReasoningReplayCache(ctx, modelName, req, opts, payload)
+ doc := ojson.NewDoc(payload)
+ scope, changed, err := prepareAntigravityGeminiReasoningReplayPayloadDoc(ctx, modelName, req, opts, doc)
+ if err != nil || !changed {
+ return payload, scope, err
+ }
+ return doc.Bytes(), scope, nil
+}
+
+func prepareAntigravityGeminiReasoningReplayPayloadDoc(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) (antigravityReasoningReplayScope, bool, error) {
+ if doc == nil || !antigravityUsesReasoningReplayCache(modelName) {
+ return antigravityReasoningReplayScope{}, false, nil
+ }
+ originalContentsRaw := doc.Get("request.contents").Raw
+ scope, replayApplied, errReplay := applyAntigravityReasoningReplayCacheDoc(ctx, modelName, req, opts, doc)
+ changed := replayApplied
if errReplay != nil {
- // Replay state is an optimization, not a correctness requirement: a ledger
- // miss is already a tolerated outcome below. Failing the request here would
- // surface as an untyped executor error, which MarkResult treats as a
- // credential fault and uses to mark every candidate credential unavailable.
- // Degrade to "no replay this turn" instead.
logAntigravityReasoningReplayDegraded(scope, "read", errReplay)
- updated = payload
- }
- updated = normalizeAntigravityGeminiFunctionResponseRoles(updated)
- if antigravityPayloadHasClaudeToolProvenanceID(updated) {
- // The replay ledger could not resolve every tool ID — the session lane
- // changed, the entry expired, the process restarted, or a turn never
- // committed. Degrade those calls instead of killing the conversation.
- degradedPayload, degradedCount := degradeAntigravityClaudeToolProvenanceIDs(updated)
- log.Warnf("antigravity executor: replay state missing for %d tool ID(s); rewriting them to synthetic IDs and continuing without reasoning replay for those calls", degradedCount)
- updated = normalizeAntigravityGeminiFunctionResponseRoles(degradedPayload)
- }
- // An identity-only restore drops the cached signature, which can leave a model
- // turn's first function call unsigned. Gemini rejects that, so re-assert the
- // invariant the pre-replay sanitizer established.
- updated = antigravityRepairUnsignedFirstFunctionCalls(updated)
- if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(updated); errPairing != nil {
- originalPairingValid := internalsignature.ValidateGeminiFunctionCallPairing(payload) == nil
+ if originalContentsRaw != "" && doc.Get("request.contents").Raw != originalContentsRaw {
+ _ = doc.SetRawBytes("request.contents", []byte(originalContentsRaw))
+ }
+ changed = false
+ replayApplied = false
+ }
+ if normalizeAntigravityGeminiFunctionResponseRolesDoc(doc) {
+ changed = true
+ }
+ if antigravityDocHasClaudeToolProvenanceID(doc) {
+ contents := doc.Get("request.contents")
+ if contents.IsArray() {
+ wrapped := []byte(`{"request":{"contents":` + contents.Raw + `}}`)
+ degradedPayload, degradedCount := degradeAntigravityClaudeToolProvenanceIDs(wrapped)
+ log.Warnf("antigravity executor: replay state missing for %d tool ID(s); rewriting them to synthetic IDs and continuing without reasoning replay for those calls", degradedCount)
+ updatedContents := util.GetGJSONBytesNoCopy(degradedPayload, "request.contents")
+ if updatedContents.IsArray() && updatedContents.Raw != contents.Raw {
+ if doc.SetRawBytes("request.contents", []byte(updatedContents.Raw)) == nil {
+ changed = true
+ }
+ }
+ if normalizeAntigravityGeminiFunctionResponseRolesDoc(doc) {
+ changed = true
+ }
+ }
+ }
+ if antigravityRepairUnsignedFirstFunctionCallsDoc(doc) {
+ changed = true
+ }
+ currentContentsRaw := doc.Get("request.contents").Raw
+ currentWrapped := []byte(`{}`)
+ if currentContentsRaw != "" {
+ currentWrapped = []byte(`{"request":{"contents":` + currentContentsRaw + `}}`)
+ }
+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(currentWrapped); errPairing != nil {
+ origWrapped := []byte(`{}`)
+ if originalContentsRaw != "" {
+ origWrapped = []byte(`{"request":{"contents":` + originalContentsRaw + `}}`)
+ }
+ originalPairingValid := internalsignature.ValidateGeminiFunctionCallPairing(origWrapped) == nil
if replayApplied && originalPairingValid && scope.valid() {
if _, errDelete := internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, scope.modelName, scope.sessionKey, scope.cacheSnapshot); errDelete != nil {
- // Invalidation is best-effort cleanup. Returning it here would replace
- // the pairing diagnosis below with an untyped error.
logAntigravityReasoningReplayDegraded(scope, "invalidate", errDelete)
}
log.Warnf("antigravity executor: reasoning replay broke Gemini function call pairing (%v); degrading to original payload", errPairing)
- return payload, scope, nil
+ if originalContentsRaw != "" {
+ _ = doc.SetRawBytes("request.contents", []byte(originalContentsRaw))
+ }
+ return scope, false, nil
}
- return payload, scope, statusErr{code: http.StatusBadRequest, msg: fmt.Sprintf("antigravity executor: invalid Gemini function call history: %v", errPairing)}
+ if originalContentsRaw != "" && doc.Get("request.contents").Raw != originalContentsRaw {
+ _ = doc.SetRawBytes("request.contents", []byte(originalContentsRaw))
+ }
+ return scope, false, statusErr{code: http.StatusBadRequest, msg: fmt.Sprintf("antigravity executor: invalid Gemini function call history: %v", errPairing)}
}
- return updated, scope, nil
+ return scope, changed, nil
}
func clearAntigravityReasoningReplayOnInvalidSignature(ctx context.Context, scope antigravityReasoningReplayScope, statusCode int, body []byte) error {
@@ -333,37 +434,93 @@ func clearAntigravityReasoningReplayOnInvalidSignature(ctx context.Context, scop
}
func applyAntigravityReasoningReplayCache(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) ([]byte, antigravityReasoningReplayScope, bool, error) {
- scope := antigravityReasoningReplayScopeFromRequest(ctx, modelName, req, opts, payload)
+ doc := ojson.NewDoc(payload)
+ scope, changed, err := applyAntigravityReasoningReplayCacheDoc(ctx, modelName, req, opts, doc)
+ if err != nil || !changed {
+ return payload, scope, false, err
+ }
+ return doc.Bytes(), scope, true, nil
+}
+
+func applyAntigravityReasoningReplayCacheDoc(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) (antigravityReasoningReplayScope, bool, error) {
+ if doc == nil {
+ return antigravityReasoningReplayScope{}, false, nil
+ }
+ scope := antigravityReasoningReplayScopeFromRequestDoc(ctx, modelName, req, opts, doc)
if !scope.valid() {
- return payload, scope, false, nil
+ return scope, false, nil
}
items, snapshot, ok, err := internalcache.GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx, scope.modelName, scope.sessionKey)
scope.cacheSnapshot = snapshot
- reservedBefore := antigravityCountClaudeToolProvenanceIDs(payload)
+ reservedBefore := antigravityCountClaudeToolProvenanceIDsDoc(doc)
if err != nil || !ok || len(items) == 0 {
- // A ledger miss on a payload that still carries reserved provenance IDs is
- // the signature of a session/lane switch, cache expiry, or a turn that never
- // committed. Log it so the two failure families stay distinguishable.
if reservedBefore > 0 {
log.Debugf("antigravity replay: ledger miss with %d reserved tool provenance ID(s) present (session=%s found=%t)",
reservedBefore, antigravityReplayLogKey(scope.sessionKey), ok)
}
- return payload, scope, false, err
+ return scope, false, err
}
var toolSchemas map[string]any
if opts.SourceFormat.String() == "claude" {
toolSchemas = antigravityReplayToolSchemasFromRequests(opts.OriginalRequest, req.Payload)
}
- updated, changed := applyAntigravityReasoningReplayItems(payload, items, toolSchemas)
+ wrapped, ok := antigravityReplayRequestEnvelopeFromDoc(doc)
+ if !ok {
+ return scope, false, nil
+ }
+ updated, changed := applyAntigravityReasoningReplayItems(wrapped, items, toolSchemas)
+ if changed {
+ updatedContents := util.GetGJSONBytesNoCopy(updated, "request.contents")
+ if updatedContents.Exists() {
+ _ = doc.SetRawBytes("request.contents", []byte(updatedContents.Raw))
+ }
+ }
if reservedBefore > 0 {
log.Debugf("antigravity replay: ledger items=%d reserved before=%d after=%d applied=%t (session=%s)",
- len(items), reservedBefore, antigravityCountClaudeToolProvenanceIDs(updated), changed,
+ len(items), reservedBefore, antigravityCountClaudeToolProvenanceIDsDoc(doc), changed,
antigravityReplayLogKey(scope.sessionKey))
}
- if !changed {
- return payload, scope, false, nil
+ return scope, changed, nil
+}
+
+func antigravityReplayRequestEnvelopeFromDoc(doc *ojson.Doc) ([]byte, bool) {
+ if doc == nil {
+ return nil, false
+ }
+ if requestNode := doc.Get("request"); requestNode.IsObject() {
+ return []byte(`{"request":` + requestNode.Raw + `}`), true
+ }
+ contents := doc.Get("request.contents")
+ if !contents.Exists() {
+ return nil, false
+ }
+ var buf bytes.Buffer
+ buf.WriteString(`{"request":{`)
+ wrote := false
+ for _, field := range []struct {
+ key string
+ path string
+ }{
+ {key: "systemInstruction", path: "request.systemInstruction"},
+ {key: "tools", path: "request.tools"},
+ {key: "toolConfig", path: "request.toolConfig"},
+ {key: "contents", path: "request.contents"},
+ } {
+ val := doc.Get(field.path)
+ if !val.Exists() {
+ continue
+ }
+ if wrote {
+ buf.WriteByte(',')
+ }
+ buf.WriteByte('"')
+ buf.WriteString(field.key)
+ buf.WriteString(`":`)
+ buf.WriteString(val.Raw)
+ wrote = true
}
- return updated, scope, true, nil
+ buf.WriteString(`}}`)
+ return buf.Bytes(), true
}
func applyAntigravityReasoningReplayItems(payload []byte, items [][]byte, toolSchemas map[string]any) ([]byte, bool) {
@@ -503,7 +660,7 @@ func applyAntigravityReplayItemsBatch(
}
func (b *antigravityReplayBatch) apply(item []byte, toolSchemas map[string]any) bool {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "thought_signature":
return b.applyThoughtSignature(itemResult)
@@ -514,16 +671,16 @@ func (b *antigravityReplayBatch) apply(item []byte, toolSchemas map[string]any)
}
}
-func (b *antigravityReplayBatch) part(key antigravityReplayPartKey) (gjson.Result, bool) {
+func (b *antigravityReplayBatch) part(key antigravityReplayPartKey) (ojson.Result, bool) {
if b == nil || b.index == nil || key.contentIndex < 0 || key.contentIndex >= len(b.index.contents) {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
parts := b.index.contents[key.contentIndex].parts
if key.partIndex < 0 || key.partIndex >= len(parts) {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
if replacement, ok := b.replacements[key]; ok {
- return gjson.ParseBytes(replacement), true
+ return util.ParseGJSONBytesNoCopy(replacement), true
}
return parts[key.partIndex], true
}
@@ -550,16 +707,16 @@ func (b *antigravityReplayBatch) removeThoughtSignatureFromOtherParts(contentInd
if !ok || antigravityNativePartThoughtSignature(part) != signature {
continue
}
- updated := []byte(part.Raw)
+ partDoc := ojson.NewDoc([]byte(part.Raw))
for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- updated, _ = sjson.DeleteBytes(updated, field)
+ _ = partDoc.Delete(field)
}
- changed = b.setPart(key, updated) || changed
+ changed = b.setPart(key, partDoc.Bytes()) || changed
}
return changed
}
-func (b *antigravityReplayBatch) applyThoughtSignature(itemResult gjson.Result) bool {
+func (b *antigravityReplayBatch) applyThoughtSignature(itemResult ojson.Result) bool {
signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
if signature == "" {
return true
@@ -582,7 +739,7 @@ func (b *antigravityReplayBatch) applyThoughtSignature(itemResult gjson.Result)
if antigravityHasNativeThoughtSignature(part.Get("thoughtSignature").String()) {
return true
}
- updated, errSet := sjson.SetBytes([]byte(part.Raw), "thoughtSignature", signature)
+ updated, errSet := ojson.SetBytes([]byte(part.Raw), "thoughtSignature", signature)
if errSet != nil {
return false
}
@@ -594,7 +751,7 @@ func (b *antigravityReplayBatch) applyThoughtSignature(itemResult gjson.Result)
return true
}
-func (b *antigravityReplayBatch) applyFunctionCallSignature(itemResult gjson.Result, toolSchemas map[string]any) bool {
+func (b *antigravityReplayBatch) applyFunctionCallSignature(itemResult ojson.Result, toolSchemas map[string]any) bool {
location, found := b.index.functionCallPartLocationForReplayWithSchemas(itemResult, toolSchemas)
if !found {
location, found = b.index.functionCallProvenanceLocation(itemResult, toolSchemas)
@@ -643,19 +800,19 @@ func (b *antigravityReplayBatch) applyFunctionCallSignature(itemResult gjson.Res
if restoreStableID && !b.functionResponsesCanRestoreID(currentID, name) {
return true
}
- updated, errSet := sjson.SetRawBytes([]byte(part.Raw), "functionCall", nativeCall)
- if errSet != nil {
+ partDoc := ojson.NewDoc([]byte(part.Raw))
+ if errSet := partDoc.SetRawBytes("functionCall", nativeCall); errSet != nil {
return false
}
for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- updated, _ = sjson.DeleteBytes(updated, field)
+ _ = partDoc.Delete(field)
}
if signature != "" {
- updated, errSet = sjson.SetBytes(updated, "thoughtSignature", signature)
- if errSet != nil {
+ if errSet := partDoc.Set("thoughtSignature", signature); errSet != nil {
return false
}
}
+ updated := partDoc.Bytes()
// Stage every fragment before committing any of them. A function replay can
// touch the call, matching responses, and duplicate signatures; a failed
@@ -671,11 +828,11 @@ func (b *antigravityReplayBatch) applyFunctionCallSignature(itemResult gjson.Res
if !okPart || antigravityNativePartThoughtSignature(otherPart) != signature {
continue
}
- otherUpdated := []byte(otherPart.Raw)
+ otherDoc := ojson.NewDoc([]byte(otherPart.Raw))
for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- otherUpdated, _ = sjson.DeleteBytes(otherUpdated, field)
+ _ = otherDoc.Delete(field)
}
- pending[otherKey] = otherUpdated
+ pending[otherKey] = otherDoc.Bytes()
}
}
if restoreStableID {
@@ -730,7 +887,7 @@ func (b *antigravityReplayBatch) stageFunctionResponseIDRestores(
for _, key := range b.functionResponsePartsByID[currentID] {
part, ok := b.part(key)
if replacement, exists := pending[key]; exists {
- part = gjson.ParseBytes(replacement)
+ part = util.ParseGJSONBytesNoCopy(replacement)
ok = true
}
if !ok {
@@ -740,15 +897,14 @@ func (b *antigravityReplayBatch) stageFunctionResponseIDRestores(
if !response.Exists() || strings.TrimSpace(response.Get("id").String()) != currentID {
return false
}
- updated, errSet := sjson.SetBytes([]byte(part.Raw), "functionResponse.id", nativeID)
- if errSet != nil {
+ partDoc := ojson.NewDoc([]byte(part.Raw))
+ if errSet := partDoc.Set("functionResponse.id", nativeID); errSet != nil {
return false
}
- updated, errSet = sjson.SetBytes(updated, "functionResponse.name", nativeName)
- if errSet != nil {
+ if errSet := partDoc.Set("functionResponse.name", nativeName); errSet != nil {
return false
}
- pending[key] = updated
+ pending[key] = partDoc.Bytes()
}
return true
}
@@ -801,13 +957,13 @@ func (b *antigravityReplayBatch) build(payload []byte) ([]byte, bool) {
return out, true
}
-func (b *antigravityReplayBatch) indexedPart(key antigravityReplayPartKey) (gjson.Result, bool) {
+func (b *antigravityReplayBatch) indexedPart(key antigravityReplayPartKey) (ojson.Result, bool) {
if b == nil || b.index == nil || key.contentIndex < 0 || key.contentIndex >= len(b.index.contents) {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
parts := b.index.contents[key.contentIndex].parts
if key.partIndex < 0 || key.partIndex >= len(parts) {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
return parts[key.partIndex], true
}
@@ -824,7 +980,7 @@ func filterAntigravityReasoningReplayItemsForRequestWithIndex(
) [][]byte {
filtered := make([][]byte, 0, len(items))
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "function_call_part":
signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
@@ -895,7 +1051,7 @@ func antigravityExistingToolCallKeys(payload []byte) map[string]bool {
return existing
}
-func antigravityReplayToolCallKeys(itemResult gjson.Result) []string {
+func antigravityReplayToolCallKeys(itemResult ojson.Result) []string {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
if callID == "" {
callID = strings.TrimSpace(itemResult.Get("id").String())
@@ -912,8 +1068,8 @@ func antigravityReplayToolCallKeys(itemResult gjson.Result) []string {
return []string{key}
}
-func antigravityReplayToolCallKeysFromPart(fc gjson.Result) []string {
- return antigravityReplayToolCallKeys(gjson.Parse(fc.Raw))
+func antigravityReplayToolCallKeysFromPart(fc ojson.Result) []string {
+ return antigravityReplayToolCallKeys(ojson.Parse(fc.Raw))
}
func antigravityFunctionCallKey(name, argsRaw, callID string) string {
@@ -944,25 +1100,36 @@ func restoreAntigravityFunctionResponseReplayIdentity(payload []byte, currentID,
if currentID == "" || nativeID == "" || nativeName == "" || currentID == nativeID {
return payload
}
- out := payload
- contents := util.GetGJSONBytesNoCopy(out, "request.contents")
- contents.ForEach(func(contentKey, content gjson.Result) bool {
- content.Get("parts").ForEach(func(partKey, part gjson.Result) bool {
+ var doc *ojson.Doc
+ changed := false
+ contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
+ contents.ForEach(func(contentKey, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(partKey, part ojson.Result) bool {
response := part.Get("functionResponse")
if !response.Exists() || strings.TrimSpace(response.Get("id").String()) != currentID {
return true
}
+ if doc == nil {
+ doc = ojson.NewDoc(payload)
+ }
responsePath := fmt.Sprintf("request.contents.%d.parts.%d.functionResponse", contentKey.Int(), partKey.Int())
- out, _ = sjson.SetBytes(out, responsePath+".id", nativeID)
- out, _ = sjson.SetBytes(out, responsePath+".name", nativeName)
+ if doc.Set(responsePath+".id", nativeID) == nil {
+ changed = true
+ }
+ if doc.Set(responsePath+".name", nativeName) == nil {
+ changed = true
+ }
return true
})
return true
})
- return out
+ if !changed || doc == nil {
+ return payload
+ }
+ return doc.Bytes()
}
-func (i *antigravityReplayRequestIndex) functionResponseContentIndexForReplay(itemResult gjson.Result) (int, string, bool) {
+func (i *antigravityReplayRequestIndex) functionResponseContentIndexForReplay(itemResult ojson.Result) (int, string, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
@@ -979,7 +1146,7 @@ func (i *antigravityReplayRequestIndex) functionResponseContentIndexForReplay(it
}
func (i *antigravityReplayRequestIndex) functionCallPartLocationForReplayWithSchemas(
- itemResult gjson.Result,
+ itemResult ojson.Result,
toolSchemas map[string]any,
) (antigravityReplayIndexedPart, bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
@@ -1082,7 +1249,7 @@ func (i *antigravityReplayRequestIndex) functionCallPartLocationForReplayWithSch
}
func (i *antigravityReplayRequestIndex) functionCallProvenanceLocation(
- itemResult gjson.Result,
+ itemResult ojson.Result,
toolSchemas map[string]any,
) (antigravityReplayIndexedPart, bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
@@ -1112,7 +1279,7 @@ func (i *antigravityReplayRequestIndex) functionCallProvenanceLocation(
// therefore costs this signature nothing, so it is deliberately not gated on
// the context fingerprint. The positional fallback below has no such proof and
// stays gated.
-func (i *antigravityReplayRequestIndex) thoughtSignaturePartIndex(itemResult gjson.Result) (contentIndex int, partIndex int, ok bool) {
+func (i *antigravityReplayRequestIndex) thoughtSignaturePartIndex(itemResult ojson.Result) (contentIndex int, partIndex int, ok bool) {
contentIndex = int(itemResult.Get("contentIndex").Int())
if i == nil || contentIndex < 0 || contentIndex >= len(i.contents) {
return -1, -1, false
@@ -1165,7 +1332,7 @@ func (i *antigravityReplayRequestIndex) thoughtSignaturePartIndex(itemResult gjs
return -1, -1, false
}
candidateIndex := int(itemResult.Get("partIndex").Int())
- if candidateIndex >= 0 && candidateIndex < len(parts) && parts[candidateIndex].Type != gjson.Null {
+ if candidateIndex >= 0 && candidateIndex < len(parts) && parts[candidateIndex].Type != ojson.Null {
if kind, _ := antigravityReplayPartFingerprint(parts[candidateIndex]); kind != "" {
partIndex = candidateIndex
}
@@ -1188,7 +1355,7 @@ func (i *antigravityReplayRequestIndex) thoughtSignaturePartIndex(itemResult gjs
return contentIndex, partIndex, true
}
-func (i *antigravityReplayRequestIndex) hasThoughtSignatureAt(itemResult gjson.Result) bool {
+func (i *antigravityReplayRequestIndex) hasThoughtSignatureAt(itemResult ojson.Result) bool {
contentIndex, partIndex, ok := i.thoughtSignaturePartIndex(itemResult)
if !ok {
return false
@@ -1197,7 +1364,7 @@ func (i *antigravityReplayRequestIndex) hasThoughtSignatureAt(itemResult gjson.R
return antigravityHasNativeThoughtSignature(part.Get("thoughtSignature").String())
}
-func (i *antigravityReplayRequestIndex) thoughtSignatureReplayPartPath(itemResult gjson.Result) (string, bool) {
+func (i *antigravityReplayRequestIndex) thoughtSignatureReplayPartPath(itemResult ojson.Result) (string, bool) {
contentIndex, partIndex, ok := i.thoughtSignaturePartIndex(itemResult)
if !ok {
return "", false
@@ -1205,7 +1372,7 @@ func (i *antigravityReplayRequestIndex) thoughtSignatureReplayPartPath(itemResul
return fmt.Sprintf("request.contents.%d.parts.%d", contentIndex, partIndex), true
}
-func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex int, name, callID, thoughtSig string, args gjson.Result) ([]byte, bool) {
+func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex int, name, callID, thoughtSig string, args ojson.Result) ([]byte, bool) {
contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
if !contents.IsArray() {
return payload, false
@@ -1238,16 +1405,16 @@ func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex
for i := beforeIndex; i < len(arr); i++ {
newArr = append(newArr, arr[i].Value())
}
- updated, err := sjson.SetBytes(payload, "request.contents", newArr)
+ updated, err := ojson.SetBytes(payload, "request.contents", newArr)
if err != nil {
return payload, false
}
return updated, true
}
-func appendAntigravityFunctionCallToModelContent(payload []byte, contentIndex int, name, callID, thoughtSig string, args gjson.Result) ([]byte, bool) {
+func appendAntigravityFunctionCallToModelContent(payload []byte, contentIndex int, name, callID, thoughtSig string, args ojson.Result) ([]byte, bool) {
contentPath := fmt.Sprintf("request.contents.%d", contentIndex)
- if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(payload, contentPath+".role").String()), "model") || !gjson.GetBytes(payload, contentPath+".parts").IsArray() {
+ if !strings.EqualFold(strings.TrimSpace(ojson.GetBytesNoCopy(payload, contentPath+".role").String()), "model") || !ojson.GetBytesNoCopy(payload, contentPath+".parts").IsArray() {
return payload, false
}
fc := map[string]any{"name": name}
@@ -1260,7 +1427,7 @@ func appendAntigravityFunctionCallToModelContent(payload []byte, contentIndex in
part := map[string]any{"functionCall": fc}
if thoughtSig == "" {
hasFunctionCall := false
- gjson.GetBytes(payload, contentPath+".parts").ForEach(func(_, existingPart gjson.Result) bool {
+ ojson.GetBytesNoCopy(payload, contentPath+".parts").ForEach(func(_, existingPart ojson.Result) bool {
hasFunctionCall = existingPart.Get("functionCall").Exists()
return !hasFunctionCall
})
@@ -1271,7 +1438,7 @@ func appendAntigravityFunctionCallToModelContent(payload []byte, contentIndex in
if thoughtSig != "" {
part["thoughtSignature"] = thoughtSig
}
- updated, errSet := sjson.SetBytes(payload, contentPath+".parts.-1", part)
+ updated, errSet := ojson.SetBytes(payload, contentPath+".parts.-1", part)
if errSet != nil {
return payload, false
}
@@ -1281,21 +1448,30 @@ func appendAntigravityFunctionCallToModelContent(payload []byte, contentIndex in
func antigravityRemoveThoughtSignatureFromOtherParts(payload []byte, contentIndex int, signature, keepPartPath string) []byte {
signature = strings.TrimSpace(signature)
partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex)
- parts := gjson.GetBytes(payload, partsPath)
+ parts := ojson.GetBytesNoCopy(payload, partsPath)
if signature == "" || !parts.IsArray() {
return payload
}
- out := payload
+ var doc *ojson.Doc
+ changed := false
for partIndex, part := range parts.Array() {
partPath := fmt.Sprintf("%s.%d", partsPath, partIndex)
if partPath == keepPartPath || antigravityNativePartThoughtSignature(part) != signature {
continue
}
+ if doc == nil {
+ doc = ojson.NewDoc(payload)
+ }
for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- out, _ = sjson.DeleteBytes(out, partPath+"."+field)
+ if doc.Delete(partPath+"."+field) == nil {
+ changed = true
+ }
}
}
- return out
+ if !changed || doc == nil {
+ return payload
+ }
+ return doc.Bytes()
}
func antigravityHasNativeThoughtSignature(signature string) bool {
@@ -1303,7 +1479,7 @@ func antigravityHasNativeThoughtSignature(signature string) bool {
return signature != "" && signature != "skip_thought_signature_validator"
}
-func antigravityReplayPartFingerprint(part gjson.Result) (kind, fingerprint string) {
+func antigravityReplayPartFingerprint(part ojson.Result) (kind, fingerprint string) {
if part.Get("functionCall").Exists() || part.Get("functionResponse").Exists() {
return "", ""
}
@@ -1319,7 +1495,7 @@ func antigravityReplayPartFingerprint(part gjson.Result) (kind, fingerprint stri
return kind, fmt.Sprintf("%x", sum[:])
}
-func antigravityReplayPartOccurrence(parts []gjson.Result, targetPartIndex int, targetKind, targetHash string) int {
+func antigravityReplayPartOccurrence(parts []ojson.Result, targetPartIndex int, targetKind, targetHash string) int {
occurrence := 0
for partIndex := 0; partIndex < targetPartIndex && partIndex < len(parts); partIndex++ {
kind, fingerprint := antigravityReplayPartFingerprint(parts[partIndex])
@@ -1333,13 +1509,13 @@ func antigravityReplayPartOccurrence(parts []gjson.Result, targetPartIndex int,
type antigravityReplayIndexedPart struct {
contentIndex int
partIndex int
- part gjson.Result
- functionCall gjson.Result
+ part ojson.Result
+ functionCall ojson.Result
}
type antigravityReplayIndexedContent struct {
- content gjson.Result
- parts []gjson.Result
+ content ojson.Result
+ parts []ojson.Result
}
// antigravityReplayRequestIndex is an immutable, request-scoped view over one
@@ -1557,7 +1733,7 @@ func (i *antigravityReplayRequestIndex) openAIChatContextFingerprint(contentInde
return antigravityOpenAIReplayContextPrefix + i.openAIChatContextFingerprints.at(contentIndex)
}
-func (i *antigravityReplayRequestIndex) contextMatches(itemResult gjson.Result, contentIndex int) bool {
+func (i *antigravityReplayRequestIndex) contextMatches(itemResult ojson.Result, contentIndex int) bool {
expected := strings.TrimSpace(itemResult.Get("contextHash").String())
if strings.HasPrefix(expected, antigravityOpenAIReplayContextPrefix) {
return expected == i.openAIChatContextFingerprint(contentIndex)
@@ -1668,11 +1844,11 @@ func (f *antigravityReplayContextFingerprints) at(beforeContentIndex int) string
f.writeString(strings.ToLower(strings.TrimSpace(content.content.Get("role").String())))
f.writeByte(0)
for _, part := range content.parts {
- normalized := []byte(part.Raw)
+ partDoc := ojson.NewDoc([]byte(part.Raw))
for _, signaturePath := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- normalized, _ = sjson.DeleteBytes(normalized, signaturePath)
+ _ = partDoc.Delete(signaturePath)
}
- f.write(antigravityCanonicalReplayJSON(normalized))
+ f.write(antigravityCanonicalReplayJSON(partDoc.Bytes()))
f.writeByte(0)
}
f.sums = append(f.sums, f.sum())
@@ -1692,7 +1868,7 @@ func antigravityReplayToolSchemasFromRequests(rawRequests ...[]byte) map[string]
continue
}
for _, tool := range tools.Array() {
- candidates := []gjson.Result{tool}
+ candidates := []ojson.Result{tool}
if function := tool.Get("function"); function.Exists() {
candidates = append(candidates, function)
}
@@ -1701,7 +1877,7 @@ func antigravityReplayToolSchemasFromRequests(rawRequests ...[]byte) map[string]
if name == "" {
continue
}
- var schema gjson.Result
+ var schema ojson.Result
for _, path := range []string{"input_schema", "parameters", "parametersJsonSchema"} {
if value := candidate.Get(path); value.Exists() && value.IsObject() {
schema = value
@@ -1729,9 +1905,9 @@ func antigravityReplayToolSchemasFromRequests(rawRequests ...[]byte) map[string]
return toolSchemas
}
-func antigravityReplayJSONValue(result gjson.Result) (any, bool) {
+func antigravityReplayJSONValue(result ojson.Result) (any, bool) {
raw := result.Raw
- if result.Type == gjson.String {
+ if result.Type == ojson.String {
raw = result.String()
}
var value any
@@ -1770,7 +1946,7 @@ func antigravityNormalizeReplayToolValue(value, schema any) any {
}
}
-func antigravityFunctionCallMatchesReplayItem(functionCall, itemResult gjson.Result, toolSchemas map[string]any) bool {
+func antigravityFunctionCallMatchesReplayItem(functionCall, itemResult ojson.Result, toolSchemas map[string]any) bool {
name := strings.TrimSpace(itemResult.Get("name").String())
if name == "" || strings.TrimSpace(functionCall.Get("name").String()) != name {
return false
@@ -1796,14 +1972,24 @@ func antigravityFunctionCallMatchesReplayItem(functionCall, itemResult gjson.Res
}
func antigravityPayloadHasClaudeToolProvenanceID(payload []byte) bool {
- contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
+ return antigravityContentsHasClaudeToolProvenanceID(util.GetGJSONBytesNoCopy(payload, "request.contents"))
+}
+
+func antigravityDocHasClaudeToolProvenanceID(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ return antigravityContentsHasClaudeToolProvenanceID(doc.Get("request.contents"))
+}
+
+func antigravityContentsHasClaudeToolProvenanceID(contents ojson.Result) bool {
if !contents.IsArray() {
return false
}
found := false
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
- hasReservedID := func(part gjson.Result) bool {
+ hasReservedID := func(part ojson.Result) bool {
for _, path := range []string{"functionCall.id", "functionResponse.id"} {
if util.IsGeminiClaudeToolUseID(part.Get(path).String()) {
return true
@@ -1812,11 +1998,11 @@ func antigravityPayloadHasClaudeToolProvenanceID(payload []byte) bool {
return false
}
if parts.IsArray() {
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
found = hasReservedID(part)
return !found
})
- } else if parts.Type != gjson.Null {
+ } else if parts.Type != ojson.Null {
// Result.Array returns a non-array JSON value as one item.
found = hasReservedID(parts)
}
@@ -1870,18 +2056,19 @@ func degradeAntigravityClaudeToolProvenanceIDs(payload []byte) ([]byte, int) {
if !util.IsGeminiClaudeToolUseID(id) {
continue
}
- updatedPart, _ := sjson.SetBytes([]byte(part.Raw), "functionCall.id", antigravitySyntheticToolCallID(id))
+ partDoc := ojson.NewDoc([]byte(part.Raw))
+ _ = partDoc.Set("functionCall.id", antigravitySyntheticToolCallID(id))
if part.Get("thoughtSignature").Exists() && part.Get("thoughtSignature").String() != "" {
if isFirstFC {
- updatedPart, _ = sjson.SetBytes(updatedPart, "thoughtSignature", internalsignature.GeminiSkipThoughtSignatureValidator)
+ _ = partDoc.Set("thoughtSignature", internalsignature.GeminiSkipThoughtSignatureValidator)
} else {
- updatedPart, _ = sjson.DeleteBytes(updatedPart, "thoughtSignature")
+ _ = partDoc.Delete("thoughtSignature")
}
}
replacements = append(replacements, partReplacement{
start: part.Index,
end: part.Index + len(part.Raw),
- data: updatedPart,
+ data: partDoc.Bytes(),
})
continue
}
@@ -1890,7 +2077,7 @@ func degradeAntigravityClaudeToolProvenanceIDs(payload []byte) ([]byte, int) {
if !util.IsGeminiClaudeToolUseID(id) {
continue
}
- updatedPart, _ := sjson.SetBytes([]byte(part.Raw), "functionResponse.id", antigravitySyntheticToolCallID(id))
+ updatedPart, _ := ojson.SetBytes([]byte(part.Raw), "functionResponse.id", antigravitySyntheticToolCallID(id))
replacements = append(replacements, partReplacement{
start: part.Index,
end: part.Index + len(part.Raw),
@@ -1932,12 +2119,23 @@ func degradeAntigravityClaudeToolProvenanceIDs(payload []byte) ([]byte, int) {
// context deliberately declines to replay one. Only a missing signature is filled
// in here, so native signatures are never touched.
func antigravityRepairUnsignedFirstFunctionCalls(payload []byte) []byte {
- contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
- if !contents.IsArray() {
+ doc := ojson.NewDoc(payload)
+ if !antigravityRepairUnsignedFirstFunctionCallsDoc(doc) {
return payload
}
- out := payload
- contents.ForEach(func(contentIndex, content gjson.Result) bool {
+ return doc.Bytes()
+}
+
+func antigravityRepairUnsignedFirstFunctionCallsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ contents := doc.Get("request.contents")
+ if !contents.IsArray() {
+ return false
+ }
+ changed := false
+ contents.ForEach(func(contentIndex, content ojson.Result) bool {
if !strings.EqualFold(strings.TrimSpace(content.Get("role").String()), "model") {
return true
}
@@ -1945,7 +2143,7 @@ func antigravityRepairUnsignedFirstFunctionCalls(payload []byte) []byte {
if !parts.IsArray() {
return true
}
- parts.ForEach(func(partIndex, part gjson.Result) bool {
+ parts.ForEach(func(partIndex, part ojson.Result) bool {
if !part.Get("functionCall").Exists() {
return true
}
@@ -1955,7 +2153,9 @@ func antigravityRepairUnsignedFirstFunctionCalls(payload []byte) []byte {
contentIndex.Int(),
partIndex.Int(),
)
- out, _ = sjson.SetBytes(out, path, internalsignature.GeminiSkipThoughtSignatureValidator)
+ if doc.Set(path, internalsignature.GeminiSkipThoughtSignatureValidator) == nil {
+ changed = true
+ }
}
// Only the first function call of a turn needs a signature; siblings stay
// unsigned to preserve the native parallel-call shape.
@@ -1963,7 +2163,7 @@ func antigravityRepairUnsignedFirstFunctionCalls(payload []byte) []byte {
})
return true
})
- return out
+ return changed
}
func antigravityCanonicalReplayJSON(raw []byte) []byte {
@@ -1980,7 +2180,7 @@ func antigravityCanonicalReplayJSON(raw []byte) []byte {
func antigravitySetReplayItemContextHashValue(item []byte, contextHash string) []byte {
if contextHash != "" {
- item, _ = sjson.SetBytes(item, "contextHash", contextHash)
+ item, _ = ojson.SetBytes(item, "contextHash", contextHash)
}
return item
}
@@ -1990,12 +2190,12 @@ func antigravityExistingReplayPartPath(payload []byte, contentIndex int, partInd
return "", false
}
partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex)
- parts := gjson.GetBytes(payload, partsPath)
+ parts := ojson.GetBytesNoCopy(payload, partsPath)
if !parts.IsArray() {
return "", false
}
arr := parts.Array()
- if partIndex >= len(arr) || arr[partIndex].Type == gjson.Null {
+ if partIndex >= len(arr) || arr[partIndex].Type == ojson.Null {
return "", false
}
return fmt.Sprintf("%s.%d", partsPath, partIndex), true
@@ -2006,7 +2206,7 @@ func antigravityReplayPartWritePath(payload []byte, contentIndex int, partIndex
return path
}
partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex)
- if gjson.GetBytes(payload, partsPath).IsArray() {
+ if ojson.GetBytesNoCopy(payload, partsPath).IsArray() {
return partsPath + ".-1"
}
return partsPath + ".0"
@@ -2023,7 +2223,7 @@ func insertAntigravityReasoningReplayItemsWithSchemas(index *antigravityReplayRe
// receive no index back and must rebuild their own if they keep using one.
for itemIndex, item := range items {
hasSuccessor := itemIndex+1 < len(items)
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "thought_signature":
sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
@@ -2035,12 +2235,12 @@ func insertAntigravityReasoningReplayItemsWithSchemas(index *antigravityReplayRe
continue
}
path := partPath + ".thoughtSignature"
- if antigravityHasNativeThoughtSignature(gjson.GetBytes(out, path).String()) {
+ if antigravityHasNativeThoughtSignature(ojson.GetBytesNoCopy(out, path).String()) {
continue
}
ci := int(itemResult.Get("contentIndex").Int())
out = antigravityRemoveThoughtSignatureFromOtherParts(out, ci, sig, partPath)
- updated, err := sjson.SetBytes(out, path, sig)
+ updated, err := ojson.SetBytes(out, path, sig)
if err != nil {
// antigravityRemoveThoughtSignatureFromOtherParts may already have
// rewritten out, so the index has to be refreshed regardless.
@@ -2068,31 +2268,31 @@ func insertAntigravityReasoningReplayItemsWithSchemas(index *antigravityReplayRe
return out, changed
}
-func antigravityNativeFunctionCallJSON(itemResult gjson.Result, fallbackID string) ([]byte, bool) {
+func antigravityNativeFunctionCallJSON(itemResult ojson.Result, fallbackID string) ([]byte, bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
if name == "" || !args.Exists() {
return nil, false
}
- functionCall := []byte(`{"name":""}`)
- functionCall, _ = sjson.SetBytes(functionCall, "name", name)
+ fcDoc := ojson.NewDoc([]byte(`{"name":""}`))
+ _ = fcDoc.Set("name", name)
callID := strings.TrimSpace(itemResult.Get("call_id").String())
if callID == "" {
callID = fallbackID
}
if callID != "" {
- functionCall, _ = sjson.SetBytes(functionCall, "id", callID)
+ _ = fcDoc.Set("id", callID)
}
- if args.Type == gjson.String {
- if parsed := gjson.Parse(args.String()); parsed.Exists() {
- functionCall, _ = sjson.SetRawBytes(functionCall, "args", []byte(parsed.Raw))
+ if args.Type == ojson.String {
+ if parsed := ojson.Parse(args.String()); parsed.Exists() {
+ _ = fcDoc.SetRawBytes("args", []byte(parsed.Raw))
} else {
- functionCall, _ = sjson.SetBytes(functionCall, "args", args.String())
+ _ = fcDoc.Set("args", args.String())
}
} else {
- functionCall, _ = sjson.SetRawBytes(functionCall, "args", []byte(args.Raw))
+ _ = fcDoc.SetRawBytes("args", []byte(args.Raw))
}
- return functionCall, true
+ return fcDoc.Bytes(), true
}
func antigravityFunctionResponsesCanRestoreID(payload []byte, currentID, nativeName string) bool {
@@ -2104,8 +2304,8 @@ func antigravityFunctionResponsesCanRestoreID(payload []byte, currentID, nativeN
return false
}
valid := true
- contents.ForEach(func(_, content gjson.Result) bool {
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
response := part.Get("functionResponse")
if !response.Exists() || strings.TrimSpace(response.Get("id").String()) != currentID {
return true
@@ -2124,9 +2324,9 @@ func antigravityFunctionResponsesCanRestoreID(payload []byte, currentID, nativeN
// thoughtSignature may be replayed as well; identity-only restores pass false
// because the surrounding context no longer matches the one the signature was
// issued for.
-func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, partIndex int, itemResult gjson.Result, allowLegacyIDRestore, allowSignature bool) ([]byte, bool) {
+func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, partIndex int, itemResult ojson.Result, allowLegacyIDRestore, allowSignature bool) ([]byte, bool) {
partPath := fmt.Sprintf("request.contents.%d.parts.%d", contentIndex, partIndex)
- currentCall := gjson.GetBytes(payload, partPath+".functionCall")
+ currentCall := ojson.GetBytesNoCopy(payload, partPath+".functionCall")
if !currentCall.Exists() {
return payload, false
}
@@ -2136,11 +2336,11 @@ func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, pa
restoreIdentity := currentID == nativeID || util.IsGeminiClaudeToolUseID(currentID) || allowLegacyIDRestore
if !restoreIdentity {
signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
- if !allowSignature || signature == "" || antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, partPath+".thoughtSignature").String()) {
+ if !allowSignature || signature == "" || antigravityHasNativeThoughtSignature(ojson.GetBytesNoCopy(payload, partPath+".thoughtSignature").String()) {
return payload, false
}
payload = antigravityRemoveThoughtSignatureFromOtherParts(payload, contentIndex, signature, partPath)
- updated, errSet := sjson.SetBytes(payload, partPath+".thoughtSignature", signature)
+ updated, errSet := ojson.SetBytes(payload, partPath+".thoughtSignature", signature)
return updated, errSet == nil
}
if currentID != nativeID && !antigravityFunctionResponsesCanRestoreID(payload, currentID, nativeName) {
@@ -2150,33 +2350,45 @@ func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, pa
if !okCall {
return payload, false
}
- out, errSet := sjson.SetRawBytes(payload, partPath+".functionCall", nativeCall)
- if errSet != nil {
+ doc := ojson.NewDoc(payload)
+ if errSet := doc.SetRawBytes(partPath+".functionCall", nativeCall); errSet != nil {
return payload, false
}
for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- out, _ = sjson.DeleteBytes(out, partPath+"."+field)
+ _ = doc.Delete(partPath + "." + field)
}
if signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()); allowSignature && signature != "" {
- out = antigravityRemoveThoughtSignatureFromOtherParts(out, contentIndex, signature, partPath)
- out, _ = sjson.SetBytes(out, partPath+".thoughtSignature", signature)
+ partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex)
+ if parts := doc.Get(partsPath); parts.IsArray() {
+ for idx, part := range parts.Array() {
+ otherPath := fmt.Sprintf("%s.%d", partsPath, idx)
+ if otherPath == partPath || antigravityNativePartThoughtSignature(part) != signature {
+ continue
+ }
+ for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
+ _ = doc.Delete(otherPath + "." + field)
+ }
+ }
+ }
+ _ = doc.Set(partPath+".thoughtSignature", signature)
}
if currentID != "" && nativeID != "" && currentID != nativeID {
- contents := util.GetGJSONBytesNoCopy(out, "request.contents")
- contents.ForEach(func(contentKey, content gjson.Result) bool {
- content.Get("parts").ForEach(func(partKey, part gjson.Result) bool {
+ contents := doc.Get("request.contents")
+ contents.ForEach(func(contentKey, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(partKey, part ojson.Result) bool {
response := part.Get("functionResponse")
if !response.Exists() || strings.TrimSpace(response.Get("id").String()) != currentID {
return true
}
responsePath := fmt.Sprintf("request.contents.%d.parts.%d.functionResponse", contentKey.Int(), partKey.Int())
- out, _ = sjson.SetBytes(out, responsePath+".id", nativeID)
- out, _ = sjson.SetBytes(out, responsePath+".name", nativeName)
+ _ = doc.Set(responsePath+".id", nativeID)
+ _ = doc.Set(responsePath+".name", nativeName)
return true
})
return true
})
}
+ out := doc.Bytes()
return out, !bytes.Equal(out, payload)
}
@@ -2184,7 +2396,7 @@ func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, pa
// index, which must describe exactly the payload passed alongside it. Every
// lookup happens before the first mutation, so one index is valid for the whole
// call.
-func mergeAntigravityFunctionCallPartReplayWithSchemas(index *antigravityReplayRequestIndex, payload []byte, itemResult gjson.Result, toolSchemas map[string]any) ([]byte, bool) {
+func mergeAntigravityFunctionCallPartReplayWithSchemas(index *antigravityReplayRequestIndex, payload []byte, itemResult ojson.Result, toolSchemas map[string]any) ([]byte, bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
callID := strings.TrimSpace(itemResult.Get("call_id").String())
@@ -2250,7 +2462,7 @@ func mergeAntigravityFunctionCallPartReplayWithSchemas(index *antigravityReplayR
if callID != "" {
fc["id"] = callID
}
- if args.Type == gjson.String {
+ if args.Type == ojson.String {
fc["args"] = args.String()
} else {
var parsed any
@@ -2262,27 +2474,38 @@ func mergeAntigravityFunctionCallPartReplayWithSchemas(index *antigravityReplayR
if sig != "" {
part["thoughtSignature"] = sig
}
- if updated, err := sjson.SetBytes(out, antigravityReplayPartWritePath(out, ci, pi), part); err == nil {
+ if updated, err := ojson.SetBytes(out, antigravityReplayPartWritePath(out, ci, pi), part); err == nil {
return updated, true
}
return payload, false
}
pathSig := partPath + ".thoughtSignature"
- if sig != "" && !antigravityHasNativeThoughtSignature(gjson.GetBytes(out, pathSig).String()) {
- out = antigravityRemoveThoughtSignatureFromOtherParts(out, ci, sig, partPath)
- if updated, err := sjson.SetBytes(out, pathSig, sig); err == nil {
- out = updated
+ doc := ojson.NewDoc(out)
+ if sig != "" && !antigravityHasNativeThoughtSignature(doc.Get(pathSig).String()) {
+ partsPath := fmt.Sprintf("request.contents.%d.parts", ci)
+ if parts := doc.Get(partsPath); parts.IsArray() {
+ for idx, part := range parts.Array() {
+ otherPath := fmt.Sprintf("%s.%d", partsPath, idx)
+ if otherPath == partPath || antigravityNativePartThoughtSignature(part) != sig {
+ continue
+ }
+ for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
+ _ = doc.Delete(otherPath + "." + field)
+ }
+ }
+ }
+ if errSet := doc.Set(pathSig, sig); errSet == nil {
changed = true
}
}
pathFC := partPath + ".functionCall"
- if !gjson.GetBytes(out, pathFC).Exists() {
+ if !doc.Get(pathFC).Exists() {
fc := map[string]any{"name": name}
if callID != "" {
fc["id"] = callID
}
- if args.Type == gjson.String {
+ if args.Type == ojson.String {
fc["args"] = args.String()
} else {
var parsed any
@@ -2290,11 +2513,13 @@ func mergeAntigravityFunctionCallPartReplayWithSchemas(index *antigravityReplayR
fc["args"] = parsed
}
}
- if updated, err := sjson.SetBytes(out, pathFC, fc); err == nil {
- out = updated
+ if errSet := doc.Set(pathFC, fc); errSet == nil {
changed = true
}
}
+ if changed {
+ out = doc.Bytes()
+ }
return out, changed
}
@@ -2335,15 +2560,15 @@ func newAntigravityReasoningReplayAccumulator(scope antigravityReasoningReplaySc
if scope.openAIChat {
responseContextHash = index.openAIChatContextFingerprint(contentIndex)
for itemIndex, item := range items {
- cachedContentIndex := int(gjson.GetBytes(item, "contentIndex").Int())
+ cachedContentIndex := int(ojson.GetBytesNoCopy(item, "contentIndex").Int())
items[itemIndex] = antigravitySetReplayItemContextHashValue(item, index.openAIChatContextFingerprint(cachedContentIndex))
}
}
seenSignatures := make(map[string]bool, len(items))
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
if signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()); signature != "" {
- seenSignatures[signature] = true
+ seenSignatures[strings.Clone(signature)] = true
}
}
itemBytes := 0
@@ -2430,7 +2655,7 @@ func (i *antigravityReplayRequestIndex) reasoningReplayItemsFromRequest() [][]by
continue
}
item := buildAntigravityThoughtSignatureItem(contentIndex, targetPartIndex, signature, kind, fingerprint)
- item, _ = sjson.SetBytes(item, "targetOccurrence", antigravityReplayPartOccurrence(content.parts, targetPartIndex, kind, fingerprint))
+ item, _ = ojson.SetBytes(item, "targetOccurrence", antigravityReplayPartOccurrence(content.parts, targetPartIndex, kind, fingerprint))
items = append(items, antigravitySetReplayItemContextHashValue(item, i.contextFingerprint(contentIndex)))
}
}
@@ -2454,14 +2679,14 @@ func (a *antigravityReasoningReplayAccumulator) attachDetachedSignatureToLastFun
return
}
for itemIndex := len(a.items) - 1; itemIndex >= 0; itemIndex-- {
- item := gjson.ParseBytes(a.items[itemIndex])
+ item := util.ParseGJSONBytesNoCopy(a.items[itemIndex])
if item.Get("type").String() != "function_call_part" {
continue
}
if strings.TrimSpace(item.Get("thoughtSignature").String()) != "" {
return
}
- updated, errSet := sjson.SetBytes(a.items[itemIndex], "thoughtSignature", signature)
+ updated, errSet := ojson.SetBytes(a.items[itemIndex], "thoughtSignature", signature)
if errSet != nil {
return
}
@@ -2492,7 +2717,7 @@ func (a *antigravityReasoningReplayAccumulator) claimPrecedingTurnThoughtSignatu
return ""
}
for itemIndex := range a.items {
- item := gjson.ParseBytes(a.items[itemIndex])
+ item := util.ParseGJSONBytesNoCopy(a.items[itemIndex])
if item.Get("type").String() == "function_call_part" &&
int(item.Get("contentIndex").Int()) == a.contentIndex &&
strings.TrimSpace(item.Get("thoughtSignature").String()) != "" {
@@ -2500,36 +2725,36 @@ func (a *antigravityReasoningReplayAccumulator) claimPrecedingTurnThoughtSignatu
}
}
for itemIndex := len(a.items) - 1; itemIndex >= 0; itemIndex-- {
- item := gjson.ParseBytes(a.items[itemIndex])
+ item := util.ParseGJSONBytesNoCopy(a.items[itemIndex])
if item.Get("type").String() != "thought_signature" ||
int(item.Get("contentIndex").Int()) != a.contentIndex ||
item.Get("targetKind").String() != "thought" {
continue
}
if sig := strings.TrimSpace(item.Get("thoughtSignature").String()); sig != "" {
- return sig
+ return strings.Clone(sig)
}
}
return ""
}
func (a *antigravityReasoningReplayAccumulator) observeResponsePayload(payload []byte) {
- if finishReason := strings.TrimSpace(gjson.GetBytes(payload, "response.candidates.0.finishReason").String()); finishReason != "" {
+ if finishReason := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response.candidates.0.finishReason").String()); finishReason != "" {
a.terminal = true
- } else if finishReason := strings.TrimSpace(gjson.GetBytes(payload, "candidates.0.finishReason").String()); finishReason != "" {
+ } else if finishReason := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "candidates.0.finishReason").String()); finishReason != "" {
a.terminal = true
}
- parts := gjson.GetBytes(payload, "response.candidates.0.content.parts")
+ parts := ojson.GetBytesNoCopy(payload, "response.candidates.0.content.parts")
if !parts.IsArray() {
- parts = gjson.GetBytes(payload, "candidates.0.content.parts")
+ parts = ojson.GetBytesNoCopy(payload, "candidates.0.content.parts")
}
if !parts.IsArray() {
return
}
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
pi := a.nextPartIndex
a.nextPartIndex++
- signature := antigravityNativePartThoughtSignature(part)
+ signature := strings.Clone(antigravityNativePartThoughtSignature(part))
if !antigravityHasNativeThoughtSignature(signature) {
signature = ""
}
@@ -2678,16 +2903,20 @@ func (a *antigravityReasoningReplayAccumulator) observeResponsePayload(payload [
func buildAntigravityThoughtSignatureItem(contentIndex, partIndex int, signature, targetKind, targetHash string) []byte {
item := []byte(fmt.Sprintf(`{"type":"thought_signature","thoughtSignature":%q,"contentIndex":%d,"partIndex":%d}`,
signature, contentIndex, partIndex))
+ if targetKind == "" && targetHash == "" {
+ return item
+ }
+ itemDoc := ojson.NewDoc(item)
if targetKind != "" {
- item, _ = sjson.SetBytes(item, "targetKind", targetKind)
+ _ = itemDoc.Set("targetKind", targetKind)
}
if targetHash != "" {
- item, _ = sjson.SetBytes(item, "targetHash", targetHash)
+ _ = itemDoc.Set("targetHash", targetHash)
}
- return item
+ return itemDoc.Bytes()
}
-func buildAntigravityFunctionCallPartItem(contentIndex, partIndex, targetOccurrence int, fc gjson.Result, signature string) []byte {
+func buildAntigravityFunctionCallPartItem(contentIndex, partIndex, targetOccurrence int, fc ojson.Result, signature string) []byte {
item := map[string]any{
"type": "function_call_part",
"contentIndex": contentIndex,
@@ -2699,7 +2928,7 @@ func buildAntigravityFunctionCallPartItem(contentIndex, partIndex, targetOccurre
item["call_id"] = id
}
if args := fc.Get("args"); args.Exists() {
- if args.Type == gjson.String {
+ if args.Type == ojson.String {
item["args"] = args.String()
} else {
item["args"] = json.RawMessage(args.Raw)
@@ -2741,7 +2970,7 @@ func (a *antigravityReasoningReplayAccumulator) flushPendingThoughtSignaturesFor
continue
}
item := buildAntigravityThoughtSignatureItem(a.contentIndex, partIndex, pending.signature, targetKind, targetHash)
- item, _ = sjson.SetBytes(item, "targetOccurrence", targetOccurrence)
+ item, _ = ojson.SetBytes(item, "targetOccurrence", targetOccurrence)
a.appendItem(antigravitySetReplayItemContextHashValue(item, a.responseContextHash))
}
a.pendingSignatures = remaining
@@ -2816,7 +3045,7 @@ func cacheAntigravityReasoningReplayFromResponse(ctx context.Context, scope anti
return
}
acc.observeResponsePayload(body)
- if !acc.terminal && (gjson.GetBytes(body, "response.candidates.0").Exists() || gjson.GetBytes(body, "candidates.0").Exists()) {
+ if !acc.terminal && (ojson.GetBytesNoCopy(body, "response.candidates.0").Exists() || ojson.GetBytesNoCopy(body, "candidates.0").Exists()) {
acc.terminal = true
}
acc.Commit(ctx)
@@ -2830,6 +3059,13 @@ func applyAntigravityNativeSignatureReplayIfNeeded(modelName string, payload []b
return payload
}
+func applyAntigravityNativeSignatureReplayIfNeededDoc(modelName string, doc *ojson.Doc) {
+ if doc == nil || antigravityUsesReasoningReplayCache(modelName) {
+ return
+ }
+ // Native per-part signature replay is not on upstream/dev; Gemini uses HOME replay only.
+}
+
func antigravityUsesReasoningReplayCache(modelName string) bool {
modelName = strings.ToLower(modelName)
if strings.Contains(modelName, "claude") {
@@ -2838,10 +3074,10 @@ func antigravityUsesReasoningReplayCache(modelName string) bool {
return strings.Contains(modelName, "gemini") || strings.Contains(modelName, "flash") || strings.Contains(modelName, "agent")
}
-func antigravityNativePartThoughtSignature(part gjson.Result) string {
+func antigravityNativePartThoughtSignature(part ojson.Result) string {
for _, path := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
if signature := strings.TrimSpace(part.Get(path).String()); signature != "" {
- return signature
+ return strings.Clone(signature)
}
}
return ""
diff --git a/internal/runtime/executor/antigravity_reasoning_replay_index_test.go b/internal/runtime/executor/antigravity_reasoning_replay_index_test.go
index f3d33ddbe36..a39bc00b851 100644
--- a/internal/runtime/executor/antigravity_reasoning_replay_index_test.go
+++ b/internal/runtime/executor/antigravity_reasoning_replay_index_test.go
@@ -7,10 +7,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// antigravityReplayItemContextHashForTest stamps an item with the production
@@ -21,12 +20,12 @@ func antigravityReplayItemContextHashForTest(item, payload []byte, contentIndex
}
func legacyAntigravityReasoningReplayItemsFromRequest(payload []byte) [][]byte {
- contents := gjson.GetBytes(payload, "request.contents")
+ contents := ojson.GetBytes(payload, "request.contents")
if !contents.IsArray() {
return nil
}
items := make([][]byte, 0)
- contents.ForEach(func(contentKey, content gjson.Result) bool {
+ contents.ForEach(func(contentKey, content ojson.Result) bool {
if !strings.EqualFold(strings.TrimSpace(content.Get("role").String()), "model") {
return true
}
@@ -68,7 +67,7 @@ func legacyAntigravityReasoningReplayItemsFromRequest(payload []byte) [][]byte {
continue
}
item := buildAntigravityThoughtSignatureItem(contentIndex, targetPartIndex, signature, kind, fingerprint)
- item, _ = sjson.SetBytes(item, "targetOccurrence", antigravityReplayPartOccurrence(partArray, targetPartIndex, kind, fingerprint))
+ item, _ = ojson.SetBytes(item, "targetOccurrence", antigravityReplayPartOccurrence(partArray, targetPartIndex, kind, fingerprint))
items = append(items, legacyAntigravitySetReplayItemContextHash(item, payload, contentIndex))
}
return true
@@ -79,12 +78,12 @@ func legacyAntigravityReasoningReplayItemsFromRequest(payload []byte) [][]byte {
func legacyFilterAntigravityReasoningReplayItemsForRequestWithSchemas(payload []byte, items [][]byte, toolSchemas map[string]any) [][]byte {
filtered := make([][]byte, 0, len(items))
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := ojson.ParseBytes(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "function_call_part":
signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
if contentIndex, partIndex, foundCall := legacyAntigravityFunctionCallPartLocationForReplayWithSchemas(payload, itemResult, toolSchemas); foundCall {
- part := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d", contentIndex, partIndex))
+ part := ojson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d", contentIndex, partIndex))
currentID := strings.TrimSpace(part.Get("functionCall.id").String())
nativeID := strings.TrimSpace(itemResult.Get("call_id").String())
needsNativeRestore := currentID != nativeID || !bytes.Equal(
@@ -109,7 +108,7 @@ func legacyFilterAntigravityReasoningReplayItemsForRequestWithSchemas(payload []
}
contextMatches := legacyAntigravityReplayItemContextMatches(payload, itemResult, responseIndex)
if !contextMatches && responseIndex > 0 {
- previousRole := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseIndex-1)).String()
+ previousRole := ojson.GetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseIndex-1)).String()
contextMatches = strings.EqualFold(strings.TrimSpace(previousRole), "model") && legacyAntigravityReplayItemContextMatches(payload, itemResult, responseIndex-1)
}
if !contextMatches {
@@ -250,14 +249,14 @@ func TestFilterAntigravityReasoningReplayItemsWithIndexMatchesLegacy(t *testing.
]}
}`)
items := legacyAntigravityReasoningReplayItemsFromRequest(payload)
- withoutSignatures, errDelete := sjson.DeleteBytes(payload, "request.contents.1.parts.0.thoughtSignature")
- if errDelete != nil {
+ withoutSignaturesDoc := ojson.NewDoc(payload)
+ if errDelete := withoutSignaturesDoc.Delete("request.contents.1.parts.0.thoughtSignature"); errDelete != nil {
t.Fatal(errDelete)
}
- withoutSignatures, errDelete = sjson.DeleteBytes(withoutSignatures, "request.contents.1.parts.1.thoughtSignature")
- if errDelete != nil {
+ if errDelete := withoutSignaturesDoc.Delete("request.contents.1.parts.1.thoughtSignature"); errDelete != nil {
t.Fatal(errDelete)
}
+ withoutSignatures := withoutSignaturesDoc.Bytes()
for _, test := range []struct {
name string
@@ -301,19 +300,19 @@ func TestAntigravityReplayRequestIndexRandomizedDifferential(t *testing.T) {
var errSet error
switch randomSource.Intn(7) {
case 0:
- payload, errSet = sjson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.thoughtSignature", callContentIndex))
+ payload, errSet = ojson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.thoughtSignature", callContentIndex))
case 1:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex), fmt.Sprintf("changed-%d", turn))
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex), fmt.Sprintf("changed-%d", turn))
case 2:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.args.turn", callContentIndex), turn+100)
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.args.turn", callContentIndex), turn+100)
case 3:
- payload, errSet = sjson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex))
+ payload, errSet = ojson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex))
case 4:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionResponse.id", responseContentIndex), fmt.Sprintf("changed-%d", turn))
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionResponse.id", responseContentIndex), fmt.Sprintf("changed-%d", turn))
case 5:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseContentIndex), "user")
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseContentIndex), "user")
case 6:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex), "call-0")
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex), "call-0")
}
if errSet != nil {
t.Fatalf("case %d mutation failed: %v", caseIndex, errSet)
@@ -364,7 +363,7 @@ func TestAntigravityReasoningReplayAccumulatorUsesIndexedContextHash(t *testing.
if len(accumulator.items) != 1 {
t.Fatalf("items = %d, want 1", len(accumulator.items))
}
- if got := gjson.GetBytes(accumulator.items[0], "contextHash").String(); got != wantContextHash {
+ if got := ojson.GetBytes(accumulator.items[0], "contextHash").String(); got != wantContextHash {
t.Fatalf("item context hash = %q, want %q", got, wantContextHash)
}
}
@@ -422,7 +421,7 @@ func TestApplyAntigravityReasoningReplayItemsBatchPreservesSignatureMoveOrder(t
const signature = "shared-native-signature-123456789"
items := make([][]byte, 0, 2)
for partIndex, text := range []string{"first", "second"} {
- kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"` + text + `"}`))
+ kind, fingerprint := antigravityReplayPartFingerprint(ojson.Parse(`{"text":"` + text + `"}`))
item := buildAntigravityThoughtSignatureItem(1, partIndex, signature, kind, fingerprint)
items = append(items, antigravityReplayItemContextHashForTest(item, payload, 1))
}
@@ -440,10 +439,10 @@ func TestApplyAntigravityReasoningReplayItemsBatchPreservesSignatureMoveOrder(t
if gotChanged != wantChanged || !bytes.Equal(got, want) {
t.Fatalf("batch differs from sequential replay\n got: %s\nwant: %s", got, want)
}
- if first := gjson.GetBytes(got, "request.contents.1.parts.0.thoughtSignature"); first.Exists() {
+ if first := ojson.GetBytes(got, "request.contents.1.parts.0.thoughtSignature"); first.Exists() {
t.Fatalf("signature remained on the first part: %s", got)
}
- if second := gjson.GetBytes(got, "request.contents.1.parts.1.thoughtSignature").String(); second != signature {
+ if second := ojson.GetBytes(got, "request.contents.1.parts.1.thoughtSignature").String(); second != signature {
t.Fatalf("second signature = %q, want %q", second, signature)
}
}
@@ -456,7 +455,7 @@ func TestApplyAntigravityReasoningReplayItemsBatchPreservesStickyChangedResult(t
partIndex int
text string
}{{partIndex: 1, text: "second"}, {partIndex: 0, text: "first"}} {
- kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"` + target.text + `"}`))
+ kind, fingerprint := antigravityReplayPartFingerprint(ojson.Parse(`{"text":"` + target.text + `"}`))
item := buildAntigravityThoughtSignatureItem(1, target.partIndex, signature, kind, fingerprint)
items = append(items, antigravityReplayItemContextHashForTest(item, payload, 1))
}
@@ -502,10 +501,10 @@ func TestApplyAntigravityReasoningReplayItemsBatchesStableProvenanceIDs(t *testi
callContentIndex := 1 + turn*2
responseContentIndex := callContentIndex + 1
wantID := fmt.Sprintf("call-%d", turn)
- if gotID := gjson.GetBytes(got, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex)).String(); gotID != wantID {
+ if gotID := ojson.GetBytes(got, fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", callContentIndex)).String(); gotID != wantID {
t.Fatalf("call %d ID = %q, want %q", turn, gotID, wantID)
}
- if gotID := gjson.GetBytes(got, fmt.Sprintf("request.contents.%d.parts.0.functionResponse.id", responseContentIndex)).String(); gotID != wantID {
+ if gotID := ojson.GetBytes(got, fmt.Sprintf("request.contents.%d.parts.0.functionResponse.id", responseContentIndex)).String(); gotID != wantID {
t.Fatalf("response %d ID = %q, want %q", turn, gotID, wantID)
}
}
@@ -537,7 +536,7 @@ func TestApplyAntigravityReasoningReplayItemsSegmentsDuplicateStableProvenanceID
}
for _, contentIndex := range []int{1, 3} {
path := fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", contentIndex)
- if gotID := gjson.GetBytes(got, path).String(); gotID != nativeID {
+ if gotID := ojson.GetBytes(got, path).String(); gotID != nativeID {
t.Fatalf("call at contents[%d] ID = %q, want %q", contentIndex, gotID, nativeID)
}
}
@@ -571,7 +570,7 @@ func TestApplyAntigravityReasoningReplayItemsFallsBackAfterIdentityChangesContex
if gotChanged != wantChanged || !bytes.Equal(got, want) {
t.Fatalf("fallback differs from sequential replay\n got: %s\nwant: %s", got, want)
}
- if signature := gjson.GetBytes(got, "request.contents.3.parts.0.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(got, "request.contents.3.parts.0.thoughtSignature"); signature.Exists() {
t.Fatalf("stale-context signature was applied: %s", got)
}
}
@@ -583,7 +582,7 @@ func TestApplyAntigravityReasoningReplayItemsSegmentsLateStructuralFallback(t *t
payload := syntheticAntigravityReplayBenchmarkPayloadWithoutSignatures(1<<10, turns)
lastCallPath := fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", 1+(turns-1)*2)
var errSet error
- payload, errSet = sjson.SetBytes(payload, lastCallPath, "lookup-legacy-client-id")
+ payload, errSet = ojson.SetBytes(payload, lastCallPath, "lookup-legacy-client-id")
if errSet != nil {
t.Fatalf("set legacy call ID: %v", errSet)
}
@@ -600,7 +599,7 @@ func TestApplyAntigravityReasoningReplayItemsSegmentsLateStructuralFallback(t *t
t.Fatalf("segmented fallback differs from sequential replay\n got: %s\nwant: %s", got, want)
}
wantLastID := fmt.Sprintf("call-%d", turns-1)
- if gotLastID := gjson.GetBytes(got, lastCallPath).String(); gotLastID != wantLastID {
+ if gotLastID := ojson.GetBytes(got, lastCallPath).String(); gotLastID != wantLastID {
t.Fatalf("last call ID = %q, want %q", gotLastID, wantLastID)
}
}
@@ -762,7 +761,7 @@ func TestAntigravityReplayMergeRandomizedDifferential(t *testing.T) {
items := legacyAntigravityReasoningReplayItemsFromRequest(basePayload)
thoughtItems, callItems := 0, 0
for _, item := range items {
- switch gjson.GetBytes(item, "type").String() {
+ switch ojson.GetBytes(item, "type").String() {
case "thought_signature":
thoughtItems++
case "function_call_part":
@@ -784,23 +783,23 @@ func TestAntigravityReplayMergeRandomizedDifferential(t *testing.T) {
var errSet error
switch randomSource.Intn(9) {
case 0:
- payload, errSet = sjson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", modelIndex, part))
+ payload, errSet = ojson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", modelIndex, part))
case 1:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.2.functionCall.args.turn", modelIndex), turn+50)
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.2.functionCall.args.turn", modelIndex), turn+50)
case 2:
- payload, errSet = sjson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.2.functionCall.id", modelIndex))
+ payload, errSet = ojson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.2.functionCall.id", modelIndex))
case 3:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.1.text", modelIndex), "drifted")
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.1.text", modelIndex), "drifted")
case 4:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseIndex), "model")
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseIndex), "model")
case 5:
- payload, errSet = sjson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d", modelIndex, part))
+ payload, errSet = ojson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d", modelIndex, part))
case 6:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.thought", modelIndex), false)
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.thought", modelIndex), false)
case 7:
- payload, errSet = sjson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.2.functionCall.id", modelIndex), "call-0")
+ payload, errSet = ojson.SetBytes(payload, fmt.Sprintf("request.contents.%d.parts.2.functionCall.id", modelIndex), "call-0")
case 8:
- payload, errSet = sjson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "ANY")
+ payload, errSet = ojson.SetBytes(payload, "request.toolConfig.functionCallingConfig.mode", "ANY")
}
if errSet != nil {
t.Fatalf("case %d mutation failed: %v", caseIndex, errSet)
@@ -871,7 +870,7 @@ func TestAntigravityReplayLegacyItemWithoutTargetHash(t *testing.T) {
// streamed signature-only part that no longer exists.
item := buildAntigravityThoughtSignatureItem(1, 7, signature, "", "")
item = antigravityReplayItemContextHashForTest(item, payload, 1)
- if gjson.GetBytes(item, "targetHash").Exists() {
+ if ojson.GetBytes(item, "targetHash").Exists() {
t.Fatal("this test must exercise the no-targetHash path")
}
@@ -879,11 +878,11 @@ func TestAntigravityReplayLegacyItemWithoutTargetHash(t *testing.T) {
if !changed {
t.Fatalf("legacy positional item was not applied: %s", got)
}
- if sig := gjson.GetBytes(got, "request.contents.1.parts.2.thoughtSignature").String(); sig != signature {
+ if sig := ojson.GetBytes(got, "request.contents.1.parts.2.thoughtSignature").String(); sig != signature {
t.Fatalf("signature must attach to the LAST semantic part, got parts.2=%q body=%s", sig, got)
}
for _, path := range []string{"request.contents.1.parts.0.thoughtSignature", "request.contents.1.parts.1.thoughtSignature"} {
- if gjson.GetBytes(got, path).Exists() {
+ if ojson.GetBytes(got, path).Exists() {
t.Fatalf("signature leaked to %s: %s", path, got)
}
}
@@ -894,7 +893,7 @@ func TestAntigravityReplayLegacyItemWithoutTargetHash(t *testing.T) {
}
// Context drift must reject the positional guess entirely.
- drifted, errSet := sjson.SetBytes(payload, "request.contents.0.parts.0.text", "different question")
+ drifted, errSet := ojson.SetBytes(payload, "request.contents.0.parts.0.text", "different question")
if errSet != nil {
t.Fatal(errSet)
}
@@ -917,7 +916,7 @@ func BenchmarkApplyAntigravityReasoningReplayItems(b *testing.B) {
payload := base
for turn := range turns {
var err error
- payload, err = sjson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.thoughtSignature", 1+turn*2))
+ payload, err = ojson.DeleteBytes(payload, fmt.Sprintf("request.contents.%d.parts.0.thoughtSignature", 1+turn*2))
if err != nil {
b.Fatal(err)
}
@@ -973,7 +972,7 @@ func BenchmarkApplyAntigravityReasoningReplayItemsLargeSegmentedFallback(b *test
payload := syntheticAntigravityReplayBenchmarkPayloadWithoutSignatures(inlineBytes, turns)
lastCallPath := fmt.Sprintf("request.contents.%d.parts.0.functionCall.id", 1+(turns-1)*2)
var errSet error
- payload, errSet = sjson.SetBytes(payload, lastCallPath, "lookup-legacy-client-id")
+ payload, errSet = ojson.SetBytes(payload, lastCallPath, "lookup-legacy-client-id")
if errSet != nil {
b.Fatalf("set legacy call ID: %v", errSet)
}
diff --git a/internal/runtime/executor/antigravity_reasoning_replay_legacy_oracle_test.go b/internal/runtime/executor/antigravity_reasoning_replay_legacy_oracle_test.go
index 4c53fcba0c9..d356e8e49f4 100644
--- a/internal/runtime/executor/antigravity_reasoning_replay_legacy_oracle_test.go
+++ b/internal/runtime/executor/antigravity_reasoning_replay_legacy_oracle_test.go
@@ -16,13 +16,12 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
-func legacyAntigravityFunctionResponseContentIndexForReplay(payload []byte, itemResult gjson.Result) (int, string, bool) {
+func legacyAntigravityFunctionResponseContentIndexForReplay(payload []byte, itemResult ojson.Result) (int, string, bool) {
callID := strings.TrimSpace(itemResult.Get("call_id").String())
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
@@ -91,7 +90,7 @@ func legacyAntigravityFunctionCallPartLocation(payload []byte, callID string) (c
return -1, -1, false
}
-func legacyAntigravityFunctionCallPartLocationForReplayWithSchemas(payload []byte, itemResult gjson.Result, toolSchemas map[string]any) (contentIndex int, partIndex int, ok bool) {
+func legacyAntigravityFunctionCallPartLocationForReplayWithSchemas(payload []byte, itemResult ojson.Result, toolSchemas map[string]any) (contentIndex int, partIndex int, ok bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
if name == "" || !args.Exists() {
@@ -114,7 +113,7 @@ func legacyAntigravityFunctionCallPartLocationForReplayWithSchemas(payload []byt
continue
}
if legacyAntigravityReplayItemContextMatches(payload, itemResult, ci) {
- fc := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi))
+ fc := ojson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi))
if antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) {
return ci, pi, true
}
@@ -175,7 +174,7 @@ func legacyAntigravityFunctionCallPartLocationForReplayWithSchemas(payload []byt
return -1, -1, false
}
-func legacyAntigravityFunctionCallProvenanceLocation(payload []byte, itemResult gjson.Result, toolSchemas map[string]any) (contentIndex int, partIndex int, ok bool) {
+func legacyAntigravityFunctionCallProvenanceLocation(payload []byte, itemResult ojson.Result, toolSchemas map[string]any) (contentIndex int, partIndex int, ok bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
callID := strings.TrimSpace(itemResult.Get("call_id").String())
@@ -190,22 +189,22 @@ func legacyAntigravityFunctionCallProvenanceLocation(payload []byte, itemResult
if !found {
return -1, -1, false
}
- fc := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi))
+ fc := ojson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi))
if !antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) {
return -1, -1, false
}
return ci, pi, true
}
-func legacyAntigravityRequestHasThoughtSignatureAt(payload []byte, itemResult gjson.Result) bool {
+func legacyAntigravityRequestHasThoughtSignatureAt(payload []byte, itemResult ojson.Result) bool {
partPath, ok := legacyAntigravityThoughtSignatureReplayPartPath(payload, itemResult)
if !ok {
return false
}
- return antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, partPath+".thoughtSignature").String())
+ return antigravityHasNativeThoughtSignature(ojson.GetBytes(payload, partPath+".thoughtSignature").String())
}
-func legacyAntigravityThoughtSignatureReplayPartPath(payload []byte, itemResult gjson.Result) (string, bool) {
+func legacyAntigravityThoughtSignatureReplayPartPath(payload []byte, itemResult ojson.Result) (string, bool) {
ci := int(itemResult.Get("contentIndex").Int())
contents := util.GetGJSONBytesNoCopy(payload, "request.contents")
if !contents.IsArray() {
@@ -266,7 +265,7 @@ func legacyAntigravityThoughtSignatureReplayPartPath(payload []byte, itemResult
return "", false
}
pi := int(itemResult.Get("partIndex").Int())
- if pi >= 0 && pi < len(partArr) && partArr[pi].Type != gjson.Null {
+ if pi >= 0 && pi < len(partArr) && partArr[pi].Type != ojson.Null {
if kind, _ := antigravityReplayPartFingerprint(partArr[pi]); kind != "" {
return fmt.Sprintf("request.contents.%d.parts.%d", ci, pi), true
}
@@ -293,7 +292,7 @@ func legacyAntigravityReplayContextFingerprint(payload []byte, beforeContentInde
}
var context strings.Builder
for _, path := range []string{"request.systemInstruction", "request.tools", "request.toolConfig"} {
- if value := gjson.GetBytes(payload, path); value.Exists() {
+ if value := ojson.GetBytes(payload, path); value.Exists() {
context.WriteString(path)
context.WriteByte('\x00')
context.Write(antigravityCanonicalReplayJSON([]byte(value.Raw)))
@@ -308,10 +307,10 @@ func legacyAntigravityReplayContextFingerprint(payload []byte, beforeContentInde
if !parts.IsArray() {
continue
}
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
normalized := []byte(part.Raw)
for _, signaturePath := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
- normalized, _ = sjson.DeleteBytes(normalized, signaturePath)
+ normalized, _ = ojson.DeleteBytes(normalized, signaturePath)
}
context.Write(antigravityCanonicalReplayJSON(normalized))
context.WriteByte('\x00')
@@ -325,14 +324,14 @@ func legacyAntigravityReplayContextFingerprint(payload []byte, beforeContentInde
return fmt.Sprintf("%x", sum[:])
}
-func legacyAntigravityReplayItemContextMatches(payload []byte, itemResult gjson.Result, contentIndex int) bool {
+func legacyAntigravityReplayItemContextMatches(payload []byte, itemResult ojson.Result, contentIndex int) bool {
expected := strings.TrimSpace(itemResult.Get("contextHash").String())
return expected == "" || expected == legacyAntigravityReplayContextFingerprint(payload, contentIndex)
}
func legacyAntigravitySetReplayItemContextHash(item []byte, payload []byte, contentIndex int) []byte {
if contextHash := legacyAntigravityReplayContextFingerprint(payload, contentIndex); contextHash != "" {
- item, _ = sjson.SetBytes(item, "contextHash", contextHash)
+ item, _ = ojson.SetBytes(item, "contextHash", contextHash)
}
return item
}
@@ -341,7 +340,7 @@ func legacyInsertAntigravityReasoningReplayItemsWithSchemas(payload []byte, item
out := payload
changed := false
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := ojson.ParseBytes(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "thought_signature":
sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String())
@@ -353,12 +352,12 @@ func legacyInsertAntigravityReasoningReplayItemsWithSchemas(payload []byte, item
continue
}
path := partPath + ".thoughtSignature"
- if antigravityHasNativeThoughtSignature(gjson.GetBytes(out, path).String()) {
+ if antigravityHasNativeThoughtSignature(ojson.GetBytes(out, path).String()) {
continue
}
ci := int(itemResult.Get("contentIndex").Int())
out = antigravityRemoveThoughtSignatureFromOtherParts(out, ci, sig, partPath)
- updated, err := sjson.SetBytes(out, path, sig)
+ updated, err := ojson.SetBytes(out, path, sig)
if err != nil {
continue
}
@@ -375,7 +374,7 @@ func legacyInsertAntigravityReasoningReplayItemsWithSchemas(payload []byte, item
return out, changed
}
-func legacyMergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, itemResult gjson.Result, toolSchemas map[string]any) ([]byte, bool) {
+func legacyMergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, itemResult ojson.Result, toolSchemas map[string]any) ([]byte, bool) {
name := strings.TrimSpace(itemResult.Get("name").String())
args := itemResult.Get("args")
callID := strings.TrimSpace(itemResult.Get("call_id").String())
@@ -405,7 +404,7 @@ func legacyMergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, ite
}
if frIndex, currentResponseID, ok := legacyAntigravityFunctionResponseContentIndexForReplay(payload, itemResult); ok {
parallelModelIndex := frIndex - 1
- if parallelModelIndex >= 0 && strings.EqualFold(strings.TrimSpace(gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.role", parallelModelIndex)).String()), "model") && legacyAntigravityReplayItemContextMatches(payload, itemResult, parallelModelIndex) {
+ if parallelModelIndex >= 0 && strings.EqualFold(strings.TrimSpace(ojson.GetBytes(payload, fmt.Sprintf("request.contents.%d.role", parallelModelIndex)).String()), "model") && legacyAntigravityReplayItemContextMatches(payload, itemResult, parallelModelIndex) {
if updated, appended := appendAntigravityFunctionCallToModelContent(payload, parallelModelIndex, name, callID, sig, args); appended {
return restoreAntigravityFunctionResponseReplayIdentity(updated, currentResponseID, callID, name), true
}
@@ -436,7 +435,7 @@ func legacyMergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, ite
if callID != "" {
fc["id"] = callID
}
- if args.Type == gjson.String {
+ if args.Type == ojson.String {
fc["args"] = args.String()
} else {
var parsed any
@@ -448,27 +447,27 @@ func legacyMergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, ite
if sig != "" {
part["thoughtSignature"] = sig
}
- if updated, err := sjson.SetBytes(out, antigravityReplayPartWritePath(out, ci, pi), part); err == nil {
+ if updated, err := ojson.SetBytes(out, antigravityReplayPartWritePath(out, ci, pi), part); err == nil {
return updated, true
}
return payload, false
}
pathSig := partPath + ".thoughtSignature"
- if sig != "" && !antigravityHasNativeThoughtSignature(gjson.GetBytes(out, pathSig).String()) {
+ if sig != "" && !antigravityHasNativeThoughtSignature(ojson.GetBytes(out, pathSig).String()) {
out = antigravityRemoveThoughtSignatureFromOtherParts(out, ci, sig, partPath)
- if updated, err := sjson.SetBytes(out, pathSig, sig); err == nil {
+ if updated, err := ojson.SetBytes(out, pathSig, sig); err == nil {
out = updated
changed = true
}
}
pathFC := partPath + ".functionCall"
- if !gjson.GetBytes(out, pathFC).Exists() {
+ if !ojson.GetBytes(out, pathFC).Exists() {
fc := map[string]any{"name": name}
if callID != "" {
fc["id"] = callID
}
- if args.Type == gjson.String {
+ if args.Type == ojson.String {
fc["args"] = args.String()
} else {
var parsed any
@@ -476,7 +475,7 @@ func legacyMergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, ite
fc["args"] = parsed
}
}
- if updated, err := sjson.SetBytes(out, pathFC, fc); err == nil {
+ if updated, err := ojson.SetBytes(out, pathFC, fc); err == nil {
out = updated
changed = true
}
diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go
index 203f3f8bebc..fc3b7545870 100644
--- a/internal/runtime/executor/antigravity_reasoning_replay_test.go
+++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
@@ -15,7 +16,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestAntigravityReasoningReplayAccumulatorMultiToolSSEChunks(t *testing.T) {
@@ -42,12 +42,12 @@ func TestAntigravityReasoningReplayAccumulatorMultiToolSSEChunks(t *testing.T) {
if !ok || len(items) != 2 {
t.Fatalf("cached items = %v ok=%v, want 2 items", len(items), ok)
}
- pi0 := int(gjson.GetBytes(items[0], "partIndex").Int())
- pi1 := int(gjson.GetBytes(items[1], "partIndex").Int())
+ pi0 := int(ojson.GetBytes(items[0], "partIndex").Int())
+ pi1 := int(ojson.GetBytes(items[1], "partIndex").Int())
if pi0 != 0 || pi1 != 1 {
t.Fatalf("partIndex = %d,%d, want 0,1", pi0, pi1)
}
- if got := gjson.GetBytes(items[0], "thoughtSignature").String(); got != "sig-first" {
+ if got := ojson.GetBytes(items[0], "thoughtSignature").String(); got != "sig-first" {
t.Fatalf("first sig = %q", got)
}
}
@@ -69,7 +69,7 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadToleratesHomeKVFailure(t
if len(out) == 0 {
t.Fatal("prepare returned an empty payload")
}
- if got := gjson.GetBytes(out, "sessionId").String(); got != "kv-failure" {
+ if got := ojson.GetBytes(out, "sessionId").String(); got != "kv-failure" {
t.Fatalf("payload sessionId = %q, want kv-failure", got)
}
}
@@ -109,7 +109,7 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadKeepsCacheForClientMalfor
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
const model, sessionKey = "gemini-3.6-flash-high", "session:client-malformed-history"
payload := []byte(`{"sessionId":"client-malformed-history","request":{"contents":[{"role":"model","parts":[{"text":"answer"}]},{"role":"model","parts":[{"functionResponse":{"id":"orphan","name":"run","response":{"result":"bad"}}}]}]}}`)
- kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"answer"}`))
+ kind, fingerprint := antigravityReplayPartFingerprint(ojson.Parse(`{"text":"answer"}`))
item := buildAntigravityThoughtSignatureItem(0, 0, "valid-cache-signature-123456789", kind, fingerprint)
item = antigravityReplayItemContextHashForTest(item, payload, 0)
if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) {
@@ -143,16 +143,16 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadInjectsCachedToolPart(t *
if !scope.valid() {
t.Fatal("scope invalid")
}
- if gjson.GetBytes(out, "request.contents.1.role").String() != "model" {
+ if ojson.GetBytes(out, "request.contents.1.role").String() != "model" {
t.Fatalf("functionCall replay must be model role at [1], got %s", string(out))
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "sig-first" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "sig-first" {
t.Fatalf("thoughtSignature = %q, want sig-first", got)
}
- if !gjson.GetBytes(out, "request.contents.1.parts.0.functionCall").Exists() {
+ if !ojson.GetBytes(out, "request.contents.1.parts.0.functionCall").Exists() {
t.Fatalf("functionCall not injected: %s", string(out))
}
- if !gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse").Exists() {
+ if !ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse").Exists() {
t.Fatalf("functionResponse should follow model functionCall at [2]: %s", string(out))
}
}
@@ -172,10 +172,10 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadSanitizesInsertedUnsigned
if err != nil {
t.Fatalf("prepare error: %v", err)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "skip_thought_signature_validator" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "skip_thought_signature_validator" {
t.Fatalf("inserted first synthetic functionCall signature = %q, want bypass sentinel; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "request.contents.2.role").String(); got != "model" {
+ if got := ojson.GetBytes(out, "request.contents.2.role").String(); got != "model" {
t.Fatalf("replayed functionResponse role = %q, want native model role; output=%s", got, out)
}
}
@@ -192,10 +192,10 @@ func TestPrepareAntigravityGeminiReasoningReplayInsertsBeforeModelFunctionRespon
if err != nil {
t.Fatal(err)
}
- if !gjson.GetBytes(out, "request.contents.1.parts.0.functionCall").Exists() || gjson.GetBytes(out, "request.contents.1.role").String() != "model" {
+ if !ojson.GetBytes(out, "request.contents.1.parts.0.functionCall").Exists() || ojson.GetBytes(out, "request.contents.1.role").String() != "model" {
t.Fatalf("want model functionCall at [1]: %s", string(out))
}
- if !gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse").Exists() {
+ if !ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse").Exists() {
t.Fatalf("functionResponse should be at [2]: %s", string(out))
}
}
@@ -212,7 +212,7 @@ func TestMergeAntigravityFunctionCallPartReplayMergesSignatureIntoExistingFuncti
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "sig-first" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "sig-first" {
t.Fatalf("thoughtSignature = %q, want sig-first; body=%s", got, out)
}
}
@@ -230,7 +230,7 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadDropsStaleThoughtSignatur
t.Fatal(err)
}
- parts := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ parts := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(parts) != 1 {
t.Fatalf("parts length = %d, want unchanged single text part; body=%s", len(parts), out)
}
@@ -266,10 +266,10 @@ func TestAntigravityReasoningReplayAccumulatesCompleteTextSignatureChain(t *test
if errPrepare2 != nil {
t.Fatal(errPrepare2)
}
- if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 2 signature = %q, want %q; body=%s", got, sig1, prepared2)
}
- if got := gjson.GetBytes(prepared2, "request.contents.1.parts.#").Int(); got != 1 {
+ if got := ojson.GetBytes(prepared2, "request.contents.1.parts.#").Int(); got != 1 {
t.Fatalf("turn 2 must attach signature in place, parts=%d; body=%s", got, prepared2)
}
@@ -288,13 +288,13 @@ func TestAntigravityReasoningReplayAccumulatesCompleteTextSignatureChain(t *test
if errPrepare3 != nil {
t.Fatal(errPrepare3)
}
- if got := gjson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 3 first signature = %q, want %q; body=%s", got, sig1, prepared3)
}
- if got := gjson.GetBytes(prepared3, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(prepared3, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 {
t.Fatalf("turn 3 second signature = %q, want %q; body=%s", got, sig2, prepared3)
}
- if got := len(gjson.GetBytes(prepared3, "request.contents.1.parts").Array()) + len(gjson.GetBytes(prepared3, "request.contents.3.parts").Array()); got != 2 {
+ if got := len(ojson.GetBytes(prepared3, "request.contents.1.parts").Array()) + len(ojson.GetBytes(prepared3, "request.contents.3.parts").Array()); got != 2 {
t.Fatalf("signatures must remain attached to native text parts, total parts=%d; body=%s", got, prepared3)
}
}
@@ -320,10 +320,10 @@ func TestAntigravityReasoningReplaySplitsConsecutiveSignedTextSegments(t *testin
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first consecutive text signature = %q, want %q; body=%s", got, sig1, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second consecutive text signature = %q, want %q; body=%s", got, sig2, out)
}
}
@@ -350,10 +350,10 @@ func TestAntigravityReasoningReplaySignatureOnlyCarrierEndsTextSegment(t *testin
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first trailing text signature = %q, want %q; body=%s", got, sig1, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second trailing text signature = %q, want %q; body=%s", got, sig2, out)
}
}
@@ -378,10 +378,10 @@ func TestAntigravityReasoningReplayDropsUnmatchedConsecutiveCarrier(t *testing.T
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first signature = %q, want %q; body=%s", got, sig1, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig3 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig3 {
t.Fatalf("second signature = %q, want %q; body=%s", got, sig3, out)
}
if strings.Contains(string(out), sig2) {
@@ -408,10 +408,10 @@ func TestAntigravityReasoningReplayDuplicateCarrierDoesNotSplitSegment(t *testin
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first thought signature = %q, want %q; body=%s", got, sig1, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second thought signature = %q, want %q; body=%s", got, sig2, out)
}
}
@@ -435,7 +435,7 @@ func TestAntigravityReasoningReplayDirectTextSignatureWinsOverUnboundPrefix(t *t
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig {
t.Fatalf("thought signature = %q, want direct signature %q; body=%s", got, directSig, out)
}
if strings.Contains(string(out), prefixSig) {
@@ -459,7 +459,7 @@ func TestAntigravityReasoningReplaySameDirectSignatureReplacesPrefix(t *testing.
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("thought signature = %q, want %q; body=%s", got, signature, out)
}
}
@@ -483,10 +483,10 @@ func TestAntigravityReasoningReplayDirectToolSignatureWinsOverPrefix(t *testing.
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig {
t.Fatalf("tool signature = %q, want %q; body=%s", got, directSig, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" {
t.Fatalf("prefix signature retargeted to later text: %q; body=%s", got, out)
}
}
@@ -507,7 +507,7 @@ func TestAntigravityReasoningReplayAttachesDetachedSignatureToFunctionCall(t *te
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("function signature = %q, want %q; body=%s", got, signature, out)
}
}
@@ -534,7 +534,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelOmittedCalls(t *
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(out); errValidate != nil {
t.Fatalf("parallel replay is invalid: %v; body=%s", errValidate, out)
}
- calls := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ calls := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(calls) != 2 || calls[0].Get("functionCall.id").String() != "id1" || calls[1].Get("functionCall.id").String() != "id2" {
t.Fatalf("parallel calls were not restored together: %s", out)
}
@@ -561,11 +561,11 @@ func TestPrepareAntigravityGeminiReasoningReplayReordersResponsesAfterRestoringP
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(out); errValidate != nil {
t.Fatalf("restored reverse responses are invalid: %v; body=%s", errValidate, out)
}
- responses := gjson.GetBytes(out, "request.contents.2.parts").Array()
+ responses := ojson.GetBytes(out, "request.contents.2.parts").Array()
if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "id1" || responses[1].Get("functionResponse.id").String() != "id2" {
t.Fatalf("restored responses were not reordered: %s", out)
}
- if got := gjson.GetBytes(out, "request.contents.2.role").String(); got != "model" {
+ if got := ojson.GetBytes(out, "request.contents.2.role").String(); got != "model" {
t.Fatalf("restored response role = %q, want model; body=%s", got, out)
}
}
@@ -590,7 +590,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresSyntheticParallelCallsWi
if errPrepare != nil {
t.Fatal(errPrepare)
}
- calls := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ calls := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(calls) != 2 {
t.Fatalf("parallel synthetic calls = %d, want 2; body=%s", len(calls), out)
}
@@ -622,8 +622,8 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresSequentialOmittedCalls(t
t.Fatal(errPrepare)
}
var calls []string
- gjson.GetBytes(out, "request.contents").ForEach(func(_, content gjson.Result) bool {
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ ojson.GetBytes(out, "request.contents").ForEach(func(_, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if callID := part.Get("functionCall.id").String(); callID != "" {
calls = append(calls, callID)
}
@@ -658,7 +658,7 @@ func TestAntigravityReasoningReplayAccumulatesCompleteToolSignatureChain(t *test
if errPrepare2 != nil {
t.Fatal(errPrepare2)
}
- if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 2 tool signature = %q, want %q; body=%s", got, sig1, prepared2)
}
@@ -672,10 +672,10 @@ func TestAntigravityReasoningReplayAccumulatesCompleteToolSignatureChain(t *test
if errPrepare3 != nil {
t.Fatal(errPrepare3)
}
- if got := gjson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("turn 3 first tool signature = %q, want %q; body=%s", got, sig1, prepared3)
}
- if got := gjson.GetBytes(prepared3, "request.contents.4.parts.0.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(prepared3, "request.contents.4.parts.0.thoughtSignature").String(); got != sig2 {
t.Fatalf("turn 3 second tool signature = %q, want %q; body=%s", got, sig2, prepared3)
}
}
@@ -697,7 +697,7 @@ func TestAntigravityReasoningReplayDirectSignatureClosesSegmentBeforeUnsignedTex
if errPrepare != nil {
t.Fatal(errPrepare)
}
- parts := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ parts := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != signature || parts[1].Get("thoughtSignature").String() != "" {
t.Fatalf("direct signature crossed into unsigned text: %s", out)
}
@@ -726,10 +726,10 @@ func TestAntigravityReasoningReplayKeepsMixedTextToolTextFingerprintsSeparate(t
if !ok || len(items) != 3 {
t.Fatalf("cached mixed items = %d ok=%v, want 3", len(items), ok)
}
- if occurrence := gjson.GetBytes(items[0], "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != 0 {
+ if occurrence := ojson.GetBytes(items[0], "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != 0 {
t.Fatalf("first text targetOccurrence = %s, want 0; item=%s", occurrence.Raw, items[0])
}
- if got := gjson.GetBytes(items[2], "targetOccurrence").Int(); got != 1 {
+ if got := ojson.GetBytes(items[2], "targetOccurrence").Int(); got != 1 {
t.Fatalf("second text targetOccurrence = %d, want 1; item=%s", got, items[2])
}
@@ -739,7 +739,7 @@ func TestAntigravityReasoningReplayKeepsMixedTextToolTextFingerprintsSeparate(t
t.Fatal(errPrepare)
}
for partIndex, want := range []string{textSig1, toolSig, textSig2} {
- if got := gjson.GetBytes(out, fmt.Sprintf("request.contents.1.parts.%d.thoughtSignature", partIndex)).String(); got != want {
+ if got := ojson.GetBytes(out, fmt.Sprintf("request.contents.1.parts.%d.thoughtSignature", partIndex)).String(); got != want {
t.Fatalf("part %d signature = %q, want %q; body=%s", partIndex, got, want, out)
}
}
@@ -757,7 +757,7 @@ func TestAntigravityReasoningReplayAccumulatorCountsExistingSegmentOccurrences(t
t.Fatalf("captured items = %d, want 2: %q", len(acc.items), acc.items)
}
for itemIndex, wantKind := range []string{"text", "thought"} {
- item := gjson.ParseBytes(acc.items[itemIndex])
+ item := ojson.ParseBytes(acc.items[itemIndex])
if item.Get("targetKind").String() != wantKind || item.Get("targetOccurrence").Int() != 1 {
t.Fatalf("item %d kind/occurrence = %q/%d, want %q/1: %s", itemIndex, item.Get("targetKind").String(), item.Get("targetOccurrence").Int(), wantKind, item.Raw)
}
@@ -776,7 +776,7 @@ func TestAntigravityReasoningReplayAccumulatorCountsExistingFunctionOccurrenceTh
acc.Commit(context.Background())
items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey)
- if !ok || len(items) != 2 || gjson.GetBytes(items[1], "targetOccurrence").Int() != 1 {
+ if !ok || len(items) != 2 || ojson.GetBytes(items[1], "targetOccurrence").Int() != 1 {
t.Fatalf("function occurrences were not committed: ok=%v items=%q", ok, items)
}
replayPayload := []byte(`{"sessionId":"existing-function","request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"run","args":{"value":"same"}}},{"functionCall":{"name":"run","args":{"value":"same"}}}]}]}}`)
@@ -786,7 +786,7 @@ func TestAntigravityReasoningReplayAccumulatorCountsExistingFunctionOccurrenceTh
}
// The leading call gets Gemini's bypass sentinel (it carries no native
// signature); only the second occurrence may receive the replayed one.
- parts := gjson.GetBytes(prepared, "request.contents.0.parts").Array()
+ parts := ojson.GetBytes(prepared, "request.contents.0.parts").Array()
if len(parts) != 2 || antigravityHasNativeThoughtSignature(parts[0].Get("thoughtSignature").String()) || parts[1].Get("thoughtSignature").String() != signature {
t.Fatalf("function occurrence replay targeted the wrong call: %s", prepared)
}
@@ -809,7 +809,7 @@ func TestAntigravityReasoningReplayCapturesSignatureBeforeThoughtText(t *testing
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("thought-first signature = %q, want %q; body=%s", got, signature, out)
}
}
@@ -825,7 +825,7 @@ func TestPrepareAntigravityGeminiReasoningReplayReplacesIDLessFunctionCallBypass
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "idless-native-signature-123456" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "idless-native-signature-123456" {
t.Fatalf("id-less function signature = %q, want native replay; body=%s", got, out)
}
}
@@ -854,7 +854,7 @@ func TestPrepareAntigravityGeminiReasoningReplayMatchesRewrittenToolCallID(t *te
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "rewritten-id-signature-123456" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "rewritten-id-signature-123456" {
t.Fatalf("rewritten-ID function signature = %q, want native replay; body=%s", got, out)
}
}
@@ -870,15 +870,15 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsReusedIDWithChangedCall(t
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); antigravityHasNativeThoughtSignature(got) {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); antigravityHasNativeThoughtSignature(got) {
t.Fatalf("changed call with reused ID received stale signature %q; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" {
t.Fatalf("reused-ID signature fell through to another semantic match %q; body=%s", got, out)
}
callCount := 0
- gjson.GetBytes(out, "request.contents").ForEach(func(_, content gjson.Result) bool {
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ ojson.GetBytes(out, "request.contents").ForEach(func(_, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall.id").String() == "reused-id" {
callCount++
}
@@ -902,7 +902,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedIDLessCallAtSamePo
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); antigravityHasNativeThoughtSignature(got) {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); antigravityHasNativeThoughtSignature(got) {
t.Fatalf("changed ID-less call received stale signature %q; body=%s", got, out)
}
}
@@ -926,10 +926,10 @@ func TestAntigravityReasoningReplayPreservesRepeatedIDLessCalls(t *testing.T) {
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first repeated signature = %q, want %q; body=%s", got, sig1, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second repeated signature = %q, want %q; body=%s", got, sig2, out)
}
}
@@ -955,7 +955,7 @@ func TestAntigravityReasoningReplayPreservesRepeatedIDLessCallsAcrossSplitSSEPar
t.Fatalf("cached items = %d ok=%v, want 2", len(items), ok)
}
for index, item := range items {
- if occurrence := gjson.GetBytes(item, "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != int64(index) {
+ if occurrence := ojson.GetBytes(item, "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != int64(index) {
t.Fatalf("item %d occurrence = %s, want %d; item=%s", index, occurrence.Raw, index, item)
}
}
@@ -965,15 +965,15 @@ func TestAntigravityReasoningReplayPreservesRepeatedIDLessCallsAcrossSplitSSEPar
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first split repeated signature = %q, want %q; body=%s", got, sig1, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 {
t.Fatalf("second split repeated signature = %q, want %q; body=%s", got, sig2, out)
}
rebuiltItems := antigravityReasoningReplayItemsFromRequest(out)
- if len(rebuiltItems) != 2 || gjson.GetBytes(rebuiltItems[0], "targetOccurrence").Int() != 0 || gjson.GetBytes(rebuiltItems[1], "targetOccurrence").Int() != 1 {
+ if len(rebuiltItems) != 2 || ojson.GetBytes(rebuiltItems[0], "targetOccurrence").Int() != 0 || ojson.GetBytes(rebuiltItems[1], "targetOccurrence").Int() != 1 {
t.Fatalf("rebuilt occurrences were not preserved: %q", rebuiltItems)
}
}
@@ -1004,7 +1004,7 @@ func TestAntigravityReasoningReplayAssociatesSignatureBeforeFunctionCall(t *test
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature {
t.Fatalf("signature-first tool signature = %q, want %q; body=%s", got, signature, out)
}
}
@@ -1045,7 +1045,7 @@ func TestPrepareAntigravityGeminiReasoningReplayKeepsTextSignatureOnContextDrift
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
- kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"same answer"}`))
+ kind, fingerprint := antigravityReplayPartFingerprint(ojson.Parse(`{"text":"same answer"}`))
item := buildAntigravityThoughtSignatureItem(1, 0, "fingerprinted-signature-123456", kind, fingerprint)
originalPayload := []byte(`{"sessionId":"rebuilt","request":{"contents":[{"role":"user","parts":[{"text":"old context"}]},{"role":"model","parts":[{"text":"same answer"}]},{"role":"user","parts":[{"text":"old next"}]}]}}`)
item = antigravityReplayItemContextHashForTest(item, originalPayload, 1)
@@ -1059,7 +1059,7 @@ func TestPrepareAntigravityGeminiReasoningReplayKeepsTextSignatureOnContextDrift
// The signed part itself is byte-identical, so the signature still describes
// it exactly. Only the surrounding turns drifted, which Gemini does not bind
// signatures to, so dropping it here would only force needless re-reasoning.
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "fingerprinted-signature-123456" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "fingerprinted-signature-123456" {
t.Fatalf("signature = %q, want the signature replayed even though the surrounding context drifted; body=%s", got, out)
}
}
@@ -1068,7 +1068,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsFingerprintMismatch(t *te
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
- kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"original answer"}`))
+ kind, fingerprint := antigravityReplayPartFingerprint(ojson.Parse(`{"text":"original answer"}`))
item := buildAntigravityThoughtSignatureItem(1, 0, "fingerprinted-signature-123456", kind, fingerprint)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:edited", [][]byte{item})
@@ -1079,7 +1079,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsFingerprintMismatch(t *te
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" {
t.Fatalf("edited part received stale signature %q; body=%s", got, out)
}
}
@@ -1088,7 +1088,7 @@ func TestPrepareAntigravityGeminiReasoningReplayMovesClientSignatureToNativePart
internalcache.ClearAntigravityReasoningReplayCache()
t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache)
- kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"visible answer"}`))
+ kind, fingerprint := antigravityReplayPartFingerprint(ojson.Parse(`{"text":"visible answer"}`))
item := buildAntigravityThoughtSignatureItem(1, 1, "client-carried-signature-123456", kind, fingerprint)
internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:client-carried", [][]byte{item})
payload := []byte(`{"sessionId":"client-carried","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true,"thoughtSignature":"client-carried-signature-123456"},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`)
@@ -1096,10 +1096,10 @@ func TestPrepareAntigravityGeminiReasoningReplayMovesClientSignatureToNativePart
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" {
t.Fatalf("client-carried signature remained on non-native thought part: %q; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "client-carried-signature-123456" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "client-carried-signature-123456" {
t.Fatalf("client-carried signature = %q on visible part, want native placement; body=%s", got, out)
}
}
@@ -1115,7 +1115,7 @@ func TestPrepareAntigravityGeminiReasoningReplayReplacesBypassSignature(t *testi
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "native-real-signature-123456" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "native-real-signature-123456" {
t.Fatalf("signature = %q, want native replay; body=%s", got, out)
}
}
@@ -1221,7 +1221,7 @@ func TestAntigravityReasoningReplaySeparatesClaudeSessionTitleFromResumedTranscr
titleAccumulator.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"session title"},{"text":"","thoughtSignature":"claude-title-signature-123456"}]},"finishReason":"STOP"}]}}`))
titleAccumulator.Commit(context.Background())
- if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("first main signature after title request = %q, want %q; body=%s", got, sig1, prepared2)
}
mainAccumulator2 := newAntigravityReasoningReplayAccumulator(scope2, prepared2)
@@ -1237,10 +1237,10 @@ func TestAntigravityReasoningReplaySeparatesClaudeSessionTitleFromResumedTranscr
if resumeScope.sessionKey != mainScope.sessionKey {
t.Fatalf("resumed replay scope = %q, want %q", resumeScope.sessionKey, mainScope.sessionKey)
}
- if got := gjson.GetBytes(resumed, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
+ if got := ojson.GetBytes(resumed, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 {
t.Fatalf("resumed first signature = %q, want %q; body=%s", got, sig1, resumed)
}
- if got := gjson.GetBytes(resumed, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 {
+ if got := ojson.GetBytes(resumed, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 {
t.Fatalf("resumed second signature = %q, want %q; body=%s", got, sig2, resumed)
}
}
@@ -1268,12 +1268,12 @@ func TestAntigravityReasoningReplayScopeUsesStableSessionWithoutSessionId(t *tes
}
func TestAntigravityReplayToolCallKeysUsesNativeFunctionCallID(t *testing.T) {
- fc := gjson.Parse(`{"name":"Read","args":{"file_path":"/a"},"id":"id-native"}`)
+ fc := ojson.Parse(`{"name":"Read","args":{"file_path":"/a"},"id":"id-native"}`)
keys := antigravityReplayToolCallKeysFromPart(fc)
if len(keys) != 1 {
t.Fatalf("keys = %v", keys)
}
- fc2 := gjson.Parse(`{"name":"Read","args":{"file_path":"/a"},"id":"id-native-2"}`)
+ fc2 := ojson.Parse(`{"name":"Read","args":{"file_path":"/a"},"id":"id-native-2"}`)
keys2 := antigravityReplayToolCallKeysFromPart(fc2)
if keys[0] == keys2[0] {
t.Fatalf("parallel tool calls should not share replay key: %v vs %v", keys, keys2)
@@ -1335,16 +1335,16 @@ func TestPrepareAntigravityGeminiReasoningReplayRepairsSequentialCompactedUnknow
}
// Verify restored model functionCall has name "Read" and thoughtSignature
- fcName := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
- fcID := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String()
- fcSig := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String()
+ fcName := ojson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
+ fcID := ojson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String()
+ fcSig := ojson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String()
if fcName != "Read" || fcID != "call_seq_1" || fcSig != "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" {
t.Fatalf("restored functionCall = name:%q id:%q sig:%q, want Read/call_seq_1/signature", fcName, fcID, fcSig)
}
// Verify user functionResponse.name was repaired from "unknown" to "Read"
- frName := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
- frID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
+ frName := ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
+ frID := ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
if frName != "Read" || frID != "call_seq_1" {
t.Fatalf("repaired functionResponse = name:%q id:%q, want Read/call_seq_1", frName, frID)
}
@@ -1425,15 +1425,15 @@ func TestPrepareAntigravityGeminiReasoningReplayRepairsParallelCompactedUnknownR
}
// Verify parallel functionCall restoration
- fc1Name := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
- fc2Name := gjson.GetBytes(out, "request.contents.1.parts.1.functionCall.name").String()
+ fc1Name := ojson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
+ fc2Name := ojson.GetBytes(out, "request.contents.1.parts.1.functionCall.name").String()
if fc1Name != "Read" || fc2Name != "Grep" {
t.Fatalf("restored parallel functionCalls = %q, %q, want Read, Grep", fc1Name, fc2Name)
}
// Verify parallel functionResponse.name repair
- fr1Name := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
- fr2Name := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.name").String()
+ fr1Name := ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
+ fr2Name := ojson.GetBytes(out, "request.contents.2.parts.1.functionResponse.name").String()
if fr1Name != "Read" || fr2Name != "Grep" {
t.Fatalf("repaired parallel functionResponses = %q, %q, want Read, Grep", fr1Name, fr2Name)
}
@@ -1445,17 +1445,17 @@ func TestPrepareAntigravityGeminiReasoningReplayRepairsParallelCompactedUnknownR
}
func TestAntigravityFunctionCallMatchesReplayItemOnlyIgnoresDeclaredDefaults(t *testing.T) {
- item := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a"}}`)
+ item := ojson.Parse(`{"name":"Edit","args":{"path":"/tmp/a"}}`)
schemas := map[string]any{"Edit": map[string]any{"type": "object", "properties": map[string]any{"replace_all": map[string]any{"type": "boolean", "default": false}}}}
- declaredDefault := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":false}}`)
+ declaredDefault := ojson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":false}}`)
if !antigravityFunctionCallMatchesReplayItem(declaredDefault, item, schemas) {
t.Fatal("declared schema default should be semantically equivalent")
}
- changedDefault := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":true}}`)
+ changedDefault := ojson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":true}}`)
if antigravityFunctionCallMatchesReplayItem(changedDefault, item, schemas) {
t.Fatal("non-default value must not match native args")
}
- undeclaredExtra := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","force":false}}`)
+ undeclaredExtra := ojson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","force":false}}`)
if antigravityFunctionCallMatchesReplayItem(undeclaredExtra, item, schemas) {
t.Fatal("undeclared client arg must not match native args")
}
@@ -1483,14 +1483,14 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresClaudeToolProvenanceWith
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
- call := gjson.GetBytes(out, "request.contents.1.parts.0")
+ call := ojson.GetBytes(out, "request.contents.1.parts.0")
if call.Get("functionCall.id").String() != nativeID || call.Get("functionCall.name").String() != "Edit" || call.Get("thoughtSignature").String() != signature {
t.Fatalf("native call provenance was not restored: %s", call.Raw)
}
if call.Get("functionCall.args.replace_all").Exists() {
t.Fatalf("client-inserted schema default leaked into restored native call: %s", call.Raw)
}
- response := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
+ response := ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
if response.Get("id").String() != nativeID || response.Get("name").String() != "Edit" {
t.Fatalf("function response provenance was not restored: %s", response.Raw)
}
@@ -1515,10 +1515,10 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresLegacyClaudeToolIDWithSc
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String(); got != "native-edit-legacy" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String(); got != "native-edit-legacy" {
t.Fatalf("legacy client ID was not restored: %q", got)
}
- if got := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String(); got != "native-edit-legacy" {
+ if got := ojson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String(); got != "native-edit-legacy" {
t.Fatalf("legacy functionResponse ID was not restored: %q", got)
}
}
@@ -1541,8 +1541,8 @@ func TestPrepareAntigravityGeminiReasoningReplayDegradesWithoutClaudeToolProvena
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
- call := gjson.GetBytes(out, "request.contents.0.parts.0")
- response := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse")
+ call := ojson.GetBytes(out, "request.contents.0.parts.0")
+ response := ojson.GetBytes(out, "request.contents.1.parts.0.functionResponse")
callID := call.Get("functionCall.id").String()
if callID == "" || callID != response.Get("id").String() {
t.Fatalf("degraded call/response pairing broken: call=%q response=%q", callID, response.Get("id").String())
@@ -1580,21 +1580,21 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProven
if errPrepare != nil {
t.Fatalf("prepare failed: %v", errPrepare)
}
- calls := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ calls := ojson.GetBytes(out, "request.contents.0.parts").Array()
if len(calls) != 2 || calls[0].Get("functionCall.id").String() != "native-read-1" || calls[1].Get("functionCall.id").String() != "native-read-2" {
t.Fatalf("parallel calls were not restored in native order: %s", out)
}
if calls[0].Get("thoughtSignature").String() == "" || calls[1].Get("thoughtSignature").Exists() {
- t.Fatalf("signed/unsigned parallel provenance changed: %s", gjson.GetBytes(out, "request.contents.0").Raw)
+ t.Fatalf("signed/unsigned parallel provenance changed: %s", ojson.GetBytes(out, "request.contents.0").Raw)
}
- responses := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ responses := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(responses) != 4 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" {
- t.Fatalf("parallel responses were not normalized to native order: %s", gjson.GetBytes(out, "request.contents.1").Raw)
+ t.Fatalf("parallel responses were not normalized to native order: %s", ojson.GetBytes(out, "request.contents.1").Raw)
}
if responses[2].Get("text").String() != "results follow" || responses[3].Get("text").String() != "continue" {
- t.Fatalf("mixed user parts were not retained after parallel responses: %s", gjson.GetBytes(out, "request.contents.1").Raw)
+ t.Fatalf("mixed user parts were not retained after parallel responses: %s", ojson.GetBytes(out, "request.contents.1").Raw)
}
- if got := gjson.GetBytes(out, "request.contents.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(out, "request.contents.1.role").String(); got != "user" {
t.Fatalf("mixed response role = %q, want user; output=%s", got, out)
}
if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil {
@@ -1626,7 +1626,7 @@ func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedClaudeToolArgument
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
- call := gjson.GetBytes(out, "request.contents.0.parts.0")
+ call := ojson.GetBytes(out, "request.contents.0.parts.0")
if got := call.Get("functionCall.id").String(); got == "native-edit-changed" {
t.Fatalf("native call ID was restored onto changed arguments: %s", out)
}
@@ -1721,11 +1721,11 @@ func TestPrepareAntigravityGeminiReasoningReplayRestoresIdentityOnContextDrift(t
if antigravityPayloadHasClaudeToolProvenanceID(out) {
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
- call := gjson.GetBytes(out, "request.contents.0.parts.0")
+ call := ojson.GetBytes(out, "request.contents.0.parts.0")
if got := call.Get("functionCall.id").String(); got != "native-drift" {
t.Fatalf("functionCall.id = %q, want native identity restored despite context drift", got)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.id").String(); got != "native-drift" {
+ if got := ojson.GetBytes(out, "request.contents.1.parts.0.functionResponse.id").String(); got != "native-drift" {
t.Fatalf("functionResponse.id = %q, want native identity restored", got)
}
if got := call.Get("thoughtSignature").String(); !antigravityHasNativeThoughtSignature(got) {
@@ -1760,8 +1760,8 @@ func TestDegradeAntigravityClaudeToolProvenanceIDsKeepsParallelShape(t *testing.
t.Fatalf("reserved provenance IDs leaked upstream: %s", out)
}
- calls := gjson.GetBytes(out, "request.contents.0.parts").Array()
- responses := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ calls := ojson.GetBytes(out, "request.contents.0.parts").Array()
+ responses := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(calls) != 3 || len(responses) != 3 {
t.Fatalf("part counts changed: %d calls, %d responses", len(calls), len(responses))
}
@@ -1810,7 +1810,7 @@ func TestDegradeAntigravityClaudeToolProvenanceIDs_AllCallsSigned_OnlyFirstGetsB
t.Fatalf("degraded = %d, want 6", degraded)
}
- calls := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ calls := ojson.GetBytes(out, "request.contents.0.parts").Array()
if len(calls) != 3 {
t.Fatalf("call count: %d", len(calls))
}
@@ -1924,8 +1924,8 @@ func TestPrepareAntigravityGeminiReasoningReplay_ReordersPermutedParallelToolRes
t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v\noutput: %s", errPairing, out)
}
- outCalls := gjson.GetBytes(out, "request.contents.0.parts").Array()
- outResponses := gjson.GetBytes(out, "request.contents.1.parts").Array()
+ outCalls := ojson.GetBytes(out, "request.contents.0.parts").Array()
+ outResponses := ojson.GetBytes(out, "request.contents.1.parts").Array()
if len(outCalls) != count || len(outResponses) != count {
t.Fatalf("part counts: %d calls, %d responses", len(outCalls), len(outResponses))
}
diff --git a/internal/runtime/executor/antigravity_schema_sanitize_test.go b/internal/runtime/executor/antigravity_schema_sanitize_test.go
index e7736478a88..92d156b751a 100644
--- a/internal/runtime/executor/antigravity_schema_sanitize_test.go
+++ b/internal/runtime/executor/antigravity_schema_sanitize_test.go
@@ -7,10 +7,10 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
antigravityclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/claude"
antigravitychat "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/openai/chat-completions"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
const sanitizeTestPayload = `{
@@ -58,22 +58,22 @@ func TestSanitizeAntigravityRequestSchemasPreservesHistory(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeAntigravityRequestSchemas(sanitizeTestPayload, tc.useAntigravitySchema)
- before := gjson.Get(sanitizeTestPayload, "request.contents")
- after := gjson.Get(got, "request.contents")
+ before := ojson.Get(sanitizeTestPayload, "request.contents")
+ after := ojson.Get(got, "request.contents")
if before.Raw != after.Raw {
t.Errorf("conversation history was mutated.\nbefore: %s\nafter: %s", before.Raw, after.Raw)
}
- todo := gjson.Get(got, `request.contents.0.parts.0.functionCall.args.todoList`)
+ todo := ojson.Get(got, `request.contents.0.parts.0.functionCall.args.todoList`)
for i, item := range todo.Array() {
if !item.Get("title").Exists() {
t.Errorf("todoList[%d] lost its title: %s", i, item.Raw)
}
}
- args := gjson.Get(got, `request.contents.1.parts.0.functionCall.args`)
+ args := ojson.Get(got, `request.contents.1.parts.0.functionCall.args`)
for _, key := range []string{"format", "default", "pattern", "const", "deprecated", "examples", "additionalProperties", "x-custom"} {
- if !args.Get(gjson.Escape(key)).Exists() {
+ if !args.Get(ojson.Escape(key)).Exists() {
t.Errorf("argument key %q was stripped from history: %s", key, args.Raw)
}
}
@@ -90,12 +90,12 @@ func TestSanitizeAntigravityRequestSchemasStillCleansSchemas(t *testing.T) {
got := sanitizeAntigravityRequestSchemas(sanitizeTestPayload, false)
decl := "request.tools.0.functionDeclarations.0"
- if gjson.Get(got, decl+".parametersJsonSchema").Exists() {
- t.Errorf("parametersJsonSchema was not renamed: %s", gjson.Get(got, decl).Raw)
+ if ojson.Get(got, decl+".parametersJsonSchema").Exists() {
+ t.Errorf("parametersJsonSchema was not renamed: %s", ojson.Get(got, decl).Raw)
}
- schema := gjson.Get(got, decl+".parameters")
+ schema := ojson.Get(got, decl+".parameters")
if !schema.Exists() {
- t.Fatalf("parameters missing after sanitization: %s", gjson.Get(got, decl).Raw)
+ t.Fatalf("parameters missing after sanitization: %s", ojson.Get(got, decl).Raw)
}
items := schema.Get("properties.todoList.items")
@@ -124,7 +124,7 @@ func TestSanitizeAntigravityRequestSchemasCleansResultSchemas(t *testing.T) {
}]}]}}`
got := sanitizeAntigravityRequestSchemas(payload, false)
- decl := gjson.Get(got, "request.tools.0.functionDeclarations.0")
+ decl := ojson.Get(got, "request.tools.0.functionDeclarations.0")
for _, unsupported := range []string{`parameters.\$id`, `response.\$comment`, `responseJsonSchema.\$id`} {
if decl.Get(unsupported).Exists() {
@@ -177,7 +177,7 @@ func TestAntigravitySchemaPathsCoverEverySchemaLocation(t *testing.T) {
check := func(path string) {
t.Helper()
- node := gjson.Get(got, path)
+ node := ojson.Get(got, path)
if !node.Exists() {
t.Errorf("%s disappeared: %s", path, got)
return
@@ -191,7 +191,7 @@ func TestAntigravitySchemaPathsCoverEverySchemaLocation(t *testing.T) {
// Only the camelCase alias is renamed onto parameters, matching whole-payload
// cleaning. Every other spelling is cleaned where the client put it.
if k == "parametersJsonSchema" {
- if gjson.Get(got, base+k).Exists() {
+ if ojson.Get(got, base+k).Exists() {
t.Errorf("%s should have been renamed onto parameters: %s", k, got)
}
continue
@@ -226,8 +226,8 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing
for name, schema := range shapes {
doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + schema + `}]}]}}`
whole := util.CleanJSONSchemaForAntigravityTool(doc, useAntigravitySchema)
- want := gjson.Get(whole, schemaPath).Raw
- got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw
+ want := ojson.Get(whole, schemaPath).Raw
+ got := ojson.Get(sanitizeAntigravityRequestSchemas(doc, useAntigravitySchema), schemaPath).Raw
if want != got {
t.Errorf("%s (antigravity=%v) diverged from whole-payload cleaning.\nwant: %s\ngot: %s",
name, useAntigravitySchema, want, got)
@@ -237,7 +237,7 @@ func TestSanitizeAntigravityRequestSchemasMatchesWholePayloadCleaning(t *testing
// Explicitly pin the placeholder, so the equivalence above cannot pass by both sides dropping it.
doc := `{"request":{"tools":[{"functionDeclarations":[{"name":"t","parameters":` + shapes["optionalOnly"] + `}]}]}}`
- got := gjson.Get(sanitizeAntigravityRequestSchemas(doc, true), schemaPath)
+ got := ojson.Get(sanitizeAntigravityRequestSchemas(doc, true), schemaPath)
if req := got.Get("required").Array(); len(req) != 1 || req[0].String() != "_" {
t.Errorf("Claude VALIDATED placeholder missing for an optional-only schema: %s", got.Raw)
}
@@ -253,12 +253,12 @@ func TestSanitizeAntigravityRequestSchemasKeepsResponseSchemasPlaceholderFree(t
}}`
got := sanitizeAntigravityRequestSchemas(payload, true)
- toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
+ toolSchema := ojson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
if required := toolSchema.Get("required.0").String(); required != "_" {
t.Fatalf("tool schema lost VALIDATED placeholder, required[0] = %q: %s", required, got)
}
- responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")
+ responseSchema := ojson.Get(got, "request.generationConfig.responseSchema")
for _, path := range []string{
"required",
"properties._",
@@ -299,7 +299,7 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t
}}`
got := sanitizeAntigravityRequestSchemas(payload, true)
- responseSchema := gjson.Get(got, "request.generationConfig.responseSchema")
+ responseSchema := ojson.Get(got, "request.generationConfig.responseSchema")
action := responseSchema.Get("properties.action")
if action.Get("anyOf").Exists() || action.Get("type").String() != "object" || !action.Get("nullable").Bool() {
t.Errorf("response anyOf was not projected to nullable object: %s", responseSchema.Raw)
@@ -309,12 +309,12 @@ func TestSanitizeAntigravityRequestSchemasProjectsUnionsAndPreservesEnumTypes(t
t.Errorf("response enum type = %q, want number: %s", gotType, responseSchema.Raw)
}
for _, enumValue := range conviction.Get("enum").Array() {
- if enumValue.Type != gjson.String {
+ if enumValue.Type != ojson.String {
t.Errorf("response enum value is not a string: %s", conviction.Raw)
}
}
- toolSchema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
+ toolSchema := ojson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
if toolSchema.Get("properties.choice.anyOf").Exists() {
t.Errorf("tool nullable single-branch anyOf union was not flattened: %s", toolSchema.Raw)
}
@@ -350,12 +350,12 @@ func TestSanitizeAntigravityToolSchemasKeepNativeTypeAndNullableOnBothPaths(t *t
for _, requirePlaceholder := range []bool{false, true} {
got := sanitizeAntigravityRequestSchemas(payload, requirePlaceholder)
- schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
+ schema := ojson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
if schema.Get("properties.level.type").String() != "number" {
t.Fatalf("placeholder=%v changed numeric tool argument type: %s", requirePlaceholder, schema.Raw)
}
for _, member := range schema.Get("properties.level.enum").Array() {
- if member.Type != gjson.String {
+ if member.Type != ojson.String {
t.Fatalf("placeholder=%v left non-string proto enum: %s", requirePlaceholder, schema.Raw)
}
}
@@ -374,7 +374,7 @@ func TestAntigravityBuildRequestKeepsJSONObjectMimeOnly(t *testing.T) {
t.Fatal(errMarshal)
}
- generationConfig := gjson.GetBytes(encoded, "request.generationConfig")
+ generationConfig := ojson.GetBytes(encoded, "request.generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json: %s", got, encoded)
}
@@ -399,7 +399,7 @@ func TestAntigravityBuildRequestPreservesGenerationResponseSchemaMetadata(t *tes
t.Fatal(errMarshal)
}
- schema := gjson.GetBytes(encoded, "request.generationConfig.responseSchema")
+ schema := ojson.GetBytes(encoded, "request.generationConfig.responseSchema")
if !schema.Get("nullable").Bool() || !schema.Get("properties._.nullable").Bool() {
t.Fatalf("response schema nullable metadata was removed: %s", schema.Raw)
}
@@ -433,11 +433,11 @@ func TestAntigravityBuildRequestSanitizesSnakeCaseGenerationResponseSchemas(t *t
base := "request.generationConfig."
// The upstream API accepts either spelling, so the field must stay where the client put
// it. Only the unsupported keywords inside it are what upstream rejects.
- schema := gjson.GetBytes(encoded, base+testCase.alias)
+ schema := ojson.GetBytes(encoded, base+testCase.alias)
if !schema.Exists() {
t.Fatalf("snake_case response schema was renamed or dropped: %s", encoded)
}
- if gjson.GetBytes(encoded, base+testCase.canonical).Exists() {
+ if ojson.GetBytes(encoded, base+testCase.canonical).Exists() {
t.Fatalf("cleaning must not add a second spelling: %s", encoded)
}
if schema.Get(`\$id`).Exists() {
@@ -463,7 +463,7 @@ func TestSanitizeAntigravityRequestSchemasCleansBothSpellingsInPlace(t *testing.
"request.generationConfig.responseSchema": "canonical",
"request.generationConfig.response_schema": "alias",
} {
- schema := gjson.Get(got, path)
+ schema := ojson.Get(got, path)
if !schema.Exists() {
t.Errorf("%s was dropped: %s", path, got)
continue
@@ -498,7 +498,7 @@ func TestSanitizeAntigravityRequestSchemasIsIdempotent(t *testing.T) {
base := "request.tools.0.functionDeclarations.0.parameters.properties."
for _, prop := range []string{"withDesc", "bare", "compound"} {
descPath := base + prop + ".description"
- first, second := gjson.Get(once, descPath).String(), gjson.Get(twice, descPath).String()
+ first, second := ojson.Get(once, descPath).String(), ojson.Get(twice, descPath).String()
if first != second {
t.Errorf("%s: cleaning is not idempotent.\nonce: %s\ntwice: %s", prop, first, second)
}
@@ -572,13 +572,13 @@ func TestSanitizeAntigravityRequestSchemasKeepsPropertyNamesInHistory(t *testing
for _, useAntigravitySchema := range []bool{false, true} {
got := sanitizeAntigravityRequestSchemas(payload, useAntigravitySchema)
- before := gjson.Get(payload, "request.contents")
- after := gjson.Get(got, "request.contents")
+ before := ojson.Get(payload, "request.contents")
+ after := ojson.Get(got, "request.contents")
if before.Raw != after.Raw {
t.Errorf("antigravity=%v: history was mutated.\nbefore: %s\nafter: %s", useAntigravitySchema, before.Raw, after.Raw)
}
- schema := gjson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
+ schema := ojson.Get(got, "request.tools.0.functionDeclarations.0.parameters")
if !schema.Get("properties.propertyNames").Exists() {
t.Errorf("antigravity=%v: property named propertyNames was removed: %s", useAntigravitySchema, schema.Raw)
}
@@ -607,11 +607,11 @@ func TestAntigravityBuildRequestStripsPropertyNamesFromOutboundBody(t *testing.T
}
for _, path := range []string{"request.tools", "request.generationConfig"} {
- if node := gjson.GetBytes(encoded, path); strings.Contains(node.Raw, `"propertyNames"`) {
+ if node := ojson.GetBytes(encoded, path); strings.Contains(node.Raw, `"propertyNames"`) {
t.Errorf("%s still carries propertyNames: %s", path, node.Raw)
}
}
- args := gjson.GetBytes(encoded, "request.contents.0.parts.0.functionCall.args")
+ args := ojson.GetBytes(encoded, "request.contents.0.parts.0.functionCall.args")
if args.Get("propertyNames").String() != "keep-me" {
t.Errorf("functionCall argument named propertyNames was rewritten: %s", args.Raw)
}
@@ -633,7 +633,7 @@ func TestSanitizeAntigravityRequestSchemasStripsEncryptedMetadata(t *testing.T)
if strings.Contains(got, `"encrypted"`) {
t.Errorf("declContainer=%s antigravity=%v: 'encrypted' marker survived sanitization: %s", declContainer, useAntigravitySchema, got)
}
- schema := gjson.Get(got, "request.tools.0."+declContainer+".0.parameters")
+ schema := ojson.Get(got, "request.tools.0."+declContainer+".0.parameters")
if !schema.Get("properties.key.type").Exists() || schema.Get("properties.key.type").String() != "string" {
t.Errorf("declContainer=%s antigravity=%v: key property was corrupted: %s", declContainer, useAntigravitySchema, schema.Raw)
}
@@ -677,7 +677,7 @@ func TestSanitizeAntigravityRequestSchemasNormalizesTrueBooleanSubschemas(t *tes
for _, useAntigravitySchema := range []bool{false, true} {
got := sanitizeAntigravityRequestSchemas(payload, useAntigravitySchema)
- decl := gjson.Get(got, "request.tools.0.functionDeclarations.0")
+ decl := ojson.Get(got, "request.tools.0.functionDeclarations.0")
params := decl.Get("parameters")
if !params.Exists() {
t.Fatalf("parameters missing in sanitized declaration: %s", decl.Raw)
@@ -688,23 +688,23 @@ func TestSanitizeAntigravityRequestSchemasNormalizesTrueBooleanSubschemas(t *tes
if !val.Exists() {
t.Errorf("useAntigravitySchema=%v: property %s missing: %s", useAntigravitySchema, prop, params.Raw)
}
- if val.Type != gjson.JSON || val.Raw != "{}" {
+ if val.Type != ojson.JSON || val.Raw != "{}" {
t.Errorf("useAntigravitySchema=%v: property %s should be {}, got %s", useAntigravitySchema, prop, val.Raw)
}
}
// Check false is preserved
- if val := params.Get("properties.disabled"); val.Type != gjson.False {
+ if val := params.Get("properties.disabled"); val.Type != ojson.False {
t.Errorf("useAntigravitySchema=%v: disabled should be false, got %s", useAntigravitySchema, val.Raw)
}
// Check nested property
- if val := params.Get("properties.metadata.properties.nested_flag"); val.Type != gjson.JSON || val.Raw != "{}" {
+ if val := params.Get("properties.metadata.properties.nested_flag"); val.Type != ojson.JSON || val.Raw != "{}" {
t.Errorf("useAntigravitySchema=%v: nested_flag should be {}, got %s", useAntigravitySchema, val.Raw)
}
// Check items: true -> items: {}
- if val := params.Get("properties.tags.items"); val.Type != gjson.JSON || val.Raw != "{}" {
+ if val := params.Get("properties.tags.items"); val.Type != ojson.JSON || val.Raw != "{}" {
t.Errorf("useAntigravitySchema=%v: tags.items should be {}, got %s", useAntigravitySchema, val.Raw)
}
@@ -735,14 +735,14 @@ func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(
`"required":["value"]` +
`}}]}]}}`)
- declParams := func(t *testing.T, modelName string) gjson.Result {
+ declParams := func(t *testing.T, modelName string) ojson.Result {
t.Helper()
body := buildRequestBodyFromRawPayload(t, modelName, payload)
encoded, errMarshal := json.Marshal(body)
if errMarshal != nil {
t.Fatal(errMarshal)
}
- decl := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0")
+ decl := ojson.GetBytes(encoded, "request.tools.0.functionDeclarations.0")
if !decl.Exists() {
t.Fatalf("model %s: function declaration missing: %s", modelName, encoded)
}
@@ -805,7 +805,7 @@ func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(
if errMarshal != nil {
t.Fatal(errMarshal)
}
- gotDecl := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0")
+ gotDecl := ojson.GetBytes(encoded, "request.tools.0.functionDeclarations.0")
params := gotDecl.Get("parameters")
if params.Get("properties.item.anyOf").Exists() || params.Get("properties.item.type").String() != "object" || !params.Get("properties.item.properties.image").Exists() {
t.Fatalf("claude parameter schema union was not flattened: %s", params.Raw)
@@ -834,7 +834,7 @@ func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(
if errConv != nil {
t.Fatalf("convert error: %v", errConv)
}
- translatedValue := gjson.GetBytes(translated, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.value")
+ translatedValue := ojson.GetBytes(translated, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.value")
if len(translatedValue.Get("anyOf").Array()) != 2 || !translatedValue.Get("nullable").Bool() {
t.Fatalf("translator must preserve the union and set nullable:true: %s", translatedValue.Raw)
}
@@ -844,7 +844,7 @@ func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(
if errMarshal != nil {
t.Fatal(errMarshal)
}
- finalValue := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0.parameters.properties.value")
+ finalValue := ojson.GetBytes(encoded, "request.tools.0.functionDeclarations.0.parameters.properties.value")
if finalValue.Get("anyOf").Exists() || finalValue.Get("type").String() != "string" {
t.Fatalf("executor must flatten the union for Claude: %s", finalValue.Raw)
}
@@ -860,7 +860,7 @@ func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(
if errConv != nil {
t.Fatalf("model %s: convert error: %v", modelName, errConv)
}
- translatedParams := gjson.GetBytes(translated, "request.tools.0.functionDeclarations.0.parametersJsonSchema")
+ translatedParams := ojson.GetBytes(translated, "request.tools.0.functionDeclarations.0.parametersJsonSchema")
if got := len(translatedParams.Get("properties.item.anyOf").Array()); got != 3 {
t.Fatalf("model %s: translator anyOf branch count = %d, want 3 (flattening belongs to the executor, not the translator): %s", modelName, got, translatedParams.Raw)
}
@@ -873,7 +873,7 @@ func TestAntigravityBuildRequestFlattensToolUnionsForClaude_Issue3662Regression(
if errMarshal != nil {
t.Fatal(errMarshal)
}
- finalParams := gjson.GetBytes(encoded, "request.tools.0.functionDeclarations.0.parameters")
+ finalParams := ojson.GetBytes(encoded, "request.tools.0.functionDeclarations.0.parameters")
if !finalParams.Exists() {
t.Fatalf("model %s: final parameters missing: %s", modelName, encoded)
}
@@ -945,8 +945,8 @@ func TestSanitizeAntigravityRequestSchemasScalesLinearlyWithDeclarations(t *test
if got != want.String() {
t.Fatalf("sanitized tools diverged from the expected bytes\nfirst declaration want: %s\nfirst declaration got: %s",
- gjson.Get(want.String(), "request.tools.0.functionDeclarations.0").Raw,
- gjson.Get(got, "request.tools.0.functionDeclarations.0").Raw)
+ ojson.Get(want.String(), "request.tools.0.functionDeclarations.0").Raw,
+ ojson.Get(got, "request.tools.0.functionDeclarations.0").Raw)
}
// Whole-document work is bounded by a constant number of passes: extracting
diff --git a/internal/runtime/executor/antigravity_transport_decorator_test.go b/internal/runtime/executor/antigravity_transport_decorator_test.go
index a8206212959..a7c8538fb0f 100644
--- a/internal/runtime/executor/antigravity_transport_decorator_test.go
+++ b/internal/runtime/executor/antigravity_transport_decorator_test.go
@@ -10,12 +10,12 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type auditTestTransport struct {
@@ -120,8 +120,8 @@ func TestExecutorAttemptDecoratorObservesTranslatedPayloadAndStatusWithoutReques
if errRead != nil {
return nil, errRead
}
- observedModel = gjson.GetBytes(bodyBytes, "model").String()
- observedTemp = gjson.GetBytes(bodyBytes, "temperature").Float()
+ observedModel = ojson.GetBytes(bodyBytes, "model").String()
+ observedTemp = ojson.GetBytes(bodyBytes, "temperature").Float()
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
resp, errRT := next.RoundTrip(req)
diff --git a/internal/runtime/executor/apply_patch_bridge_test.go b/internal/runtime/executor/apply_patch_bridge_test.go
index 01c96a91103..29a7a337243 100644
--- a/internal/runtime/executor/apply_patch_bridge_test.go
+++ b/internal/runtime/executor/apply_patch_bridge_test.go
@@ -13,6 +13,7 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -23,8 +24,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -42,12 +41,12 @@ func applyPatchTestPayloads(chunk []byte) [][]byte {
line = bytes.TrimSpace(line)
if bytes.HasPrefix(line, []byte("data:")) {
payload := bytes.TrimSpace(line[len("data:"):])
- if gjson.ValidBytes(payload) {
+ if ojson.ValidBytes(payload) {
payloads = append(payloads, bytes.Clone(payload))
}
}
}
- if len(payloads) == 0 && gjson.ValidBytes(chunk) {
+ if len(payloads) == 0 && ojson.ValidBytes(chunk) {
payloads = append(payloads, bytes.Clone(chunk))
}
return payloads
@@ -88,28 +87,28 @@ func applyPatchTestFrames(protocol, name string) (string, string) {
func applyPatchTestDeclaration(t *testing.T, protocol string, body []byte) {
t.Helper()
- var tool gjson.Result
+ var tool ojson.Result
parameters := "parameters"
switch protocol {
case "chat":
- if gjson.GetBytes(body, "tools.0.type").String() != "function" {
+ if ojson.GetBytes(body, "tools.0.type").String() != "function" {
t.Fatalf("not a standard function: %s", body)
}
- tool = gjson.GetBytes(body, "tools.0.function")
+ tool = ojson.GetBytes(body, "tools.0.function")
case "gemini":
- tool = gjson.GetBytes(body, "tools.0.functionDeclarations.0")
+ tool = ojson.GetBytes(body, "tools.0.functionDeclarations.0")
parameters = "parametersJsonSchema"
case "antigravity":
- tool = gjson.GetBytes(body, "request.tools.0.functionDeclarations.0")
+ tool = ojson.GetBytes(body, "request.tools.0.functionDeclarations.0")
parameters = "parameters"
case "devin":
if len(body) < 5 {
t.Fatal("missing Connect envelope")
}
wire := applyPatchTestWireField(body[5:], 10)
- tool = gjson.Parse(fmt.Sprintf(`{"name":%q,"description":%q,"parameters":%s}`, applyPatchTestWireField(wire, 1), applyPatchTestWireField(wire, 2), applyPatchTestWireField(wire, 3)))
+ tool = ojson.Parse(fmt.Sprintf(`{"name":%q,"description":%q,"parameters":%s}`, applyPatchTestWireField(wire, 1), applyPatchTestWireField(wire, 2), applyPatchTestWireField(wire, 3)))
default:
- tool = gjson.GetBytes(body, "tools.0")
+ tool = ojson.GetBytes(body, "tools.0")
if protocol == "claude" {
parameters = "input_schema"
}
@@ -211,7 +210,7 @@ func TestApplyPatchBridgeLiveHTTPPreviewMatrix(t *testing.T) {
requestBody <- body
name := "apply_patch"
if tc.protocol == "claude" {
- name = gjson.GetBytes(body, "tools.0.name").String()
+ name = ojson.GetBytes(body, "tools.0.name").String()
}
first, last := applyPatchTestFrames(tc.protocol, name)
w.Header().Set("Content-Type", "text/event-stream")
@@ -249,7 +248,7 @@ func TestApplyPatchBridgeLiveHTTPPreviewMatrix(t *testing.T) {
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
- payload, errSetBytes := sjson.SetBytes(applyPatchTestRequest(), "stream", true)
+ payload, errSetBytes := ojson.SetBytes(applyPatchTestRequest(), "stream", true)
if errSetBytes != nil {
t.Fatal(errSetBytes)
}
@@ -275,7 +274,7 @@ func TestApplyPatchBridgeLiveHTTPPreviewMatrix(t *testing.T) {
acknowledged := false
for scanner.Scan() {
for _, raw := range applyPatchTestPayloads(scanner.Bytes()) {
- event := gjson.ParseBytes(raw)
+ event := ojson.ParseBytes(raw)
lifecycle.consume(t, event)
if !acknowledged {
if tc.snapshot {
@@ -326,7 +325,7 @@ func applyPatchTestCatalog(t *testing.T, server *httptest.Server, version string
if errReadAll != nil {
t.Fatal(errReadAll)
}
- entry := gjson.GetBytes(body, `models.#(slug=="test")`)
+ entry := ojson.GetBytes(body, `models.#(slug=="test")`)
if response.StatusCode != http.StatusOK || entry.Get("apply_patch_tool_type").String() != "freeform" {
t.Fatalf("catalog not backed by the registered route: %d %s", response.StatusCode, body)
}
@@ -342,7 +341,7 @@ type applyPatchTestLifecycle struct {
itemID, callID string
}
-func (s *applyPatchTestLifecycle) consume(t *testing.T, event gjson.Result) {
+func (s *applyPatchTestLifecycle) consume(t *testing.T, event ojson.Result) {
t.Helper()
switch event.Get("type").String() {
case "response.failed", "error", "response.function_call_arguments.delta":
@@ -433,11 +432,11 @@ func TestApplyPatchBridgeHistoryRoundTrip(t *testing.T) {
t.Fatal(errExecute)
}
applyPatchTestDeclaration(t, "chat", <-bodies)
- call := gjson.GetBytes(response.Payload, `output.#(type=="custom_tool_call")`)
+ call := ojson.GetBytes(response.Payload, `output.#(type=="custom_tool_call")`)
if call.Get("call_id").String() != "c1" || call.Get("input").String() != applyPatchTestInput {
t.Fatalf("wrong client call: %s", response.Payload)
}
- next, errSetRawBytes := sjson.SetRawBytes(payload, "input", []byte("["+call.Raw+`,{"type":"custom_tool_call_output","call_id":"c1","output":"Success"}]`))
+ next, errSetRawBytes := ojson.SetRawBytes(payload, "input", []byte("["+call.Raw+`,{"type":"custom_tool_call_output","call_id":"c1","output":"Success"}]`))
if errSetRawBytes != nil {
t.Fatal(errSetRawBytes)
}
@@ -446,11 +445,11 @@ func TestApplyPatchBridgeHistoryRoundTrip(t *testing.T) {
t.Fatal(errExecute)
}
body := <-bodies
- arguments := gjson.GetBytes(body, `messages.#(role=="assistant").tool_calls.0.function.arguments`).String()
- if !gjson.Valid(arguments) || gjson.Get(arguments, "input").String() != call.Get("input").String() || gjson.GetBytes(body, `messages.#(role=="assistant").tool_calls.0.id`).String() != "c1" {
+ arguments := ojson.GetBytes(body, `messages.#(role=="assistant").tool_calls.0.function.arguments`).String()
+ if !ojson.Valid(arguments) || ojson.Get(arguments, "input").String() != call.Get("input").String() || ojson.GetBytes(body, `messages.#(role=="assistant").tool_calls.0.id`).String() != "c1" {
t.Fatalf("wrong replay arguments/identity: %s", body)
}
- if gjson.GetBytes(body, `messages.#(role=="tool").tool_call_id`).String() != "c1" || gjson.GetBytes(body, `messages.#(role=="tool").content`).String() != "Success" {
+ if ojson.GetBytes(body, `messages.#(role=="tool").tool_call_id`).String() != "c1" || ojson.GetBytes(body, `messages.#(role=="tool").content`).String() != "Success" {
t.Fatalf("wrong replay result identity: %s", body)
}
}
@@ -480,15 +479,15 @@ func TestApplyPatchBridgeOrdinaryFunctionControl(t *testing.T) {
t.Fatal(errExecute)
}
body := <-bodies
- if !gjson.GetBytes(body, "tools.0.function.parameters.properties.extra").Exists() || strings.Contains(gjson.GetBytes(body, "tools.0.function.description").String(), "*** Begin Patch") {
+ if !ojson.GetBytes(body, "tools.0.function.parameters.properties.extra").Exists() || strings.Contains(ojson.GetBytes(body, "tools.0.function.description").String(), "*** Begin Patch") {
t.Fatalf("ordinary function schema changed: %s", body)
}
path := "choices.0.message.tool_calls.0.function.arguments"
if source == sdktranslator.FormatOpenAIResponse {
path = `output.#(type=="function_call").arguments`
}
- arguments := gjson.GetBytes(response.Payload, path).String()
- if arguments != args || !gjson.Valid(arguments) || bytes.Contains(response.Payload, []byte(`"custom_tool_call"`)) {
+ arguments := ojson.GetBytes(response.Payload, path).String()
+ if arguments != args || !ojson.Valid(arguments) || bytes.Contains(response.Payload, []byte(`"custom_tool_call"`)) {
t.Fatalf("ordinary JSON function promoted to freeform: %s", response.Payload)
}
})
@@ -515,19 +514,19 @@ func TestApplyPatchBridgeKimiReusedClientControls(t *testing.T) {
t.Fatal(errExecute)
}
body := <-bodies
- tool := gjson.GetBytes(body, "tools.0.function")
+ tool := ojson.GetBytes(body, "tools.0.function")
parameters := "parameters"
if source == sdktranslator.FormatClaude {
- tool, parameters = gjson.GetBytes(body, "tools.0"), "input_schema"
+ tool, parameters = ojson.GetBytes(body, "tools.0"), "input_schema"
}
if !tool.Get(parameters+".properties.extra").Exists() || strings.Contains(tool.Get("description").String(), "*** Begin Patch") || bytes.Contains(response.Payload, []byte("custom_tool_call")) || !bytes.Contains(response.Payload, []byte("RAW_SECRET")) {
t.Fatalf("ordinary reused client was promoted or restricted: request=%s response=%s", body, response.Payload)
}
- arguments := gjson.GetBytes(response.Payload, "choices.0.message.tool_calls.0.function.arguments").String()
+ arguments := ojson.GetBytes(response.Payload, "choices.0.message.tool_calls.0.function.arguments").String()
if source == sdktranslator.FormatClaude {
- arguments = gjson.GetBytes(response.Payload, "content.0.input").Raw
+ arguments = ojson.GetBytes(response.Payload, "content.0.input").Raw
}
- if !gjson.Valid(arguments) || gjson.Get(arguments, "extra").Int() != 7 {
+ if !ojson.Valid(arguments) || ojson.Get(arguments, "extra").Int() != 7 {
t.Fatalf("ordinary function lost valid JSON: %s", response.Payload)
}
})
@@ -545,7 +544,7 @@ func applyPatchTestPreviewChunks(t *testing.T, chunks <-chan cliproxyexecutor.St
}
output = append(output, chunk.Payload...)
for _, raw := range applyPatchTestPayloads(chunk.Payload) {
- lifecycle.consume(t, gjson.ParseBytes(raw))
+ lifecycle.consume(t, ojson.ParseBytes(raw))
if !preview && strings.Contains(lifecycle.deltas.String(), "+hello\n") {
if lifecycle.completed != 0 || lifecycle.inputDone != "" || lifecycle.itemDone != "" {
t.Fatal("preview arrived only at completion")
@@ -636,7 +635,7 @@ func TestApplyPatchBridgeLiveWebsocketPreview(t *testing.T) {
output := applyPatchTestPreviewChunks(t, result.Chunks, unblock)
body := <-bodies
if native {
- if gjson.GetBytes(body, "tools.0").Raw != gjson.GetBytes(payload, "tools.0").Raw {
+ if ojson.GetBytes(body, "tools.0").Raw != ojson.GetBytes(payload, "tools.0").Raw {
t.Fatalf("native grammar declaration altered: %s", body)
}
for _, raw := range applyPatchTestPayloads([]byte(first + last)) {
@@ -674,7 +673,7 @@ func TestApplyPatchBridgeOrdinaryFunctionStreamControl(t *testing.T) {
t.Fatalf("ordinary stream was promoted or restricted: %s", chunk.Payload)
}
for _, raw := range applyPatchTestPayloads(chunk.Payload) {
- event := gjson.ParseBytes(raw)
+ event := ojson.ParseBytes(raw)
if source == sdktranslator.FormatOpenAI {
arguments.WriteString(event.Get("choices.0.delta.tool_calls.0.function.arguments").String())
} else if event.Get("type").String() == "response.function_call_arguments.delta" {
@@ -682,7 +681,7 @@ func TestApplyPatchBridgeOrdinaryFunctionStreamControl(t *testing.T) {
}
}
}
- if !gjson.Valid(arguments.String()) || arguments.String() != args || !gjson.GetBytes(<-bodies, "tools.0.function.parameters.properties.extra").Exists() {
+ if !ojson.Valid(arguments.String()) || arguments.String() != args || !ojson.GetBytes(<-bodies, "tools.0.function.parameters.properties.extra").Exists() {
t.Fatalf("ordinary stream did not preserve JSON arguments/schema: %q", arguments.String())
}
})
diff --git a/internal/runtime/executor/apply_patch_identity_test.go b/internal/runtime/executor/apply_patch_identity_test.go
index 9389774c932..2192d48648f 100644
--- a/internal/runtime/executor/apply_patch_identity_test.go
+++ b/internal/runtime/executor/apply_patch_identity_test.go
@@ -8,13 +8,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Check every lifecycle identity, not merely the final snapshot or event counts.
@@ -23,7 +22,7 @@ func assertApplyPatchIdentityLifecycle(t *testing.T, events [][]byte, id, call,
counts := map[string]int{}
var actual []string
for _, event := range events {
- root := gjson.ParseBytes(event)
+ root := ojson.ParseBytes(event)
kind := root.Get("type").String()
identity, idKey := root, "item_id"
switch kind {
@@ -239,14 +238,14 @@ func TestApplyPatchNamedLateIdentityActualFailures(t *testing.T) {
if mode == "call-conflict" {
key = ".call_id"
}
- source[len(source)-2], _ = sjson.SetBytes(source[len(source)-2], itemPath+key, "changed")
- source[len(source)-1], _ = sjson.SetBytes(source[len(source)-1], finalPath+key, "changed")
+ source[len(source)-2], _ = ojson.SetBytes(source[len(source)-2], itemPath+key, "changed")
+ source[len(source)-1], _ = ojson.SetBytes(source[len(source)-1], finalPath+key, "changed")
case "partial-snapshot", "invalid-snapshot":
args := `{"input":"p`
if mode == "invalid-snapshot" {
args = `{"input":"p","extra":"RAW_SECRET"}`
}
- source[0], _ = sjson.SetBytes(source[0], itemPath+".arguments", args)
+ source[0], _ = ojson.SetBytes(source[0], itemPath+".arguments", args)
case "eof":
source = source[:len(source)-2]
}
@@ -271,7 +270,7 @@ func TestApplyPatchNamedLateIdentityActualFailures(t *testing.T) {
t.Fatalf("failure leaked/completed: %s", chunk.Payload)
}
for _, payload := range applyPatchTestPayloads(chunk.Payload) {
- if gjson.GetBytes(payload, "type").String() == "response.failed" {
+ if ojson.GetBytes(payload, "type").String() == "response.failed" {
failed++
} else {
t.Fatalf("unresolved call published: %s", payload)
@@ -300,17 +299,17 @@ func TestApplyPatchNamedLateIdentityActualInterleaved(t *testing.T) {
if i == len(second)-1 {
continue
}
- if gjson.GetBytes(event, indexKey).Exists() {
- event, _ = sjson.SetBytes(event, indexKey, 1)
+ if ojson.GetBytes(event, indexKey).Exists() {
+ event, _ = ojson.SetBytes(event, indexKey, 1)
}
- if gjson.GetBytes(event, itemPath+".id").String() == "a" {
- event, _ = sjson.SetBytes(event, itemPath+".id", "b")
+ if ojson.GetBytes(event, itemPath+".id").String() == "a" {
+ event, _ = ojson.SetBytes(event, itemPath+".id", "b")
}
- if gjson.GetBytes(event, itemPath+".call_id").String() == "c" {
- event, _ = sjson.SetBytes(event, itemPath+".call_id", "d")
+ if ojson.GetBytes(event, itemPath+".call_id").String() == "c" {
+ event, _ = ojson.SetBytes(event, itemPath+".call_id", "d")
}
- if interactions && gjson.GetBytes(event, "step.index").Exists() {
- event, _ = sjson.SetBytes(event, "step.index", 1)
+ if interactions && ojson.GetBytes(event, "step.index").Exists() {
+ event, _ = ojson.SetBytes(event, "step.index", 1)
}
event = []byte(strings.ReplaceAll(string(event), "pq", "uv"))
if i == 1 || i == 2 {
@@ -321,13 +320,13 @@ func TestApplyPatchNamedLateIdentityActualInterleaved(t *testing.T) {
if i == 2 {
value = `v"}`
}
- event, _ = sjson.SetBytes(event, path, value)
+ event, _ = ojson.SetBytes(event, path, value)
}
second[i] = event
}
terminal := first[len(first)-1]
- item := gjson.GetBytes(second[len(second)-2], itemPath).Raw
- terminal, _ = sjson.SetRawBytes(terminal, finalPath+".1", []byte(item))
+ item := ojson.GetBytes(second[len(second)-2], itemPath).Raw
+ terminal, _ = ojson.SetRawBytes(terminal, finalPath+".1", []byte(item))
source := [][]byte{first[0], second[0], first[1], second[1], second[2], first[2]}
source = append(source, second[3:len(second)-1]...)
source = append(source, first[3:len(first)-1]...)
@@ -347,17 +346,17 @@ func TestApplyPatchNamedLateIdentityActualInterleaved(t *testing.T) {
t.Fatal(chunk.Err)
}
for _, event := range applyPatchTestPayloads(chunk.Payload) {
- if gjson.GetBytes(event, "type").String() == "response.completed" {
- if len(gjson.GetBytes(event, "response.output").Array()) != 2 {
+ if ojson.GetBytes(event, "type").String() == "response.completed" {
+ if len(ojson.GetBytes(event, "response.output").Array()) != 2 {
t.Fatalf("lost final call: %s", event)
}
for index := range perCall {
- item := gjson.GetBytes(event, fmt.Sprintf("response.output.%d", index))
- final, _ := sjson.SetRawBytes(event, "response.output", []byte("["+item.Raw+"]"))
+ item := ojson.GetBytes(event, fmt.Sprintf("response.output.%d", index))
+ final, _ := ojson.SetRawBytes(event, "response.output", []byte("["+item.Raw+"]"))
perCall[index] = append(perCall[index], final)
}
} else {
- index := int(gjson.GetBytes(event, "output_index").Int())
+ index := int(ojson.GetBytes(event, "output_index").Int())
if index < 0 || index > 1 {
t.Fatalf("unknown call index: %s", event)
}
diff --git a/internal/runtime/executor/apply_patch_integration_test.go b/internal/runtime/executor/apply_patch_integration_test.go
index 10a4ee823d1..12848a3b8b1 100644
--- a/internal/runtime/executor/apply_patch_integration_test.go
+++ b/internal/runtime/executor/apply_patch_integration_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -24,7 +25,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -122,7 +122,7 @@ func assertTask6FailedStream(t *testing.T, chunks <-chan cliproxyexecutor.Stream
for chunk := range chunks {
output = append(output, chunk.Payload...)
for _, line := range bytes.Split(chunk.Payload, []byte("\n")) {
- if bytes.HasPrefix(line, []byte("data:")) && gjson.GetBytes(bytes.TrimSpace(line[5:]), "type").String() == "response.failed" {
+ if bytes.HasPrefix(line, []byte("data:")) && ojson.GetBytes(bytes.TrimSpace(line[5:]), "type").String() == "response.failed" {
failed++
}
}
@@ -148,7 +148,7 @@ func TestApplyPatchActualProviderErrorAndEOF(t *testing.T) {
}
name := "apply_patch"
if strings.HasPrefix(provider, "claude") {
- name = gjson.GetBytes(body, "tools.0.name").String()
+ name = ojson.GetBytes(body, "tools.0.name").String()
}
if mode != "nonstream" {
w.Header().Set("Content-Type", "text/event-stream")
@@ -448,7 +448,7 @@ func TestApplyPatchHTTPGatewayErrorMatrix(t *testing.T) {
name := "apply_patch"
actualMode := mode
if strings.HasPrefix(provider, "claude") {
- name = gjson.GetBytes(body, "tools.0.name").String()
+ name = ojson.GetBytes(body, "tools.0.name").String()
if mode == "nonstream" {
actualMode = "stream"
}
@@ -582,7 +582,7 @@ func TestApplyPatchFailureStopsConsumptionAndNextAttemptIsFresh(t *testing.T) {
t.Fatal("failed attempt kept consuming the upstream")
}
response, errExecute := exec.Execute(t.Context(), auth, req, opts)
- if errExecute != nil || gjson.GetBytes(response.Payload, "output.0.input").String() != "valid patch" {
+ if errExecute != nil || ojson.GetBytes(response.Payload, "output.0.input").String() != "valid patch" {
t.Fatalf("next attempt reused failed state: %s %v", response.Payload, errExecute)
}
}
@@ -636,10 +636,10 @@ func TestApplyPatchXAIWebsocketFailureMatrix(t *testing.T) {
errors++
assertTask6PatchError(t, chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.failed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.failed" {
failed++
}
- if bytes.Contains(chunk.Payload, []byte("RAW_SECRET")) || gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if bytes.Contains(chunk.Payload, []byte("RAW_SECRET")) || ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
t.Fatal("raw WS failure leaked or completed")
}
}
@@ -700,7 +700,7 @@ func TestApplyPatchDevinLegacyOtherToolsPreserved(t *testing.T) {
if custom {
key, expectedType = "input", "custom_tool_call"
}
- item := gjson.GetBytes(response.Payload, "output.0")
+ item := ojson.GetBytes(response.Payload, "output.0")
if item.Get("type").String() != expectedType || item.Get(key).String() != input {
t.Fatalf("legacy provenance changed: %s", response.Payload)
}
diff --git a/internal/runtime/executor/apply_patch_repair_test.go b/internal/runtime/executor/apply_patch_repair_test.go
index 8090a5f5f0a..d1403004a4d 100644
--- a/internal/runtime/executor/apply_patch_repair_test.go
+++ b/internal/runtime/executor/apply_patch_repair_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -23,7 +24,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// task6RepairSource completes tool input without implicitly completing the response.
@@ -129,7 +129,7 @@ func task6RepairChunkType(payload []byte) string {
if bytes.HasPrefix(payload, []byte("data:")) {
payload = bytes.TrimSpace(payload[5:])
}
- return gjson.GetBytes(payload, "type").String()
+ return ojson.GetBytes(payload, "type").String()
}
func task6RepairReadStream(t *testing.T, stream *cliproxyexecutor.StreamResult, failure bool) {
@@ -258,7 +258,7 @@ func TestApplyPatchRepairXAIWebsocketPersistentFailureRetry(t *testing.T) {
// Inject only the optional translator failure; retain the real executor/socket.
sdktranslator.Register(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatCodex, nil, sdktranslator.ResponseTransform{Stream: func(ctx context.Context, model string, original, effective, event []byte, param *any) [][]byte {
payload := bytes.TrimSpace(bytes.TrimPrefix(event, []byte("data: ")))
- if gjson.GetBytes(payload, "type").String() == "repair.invalid" || (gjson.GetBytes(payload, "type").String() == "response.completed" && gjson.GetBytes(payload, "response.id").String() == "repair-invalid") {
+ if ojson.GetBytes(payload, "type").String() == "repair.invalid" || (ojson.GetBytes(payload, "type").String() == "response.completed" && ojson.GetBytes(payload, "response.id").String() == "repair-invalid") {
state := &translatorcommon.ApplyPatchErrorState{}
state.SetToolInputError(errors.New("RAW_SECRET"))
*param = state
@@ -535,7 +535,7 @@ func TestApplyPatchRepairXAIWebsocketCompactionUsage(t *testing.T) {
opts.Stream = false
var response cliproxyexecutor.Response
response, errExecute = exec.XAIExecutor.Execute(t.Context(), auth, req, opts)
- if output := gjson.GetBytes(response.Payload, "output"); !output.IsArray() || len(output.Array()) != 0 {
+ if output := ojson.GetBytes(response.Payload, "output"); !output.IsArray() || len(output.Array()) != 0 {
t.Errorf("ordinary HTTP compact output changed: %s", response.Payload)
}
} else {
@@ -550,11 +550,11 @@ func TestApplyPatchRepairXAIWebsocketCompactionUsage(t *testing.T) {
if chunk.Err != nil {
t.Errorf("compact stream chunk error: %v", chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
completed = chunk.Payload
}
}
- if tc.wantStatus != 0 || gjson.GetBytes(completed, "response.output.0.encrypted_content").String() != "opaque-state" {
+ if tc.wantStatus != 0 || ojson.GetBytes(completed, "response.output.0.encrypted_content").String() != "opaque-state" {
t.Errorf("unexpected compact success: %s", completed)
}
}
diff --git a/internal/runtime/executor/apply_patch_source_stop_test.go b/internal/runtime/executor/apply_patch_source_stop_test.go
index f809613b69d..8aeeee7d055 100644
--- a/internal/runtime/executor/apply_patch_source_stop_test.go
+++ b/internal/runtime/executor/apply_patch_source_stop_test.go
@@ -5,9 +5,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// Run the R1 source trace through the actual Interactions HTTP executor and usage boundary.
@@ -61,7 +61,7 @@ func TestApplyPatchInteractionsSourceStopActualFailures(t *testing.T) {
t.Fatalf("failure leaked/completed: %s", chunk.Payload)
}
for _, payload := range applyPatchTestPayloads(chunk.Payload) {
- if gjson.GetBytes(payload, "type").String() != "response.failed" || gjson.GetBytes(payload, "response.error.code").String() != "invalid_tool_arguments" {
+ if ojson.GetBytes(payload, "type").String() != "response.failed" || ojson.GetBytes(payload, "response.error.code").String() != "invalid_tool_arguments" {
t.Fatalf("source-stopped unresolved patch published success/raw fallback: %s", payload)
}
failed++
diff --git a/internal/runtime/executor/caching_verify_test.go b/internal/runtime/executor/caching_verify_test.go
index b63a273b7e5..f3754bfb8eb 100644
--- a/internal/runtime/executor/caching_verify_test.go
+++ b/internal/runtime/executor/caching_verify_test.go
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
func TestEnsureCacheControl(t *testing.T) {
@@ -16,7 +16,7 @@ func TestEnsureCacheControl(t *testing.T) {
input := []byte(`{"model": "claude-3-5-sonnet", "system": "This is a long system prompt", "messages": []}`)
output := ensureCacheControl(input)
- res := gjson.GetBytes(output, "system.0.cache_control.type")
+ res := ojson.GetBytes(output, "system.0.cache_control.type")
if res.String() != "ephemeral" {
t.Errorf("cache_control not found in system string. Output: %s", string(output))
}
@@ -28,8 +28,8 @@ func TestEnsureCacheControl(t *testing.T) {
output := ensureCacheControl(input)
// cache_control should only be on the LAST element
- res0 := gjson.GetBytes(output, "system.0.cache_control")
- res1 := gjson.GetBytes(output, "system.1.cache_control.type")
+ res0 := ojson.GetBytes(output, "system.0.cache_control")
+ res1 := ojson.GetBytes(output, "system.1.cache_control.type")
if res0.Exists() {
t.Errorf("cache_control should NOT be on the first element")
@@ -52,11 +52,11 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if gjson.GetBytes(output, "tools.0.cache_control").Exists() || gjson.GetBytes(output, "tools.1.cache_control").Exists() {
+ if ojson.GetBytes(output, "tools.0.cache_control").Exists() || ojson.GetBytes(output, "tools.1.cache_control").Exists() {
t.Errorf("default ensureCacheControl must not stamp tools[*].cache_control: %s", string(output))
}
- systemCache := gjson.GetBytes(output, "system.0.cache_control")
+ systemCache := ojson.GetBytes(output, "system.0.cache_control")
if systemCache.Get("type").String() != "ephemeral" {
t.Errorf("cache_control not found in system. Output: %s", string(output))
}
@@ -82,14 +82,14 @@ func TestEnsureCacheControl(t *testing.T) {
output := ensureCacheControl(input)
// Tool already has cache_control - should not be changed
- tool0Cache := gjson.GetBytes(output, "tools.0.cache_control.type")
+ tool0Cache := ojson.GetBytes(output, "tools.0.cache_control.type")
if tool0Cache.String() != "ephemeral" {
t.Errorf("existing cache_control was incorrectly removed")
}
// System SHOULD get cache_control because it is an INDEPENDENT breakpoint
// Tools and system are separate cache levels in the hierarchy
- systemCache := gjson.GetBytes(output, "system.0.cache_control.type")
+ systemCache := ojson.GetBytes(output, "system.0.cache_control.type")
if systemCache.String() != "ephemeral" {
t.Errorf("system should have its own cache_control breakpoint (independent of tools)")
}
@@ -111,16 +111,16 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if gjson.GetBytes(output, "tools.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "tools.0.cache_control").Exists() {
t.Errorf("only the last tool may host the fallback breakpoint: %s", string(output))
}
- if got := gjson.GetBytes(output, "tools.1.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "tools.1.cache_control.type").String(); got != "ephemeral" {
t.Errorf("missing tools fallback breakpoint when system is absent: %s", string(output))
}
- if gjson.GetBytes(output, "tools.1.cache_control.ttl").Exists() {
+ if ojson.GetBytes(output, "tools.1.cache_control.ttl").Exists() {
t.Errorf("tools fallback breakpoint must not carry a default ttl: %s", string(output))
}
- if got := gjson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("rolling message breakpoint should still be present: %s", string(output))
}
})
@@ -140,7 +140,7 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "tools.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "tools.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("an unusable system prompt must still yield a tools breakpoint: %s", string(output))
}
// Empty/blank string system must not be rewritten into a marked text
@@ -148,7 +148,7 @@ func TestEnsureCacheControl(t *testing.T) {
if bytes.Contains(output, []byte(`"text":""`)) || bytes.Contains(output, []byte(`"text":" "`)) {
t.Errorf("unusable string system must stay unconverted: %s", string(output))
}
- if gjson.GetBytes(output, "system.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "system.0.cache_control").Exists() {
t.Errorf("unusable system must not receive its own breakpoint: %s", string(output))
}
if countCacheControls(output) != 2 {
@@ -181,20 +181,20 @@ func TestEnsureCacheControl(t *testing.T) {
for i := 0; i < 50; i++ {
path := fmt.Sprintf("tools.%d.cache_control", i)
- if gjson.GetBytes(output, path).Exists() {
+ if ojson.GetBytes(output, path).Exists() {
t.Errorf("tool %d should NOT have cache_control under default ensure", i)
}
}
helperOut := injectToolsCacheControl(input)
- if got := gjson.GetBytes(helperOut, "tools.49.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(helperOut, "tools.49.cache_control.type").String(); got != "ephemeral" {
t.Errorf("injectToolsCacheControl should still mark last tool")
}
- if got := gjson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("system should have cache_control, got %q", got)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("latest user should have cache_control, got %q", got)
}
})
@@ -205,7 +205,7 @@ func TestEnsureCacheControl(t *testing.T) {
output := ensureCacheControl(input)
// System should still get cache_control
- systemCache := gjson.GetBytes(output, "system.0.cache_control.type")
+ systemCache := ojson.GetBytes(output, "system.0.cache_control.type")
if systemCache.String() != "ephemeral" {
t.Errorf("system should have cache_control even with empty tools array")
}
@@ -225,13 +225,13 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.4.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.4.content.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("cache_control.type on latest user = %q, want ephemeral. Output: %s", got, string(output))
}
- if gjson.GetBytes(output, "messages.4.content.0.cache_control.ttl").Exists() {
+ if ojson.GetBytes(output, "messages.4.content.0.cache_control.ttl").Exists() {
t.Errorf("default rolling marker must not carry ttl. Output: %s", string(output))
}
- if gjson.GetBytes(output, "messages.2.content.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.2.content.0.cache_control").Exists() {
t.Errorf("second-to-last user turn should NOT have cache_control; native Claude Code rolls onto the latest user")
}
})
@@ -249,7 +249,7 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- systemContent := gjson.GetBytes(output, "messages.2.content")
+ systemContent := ojson.GetBytes(output, "messages.2.content")
if !systemContent.IsArray() || len(systemContent.Array()) != 1 {
t.Fatalf("trailing string system was not replaced by a single text block: %s", output)
}
@@ -259,7 +259,7 @@ func TestEnsureCacheControl(t *testing.T) {
if got := systemContent.Get("0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("trailing string system did not take the native special case: %s", output)
}
- if gjson.GetBytes(output, "messages.1.content.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.1.content.0.cache_control").Exists() {
t.Fatalf("preceding assistant must not also receive the rolling marker: %s", output)
}
})
@@ -278,10 +278,10 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if gjson.GetBytes(output, "messages.2.content.1.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.2.content.1.cache_control").Exists() {
t.Fatalf("array-content trailing system must not be marked; native requires string content: %s", output)
}
- if got := gjson.GetBytes(output, "messages.1.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.1.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("marker should fall back to the last eligible assistant turn: %s", output)
}
})
@@ -295,14 +295,14 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.1.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.1.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("trailing assistant cache_control.type = %q, want ephemeral. Output: %s", got, output)
}
wantAssistant := []byte(`[{"type":"text","text":"Assistant prefill","cache_control":{"type":"ephemeral"}}]`)
if !bytes.Contains(output, wantAssistant) {
t.Fatalf("assistant string promotion does not match native order: %s", output)
}
- if gjson.GetBytes(output, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("preceding user must not receive an assistant rolling marker: %s", output)
}
})
@@ -320,13 +320,13 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("preceding user cache_control.type = %q, want fallback ephemeral. Output: %s", got, output)
}
- if got := gjson.GetBytes(output, "messages.1.content"); got.Type != gjson.String {
+ if got := ojson.GetBytes(output, "messages.1.content"); got.Type != ojson.String {
t.Fatalf("internal system message was rewritten instead of skipped: %s", output)
}
- if gjson.GetBytes(output, "messages.2.content.1.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.2.content.1.cache_control").Exists() {
t.Fatalf("assistant thinking block must not receive cache_control: %s", output)
}
})
@@ -347,16 +347,16 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.0.content.1.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.1.cache_control.type").String(); got != "ephemeral" {
t.Errorf("cloaking first-user marker lost: %s", string(output))
}
- if got := gjson.GetBytes(output, "messages.4.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.4.content.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("latest user missing rolling cache_control after cloaking marker. Output: %s", string(output))
}
- if gjson.GetBytes(output, "tools.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "tools.0.cache_control").Exists() {
t.Errorf("a payload with a system prompt must not stamp tools[*].cache_control: %s", string(output))
}
- if got := gjson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
t.Errorf("system should still receive independent cache_control. Output: %s", string(output))
}
})
@@ -373,10 +373,10 @@ func TestEnsureCacheControl(t *testing.T) {
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.2.content.0.cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytes(output, "messages.2.content.0.cache_control.ttl").String(); got != "1h" {
t.Errorf("existing latest-user cache_control.ttl = %q, want 1h. Output: %s", got, string(output))
}
- if gjson.GetBytes(output, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.0.content.0.cache_control").Exists() {
t.Errorf("should not invent an extra message breakpoint on the first user when latest already has one")
}
})
@@ -510,7 +510,7 @@ func TestInjectToolsCacheControlSkipsDeferredTools(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := injectToolsCacheControl([]byte(tt.input))
- tools := gjson.GetBytes(output, "tools").Array()
+ tools := ojson.GetBytes(output, "tools").Array()
cacheCount := 0
for index, tool := range tools {
cacheControl := tool.Get("cache_control")
@@ -534,7 +534,7 @@ func TestInjectToolsCacheControlSkipsDeferredTools(t *testing.T) {
}
if tt.wantCacheTTL != "" {
path := fmt.Sprintf("tools.%d.cache_control.ttl", tt.wantCacheIndex)
- if got := gjson.GetBytes(output, path).String(); got != tt.wantCacheTTL {
+ if got := ojson.GetBytes(output, path).String(); got != tt.wantCacheTTL {
t.Errorf("cache_control TTL = %q, want %q: %s", got, tt.wantCacheTTL, string(output))
}
}
@@ -562,23 +562,23 @@ func TestCacheControlOrder(t *testing.T) {
output := ensureCacheControl(input)
// Native default path does not stamp tools.
- if gjson.GetBytes(output, "tools.0.cache_control").Exists() || gjson.GetBytes(output, "tools.1.cache_control").Exists() {
+ if ojson.GetBytes(output, "tools.0.cache_control").Exists() || ojson.GetBytes(output, "tools.1.cache_control").Exists() {
t.Error("default ensureCacheControl must not stamp tools[*].cache_control")
}
// Last system element has the default cache_control, which carries no ttl.
- if gjson.GetBytes(output, "system.1.cache_control.type").String() != "ephemeral" {
+ if ojson.GetBytes(output, "system.1.cache_control.type").String() != "ephemeral" {
t.Error("last system element should have cache_control")
}
- if gjson.GetBytes(output, "system.1.cache_control.ttl").Exists() {
+ if ojson.GetBytes(output, "system.1.cache_control.ttl").Exists() {
t.Error("default last system element must not carry a ttl")
}
- if got := gjson.GetBytes(upgradeClaudeCacheControlTTL(output, claudeCacheControlTTL1h), "system.1.cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytes(upgradeClaudeCacheControlTTL(output, claudeCacheControlTTL1h), "system.1.cache_control.ttl").String(); got != "1h" {
t.Errorf("upgraded last system element ttl = %q, want 1h", got)
}
// First system element has NO cache_control
- if gjson.GetBytes(output, "system.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "system.0.cache_control").Exists() {
t.Error("first system element should NOT have cache_control")
}
}
@@ -595,12 +595,12 @@ func TestUpgradeClaudeCacheControlTTL(t *testing.T) {
output := upgradeClaudeCacheControlTTL(input, claudeCacheControlTTL1h)
for _, path := range []string{"tools.0", "system.1", "messages.0.content.1"} {
- if got := gjson.GetBytes(output, path+".cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytes(output, path+".cache_control.ttl").String(); got != "1h" {
t.Errorf("%s.cache_control.ttl = %q, want 1h. Output: %s", path, got, output)
}
}
for _, path := range []string{"tools.1", "system.0", "messages.0.content.0"} {
- if gjson.GetBytes(output, path+".cache_control").Exists() {
+ if ojson.GetBytes(output, path+".cache_control").Exists() {
t.Errorf("%s must not gain a cache_control: %s", path, output)
}
}
@@ -612,7 +612,7 @@ func TestUpgradeClaudeCacheControlTTL(t *testing.T) {
t.Run("Preserves Caller TTL And Is Idempotent", func(t *testing.T) {
input := []byte(`{"system":[{"type":"text","text":"s","cache_control":{"type":"ephemeral","ttl":"5m"}}]}`)
output := upgradeClaudeCacheControlTTL(input, claudeCacheControlTTL1h)
- if got := gjson.GetBytes(output, "system.0.cache_control.ttl").String(); got != "5m" {
+ if got := ojson.GetBytes(output, "system.0.cache_control.ttl").String(); got != "5m" {
t.Errorf("existing ttl = %q, want the caller's 5m to survive", got)
}
@@ -665,8 +665,8 @@ func TestClaudeExecutorCloakedRollingCacheBreakpointAdvances(t *testing.T) {
// lastMarkedMessage reports the highest message index carrying a breakpoint.
lastMarkedMessage := func(body []byte) int {
last := -1
- gjson.GetBytes(body, "messages").ForEach(func(msgIdx, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ ojson.GetBytes(body, "messages").ForEach(func(msgIdx, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("cache_control").Exists() {
last = int(msgIdx.Int())
}
@@ -690,11 +690,11 @@ func TestClaudeExecutorCloakedRollingCacheBreakpointAdvances(t *testing.T) {
t.Fatalf("rolling breakpoint did not advance with history: short=%d long=%d\n%s", shortMarked, longMarked, longBody)
}
// The rolling marker must land on the final turn, not an early frozen prefix.
- if want := int(gjson.GetBytes(longBody, "messages.#").Int()) - 1; longMarked != want {
+ if want := int(ojson.GetBytes(longBody, "messages.#").Int()) - 1; longMarked != want {
t.Fatalf("rolling breakpoint at message %d, want final message %d: %s", longMarked, want, longBody)
}
// Cloaking's own first-user marker must still be present alongside it.
- if !gjson.GetBytes(longBody, "messages.0.content.1.cache_control").Exists() {
+ if !ojson.GetBytes(longBody, "messages.0.content.1.cache_control").Exists() {
t.Fatalf("cloak first-user breakpoint lost: %s", longBody)
}
if total := countCacheControls(longBody); total > 4 {
@@ -716,10 +716,10 @@ func TestEnsureCacheControlSkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
}]
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("messages.0.content.0.cache_control.type = %q, want ephemeral. Output: %s", got, output)
}
- if gjson.GetBytes(output, "messages.0.content.1.cache_control").Exists() || gjson.GetBytes(output, "messages.0.content.2.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.0.content.1.cache_control").Exists() || ojson.GetBytes(output, "messages.0.content.2.cache_control").Exists() {
t.Fatalf("empty/whitespace text block must not receive cache_control: %s", output)
}
})
@@ -734,10 +734,10 @@ func TestEnsureCacheControlSkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
]
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("messages.0.content.0.cache_control.type = %q, want fallback ephemeral. Output: %s", got, output)
}
- if gjson.GetBytes(output, "messages.1.content.0.cache_control").Exists() || gjson.GetBytes(output, "messages.2.content.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.1.content.0.cache_control").Exists() || ojson.GetBytes(output, "messages.2.content.0.cache_control").Exists() {
t.Fatalf("empty messages must not receive cache_control: %s", output)
}
})
@@ -749,10 +749,10 @@ func TestEnsureCacheControlSkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
"messages": [{"role": "user", "content": [{"type": "text", "text": ""}]}]
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("system.0.cache_control.type = %q, want ephemeral. Output: %s", got, output)
}
- if gjson.GetBytes(output, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("fallback empty user text block must not receive cache_control: %s", output)
}
})
@@ -768,10 +768,10 @@ func TestEnsureCacheControlSkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
"messages": [{"role": "user", "content": "Hi"}]
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "system.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("system.0.cache_control.type = %q, want ephemeral. Output: %s", got, output)
}
- if gjson.GetBytes(output, "system.1.cache_control").Exists() || gjson.GetBytes(output, "system.2.cache_control").Exists() {
+ if ojson.GetBytes(output, "system.1.cache_control").Exists() || ojson.GetBytes(output, "system.2.cache_control").Exists() {
t.Fatalf("empty system blocks must not receive cache_control: %s", output)
}
})
@@ -784,10 +784,10 @@ func TestEnsureCacheControlSkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
"messages": [{"role": "user", "content": "Hi"}]
}`)
output := ensureCacheControl(input)
- if got := gjson.GetBytes(output, "tools.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "tools.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("tools.0.cache_control.type = %q, want ephemeral fallback when system array has only empty blocks: %s", got, output)
}
- if gjson.GetBytes(output, "system.0.cache_control").Exists() || gjson.GetBytes(output, "system.1.cache_control").Exists() {
+ if ojson.GetBytes(output, "system.0.cache_control").Exists() || ojson.GetBytes(output, "system.1.cache_control").Exists() {
t.Fatalf("empty system array elements must not receive cache_control: %s", output)
}
})
@@ -804,10 +804,10 @@ func TestEnsureCacheControlSkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
}]
}`)
output := injectClaudeCodeCurrentDate(input, "2026-10-20")
- if gjson.GetBytes(output, "messages.0.content.1.cache_control").Exists() {
+ if ojson.GetBytes(output, "messages.0.content.1.cache_control").Exists() {
t.Fatalf("empty text block at index 1 must not receive cloaking cache_control: %s", output)
}
- if got := gjson.GetBytes(output, "messages.0.content.2.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(output, "messages.0.content.2.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("first non-empty user text block at index 2 should receive cloaking cache_control, got %q: %s", got, output)
}
})
diff --git a/internal/runtime/executor/claude_cloaked_cache_repro_test.go b/internal/runtime/executor/claude_cloaked_cache_repro_test.go
index 28ca44a3a13..d5bd0502e6c 100644
--- a/internal/runtime/executor/claude_cloaked_cache_repro_test.go
+++ b/internal/runtime/executor/claude_cloaked_cache_repro_test.go
@@ -9,12 +9,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// TestClaudeBillingFingerprintMultiTurnUsesFirstUserText verifies that in a multi-turn conversation,
@@ -318,8 +318,8 @@ func TestClaudeCloakedMultiTurnPrefixStability(t *testing.T) {
body2 := capturedBodies[1]
// 1. Verify system[0] billing header version and buildHash match across turns
- sys1 := gjson.GetBytes(body1, "system.0.text").String()
- sys2 := gjson.GetBytes(body2, "system.0.text").String()
+ sys1 := ojson.GetBytes(body1, "system.0.text").String()
+ sys2 := ojson.GetBytes(body2, "system.0.text").String()
ver1 := extractTag(sys1, "cc_version=")
ver2 := extractTag(sys2, "cc_version=")
if ver1 != ver2 {
@@ -327,22 +327,22 @@ func TestClaudeCloakedMultiTurnPrefixStability(t *testing.T) {
}
// 2. Verify system[1] CLI identity block matches across turns
- agent1 := gjson.GetBytes(body1, "system.1").Raw
- agent2 := gjson.GetBytes(body2, "system.1").Raw
+ agent1 := ojson.GetBytes(body1, "system.1").Raw
+ agent2 := ojson.GetBytes(body2, "system.1").Raw
if agent1 != agent2 {
t.Fatalf("system.1 identity block differs across turns:\nTurn 1: %s\nTurn 2: %s", agent1, agent2)
}
// 3. Verify messages[0] (the first user message anchor) matches across turns
- msg1User := gjson.GetBytes(body1, "messages.0").Raw
- msg2User := gjson.GetBytes(body2, "messages.0").Raw
+ msg1User := ojson.GetBytes(body1, "messages.0").Raw
+ msg2User := ojson.GetBytes(body2, "messages.0").Raw
if msg1User != msg2User {
t.Fatalf("messages.0 differs across turns:\nTurn 1: %s\nTurn 2: %s", msg1User, msg2User)
}
// 4. Verify messages[1] (the relocated caller system instructions) matches across turns
- msg1Sys := gjson.GetBytes(body1, "messages.1").Raw
- msg2Sys := gjson.GetBytes(body2, "messages.1").Raw
+ msg1Sys := ojson.GetBytes(body1, "messages.1").Raw
+ msg2Sys := ojson.GetBytes(body2, "messages.1").Raw
if msg1Sys != msg2Sys {
t.Fatalf("messages.1 differs across turns:\nTurn 1: %s\nTurn 2: %s", msg1Sys, msg2Sys)
}
@@ -400,7 +400,7 @@ func TestClaudeCloakedMultiTurnPrefixStability(t *testing.T) {
body3 := capturedBodies[3]
body3Repeat := capturedBodies[4]
- sys3 := gjson.GetBytes(body3, "system.0.text").String()
+ sys3 := ojson.GetBytes(body3, "system.0.text").String()
ver3 := extractTag(sys3, "cc_version=")
if ver3 != ver1 {
t.Fatalf("turn 3 cc_version differs: turn1=%q turn3=%q", ver1, ver3)
@@ -634,7 +634,7 @@ func TestClaudeCloakedToolContinuationPreservesExplicitPromptID(t *testing.T) {
t.Fatalf("expected 2 captured bodies, got %d", len(capturedBodies))
}
- sysTool := gjson.GetBytes(capturedBodies[1], "system.0.text").String()
+ sysTool := ojson.GetBytes(capturedBodies[1], "system.0.text").String()
promptTool := extractTag(sysTool, "cc_prompt_id=")
if promptTool != explicitUUID {
t.Fatalf("tool continuation lost explicit prompt ID: got %q, want %q", promptTool, explicitUUID)
@@ -718,8 +718,8 @@ func TestClaudeCloakedColdStartToolContinuationUsesDeterministicPromptID(t *test
t.Fatalf("expected 2 captured bodies, got %d", len(capturedBodies))
}
- sys1 := gjson.GetBytes(capturedBodies[0], "system.0.text").String()
- sys2 := gjson.GetBytes(capturedBodies[1], "system.0.text").String()
+ sys1 := ojson.GetBytes(capturedBodies[0], "system.0.text").String()
+ sys2 := ojson.GetBytes(capturedBodies[1], "system.0.text").String()
p1 := extractTag(sys1, "cc_prompt_id=")
p2 := extractTag(sys2, "cc_prompt_id=")
if p1 == "" || p1 != p2 {
diff --git a/internal/runtime/executor/claude_cloaked_date_pin_test.go b/internal/runtime/executor/claude_cloaked_date_pin_test.go
index 32b3c9732c4..c4955f005d5 100644
--- a/internal/runtime/executor/claude_cloaked_date_pin_test.go
+++ b/internal/runtime/executor/claude_cloaked_date_pin_test.go
@@ -5,18 +5,21 @@ import (
"fmt"
"io"
"net/http"
+ "path/filepath"
"regexp"
"strings"
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
var claudeDateReminderPattern = regexp.MustCompile(`Today's date is (\d{4}-\d{2}-\d{2})`)
@@ -25,7 +28,7 @@ var claudeDateReminderPattern = regexp.MustCompile(`Today's date is (\d{4}-\d{2}
// reminder injected into the first user message of a cloaked payload.
func extractClaudeCloakDate(t *testing.T, payload []byte) string {
t.Helper()
- content := gjson.GetBytes(payload, "messages.0.content")
+ content := ojson.GetBytes(payload, "messages.0.content")
if content.IsArray() {
for _, block := range content.Array() {
if match := claudeDateReminderPattern.FindStringSubmatch(block.Get("text").String()); match != nil {
@@ -198,9 +201,9 @@ func TestClaudeCloakedDateReminderSessionByteStability(t *testing.T) {
t.Fatalf("expected 3 captured bodies, got %d", len(capturedBodies))
}
- msg0First := gjson.GetBytes(capturedBodies[0], "messages.0").Raw
- msg0Second := gjson.GetBytes(capturedBodies[1], "messages.0").Raw
- msg0SecondStream := gjson.GetBytes(capturedBodies[2], "messages.0").Raw
+ msg0First := ojson.GetBytes(capturedBodies[0], "messages.0").Raw
+ msg0Second := ojson.GetBytes(capturedBodies[1], "messages.0").Raw
+ msg0SecondStream := ojson.GetBytes(capturedBodies[2], "messages.0").Raw
if msg0First != msg0Second {
t.Fatalf("messages[0] changed between requests of one session:\nfirst: %s\nsecond: %s", msg0First, msg0Second)
}
@@ -340,3 +343,191 @@ func TestClaudeCloakedDateReminderProbeOverrideDoesNotReinitializeSessionDate(t
t.Fatalf("configured ExecuteStream request date = %q, want original probe date 2026-08-02", got)
}
}
+
+func TestClaudeCloakedDateReminderPersistsAcrossRestartWithLocalBBoltKV(t *testing.T) {
+ // Create the temp dir before registering the shutdown cleanup. Cleanups run
+ // LIFO, so the store must close before TempDir removal, or Windows fails to
+ // delete the still-open kv.db.
+ dbPath := filepath.Join(t.TempDir(), "kv.db")
+
+ homekv.ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ helps.ResetClaudeDiagnosticsForTest()
+ t.Cleanup(func() {
+ homekv.ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ helps.ResetClaudeDiagnosticsForTest()
+ })
+
+ if errCfg := kvstore.ConfigureLocal(true, dbPath); errCfg != nil {
+ t.Fatalf("kvstore.ConfigureLocal(true, %q) error = %v", dbPath, errCfg)
+ }
+
+ var mockTime time.Time
+ claudeCodeCurrentTimeFunc = func(cfg *config.Config, auth *cliproxyauth.Auth) time.Time {
+ return mockTime
+ }
+ t.Cleanup(func() {
+ claudeCodeCurrentTimeFunc = nil
+ })
+
+ var capturedBodies [][]byte
+ call := 0
+ transport := datePinRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ body := make([]byte, 0)
+ if req.Body != nil {
+ buf, errRead := io.ReadAll(req.Body)
+ if errRead != nil {
+ t.Fatal(errRead)
+ }
+ body = buf
+ }
+ capturedBodies = append(capturedBodies, body)
+ call++
+ response := fmt.Sprintf(`{"id":"msg_%d","type":"message","model":"claude-opus-5","role":"assistant","content":[{"type":"text","text":"OK"}],"usage":{"input_tokens":10,"output_tokens":2}}`, call)
+ header := make(http.Header)
+ header.Set("Content-Type", "application/json")
+ header.Set("request-id", fmt.Sprintf("req_upstream_%d", call))
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: header,
+ Body: io.NopCloser(strings.NewReader(response)),
+ Request: req,
+ }, nil
+ })
+
+ // Explicitly configure session-date-pin-days: 0 to verify KV mode ignores it
+ // and persists the session date pin permanently.
+ zero := 0
+ cfg := &config.Config{
+ Claude: config.ClaudeConfig{SessionDatePinDays: &zero},
+ }
+ authA := &cliproxyauth.Auth{
+ ID: "test-date-pin-bbolt-oauth-a",
+ Attributes: map[string]string{"api_key": "sk-ant-oat-test-oauth-key-a", "cloak_mode": "always", "timezone": "UTC"},
+ Metadata: map[string]any{
+ "account_uuid": "11111111-2222-4333-8444-555555555555",
+ claudeauth.ClaudeDeviceIDsMetadataKey: []string{
+ "00000000000000000000000000000000000000000000000000000000000000d1",
+ },
+ },
+ }
+ authB := &cliproxyauth.Auth{
+ ID: "test-date-pin-bbolt-oauth-b",
+ Attributes: map[string]string{"api_key": "sk-ant-oat-test-oauth-key-b", "cloak_mode": "always", "timezone": "UTC"},
+ Metadata: map[string]any{
+ "account_uuid": "22222222-3333-4444-8555-666666666666",
+ claudeauth.ClaudeDeviceIDsMetadataKey: []string{
+ "00000000000000000000000000000000000000000000000000000000000000d2",
+ },
+ },
+ }
+
+ exec1 := NewClaudeExecutor(cfg)
+ payload := `{
+ "model": "claude-opus-5",
+ "system": [{"type": "text", "text": "You are an expert software engineer.", "cache_control": {"type": "ephemeral"}}],
+ "messages": [{"role": "user", "content": "Say OK."}],
+ "max_tokens": 100
+ }`
+ options := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ Headers: http.Header{"X-Session-ID": []string{"bbolt-restart-session-1"}},
+ }
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ req := cliproxyexecutor.Request{Model: "claude-opus-5", Payload: []byte(payload)}
+
+ // 1. Send request 1 on day 1 (2026-08-01).
+ mockTime = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ if _, err := exec1.Execute(ctx, authA, req, options); err != nil {
+ t.Fatalf("request 1 failed: %v", err)
+ }
+
+ // 2. Simulate a full CPA restart: close and reopen local bbolt at the same
+ // dbPath and wipe all in-memory continuity/date-pin state.
+ if errShutdown := kvstore.ShutdownLocal(); errShutdown != nil {
+ t.Fatalf("kvstore.ShutdownLocal() error = %v", errShutdown)
+ }
+ helps.ResetClaudeDiagnosticsForTest()
+ if errReopen := kvstore.ConfigureLocal(true, dbPath); errReopen != nil {
+ t.Fatalf("kvstore.ConfigureLocal(true, %q) reopen error = %v", dbPath, errReopen)
+ }
+ exec2 := NewClaudeExecutor(cfg)
+
+ // 3. Advance mockTime to 2026-09-15 (45 days later, well past the 7-day
+ // in-memory default and with session-date-pin-days: 0).
+ mockTime = time.Date(2026, 9, 15, 14, 30, 0, 0, time.UTC)
+
+ // 4. Send request 2 for the same session with a different credential.
+ if _, err := exec2.Execute(ctx, authB, req, options); err != nil {
+ t.Fatalf("request 2 after restart failed: %v", err)
+ }
+ if _, err := exec2.ExecuteStream(ctx, authB, req, options); err != nil {
+ t.Fatalf("request 2b (stream) after restart failed: %v", err)
+ }
+
+ if len(capturedBodies) != 3 {
+ t.Fatalf("expected 3 captured bodies, got %d", len(capturedBodies))
+ }
+
+ msg0First := ojson.GetBytes(capturedBodies[0], "messages.0").Raw
+ msg0AfterRestart := ojson.GetBytes(capturedBodies[1], "messages.0").Raw
+ msg0AfterRestartStream := ojson.GetBytes(capturedBodies[2], "messages.0").Raw
+ if msg0First != msg0AfterRestart {
+ t.Fatalf("messages[0] changed across CPA restart:\nbefore: %s\nafter: %s", msg0First, msg0AfterRestart)
+ }
+ if msg0First != msg0AfterRestartStream {
+ t.Fatalf("messages[0] changed across CPA restart in stream:\nbefore: %s\nstream: %s", msg0First, msg0AfterRestartStream)
+ }
+ if got := extractClaudeCloakDate(t, capturedBodies[1]); got != "2026-08-01" {
+ t.Fatalf("session date after restart = %q, want anchored 2026-08-01", got)
+ }
+
+ // 5. Verify that a caller-supplied string-form currentDate reminder seeds the
+ // permanent KV anchor, strips the duplicate string reminder while preserving
+ // user text, and survives a restart + 45-day clock jump with session-date-pin-days: 0.
+ callerOptions := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ Headers: http.Header{"X-Session-ID": []string{"bbolt-restart-caller-session-2"}},
+ }
+ callerDoc1 := ojson.NewDocString(`{"model":"claude-opus-5","messages":[{"role":"user","content":""}],"max_tokens":100}`)
+ _ = callerDoc1.Set("messages.0.content", claudeCodeCurrentDateReminder("2026-07-20")+"Say OK.")
+ mockTime = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
+ if _, err := exec2.Execute(ctx, authA, cliproxyexecutor.Request{Model: "claude-opus-5", Payload: callerDoc1.Bytes()}, callerOptions); err != nil {
+ t.Fatalf("caller reminder request 1 failed: %v", err)
+ }
+
+ if errShutdown := kvstore.ShutdownLocal(); errShutdown != nil {
+ t.Fatalf("kvstore.ShutdownLocal() second error = %v", errShutdown)
+ }
+ helps.ResetClaudeDiagnosticsForTest()
+ if errReopen := kvstore.ConfigureLocal(true, dbPath); errReopen != nil {
+ t.Fatalf("kvstore.ConfigureLocal(true, %q) second reopen error = %v", dbPath, errReopen)
+ }
+ exec3 := NewClaudeExecutor(cfg)
+
+ mockTime = time.Date(2026, 9, 15, 18, 0, 0, 0, time.UTC)
+ callerDoc2 := ojson.NewDocString(`{"model":"claude-opus-5","messages":[{"role":"user","content":""}],"max_tokens":100}`)
+ _ = callerDoc2.Set("messages.0.content", claudeCodeCurrentDateReminder("2026-09-15")+"Say OK.")
+ if _, err := exec3.Execute(ctx, authB, cliproxyexecutor.Request{Model: "claude-opus-5", Payload: callerDoc2.Bytes()}, callerOptions); err != nil {
+ t.Fatalf("caller reminder request 2 after restart failed: %v", err)
+ }
+
+ if len(capturedBodies) != 5 {
+ t.Fatalf("expected 5 captured bodies, got %d", len(capturedBodies))
+ }
+ callerMsg0Before := ojson.GetBytes(capturedBodies[3], "messages.0").Raw
+ callerMsg0After := ojson.GetBytes(capturedBodies[4], "messages.0").Raw
+ if callerMsg0Before != callerMsg0After {
+ t.Fatalf("caller-seeded messages[0] changed across restart:\nbefore: %s\nafter: %s", callerMsg0Before, callerMsg0After)
+ }
+ if got := extractClaudeCloakDate(t, capturedBodies[4]); got != "2026-07-20" {
+ t.Fatalf("caller-seeded session date after restart = %q, want 2026-07-20", got)
+ }
+ if got := strings.Count(callerMsg0After, "Today's date is"); got != 1 {
+ t.Fatalf("caller-seeded messages[0] has %d date reminders, want 1: %s", got, callerMsg0After)
+ }
+ if !strings.Contains(callerMsg0After, "Say OK.") {
+ t.Fatalf("caller-seeded messages[0] lost user text 'Say OK.': %s", callerMsg0After)
+ }
+}
diff --git a/internal/runtime/executor/claude_effort_directive_test.go b/internal/runtime/executor/claude_effort_directive_test.go
index f0b5e09adc4..dc2e7f80311 100644
--- a/internal/runtime/executor/claude_effort_directive_test.go
+++ b/internal/runtime/executor/claude_effort_directive_test.go
@@ -6,11 +6,11 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestInsertClaudeMidConversationSystemMessages_EffortDirective(t *testing.T) {
@@ -31,9 +31,9 @@ func TestInsertClaudeMidConversationSystemMessages_EffortDirective(t *testing.T)
} {
t.Run(test.name, func(t *testing.T) {
payload := []byte(`{"messages":[{"role":"user","content":"summary"},` + test.tail + `]}`)
- before := gjson.GetBytes(payload, "messages").Array()
+ before := ojson.GetBytes(payload, "messages").Array()
out := insertClaudeMidConversationSystemMessages(payload, []string{"first guidance", "second guidance"})
- after := gjson.GetBytes(out, "messages").Array()
+ after := ojson.GetBytes(out, "messages").Array()
if len(after) != len(before)+2 {
t.Fatalf("message count = %d, want %d: %s", len(after), len(before)+2, out)
}
@@ -71,22 +71,22 @@ func TestClaudeSystemPlacement_EffortDirectivePolicies(t *testing.T) {
{name: "count tokens", out: relocateClaudeSystemPromptForCountTokensWithPolicy(payload, false, false, keepTopLevel)},
} {
t.Run(test.name, func(t *testing.T) {
- if got := gjson.GetBytes(test.out, "messages.1").Raw; got != gjson.GetBytes(payload, "messages.1").Raw {
+ if got := ojson.GetBytes(test.out, "messages.1").Raw; got != ojson.GetBytes(payload, "messages.1").Raw {
t.Fatalf("caller effort directive changed or moved: %s", test.out)
}
wantMessages := int64(4)
if keepTopLevel {
wantMessages = 3
- if !bytes.Contains([]byte(gjson.GetBytes(test.out, "system").Raw), []byte("caller guidance")) {
+ if !bytes.Contains([]byte(ojson.GetBytes(test.out, "system").Raw), []byte("caller guidance")) {
t.Fatalf("caller guidance missing from top-level system: %s", test.out)
}
} else {
assertClaudeMidConversationSystemMessage(t, test.out, 3, "caller guidance", "")
- if bytes.Contains([]byte(gjson.GetBytes(test.out, "system").Raw), []byte("caller guidance")) {
+ if bytes.Contains([]byte(ojson.GetBytes(test.out, "system").Raw), []byte("caller guidance")) {
t.Fatalf("relocated caller guidance remains in top-level system: %s", test.out)
}
}
- if got := gjson.GetBytes(test.out, "messages.#").Int(); got != wantMessages {
+ if got := ojson.GetBytes(test.out, "messages.#").Int(); got != wantMessages {
t.Fatalf("message count = %d, want %d: %s", got, wantMessages, test.out)
}
})
@@ -109,7 +109,7 @@ func TestCaptureClaudeCodeSystemPlacement_EffortDirective(t *testing.T) {
if state.insertAt != 3 || len(state.insertedRaw) != 1 || len(state.texts) != 1 {
t.Fatalf("placement = %+v, want one inserted guidance turn at index 3", state)
}
- if state.insertedRaw[0] != gjson.GetBytes(after, "messages.3").Raw || state.texts[0] != "guidance" {
+ if state.insertedRaw[0] != ojson.GetBytes(after, "messages.3").Raw || state.texts[0] != "guidance" {
t.Fatalf("placement captured the wrong turn: %+v", state)
}
if callerOwned := captureClaudeCodeSystemPlacement(before, before, true); len(callerOwned.insertedRaw) != 0 {
@@ -165,16 +165,16 @@ func TestClaudeExecutor_EffortDirectiveAfterCompaction(t *testing.T) {
if !upstream.called {
t.Fatal("upstream request was not sent")
}
- if got := gjson.GetBytes(upstream.body, "messages.1").Raw; got != gjson.GetBytes(payload, "messages.1").Raw {
+ if got := ojson.GetBytes(upstream.body, "messages.1").Raw; got != ojson.GetBytes(payload, "messages.1").Raw {
t.Fatalf("caller effort directive changed or moved on the wire: %s", upstream.body)
}
- if got := gjson.GetBytes(upstream.body, "messages.#.role").Raw; got != `["user","system","user","system"]` {
+ if got := ojson.GetBytes(upstream.body, "messages.#.role").Raw; got != `["user","system","user","system"]` {
t.Fatalf("wire roles = %s, want [user, directive, user, caller system]: %s", got, upstream.body)
}
- if got := gjson.GetBytes(upstream.body, "messages.3.content.0.text").String(); got != "caller guidance" {
+ if got := ojson.GetBytes(upstream.body, "messages.3.content.0.text").String(); got != "caller guidance" {
t.Fatalf("caller guidance missing from trailing system message: %s", upstream.body)
}
- if bytes.Contains([]byte(gjson.GetBytes(upstream.body, "system").Raw), []byte("caller guidance")) {
+ if bytes.Contains([]byte(ojson.GetBytes(upstream.body, "system").Raw), []byte("caller guidance")) {
t.Fatalf("OAuth caller guidance must not remain in top-level system: %s", upstream.body)
}
})
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index c8029732ae3..c20855ab686 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
@@ -20,8 +21,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ClaudeExecutor handles Anthropic Claude over the messages API and keeps bounded
@@ -78,17 +77,155 @@ func shouldSanitizeClaudeMessagesForUpstream(baseModel string) bool {
}
func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body []byte, baseModel string, preserveEmptyThinkingBlocks ...bool) []byte {
+ doc := ojson.NewDoc(body)
+ if !sanitizeClaudeMessagesForClaudeUpstreamWithDebugDoc(ctx, doc, baseModel, preserveEmptyThinkingBlocks...) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func sanitizeClaudeMessagesForClaudeUpstreamWithDebugDoc(ctx context.Context, doc *ojson.Doc, baseModel string, preserveEmptyThinkingBlocks ...bool) bool {
+ if doc == nil {
+ return false
+ }
// The internal commentary marker must never reach any upstream, so its
// restore runs unconditionally, independent of the provider signature
// sanitization gate below (which non-Claude model names skip entirely).
- sanitized, _ := sigcompat.RestoreCodexCommentaryMarkers(body)
+ changed := sigcompat.RestoreCodexCommentaryMarkersDoc(doc) > 0
preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
if shouldSanitizeClaudeMessagesForUpstream(baseModel) || preserveEmpty {
- var report sigcompat.SignatureSanitizeReport
- sanitized, report = sigcompat.SanitizeClaudeMessagesForClaudeUpstream(body, baseModel, preserveEmptyThinkingBlocks...)
+ report, sanitized := sanitizeClaudeMessagesForClaudeUpstreamDoc(doc, baseModel, preserveEmpty)
+ if sanitized {
+ changed = true
+ }
logClaudeSignatureSanitizeReport(ctx, baseModel, report)
}
- return helps.NormalizeClaudeToolCallIDs(sanitizeClaudeWebSearchDomains(sanitized))
+ if sanitizeClaudeWebSearchDomainsDoc(doc) {
+ changed = true
+ }
+ if helps.NormalizeClaudeToolCallIDsDoc(doc) {
+ changed = true
+ }
+ return changed
+}
+
+func sanitizeClaudeMessagesForClaudeUpstreamDoc(doc *ojson.Doc, targetModel string, preserveEmpty bool) (sigcompat.SignatureSanitizeReport, bool) {
+ report := sigcompat.SignatureSanitizeReport{TargetProvider: sigcompat.SignatureProviderClaude}
+ if doc == nil {
+ return report, false
+ }
+ messages := doc.Get("messages")
+ if !messages.IsArray() {
+ return report, false
+ }
+ messageResults := messages.Array()
+ modified := false
+ var droppedMessages []int
+
+ for i, message := range messageResults {
+ content := message.Get("content")
+ if !content.IsArray() {
+ continue
+ }
+ contentResults := content.Array()
+ var droppedParts []int
+
+ for j, part := range contentResults {
+ partPath := fmt.Sprintf("messages.%d.content.%d", i, j)
+ partType := part.Get("type").String()
+ if partType == "tool_use" {
+ if stripClaudeToolUseProvenanceFieldsDoc(doc, partPath) {
+ modified = true
+ report.DroppedSignatures++
+ }
+ continue
+ }
+ if partType != "thinking" {
+ continue
+ }
+ rawSignature := part.Get("signature").String()
+ if sigcompat.IsCodexCommentarySignatureMarker(rawSignature) {
+ if text := part.Get("thinking").String(); text != "" {
+ _ = doc.Set(partPath+".type", "text")
+ _ = doc.Delete(partPath + ".thinking")
+ _ = doc.Delete(partPath + ".signature")
+ _ = doc.Set(partPath+".text", text)
+ report.DroppedSignatures++
+ modified = true
+ continue
+ }
+ report.DroppedBlocks++
+ droppedParts = append(droppedParts, j)
+ modified = true
+ continue
+ }
+ if preserveEmpty {
+ report.Preserved++
+ continue
+ }
+ decision := sigcompat.DecideSignatureCompatibilityForModel(sigcompat.SignatureProviderClaude, targetModel, rawSignature, sigcompat.SignatureBlockKindClaudeThinking)
+ decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason)
+ report.Decisions = append(report.Decisions, decision)
+
+ switch decision.Action {
+ case sigcompat.SignatureActionPreserve:
+ report.Preserved++
+ if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature {
+ _ = doc.Set(partPath+".signature", decision.NormalizedSignature)
+ modified = true
+ }
+ case sigcompat.SignatureActionReplaceWithGeminiBypass:
+ report.ReplacedSignatures++
+ _ = doc.Set(partPath+".signature", decision.ReplacementSignature)
+ modified = true
+ case sigcompat.SignatureActionDropSignature:
+ report.DroppedSignatures++
+ _ = doc.Delete(partPath + ".signature")
+ modified = true
+ default:
+ report.DroppedBlocks++
+ droppedParts = append(droppedParts, j)
+ modified = true
+ }
+ }
+
+ if len(droppedParts) > 0 {
+ if len(droppedParts) == len(contentResults) {
+ droppedMessages = append(droppedMessages, i)
+ continue
+ }
+ for k := len(droppedParts) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d.content.%d", i, droppedParts[k]))
+ }
+ }
+ }
+
+ for k := len(droppedMessages) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d", droppedMessages[k]))
+ }
+ return report, modified
+}
+
+func stripClaudeToolUseProvenanceFieldsDoc(doc *ojson.Doc, partPath string) bool {
+ changed := false
+ for _, relPath := range []string{"signature", "thoughtSignature", "thought_signature", "extra_content.google.thought_signature", "model"} {
+ sigPath := partPath + "." + relPath
+ if !doc.Get(sigPath).Exists() {
+ continue
+ }
+ if doc.Delete(sigPath) == nil {
+ changed = true
+ }
+ }
+ for _, objPath := range []string{partPath + ".extra_content.google", partPath + ".extra_content"} {
+ res := doc.Get(objPath)
+ if res.Exists() && res.IsObject() && len(res.Map()) == 0 {
+ if doc.Delete(objPath) == nil {
+ changed = true
+ }
+ }
+ }
+ return changed
}
// sanitizeClaudeWebSearchDomains removes empty allowed_domains/blocked_domains
@@ -97,11 +234,27 @@ func sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx context.Context, body
// "Empty list of domains is ambiguous. Provide at least one domain or null.".
// Deleting the key is equivalent to leaving it unset.
func sanitizeClaudeWebSearchDomains(body []byte) []byte {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.Exists() || !tools.IsArray() {
return body
}
- tools.ForEach(func(index, tool gjson.Result) bool {
+ doc := ojson.NewDoc(body)
+ if !sanitizeClaudeWebSearchDomainsDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func sanitizeClaudeWebSearchDomainsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("tools")
+ if !tools.Exists() || !tools.IsArray() {
+ return false
+ }
+ changed := false
+ tools.ForEach(func(index, tool ojson.Result) bool {
if !strings.HasPrefix(tool.Get("type").String(), "web_search_") {
return true
}
@@ -109,14 +262,14 @@ func sanitizeClaudeWebSearchDomains(body []byte) []byte {
value := tool.Get(field)
if value.Exists() && value.IsArray() && len(value.Array()) == 0 {
path := fmt.Sprintf("tools.%d.%s", index.Int(), field)
- if updated, errDelete := sjson.DeleteBytes(body, path); errDelete == nil {
- body = updated
+ if errDelete := doc.Delete(path); errDelete == nil {
+ changed = true
}
}
}
return true
})
- return body
+ return changed
}
func logClaudeSignatureSanitizeReport(ctx context.Context, baseModel string, report sigcompat.SignatureSanitizeReport) {
@@ -159,7 +312,7 @@ func stripTranslatedDefaultMaxTokens(from sdktranslator.Format, originalPayload,
}
hasExplicitMaxTokens := func(paths ...string) bool {
for _, path := range paths {
- if v := gjson.GetBytes(originalPayload, path); v.Exists() && v.Type != gjson.Null {
+ if v := ojson.GetBytesNoCopy(originalPayload, path); v.Exists() && v.Type != ojson.Null {
return true
}
}
@@ -181,7 +334,7 @@ func stripTranslatedDefaultMaxTokens(from sdktranslator.Format, originalPayload,
default:
return originalTranslated
}
- stripped, errDelete := sjson.DeleteBytes(originalTranslated, "max_tokens")
+ stripped, errDelete := ojson.DeleteBytes(originalTranslated, "max_tokens")
if errDelete != nil {
return originalTranslated
}
@@ -345,23 +498,23 @@ func restoreClaudeResponseModel(payload []byte, model string) []byte {
}
func setClaudeResponseModel(payload []byte, model string) ([]byte, bool) {
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
return payload, false
}
- updated := payload
+ doc := ojson.NewDoc(payload)
changed := false
for _, path := range []string{"model", "message.model"} {
- if !gjson.GetBytes(updated, path).Exists() {
+ if !doc.Get(path).Exists() {
continue
}
- next, errSet := sjson.SetBytes(updated, path, model)
- if errSet != nil {
- continue
+ if errSet := doc.Set(path, model); errSet == nil {
+ changed = true
}
- updated = next
- changed = true
}
- return updated, changed
+ if !changed {
+ return payload, false
+ }
+ return doc.Bytes(), true
}
// PrepareRequest injects Claude credentials into the outgoing HTTP request.
diff --git a/internal/runtime/executor/claude_executor_beta_policy_test.go b/internal/runtime/executor/claude_executor_beta_policy_test.go
index 98608849255..3e64e5d4c45 100644
--- a/internal/runtime/executor/claude_executor_beta_policy_test.go
+++ b/internal/runtime/executor/claude_executor_beta_policy_test.go
@@ -11,11 +11,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const claudeRaceProbeOAuthKey = "sk-ant-oat-beta-policy"
@@ -154,7 +154,7 @@ func TestClaudeExecutor_ContextManagementNeverLeaksToOtherUpstreams(t *testing.T
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Fatalf("non-Anthropic upstream received context_management = %s", got.Raw)
}
}
diff --git a/internal/runtime/executor/claude_executor_cloaking.go b/internal/runtime/executor/claude_executor_cloaking.go
index 57804fcff48..9bf62699e31 100644
--- a/internal/runtime/executor/claude_executor_cloaking.go
+++ b/internal/runtime/executor/claude_executor_cloaking.go
@@ -6,12 +6,17 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"net/http"
+ "strconv"
"strings"
"time"
+ "unicode"
"unicode/utf16"
+ "unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -19,8 +24,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"github.com/gin-gonic/gin"
)
@@ -94,6 +97,21 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (cloakMode string, strictMo
// injectFakeUserID generates and injects a fake user ID into the request metadata.
// When useCache is false, a new user ID is generated for every call.
func injectFakeUserID(ctx context.Context, payload []byte, apiKey string, useCache bool) ([]byte, error) {
+ doc := ojson.NewDoc(payload)
+ changed, errInject := injectFakeUserIDDoc(ctx, doc, apiKey, useCache)
+ if errInject != nil {
+ return nil, errInject
+ }
+ if !changed {
+ return payload, nil
+ }
+ return doc.Bytes(), nil
+}
+
+func injectFakeUserIDDoc(ctx context.Context, doc *ojson.Doc, apiKey string, useCache bool) (bool, error) {
+ if doc == nil {
+ return false, nil
+ }
generateID := func() (string, error) {
if useCache {
return helps.CachedUserIDRequired(ctx, apiKey)
@@ -105,25 +123,26 @@ func injectFakeUserID(ctx context.Context, payload []byte, apiKey string, useCac
return helps.GenerateFakeUserIDWithSessionID(sessionID), nil
}
- metadata := gjson.GetBytes(payload, "metadata")
+ metadata := doc.Get("metadata")
if !metadata.Exists() {
userID, errUserID := generateID()
if errUserID != nil {
- return nil, errUserID
+ return false, errUserID
}
- payload, _ = sjson.SetBytes(payload, "metadata.user_id", userID)
- return payload, nil
+ _ = doc.Set("metadata.user_id", userID)
+ return true, nil
}
- existingUserID := gjson.GetBytes(payload, "metadata.user_id").String()
+ existingUserID := doc.Get("metadata.user_id").String()
if existingUserID == "" || !helps.IsValidUserID(existingUserID) {
userID, errUserID := generateID()
if errUserID != nil {
- return nil, errUserID
+ return false, errUserID
}
- payload, _ = sjson.SetBytes(payload, "metadata.user_id", userID)
+ _ = doc.Set("metadata.user_id", userID)
+ return true, nil
}
- return payload, nil
+ return false, nil
}
// fingerprintSalt is the salt used by Claude Code to compute the 3-char build fingerprint.
@@ -252,18 +271,108 @@ func resolveClaudeContinuityTags(
return prevReq, promptID, cCtx, true
}
+func resolveClaudeContinuityTagsDoc(
+ ctx context.Context,
+ cfg *config.Config,
+ auth *cliproxyauth.Auth,
+ incomingHeaders http.Header,
+ doc *ojson.Doc,
+ confirmedClaudeCode bool,
+ existingPrevReq, existingPromptID string,
+) (prevReq, promptID string, cCtx helps.ClaudeContinuityContext, ok bool) {
+ if doc == nil {
+ return "", "", helps.ClaudeContinuityContext{}, false
+ }
+ hasExecutionMetadata := helps.ClaudeExecutionMetadataFromContext(ctx)
+ sessionID := helps.ClaudeSessionIDFromContext(ctx)
+ if sessionID == "" && auth != nil {
+ payload := doc.Bytes()
+ sessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, payload, payload, confirmedClaudeCode)
+ }
+ if sessionID == "" || auth == nil {
+ return "", "", helps.ClaudeContinuityContext{}, false
+ }
+
+ credIdentity := claudeDiagnosticsCredentialIdentity(auth)
+ isNewTurn := isClaudeNewPromptTurnDoc(doc)
+ continuityKey, seq, prevMsgID, storedPrevReq, storedPromptID := helps.BeginClaudeContinuity(credIdentity, sessionID, isNewTurn, existingPromptID)
+
+ pinnedDate := resolveClaudeSessionPinnedDateDoc(cfg, auth, doc, continuityKey, sessionID)
+
+ if existingPromptID != "" {
+ promptID = existingPromptID
+ } else if prevMsgID != "" && storedPromptID != "" && (hasExecutionMetadata || !isNewTurn) {
+ promptID = storedPromptID
+ } else if !hasExecutionMetadata {
+ promptID = helps.ClaudeDeterministicPromptID("cpa:prompt:" + claudeBillingFingerprintMessageTextDoc(doc))
+ } else {
+ promptID = storedPromptID
+ }
+
+ if (hasExecutionMetadata || existingPrevReq != "") && storedPrevReq != "" {
+ prevReq = storedPrevReq
+ } else {
+ prevReq = existingPrevReq
+ }
+
+ cCtx = helps.ClaudeContinuityContext{
+ Key: continuityKey,
+ Sequence: seq,
+ PromptID: promptID,
+ PinnedDate: pinnedDate,
+ Initialized: true,
+ }
+ if hasExecutionMetadata || existingPrevReq != "" {
+ cCtx.PreviousMessageID = prevMsgID
+ cCtx.PreviousRequestID = storedPrevReq
+ } else {
+ cCtx.PreviousMessageID = ""
+ cCtx.PreviousRequestID = ""
+ }
+ return prevReq, promptID, cCtx, true
+}
+
func claudeBillingFingerprintMessageText(payload []byte) string {
idx := firstClaudeUserMessageIndex(payload)
if idx < 0 {
return ""
}
- content := gjson.GetBytes(payload, fmt.Sprintf("messages.%d.content", idx))
- if content.Type == gjson.String {
+ content := ojson.GetBytesNoCopy(payload, fmt.Sprintf("messages.%d.content", idx))
+ if content.Type == ojson.String {
return content.String()
}
if content.IsArray() {
messageText := ""
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
+ if part.Get("type").String() == "text" {
+ text := part.Get("text").String()
+ if !isClaudeCodeCurrentDateReminder(text) && !isClaudeCodeContextReminder(text) {
+ messageText = text
+ return false
+ }
+ }
+ return true
+ })
+ return messageText
+ }
+ return ""
+}
+
+func claudeBillingFingerprintMessageTextDoc(doc *ojson.Doc) string {
+ if doc == nil {
+ return ""
+ }
+ idx := firstClaudeUserMessageIndexDoc(doc)
+ if idx < 0 {
+ return ""
+ }
+ content := doc.Get(fmt.Sprintf("messages.%d.content", idx))
+ if content.Type == ojson.String {
+ return content.String()
+ }
+ if content.IsArray() {
+ messageText := ""
+ content.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "text" {
text := part.Get("text").String()
if !isClaudeCodeCurrentDateReminder(text) && !isClaudeCodeContextReminder(text) {
@@ -304,6 +413,281 @@ func claudeCCHFallbackBillingHeader(ctx context.Context, cfg *config.Config, pay
)
}
+func claudeCCHFallbackBillingHeaderDoc(ctx context.Context, cfg *config.Config, doc *ojson.Doc, entrypoint string) string {
+ isProbeOrHelper := isClaudeProbeOrHelperRequestDoc(doc)
+ prevReq, promptID := extractClaudeBillingTagsDoc(doc)
+ if !isProbeOrHelper {
+ continuityCtx := helps.ClaudeContinuityContextFromContext(ctx)
+ if prevReq == "" && continuityCtx != nil {
+ prevReq = continuityCtx.PreviousRequestID
+ }
+ if promptID == "" && continuityCtx != nil {
+ promptID = continuityCtx.PromptID
+ }
+ }
+ incomingHeaders := resolveIncomingClaudeHeaders(ctx, helps.IncomingHeadersFromContext(ctx))
+ isSubagent := isClaudeSubagentRequestDoc(incomingHeaders, doc)
+ return generateBillingHeader(
+ true,
+ helps.DefaultClaudeVersion(cfg),
+ claudeBillingFingerprintMessageTextDoc(doc),
+ entrypoint,
+ getWorkloadFromContext(ctx),
+ isSubagent,
+ prevReq,
+ promptID,
+ )
+}
+
+func isClaudeProbeOrHelperRequestDoc(doc *ojson.Doc) bool {
+ return isClaudeProbeRequestDoc(doc) || isClaudeTitleHelperRequestDoc(doc)
+}
+
+func isClaudeProbeRequestDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ maxTokens := doc.Get("max_tokens")
+ if !maxTokens.Exists() || maxTokens.Int() != 1 {
+ return false
+ }
+ tools := doc.Get("tools")
+ if tools.Exists() && len(tools.Array()) > 0 {
+ return false
+ }
+ messages := doc.Get("messages")
+ if !messages.Exists() || !messages.IsArray() || len(messages.Array()) == 0 {
+ return true
+ }
+ arr := messages.Array()
+ if len(arr) != 1 {
+ return false
+ }
+ firstMsg := arr[0]
+ if firstMsg.Get("role").String() != "user" {
+ return false
+ }
+ content := firstMsg.Get("content")
+ if content.Type == ojson.String {
+ str := strings.TrimSpace(content.String())
+ return str == "quota" || str == "test" || str == "." || str == "probe"
+ }
+ if content.IsArray() {
+ nonReminderCount := 0
+ matched := false
+ for _, part := range content.Array() {
+ t := strings.TrimSpace(part.Get("text").String())
+ if strings.Contains(t, "") {
+ continue
+ }
+ nonReminderCount++
+ if t == "quota" || t == "test" || t == "." || t == "probe" || (t == "Hi" && part.Get("cache_control").Exists()) {
+ matched = true
+ }
+ }
+ return nonReminderCount == 1 && matched
+ }
+ return false
+}
+
+func isClaudeTitleHelperInstructionDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ matchesTitlePrompt := func(t string) bool {
+ return strings.Contains(t, "Return a short title") ||
+ strings.Contains(t, "naming a coding session") ||
+ strings.Contains(t, "Write the title in the predominant language") ||
+ strings.Contains(t, "")
+ }
+ system := doc.Get("system")
+ if system.IsArray() {
+ for _, part := range system.Array() {
+ if matchesTitlePrompt(part.Get("text").String()) {
+ return true
+ }
+ }
+ } else if matchesTitlePrompt(system.String()) {
+ return true
+ }
+ messages := doc.Get("messages")
+ if messages.IsArray() {
+ for _, msg := range messages.Array() {
+ content := msg.Get("content")
+ if content.IsArray() {
+ for _, part := range content.Array() {
+ if matchesTitlePrompt(part.Get("text").String()) {
+ return true
+ }
+ }
+ } else if matchesTitlePrompt(content.String()) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func isClaudeTitleHelperRequestDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ props := doc.Get("output_config.format.schema.properties")
+ if props.Exists() {
+ if props.Get("title").Exists() && len(props.Map()) == 1 {
+ return isClaudeTitleHelperInstructionDoc(doc)
+ }
+ return false
+ }
+ matchesSystemTitleInstruction := func(t string) bool {
+ return strings.Contains(t, "naming a coding session") ||
+ strings.Contains(t, "Return a short title") ||
+ strings.Contains(t, "Write the title in the predominant language")
+ }
+ system := doc.Get("system")
+ if system.IsArray() {
+ for _, part := range system.Array() {
+ if matchesSystemTitleInstruction(part.Get("text").String()) {
+ return true
+ }
+ }
+ } else if matchesSystemTitleInstruction(system.String()) {
+ return true
+ }
+ messages := doc.Get("messages")
+ if messages.IsArray() {
+ for _, msg := range messages.Array() {
+ if msg.Get("role").String() == "system" {
+ content := msg.Get("content")
+ if content.IsArray() {
+ for _, part := range content.Array() {
+ if matchesSystemTitleInstruction(part.Get("text").String()) {
+ return true
+ }
+ }
+ } else if matchesSystemTitleInstruction(content.String()) {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
+func isClaudeNewPromptTurnDoc(doc *ojson.Doc) bool {
+ if doc == nil || isClaudeProbeOrHelperRequestDoc(doc) {
+ return false
+ }
+ messages := doc.Get("messages")
+ if !messages.IsArray() {
+ return true
+ }
+ arr := messages.Array()
+ if len(arr) == 0 {
+ return true
+ }
+ lastMsg := arr[len(arr)-1]
+ if lastMsg.Get("role").String() != "user" {
+ return false
+ }
+ content := lastMsg.Get("content")
+ if content.IsArray() {
+ for _, part := range content.Array() {
+ if part.Get("type").String() == "tool_result" {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func isClaudeSubagentRequestDoc(headers http.Header, doc *ojson.Doc) bool {
+ if val := helps.HeaderValueCaseInsensitive(headers, "X-Claude-Code-Agent-Id"); val != "" {
+ return true
+ }
+ if val := helps.HeaderValueCaseInsensitive(headers, "X-Claude-Code-Parent-Agent-Id"); val != "" {
+ return true
+ }
+ if doc == nil {
+ return false
+ }
+ if doc.Get("metadata.user_id.parent_session_id").Exists() {
+ return true
+ }
+ if userID := doc.Get("metadata.user_id").String(); userID != "" {
+ if strings.Contains(userID, `"parent_session_id"`) {
+ return true
+ }
+ }
+ system := doc.Get("system")
+ if system.IsArray() && len(system.Array()) > 0 {
+ if strings.Contains(system.Array()[0].Get("text").String(), "cc_is_subagent=true") {
+ return true
+ }
+ } else if system.Type == ojson.String && strings.Contains(system.String(), "cc_is_subagent=true") {
+ return true
+ }
+ return false
+}
+
+func claudePayloadHas1hTTLDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ has1h := false
+ forEachClaudeCacheControlPathDoc(doc, func(_ string, cc ojson.Result) bool {
+ if cc.IsObject() && cc.Get("ttl").String() == "1h" {
+ has1h = true
+ return false
+ }
+ return true
+ })
+ return has1h
+}
+
+func claudeSubagentRequests1hDoc(headers http.Header, doc *ojson.Doc) bool {
+ if claudePayloadHas1hTTLDoc(doc) {
+ return true
+ }
+ betas := strings.Join(helps.HeaderValuesCaseInsensitive(headers, "Anthropic-Beta"), ",")
+ return strings.Contains(betas, "extended-cache-ttl-2025-04-11")
+}
+
+func extractClaudeBillingTagsDoc(doc *ojson.Doc) (prevReq, promptID string) {
+ if doc == nil {
+ return "", ""
+ }
+ system := doc.Get("system")
+ var billingText string
+ if system.IsArray() && len(system.Array()) > 0 {
+ billingText = system.Array()[0].Get("text").String()
+ } else if system.Type == ojson.String {
+ billingText = system.String()
+ }
+ if billingText == "" || !strings.HasPrefix(billingText, "x-anthropic-billing-header:") {
+ return "", ""
+ }
+ if idx := strings.Index(billingText, "cc_prev_req="); idx >= 0 {
+ val := billingText[idx+len("cc_prev_req="):]
+ if end := strings.IndexByte(val, ';'); end >= 0 {
+ val = val[:end]
+ }
+ if val != "" && !strings.ContainsAny(val, " \t\r\n;") {
+ prevReq = strings.Clone(val)
+ }
+ }
+ if idx := strings.Index(billingText, "cc_prompt_id="); idx >= 0 {
+ val := billingText[idx+len("cc_prompt_id="):]
+ if end := strings.IndexByte(val, ';'); end >= 0 {
+ val = val[:end]
+ }
+ if helps.IsValidClaudePromptID(val) {
+ promptID = strings.Clone(strings.ToLower(val))
+ }
+ }
+ return prevReq, promptID
+}
+
const claudeCodeCLIIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
const claudeCodeFableReportingOutcomes = `# Reporting outcomes
@@ -315,6 +699,11 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool, keepCaller
return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.280", "cli", "", keepAtTopLevel)
}
+func checkSystemInstructionsWithModeDoc(doc *ojson.Doc, strictMode bool, keepCallerSystemTopLevel ...bool) bool {
+ keepAtTopLevel := len(keepCallerSystemTopLevel) > 0 && keepCallerSystemTopLevel[0]
+ return checkSystemInstructionsWithSigningModeDoc(doc, strictMode, false, "2.1.280", "cli", "", keepAtTopLevel)
+}
+
// checkSystemInstructionsWithSigningMode keeps the top-level system in Claude
// Code's minimal CLI shape. Each caller system block is preserved as a separate
// mid-conversation system message after the first user turn, where supported
@@ -325,6 +714,11 @@ func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, cch
return checkSystemInstructionsWithSigningModeAt(payload, strictMode, cchSigning, version, entrypoint, workload, claudeCodeLocalDate(time.Now()), false, "", "", keepAtTopLevel, "")
}
+func checkSystemInstructionsWithSigningModeDoc(doc *ojson.Doc, strictMode bool, cchSigning bool, version, entrypoint, workload string, keepCallerSystemTopLevel ...bool) bool {
+ keepAtTopLevel := len(keepCallerSystemTopLevel) > 0 && keepCallerSystemTopLevel[0]
+ return checkSystemInstructionsWithSigningModeAtDoc(doc, strictMode, cchSigning, version, entrypoint, workload, claudeCodeLocalDate(time.Now()), false, "", "", keepAtTopLevel, "")
+}
+
// isClaudeFable51Model reports whether the model is specifically Fable 5.1 / Mythos 5.1,
// matching native Claude Code 2.1.258 family/major/minor checks (AFo = {major:5, minor:1}).
func isClaudeFable51Model(model string) bool {
@@ -357,15 +751,15 @@ func buildForwardedSystemBlock(text string, block forwardedClaudeSystemPromptBlo
return buildTextBlock(text, &claudeCodeCacheControl)
}
-func isValidClaudeCacheControl(cc gjson.Result) bool {
- if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
+func isValidClaudeCacheControl(cc ojson.Result) bool {
+ if !cc.Exists() || cc.Type == ojson.Null || !cc.IsObject() {
return false
}
typ := cc.Get("type")
- return typ.Type == gjson.String && typ.String() == "ephemeral"
+ return typ.Type == ojson.String && typ.String() == "ephemeral"
}
-func collectForwardedClaudeSystemPromptBlocksWithCache(system gjson.Result) []forwardedClaudeSystemPromptBlock {
+func collectForwardedClaudeSystemPromptBlocksWithCache(system ojson.Result) []forwardedClaudeSystemPromptBlock {
var blocks []forwardedClaudeSystemPromptBlock
appendBlock := func(text string, cc *claudeCacheControl, rawCC string) {
if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) || isClaudeCodeIdentitySystemText(text) {
@@ -374,14 +768,14 @@ func collectForwardedClaudeSystemPromptBlocksWithCache(system gjson.Result) []fo
blocks = append(blocks, forwardedClaudeSystemPromptBlock{Text: text, CacheControl: cc, RawCC: rawCC})
}
- if system.Type == gjson.String {
+ if system.Type == ojson.String {
appendBlock(system.String(), nil, "")
return blocks
}
if !system.IsArray() {
return blocks
}
- system.ForEach(func(_, item gjson.Result) bool {
+ system.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "text" {
var cc *claudeCacheControl
rawCC := ""
@@ -411,13 +805,34 @@ func checkSystemInstructionsWithSigningModeAt(
keepCallerSystemTopLevel bool,
turnOrigin ...string,
) []byte {
- system := gjson.GetBytes(payload, "system")
- messageText := claudeBillingFingerprintMessageText(payload)
+ doc := ojson.NewDoc(payload)
+ if !checkSystemInstructionsWithSigningModeAtDoc(doc, strictMode, cchSigning, version, entrypoint, workload, currentDate, isSubagent, prevReq, promptID, keepCallerSystemTopLevel, turnOrigin...) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func checkSystemInstructionsWithSigningModeAtDoc(
+ doc *ojson.Doc,
+ strictMode bool,
+ cchSigning bool,
+ version, entrypoint, workload string,
+ currentDate string,
+ isSubagent bool,
+ prevReq, promptID string,
+ keepCallerSystemTopLevel bool,
+ turnOrigin ...string,
+) bool {
+ if doc == nil {
+ return false
+ }
+ system := doc.Get("system")
+ messageText := claudeBillingFingerprintMessageTextDoc(doc)
billingText := generateBillingHeader(cchSigning, version, messageText, entrypoint, workload, isSubagent, prevReq, promptID, turnOrigin...)
billingBlock := buildTextBlock(billingText, nil)
- isExplicit := (len(turnOrigin) > 1 && turnOrigin[1] == "explicit_cache_mode") || isExplicitPromptCacheMode(payload)
+ isExplicit := (len(turnOrigin) > 1 && turnOrigin[1] == "explicit_cache_mode") || isExplicitPromptCacheModeDoc(doc)
var agentCC *claudeCacheControl = &claudeCodeCacheControl
if isExplicit {
agentCC = nil
@@ -425,37 +840,41 @@ func checkSystemInstructionsWithSigningModeAt(
agentBlock := buildTextBlock(claudeCodeCLIIdentity, agentCC)
systemBlocks := []string{billingBlock, agentBlock}
- model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "model").String()))
- if isClaudeFable51Model(model) && !helps.IsClaudeProbeOrHelperRequest(payload) {
+ model := strings.ToLower(strings.TrimSpace(doc.Get("model").String()))
+ if isClaudeFable51Model(model) && !isClaudeProbeOrHelperRequestDoc(doc) {
systemBlocks = append(systemBlocks, buildTextBlock(claudeCodeFableReportingOutcomes, nil))
}
- payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]"))
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(systemBlocks, ",")+"]"))
if strictMode {
- return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit)
+ injectClaudeCodeCurrentDateInternalDoc(doc, currentDate, isExplicit)
+ return true
}
forwardedSystemBlocks := collectForwardedClaudeSystemPromptBlocksWithCache(system)
if len(forwardedSystemBlocks) == 0 {
- return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit)
+ injectClaudeCodeCurrentDateInternalDoc(doc, currentDate, isExplicit)
+ return true
}
- if claudeHistoryHasAdvisorCallOrResult(payload) {
+ if claudeHistoryHasAdvisorCallOrResultDoc(doc) {
for _, block := range forwardedSystemBlocks {
systemBlocks = append(systemBlocks, buildForwardedSystemBlock(block.Text, block, isExplicit))
}
- payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]"))
- return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit)
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(systemBlocks, ",")+"]"))
+ injectClaudeCodeCurrentDateInternalDoc(doc, currentDate, isExplicit)
+ return true
}
- if claudeUsesLegacySystemReminder(payload) {
- payload = prependClaudeSystemReminderBlocksToFirstUserMessage(payload, forwardedSystemBlocks, isExplicit)
- } else if keepCallerSystemTopLevel && claudeMidConversationSystemMessagesAtEnd(payload) {
+ if claudeUsesLegacySystemReminderDoc(doc) {
+ prependClaudeSystemReminderBlocksToFirstUserMessageDoc(doc, forwardedSystemBlocks, isExplicit)
+ } else if keepCallerSystemTopLevel && claudeMidConversationSystemMessagesAtEndDoc(doc) {
for _, block := range forwardedSystemBlocks {
systemBlocks = append(systemBlocks, buildForwardedSystemBlock(block.Text, block, isExplicit))
}
- payload, _ = sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(systemBlocks, ",")+"]"))
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(systemBlocks, ",")+"]"))
} else {
- payload = insertClaudeMidConversationSystemBlocks(payload, forwardedSystemBlocks, isExplicit)
+ insertClaudeMidConversationSystemBlocksDoc(doc, forwardedSystemBlocks, isExplicit)
}
- return injectClaudeCodeCurrentDateInternal(payload, currentDate, isExplicit)
+ injectClaudeCodeCurrentDateInternalDoc(doc, currentDate, isExplicit)
+ return true
}
// relocateClaudeSystemPromptForCountTokens keeps a cloaked count_tokens request
@@ -471,10 +890,21 @@ func relocateClaudeSystemPromptForCountTokens(payload []byte, strictMode bool, e
}
func relocateClaudeSystemPromptForCountTokensWithPolicy(payload []byte, strictMode, explicitCacheMode, keepCallerSystemTopLevel bool) []byte {
- system := gjson.GetBytes(payload, "system")
- if !system.Exists() {
+ doc := ojson.NewDoc(payload)
+ if !relocateClaudeSystemPromptForCountTokensWithPolicyDoc(doc, strictMode, explicitCacheMode, keepCallerSystemTopLevel) {
return payload
}
+ return doc.Bytes()
+}
+
+func relocateClaudeSystemPromptForCountTokensWithPolicyDoc(doc *ojson.Doc, strictMode, explicitCacheMode, keepCallerSystemTopLevel bool) bool {
+ if doc == nil {
+ return false
+ }
+ system := doc.Get("system")
+ if !system.Exists() {
+ return false
+ }
isExplicit := explicitCacheMode
// Strict mode drops caller prompts on the Messages path, so it must not
// reintroduce them here either.
@@ -483,10 +913,9 @@ func relocateClaudeSystemPromptForCountTokensWithPolicy(payload []byte, strictMo
forwardedSystemBlocks = collectForwardedClaudeSystemPromptBlocksWithCache(system)
}
if len(forwardedSystemBlocks) == 0 {
- updated, _ := sjson.DeleteBytes(payload, "system")
- return updated
+ return doc.Delete("system") == nil
}
- if claudeHistoryHasAdvisorCallOrResult(payload) {
+ if claudeHistoryHasAdvisorCallOrResultDoc(doc) {
// When an advisor call or result is present, the Messages path preserves
// caller system blocks in top-level system to prevent shifting message
// positions. Keep them in top-level system here as well so token counting
@@ -495,26 +924,25 @@ func relocateClaudeSystemPromptForCountTokensWithPolicy(payload []byte, strictMo
for _, block := range forwardedSystemBlocks {
blocks = append(blocks, buildForwardedSystemBlock(block.Text, block, isExplicit))
}
- updated, _ := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(blocks, ",")+"]"))
- return updated
+ return doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]")) == nil
}
- updated, errDelete := sjson.DeleteBytes(payload, "system")
- if errDelete != nil {
- return payload
+ if errDelete := doc.Delete("system"); errDelete != nil {
+ return false
}
- payload = updated
- if claudeUsesLegacySystemReminder(payload) {
- return prependClaudeSystemReminderBlocksToFirstUserMessage(payload, forwardedSystemBlocks, isExplicit)
+ if claudeUsesLegacySystemReminderDoc(doc) {
+ prependClaudeSystemReminderBlocksToFirstUserMessageDoc(doc, forwardedSystemBlocks, isExplicit)
+ return true
}
- if keepCallerSystemTopLevel && claudeMidConversationSystemMessagesAtEnd(payload) {
+ if keepCallerSystemTopLevel && claudeMidConversationSystemMessagesAtEndDoc(doc) {
blocks := make([]string, 0, len(forwardedSystemBlocks))
for _, block := range forwardedSystemBlocks {
blocks = append(blocks, buildForwardedSystemBlock(block.Text, block, isExplicit))
}
- updated, _ := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(blocks, ",")+"]"))
- return updated
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]"))
+ return true
}
- return insertClaudeMidConversationSystemBlocks(payload, forwardedSystemBlocks, isExplicit)
+ insertClaudeMidConversationSystemBlocksDoc(doc, forwardedSystemBlocks, isExplicit)
+ return true
}
// claudeLegacySystemReminderModels lists the official Anthropic model IDs and
@@ -544,7 +972,19 @@ var claudeLegacySystemReminderModels = map[string]struct{}{
}
func claudeUsesLegacySystemReminder(payload []byte) bool {
- model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "model").String()))
+ model := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(payload, "model").String()))
+ if slash := strings.LastIndexByte(model, '/'); slash >= 0 {
+ model = model[slash+1:]
+ }
+ _, legacy := claudeLegacySystemReminderModels[model]
+ return legacy
+}
+
+func claudeUsesLegacySystemReminderDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ model := strings.ToLower(strings.TrimSpace(doc.Get("model").String()))
if slash := strings.LastIndexByte(model, '/'); slash >= 0 {
model = model[slash+1:]
}
@@ -645,7 +1085,17 @@ func validateClaudeMidSystemMessageModel(payload []byte, confirmedClaudeCode, fi
if !claudeUsesLegacySystemReminder(payload) || !claudePayloadHasMidSystemMessage(payload) {
return nil
}
- return newClaudeMidSystemMessageModelError(gjson.GetBytes(payload, "model").String())
+ return newClaudeMidSystemMessageModelError(strings.Clone(ojson.GetBytesNoCopy(payload, "model").String()))
+}
+
+func validateClaudeMidSystemMessageModelDoc(doc *ojson.Doc, confirmedClaudeCode, firstPartyAnthropic bool) error {
+ if doc == nil || confirmedClaudeCode || !firstPartyAnthropic {
+ return nil
+ }
+ if !claudeUsesLegacySystemReminderDoc(doc) || !claudeDocHasMidSystemMessage(doc) {
+ return nil
+ }
+ return newClaudeMidSystemMessageModelError(strings.Clone(doc.Get("model").String()))
}
// validateClaudeCallerSystemBlocks rejects caller system content that cannot keep
@@ -656,14 +1106,14 @@ func validateClaudeMidSystemMessageModel(payload []byte, confirmedClaudeCode, fi
// Cloaking relocates caller blocks into one of those two slots, so a non-text
// block has no destination. Failing here keeps the caller's instructions from
// being silently dropped, and costs no upstream attempt.
-func validateClaudeCallerSystemBlocks(system gjson.Result) error {
+func validateClaudeCallerSystemBlocks(system ojson.Result) error {
if !system.IsArray() {
// A string system prompt is text by definition.
return nil
}
var blockErr error
index := 0
- system.ForEach(func(_, part gjson.Result) bool {
+ system.ForEach(func(_, part ojson.Result) bool {
if strings.TrimSpace(part.Get("type").String()) != "text" {
blockErr = newClaudeCallerSystemBlockError(index, strings.TrimSpace(part.Get("type").String()))
return false
@@ -674,7 +1124,7 @@ func validateClaudeCallerSystemBlocks(system gjson.Result) error {
return blockErr
}
-func collectForwardedClaudeSystemPromptBlocks(system gjson.Result) []string {
+func collectForwardedClaudeSystemPromptBlocks(system ojson.Result) []string {
var blocks []string
appendText := func(text string) {
if strings.TrimSpace(text) == "" || util.IsClaudeCodeAttributionSystemText(text) || text == claudeCodeCLIIdentity {
@@ -684,13 +1134,13 @@ func collectForwardedClaudeSystemPromptBlocks(system gjson.Result) []string {
}
if system.IsArray() {
- system.ForEach(func(_, part gjson.Result) bool {
+ system.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "text" {
appendText(part.Get("text").String())
}
return true
})
- } else if system.Type == gjson.String {
+ } else if system.Type == ojson.String {
appendText(system.String())
}
return blocks
@@ -731,13 +1181,24 @@ func prependClaudeSystemRemindersToFirstUserMessage(payload []byte, texts []stri
}
func prependClaudeSystemReminderBlocksToFirstUserMessage(payload []byte, blocks []forwardedClaudeSystemPromptBlock, isExplicit bool) []byte {
- firstUserIdx := firstClaudeUserMessageIndex(payload)
- if firstUserIdx < 0 || len(blocks) == 0 {
+ doc := ojson.NewDoc(payload)
+ if !prependClaudeSystemReminderBlocksToFirstUserMessageDoc(doc, blocks, isExplicit) {
return payload
}
+ return doc.Bytes()
+}
+
+func prependClaudeSystemReminderBlocksToFirstUserMessageDoc(doc *ojson.Doc, blocks []forwardedClaudeSystemPromptBlock, isExplicit bool) bool {
+ if doc == nil || len(blocks) == 0 {
+ return false
+ }
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return false
+ }
contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx)
- content := gjson.GetBytes(payload, contentPath)
+ content := doc.Get(contentPath)
if content.IsArray() {
contentBlocks := content.Array()
existing := make(map[string]int, len(contentBlocks))
@@ -761,7 +1222,7 @@ func prependClaudeSystemReminderBlocksToFirstUserMessage(payload []byte, blocks
}
}
if len(reminderBlocks) == 0 {
- return payload
+ return false
}
insertAt := 0
@@ -778,8 +1239,8 @@ func prependClaudeSystemReminderBlocksToFirstUserMessage(payload []byte, blocks
if insertAt == len(contentBlocks) {
rawBlocks = append(rawBlocks, reminderBlocks...)
}
- payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]"))
- } else if content.Type == gjson.String {
+ return doc.SetRawBytes(contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) == nil
+ } else if content.Type == ojson.String {
rawBlocks := make([]string, 0, len(blocks)+1)
for _, b := range blocks {
reminderText := claudeCallerSystemReminder(b.Text)
@@ -790,9 +1251,9 @@ func prependClaudeSystemReminderBlocksToFirstUserMessage(payload []byte, blocks
}
}
rawBlocks = append(rawBlocks, buildTextBlock(content.String(), nil))
- payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]"))
+ return doc.SetRawBytes(contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) == nil
}
- return payload
+ return false
}
func claudeCallerSystemReminder(text string) string {
@@ -812,7 +1273,17 @@ func claudeCallerSystemReminder(text string) string {
// advisor result to the conversation layout; any mid-conversation system splice
// shifts message indices and causes upstream 400 errors.
func claudeHistoryHasAdvisorCallOrResult(payload []byte) bool {
- messages := gjson.GetBytes(payload, "messages")
+ return claudeMessagesHaveAdvisorCallOrResult(ojson.GetBytesNoCopy(payload, "messages"))
+}
+
+func claudeHistoryHasAdvisorCallOrResultDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ return claudeMessagesHaveAdvisorCallOrResult(doc.Get("messages"))
+}
+
+func claudeMessagesHaveAdvisorCallOrResult(messages ojson.Result) bool {
if !messages.IsArray() {
return false
}
@@ -856,7 +1327,7 @@ func claudeHistoryHasAdvisorCallOrResult(payload []byte) bool {
// claudeLeadingUserRunEnd skips directive-only system turns because Anthropic
// accepts them at any position. They must not cause caller system blocks to be
// inserted before a subsequent user turn.
-func claudeLeadingUserRunEnd(messageBlocks []gjson.Result, firstUserIdx int) int {
+func claudeLeadingUserRunEnd(messageBlocks []ojson.Result, firstUserIdx int) int {
insertAt := firstUserIdx + 1
for insertAt < len(messageBlocks) {
message := messageBlocks[insertAt]
@@ -868,7 +1339,7 @@ func claudeLeadingUserRunEnd(messageBlocks []gjson.Result, firstUserIdx int) int
return insertAt
}
-func isClaudeSystemDirectiveMessage(message gjson.Result) bool {
+func isClaudeSystemDirectiveMessage(message ojson.Result) bool {
content := message.Get("content")
return message.Get("role").String() == "system" &&
message.Get("output_config").Exists() &&
@@ -881,7 +1352,25 @@ func claudeMidConversationSystemMessagesAtEnd(payload []byte) bool {
return false
}
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
+ if !messages.IsArray() {
+ return false
+ }
+ messageBlocks := messages.Array()
+ insertAt := claudeLeadingUserRunEnd(messageBlocks, firstUserIdx)
+ return insertAt == len(messageBlocks) || insertAt > firstUserIdx+1
+}
+
+func claudeMidConversationSystemMessagesAtEndDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return false
+ }
+
+ messages := doc.Get("messages")
if !messages.IsArray() {
return false
}
@@ -899,14 +1388,25 @@ func insertClaudeMidConversationSystemMessages(payload []byte, texts []string) [
}
func insertClaudeMidConversationSystemBlocks(payload []byte, blocks []forwardedClaudeSystemPromptBlock, isExplicit bool) []byte {
- firstUserIdx := firstClaudeUserMessageIndex(payload)
- if firstUserIdx < 0 || len(blocks) == 0 {
+ doc := ojson.NewDoc(payload)
+ if !insertClaudeMidConversationSystemBlocksDoc(doc, blocks, isExplicit) {
return payload
}
+ return doc.Bytes()
+}
+
+func insertClaudeMidConversationSystemBlocksDoc(doc *ojson.Doc, blocks []forwardedClaudeSystemPromptBlock, isExplicit bool) bool {
+ if doc == nil || len(blocks) == 0 {
+ return false
+ }
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return false
+ }
- messages := gjson.GetBytes(payload, "messages")
+ messages := doc.Get("messages")
if !messages.IsArray() {
- return payload
+ return false
}
messageBlocks := messages.Array()
insertAt := claudeLeadingUserRunEnd(messageBlocks, firstUserIdx)
@@ -920,7 +1420,7 @@ func insertClaudeMidConversationSystemBlocks(payload []byte, blocks []forwardedC
}
}
if matches {
- return payload
+ return false
}
}
@@ -939,19 +1439,18 @@ func insertClaudeMidConversationSystemBlocks(payload []byte, blocks []forwardedC
if insertAt == len(messageBlocks) {
rawMessages = append(rawMessages, systemMessages...)
}
- payload, _ = sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(rawMessages, ",")+"]"))
- return payload
+ return doc.SetRawBytes("messages", []byte("["+strings.Join(rawMessages, ",")+"]")) == nil
}
-func claudeMessageContentText(content gjson.Result) string {
- if content.Type == gjson.String {
+func claudeMessageContentText(content ojson.Result) string {
+ if content.Type == ojson.String {
return content.String()
}
if !content.IsArray() {
return ""
}
var parts []string
- content.ForEach(func(_, block gjson.Result) bool {
+ content.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "text" {
parts = append(parts, block.Get("text").String())
}
@@ -970,6 +1469,65 @@ type claudeCodeSystemPlacementState struct {
texts []string
}
+type claudeCodeSystemPlacementBeforeSnapshot struct {
+ legacy bool
+ texts []string
+ messagesLen int
+ firstUserIdx int
+ insertAt int
+}
+
+func captureClaudeCodeSystemPlacementBeforeDoc(doc *ojson.Doc) claudeCodeSystemPlacementBeforeSnapshot {
+ if doc == nil || claudeUsesLegacySystemReminderDoc(doc) {
+ return claudeCodeSystemPlacementBeforeSnapshot{legacy: true}
+ }
+ texts := collectForwardedClaudeSystemPromptBlocks(doc.Get("system"))
+ if len(texts) == 0 {
+ return claudeCodeSystemPlacementBeforeSnapshot{}
+ }
+ beforeMessages := doc.Get("messages").Array()
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return claudeCodeSystemPlacementBeforeSnapshot{}
+ }
+ insertAt := claudeLeadingUserRunEnd(beforeMessages, firstUserIdx)
+ return claudeCodeSystemPlacementBeforeSnapshot{
+ texts: texts,
+ messagesLen: len(beforeMessages),
+ firstUserIdx: firstUserIdx,
+ insertAt: insertAt,
+ }
+}
+
+func captureClaudeCodeSystemPlacementAfterDoc(before claudeCodeSystemPlacementBeforeSnapshot, afterDoc *ojson.Doc, cloaked bool) claudeCodeSystemPlacementState {
+ if !cloaked || before.legacy || len(before.texts) == 0 || before.firstUserIdx < 0 || afterDoc == nil {
+ return claudeCodeSystemPlacementState{}
+ }
+ wantLen := before.messagesLen + len(before.texts)
+ if wantLen <= 0 || before.insertAt+len(before.texts) > wantLen {
+ return claudeCodeSystemPlacementState{}
+ }
+ var scanner claudeStreamDocScanner
+ scanner.scan(afterDoc, false, false)
+ if !scanner.messagesIsArray || scanner.messagesLen != wantLen {
+ return claudeCodeSystemPlacementState{}
+ }
+ insertedRaw := make([]string, len(before.texts))
+ for rel, text := range before.texts {
+ idx := before.insertAt + rel
+ message := afterDoc.Get("messages." + strconv.Itoa(idx))
+ if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text {
+ return claudeCodeSystemPlacementState{}
+ }
+ insertedRaw[rel] = strings.Clone(message.Raw)
+ }
+ return claudeCodeSystemPlacementState{
+ insertAt: before.insertAt,
+ insertedRaw: insertedRaw,
+ texts: append([]string(nil), before.texts...),
+ }
+}
+
// captureClaudeCodeSystemPlacement records CPA's modern-model system placement
// immediately after cloaking. The message-count increase is part of the proof:
// insertClaudeMidConversationSystemMessages returns without inserting when the
@@ -978,13 +1536,13 @@ func captureClaudeCodeSystemPlacement(before, after []byte, cloaked bool) claude
if !cloaked || claudeUsesLegacySystemReminder(before) {
return claudeCodeSystemPlacementState{}
}
- texts := collectForwardedClaudeSystemPromptBlocks(gjson.GetBytes(before, "system"))
+ texts := collectForwardedClaudeSystemPromptBlocks(ojson.GetBytesNoCopy(before, "system"))
if len(texts) == 0 {
return claudeCodeSystemPlacementState{}
}
- beforeMessages := gjson.GetBytes(before, "messages").Array()
- afterMessages := gjson.GetBytes(after, "messages").Array()
+ beforeMessages := ojson.GetBytesNoCopy(before, "messages").Array()
+ afterMessages := ojson.GetBytesNoCopy(after, "messages").Array()
if len(afterMessages) != len(beforeMessages)+len(texts) {
return claudeCodeSystemPlacementState{}
}
@@ -1003,7 +1561,7 @@ func captureClaudeCodeSystemPlacement(before, after []byte, cloaked bool) claude
if message.Get("role").String() != "system" || claudeMessageContentText(message.Get("content")) != text {
return claudeCodeSystemPlacementState{}
}
- insertedRaw[idx] = message.Raw
+ insertedRaw[idx] = strings.Clone(message.Raw)
}
return claudeCodeSystemPlacementState{
insertAt: insertAt,
@@ -1019,16 +1577,24 @@ func captureClaudeCodeSystemPlacement(before, after []byte, cloaked bool) claude
// rule also changed those messages, reconciliation fails closed and leaves the
// final validation guard to return 400.
func reconcileClaudeCodeSystemPlacementAfterPayload(payload []byte, state claudeCodeSystemPlacementState) []byte {
- if len(state.insertedRaw) == 0 || !claudeUsesLegacySystemReminder(payload) {
+ doc := ojson.NewDoc(payload)
+ if !reconcileClaudeCodeSystemPlacementAfterPayloadDoc(doc, state) {
return payload
}
- messages := gjson.GetBytes(payload, "messages").Array()
+ return doc.Bytes()
+}
+
+func reconcileClaudeCodeSystemPlacementAfterPayloadDoc(doc *ojson.Doc, state claudeCodeSystemPlacementState) bool {
+ if doc == nil || len(state.insertedRaw) == 0 || !claudeUsesLegacySystemReminderDoc(doc) {
+ return false
+ }
+ messages := doc.Get("messages").Array()
if state.insertAt < 0 || state.insertAt+len(state.insertedRaw) > len(messages) {
- return payload
+ return false
}
for idx, raw := range state.insertedRaw {
if messages[state.insertAt+idx].Raw != raw {
- return payload
+ return false
}
}
@@ -1039,11 +1605,15 @@ func reconcileClaudeCodeSystemPlacementAfterPayload(payload []byte, state claude
}
rawMessages = append(rawMessages, message.Raw)
}
- updated, errSet := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(rawMessages, ",")+"]"))
- if errSet != nil {
- return payload
+ if errSet := doc.SetRawBytes("messages", []byte("["+strings.Join(rawMessages, ",")+"]")); errSet != nil {
+ return false
}
- return prependClaudeSystemRemindersToFirstUserMessage(updated, state.texts)
+ blocks := make([]forwardedClaudeSystemPromptBlock, 0, len(state.texts))
+ for _, text := range state.texts {
+ blocks = append(blocks, forwardedClaudeSystemPromptBlock{Text: text})
+ }
+ prependClaudeSystemReminderBlocksToFirstUserMessageDoc(doc, blocks, false)
+ return true
}
type claudeCodeFableState struct {
@@ -1052,8 +1622,46 @@ type claudeCodeFableState struct {
injectedReporting bool
}
+type claudeCodeFableBeforeSnapshot struct {
+ hasFallbacks bool
+ hasDisplay bool
+ hasReporting bool
+}
+
+func captureClaudeCodeFableBeforeDoc(doc *ojson.Doc) claudeCodeFableBeforeSnapshot {
+ if doc == nil {
+ return claudeCodeFableBeforeSnapshot{}
+ }
+ return claudeCodeFableBeforeSnapshot{
+ hasFallbacks: doc.Get("fallbacks").Exists(),
+ hasDisplay: doc.Get("thinking.display").Exists(),
+ hasReporting: hasFableReportingBlockDoc(doc),
+ }
+}
+
+func captureClaudeCodeFableAfterDoc(before claudeCodeFableBeforeSnapshot, afterDoc *ojson.Doc, cloaked bool) claudeCodeFableState {
+ if !cloaked || afterDoc == nil {
+ return claudeCodeFableState{}
+ }
+ return claudeCodeFableState{
+ injectedFallbacks: !before.hasFallbacks && afterDoc.Get("fallbacks").Exists(),
+ injectedDisplay: !before.hasDisplay && afterDoc.Get("thinking.display").Exists(),
+ injectedReporting: !before.hasReporting && hasFableReportingBlockDoc(afterDoc),
+ }
+}
+
func hasFableReportingBlock(body []byte) bool {
- system := gjson.GetBytes(body, "system")
+ return hasFableReportingBlockFromSystem(ojson.GetBytesNoCopy(body, "system"))
+}
+
+func hasFableReportingBlockDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ return hasFableReportingBlockFromSystem(doc.Get("system"))
+}
+
+func hasFableReportingBlockFromSystem(system ojson.Result) bool {
if !system.IsArray() {
str := strings.ReplaceAll(system.String(), "\u200B", "")
return str == claudeCodeFableReportingOutcomes || strings.Contains(str, claudeCodeFableReportingOutcomes)
@@ -1072,8 +1680,8 @@ func captureClaudeCodeFableState(before, after []byte, cloaked bool) claudeCodeF
return claudeCodeFableState{}
}
return claudeCodeFableState{
- injectedFallbacks: !gjson.GetBytes(before, "fallbacks").Exists() && gjson.GetBytes(after, "fallbacks").Exists(),
- injectedDisplay: !gjson.GetBytes(before, "thinking.display").Exists() && gjson.GetBytes(after, "thinking.display").Exists(),
+ injectedFallbacks: !ojson.GetBytesNoCopy(before, "fallbacks").Exists() && ojson.GetBytesNoCopy(after, "fallbacks").Exists(),
+ injectedDisplay: !ojson.GetBytesNoCopy(before, "thinking.display").Exists() && ojson.GetBytesNoCopy(after, "thinking.display").Exists(),
injectedReporting: !hasFableReportingBlock(before) && hasFableReportingBlock(after),
}
}
@@ -1092,17 +1700,40 @@ func reconcileClaudeCodeFableModelAfterPayload(
if !cloaked || len(body) == 0 {
return body
}
+ doc := ojson.NewDoc(body)
+ if !reconcileClaudeCodeFableModelAfterPayloadDoc(doc, fableState, payloadTouchedFallbacks, payloadTouchedDisplay, cloaked, isProbeOrHelper) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func reconcileClaudeCodeFableModelAfterPayloadDoc(
+ doc *ojson.Doc,
+ fableState claudeCodeFableState,
+ payloadTouchedFallbacks bool,
+ payloadTouchedDisplay bool,
+ cloaked bool,
+ isProbeOrHelper bool,
+) bool {
+ if !cloaked || doc == nil || doc.Len() == 0 {
+ return false
+ }
+ changed := false
// Probes and helpers must never carry Fable additions (Opus fallback, display=updates, reporting block)
if isProbeOrHelper {
if fableState.injectedFallbacks && !payloadTouchedFallbacks {
- body, _ = sjson.DeleteBytes(body, "fallbacks")
+ if doc.Delete("fallbacks") == nil {
+ changed = true
+ }
}
if fableState.injectedDisplay && !payloadTouchedDisplay {
- body, _ = sjson.DeleteBytes(body, "thinking.display")
+ if doc.Delete("thinking.display") == nil {
+ changed = true
+ }
}
if fableState.injectedReporting {
- system := gjson.GetBytes(body, "system")
+ system := doc.Get("system")
if system.IsArray() {
blocks := make([]string, 0, len(system.Array()))
removed := false
@@ -1114,15 +1745,18 @@ func reconcileClaudeCodeFableModelAfterPayload(
blocks = append(blocks, blk.Raw)
}
if removed {
- body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]"))
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]"))
+ changed = true
}
} else if strings.ReplaceAll(system.String(), "\u200B", "") == claudeCodeFableReportingOutcomes {
- body, _ = sjson.DeleteBytes(body, "system")
+ if doc.Delete("system") == nil {
+ changed = true
+ }
}
}
- return body
+ return changed
}
- currentModel := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String()))
+ currentModel := strings.ToLower(strings.TrimSpace(doc.Get("model").String()))
// A model rewrite may change the native fallback target. Never override a
// caller or payload-rule fallback; only replace one inserted by the cloak.
if fableState.injectedFallbacks && !payloadTouchedFallbacks {
@@ -1133,73 +1767,91 @@ func reconcileClaudeCodeFableModelAfterPayload(
case isClaudeOpus55Model(currentModel):
wantFallback = "claude-opus-4-8"
}
- if gjson.GetBytes(body, "fallbacks.0.model").String() != wantFallback {
- body, _ = sjson.DeleteBytes(body, "fallbacks")
+ if doc.Get("fallbacks.0.model").String() != wantFallback {
+ if doc.Delete("fallbacks") == nil {
+ changed = true
+ }
}
}
if isClaudeFable51Model(currentModel) {
// Non-Fable rewritten to Fable 5.1 (or original Fable 5.1): attach Fable additions
// unless matching payload rules explicitly configured or filtered them.
- if !gjson.GetBytes(body, "fallbacks").Exists() && !payloadTouchedFallbacks {
- body, _ = sjson.SetRawBytes(body, "fallbacks", []byte(`[{"model":"claude-opus-5"}]`))
+ if !doc.Get("fallbacks").Exists() && !payloadTouchedFallbacks {
+ _ = doc.SetRawBytes("fallbacks", []byte(`[{"model":"claude-opus-5"}]`))
+ changed = true
}
- if gjson.GetBytes(body, "thinking").Exists() {
- thinkingType := gjson.GetBytes(body, "thinking.type").String()
- if thinkingType == "adaptive" && !gjson.GetBytes(body, "thinking.display").Exists() && !payloadTouchedDisplay {
- body, _ = sjson.SetBytes(body, "thinking.display", "updates")
+ if doc.Get("thinking").Exists() {
+ thinkingType := doc.Get("thinking.type").String()
+ if thinkingType == "adaptive" && !doc.Get("thinking.display").Exists() && !payloadTouchedDisplay {
+ _ = doc.Set("thinking.display", "updates")
+ changed = true
} else if thinkingType != "adaptive" && fableState.injectedDisplay && !payloadTouchedDisplay {
- body, _ = sjson.DeleteBytes(body, "thinking.display")
+ if doc.Delete("thinking.display") == nil {
+ changed = true
+ }
}
} else if fableState.injectedDisplay && !payloadTouchedDisplay {
- body, _ = sjson.DeleteBytes(body, "thinking.display")
+ if doc.Delete("thinking.display") == nil {
+ changed = true
+ }
}
- if !hasFableReportingBlock(body) {
- system := gjson.GetBytes(body, "system")
+ if !hasFableReportingBlockDoc(doc) {
+ system := doc.Get("system")
if system.IsArray() {
blocks := make([]string, 0, len(system.Array())+1)
for _, blk := range system.Array() {
blocks = append(blocks, blk.Raw)
}
blocks = append(blocks, buildTextBlock(claudeCodeFableReportingOutcomes, nil))
- body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]"))
- } else if system.Type == gjson.String {
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]"))
+ changed = true
+ } else if system.Type == ojson.String {
str := system.String()
blocks := []string{
buildTextBlock(str, nil),
buildTextBlock(claudeCodeFableReportingOutcomes, nil),
}
- body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]"))
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]"))
+ changed = true
} else if !system.Exists() {
blocks := []string{
buildTextBlock(claudeCodeFableReportingOutcomes, nil),
}
- body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]"))
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]"))
+ changed = true
}
}
if !isProbeOrHelper {
- body = applyClaudeCloakThinkingDisplay(body, payloadTouchedDisplay)
+ if applyClaudeCloakThinkingDisplayDoc(doc, payloadTouchedDisplay) {
+ changed = true
+ }
}
- return body
+ return changed
}
- if isClaudeOpus55Model(currentModel) && !gjson.GetBytes(body, "fallbacks").Exists() && !payloadTouchedFallbacks {
- body, _ = sjson.SetRawBytes(body, "fallbacks", []byte(`[{"model":"claude-opus-4-8"}]`))
+ if isClaudeOpus55Model(currentModel) && !doc.Get("fallbacks").Exists() && !payloadTouchedFallbacks {
+ _ = doc.SetRawBytes("fallbacks", []byte(`[{"model":"claude-opus-4-8"}]`))
+ changed = true
}
- thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
+ thinkingType := strings.ToLower(strings.TrimSpace(doc.Get("thinking.type").String()))
thinkingActive := thinkingType == "adaptive" || thinkingType == "enabled"
// Payload rules can disable thinking without touching display. Remove only
// CPA's injected value; explicit caller and operator choices remain owned.
if fableState.injectedDisplay && !payloadTouchedDisplay && (!thinkingActive || !claudeModelUsesProgressDisplay(currentModel)) {
- body, _ = sjson.DeleteBytes(body, "thinking.display")
+ if doc.Delete("thinking.display") == nil {
+ changed = true
+ }
}
if !isProbeOrHelper {
- body = applyClaudeCloakThinkingDisplay(body, payloadTouchedDisplay)
+ if applyClaudeCloakThinkingDisplayDoc(doc, payloadTouchedDisplay) {
+ changed = true
+ }
}
// Remove Reporting outcomes if CPA automatically injected it
if fableState.injectedReporting {
- system := gjson.GetBytes(body, "system")
+ system := doc.Get("system")
if system.IsArray() {
blocks := make([]string, 0, len(system.Array()))
removed := false
@@ -1211,13 +1863,16 @@ func reconcileClaudeCodeFableModelAfterPayload(
blocks = append(blocks, blk.Raw)
}
if removed {
- body, _ = sjson.SetRawBytes(body, "system", []byte("["+strings.Join(blocks, ",")+"]"))
+ _ = doc.SetRawBytes("system", []byte("["+strings.Join(blocks, ",")+"]"))
+ changed = true
}
} else if strings.ReplaceAll(system.String(), "\u200B", "") == claudeCodeFableReportingOutcomes {
- body, _ = sjson.DeleteBytes(body, "system")
+ if doc.Delete("system") == nil {
+ changed = true
+ }
}
}
- return body
+ return changed
}
// claudeCodeLocalDate reproduces Claude Code 2.1.220's wcs() helper:
@@ -1281,13 +1936,25 @@ Today's date is %s.
}
func firstClaudeUserMessageIndex(payload []byte) int {
- messages := gjson.GetBytes(payload, "messages")
+ return firstClaudeUserMessageIndexFromMessages(ojson.GetBytesNoCopy(payload, "messages"))
+}
+
+func firstClaudeUserMessageIndexDoc(doc *ojson.Doc) int {
+ if doc == nil || doc.Len() == 0 {
+ return -1
+ }
+ var scanner claudeStreamDocScanner
+ scanner.scan(doc, false, true)
+ return scanner.firstUserIdx
+}
+
+func firstClaudeUserMessageIndexFromMessages(messages ojson.Result) int {
if !messages.Exists() || !messages.IsArray() {
return -1
}
firstUserIdx := -1
- messages.ForEach(func(idx, msg gjson.Result) bool {
+ messages.ForEach(func(idx, msg ojson.Result) bool {
if msg.Get("role").String() == "user" {
firstUserIdx = int(idx.Int())
return false
@@ -1328,6 +1995,13 @@ func claudeSessionDatePinRetention(cfg *config.Config) time.Duration {
return cfg.Claude.SessionDatePinRetention()
}
+func claudeSessionDatePinEnabled(cfg *config.Config) bool {
+ if helps.IsClaudeSessionDatePinKVActive() {
+ return true
+ }
+ return claudeSessionDatePinRetention(cfg) > 0
+}
+
// claudeCallerCurrentDateReminderDate returns the date of the first valid
// caller-supplied currentDate reminder in the first user message.
func claudeCallerCurrentDateReminderDate(payload []byte) string {
@@ -1335,8 +2009,22 @@ func claudeCallerCurrentDateReminderDate(payload []byte) string {
if firstUserIdx < 0 {
return ""
}
- content := gjson.GetBytes(payload, fmt.Sprintf("messages.%d.content", firstUserIdx))
- if content.Type == gjson.String {
+ return claudeCallerCurrentDateReminderDateFromContent(ojson.GetBytesNoCopy(payload, fmt.Sprintf("messages.%d.content", firstUserIdx)))
+}
+
+func claudeCallerCurrentDateReminderDateDoc(doc *ojson.Doc) string {
+ if doc == nil {
+ return ""
+ }
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return ""
+ }
+ return claudeCallerCurrentDateReminderDateFromContent(doc.Get(fmt.Sprintf("messages.%d.content", firstUserIdx)))
+}
+
+func claudeCallerCurrentDateReminderDateFromContent(content ojson.Result) string {
+ if content.Type == ojson.String {
return claudeCodeCurrentDateReminderDate(content.String())
}
if !content.IsArray() {
@@ -1371,24 +2059,31 @@ func stripClaudeCodeCurrentDateReminder(text string) string {
// currentDate reminder from a string-form first user message before the
// cloaking pipeline converts the content into blocks, preserving user text.
func stripFirstUserClaudeCodeCurrentDateReminder(payload []byte) []byte {
- firstUserIdx := firstClaudeUserMessageIndex(payload)
- if firstUserIdx < 0 {
+ doc := ojson.NewDoc(payload)
+ if !stripFirstUserClaudeCodeCurrentDateReminderDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func stripFirstUserClaudeCodeCurrentDateReminderDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return false
+ }
contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx)
- content := gjson.GetBytes(payload, contentPath)
- if content.Type != gjson.String {
- return payload
+ content := doc.Get(contentPath)
+ if content.Type != ojson.String {
+ return false
}
stripped := stripClaudeCodeCurrentDateReminder(content.String())
if stripped == content.String() {
- return payload
- }
- updated, errSet := sjson.SetBytes(payload, contentPath, stripped)
- if errSet != nil {
- return payload
+ return false
}
- return updated
+ return doc.Set(contentPath, stripped) == nil
}
// resolveClaudeSessionPinnedDate resolves the session date pin. With a
@@ -1404,10 +2099,13 @@ func resolveClaudeSessionPinnedDate(
) string {
retention := claudeSessionDatePinRetention(cfg)
candidate := claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth))
- if retention <= 0 {
+ if !claudeSessionDatePinEnabled(cfg) {
return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention)
}
if existing := helps.LookupClaudeSessionDatePin(sessionID, retention); existing != "" {
+ if helps.IsClaudeSessionDatePinKVActive() {
+ return existing
+ }
// Re-pin the anchored date so the sliding retention refreshes
// without ever changing the established prefix.
return helps.PinClaudeSessionDate(continuityKey, sessionID, existing, retention)
@@ -1418,6 +2116,30 @@ func resolveClaudeSessionPinnedDate(
return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention)
}
+func resolveClaudeSessionPinnedDateDoc(
+ cfg *config.Config,
+ auth *cliproxyauth.Auth,
+ doc *ojson.Doc,
+ continuityKey string,
+ sessionID string,
+) string {
+ retention := claudeSessionDatePinRetention(cfg)
+ candidate := claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth))
+ if !claudeSessionDatePinEnabled(cfg) {
+ return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention)
+ }
+ if existing := helps.LookupClaudeSessionDatePin(sessionID, retention); existing != "" {
+ if helps.IsClaudeSessionDatePinKVActive() {
+ return existing
+ }
+ return helps.PinClaudeSessionDate(continuityKey, sessionID, existing, retention)
+ }
+ if callerDate := claudeCallerCurrentDateReminderDateDoc(doc); callerDate != "" {
+ candidate = callerDate
+ }
+ return helps.PinClaudeSessionDate(continuityKey, sessionID, candidate, retention)
+}
+
// injectClaudeCodeCurrentDate stamps the currentDate reminder into the first
// user message. date is the already-resolved calendar date (the session-pinned
// one whenever continuity is available) so the reminder text cannot change
@@ -1427,28 +2149,38 @@ func injectClaudeCodeCurrentDate(payload []byte, date string) []byte {
}
func injectClaudeCodeCurrentDateInternal(payload []byte, date string, isExplicit bool) []byte {
- firstUserIdx := firstClaudeUserMessageIndex(payload)
- if firstUserIdx < 0 {
+ doc := ojson.NewDoc(payload)
+ if !injectClaudeCodeCurrentDateInternalDoc(doc, date, isExplicit) {
return payload
}
+ return doc.Bytes()
+}
+
+func injectClaudeCodeCurrentDateInternalDoc(doc *ojson.Doc, date string, isExplicit bool) bool {
+ if doc == nil {
+ return false
+ }
+ firstUserIdx := firstClaudeUserMessageIndexDoc(doc)
+ if firstUserIdx < 0 {
+ return false
+ }
contentPath := fmt.Sprintf("messages.%d.content", firstUserIdx)
- content := gjson.GetBytes(payload, contentPath)
+ content := doc.Get(contentPath)
dateText := claudeCodeCurrentDateReminder(date)
dateBlock := buildTextBlock(dateText, nil)
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
var userCC *claudeCacheControl = &claudeCodeCacheControl
if isExplicit || strings.TrimSpace(content.String()) == "" {
userCC = nil
}
userBlock := buildTextBlock(content.String(), userCC)
newArray := "[" + dateBlock + "," + userBlock + "]"
- payload, _ = sjson.SetRawBytes(payload, contentPath, []byte(newArray))
- return payload
+ return doc.SetRawBytes(contentPath, []byte(newArray)) == nil
}
if !content.IsArray() {
- return payload
+ return false
}
blocks := content.Array()
@@ -1475,14 +2207,13 @@ func injectClaudeCodeCurrentDateInternal(payload []byte, date string, isExplicit
// to lead with its tool_result blocks, so the reminder goes after them.
// Every other content shape keeps the native first-block placement.
insertAt := 0
- for insertAt < len(rawBlocks) && gjson.Parse(rawBlocks[insertAt]).Get("type").String() == "tool_result" {
+ for insertAt < len(rawBlocks) && ojson.Parse(rawBlocks[insertAt]).Get("type").String() == "tool_result" {
insertAt++
}
rawBlocks = append(rawBlocks, "")
copy(rawBlocks[insertAt+1:], rawBlocks[insertAt:])
rawBlocks[insertAt] = dateBlock
- payload, _ = sjson.SetRawBytes(payload, contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]"))
- return payload
+ return doc.SetRawBytes(contentPath, []byte("["+strings.Join(rawBlocks, ",")+"]")) == nil
}
// claudeCodeContextManagement is the context_management object Claude Code
@@ -1502,7 +2233,19 @@ const claudeCodeContextManagement = `{"edits":[{"type":"clear_thinking_20251015"
// {"type":"disabled"}, which is why this checks for the accepted values rather
// than excluding the disabled one.
func claudeThinkingAcceptsClearThinking(payload []byte) bool {
- switch gjson.GetBytes(payload, "thinking.type").String() {
+ switch ojson.GetBytesNoCopy(payload, "thinking.type").String() {
+ case "enabled", "adaptive":
+ return true
+ default:
+ return false
+ }
+}
+
+func claudeThinkingAcceptsClearThinkingDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ switch doc.Get("thinking.type").String() {
case "enabled", "adaptive":
return true
default:
@@ -1515,17 +2258,18 @@ func claudeThinkingAcceptsClearThinking(payload []byte) bool {
// so a missing body field is an observable inconsistency with the real client. A
// caller that sent its own object keeps it untouched.
func injectClaudeCodeContextManagement(payload []byte) ([]byte, bool) {
- if gjson.GetBytes(payload, "context_management").Exists() {
- return payload, false
- }
- if !claudeThinkingAcceptsClearThinking(payload) {
+ doc := ojson.NewDoc(payload)
+ if !injectClaudeCodeContextManagementDoc(doc) {
return payload, false
}
- updated, err := sjson.SetRawBytes(payload, "context_management", []byte(claudeCodeContextManagement))
- if err != nil {
- return payload, false
+ return doc.Bytes(), true
+}
+
+func injectClaudeCodeContextManagementDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Get("context_management").Exists() || !claudeThinkingAcceptsClearThinkingDoc(doc) {
+ return false
}
- return updated, true
+ return doc.SetRawBytes("context_management", []byte(claudeCodeContextManagement)) == nil
}
type claudeCodeContextManagementState struct {
@@ -1537,34 +2281,37 @@ type claudeCodeContextManagementState struct {
// reconcileClaudeCodeContextManagement resolves built-in ownership after forced
// tool-choice processing. User payload rules run later and never need reconciliation.
func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContextManagementState) []byte {
- contextManagement := gjson.GetBytes(payload, "context_management")
+ doc := ojson.NewDoc(payload)
+ if !reconcileClaudeCodeContextManagementDoc(doc, state) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func reconcileClaudeCodeContextManagementDoc(doc *ojson.Doc, state claudeCodeContextManagementState) bool {
+ if doc == nil {
+ return false
+ }
+ contextManagement := doc.Get("context_management")
// Any thinking value the strategy does not accept must drop an object CPA
// injected itself. disableThinkingIfToolChoiceForced deletes the whole
// thinking field after injection, so this also covers a request that was
// still eligible when injectClaudeCodeContextManagement ran.
- if !claudeThinkingAcceptsClearThinking(payload) {
+ if !claudeThinkingAcceptsClearThinkingDoc(doc) {
if state.callerOwned || !state.automaticallyInjected {
- return payload
+ return false
}
if contextManagement.Raw != claudeCodeContextManagement {
- return payload
- }
- updated, err := sjson.DeleteBytes(payload, "context_management")
- if err != nil {
- return payload
+ return false
}
- return updated
+ return doc.Delete("context_management") == nil
}
if !state.eligible || state.callerOwned || contextManagement.Exists() {
- return payload
- }
- updated, err := sjson.SetRawBytes(payload, "context_management", []byte(claudeCodeContextManagement))
- if err != nil {
- return payload
+ return false
}
- return updated
+ return doc.SetRawBytes("context_management", []byte(claudeCodeContextManagement)) == nil
}
// withEphemeralCacheControl stamps the native Claude Code default cache marker
@@ -1572,7 +2319,7 @@ func reconcileClaudeCodeContextManagement(payload []byte, state claudeCodeContex
// shape; upgradeClaudeCacheControlTTL adds it for the credentials native uses it
// on, after all placement decisions are final.
func withEphemeralCacheControl(rawBlock string) string {
- updated, err := sjson.SetRawBytes([]byte(rawBlock), "cache_control", []byte(`{"type":"ephemeral"}`))
+ updated, err := ojson.SetRawBytes([]byte(rawBlock), "cache_control", []byte(`{"type":"ephemeral"}`))
if err != nil {
return rawBlock
}
@@ -1612,26 +2359,37 @@ func isEffectiveConfirmedClaudeCode(detection helps.ClaudeCodeRequestDetection,
}
func stripClaudeCodeSystemBlocks(payload []byte) []byte {
- payload = util.StripClaudeCodeAttributionSystem(payload)
- system := gjson.GetBytes(payload, "system")
- if !system.Exists() {
+ doc := ojson.NewDoc(payload)
+ if !stripClaudeCodeSystemBlocksDoc(doc) {
return payload
}
- if system.Type == gjson.String {
+ return doc.Bytes()
+}
+
+func stripClaudeCodeSystemBlocksDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ changed := stripClaudeCodeAttributionSystemDoc(doc)
+ system := doc.Get("system")
+ if !system.Exists() {
+ return changed
+ }
+ if system.Type == ojson.String {
if !isClaudeCodeIdentitySystemText(system.String()) {
- return payload
+ return changed
}
- if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil {
- return updated
+ if errDelete := doc.Delete("system"); errDelete == nil {
+ return true
}
- return payload
+ return changed
}
if !system.IsArray() {
- return payload
+ return changed
}
kept := make([]string, 0, len(system.Array()))
removed := false
- system.ForEach(func(_, block gjson.Result) bool {
+ system.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "text" && isClaudeCodeIdentitySystemText(block.Get("text").String()) {
removed = true
return true
@@ -1642,40 +2400,49 @@ func stripClaudeCodeSystemBlocks(payload []byte) []byte {
return true
})
if !removed {
- return payload
+ return changed
}
if len(kept) == 0 {
- if updated, errDelete := sjson.DeleteBytes(payload, "system"); errDelete == nil {
- return updated
+ if errDelete := doc.Delete("system"); errDelete == nil {
+ return true
}
- return payload
+ return changed
}
- if updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]")); errSet == nil {
- return updated
+ if errSet := doc.SetRawBytes("system", []byte("["+strings.Join(kept, ",")+"]")); errSet == nil {
+ return true
}
- return payload
+ return changed
}
func uncloakClaudeCodePayload(payload []byte, confirmedClaudeCode bool) []byte {
- payload = stripClaudeCodeSystemBlocks(payload)
- if metadata := gjson.GetBytes(payload, "metadata"); metadata.Exists() && metadata.IsObject() {
+ doc := ojson.NewDoc(payload)
+ if !uncloakClaudeCodePayloadDoc(doc, confirmedClaudeCode) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func uncloakClaudeCodePayloadDoc(doc *ojson.Doc, confirmedClaudeCode bool) bool {
+ if doc == nil {
+ return false
+ }
+ changed := stripClaudeCodeSystemBlocksDoc(doc)
+ if metadata := doc.Get("metadata"); metadata.Exists() && metadata.IsObject() {
if userID := metadata.Get("user_id"); userID.Exists() && (confirmedClaudeCode || helps.IsValidUserID(userID.String())) {
- if updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id"); errDelete == nil {
- payload = updated
- if remaining := gjson.GetBytes(payload, "metadata"); remaining.Exists() && remaining.IsObject() && len(remaining.Map()) == 0 {
- if updatedMeta, errDeleteMeta := sjson.DeleteBytes(payload, "metadata"); errDeleteMeta == nil {
- payload = updatedMeta
- }
+ if errDelete := doc.Delete("metadata.user_id"); errDelete == nil {
+ changed = true
+ if remaining := doc.Get("metadata"); remaining.Exists() && remaining.IsObject() && len(remaining.Map()) == 0 {
+ _ = doc.Delete("metadata")
}
}
}
}
- if diag := gjson.GetBytes(payload, "diagnostics"); diag.Exists() && (confirmedClaudeCode || diag.Get("previous_message_id").Exists()) {
- if updated, errDelete := sjson.DeleteBytes(payload, "diagnostics"); errDelete == nil {
- payload = updated
+ if diag := doc.Get("diagnostics"); diag.Exists() && (confirmedClaudeCode || diag.Get("previous_message_id").Exists()) {
+ if errDelete := doc.Delete("diagnostics"); errDelete == nil {
+ changed = true
}
}
- return payload
+ return changed
}
func resolveClaudeWirePolicy(cfg *config.Config, auth *cliproxyauth.Auth, apiKey string, confirmedClaudeCode bool) (claudeWirePolicy, claudeCloakSettings) {
@@ -1771,26 +2538,52 @@ func applyCloakingInternal(
obfuscateSensitiveWords bool,
explicitCacheMode ...bool,
) ([]byte, bool, error) {
+ doc := ojson.NewDoc(payload)
+ cloaked, changed, errCloak := applyCloakingInternalDoc(ctx, cfg, auth, doc, apiKey, confirmedClaudeCode, cchSigning, obfuscateSensitiveWords, explicitCacheMode...)
+ if errCloak != nil {
+ return nil, false, errCloak
+ }
+ if !changed {
+ return payload, cloaked, nil
+ }
+ return doc.Bytes(), cloaked, nil
+}
+
+func applyCloakingInternalDoc(
+ ctx context.Context,
+ cfg *config.Config,
+ auth *cliproxyauth.Auth,
+ doc *ojson.Doc,
+ apiKey string,
+ confirmedClaudeCode bool,
+ cchSigning bool,
+ obfuscateSensitiveWords bool,
+ explicitCacheMode ...bool,
+) (bool, bool, error) {
+ if doc == nil {
+ return false, false, nil
+ }
policy, settings := resolveClaudeWirePolicy(cfg, auth, apiKey, confirmedClaudeCode)
if !policy.Cloak {
+ changed := false
if policy.Uncloak {
- payload = uncloakClaudeCodePayload(payload, confirmedClaudeCode)
+ changed = uncloakClaudeCodePayloadDoc(doc, confirmedClaudeCode)
}
- return payload, false, nil
+ return false, changed, nil
}
isExplicit := (len(explicitCacheMode) > 0 && explicitCacheMode[0]) || isExplicitPromptCacheModeContext(ctx)
// Strict mode drops caller system prompts entirely, so nothing needs a
// destination and an unusable block cannot lose information.
if !settings.strictMode {
- if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(payload, "system")); errSystem != nil {
- return nil, false, errSystem
+ if errSystem := validateClaudeCallerSystemBlocks(doc.Get("system")); errSystem != nil {
+ return false, false, errSystem
}
}
billingVersion := helps.DefaultClaudeVersion(cfg)
workload := getWorkloadFromContext(ctx)
- isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(payload)
+ isProbeOrHelper := isClaudeProbeOrHelperRequestDoc(doc)
isSubagent := false
prevReq := ""
promptID := ""
@@ -1798,12 +2591,12 @@ func applyCloakingInternal(
var incomingHeaders http.Header
if !isProbeOrHelper {
incomingHeaders = resolveIncomingClaudeHeaders(ctx, helps.IncomingHeadersFromContext(ctx))
- isSubagent = helps.IsClaudeSubagentRequest(incomingHeaders, payload)
- existingPrevReq, existingPromptID := helps.ExtractClaudeBillingTags(payload)
+ isSubagent = isClaudeSubagentRequestDoc(incomingHeaders, doc)
+ existingPrevReq, existingPromptID := extractClaudeBillingTagsDoc(doc)
var cCtx helps.ClaudeContinuityContext
var ok bool
- prevReq, promptID, cCtx, ok = resolveClaudeContinuityTags(ctx, cfg, auth, incomingHeaders, payload, confirmedClaudeCode, existingPrevReq, existingPromptID)
+ prevReq, promptID, cCtx, ok = resolveClaudeContinuityTagsDoc(ctx, cfg, auth, incomingHeaders, doc, confirmedClaudeCode, existingPrevReq, existingPromptID)
if ok {
pinnedDate = cCtx.PinnedDate
if continuityCtx := helps.ClaudeContinuityContextFromContext(ctx); continuityCtx != nil {
@@ -1816,10 +2609,10 @@ func applyCloakingInternal(
if pinnedDate == "" {
pinnedDate = claudeCodeLocalDate(claudeCodeCurrentTime(cfg, auth))
}
- if !isProbeOrHelper && claudeSessionDatePinRetention(cfg) > 0 {
+ if !isProbeOrHelper && claudeSessionDatePinEnabled(cfg) {
// Strip a string-form caller reminder before the pipeline turns the
// content into blocks, so user text is never dropped with it.
- payload = stripFirstUserClaudeCodeCurrentDateReminder(payload)
+ stripFirstUserClaudeCodeCurrentDateReminderDoc(doc)
}
turnOrigin := ""
@@ -1830,8 +2623,8 @@ func applyCloakingInternal(
if isExplicit {
turnOriginArgs = append(turnOriginArgs, "explicit_cache_mode")
}
- payload = checkSystemInstructionsWithSigningModeAt(
- payload,
+ checkSystemInstructionsWithSigningModeAtDoc(
+ doc,
settings.strictMode,
cchSigning,
billingVersion,
@@ -1847,44 +2640,42 @@ func applyCloakingInternal(
// In native Claude Code 2.1.258, claude-fable-5-1 requests carry:
// "fallbacks": [{"model": "claude-opus-5"}]
- model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "model").String()))
- if isClaudeOpus55Model(model) && !isProbeOrHelper && !gjson.GetBytes(payload, "fallbacks").Exists() {
- payload, _ = sjson.SetRawBytes(payload, "fallbacks", []byte(`[{"model":"claude-opus-4-8"}]`))
+ model := strings.ToLower(strings.TrimSpace(doc.Get("model").String()))
+ if isClaudeOpus55Model(model) && !isProbeOrHelper && !doc.Get("fallbacks").Exists() {
+ _ = doc.SetRawBytes("fallbacks", []byte(`[{"model":"claude-opus-4-8"}]`))
}
if isClaudeFable51Model(model) && !isProbeOrHelper {
- if !gjson.GetBytes(payload, "fallbacks").Exists() {
- payload, _ = sjson.SetRawBytes(payload, "fallbacks", []byte(`[{"model":"claude-opus-5"}]`))
+ if !doc.Get("fallbacks").Exists() {
+ _ = doc.SetRawBytes("fallbacks", []byte(`[{"model":"claude-opus-5"}]`))
}
}
if !isProbeOrHelper {
- payload = applyClaudeCloakThinkingDisplay(payload, false)
+ applyClaudeCloakThinkingDisplayDoc(doc, false)
}
// Probes never use 1h cache in native Claude Code; ensure any caller-supplied
// 1h ttl is stripped to match extended-cache-ttl beta suppression. Subagents
// preserve caller-requested 1h cache TTL (e.g. subagentPromptCacheTtl: 1h).
- if !isExplicit && (isProbeOrHelper || (isSubagent && !helps.ClaudeSubagentRequests1h(incomingHeaders, payload))) {
- payload = stripClaudeCacheControlTTL(payload)
+ if !isExplicit && (isProbeOrHelper || (isSubagent && !claudeSubagentRequests1hDoc(incomingHeaders, doc))) {
+ stripClaudeCacheControlTTLDoc(doc)
}
// Claude-Code-CLI fingerprint identity (real OAuth or fingerprint-profile=claude-code-cli)
// is applied later through the shared ApplyClaudeCredentialMetadata path.
// Other non-OAuth cloaking keeps the legacy per-request fake user_id.
if !policy.ProfileClaudeCodeCLI {
- var errFakeUserID error
- payload, errFakeUserID = injectFakeUserID(ctx, payload, apiKey, settings.cacheUserID)
- if errFakeUserID != nil {
- return nil, false, errFakeUserID
+ if _, errFakeUserID := injectFakeUserIDDoc(ctx, doc, apiKey, settings.cacheUserID); errFakeUserID != nil {
+ return false, false, errFakeUserID
}
}
// Apply sensitive word obfuscation
if obfuscateSensitiveWords && len(settings.sensitiveWords) > 0 {
matcher := helps.BuildSensitiveWordMatcher(settings.sensitiveWords)
- payload = helps.ObfuscateSensitiveWords(payload, matcher)
+ helps.ObfuscateSensitiveWordsDoc(doc, matcher)
}
- return payload, true, nil
+ return true, true, nil
}
type claudeCacheControl struct {
@@ -1931,12 +2722,30 @@ const claudeCacheControlTTL1h = "1h"
// suppress system/latest-user breakpoints. Callers still run enforceCacheControlLimit.
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
func ensureCacheControl(payload []byte) []byte {
- if !claudePayloadHasCacheableSystem(payload) {
- payload = injectToolsCacheControl(payload)
+ doc := ojson.NewDoc(payload)
+ if !ensureCacheControlDoc(doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func ensureCacheControlDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
+ if !claudeDocHasCacheableSystem(doc) {
+ if injectToolsCacheControlDoc(doc) {
+ changed = true
+ }
+ }
+ if injectSystemCacheControlDoc(doc) {
+ changed = true
}
- payload = injectSystemCacheControl(payload)
- payload = injectMessagesCacheControl(payload)
- return payload
+ if injectMessagesCacheControlDoc(doc) {
+ changed = true
+ }
+ return changed
}
// claudePayloadHasCacheableSystem reports whether the payload has a system prompt
@@ -1944,20 +2753,30 @@ func ensureCacheControl(payload []byte) []byte {
// empty array, an array of only empty/whitespace text blocks, and an empty string
// all leave the tools prefix uncovered.
func claudePayloadHasCacheableSystem(payload []byte) bool {
- system := gjson.GetBytes(payload, "system")
+ return claudeSystemHasCacheableBlocks(ojson.GetBytesNoCopy(payload, "system"))
+}
+
+func claudeDocHasCacheableSystem(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ return claudeSystemHasCacheableBlocks(doc.Get("system"))
+}
+
+func claudeSystemHasCacheableBlocks(system ojson.Result) bool {
switch {
case !system.Exists():
return false
case system.IsArray():
return lastCacheableClaudeSystemIndex(system) >= 0
- case system.Type == gjson.String:
+ case system.Type == ojson.String:
return strings.TrimSpace(system.String()) != ""
default:
return false
}
}
-func claudeSystemBlockCanHostCacheControl(item gjson.Result) bool {
+func claudeSystemBlockCanHostCacheControl(item ojson.Result) bool {
if !item.IsObject() {
return false
}
@@ -1967,12 +2786,12 @@ func claudeSystemBlockCanHostCacheControl(item gjson.Result) bool {
return strings.TrimSpace(item.Get("text").String()) != ""
}
-func lastCacheableClaudeSystemIndex(system gjson.Result) int {
+func lastCacheableClaudeSystemIndex(system ojson.Result) int {
if !system.IsArray() {
return -1
}
lastEligible := -1
- system.ForEach(func(index, item gjson.Result) bool {
+ system.ForEach(func(index, item ojson.Result) bool {
if claudeSystemBlockCanHostCacheControl(item) {
lastEligible = int(index.Int())
}
@@ -1999,18 +2818,28 @@ func lastCacheableClaudeSystemIndex(system gjson.Result) int {
// {"type":"ephemeral"} native default, which also avoids sending ttl to
// Anthropic-compatible gateways that never advertised support for it.
func upgradeClaudeCacheControlTTL(payload []byte, ttl string) []byte {
- if ttl == "" || len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if ttl == "" || len(payload) == 0 || !ojson.ValidBytes(payload) {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !upgradeClaudeCacheControlTTLDoc(doc, ttl) {
return payload
}
+ return doc.Bytes()
+}
- upgrade := func(path string, block gjson.Result) {
- cacheControl := block.Get("cache_control")
+func upgradeClaudeCacheControlTTLDoc(doc *ojson.Doc, ttl string) bool {
+ if ttl == "" || doc == nil || doc.Len() == 0 {
+ return false
+ }
+ changed := false
+ forEachClaudeCacheControlPathDoc(doc, func(path string, cacheControl ojson.Result) bool {
if !cacheControl.IsObject() || cacheControl.Get("ttl").Exists() {
- return
+ return true
}
blockType := cacheControl.Get("type")
- if blockType.Type != gjson.String {
- return
+ if blockType.Type != ojson.String {
+ return true
}
// Rebuild the object so the native {type, ttl, scope} key order survives
// instead of appending ttl after a caller-supplied scope.
@@ -2020,15 +2849,12 @@ func upgradeClaudeCacheControlTTL(payload []byte, ttl string) []byte {
upgraded += `,"scope":` + scope.Raw
}
upgraded += "}"
- updated, errSet := sjson.SetRawBytes(payload, path+".cache_control", []byte(upgraded))
- if errSet != nil {
- return
+ if errSet := doc.SetRawBytes(path+".cache_control", []byte(upgraded)); errSet == nil {
+ changed = true
}
- payload = updated
- }
-
- forEachClaudeCacheControlBlock(payload, upgrade)
- return payload
+ return true
+ })
+ return changed
}
// stripClaudeCacheControlTTL removes any ttl field from cache_control blocks in payload,
@@ -2037,48 +2863,830 @@ func upgradeClaudeCacheControlTTL(payload []byte, ttl string) []byte {
// subagents, or non-OAuth credentials), the body does not retain a ttl field that would
// trigger Anthropic 400 errors or fingerprint mismatch.
func stripClaudeCacheControlTTL(payload []byte) []byte {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !stripClaudeCacheControlTTLDoc(doc) {
return payload
}
-
- strip := func(path string, block gjson.Result) {
- cacheControl := block.Get("cache_control")
- if !cacheControl.IsObject() || !cacheControl.Get("ttl").Exists() {
+ return doc.Bytes()
+}
+
+func stripClaudeCacheControlTTLDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ changed := false
+ forEachClaudeCacheControlPathDoc(doc, func(path string, cacheControl ojson.Result) bool {
+ if !cacheControl.IsObject() || !cacheControl.Get("ttl").Exists() {
+ return true
+ }
+ if errDel := doc.Delete(path + ".cache_control.ttl"); errDel == nil {
+ changed = true
+ }
+ return true
+ })
+ return changed
+}
+
+var errStopClaudeScan = errors.New("stop claude scan")
+
+const (
+ claudeSecNone uint8 = iota
+ claudeSecTools
+ claudeSecSystem
+ claudeSecMessages
+)
+
+const (
+ claudeK3None uint8 = iota
+ claudeK3CacheControl
+ claudeK3Role
+ claudeK3Content
+)
+
+const (
+ claudeK5None uint8 = iota
+ claudeK5Type
+ claudeK5Text
+ claudeK5CacheControl
+)
+
+const (
+ claudeScanTokNone uint8 = iota
+ claudeScanTokString
+ claudeScanTokPrimitive
+)
+
+const (
+ claudeStrIgnore uint8 = iota
+ claudeStrCapShort
+ claudeStrCheckNonSpace
+)
+
+type claudeScannedCC struct {
+ idx1 int
+ idx2 int
+ raw []byte
+}
+
+type claudeStreamDocScanner struct {
+ collectCC bool
+ stopOnFirstUser bool
+
+ depth int
+ isObj [64]bool
+ inKey [64]bool
+ topSection uint8
+ elemIdx int
+ lvl3Key uint8
+ blockIdx int
+ lvl5Key uint8
+
+ tok uint8
+ strEsc uint8
+ uVal uint16
+ strIsKey bool
+ strTarget uint8
+ strBuf [256]byte
+ strExtra []byte
+ strLen int
+ strHasEsc bool
+ strHasNonSpace bool
+ utfBuf [4]byte
+ utfLen uint8
+
+ captureActive bool
+ captureDepth int
+ capStart int
+ ccBuf [256]byte
+ ccExtra []byte
+ ccLen int
+
+ ccArena [1024]byte
+ ccArenaUsed int
+ toolCC []claudeScannedCC
+ toolCCBuf [8]claudeScannedCC
+ sysCC []claudeScannedCC
+ sysCCBuf [8]claudeScannedCC
+ msgCC []claudeScannedCC
+ msgCCBuf [16]claudeScannedCC
+
+ messagesIsArray bool
+ messagesLen int
+ firstUserIdx int
+ lastMessageIndex int
+ lastMsgRole string
+ lastMsgContentIsNonEmptyString bool
+ lastEligibleIndex int
+ lastEligibleContentIsArray bool
+ lastEligibleContentIsNonEmptyStr bool
+ lastEligibleHasCacheControl bool
+ lastEligibleTargetBlockIndex int
+
+ curMsgRole string
+ curMsgContentIsString bool
+ curMsgContentHasNonSpc bool
+ curMsgContentIsArray bool
+ curMsgHasCacheControl bool
+ curMsgLastCacheableUser int
+ curMsgLastCacheableAsst int
+
+ curBlockIsObj bool
+ curBlockType string
+ curBlockTextHasNonSpc bool
+
+ err error
+}
+
+func (s *claudeStreamDocScanner) scan(doc *ojson.Doc, collectCC, stopOnFirstUser bool) {
+ s.collectCC = collectCC
+ s.stopOnFirstUser = stopOnFirstUser
+ s.firstUserIdx = -1
+ s.lastMessageIndex = -1
+ s.lastEligibleIndex = -1
+ s.lastEligibleTargetBlockIndex = -1
+ s.toolCC = s.toolCCBuf[:0]
+ s.sysCC = s.sysCCBuf[:0]
+ s.msgCC = s.msgCCBuf[:0]
+ _, _ = doc.WriteTo(s)
+}
+
+func (s *claudeStreamDocScanner) appendCaptureBytes(b []byte) {
+ if len(b) == 0 {
+ return
+ }
+ if len(s.ccExtra) > 0 {
+ s.ccExtra = append(s.ccExtra, b...)
+ s.ccLen = len(s.ccExtra)
+ return
+ }
+ if s.ccLen+len(b) <= len(s.ccBuf) {
+ copy(s.ccBuf[s.ccLen:], b)
+ s.ccLen += len(b)
+ return
+ }
+ s.ccExtra = append(s.ccExtra[:0], s.ccBuf[:s.ccLen]...)
+ s.ccExtra = append(s.ccExtra, b...)
+ s.ccLen = len(s.ccExtra)
+}
+
+func (s *claudeStreamDocScanner) startCapture(depth, idx int) {
+ s.captureActive = true
+ s.captureDepth = depth
+ s.capStart = idx
+ s.ccLen = 0
+ if s.ccExtra != nil {
+ s.ccExtra = s.ccExtra[:0]
+ }
+}
+
+func (s *claudeStreamDocScanner) finishCapture(p []byte, endIdx int) {
+ s.appendCaptureBytes(p[s.capStart:endIdx])
+ s.captureActive = false
+ if s.ccLen == 0 {
+ return
+ }
+ var raw []byte
+ if len(s.ccExtra) > 0 {
+ raw = s.ccExtra
+ } else {
+ raw = s.ccBuf[:s.ccLen]
+ }
+ if s.captureDepth == 5 {
+ if isValidClaudeCacheControl(util.ParseGJSONBytesNoCopy(raw)) {
+ s.curMsgHasCacheControl = true
+ }
+ }
+ if !s.collectCC {
+ return
+ }
+ var stored []byte
+ if s.ccArenaUsed+len(raw) <= len(s.ccArena) {
+ stored = s.ccArena[s.ccArenaUsed : s.ccArenaUsed+len(raw)]
+ copy(stored, raw)
+ s.ccArenaUsed += len(raw)
+ } else {
+ stored = bytes.Clone(raw)
+ }
+ switch s.topSection {
+ case claudeSecTools:
+ s.toolCC = append(s.toolCC, claudeScannedCC{idx1: s.elemIdx, raw: stored})
+ case claudeSecSystem:
+ s.sysCC = append(s.sysCC, claudeScannedCC{idx1: s.elemIdx, raw: stored})
+ case claudeSecMessages:
+ s.msgCC = append(s.msgCC, claudeScannedCC{idx1: s.elemIdx, idx2: s.blockIdx, raw: stored})
+ }
+}
+
+func (s *claudeStreamDocScanner) resetShortToken() {
+ s.strTarget = claudeStrCapShort
+ s.strLen = 0
+ s.strHasEsc = false
+ if s.strExtra != nil {
+ s.strExtra = s.strExtra[:0]
+ }
+}
+
+func (s *claudeStreamDocScanner) appendShortByte(c byte) {
+ if len(s.strExtra) > 0 {
+ s.strExtra = append(s.strExtra, c)
+ s.strLen = len(s.strExtra)
+ return
+ }
+ if s.strLen < len(s.strBuf) {
+ s.strBuf[s.strLen] = c
+ s.strLen++
+ return
+ }
+ s.strExtra = append(s.strExtra[:0], s.strBuf[:s.strLen]...)
+ s.strExtra = append(s.strExtra, c)
+ s.strLen = len(s.strExtra)
+}
+
+func (s *claudeStreamDocScanner) decodeShortToken() string {
+ var rawStr []byte
+ if len(s.strExtra) > 0 {
+ rawStr = s.strExtra
+ } else {
+ rawStr = s.strBuf[:s.strLen]
+ }
+ if !s.strHasEsc {
+ switch string(rawStr) {
+ case "":
+ return ""
+ case "tools":
+ return "tools"
+ case "system":
+ return "system"
+ case "messages":
+ return "messages"
+ case "cache_control":
+ return "cache_control"
+ case "role":
+ return "role"
+ case "content":
+ return "content"
+ case "type":
+ return "type"
+ case "text":
+ return "text"
+ case "user":
+ return "user"
+ case "assistant":
+ return "assistant"
+ case "thinking":
+ return "thinking"
+ case "redacted_thinking":
+ return "redacted_thinking"
+ default:
+ return "other"
+ }
+ }
+ var stackRaw [258]byte
+ var quoted []byte
+ if 2+len(rawStr) <= len(stackRaw) {
+ stackRaw[0] = '"'
+ copy(stackRaw[1:], rawStr)
+ stackRaw[1+len(rawStr)] = '"'
+ quoted = stackRaw[:2+len(rawStr)]
+ } else {
+ quoted = make([]byte, 2+len(rawStr))
+ quoted[0] = '"'
+ copy(quoted[1:], rawStr)
+ quoted[1+len(rawStr)] = '"'
+ }
+ decoded := util.ParseGJSONBytesNoCopy(quoted).String()
+ switch decoded {
+ case "":
+ return ""
+ case "tools":
+ return "tools"
+ case "system":
+ return "system"
+ case "messages":
+ return "messages"
+ case "cache_control":
+ return "cache_control"
+ case "role":
+ return "role"
+ case "content":
+ return "content"
+ case "type":
+ return "type"
+ case "text":
+ return "text"
+ case "user":
+ return "user"
+ case "assistant":
+ return "assistant"
+ case "thinking":
+ return "thinking"
+ case "redacted_thinking":
+ return "redacted_thinking"
+ default:
+ return "other"
+ }
+}
+
+func (s *claudeStreamDocScanner) finishKey() {
+ if s.strTarget != claudeStrCapShort {
+ return
+ }
+ key := s.decodeShortToken()
+ switch s.depth {
+ case 1:
+ switch key {
+ case "tools":
+ s.topSection = claudeSecTools
+ case "system":
+ s.topSection = claudeSecSystem
+ case "messages":
+ s.topSection = claudeSecMessages
+ default:
+ s.topSection = claudeSecNone
+ }
+ case 3:
+ if s.isObj[2] || !s.isObj[3] {
+ s.lvl3Key = claudeK3None
+ return
+ }
+ if s.topSection == claudeSecTools || s.topSection == claudeSecSystem {
+ if key == "cache_control" {
+ s.lvl3Key = claudeK3CacheControl
+ } else {
+ s.lvl3Key = claudeK3None
+ }
+ } else if s.topSection == claudeSecMessages {
+ switch key {
+ case "role":
+ s.lvl3Key = claudeK3Role
+ case "content":
+ s.lvl3Key = claudeK3Content
+ default:
+ s.lvl3Key = claudeK3None
+ }
+ }
+ case 5:
+ if s.topSection != claudeSecMessages || s.lvl3Key != claudeK3Content || s.isObj[4] || !s.isObj[5] {
+ s.lvl5Key = claudeK5None
+ return
+ }
+ switch key {
+ case "type":
+ s.lvl5Key = claudeK5Type
+ case "text":
+ s.lvl5Key = claudeK5Text
+ case "cache_control":
+ s.lvl5Key = claudeK5CacheControl
+ default:
+ s.lvl5Key = claudeK5None
+ }
+ }
+}
+
+func (s *claudeStreamDocScanner) startValue(c byte, idx int) {
+ switch s.depth {
+ case 2:
+ if s.topSection != claudeSecNone && !s.isObj[2] {
+ if s.topSection == claudeSecMessages {
+ s.messagesLen = s.elemIdx + 1
+ s.curMsgRole = ""
+ s.curMsgContentIsString = false
+ s.curMsgContentHasNonSpc = false
+ s.curMsgContentIsArray = false
+ s.curMsgHasCacheControl = false
+ s.curMsgLastCacheableUser = -1
+ s.curMsgLastCacheableAsst = -1
+ }
+ }
+ case 3:
+ if !s.isObj[2] && s.isObj[3] {
+ if s.lvl3Key == claudeK3CacheControl && s.collectCC {
+ s.startCapture(3, idx)
+ } else if s.lvl3Key == claudeK3Content && s.topSection == claudeSecMessages {
+ if c == '"' {
+ s.curMsgContentIsString = true
+ } else if c == '[' {
+ s.curMsgContentIsArray = true
+ s.blockIdx = 0
+ }
+ }
+ }
+ case 4:
+ if s.topSection == claudeSecMessages && s.lvl3Key == claudeK3Content && !s.isObj[4] {
+ s.curBlockIsObj = c == '{'
+ s.curBlockType = ""
+ s.curBlockTextHasNonSpc = false
+ }
+ case 5:
+ if s.topSection == claudeSecMessages && s.lvl3Key == claudeK3Content && !s.isObj[4] && s.isObj[5] {
+ if s.lvl5Key == claudeK5CacheControl {
+ s.startCapture(5, idx)
+ }
+ }
+ }
+}
+
+func (s *claudeStreamDocScanner) finishValue(p []byte, endIdx int) {
+ if s.captureActive && s.depth == s.captureDepth {
+ s.finishCapture(p, endIdx)
+ }
+ switch s.depth {
+ case 1:
+ s.topSection = claudeSecNone
+ case 2:
+ if s.topSection == claudeSecMessages && !s.isObj[2] {
+ s.lastMessageIndex = s.elemIdx
+ s.lastMsgRole = s.curMsgRole
+ s.lastMsgContentIsNonEmptyString = s.curMsgContentIsString && s.curMsgContentHasNonSpc
+ if s.curMsgRole == "user" && s.firstUserIdx < 0 {
+ s.firstUserIdx = s.elemIdx
+ if s.stopOnFirstUser {
+ s.err = errStopClaudeScan
+ }
+ }
+ if s.curMsgRole == "user" || s.curMsgRole == "assistant" {
+ targetBlock := s.curMsgLastCacheableUser
+ if s.curMsgRole == "assistant" {
+ targetBlock = s.curMsgLastCacheableAsst
+ }
+ eligible := false
+ if s.curMsgContentIsString {
+ eligible = s.curMsgContentHasNonSpc
+ } else if s.curMsgContentIsArray {
+ eligible = targetBlock >= 0
+ }
+ if eligible {
+ s.lastEligibleIndex = s.elemIdx
+ s.lastEligibleContentIsArray = s.curMsgContentIsArray
+ s.lastEligibleContentIsNonEmptyStr = s.curMsgContentIsString && s.curMsgContentHasNonSpc
+ s.lastEligibleHasCacheControl = s.curMsgHasCacheControl
+ s.lastEligibleTargetBlockIndex = targetBlock
+ }
+ }
+ }
+ case 3:
+ if s.isObj[3] {
+ s.lvl3Key = claudeK3None
+ }
+ case 4:
+ if s.topSection == claudeSecMessages && s.lvl3Key == claudeK3Content && !s.isObj[4] {
+ if s.curBlockIsObj {
+ switch s.curBlockType {
+ case "text":
+ if s.curBlockTextHasNonSpc {
+ s.curMsgLastCacheableUser = s.blockIdx
+ s.curMsgLastCacheableAsst = s.blockIdx
+ }
+ case "thinking", "redacted_thinking":
+ s.curMsgLastCacheableAsst = -1
+ case "":
+ default:
+ s.curMsgLastCacheableUser = s.blockIdx
+ s.curMsgLastCacheableAsst = s.blockIdx
+ }
+ }
+ }
+ case 5:
+ if s.isObj[5] {
+ s.lvl5Key = claudeK5None
+ }
+ }
+}
+
+func claudeHexValue(c byte) uint16 {
+ switch {
+ case c >= '0' && c <= '9':
+ return uint16(c - '0')
+ case c >= 'a' && c <= 'f':
+ return uint16(c-'a') + 10
+ case c >= 'A' && c <= 'F':
+ return uint16(c-'A') + 10
+ default:
+ return 0
+ }
+}
+
+func (s *claudeStreamDocScanner) Write(p []byte) (int, error) {
+ if s.err != nil {
+ return 0, s.err
+ }
+ if s.captureActive {
+ s.capStart = 0
+ }
+ i := 0
+ for i < len(p) {
+ switch s.tok {
+ case claudeScanTokString:
+ if s.strEsc == 0 && (s.strTarget == claudeStrIgnore || (s.strTarget == claudeStrCheckNonSpace && s.strHasNonSpace)) {
+ for i < len(p) && p[i] != '"' && p[i] != '\\' {
+ i++
+ }
+ if i >= len(p) {
+ break
+ }
+ }
+ c := p[i]
+ switch s.strEsc {
+ case 0:
+ if c == '\\' {
+ if s.strTarget == claudeStrCapShort {
+ s.strHasEsc = true
+ s.appendShortByte(c)
+ } else if s.strTarget == claudeStrCheckNonSpace && s.utfLen > 0 {
+ s.strHasNonSpace = true
+ s.utfLen = 0
+ }
+ s.strEsc = 1
+ i++
+ continue
+ }
+ if c == '"' {
+ if s.strTarget == claudeStrCheckNonSpace && s.utfLen > 0 {
+ s.strHasNonSpace = true
+ s.utfLen = 0
+ }
+ s.tok = claudeScanTokNone
+ if s.strIsKey {
+ s.strIsKey = false
+ s.finishKey()
+ } else {
+ if s.depth == 3 && s.topSection == claudeSecMessages && !s.isObj[2] && s.isObj[3] {
+ if s.lvl3Key == claudeK3Role {
+ s.curMsgRole = s.decodeShortToken()
+ if s.curMsgRole == "user" && s.firstUserIdx < 0 {
+ s.firstUserIdx = s.elemIdx
+ if s.stopOnFirstUser {
+ s.err = errStopClaudeScan
+ return len(p), s.err
+ }
+ }
+ } else if s.lvl3Key == claudeK3Content {
+ s.curMsgContentHasNonSpc = s.strHasNonSpace
+ }
+ } else if s.depth == 5 && s.topSection == claudeSecMessages && s.lvl3Key == claudeK3Content && !s.isObj[4] && s.isObj[5] {
+ if s.lvl5Key == claudeK5Type {
+ s.curBlockType = s.decodeShortToken()
+ } else if s.lvl5Key == claudeK5Text {
+ s.curBlockTextHasNonSpc = s.strHasNonSpace
+ }
+ }
+ s.finishValue(p, i+1)
+ if s.err != nil {
+ return len(p), s.err
+ }
+ }
+ i++
+ continue
+ }
+ if s.strTarget == claudeStrCapShort {
+ s.appendShortByte(c)
+ } else if s.strTarget == claudeStrCheckNonSpace && !s.strHasNonSpace {
+ if c < 0x80 {
+ if s.utfLen > 0 || (c > ' ') || (c != ' ' && c != '\t' && c != '\n' && c != '\v' && c != '\f' && c != '\r') {
+ s.strHasNonSpace = true
+ s.utfLen = 0
+ }
+ } else {
+ s.utfBuf[s.utfLen] = c
+ s.utfLen++
+ if utf8.FullRune(s.utfBuf[:s.utfLen]) {
+ r, _ := utf8.DecodeRune(s.utfBuf[:s.utfLen])
+ s.utfLen = 0
+ if !unicode.IsSpace(r) {
+ s.strHasNonSpace = true
+ }
+ } else if int(s.utfLen) == len(s.utfBuf) {
+ s.utfLen = 0
+ s.strHasNonSpace = true
+ }
+ }
+ }
+ i++
+ case 1:
+ if s.strTarget == claudeStrCapShort {
+ s.appendShortByte(c)
+ } else if s.strTarget == claudeStrCheckNonSpace && !s.strHasNonSpace {
+ switch c {
+ case '"', '\\', '/', 'b':
+ s.strHasNonSpace = true
+ }
+ }
+ if c == 'u' {
+ s.strEsc = 2
+ s.uVal = 0
+ } else {
+ s.strEsc = 0
+ }
+ i++
+ default:
+ if s.strTarget == claudeStrCapShort {
+ s.appendShortByte(c)
+ } else if s.strTarget == claudeStrCheckNonSpace && !s.strHasNonSpace {
+ s.uVal = (s.uVal << 4) | claudeHexValue(c)
+ if s.strEsc == 5 && !unicode.IsSpace(rune(s.uVal)) {
+ s.strHasNonSpace = true
+ }
+ }
+ if s.strEsc == 5 {
+ s.strEsc = 0
+ } else {
+ s.strEsc++
+ }
+ i++
+ }
+ continue
+
+ case claudeScanTokPrimitive:
+ for i < len(p) && p[i] > ' ' && p[i] != ',' && p[i] != '}' && p[i] != ']' && p[i] != ':' {
+ i++
+ }
+ if i >= len(p) {
+ break
+ }
+ s.tok = claudeScanTokNone
+ s.finishValue(p, i)
+ if s.err != nil {
+ return len(p), s.err
+ }
+ }
+
+ for i < len(p) && (p[i] == ' ' || p[i] == '\t' || p[i] == '\r' || p[i] == '\n') {
+ i++
+ }
+ if i >= len(p) {
+ break
+ }
+ c := p[i]
+ if c == ':' {
+ if s.depth > 0 && s.depth < len(s.inKey) {
+ s.inKey[s.depth] = false
+ }
+ i++
+ continue
+ }
+ if c == ',' {
+ if s.depth > 0 && s.depth < len(s.isObj) {
+ if s.isObj[s.depth] {
+ s.inKey[s.depth] = true
+ } else if s.depth == 2 && s.topSection != claudeSecNone {
+ s.elemIdx++
+ } else if s.depth == 4 && s.topSection == claudeSecMessages && s.lvl3Key == claudeK3Content {
+ s.blockIdx++
+ }
+ }
+ i++
+ continue
+ }
+ if c == '}' || c == ']' {
+ if s.depth > 0 {
+ s.depth--
+ if s.depth >= 1 {
+ s.finishValue(p, i+1)
+ if s.err != nil {
+ return len(p), s.err
+ }
+ }
+ }
+ i++
+ continue
+ }
+ if s.depth > 0 && s.depth < len(s.isObj) && s.isObj[s.depth] && s.inKey[s.depth] {
+ if c == '"' {
+ s.tok = claudeScanTokString
+ s.strEsc = 0
+ s.strIsKey = true
+ if s.depth == 1 || s.depth == 3 || s.depth == 5 {
+ s.resetShortToken()
+ } else {
+ s.strTarget = claudeStrIgnore
+ }
+ }
+ i++
+ continue
+ }
+
+ s.startValue(c, i)
+ switch c {
+ case '{', '[':
+ s.depth++
+ if s.depth < len(s.isObj) {
+ s.isObj[s.depth] = c == '{'
+ s.inKey[s.depth] = c == '{'
+ }
+ if s.depth == 2 && c == '[' && s.topSection != claudeSecNone {
+ s.elemIdx = 0
+ if s.topSection == claudeSecMessages {
+ s.messagesIsArray = true
+ }
+ }
+ i++
+ case '"':
+ s.tok = claudeScanTokString
+ s.strEsc = 0
+ s.strIsKey = false
+ s.strTarget = claudeStrIgnore
+ if s.depth == 3 && s.topSection == claudeSecMessages && !s.isObj[2] && s.isObj[3] {
+ if s.lvl3Key == claudeK3Role {
+ s.resetShortToken()
+ } else if s.lvl3Key == claudeK3Content {
+ s.strTarget = claudeStrCheckNonSpace
+ s.strHasNonSpace = false
+ s.utfLen = 0
+ }
+ } else if s.depth == 5 && s.topSection == claudeSecMessages && s.lvl3Key == claudeK3Content && !s.isObj[4] && s.isObj[5] {
+ if s.lvl5Key == claudeK5Type {
+ s.resetShortToken()
+ } else if s.lvl5Key == claudeK5Text {
+ s.strTarget = claudeStrCheckNonSpace
+ s.strHasNonSpace = false
+ s.utfLen = 0
+ }
+ }
+ i++
+ default:
+ s.tok = claudeScanTokPrimitive
+ i++
+ }
+ }
+ if s.captureActive {
+ s.appendCaptureBytes(p[s.capStart:len(p)])
+ }
+ return len(p), nil
+}
+
+// forEachClaudeCacheControlPathDoc walks every candidate cache_control in
+// Anthropic evaluation order (tools -> system -> messages) in a single O(N)
+// pass without materializing split containers.
+func forEachClaudeCacheControlPathDoc(doc *ojson.Doc, visit func(path string, cacheControl ojson.Result) bool) {
+ if doc == nil || doc.Len() == 0 {
+ return
+ }
+ var scanner claudeStreamDocScanner
+ scanner.scan(doc, true, false)
+ for _, entry := range scanner.toolCC {
+ ccRes := util.ParseGJSONBytesNoCopy(entry.raw)
+ if !ccRes.Exists() {
+ continue
+ }
+ if !visit("tools."+strconv.Itoa(entry.idx1), ccRes) {
+ return
+ }
+ }
+ for _, entry := range scanner.sysCC {
+ ccRes := util.ParseGJSONBytesNoCopy(entry.raw)
+ if !ccRes.Exists() {
+ continue
+ }
+ if !visit("system."+strconv.Itoa(entry.idx1), ccRes) {
return
}
- updated, errDel := sjson.DeleteBytes(payload, path+".cache_control.ttl")
- if errDel != nil {
+ }
+ for _, entry := range scanner.msgCC {
+ ccRes := util.ParseGJSONBytesNoCopy(entry.raw)
+ if !ccRes.Exists() {
+ continue
+ }
+ path := "messages." + strconv.Itoa(entry.idx1) + ".content." + strconv.Itoa(entry.idx2)
+ if !visit(path, ccRes) {
return
}
- payload = updated
}
-
- forEachClaudeCacheControlBlock(payload, strip)
- return payload
}
// forEachClaudeCacheControlBlock walks every block that can carry cache_control
// in Anthropic's evaluation order: tools, then system, then messages.
-func forEachClaudeCacheControlBlock(payload []byte, visit func(path string, block gjson.Result)) {
- if tools := gjson.GetBytes(payload, "tools"); tools.IsArray() {
- tools.ForEach(func(idx, item gjson.Result) bool {
+func forEachClaudeCacheControlBlock(payload []byte, visit func(path string, block ojson.Result)) {
+ forEachClaudeCacheControlBlockResult(
+ ojson.GetBytesNoCopy(payload, "tools"),
+ ojson.GetBytesNoCopy(payload, "system"),
+ ojson.GetBytesNoCopy(payload, "messages"),
+ visit,
+ )
+}
+
+func forEachClaudeCacheControlBlockResult(tools, system, messages ojson.Result, visit func(path string, block ojson.Result)) {
+ if tools.IsArray() {
+ tools.ForEach(func(idx, item ojson.Result) bool {
visit(fmt.Sprintf("tools.%d", int(idx.Int())), item)
return true
})
}
- if system := gjson.GetBytes(payload, "system"); system.IsArray() {
- system.ForEach(func(idx, item gjson.Result) bool {
+ if system.IsArray() {
+ system.ForEach(func(idx, item ojson.Result) bool {
visit(fmt.Sprintf("system.%d", int(idx.Int())), item)
return true
})
}
- if messages := gjson.GetBytes(payload, "messages"); messages.IsArray() {
- messages.ForEach(func(msgIdx, message gjson.Result) bool {
+ if messages.IsArray() {
+ messages.ForEach(func(msgIdx, message ojson.Result) bool {
content := message.Get("content")
if !content.IsArray() {
return true
}
- content.ForEach(func(itemIdx, item gjson.Result) bool {
+ content.ForEach(func(itemIdx, item ojson.Result) bool {
visit(fmt.Sprintf("messages.%d.content.%d", int(msgIdx.Int()), int(itemIdx.Int())), item)
return true
})
@@ -2094,7 +3702,7 @@ func isExplicitPromptCacheMode(payloads ...[]byte) bool {
if len(payload) == 0 {
continue
}
- mode := gjson.GetBytes(payload, "prompt_cache_options.mode").String()
+ mode := ojson.GetBytesNoCopy(payload, "prompt_cache_options.mode").String()
if strings.EqualFold(strings.TrimSpace(mode), "explicit") {
return true
}
@@ -2102,6 +3710,16 @@ func isExplicitPromptCacheMode(payloads ...[]byte) bool {
return false
}
+func isExplicitPromptCacheModeDoc(doc *ojson.Doc, candidates ...[]byte) bool {
+ if doc != nil && doc.Len() > 0 {
+ mode := doc.Get("prompt_cache_options.mode").String()
+ if strings.EqualFold(strings.TrimSpace(mode), "explicit") {
+ return true
+ }
+ }
+ return isExplicitPromptCacheMode(candidates...)
+}
+
type explicitPromptCacheModeContextKey struct{}
func withExplicitPromptCacheMode(ctx context.Context, explicit bool) context.Context {
@@ -2117,14 +3735,18 @@ func isExplicitPromptCacheModeContext(ctx context.Context) bool {
}
func stripPromptCacheOptions(payload []byte) []byte {
- if !gjson.GetBytes(payload, "prompt_cache_options").Exists() {
+ doc := ojson.NewDoc(payload)
+ if !stripPromptCacheOptionsDoc(doc) {
return payload
}
- updated, err := sjson.DeleteBytes(payload, "prompt_cache_options")
- if err != nil {
- return payload
+ return doc.Bytes()
+}
+
+func stripPromptCacheOptionsDoc(doc *ojson.Doc) bool {
+ if doc == nil || !doc.Get("prompt_cache_options").Exists() {
+ return false
}
- return updated
+ return doc.Delete("prompt_cache_options") == nil
}
func shouldEnsureCacheControl(payload []byte, cloaked, confirmedClaudeCode bool, candidates ...[]byte) bool {
@@ -2134,13 +3756,40 @@ func shouldEnsureCacheControl(payload []byte, cloaked, confirmedClaudeCode bool,
return !confirmedClaudeCode && (cloaked || countCacheControls(payload) == 0)
}
+func shouldEnsureCacheControlDoc(doc *ojson.Doc, cloaked, confirmedClaudeCode bool, candidates ...[]byte) bool {
+ if isExplicitPromptCacheModeDoc(doc, candidates...) {
+ return false
+ }
+ return !confirmedClaudeCode && (cloaked || countCacheControlsDoc(doc) == 0)
+}
+
func countCacheControls(payload []byte) int {
+ return countCacheControlsResult(
+ ojson.GetBytesNoCopy(payload, "system"),
+ ojson.GetBytesNoCopy(payload, "tools"),
+ ojson.GetBytesNoCopy(payload, "messages"),
+ )
+}
+
+func countCacheControlsDoc(doc *ojson.Doc) int {
+ if doc == nil {
+ return 0
+ }
+ count := 0
+ forEachClaudeCacheControlPathDoc(doc, func(_ string, cc ojson.Result) bool {
+ if isValidClaudeCacheControl(cc) {
+ count++
+ }
+ return true
+ })
+ return count
+}
+
+func countCacheControlsResult(system, tools, messages ojson.Result) int {
count := 0
- // Check system
- system := gjson.GetBytes(payload, "system")
if system.IsArray() {
- system.ForEach(func(_, item gjson.Result) bool {
+ system.ForEach(func(_, item ojson.Result) bool {
if isValidClaudeCacheControl(item.Get("cache_control")) {
count++
}
@@ -2148,10 +3797,8 @@ func countCacheControls(payload []byte) int {
})
}
- // Check tools
- tools := gjson.GetBytes(payload, "tools")
if tools.IsArray() {
- tools.ForEach(func(_, item gjson.Result) bool {
+ tools.ForEach(func(_, item ojson.Result) bool {
if isValidClaudeCacheControl(item.Get("cache_control")) {
count++
}
@@ -2159,13 +3806,11 @@ func countCacheControls(payload []byte) int {
})
}
- // Check messages
- messages := gjson.GetBytes(payload, "messages")
if messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if content.IsArray() {
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
if isValidClaudeCacheControl(item.Get("cache_control")) {
count++
}
@@ -2191,71 +3836,41 @@ func countCacheControls(payload []byte) int {
// Strategy: walk all cache_control blocks in evaluation order. Once a 5m block
// is seen, strip ttl from ALL subsequent 1h blocks (downgrading them to 5m).
func normalizeCacheControlTTL(payload []byte) []byte {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !normalizeCacheControlTTLDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func normalizeCacheControlTTLDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
- original := payload
seen5m := false
modified := false
-
- processBlock := func(path string, obj gjson.Result) {
- cc := obj.Get("cache_control")
+ forEachClaudeCacheControlPathDoc(doc, func(path string, cc ojson.Result) bool {
if !isValidClaudeCacheControl(cc) {
- return
+ return true
}
ttl := cc.Get("ttl")
- if ttl.Type != gjson.String || ttl.String() != "1h" {
+ if ttl.Type != ojson.String || ttl.String() != "1h" {
seen5m = true
- return
+ return true
}
if !seen5m {
- return
+ return true
}
- ttlPath := path + ".cache_control.ttl"
- updated, errDel := sjson.DeleteBytes(payload, ttlPath)
- if errDel != nil {
- return
+ if errDel := doc.Delete(path + ".cache_control.ttl"); errDel == nil {
+ modified = true
}
- payload = updated
- modified = true
- }
-
- tools := gjson.GetBytes(payload, "tools")
- if tools.IsArray() {
- tools.ForEach(func(idx, item gjson.Result) bool {
- processBlock(fmt.Sprintf("tools.%d", int(idx.Int())), item)
- return true
- })
- }
-
- system := gjson.GetBytes(payload, "system")
- if system.IsArray() {
- system.ForEach(func(idx, item gjson.Result) bool {
- processBlock(fmt.Sprintf("system.%d", int(idx.Int())), item)
- return true
- })
- }
-
- messages := gjson.GetBytes(payload, "messages")
- if messages.IsArray() {
- messages.ForEach(func(msgIdx, msg gjson.Result) bool {
- content := msg.Get("content")
- if !content.IsArray() {
- return true
- }
- content.ForEach(func(itemIdx, item gjson.Result) bool {
- processBlock(fmt.Sprintf("messages.%d.content.%d", int(msgIdx.Int()), int(itemIdx.Int())), item)
- return true
- })
- return true
- })
- }
-
- if !modified {
- return original
- }
- return payload
+ return true
+ })
+ return modified
}
// enforceCacheControlLimit removes excess cache_control blocks from a payload
@@ -2275,172 +3890,89 @@ func normalizeCacheControlTTL(payload []byte) []byte {
// Phase 4: remaining system blocks (last system).
// Phase 5: remaining tool blocks (last tool).
func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !enforceCacheControlLimitDoc(doc, maxBlocks) {
return payload
}
+ return doc.Bytes()
+}
+
+func enforceCacheControlLimitDoc(doc *ojson.Doc, maxBlocks int) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
- thread := gjson.GetBytes(payload, "thread")
- if thread.Exists() && thread.Type != gjson.Null && maxBlocks > 0 {
+ thread := doc.Get("thread")
+ if thread.Exists() && thread.Type != ojson.Null && maxBlocks > 0 {
// Anthropic reserves one cache breakpoint for thread continuation.
maxBlocks--
}
- total := countCacheControls(payload)
+ var toolPaths, systemPaths, messagePaths []string
+ forEachClaudeCacheControlPathDoc(doc, func(path string, cc ojson.Result) bool {
+ if !isValidClaudeCacheControl(cc) {
+ return true
+ }
+ ccPath := path + ".cache_control"
+ switch {
+ case strings.HasPrefix(path, "tools."):
+ toolPaths = append(toolPaths, ccPath)
+ case strings.HasPrefix(path, "system."):
+ systemPaths = append(systemPaths, ccPath)
+ default:
+ messagePaths = append(messagePaths, ccPath)
+ }
+ return true
+ })
+
+ total := len(toolPaths) + len(systemPaths) + len(messagePaths)
if total <= maxBlocks {
- return payload
+ return false
}
excess := total - maxBlocks
-
- system := gjson.GetBytes(payload, "system")
- if system.IsArray() {
- lastIdx := -1
- system.ForEach(func(idx, item gjson.Result) bool {
- if isValidClaudeCacheControl(item.Get("cache_control")) {
- lastIdx = int(idx.Int())
+ changed := false
+ deleteList := func(paths []string) {
+ for _, path := range paths {
+ if excess <= 0 {
+ return
}
- return true
- })
- if lastIdx >= 0 {
- system.ForEach(func(idx, item gjson.Result) bool {
- if excess <= 0 {
- return false
- }
- i := int(idx.Int())
- if i == lastIdx {
- return true
- }
- if !isValidClaudeCacheControl(item.Get("cache_control")) {
- return true
- }
- path := fmt.Sprintf("system.%d.cache_control", i)
- updated, errDel := sjson.DeleteBytes(payload, path)
- if errDel != nil {
- return true
- }
- payload = updated
+ if errDel := doc.Delete(path); errDel == nil {
+ changed = true
excess--
- return true
- })
+ }
}
}
- if excess <= 0 {
- return payload
- }
- tools := gjson.GetBytes(payload, "tools")
- if tools.IsArray() {
- lastIdx := -1
- tools.ForEach(func(idx, item gjson.Result) bool {
- if isValidClaudeCacheControl(item.Get("cache_control")) {
- lastIdx = int(idx.Int())
- }
- return true
- })
- if lastIdx >= 0 {
- tools.ForEach(func(idx, item gjson.Result) bool {
- if excess <= 0 {
- return false
- }
- i := int(idx.Int())
- if i == lastIdx {
- return true
- }
- if !isValidClaudeCacheControl(item.Get("cache_control")) {
- return true
- }
- path := fmt.Sprintf("tools.%d.cache_control", i)
- updated, errDel := sjson.DeleteBytes(payload, path)
- if errDel != nil {
- return true
- }
- payload = updated
- excess--
- return true
- })
- }
+ if len(systemPaths) > 1 {
+ deleteList(systemPaths[:len(systemPaths)-1])
}
if excess <= 0 {
- return payload
+ return changed
}
-
- messages := gjson.GetBytes(payload, "messages")
- if messages.IsArray() {
- messages.ForEach(func(msgIdx, msg gjson.Result) bool {
- if excess <= 0 {
- return false
- }
- content := msg.Get("content")
- if !content.IsArray() {
- return true
- }
- content.ForEach(func(itemIdx, item gjson.Result) bool {
- if excess <= 0 {
- return false
- }
- if !isValidClaudeCacheControl(item.Get("cache_control")) {
- return true
- }
- path := fmt.Sprintf("messages.%d.content.%d.cache_control", int(msgIdx.Int()), int(itemIdx.Int()))
- updated, errDel := sjson.DeleteBytes(payload, path)
- if errDel != nil {
- return true
- }
- payload = updated
- excess--
- return true
- })
- return true
- })
+ if len(toolPaths) > 1 {
+ deleteList(toolPaths[:len(toolPaths)-1])
}
if excess <= 0 {
- return payload
+ return changed
}
-
- system = gjson.GetBytes(payload, "system")
- if system.IsArray() {
- system.ForEach(func(idx, item gjson.Result) bool {
- if excess <= 0 {
- return false
- }
- if !isValidClaudeCacheControl(item.Get("cache_control")) {
- return true
- }
- path := fmt.Sprintf("system.%d.cache_control", int(idx.Int()))
- updated, errDel := sjson.DeleteBytes(payload, path)
- if errDel != nil {
- return true
- }
- payload = updated
- excess--
- return true
- })
+ deleteList(messagePaths)
+ if excess <= 0 {
+ return changed
+ }
+ if len(systemPaths) > 0 {
+ deleteList(systemPaths[len(systemPaths)-1:])
}
if excess <= 0 {
- return payload
+ return changed
}
-
- tools = gjson.GetBytes(payload, "tools")
- if tools.IsArray() {
- tools.ForEach(func(idx, item gjson.Result) bool {
- if excess <= 0 {
- return false
- }
- if !isValidClaudeCacheControl(item.Get("cache_control")) {
- return true
- }
- path := fmt.Sprintf("tools.%d.cache_control", int(idx.Int()))
- updated, errDel := sjson.DeleteBytes(payload, path)
- if errDel != nil {
- return true
- }
- payload = updated
- excess--
- return true
- })
+ if len(toolPaths) > 0 {
+ deleteList(toolPaths[len(toolPaths)-1:])
}
-
- return payload
+ return changed
}
// injectMessagesCacheControl adds cache_control to the message the native rolling
@@ -2460,73 +3992,68 @@ func enforceCacheControlLimit(payload []byte, maxBlocks int) []byte {
// stamping the last element of an existing array. Markers on other messages must
// not suppress this rolling write.
func injectMessagesCacheControl(payload []byte) []byte {
- messages := gjson.GetBytes(payload, "messages")
- if !messages.Exists() || !messages.IsArray() {
+ doc := ojson.NewDoc(payload)
+ if !injectMessagesCacheControlDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
- lastMessageIndex := int(messages.Get("#").Int()) - 1
- lastEligibleIndex := -1
- messages.ForEach(func(index gjson.Result, message gjson.Result) bool {
- if role := message.Get("role").String(); role != "user" && role != "assistant" {
- return true
- }
- if claudeMessageEligibleForRollingCache(message) {
- lastEligibleIndex = int(index.Int())
- }
- return true
- })
+func injectMessagesCacheControlDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ var scanner claudeStreamDocScanner
+ scanner.scan(doc, false, false)
+ if !scanner.messagesIsArray || scanner.lastMessageIndex < 0 {
+ return false
+ }
- if lastEligibleIndex >= 0 {
- finalMessage := messages.Get(fmt.Sprintf("%d", lastMessageIndex))
- finalContent := finalMessage.Get("content")
- if finalMessage.Get("role").String() == "system" &&
- finalContent.Type == gjson.String &&
- strings.TrimSpace(finalContent.String()) != "" {
- return injectClaudeFinalSystemCacheControl(payload, lastMessageIndex, finalContent.String())
+ if scanner.lastEligibleIndex >= 0 && scanner.lastMsgRole == "system" && scanner.lastMsgContentIsNonEmptyString {
+ finalContent := doc.Get(fmt.Sprintf("messages.%d.content", scanner.lastMessageIndex))
+ if finalContent.Type == ojson.String && strings.TrimSpace(finalContent.String()) != "" {
+ return injectClaudeFinalSystemCacheControlDoc(doc, scanner.lastMessageIndex, finalContent.String())
}
}
- if lastEligibleIndex < 0 {
- return payload
+ if scanner.lastEligibleIndex < 0 || scanner.lastEligibleHasCacheControl {
+ return false
}
- contentPath := fmt.Sprintf("messages.%d.content", lastEligibleIndex)
- content := gjson.GetBytes(payload, contentPath)
- if messageContentHasCacheControl(content) {
- return payload
+ if scanner.lastEligibleContentIsArray {
+ if scanner.lastEligibleTargetBlockIndex >= 0 {
+ cacheControlPath := fmt.Sprintf("messages.%d.content.%d.cache_control", scanner.lastEligibleIndex, scanner.lastEligibleTargetBlockIndex)
+ if errSet := doc.Set(cacheControlPath, claudeCodeCacheControl); errSet != nil {
+ log.Warnf("failed to inject cache_control into messages: %v", errSet)
+ return false
+ }
+ return true
+ }
+ return false
}
- if content.IsArray() {
- targetBlockIndex := lastCacheableClaudeMessageContentBlockIndex(messages.Get(fmt.Sprintf("%d", lastEligibleIndex)))
- if targetBlockIndex >= 0 {
- cacheControlPath := fmt.Sprintf("messages.%d.content.%d.cache_control", lastEligibleIndex, targetBlockIndex)
- result, err := sjson.SetBytes(payload, cacheControlPath, claudeCodeCacheControl)
- if err != nil {
- log.Warnf("failed to inject cache_control into messages: %v", err)
- return payload
+ if scanner.lastEligibleContentIsNonEmptyStr {
+ contentPath := fmt.Sprintf("messages.%d.content", scanner.lastEligibleIndex)
+ contentRes := doc.Get(contentPath)
+ if contentRes.Type == ojson.String && strings.TrimSpace(contentRes.String()) != "" {
+ newContent := "[" + buildTextBlock(contentRes.String(), &claudeCodeCacheControl) + "]"
+ if errSet := doc.SetRawBytes(contentPath, []byte(newContent)); errSet != nil {
+ log.Warnf("failed to inject cache_control into message string content: %v", errSet)
+ return false
}
- payload = result
- }
- } else if content.Type == gjson.String && strings.TrimSpace(content.String()) != "" {
- newContent := "[" + buildTextBlock(content.String(), &claudeCodeCacheControl) + "]"
- result, err := sjson.SetRawBytes(payload, contentPath, []byte(newContent))
- if err != nil {
- log.Warnf("failed to inject cache_control into message string content: %v", err)
- return payload
+ return true
}
- payload = result
}
- return payload
+ return false
}
-func isEmptyClaudeTextBlock(block gjson.Result) bool {
+func isEmptyClaudeTextBlock(block ojson.Result) bool {
return block.IsObject() &&
block.Get("type").String() == "text" &&
strings.TrimSpace(block.Get("text").String()) == ""
}
-func claudeContentBlockCanHostCacheControl(block gjson.Result) bool {
+func claudeContentBlockCanHostCacheControl(block ojson.Result) bool {
if !block.IsObject() {
return false
}
@@ -2540,7 +4067,7 @@ func claudeContentBlockCanHostCacheControl(block gjson.Result) bool {
}
}
-func lastCacheableClaudeMessageContentBlockIndex(message gjson.Result) int {
+func lastCacheableClaudeMessageContentBlockIndex(message ojson.Result) int {
content := message.Get("content")
if !content.IsArray() {
return -1
@@ -2572,9 +4099,9 @@ func lastCacheableClaudeMessageContentBlockIndex(message gjson.Result) int {
// consider this user/assistant turn as a rolling breakpoint host. Native rejects
// an assistant turn whose last content block is thinking-like, because a thinking
// block cannot host the marker, and Anthropic rejects cache_control on empty text blocks.
-func claudeMessageEligibleForRollingCache(message gjson.Result) bool {
+func claudeMessageEligibleForRollingCache(message ojson.Result) bool {
content := message.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return strings.TrimSpace(content.String()) != ""
}
return lastCacheableClaudeMessageContentBlockIndex(message) >= 0
@@ -2583,20 +4110,30 @@ func claudeMessageEligibleForRollingCache(message gjson.Result) bool {
// injectClaudeFinalSystemCacheControl reproduces the native final-system special
// case, which replaces the string content with a single marked text block.
func injectClaudeFinalSystemCacheControl(payload []byte, messageIndex int, text string) []byte {
+ doc := ojson.NewDoc(payload)
+ if !injectClaudeFinalSystemCacheControlDoc(doc, messageIndex, text) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func injectClaudeFinalSystemCacheControlDoc(doc *ojson.Doc, messageIndex int, text string) bool {
+ if doc == nil {
+ return false
+ }
contentPath := fmt.Sprintf("messages.%d.content", messageIndex)
newContent := "[" + buildTextBlock(text, &claudeCodeCacheControl) + "]"
- result, err := sjson.SetRawBytes(payload, contentPath, []byte(newContent))
- if err != nil {
- log.Warnf("failed to inject cache_control into trailing system message: %v", err)
- return payload
+ if errSet := doc.SetRawBytes(contentPath, []byte(newContent)); errSet != nil {
+ log.Warnf("failed to inject cache_control into trailing system message: %v", errSet)
+ return false
}
- return result
+ return true
}
-func messageContentHasCacheControl(content gjson.Result) bool {
+func messageContentHasCacheControl(content ojson.Result) bool {
if content.IsArray() {
found := false
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
if isValidClaudeCacheControl(item.Get("cache_control")) {
found = true
return false
@@ -2612,106 +4149,135 @@ func messageContentHasCacheControl(content gjson.Result) bool {
// Deferred tools cannot use prompt caching, so trailing deferred tools are skipped.
// This only adds cache_control if NO tool in the array already has it.
func injectToolsCacheControl(payload []byte) []byte {
- tools := gjson.GetBytes(payload, "tools")
- if !tools.Exists() || !tools.IsArray() {
+ doc := ojson.NewDoc(payload)
+ if !injectToolsCacheControlDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func injectToolsCacheControlDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("tools")
+ if !tools.IsArray() {
+ return false
+ }
// Check if ANY tool already has cache_control and find the last eligible tool.
hasCacheControlInTools := false
lastEligibleToolIndex := -1
- tools.ForEach(func(index, tool gjson.Result) bool {
+ idx := 0
+ tools.ForEach(func(_, tool ojson.Result) bool {
if isValidClaudeCacheControl(tool.Get("cache_control")) {
hasCacheControlInTools = true
return false
}
if !tool.Get("defer_loading").Bool() {
- lastEligibleToolIndex = int(index.Int())
+ lastEligibleToolIndex = idx
}
+ idx++
return true
})
if hasCacheControlInTools || lastEligibleToolIndex < 0 {
- return payload
+ return false
}
lastToolPath := fmt.Sprintf("tools.%d.cache_control", lastEligibleToolIndex)
- result, err := sjson.SetBytes(payload, lastToolPath, claudeCodeCacheControl)
- if err != nil {
- log.Warnf("failed to inject cache_control into tools array: %v", err)
- return payload
+ if errSet := doc.Set(lastToolPath, claudeCodeCacheControl); errSet != nil {
+ log.Warnf("failed to inject cache_control into tools array: %v", errSet)
+ return false
}
- return result
+ return true
}
// injectSystemCacheControl adds cache_control to the last element in the system prompt.
// Converts string system prompts to array format if needed.
// This only adds cache_control if NO system element already has it.
func injectSystemCacheControl(payload []byte) []byte {
- system := gjson.GetBytes(payload, "system")
- if !system.Exists() {
+ doc := ojson.NewDoc(payload)
+ if !injectSystemCacheControlDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+func injectSystemCacheControlDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ system := doc.Get("system")
if system.IsArray() {
- count := int(system.Get("#").Int())
- if count == 0 {
- return payload
- }
-
// Check if ANY system element already has cache_control and find the last cacheable element.
hasCacheControlInSystem := false
lastEligibleSystemIndex := -1
- system.ForEach(func(index, item gjson.Result) bool {
+ idx := 0
+ system.ForEach(func(_, item ojson.Result) bool {
if isValidClaudeCacheControl(item.Get("cache_control")) {
hasCacheControlInSystem = true
return false
}
if claudeSystemBlockCanHostCacheControl(item) {
- lastEligibleSystemIndex = int(index.Int())
+ lastEligibleSystemIndex = idx
}
+ idx++
return true
})
if hasCacheControlInSystem || lastEligibleSystemIndex < 0 {
- return payload
+ return false
}
// Add cache_control to the last cacheable system element
lastSystemPath := fmt.Sprintf("system.%d.cache_control", lastEligibleSystemIndex)
- result, err := sjson.SetBytes(payload, lastSystemPath, claudeCodeCacheControl)
- if err != nil {
- log.Warnf("failed to inject cache_control into system array: %v", err)
- return payload
+ if errSet := doc.Set(lastSystemPath, claudeCodeCacheControl); errSet != nil {
+ log.Warnf("failed to inject cache_control into system array: %v", errSet)
+ return false
}
- payload = result
- } else if system.Type == gjson.String {
+ return true
+ }
+ if !system.Exists() {
+ return false
+ }
+ if system.Type == ojson.String {
// Empty/blank strings are not cacheable hosts. claudePayloadHasCacheableSystem
// already treats them as missing so tools can cover the prefix; converting them
// here would create a second, useless breakpoint on whitespace.
if strings.TrimSpace(system.String()) == "" {
- return payload
+ return false
}
// Convert string system prompt to an ordered native text block.
newSystem := "[" + buildTextBlock(system.String(), &claudeCodeCacheControl) + "]"
- result, err := sjson.SetRawBytes(payload, "system", []byte(newSystem))
- if err != nil {
- log.Warnf("failed to inject cache_control into system string: %v", err)
- return payload
+ if errSet := doc.SetRawBytes("system", []byte(newSystem)); errSet != nil {
+ log.Warnf("failed to inject cache_control into system string: %v", errSet)
+ return false
}
- payload = result
+ return true
}
- return payload
+ return false
}
func ensureModelMaxTokens(body []byte, modelID string) []byte {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return body
}
-
- if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() {
+ doc := ojson.NewDoc(body)
+ if !ensureModelMaxTokensDoc(doc, modelID) {
return body
}
+ return doc.Bytes()
+}
+
+func ensureModelMaxTokensDoc(doc *ojson.Doc, modelID string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+
+ if maxTokens := doc.Get("max_tokens"); maxTokens.Exists() {
+ return false
+ }
for _, provider := range registry.GetGlobalRegistry().GetModelProviders(strings.TrimSpace(modelID)) {
if strings.EqualFold(provider, "claude") {
@@ -2719,10 +4285,9 @@ func ensureModelMaxTokens(body []byte, modelID string) []byte {
if info := registry.GetGlobalRegistry().GetModelInfo(strings.TrimSpace(modelID), "claude"); info != nil && info.MaxCompletionTokens > 0 {
maxTokens = info.MaxCompletionTokens
}
- body, _ = sjson.SetBytes(body, "max_tokens", maxTokens)
- return body
+ return doc.Set("max_tokens", maxTokens) == nil
}
}
- return body
+ return false
}
diff --git a/internal/runtime/executor/claude_executor_cloaking_display_test.go b/internal/runtime/executor/claude_executor_cloaking_display_test.go
index 68fcc26c735..35a04d8e4a4 100644
--- a/internal/runtime/executor/claude_executor_cloaking_display_test.go
+++ b/internal/runtime/executor/claude_executor_cloaking_display_test.go
@@ -3,8 +3,7 @@ package executor
import (
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestReconcileClaudeCloakDisplayAfterThinkingOverride(t *testing.T) {
@@ -26,10 +25,12 @@ func TestReconcileClaudeCloakDisplayAfterThinkingOverride(t *testing.T) {
{name: "payload deletion is not refilled", thinking: `{"type":"adaptive"}`, injected: true, touched: true},
} {
t.Run(tc.name, func(t *testing.T) {
- body, _ := sjson.SetBytes([]byte(`{}`), "model", model)
- body, _ = sjson.SetRawBytes(body, "thinking", []byte(tc.thinking))
+ bodyDoc := ojson.NewDoc([]byte(`{}`))
+ _ = bodyDoc.Set("model", model)
+ _ = bodyDoc.SetRawBytes("thinking", []byte(tc.thinking))
+ body := bodyDoc.Bytes()
out := reconcileClaudeCodeFableModelAfterPayload(body, claudeCodeFableState{injectedDisplay: tc.injected}, false, tc.touched, true, false)
- display := gjson.GetBytes(out, "thinking.display")
+ display := ojson.GetBytes(out, "thinking.display")
if display.String() != tc.want || (tc.want == "" && display.Exists()) {
t.Fatalf("display=%s, want %q; body=%s", display.Raw, tc.want, out)
}
@@ -42,7 +43,7 @@ func TestReconcileClaudeCloakDisplayAfterThinkingOverride(t *testing.T) {
func TestReconcileClaudeCloakDisplayRemovesSyntheticDisplayForNonProgressModel(t *testing.T) {
body := []byte(`{"model":"claude-opus-4-8","thinking":{"type":"adaptive","display":"updates"}}`)
out := reconcileClaudeCodeFableModelAfterPayload(body, claudeCodeFableState{injectedDisplay: true}, false, false, true, false)
- if display := gjson.GetBytes(out, "thinking.display"); display.Exists() {
+ if display := ojson.GetBytes(out, "thinking.display"); display.Exists() {
t.Fatalf("synthetic display survived for non-progress model: %s", out)
}
}
diff --git a/internal/runtime/executor/claude_executor_compaction.go b/internal/runtime/executor/claude_executor_compaction.go
index 7afb97138f8..b80b1535b05 100644
--- a/internal/runtime/executor/claude_executor_compaction.go
+++ b/internal/runtime/executor/claude_executor_compaction.go
@@ -7,13 +7,12 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type claudeCompactionSummaryContextKey struct{}
@@ -67,7 +66,7 @@ func (e *ClaudeExecutor) executeClaudeCompaction(ctx context.Context, auth *clip
inputTokens, outputTokens, totalTokens, cachedTokens := claudeCompactionResponsesUsage(summaryResp.Payload)
respPayload := helps.BuildAntigravityCompactionResponse(baseModel, capsule, inputTokens, outputTokens, totalTokens)
- respPayload, _ = sjson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens)
+ respPayload, _ = ojson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens)
return cliproxyexecutor.Response{
Payload: respPayload,
Headers: summaryResp.Headers,
@@ -81,15 +80,15 @@ func (e *ClaudeExecutor) executeClaudeCompactionStream(ctx context.Context, auth
return nil, errSummary
}
// Reuse the capsule and usage already sealed for the non-stream response.
- item := gjson.GetBytes(summaryResp.Payload, "output.0")
+ item := ojson.GetBytesNoCopy(summaryResp.Payload, "output.0")
capsule := item.Get("encrypted_content").String()
if item.Get("type").String() != "compaction" || capsule == "" {
return nil, fmt.Errorf("extract summary: compaction item missing")
}
- inputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens").Int())
- outputTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.output_tokens").Int())
- totalTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.total_tokens").Int())
- cachedTokens := int(gjson.GetBytes(summaryResp.Payload, "usage.input_tokens_details.cached_tokens").Int())
+ inputTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.input_tokens").Int())
+ outputTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.output_tokens").Int())
+ totalTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.total_tokens").Int())
+ cachedTokens := int(ojson.GetBytesNoCopy(summaryResp.Payload, "usage.input_tokens_details.cached_tokens").Int())
chunks := helps.BuildAntigravityCompactionStreamChunks(baseModel, capsule, inputTokens, outputTokens, totalTokens)
for i := range chunks {
chunks[i] = patchClaudeCompactionStreamUsage(chunks[i], inputTokens, outputTokens, totalTokens, cachedTokens)
@@ -126,16 +125,17 @@ func claudeCompactionSummaryFromContext(ctx context.Context) bool {
// prepareClaudeCompactionSummaryPayload keeps tool definitions that Claude needs
// in order to accept tool_use history, while still asking for a text summary.
func prepareClaudeCompactionSummaryPayload(payload []byte, modelName string) []byte {
- tools := gjson.GetBytes(payload, "tools")
- additionalTools := gjson.GetBytes(payload, "additional_tools")
- out := helps.PrepareAntigravityCompactionSummaryPayload(payload, modelName)
+ tools := ojson.GetBytesNoCopy(payload, "tools")
+ additionalTools := ojson.GetBytesNoCopy(payload, "additional_tools")
+ doc := ojson.NewDoc(payload)
+ helps.PrepareAntigravityCompactionSummaryPayloadDoc(doc, modelName)
if tools.Exists() {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
+ _ = doc.SetRaw("tools", tools.Raw)
}
if additionalTools.Exists() {
- out, _ = sjson.SetRawBytes(out, "additional_tools", []byte(additionalTools.Raw))
+ _ = doc.SetRaw("additional_tools", additionalTools.Raw)
}
- return out
+ return doc.Bytes()
}
// finalizeClaudeCompactionSummaryBody runs after built-in translation and before
@@ -143,23 +143,52 @@ func prepareClaudeCompactionSummaryPayload(payload []byte, modelName string) []b
// tool_choice none stops the summary turn from calling a tool. History without
// definitions is flattened so Anthropic does not reject orphan tool blocks.
func finalizeClaudeCompactionSummaryBody(body []byte) []byte {
- if claudeBodyHasToolDefinitions(body) {
- body, _ = sjson.SetRawBytes(body, "tool_choice", []byte(`{"type":"none"}`))
+ doc := ojson.NewDoc(body)
+ if !finalizeClaudeCompactionSummaryBodyDoc(doc) {
return body
}
- return flattenClaudeToolBlocksForCompaction(body)
+ return doc.Bytes()
+}
+
+func finalizeClaudeCompactionSummaryBodyDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ if claudeDocHasToolDefinitions(doc) {
+ return doc.SetRawBytes("tool_choice", []byte(`{"type":"none"}`)) == nil
+ }
+ return flattenClaudeToolBlocksForCompactionDoc(doc)
}
func claudeBodyHasToolDefinitions(body []byte) bool {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
+ return tools.IsArray() && len(tools.Array()) > 0
+}
+
+func claudeDocHasToolDefinitions(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("tools")
return tools.IsArray() && len(tools.Array()) > 0
}
func flattenClaudeToolBlocksForCompaction(body []byte) []byte {
- messages := gjson.GetBytes(body, "messages")
- if !messages.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !flattenClaudeToolBlocksForCompactionDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+func flattenClaudeToolBlocksForCompactionDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ messages := doc.Get("messages")
+ if !messages.IsArray() {
+ return false
+ }
changed := false
rewritten := make([]string, 0, len(messages.Array()))
for _, msg := range messages.Array() {
@@ -174,17 +203,17 @@ func flattenClaudeToolBlocksForCompaction(body []byte) []byte {
switch part.Get("type").String() {
case "tool_use":
text := fmt.Sprintf("Tool call %s (%s): %s", part.Get("name").String(), part.Get("id").String(), part.Get("input").Raw)
- block, _ := sjson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
+ block, _ := ojson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
parts = append(parts, string(block))
msgChanged = true
case "tool_result":
result := part.Get("content")
resultText := result.Raw
- if result.Type == gjson.String {
+ if result.Type == ojson.String {
resultText = result.String()
}
text := fmt.Sprintf("Tool result %s: %s", part.Get("tool_use_id").String(), resultText)
- block, _ := sjson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
+ block, _ := ojson.SetBytes([]byte(`{"type":"text","text":""}`), "text", text)
parts = append(parts, string(block))
msgChanged = true
default:
@@ -196,20 +225,19 @@ func flattenClaudeToolBlocksForCompaction(body []byte) []byte {
continue
}
changed = true
- updated, _ := sjson.SetRawBytes([]byte(msg.Raw), "content", []byte("["+strings.Join(parts, ",")+"]"))
+ updated, _ := ojson.SetRawBytes([]byte(msg.Raw), "content", []byte("["+strings.Join(parts, ",")+"]"))
rewritten = append(rewritten, string(updated))
}
if !changed {
- return body
+ return false
}
- body, _ = sjson.SetRawBytes(body, "messages", []byte("["+strings.Join(rewritten, ",")+"]"))
- return body
+ return doc.SetRawBytes("messages", []byte("["+strings.Join(rewritten, ",")+"]")) == nil
}
// claudeCompactionResponsesUsage matches the Responses translator: input includes
// cache creation and cache read, cached_tokens is cache read, and total is their sum.
func claudeCompactionResponsesUsage(payload []byte) (inputTokens, outputTokens, totalTokens, cachedTokens int) {
- usage := gjson.GetBytes(payload, "usage")
+ usage := ojson.GetBytesNoCopy(payload, "usage")
if !usage.Exists() {
parsed := helps.ParseOpenAIUsage(payload)
return int(parsed.InputTokens), int(parsed.OutputTokens), int(parsed.TotalTokens), 0
@@ -230,23 +258,24 @@ func patchClaudeCompactionStreamUsage(chunk []byte, inputTokens, outputTokens, t
return chunk
}
data := bytes.TrimSpace(chunk[idx+len(dataPrefix):])
+ doc := ojson.NewDoc(data)
path := ""
switch {
- case gjson.GetBytes(data, "response.usage").Exists():
+ case doc.Get("response.usage").Exists():
path = "response.usage"
- case gjson.GetBytes(data, "usage").Exists():
+ case doc.Get("usage").Exists():
path = "usage"
default:
return chunk
}
- data, _ = sjson.SetBytes(data, path+".input_tokens", inputTokens)
- data, _ = sjson.SetBytes(data, path+".output_tokens", outputTokens)
- data, _ = sjson.SetBytes(data, path+".total_tokens", totalTokens)
- data, _ = sjson.SetBytes(data, path+".input_tokens_details.cached_tokens", cachedTokens)
+ _ = doc.Set(path+".input_tokens", inputTokens)
+ _ = doc.Set(path+".output_tokens", outputTokens)
+ _ = doc.Set(path+".total_tokens", totalTokens)
+ _ = doc.Set(path+".input_tokens_details.cached_tokens", cachedTokens)
var buf bytes.Buffer
buf.Write(chunk[:idx])
buf.WriteString(dataPrefix)
- buf.Write(data)
+ buf.Write(doc.Bytes())
buf.WriteString("\n\n")
return buf.Bytes()
}
diff --git a/internal/runtime/executor/claude_executor_compaction_test.go b/internal/runtime/executor/claude_executor_compaction_test.go
index aefd0b44beb..b5b7761e36c 100644
--- a/internal/runtime/executor/claude_executor_compaction_test.go
+++ b/internal/runtime/executor/claude_executor_compaction_test.go
@@ -9,12 +9,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -108,7 +108,7 @@ func TestClaudeExecutor_ReplayedCompactionCapsuleIsExpanded(t *testing.T) {
"base_url": server.URL,
}}
payload := []byte(`{"model":"claude-haiku-4-5-20251001","stream":false,"input":[` +
- `{"type":"compaction","encrypted_content":` + gjson.Get(`{"v":"`+capsule+`"}`, "v").Raw + `},` +
+ `{"type":"compaction","encrypted_content":` + ojson.Get(`{"v":"`+capsule+`"}`, "v").Raw + `},` +
`{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]}`)
_, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
@@ -165,7 +165,7 @@ func TestClaudeExecutor_CompactionTriggerKeepsToolsForToolHistory(t *testing.T)
if !bytes.Contains(seen, []byte(`"type":"tool_use"`)) || !bytes.Contains(seen, []byte(`"type":"tool_result"`)) {
t.Fatalf("tool history missing from summary request: %s", seen)
}
- if got := gjson.GetBytes(seen, "tool_choice.type").String(); got != "none" {
+ if got := ojson.GetBytes(seen, "tool_choice.type").String(); got != "none" {
t.Fatalf("tool_choice.type = %q, want none so the summary cannot call tools: %s", got, seen)
}
}
@@ -242,10 +242,10 @@ func TestClaudeExecutor_CompactionSummaryPayloadRuleIsLast(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(seen, "max_tokens").Int(); got != 1234 {
+ if got := ojson.GetBytes(seen, "max_tokens").Int(); got != 1234 {
t.Fatalf("max_tokens = %d, want payload rule 1234; body=%s", got, seen)
}
- if got := gjson.GetBytes(seen, "tool_choice.type").String(); got != "auto" {
+ if got := ojson.GetBytes(seen, "tool_choice.type").String(); got != "auto" {
t.Fatalf("tool_choice.type = %q, want payload rule auto to win over built-in none; body=%s", got, seen)
}
}
@@ -265,7 +265,7 @@ func TestClaudeExecutor_CompactionTriggerIncludesPreviousCapsule(t *testing.T) {
"base_url": server.URL,
}}
payload := []byte(`{"model":"claude-haiku-4-5-20251001","stream":false,"input":[` +
- `{"type":"compaction","encrypted_content":` + gjson.Get(`{"v":"`+capsule+`"}`, "v").Raw + `},` +
+ `{"type":"compaction","encrypted_content":` + ojson.Get(`{"v":"`+capsule+`"}`, "v").Raw + `},` +
`{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]},` +
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"continued"}]},` +
`{"type":"compaction_trigger"}]}`)
@@ -354,7 +354,7 @@ func TestClaudeExecutor_CompactionUsageMatchesResponsesAccounting(t *testing.T)
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- usage := gjson.GetBytes(resp.Payload, "usage")
+ usage := ojson.GetBytes(resp.Payload, "usage")
if usage.Get("input_tokens").Int() != 114 || usage.Get("output_tokens").Int() != 7 || usage.Get("total_tokens").Int() != 121 || usage.Get("input_tokens_details.cached_tokens").Int() != 100 {
t.Fatalf("usage = %s, want input 114 output 7 total 121 cached 100", usage.Raw)
}
@@ -401,7 +401,7 @@ func claudeCompactionUpstream(t *testing.T, seen *[]byte) http.Handler {
return
}
*seen = body
- if gjson.GetBytes(body, "stream").Bool() {
+ if ojson.GetBytes(body, "stream").Bool() {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(strings.Join([]string{
`event: message_start`,
@@ -427,9 +427,9 @@ func claudeCompactionUpstream(t *testing.T, seen *[]byte) http.Handler {
func assertSingleCompactionOutput(t *testing.T, payload []byte) {
t.Helper()
- output := gjson.GetBytes(payload, "output")
+ output := ojson.GetBytes(payload, "output")
if !output.Exists() {
- output = gjson.GetBytes(payload, "response.output")
+ output = ojson.GetBytes(payload, "response.output")
}
items := output.Array()
if len(items) != 1 || items[0].Get("type").String() != "compaction" {
@@ -442,16 +442,16 @@ func assertSingleCompactionOutput(t *testing.T, payload []byte) {
if plain != claudeCompactionSummaryText {
t.Fatalf("capsule summary = %q, want %q", plain, claudeCompactionSummaryText)
}
- usage := gjson.GetBytes(payload, "usage")
+ usage := ojson.GetBytes(payload, "usage")
if !usage.Exists() {
- usage = gjson.GetBytes(payload, "response.usage")
+ usage = ojson.GetBytes(payload, "response.usage")
}
if usage.Get("input_tokens").Int() != 11 || usage.Get("output_tokens").Int() != 7 || usage.Get("total_tokens").Int() != 18 {
t.Fatalf("usage = %s, want input 11 output 7 total 18", usage.Raw)
}
}
-func compactionOutputTypes(items []gjson.Result) string {
+func compactionOutputTypes(items []ojson.Result) string {
if len(items) == 0 {
return ""
}
@@ -472,7 +472,7 @@ func assertClaudeCompactionSummaryRequest(t *testing.T, body []byte) {
}
}
-func claudeCompactionCompletedResponse(t *testing.T, stream string) gjson.Result {
+func claudeCompactionCompletedResponse(t *testing.T, stream string) ojson.Result {
t.Helper()
for _, frame := range strings.Split(stream, "\n\n") {
if !strings.Contains(frame, "event: response.completed") {
@@ -482,9 +482,9 @@ func claudeCompactionCompletedResponse(t *testing.T, stream string) gjson.Result
if !strings.HasPrefix(line, "data:") {
continue
}
- return gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ return ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
t.Fatalf("response.completed missing from stream: %s", stream)
- return gjson.Result{}
+ return ojson.Result{}
}
diff --git a/internal/runtime/executor/claude_executor_diagnostics.go b/internal/runtime/executor/claude_executor_diagnostics.go
index 87e8943f455..47b05df8ded 100644
--- a/internal/runtime/executor/claude_executor_diagnostics.go
+++ b/internal/runtime/executor/claude_executor_diagnostics.go
@@ -4,11 +4,12 @@ import (
"bytes"
"strings"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type claudeDiagnosticsRequestState struct {
@@ -22,6 +23,29 @@ func injectClaudeDiagnostics(body []byte, auth *cliproxyauth.Auth, sessionID str
return injectClaudeDiagnosticsWithState(body, key, sequence, previousMessageID, promptID)
}
+func injectClaudeDiagnosticsDoc(doc *ojson.Doc, auth *cliproxyauth.Auth, sessionID string) claudeDiagnosticsRequestState {
+ key, sequence, previousMessageID, _, promptID := helps.BeginClaudeContinuity(claudeDiagnosticsCredentialIdentity(auth), sessionID, false, "")
+ return injectClaudeDiagnosticsWithStateDoc(doc, key, sequence, previousMessageID, promptID)
+}
+
+func injectClaudeDiagnosticsWithStateDoc(doc *ojson.Doc, key string, sequence uint64, previousMessageID string, promptIDs ...string) claudeDiagnosticsRequestState {
+ if doc == nil || key == "" {
+ return claudeDiagnosticsRequestState{}
+ }
+ promptID := ""
+ if len(promptIDs) > 0 {
+ promptID = promptIDs[0]
+ }
+ value := `{"previous_message_id":null}`
+ if previousMessageID != "" {
+ value = `{"previous_message_id":` + marshalJSONStringWithoutHTMLEscape(previousMessageID) + `}`
+ }
+ if errSet := doc.SetRawBytes("diagnostics", []byte(value)); errSet != nil {
+ return claudeDiagnosticsRequestState{}
+ }
+ return claudeDiagnosticsRequestState{key: key, sequence: sequence, promptID: promptID}
+}
+
func injectClaudeDiagnosticsWithState(body []byte, key string, sequence uint64, previousMessageID string, promptIDs ...string) ([]byte, claudeDiagnosticsRequestState) {
if key == "" {
return body, claudeDiagnosticsRequestState{}
@@ -35,13 +59,13 @@ func injectClaudeDiagnosticsWithState(body []byte, key string, sequence uint64,
value = `{"previous_message_id":` + marshalJSONStringWithoutHTMLEscape(previousMessageID) + `}`
}
- if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.Exists() {
- updated, errSet := sjson.SetRawBytes(body, "diagnostics", []byte(value))
+ if diagnostics := ojson.GetBytesNoCopy(body, "diagnostics"); diagnostics.Exists() {
+ updated, errSet := ojson.SetRawBytes(body, "diagnostics", []byte(value))
if errSet == nil {
return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence, promptID: promptID}
}
}
- if contextManagement := gjson.GetBytes(body, "context_management"); contextManagement.Exists() {
+ if contextManagement := ojson.GetBytesNoCopy(body, "context_management"); contextManagement.Exists() {
start := contextManagement.Index
insertAt := start + len(contextManagement.Raw)
if start >= 0 && insertAt >= start && insertAt <= len(body) && bytes.Equal(body[start:insertAt], []byte(contextManagement.Raw)) {
@@ -53,7 +77,7 @@ func injectClaudeDiagnosticsWithState(body []byte, key string, sequence uint64,
return updated, claudeDiagnosticsRequestState{key: key, sequence: sequence, promptID: promptID}
}
}
- updated, errSet := sjson.SetRawBytes(body, "diagnostics", []byte(value))
+ updated, errSet := ojson.SetRawBytes(body, "diagnostics", []byte(value))
if errSet != nil {
return body, claudeDiagnosticsRequestState{}
}
@@ -89,22 +113,22 @@ func claudeDiagnosticsCredentialIdentity(auth *cliproxyauth.Auth) string {
}
func claudeMessageIDFromResponse(data []byte) string {
- return strings.TrimSpace(gjson.GetBytes(data, "id").String())
+ return strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(data, "id").String()))
}
func observeClaudeStreamJSONPayload(payload []byte, hasDataPrefix bool, messageID *string, completed *bool) {
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "message_start":
if id := strings.TrimSpace(root.Get("message.id").String()); id != "" {
- *messageID = id
+ *messageID = strings.Clone(id)
}
case "message_stop":
*completed = true
case "message":
if !hasDataPrefix && strings.TrimSpace(root.Get("stop_reason").String()) != "" {
if id := strings.TrimSpace(root.Get("id").String()); id != "" {
- *messageID = id
+ *messageID = strings.Clone(id)
}
*completed = true
}
@@ -125,18 +149,22 @@ func observeClaudeSSEEvent(rawEvent []byte, messageID *string, completed *bool,
}
if len(dataLines) == 0 {
trimmed := bytes.TrimSpace(rawEvent)
- if bytes.HasPrefix(trimmed, []byte("{")) && gjson.ValidBytes(trimmed) {
+ if bytes.HasPrefix(trimmed, []byte("{")) && ojson.ValidBytes(trimmed) {
observeClaudeStreamJSONPayload(trimmed, false, messageID, completed)
}
return
}
joined := bytes.TrimSpace(bytes.Join(dataLines, []byte("\n")))
- if !gjson.ValidBytes(joined) {
+ if !ojson.ValidBytes(joined) {
return
}
observeClaudeStreamJSONPayload(joined, true, messageID, completed)
- if streamErr != nil && *streamErr == nil && gjson.ParseBytes(joined).Get("type").String() == "error" {
- *streamErr = statusErr{code: 502, msg: string(joined)}
+ if streamErr != nil && *streamErr == nil {
+ if util.ParseGJSONBytesNoCopy(joined).Get("type").String() == "error" {
+ *streamErr = statusErr{code: 502, msg: string(joined)}
+ } else if refusalMsg, blocked := clienterror.ClaudePolicyRefusalMessage(joined); blocked {
+ *streamErr = statusErr{code: 400, msg: refusalMsg}
+ }
}
}
diff --git a/internal/runtime/executor/claude_executor_diagnostics_test.go b/internal/runtime/executor/claude_executor_diagnostics_test.go
index b2cea7dbb43..5f3e7cdbfd0 100644
--- a/internal/runtime/executor/claude_executor_diagnostics_test.go
+++ b/internal/runtime/executor/claude_executor_diagnostics_test.go
@@ -9,13 +9,13 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestInjectClaudeDiagnosticsMatchesNativeFieldOrderAndContinuity(t *testing.T) {
@@ -29,19 +29,19 @@ func TestInjectClaudeDiagnosticsMatchesNativeFieldOrderAndContinuity(t *testing.
if !bytes.Contains(first, []byte(wantOrder)) {
t.Fatalf("diagnostics field order differs from native: %s", first)
}
- if got := gjson.GetBytes(first, "diagnostics.previous_message_id"); got.Type != gjson.Null {
+ if got := ojson.GetBytes(first, "diagnostics.previous_message_id"); got.Type != ojson.Null {
t.Fatalf("first previous_message_id = %s, want null", got.Raw)
}
commitClaudeDiagnostics(state, "msg_01ABCDEF0123456789ABCDEFG")
second, _ := injectClaudeDiagnostics(body, auth, "session-diagnostics-order-"+testID)
- if got := gjson.GetBytes(second, "diagnostics.previous_message_id").String(); got != "msg_01ABCDEF0123456789ABCDEFG" {
+ if got := ojson.GetBytes(second, "diagnostics.previous_message_id").String(); got != "msg_01ABCDEF0123456789ABCDEFG" {
t.Fatalf("second previous_message_id = %q, want committed upstream ID", got)
}
}
func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T) {
- var previousValues []gjson.Result
+ var previousValues []ojson.Result
var betaValues []string
call := 0
transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
@@ -49,7 +49,7 @@ func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T)
if errRead != nil {
t.Fatal(errRead)
}
- previousValues = append(previousValues, gjson.GetBytes(body, "diagnostics.previous_message_id"))
+ previousValues = append(previousValues, ojson.GetBytes(body, "diagnostics.previous_message_id"))
betas := req.Header.Get("Anthropic-Beta")
if betas == "" {
betas = strings.Join(req.Header["anthropic-beta"], ",")
@@ -84,7 +84,7 @@ func TestClaudeExecutorDiagnosticsAdvancesAfterSuccessfulResponse(t *testing.T)
auth.Attributes["api_key"] = "sk-ant-oat-diagnostics-live-path-rotated"
}
}
- if len(previousValues) != 2 || previousValues[0].Type != gjson.Null || previousValues[0].Raw != "null" {
+ if len(previousValues) != 2 || previousValues[0].Type != ojson.Null || previousValues[0].Raw != "null" {
t.Fatalf("first diagnostics value = %#v, want explicit null", previousValues)
}
if got := previousValues[1].String(); got != "msg_diagnostics_1" {
@@ -119,7 +119,7 @@ func TestClaudeExecutorContinuityAdvancesRequestIDAndPromptIDInBillingHeader(t *
if errRead != nil {
t.Fatal(errRead)
}
- billing := gjson.GetBytes(body, "system.0.text").String()
+ billing := ojson.GetBytes(body, "system.0.text").String()
capturedBillingHeaders = append(capturedBillingHeaders, billing)
call++
response := `{"id":"msg_turn_` + string(rune('0'+call)) + `","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`
diff --git a/internal/runtime/executor/claude_executor_execute.go b/internal/runtime/executor/claude_executor_execute.go
index f7cc78b59bc..51c978f66bd 100644
--- a/internal/runtime/executor/claude_executor_execute.go
+++ b/internal/runtime/executor/claude_executor_execute.go
@@ -7,13 +7,14 @@ import (
"io"
"net/http"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
@@ -80,37 +81,39 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
ctx = helps.WithClaudeSessionID(ctx, claudeSessionID)
}
+ webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
+ ctx = helps.WithClaudeWebSearchResultRestore(ctx, webSearchSessionKey, webSearchTTL)
+
isCompat := helps.APIKeyModelIsCompat(req)
originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, upstreamStream, isCompat)
if err != nil {
return resp, err
}
- body = helps.SetStringIfDifferent(body, "model", upstreamModel)
- body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body)
+ doc := ojson.NewDoc(body)
+ helps.SetStringIfDifferentDoc(doc, "model", upstreamModel)
+ helps.NormalizeClaudeToolChoiceDoc(e.upstreamRequestLogProvider(), upstreamModel, doc)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return resp, err
}
- webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
- body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
+ helps.RestoreClaudeWebSearchResultsContextDoc(ctx, doc, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
- body = rebuildMidSystemMessagesToTopLevel(body)
+ rebuildMidSystemMessagesToTopLevelDoc(doc)
}
// Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation)
// based on client type and configuration.
- isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body)
- explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body)
+ isProbeOrHelper := isClaudeProbeOrHelperRequestDoc(doc)
+ explicitCacheMode := isExplicitPromptCacheModeDoc(doc, originalPayload, req.Payload)
if explicitCacheMode {
ctx = withExplicitPromptCacheMode(ctx, true)
}
var cloaked bool
- body, cloaked, err = applyCloakingInternal(
+ cloaked, _, err = applyCloakingInternalDoc(
ctx,
e.cfg,
auth,
- body,
+ doc,
apiKey,
confirmedClaudeCode,
cchSigning,
@@ -124,7 +127,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
// keeps its own shape and other gateways never see this field.
diagnosticsState := claudeDiagnosticsRequestState{}
if !isProbeOrHelper {
- isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body)
+ isProbeOrHelper = isClaudeProbeOrHelperRequestDoc(doc)
}
if continuityCtx.Initialized {
diagnosticsState = claudeDiagnosticsRequestState{
@@ -135,25 +138,25 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
}
contextManagementState := claudeCodeContextManagementState{
eligible: cloaked && isAnthropicUpstreamBase(baseURL),
- callerOwned: gjson.GetBytes(body, "context_management").Exists(),
+ callerOwned: doc.Get("context_management").Exists(),
}
if contextManagementState.eligible {
- body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body)
+ contextManagementState.automaticallyInjected = injectClaudeCodeContextManagementDoc(doc)
if fp.InjectDiagnostics && !isProbeOrHelper {
if continuityCtx.Initialized {
- body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID)
+ diagnosticsState = injectClaudeDiagnosticsWithStateDoc(doc, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID)
} else {
- body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID)
+ diagnosticsState = injectClaudeDiagnosticsDoc(doc, auth, claudeSessionID)
}
}
}
- body = ensureModelMaxTokens(body, baseModel)
+ ensureModelMaxTokensDoc(doc, baseModel)
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
- body = disableThinkingIfToolChoiceForced(body)
- body = reconcileClaudeCodeContextManagement(body, contextManagementState)
- body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode)
+ disableThinkingIfToolChoiceForcedDoc(doc)
+ reconcileClaudeCodeContextManagementDoc(doc, contextManagementState)
+ normalizeClaudeSamplingForUpstreamDoc(doc, confirmedClaudeCode)
// Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other
// non-native callers. Confirmed native Claude Code owns its marker placement and must
@@ -161,16 +164,16 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
// first-user marker cannot suppress system/latest-user breakpoints.
// cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy
// forces Cloak off for a confirmed native client.
- cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode, originalPayload, req.Payload)
+ cpaOwnsCacheControl := shouldEnsureCacheControlDoc(doc, cloaked, confirmedClaudeCode, originalPayload, req.Payload)
if cpaOwnsCacheControl {
- body = ensureCacheControl(body)
+ ensureCacheControlDoc(doc)
}
// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).
// Cloaking and ensureCacheControl may push the total over 4 when the client
// already sends multiple cache_control blocks.
- body = enforceCacheControlLimit(body, 4)
- body = stripPromptCacheOptions(body)
+ enforceCacheControlLimitDoc(doc, 4)
+ stripPromptCacheOptionsDoc(doc)
// Native selects the 1h cache pool only for OAuth credentials and pairs it with
// extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the
@@ -185,77 +188,77 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
// claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool.
// In native Claude Code, subagents default to 5m unless 1h is explicitly configured;
// probes omit both 1h cache and extended-cache-ttl.
- isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body)
- subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body)
+ isSubagent := isClaudeSubagentRequestDoc(incomingHeaders, doc)
+ subagent1h := isSubagent && claudeSubagentRequests1hDoc(incomingHeaders, doc)
if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper {
- body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h)
+ upgradeClaudeCacheControlTTLDoc(doc, claudeCacheControlTTL1h)
} else if (isProbeOrHelper || (isSubagent && !subagent1h)) && !explicitCacheMode {
- body = stripClaudeCacheControlTTL(body)
+ stripClaudeCacheControlTTLDoc(doc)
}
// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.
// A 1h-TTL block must not appear after a 5m-TTL block in evaluation order (tools→system→messages).
if !explicitCacheMode {
- body = normalizeCacheControlTTL(body)
+ normalizeCacheControlTTLDoc(doc)
}
// Set the built-in transport default before the explicit payload barrier.
// Native non-stream Haiku helper requests omit stream rather than sending
// false, so preserve that measured wire shape when the transport agrees.
- streamField := gjson.GetBytes(body, "stream")
+ streamField := doc.Get("stream")
if !claudeCodeDetection.HelperProfile || streamField.Exists() || upstreamStream {
- body = helps.SetBoolIfDifferent(body, "stream", upstreamStream)
+ helps.SetBoolIfDifferentDoc(doc, "stream", upstreamStream)
}
// Extract betas from body and convert to header
var extraBetas []string
- bodyForTranslation := body
- bodyForUpstream := body
var oauthToolNamesReverseMap map[string]string
if fp.MCPAlias && cloaked {
mcpAliases := resolveClaudeMCPAliasOptions(ctx, opts.Metadata, req.Metadata)
- bodyForUpstream, oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequest(bodyForUpstream, mcpAliases)
+ oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequestDoc(doc, mcpAliases)
if err != nil {
return resp, err
}
}
- bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req))
+ sanitizeClaudeMessagesForClaudeUpstreamWithDebugDoc(ctx, doc, baseModel, helps.APIKeyModelIsCompat(req))
if fp.ApplyCLIIdentity {
- bodyForUpstream, err = applyClaudeCLIIdentity(bodyForUpstream, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity)
+ err = applyClaudeCLIIdentityDoc(doc, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity)
if err != nil {
return resp, err
}
}
if cloaked && len(wireSettings.sensitiveWords) > 0 {
matcher := helps.BuildSensitiveWordMatcher(wireSettings.sensitiveWords)
- bodyForUpstream = helps.ObfuscateSensitiveWords(bodyForUpstream, matcher)
+ helps.ObfuscateSensitiveWordsDoc(doc, matcher)
}
cchBilling := ""
if cchSigning {
- if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) {
- cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint)
+ if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallbackDoc(doc) {
+ cchBilling = claudeCCHFallbackBillingHeaderDoc(ctx, e.cfg, doc, claudeCodeDetection.Entrypoint)
}
- bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling)
+ err = ensureClaudeBillingHeaderCCHPlaceholderDoc(doc, cchBilling)
if err != nil {
return resp, fmt.Errorf("finalize Claude CCH: %w", err)
}
}
- bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream)
+ stripDefaultKimiClaudeCodeAttributionDoc(auth, url, fp.ProfileClaudeCodeCLI, doc)
if claudeCompactionSummaryFromContext(ctx) {
- bodyForUpstream = finalizeClaudeCompactionSummaryBody(bodyForUpstream)
+ finalizeClaudeCompactionSummaryBodyDoc(doc)
}
- bodyForUpstream = applyClaudePreservedThinkingBinding(bodyForUpstream)
+ applyClaudePreservedThinkingBindingDoc(doc)
// User rules match the fully prepared business body and are applied only once.
- var touchedPayloadPaths map[string]bool
- bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths(
- e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated),
+ touchedPayloadPaths := helps.ApplyPayloadConfigWithTrackedPathsDoc(
+ e.cfg, baseModel, to.String(), from.String(), "", doc, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated),
helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers,
"diagnostics",
)
if touchedPayloadPaths["diagnostics"] {
diagnosticsState = claudeDiagnosticsRequestState{}
}
- extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream)
- bodyForUpstream = stripPromptCacheOptions(bodyForUpstream)
+ extraBetas, _ = extractAndRemoveBetasDoc(doc)
+ stripPromptCacheOptionsDoc(doc)
+ bodyForUpstream := doc.Bytes()
+ body = bodyForUpstream
+ bodyForTranslation := bodyForUpstream
if cchSigning {
bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream)
if err != nil {
@@ -371,10 +374,14 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
}
lines := bytes.Split(data, []byte("\n"))
var eventBuf bytes.Buffer
+ var upstreamMessageID string
+ var upstreamStreamErr error
+ upstreamCompleted := false
for i, line := range lines {
eventBuf.Write(line)
eventBuf.WriteByte('\n')
if len(bytes.TrimSpace(line)) == 0 {
+ observeClaudeSSEEvent(eventBuf.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr)
streamUsage.ObserveClaudeSSEEvent(eventBuf.Bytes())
eventBuf.Reset()
}
@@ -390,9 +397,16 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
lines[i] = restoredLine
}
if eventBuf.Len() > 0 {
+ observeClaudeSSEEvent(eventBuf.Bytes(), &upstreamMessageID, &upstreamCompleted, &upstreamStreamErr)
streamUsage.ObserveClaudeSSEEvent(eventBuf.Bytes())
eventBuf.Reset()
}
+ if upstreamStreamErr != nil {
+ upstreamStreamErr = wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, upstreamStreamErr)
+ helps.RecordAPIResponseError(ctx, e.cfg, upstreamStreamErr)
+ streamUsage.PublishFailure(ctx, reporter, upstreamStreamErr)
+ return resp, upstreamStreamErr
+ }
data = bytes.Join(lines, []byte("\n"))
} else {
messageID := claudeMessageIDFromResponse(data)
@@ -406,6 +420,12 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
helps.RecordAPIResponseError(ctx, e.cfg, errRestore)
return resp, wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, errRestore)
}
+ if refusalMsg, blocked := clienterror.ClaudePolicyRefusalMessage(data); blocked {
+ errRefusal := wrapClaudeFastRequestError(fastRequest, httpResp.StatusCode, statusErr{code: http.StatusBadRequest, msg: refusalMsg})
+ helps.RecordAPIResponseError(ctx, e.cfg, errRefusal)
+ reporter.PublishFailureWithDetail(ctx, helps.ParseClaudeUsage(data), errRefusal)
+ return resp, errRefusal
+ }
}
data = e.restoreResponseModel(data, req.Model)
helps.RecordClaudeWebSearchResultsContext(ctx, webSearchSessionKey, data, webSearchTTL, webSearchLimit)
diff --git a/internal/runtime/executor/claude_executor_fable_ratelimit_test.go b/internal/runtime/executor/claude_executor_fable_ratelimit_test.go
index 41484f19326..5a7323e8580 100644
--- a/internal/runtime/executor/claude_executor_fable_ratelimit_test.go
+++ b/internal/runtime/executor/claude_executor_fable_ratelimit_test.go
@@ -798,3 +798,118 @@ func TestClaudeExecutor_AuthManager_OverageSpendCapFirstRequestAllowsOpus_Issue5
t.Fatalf("Opus upstream attempts = %d, want 1", got)
}
}
+
+func TestClaudeExecutor_AuthManager_FableOnlyRejectionWithout5hHeadersAllowsOpus_Issue7197(t *testing.T) {
+ var fableAttempts, opusAttempts atomic.Int32
+ weeklyReset := strconv.FormatInt(time.Now().Add(6*24*time.Hour).Unix(), 10)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ http.Error(w, "failed to read sanitized test request", http.StatusBadRequest)
+ return
+ }
+ switch {
+ case strings.Contains(string(body), `"model":"claude-fable-5-1"`):
+ fableAttempts.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Retry-After", "546534")
+ w.Header().Set("X-Should-Retry", "true")
+ w.Header().Set("Anthropic-Ratelimit-Unified-Status", "rejected")
+ w.Header().Set("Anthropic-Ratelimit-Unified-Representative-Claim", "seven_day_overage_included")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d-Utilization", "0.62")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d-Reset", weeklyReset)
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Status", "rejected")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Utilization", "1.0")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Surpassed-Threshold", "1.0")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d_oi-Reset", weeklyReset)
+ w.Header().Set("Anthropic-Ratelimit-Unified-Overage-Status", "rejected")
+ w.Header().Set("Anthropic-Ratelimit-Unified-Overage-Disabled-Reason", "out_of_credits")
+ w.Header().Set("Anthropic-Ratelimit-Unified-Fallback-Percentage", "0.5")
+ w.Header().Set("Anthropic-Ratelimit-Unified-Reset", weeklyReset)
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`{"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."}}`))
+ case strings.Contains(string(body), `"model":"claude-opus-5-5"`):
+ opusAttempts.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Anthropic-Ratelimit-Unified-Status", "allowed")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d-Status", "allowed")
+ w.Header().Set("Anthropic-Ratelimit-Unified-7d-Utilization", "0.62")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"id":"msg-opus-ok","type":"message","model":"claude-opus-5-5","role":"assistant","content":[{"type":"text","text":"ok"}]}`))
+ default:
+ http.Error(w, "unexpected sanitized test model", http.StatusBadRequest)
+ }
+ }))
+ defer server.Close()
+
+ manager := cliproxyauth.NewManager(nil, nil, nil)
+ manager.SetRetryConfig(0, 0, 0)
+ manager.RegisterExecutor(NewClaudeExecutor(&config.Config{DisableCooling: false}))
+
+ auth := &cliproxyauth.Auth{
+ ID: uuid.NewString() + "-issue-7197-no-5h",
+ Provider: "claude",
+ Attributes: map[string]string{
+ "api_key": "sanitized-test-key",
+ "base_url": server.URL,
+ },
+ }
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(auth.ID, "claude", []*registry.ModelInfo{{ID: "claude-fable-5-1"}, {ID: "claude-opus-5-5"}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ payloadFable := []byte(`{"model":"claude-fable-5-1","messages":[{"role":"user","content":[{"type":"text","text":"test"}]}]}`)
+ _, errFable := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
+ Model: "claude-fable-5-1",
+ Payload: payloadFable,
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
+ if errFable == nil {
+ t.Fatal("expected Fable request to be rate limited")
+ }
+ if got := fableAttempts.Load(); got != 1 {
+ t.Fatalf("Fable upstream attempts = %d, want 1", got)
+ }
+
+ updatedAuth, ok := manager.GetByID(auth.ID)
+ if !ok || updatedAuth == nil {
+ t.Fatal("auth not found")
+ }
+ if updatedAuth.Quota.Reason == "credential_quota" {
+ t.Fatalf("auth Quota.Reason = credential_quota; want non-credential_quota")
+ }
+
+ fableState := updatedAuth.ModelStates["claude-fable-5-1"]
+ if fableState == nil {
+ t.Fatal("fable model state not found")
+ }
+ if fableState.Quota.NextRecoverAt.IsZero() || !fableState.Quota.NextRecoverAt.After(time.Now()) {
+ t.Fatal("fable model cooldown should be set in the future via exponential backoff")
+ }
+ if fableState.Quota.NextRecoverAt.After(time.Now().Add(10 * time.Minute)) {
+ t.Fatalf("fable model cooldown was %v; want exponential backoff (<10m), not ~6d weekly reset", fableState.Quota.NextRecoverAt)
+ }
+
+ payloadOpus := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":[{"type":"text","text":"test"}]}]}`)
+ _, errOpus := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{
+ Model: "claude-opus-5-5",
+ Payload: payloadOpus,
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude})
+ if errOpus != nil {
+ t.Fatalf("expected Opus to succeed on the same credential, got: %v", errOpus)
+ }
+ if got := opusAttempts.Load(); got != 1 {
+ t.Fatalf("Opus upstream attempts = %d, want 1", got)
+ }
+
+ afterOpusAuth, ok := manager.GetByID(auth.ID)
+ if !ok || afterOpusAuth == nil {
+ t.Fatal("auth not found after Opus request")
+ }
+ if afterOpusAuth.Unavailable {
+ t.Fatalf("auth remained unavailable after Opus succeeded; want unavailable = false")
+ }
+}
diff --git a/internal/runtime/executor/claude_executor_fast_error.go b/internal/runtime/executor/claude_executor_fast_error.go
index 53a68556954..a37feea8e09 100644
--- a/internal/runtime/executor/claude_executor_fast_error.go
+++ b/internal/runtime/executor/claude_executor_fast_error.go
@@ -38,7 +38,16 @@ func (e *claudeFastRequestError) Unwrap() error {
}
func (e *claudeFastRequestError) StatusCode() int {
- if e == nil || (e.status >= http.StatusOK && e.status < http.StatusMultipleChoices) {
+ if e == nil {
+ return 0
+ }
+ if e.status <= 0 || (e.status >= http.StatusOK && e.status < http.StatusMultipleChoices) {
+ var statusCoder interface{ StatusCode() int }
+ if errors.As(e.cause, &statusCoder) && statusCoder != nil {
+ if code := statusCoder.StatusCode(); code > 0 {
+ return code
+ }
+ }
return 0
}
return e.status
diff --git a/internal/runtime/executor/claude_executor_native_helper_test.go b/internal/runtime/executor/claude_executor_native_helper_test.go
index 5d9aef5a1d7..da7893617c1 100644
--- a/internal/runtime/executor/claude_executor_native_helper_test.go
+++ b/internal/runtime/executor/claude_executor_native_helper_test.go
@@ -10,13 +10,13 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -135,14 +135,14 @@ func TestClaudeExecutorMinimalNativeHelperPreservesMarkerlessWire(t *testing.T)
}
for _, path := range []string{"system", "stream", "context_management", "output_config"} {
- if got := gjson.GetBytes(upstreamBody, path); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, path); got.Exists() {
t.Fatalf("helper body unexpectedly contains %s=%s: %s", path, got.Raw, upstreamBody)
}
}
if bytes.Contains(upstreamBody, []byte(`"cache_control"`)) {
t.Fatalf("helper body unexpectedly contains cache_control: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "messages.0.content").String(); got != "helper probe" {
+ if got := ojson.GetBytes(upstreamBody, "messages.0.content").String(); got != "helper probe" {
t.Fatalf("messages.0.content = %q, want preserved string", got)
}
if !bytes.HasPrefix(upstreamBody, []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":1,"messages":`)) {
@@ -183,24 +183,24 @@ func TestClaudeExecutorStructuredNativeHelperPreservesStreamProfile(t *testing.T
}
}
- if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 3 {
+ if got := ojson.GetBytes(upstreamBody, "system.#").Int(); got != 3 {
t.Fatalf("system block count = %d, want native 3: %s", got, upstreamBody)
}
if !bytes.HasPrefix(upstreamBody, []byte(`{"model":"claude-haiku-4-5-20251001","messages":`)) {
t.Fatalf("structured helper top-level order changed: %s", upstreamBody)
}
for _, path := range []string{"context_management", "output_config.effort"} {
- if got := gjson.GetBytes(upstreamBody, path); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, path); got.Exists() {
t.Fatalf("structured helper unexpectedly contains %s=%s: %s", path, got.Raw, upstreamBody)
}
}
if bytes.Contains(upstreamBody, []byte(`"cache_control"`)) {
t.Fatalf("structured helper unexpectedly contains cache_control: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "stream").Bool(); !got {
+ if got := ojson.GetBytes(upstreamBody, "stream").Bool(); !got {
t.Fatalf("structured helper stream = false, want true: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "system.0.text").String(); strings.Contains(got, "cch=00000") || !strings.Contains(got, " cch=") {
+ if got := ojson.GetBytes(upstreamBody, "system.0.text").String(); strings.Contains(got, "cch=00000") || !strings.Contains(got, " cch=") {
t.Fatalf("structured helper billing CCH was not re-signed: %q", got)
}
assertClaudeNativeHelperHeaders(t, upstreamHeaders, headers)
diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go
index b0541229c90..425010e677b 100644
--- a/internal/runtime/executor/claude_executor_request.go
+++ b/internal/runtime/executor/claude_executor_request.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/buildinfo"
@@ -22,8 +23,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"github.com/gin-gonic/gin"
)
@@ -195,7 +194,7 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool)
if claudeIncludePerTurnTiming(body, requested) {
betas = append(betas, claudePerTurnTimingBeta)
}
- if !isClaudeSonnet5Model(gjson.GetBytes(body, "model").String()) {
+ if !isClaudeSonnet5Model(ojson.GetBytesNoCopy(body, "model").String()) {
betas = append(betas, claudeMidConvToolChangesBeta)
}
if claudeIncludeInlineTools(body, requested) {
@@ -220,19 +219,19 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool)
if !claudeUsesLegacySystemReminder(body) && claudeIncludeMidConvClearAt(body, requested) {
betas = append(betas, claudeMidConvSystemClearAtBeta)
}
- if requested[claudeDangerousToolUseBeta] || gjson.GetBytes(body, "safeguards").Exists() {
+ if requested[claudeDangerousToolUseBeta] || ojson.GetBytesNoCopy(body, "safeguards").Exists() {
betas = append(betas, claudeDangerousToolUseBeta)
}
if claudeRequestSupportsEffort(body, requested) {
betas = append(betas, claudeEffortBeta)
}
isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body)
- if !isProbeOrHelper && (requested[claudeServerSideFallbackBeta] || gjson.GetBytes(body, "fallbacks").Exists()) {
+ if !isProbeOrHelper && (requested[claudeServerSideFallbackBeta] || ojson.GetBytesNoCopy(body, "fallbacks").Exists()) {
betas = append(betas, claudeServerSideFallbackBeta)
}
shouldIncludeFallbackCredit := requested[claudeFallbackCreditBeta] ||
- gjson.GetBytes(body, "fallback_credit_token").Exists() ||
- (oauthToken && gjson.GetBytes(body, "fallbacks").Exists())
+ ojson.GetBytesNoCopy(body, "fallback_credit_token").Exists() ||
+ (oauthToken && ojson.GetBytesNoCopy(body, "fallbacks").Exists())
if shouldIncludeFallbackCredit {
betas = append(betas, claudeFallbackCreditBeta)
}
@@ -244,9 +243,9 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool)
betas = append(betas, beta)
}
}
- thinkingType := gjson.GetBytes(body, "thinking.type").String()
- if requested[claudeThinkingBindingBeta] || gjson.GetBytes(body, "thinking.block_binding").Exists() ||
- (claudeModelUsesProgressDisplay(gjson.GetBytes(body, "model").String()) && thinkingType == "adaptive") {
+ thinkingType := ojson.GetBytesNoCopy(body, "thinking.type").String()
+ if requested[claudeThinkingBindingBeta] || ojson.GetBytesNoCopy(body, "thinking.block_binding").Exists() ||
+ (claudeModelUsesProgressDisplay(ojson.GetBytesNoCopy(body, "model").String()) && thinkingType == "adaptive") {
betas = append(betas, claudeThinkingBindingBeta)
}
if !isProbeOrHelper && thinkingType != "disabled" && (requested[claudeThinkingDisplayUpdatesBeta] || claudeThinkingDisplayUpdates(body)) {
@@ -272,7 +271,7 @@ func claudeCodeCLIBetas(body []byte, requested map[string]bool, oauthToken bool)
if requested[claudePromptCachingEvictBeta] || bytes.Contains(body, []byte(`"evict_on_complete"`)) {
betas = append(betas, claudePromptCachingEvictBeta)
}
- if diagnostics := gjson.GetBytes(body, "diagnostics"); diagnostics.IsObject() {
+ if diagnostics := ojson.GetBytesNoCopy(body, "diagnostics"); diagnostics.IsObject() {
betas = append(betas, claudeCacheDiagnosisBeta)
}
return strings.Join(betas, ",")
@@ -360,9 +359,20 @@ func claudeThinkingTypeAcceptsBlockBinding(model, thinkingType string) bool {
// carries a replayed thinking block with a signature or a redacted_thinking
// block, i.e. history Anthropic subjects to prefix binding validation.
func claudeBodyHasSignedThinkingBlock(body []byte) bool {
+ return claudeMessagesHaveSignedThinkingBlock(ojson.GetBytesNoCopy(body, "messages"))
+}
+
+func claudeDocHasSignedThinkingBlock(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ return claudeMessagesHaveSignedThinkingBlock(doc.Get("messages"))
+}
+
+func claudeMessagesHaveSignedThinkingBlock(messages ojson.Result) bool {
found := false
- gjson.GetBytes(body, "messages").ForEach(func(_, msg gjson.Result) bool {
- msg.Get("content").ForEach(func(_, block gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
+ msg.Get("content").ForEach(func(_, block ojson.Result) bool {
switch strings.TrimSpace(block.Get("type").String()) {
case "thinking":
found = strings.TrimSpace(block.Get("signature").String()) != ""
@@ -389,24 +399,37 @@ func claudeBodyHasSignedThinkingBlock(body []byte) bool {
// user payload rules, which stay the final semantic barrier and may override or
// filter the injected value.
func applyClaudePreservedThinkingBinding(body []byte) []byte {
- model := gjson.GetBytes(body, "model").String()
- if len(body) == 0 || !isClaudePreservedThinkingModel(model) || claudeToolChoiceForcesToolUse(body) {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !applyClaudePreservedThinkingBindingDoc(doc) {
return body
}
- thinking := gjson.GetBytes(body, "thinking")
+ return doc.Bytes()
+}
+
+func applyClaudePreservedThinkingBindingDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ model := doc.Get("model").String()
+ if !isClaudePreservedThinkingModel(model) || claudeToolChoiceForcesToolUseDoc(doc) {
+ return false
+ }
+ thinking := doc.Get("thinking")
if thinking.Exists() {
if !claudeThinkingTypeAcceptsBlockBinding(model, thinking.Get("type").String()) || thinking.Get("block_binding.prefix_mismatch_behavior").Exists() {
- return body
+ return false
}
}
- if !claudeBodyHasSignedThinkingBlock(body) {
- return body
+ if !claudeDocHasSignedThinkingBlock(doc) {
+ return false
}
if !thinking.Exists() {
- body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
+ _ = doc.Set("thinking.type", "adaptive")
}
- body, _ = sjson.SetBytes(body, "thinking.block_binding.prefix_mismatch_behavior", "drop_block")
- return body
+ return doc.Set("thinking.block_binding.prefix_mismatch_behavior", "drop_block") == nil
}
// claudeModelUsesProgressDisplay reports the 2.1.280 interactive CLI models that
@@ -419,19 +442,29 @@ func claudeModelUsesProgressDisplay(model string) bool {
// translated caller did not choose one. An explicit summarized or omitted value
// from Responses, Chat, or Gemini stays untouched.
func applyClaudeCloakThinkingDisplay(body []byte, callerOwned bool) []byte {
- if callerOwned || len(body) == 0 || gjson.GetBytes(body, "thinking.display").Exists() {
+ if callerOwned || len(body) == 0 {
return body
}
- if !claudeModelUsesProgressDisplay(gjson.GetBytes(body, "model").String()) {
+ doc := ojson.NewDoc(body)
+ if !applyClaudeCloakThinkingDisplayDoc(doc, callerOwned) {
return body
}
- switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) {
+ return doc.Bytes()
+}
+
+func applyClaudeCloakThinkingDisplayDoc(doc *ojson.Doc, callerOwned bool) bool {
+ if callerOwned || doc == nil || doc.Len() == 0 || doc.Get("thinking.display").Exists() {
+ return false
+ }
+ if !claudeModelUsesProgressDisplay(doc.Get("model").String()) {
+ return false
+ }
+ switch strings.ToLower(strings.TrimSpace(doc.Get("thinking.type").String())) {
case "adaptive", "enabled":
default:
- return body
+ return false
}
- body, _ = sjson.SetBytes(body, "thinking.display", "updates")
- return body
+ return doc.Set("thinking.display", "updates") == nil
}
func claudeModelHasPerTurnEffort(model string) bool {
@@ -450,21 +483,21 @@ func claudeIncludePerTurnControl(body []byte, requested map[string]bool) bool {
if requested[claudePerTurnControlBeta] {
return true
}
- return claudeModelHasPerTurnEffort(gjson.GetBytes(body, "model").String())
+ return claudeModelHasPerTurnEffort(ojson.GetBytesNoCopy(body, "model").String())
}
func claudeIncludePerTurnTiming(body []byte, requested map[string]bool) bool {
if requested[claudePerTurnTimingBeta] {
return true
}
- if !claudeModelHasPerTurnTiming(gjson.GetBytes(body, "model").String()) {
+ if !claudeModelHasPerTurnTiming(ojson.GetBytesNoCopy(body, "model").String()) {
return false
}
- if gjson.GetBytes(body, "output_config.timing").Exists() {
+ if ojson.GetBytesNoCopy(body, "output_config.timing").Exists() {
return true
}
found := false
- gjson.GetBytes(body, "messages").ForEach(func(_, msg gjson.Result) bool {
+ ojson.GetBytesNoCopy(body, "messages").ForEach(func(_, msg ojson.Result) bool {
if msg.Get("output_config.timing").Exists() {
found = true
return false
@@ -479,8 +512,8 @@ func claudeIncludeInlineTools(body []byte, requested map[string]bool) bool {
return true
}
found := false
- gjson.GetBytes(body, "messages").ForEach(func(_, msg gjson.Result) bool {
- msg.Get("content").ForEach(func(_, block gjson.Result) bool {
+ ojson.GetBytesNoCopy(body, "messages").ForEach(func(_, msg ojson.Result) bool {
+ msg.Get("content").ForEach(func(_, block ojson.Result) bool {
if strings.EqualFold(strings.TrimSpace(block.Get("type").String()), "tool_addition") && block.Get("tool.definition").Exists() {
found = true
return false
@@ -493,11 +526,11 @@ func claudeIncludeInlineTools(body []byte, requested map[string]bool) bool {
}
func claudeIncludeMidConvClearAt(body []byte, requested map[string]bool) bool {
- if requested[claudeMidConvSystemClearAtBeta] || claudeModelUsesProgressDisplay(gjson.GetBytes(body, "model").String()) {
+ if requested[claudeMidConvSystemClearAtBeta] || claudeModelUsesProgressDisplay(ojson.GetBytesNoCopy(body, "model").String()) {
return true
}
found := false
- gjson.GetBytes(body, "messages").ForEach(func(_, msg gjson.Result) bool {
+ ojson.GetBytesNoCopy(body, "messages").ForEach(func(_, msg ojson.Result) bool {
if msg.Get("clear_at").Exists() {
found = true
return false
@@ -512,11 +545,11 @@ func claudeRequestSupportsEffort(body []byte, requested map[string]bool) bool {
if helps.IsClaudeProbeOrHelperRequest(body) {
return false
}
- model := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String()))
+ model := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "model").String()))
if isClaudeHaikuModel(model) {
return false
}
- thinkingType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()))
+ thinkingType := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "thinking.type").String()))
if thinkingType == "disabled" {
return false
}
@@ -528,8 +561,8 @@ func claudeRequestSupportsEffort(body []byte, requested map[string]bool) bool {
}
func claudeThinkingDisplayUpdates(body []byte) bool {
- display := gjson.GetBytes(body, "thinking.display")
- return display.Type == gjson.String && strings.EqualFold(strings.TrimSpace(display.String()), "updates")
+ display := ojson.GetBytesNoCopy(body, "thinking.display")
+ return display.Type == ojson.String && strings.EqualFold(strings.TrimSpace(display.String()), "updates")
}
// claudeBodyUsesAdvancedToolUse reports whether the request needs
@@ -541,7 +574,7 @@ func claudeThinkingDisplayUpdates(body []byte) bool {
// the same beta and are just as visible in the body, so callers using them keep
// working without requesting the beta explicitly.
func claudeBodyUsesAdvancedToolUse(body []byte) bool {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.IsArray() {
return false
}
@@ -560,7 +593,7 @@ func claudeBodyUsesAdvancedToolUse(body []byte) bool {
// claudeBodyHasAdvisorTool reports whether the request body declares an
// advisor server tool.
func claudeBodyHasAdvisorTool(body []byte) bool {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.IsArray() {
return false
}
@@ -584,8 +617,8 @@ func claudeBodyHasAdvisorTool(body []byte) bool {
// absent, and a native 2.1.220 CLI run with showThinkingSummaries enabled sends
// display=summarized without the beta.
func claudeThinkingDisplaySet(body []byte) bool {
- display := gjson.GetBytes(body, "thinking.display")
- return display.Type == gjson.String && strings.TrimSpace(display.String()) != ""
+ display := ojson.GetBytesNoCopy(body, "thinking.display")
+ return display.Type == ojson.String && strings.TrimSpace(display.String()) != ""
}
// claudeRequestUsesFastMode reports whether the request selects the fast service
@@ -598,8 +631,8 @@ func claudeRequestUsesFastMode(body []byte, requested map[string]bool) bool {
if requested[claudeFastModeBeta] {
return true
}
- speed := gjson.GetBytes(body, "speed")
- return speed.Type == gjson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast")
+ speed := ojson.GetBytesNoCopy(body, "speed")
+ return speed.Type == ojson.String && strings.EqualFold(strings.TrimSpace(speed.String()), "fast")
}
// claudeCountTokensBetas is the fixed profile Claude Code 2.1.220 sends to
@@ -828,7 +861,7 @@ func classifyClaudeUpstreamErrorWithCooling(statusCode int, headers http.Header,
// claudeBodyIndicatesFastModeCredits matches Anthropic's fast-mode entitlement
// refusal without matching a genuine rate limit, which never mentions fast mode.
func claudeBodyIndicatesFastModeCredits(body []byte) bool {
- message := strings.ToLower(gjson.GetBytes(body, "error.message").String())
+ message := strings.ToLower(ojson.GetBytesNoCopy(body, "error.message").String())
if message == "" {
message = strings.ToLower(string(body))
}
@@ -878,26 +911,45 @@ func isAnthropicUpstreamBase(baseURL string) bool {
// extractAndRemoveBetas extracts the "betas" array from the body and removes it.
// Returns the extracted betas as a string slice and the modified body.
func extractAndRemoveBetas(body []byte) ([]string, []byte) {
- betasResult := gjson.GetBytes(body, "betas")
+ doc := ojson.NewDoc(body)
+ betas, removed := extractAndRemoveBetasDoc(doc)
+ if !removed {
+ return betas, body
+ }
+ return betas, doc.Bytes()
+}
+
+func extractAndRemoveBetasDoc(doc *ojson.Doc) ([]string, bool) {
+ if doc == nil {
+ return nil, false
+ }
+ betasResult := doc.Get("betas")
if !betasResult.Exists() {
- return nil, body
+ return nil, false
}
var betas []string
if betasResult.IsArray() {
for _, item := range betasResult.Array() {
if s := strings.TrimSpace(item.String()); s != "" {
- betas = append(betas, s)
+ betas = append(betas, strings.Clone(s))
}
}
} else if s := strings.TrimSpace(betasResult.String()); s != "" {
- betas = append(betas, s)
+ betas = append(betas, strings.Clone(s))
}
- body, _ = sjson.DeleteBytes(body, "betas")
- return betas, body
+ return betas, doc.Delete("betas") == nil
}
func claudeToolChoiceForcesToolUse(body []byte) bool {
- toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
+ toolChoiceType := ojson.GetBytesNoCopy(body, "tool_choice.type").String()
+ return toolChoiceType == "any" || toolChoiceType == "tool"
+}
+
+func claudeToolChoiceForcesToolUseDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ toolChoiceType := doc.Get("tool_choice.type").String()
return toolChoiceType == "any" || toolChoiceType == "tool"
}
@@ -905,18 +957,34 @@ func claudeToolChoiceForcesToolUse(body []byte) bool {
// Anthropic API does not allow thinking when tool_choice is set to "any" or a specific tool.
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations
func disableThinkingIfToolChoiceForced(body []byte) []byte {
+ doc := ojson.NewDoc(body)
+ if !disableThinkingIfToolChoiceForcedDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func disableThinkingIfToolChoiceForcedDoc(doc *ojson.Doc) bool {
// "auto" is allowed with thinking, but "any" or "tool" (specific tool) are not
- if claudeToolChoiceForcesToolUse(body) {
+ if claudeToolChoiceForcesToolUseDoc(doc) {
+ changed := false
// Remove thinking configuration entirely to avoid API error
- body, _ = sjson.DeleteBytes(body, "thinking")
+ if doc.Delete("thinking") == nil {
+ changed = true
+ }
// Adaptive thinking may also set output_config.effort; remove it to avoid
// leaking thinking controls when tool_choice forces tool use.
- body, _ = sjson.DeleteBytes(body, "output_config.effort")
- if oc := gjson.GetBytes(body, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- body, _ = sjson.DeleteBytes(body, "output_config")
+ if doc.Delete("output_config.effort") == nil {
+ changed = true
+ }
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ if doc.Delete("output_config") == nil {
+ changed = true
+ }
}
+ return changed
}
- return body
+ return false
}
// normalizeClaudeSamplingForUpstream keeps Anthropic message requests valid.
@@ -934,37 +1002,63 @@ func disableThinkingIfToolChoiceForced(body []byte) []byte {
// - thinking active: temperature must be 1, top_p must be >= 0.95, top_k unset
// - otherwise: temperature and top_p cannot both be specified
func normalizeClaudeSamplingForUpstream(body []byte, nativeOwned bool) []byte {
+ doc := ojson.NewDoc(body)
+ if !normalizeClaudeSamplingForUpstreamDoc(doc, nativeOwned) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func normalizeClaudeSamplingForUpstreamDoc(doc *ojson.Doc, nativeOwned bool) bool {
+ if doc == nil {
+ return false
+ }
thinkingActive := false
- switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) {
+ switch strings.ToLower(strings.TrimSpace(doc.Get("thinking.type").String())) {
case "enabled", "adaptive", "auto":
thinkingActive = true
}
+ changed := false
if !nativeOwned {
- body, _ = sjson.DeleteBytes(body, "temperature")
- body, _ = sjson.DeleteBytes(body, "top_p")
+ if doc.Delete("temperature") == nil {
+ changed = true
+ }
+ if doc.Delete("top_p") == nil {
+ changed = true
+ }
if thinkingActive {
- body, _ = sjson.DeleteBytes(body, "top_k")
+ if doc.Delete("top_k") == nil {
+ changed = true
+ }
}
- return body
+ return changed
}
if thinkingActive {
- if temperature := gjson.GetBytes(body, "temperature"); temperature.Exists() && temperature.Num != 1 {
- body, _ = sjson.DeleteBytes(body, "temperature")
+ if temperature := doc.Get("temperature"); temperature.Exists() && temperature.Num != 1 {
+ if doc.Delete("temperature") == nil {
+ changed = true
+ }
}
- if topP := gjson.GetBytes(body, "top_p"); topP.Exists() && topP.Num < 0.95 {
- body, _ = sjson.DeleteBytes(body, "top_p")
+ if topP := doc.Get("top_p"); topP.Exists() && topP.Num < 0.95 {
+ if doc.Delete("top_p") == nil {
+ changed = true
+ }
}
- body, _ = sjson.DeleteBytes(body, "top_k")
- return body
+ if doc.Delete("top_k") == nil {
+ changed = true
+ }
+ return changed
}
// Anthropic accepts either one but not both; temperature is the knob native
// Claude Code actually sends, so top_p is the one that gives way.
- if gjson.GetBytes(body, "temperature").Exists() && gjson.GetBytes(body, "top_p").Exists() {
- body, _ = sjson.DeleteBytes(body, "top_p")
+ if doc.Get("temperature").Exists() && doc.Get("top_p").Exists() {
+ if doc.Delete("top_p") == nil {
+ changed = true
+ }
}
- return body
+ return changed
}
func claudeResponseContentEncoding(header http.Header) string {
@@ -1189,7 +1283,7 @@ func applyClaudeHeadersWithNativeProfile(
if preserveCallerFingerprint {
// Caller-owned mode preserves both header and body-lifted betas verbatim.
// The explicit speed=fast request still needs its protocol beta.
- if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "speed").String()), "fast") {
+ if strings.EqualFold(strings.TrimSpace(ojson.GetBytesNoCopy(body, "speed").String()), "fast") {
appendBeta(claudeFastModeBeta)
}
for _, beta := range extraBetas {
@@ -1228,10 +1322,10 @@ func applyClaudeHeadersWithNativeProfile(
baseBetas = withoutClaudeBeta(baseBetas, claudeOAuthBeta)
baseBetas = withoutClaudeBeta(baseBetas, claudeRedactThinkingBeta)
baseBetas = withoutClaudeBeta(baseBetas, claudeAFKModeBeta)
- if !gjson.GetBytes(body, "diagnostics").Exists() {
+ if !ojson.GetBytesNoCopy(body, "diagnostics").Exists() {
baseBetas = withoutClaudeBeta(baseBetas, claudeCacheDiagnosisBeta)
}
- if !gjson.GetBytes(body, "fallback_credit_token").Exists() {
+ if !ojson.GetBytesNoCopy(body, "fallback_credit_token").Exists() {
baseBetas = withoutClaudeBeta(baseBetas, claudeFallbackCreditBeta)
}
if !helps.ClaudePayloadHas1hTTL(body) {
@@ -1248,7 +1342,7 @@ func applyClaudeHeadersWithNativeProfile(
baseBetas = withoutClaudeBeta(baseBetas, claudeThinkingDisplayUpdatesBeta)
baseBetas = withoutClaudeBeta(baseBetas, claudeExtendedCacheTTLBeta)
}
- reqThinkingType := gjson.GetBytes(body, "thinking.type").String()
+ reqThinkingType := ojson.GetBytesNoCopy(body, "thinking.type").String()
if reqThinkingType == "disabled" {
baseBetas = withoutClaudeBeta(baseBetas, claudeThinkingDisplayUpdatesBeta)
}
@@ -1258,14 +1352,14 @@ func applyClaudeHeadersWithNativeProfile(
if !reqProbeOrHelper && !countTokens && helps.ClaudePayloadHas1hTTL(body) {
baseBetas = withClaudeExtendedCacheTTLBeta(baseBetas)
}
- reqModel := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "model").String()))
- if isClaudeHaikuModel(reqModel) && !gjson.GetBytes(body, "fallbacks").Exists() && !helperProfile {
+ reqModel := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "model").String()))
+ if isClaudeHaikuModel(reqModel) && !ojson.GetBytesNoCopy(body, "fallbacks").Exists() && !helperProfile {
baseBetas = withoutClaudeBeta(baseBetas, claudeServerSideFallbackBeta)
}
// A body carrying thinking.block_binding is rejected upstream unless
// the binding-controls beta travels with it, in every fingerprint
// mode (cloaked, caller-owned, and confirmed passthrough alike).
- if gjson.GetBytes(body, "thinking.block_binding").Exists() {
+ if ojson.GetBytesNoCopy(body, "thinking.block_binding").Exists() {
baseBetas = withClaudeBetaAppended(baseBetas, claudeThinkingBindingBeta)
}
// Operator overrides get the last word over the derived and caller betas:
@@ -1581,12 +1675,22 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
// claudePayloadHasMidSystemMessage reports whether the caller placed a
// {"role":"system"} turn inside messages.
func claudePayloadHasMidSystemMessage(payload []byte) bool {
- messages := gjson.GetBytes(payload, "messages")
+ return claudeMessagesHaveMidSystemMessage(ojson.GetBytesNoCopy(payload, "messages"))
+}
+
+func claudeDocHasMidSystemMessage(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ return claudeMessagesHaveMidSystemMessage(doc.Get("messages"))
+}
+
+func claudeMessagesHaveMidSystemMessage(messages ojson.Result) bool {
if !messages.IsArray() {
return false
}
found := false
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") {
found = true
return false
@@ -1597,14 +1701,25 @@ func claudePayloadHasMidSystemMessage(payload []byte) bool {
}
func rebuildMidSystemMessagesToTopLevel(payload []byte) []byte {
- messages := gjson.GetBytes(payload, "messages")
- if !messages.IsArray() {
+ doc := ojson.NewDoc(payload)
+ if !rebuildMidSystemMessagesToTopLevelDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func rebuildMidSystemMessagesToTopLevelDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ messages := doc.Get("messages")
+ if !messages.IsArray() {
+ return false
+ }
var movedSystemParts []string
keptMessages := make([]string, 0, int(messages.Get("#").Int()))
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
if strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") {
movedSystemParts = append(movedSystemParts, claudeSystemTextParts(message.Get("content"))...)
return true
@@ -1613,33 +1728,34 @@ func rebuildMidSystemMessagesToTopLevel(payload []byte) []byte {
return true
})
if len(movedSystemParts) == 0 {
- return payload
+ return false
}
- systemParts := claudeSystemTextParts(gjson.GetBytes(payload, "system"))
+ changed := false
+ systemParts := claudeSystemTextParts(doc.Get("system"))
systemParts = append(systemParts, movedSystemParts...)
if len(systemParts) > 0 {
- if updated, errSetSystem := sjson.SetRawBytes(payload, "system", rawJSONArray(systemParts)); errSetSystem == nil {
- payload = updated
+ if errSetSystem := doc.SetRawBytes("system", rawJSONArray(systemParts)); errSetSystem == nil {
+ changed = true
}
}
- if updated, errSetMessages := sjson.SetRawBytes(payload, "messages", rawJSONArray(keptMessages)); errSetMessages == nil {
- payload = updated
+ if errSetMessages := doc.SetRawBytes("messages", rawJSONArray(keptMessages)); errSetMessages == nil {
+ changed = true
}
- return payload
+ return changed
}
-func claudeSystemTextParts(content gjson.Result) []string {
+func claudeSystemTextParts(content ojson.Result) []string {
if !content.Exists() {
return nil
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
text := content.String()
if strings.TrimSpace(text) == "" {
return nil
}
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", text)
+ block, _ = ojson.SetBytes(block, "text", text)
return []string{string(block)}
}
if !content.IsArray() {
@@ -1647,12 +1763,12 @@ func claudeSystemTextParts(content gjson.Result) []string {
}
var parts []string
- content.ForEach(func(_, item gjson.Result) bool {
- if item.Type == gjson.String {
+ content.ForEach(func(_, item ojson.Result) bool {
+ if item.Type == ojson.String {
text := item.String()
if strings.TrimSpace(text) != "" {
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", text)
+ block, _ = ojson.SetBytes(block, "text", text)
parts = append(parts, string(block))
}
return true
@@ -1718,6 +1834,10 @@ func prepareClaudeOAuthToolNamesForUpstream(body []byte, mcpAliases claudeMCPAli
return remapOAuthToolNamesWithOptions(body, mcpAliases)
}
+func prepareClaudeOAuthToolNamesForUpstreamDoc(doc *ojson.Doc, mcpAliases claudeMCPAliasOptions) (map[string]string, bool) {
+ return remapOAuthToolNamesWithOptionsDoc(doc, mcpAliases)
+}
+
func restoreClaudeOAuthToolNamesFromResponse(body []byte, reverseMap map[string]string) ([]byte, error) {
return reverseRemapOAuthToolNames(body, reverseMap)
}
@@ -1757,11 +1877,11 @@ func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOption
}
// remapOAuthToolNamesWithBatchedEdits records offsets from the original JSON
-// and applies every rename in one copy. Repeated sjson.SetBytes calls copy most
+// and applies every rename in one copy. Repeated ojson.SetBytes calls copy most
// of the request for every historical tool reference, turning this path into
// O(body size * reference count) allocation growth.
func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string, bool) {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return nil, nil, false
}
@@ -1777,27 +1897,27 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
// Build one request-specific forward map from declarations. Every client
// tool, including typed custom declarations and names resembling Claude
// built-ins, gets an MCP alias. Historical references use this same map.
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
forwardMap := make(map[string]string)
protectedNames := make(map[string]bool)
reservedNames := helps.AugmentClaudeBuiltinToolRegistry(body, nil)
if tools.Exists() && tools.IsArray() {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
name := tool.Get("name").String()
if name != "" {
- reservedNames[name] = true
+ reservedNames[strings.Clone(name)] = true
}
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
- protectedNames[name] = true
+ protectedNames[strings.Clone(name)] = true
}
return true
})
passthroughMCPTools := make([]string, 0, 4)
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
return true
}
- name := tool.Get("name").String()
+ name := strings.Clone(tool.Get("name").String())
if name == "" {
return true
}
@@ -1831,7 +1951,7 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
}
edits := make([]claudeRawJSONEdit, 0, len(forwardMap)+1)
- appendRawEdit := func(result gjson.Result, replacement string) bool {
+ appendRawEdit := func(result ojson.Result, replacement string) bool {
start := result.Index
end := start + len(result.Raw)
if result.Raw == "" || start < 0 || end < start || end > len(body) || !bytes.Equal(body[start:end], []byte(result.Raw)) {
@@ -1840,7 +1960,7 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
edits = append(edits, claudeRawJSONEdit{start: start, end: end, replacement: replacement})
return true
}
- appendStringEdit := func(result gjson.Result, replacement string) bool {
+ appendStringEdit := func(result ojson.Result, replacement string) bool {
// Generated aliases only emit [A-Za-z0-9_-], so adding quotes is
// byte-identical to sjson's encoding without another allocation.
return appendRawEdit(result, `"`+replacement+`"`)
@@ -1850,7 +1970,7 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
// tools array only after all offsets have been collected.
toolsNeedRewrite := false
if tools.Exists() && tools.IsArray() {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
toolType := tool.Get("type").String()
if helps.IsClaudeServerToolType(toolType) {
return true
@@ -1868,7 +1988,7 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
var toolsJSON strings.Builder
toolsJSON.WriteByte('[')
toolCount := 0
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
if toolCount > 0 {
toolsJSON.WriteByte(',')
@@ -1878,19 +1998,21 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
return true
}
- name := tool.Get("name").String()
+ name := strings.Clone(tool.Get("name").String())
+ hasType := strings.TrimSpace(tool.Get("type").String()) != ""
+ newName, renamed := rewriteName(name)
toolJSON := tool.Raw
- if strings.TrimSpace(tool.Get("type").String()) != "" {
- if updatedTool, errDelete := sjson.Delete(toolJSON, "type"); errDelete == nil {
- toolJSON = updatedTool
+ if hasType || renamed {
+ toolDoc := ojson.NewDoc([]byte(tool.Raw))
+ if hasType {
+ _ = toolDoc.Delete("type")
}
- }
- if newName, renamed := rewriteName(name); renamed {
- updatedTool, err := sjson.Set(toolJSON, "name", newName)
- if err == nil {
- toolJSON = updatedTool
- recordRename(name, newName)
+ if renamed {
+ if errSet := toolDoc.Set("name", newName); errSet == nil {
+ recordRename(name, newName)
+ }
}
+ toolJSON = toolDoc.String()
}
if toolCount > 0 {
@@ -1907,10 +2029,10 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
}
// 2. Rename tool_choice if it references a declared client tool.
- toolChoice := gjson.GetBytes(body, "tool_choice")
+ toolChoice := ojson.GetBytesNoCopy(body, "tool_choice")
if toolChoice.Get("type").String() == "tool" {
nameResult := toolChoice.Get("name")
- tcName := nameResult.String()
+ tcName := strings.Clone(nameResult.String())
if newName, renamed := rewriteName(tcName); renamed {
if !appendStringEdit(nameResult, newName) {
return nil, nil, false
@@ -1921,19 +2043,19 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
// 3. Rename tool references in messages while every Result.Index still
// points into the original request bytes.
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
validOffsets := true
if messages.Exists() && messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if !content.Exists() || !content.IsArray() {
return true
}
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
switch part.Get("type").String() {
case "tool_use":
nameResult := part.Get("name")
- name := nameResult.String()
+ name := strings.Clone(nameResult.String())
if newName, renamed := rewriteName(name); renamed {
if !appendStringEdit(nameResult, newName) {
validOffsets = false
@@ -1943,7 +2065,7 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
}
case "tool_reference":
nameResult := part.Get("tool_name")
- toolName := nameResult.String()
+ toolName := strings.Clone(nameResult.String())
if newName, renamed := rewriteName(toolName); renamed {
if !appendStringEdit(nameResult, newName) {
validOffsets = false
@@ -1954,12 +2076,12 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
case "tool_result":
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
- nestedContent.ForEach(func(_, nestedPart gjson.Result) bool {
+ nestedContent.ForEach(func(_, nestedPart ojson.Result) bool {
if nestedPart.Get("type").String() != "tool_reference" {
return true
}
nameResult := nestedPart.Get("tool_name")
- nestedToolName := nameResult.String()
+ nestedToolName := strings.Clone(nameResult.String())
if newName, renamed := rewriteName(nestedToolName); renamed {
if !appendStringEdit(nameResult, newName) {
validOffsets = false
@@ -1973,12 +2095,12 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
case "tool_search_tool_result":
toolRefs := part.Get("content.tool_references")
if toolRefs.Exists() && toolRefs.IsArray() {
- toolRefs.ForEach(func(_, refPart gjson.Result) bool {
+ toolRefs.ForEach(func(_, refPart ojson.Result) bool {
if refPart.Get("type").String() != "tool_reference" {
return true
}
nameResult := refPart.Get("tool_name")
- refToolName := nameResult.String()
+ refToolName := strings.Clone(nameResult.String())
if newName, renamed := rewriteName(refToolName); renamed {
if !appendStringEdit(nameResult, newName) {
validOffsets = false
@@ -1992,7 +2114,7 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
case "tool_addition", "tool_removal":
if namePath := claudeToolChangeNamePath(part); namePath != "" {
nameResult := part.Get(namePath)
- changeToolName := nameResult.String()
+ changeToolName := strings.Clone(nameResult.String())
if newName, renamed := rewriteName(changeToolName); renamed {
if !appendStringEdit(nameResult, newName) {
validOffsets = false
@@ -2050,29 +2172,24 @@ func applyClaudeRawJSONEdits(body []byte, edits []claudeRawJSONEdit) ([]byte, bo
return out, true
}
-// remapOAuthToolNamesWithOptionsLegacy is the byte-for-byte compatibility
-// fallback for malformed JSON or an unexpected GJSON offset. Keep it available
-// as a differential-test oracle for the batched implementation.
-func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) {
+func remapOAuthToolNamesWithOptionsDoc(doc *ojson.Doc, mcpAliases claudeMCPAliasOptions) (map[string]string, bool) {
+ if doc == nil || doc.Len() == 0 {
+ return nil, false
+ }
reverseMap := make(map[string]string)
recordRename := func(original, renamed string) {
- // Preserve the first-seen original name if the same upstream name is
- // produced from multiple call sites; they all map back identically.
if _, exists := reverseMap[renamed]; !exists {
reverseMap[renamed] = original
}
}
- // Build one request-specific forward map from declarations. Every client
- // tool, including typed custom declarations and names resembling Claude
- // built-ins, gets an MCP alias. Historical references use this same map.
- tools := gjson.GetBytes(body, "tools")
+ tools := doc.Get("tools")
forwardMap := make(map[string]string)
protectedNames := make(map[string]bool)
- reservedNames := helps.AugmentClaudeBuiltinToolRegistry(body, nil)
+ reservedNames := helps.AugmentClaudeBuiltinToolRegistry(nil, nil)
if tools.Exists() && tools.IsArray() {
- tools.ForEach(func(_, tool gjson.Result) bool {
- name := tool.Get("name").String()
+ tools.ForEach(func(_, tool ojson.Result) bool {
+ name := strings.Clone(tool.Get("name").String())
if name != "" {
reservedNames[name] = true
}
@@ -2082,11 +2199,11 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
return true
})
passthroughMCPTools := make([]string, 0, 4)
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
return true
}
- name := tool.Get("name").String()
+ name := strings.Clone(tool.Get("name").String())
if name == "" {
return true
}
@@ -2119,10 +2236,10 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
return name, false
}
- // 1. Rewrite the tools array without rebuilding from a stale gjson snapshot.
+ changed := false
toolsNeedRewrite := false
if tools.Exists() && tools.IsArray() {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
toolType := tool.Get("type").String()
if helps.IsClaudeServerToolType(toolType) {
return true
@@ -2140,7 +2257,7 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
var toolsJSON strings.Builder
toolsJSON.WriteByte('[')
toolCount := 0
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if helps.IsClaudeServerToolType(tool.Get("type").String()) {
if toolCount > 0 {
toolsJSON.WriteByte(',')
@@ -2150,19 +2267,21 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
return true
}
- name := tool.Get("name").String()
+ name := strings.Clone(tool.Get("name").String())
+ hasType := strings.TrimSpace(tool.Get("type").String()) != ""
+ newName, renamed := rewriteName(name)
toolJSON := tool.Raw
- if strings.TrimSpace(tool.Get("type").String()) != "" {
- if updatedTool, errDelete := sjson.Delete(toolJSON, "type"); errDelete == nil {
- toolJSON = updatedTool
+ if hasType || renamed {
+ toolDoc := ojson.NewDoc([]byte(tool.Raw))
+ if hasType {
+ _ = toolDoc.Delete("type")
}
- }
- if newName, renamed := rewriteName(name); renamed {
- updatedTool, err := sjson.Set(toolJSON, "name", newName)
- if err == nil {
- toolJSON = updatedTool
- recordRename(name, newName)
+ if renamed {
+ if errSet := toolDoc.Set("name", newName); errSet == nil {
+ recordRename(name, newName)
+ }
}
+ toolJSON = toolDoc.String()
}
if toolCount > 0 {
@@ -2173,57 +2292,62 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
return true
})
toolsJSON.WriteByte(']')
- body, _ = sjson.SetRawBytes(body, "tools", []byte(toolsJSON.String()))
+ if doc.SetRawBytes("tools", []byte(toolsJSON.String())) == nil {
+ changed = true
+ }
}
- // 2. Rename tool_choice if it references a declared client tool.
- toolChoiceType := gjson.GetBytes(body, "tool_choice.type").String()
+ toolChoiceType := doc.Get("tool_choice.type").String()
if toolChoiceType == "tool" {
- tcName := gjson.GetBytes(body, "tool_choice.name").String()
+ tcName := strings.Clone(doc.Get("tool_choice.name").String())
if newName, renamed := rewriteName(tcName); renamed {
- body, _ = sjson.SetBytes(body, "tool_choice.name", newName)
- recordRename(tcName, newName)
+ if doc.Set("tool_choice.name", newName) == nil {
+ changed = true
+ recordRename(tcName, newName)
+ }
}
}
- // 3. Rename tool references in messages
- messages := gjson.GetBytes(body, "messages")
+ messages := doc.Get("messages")
if messages.Exists() && messages.IsArray() {
- messages.ForEach(func(msgIndex, msg gjson.Result) bool {
+ messages.ForEach(func(msgIndex, msg ojson.Result) bool {
content := msg.Get("content")
if !content.Exists() || !content.IsArray() {
return true
}
- content.ForEach(func(contentIndex, part gjson.Result) bool {
+ content.ForEach(func(contentIndex, part ojson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
- name := part.Get("name").String()
+ name := strings.Clone(part.Get("name").String())
if newName, renamed := rewriteName(name); renamed {
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
- body, _ = sjson.SetBytes(body, path, newName)
- recordRename(name, newName)
+ if doc.Set(path, newName) == nil {
+ changed = true
+ recordRename(name, newName)
+ }
}
case "tool_reference":
- toolName := part.Get("tool_name").String()
+ toolName := strings.Clone(part.Get("tool_name").String())
if newName, renamed := rewriteName(toolName); renamed {
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
- body, _ = sjson.SetBytes(body, path, newName)
- recordRename(toolName, newName)
+ if doc.Set(path, newName) == nil {
+ changed = true
+ recordRename(toolName, newName)
+ }
}
case "tool_result":
- // Handle nested tool_reference blocks inside tool_result.content[]
- toolID := part.Get("tool_use_id").String()
- _ = toolID // tool_use_id stays as-is
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
- nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
+ nestedContent.ForEach(func(nestedIndex, nestedPart ojson.Result) bool {
if nestedPart.Get("type").String() == "tool_reference" {
- nestedToolName := nestedPart.Get("tool_name").String()
+ nestedToolName := strings.Clone(nestedPart.Get("tool_name").String())
if newName, renamed := rewriteName(nestedToolName); renamed {
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
- body, _ = sjson.SetBytes(body, nestedPath, newName)
- recordRename(nestedToolName, newName)
+ if doc.Set(nestedPath, newName) == nil {
+ changed = true
+ recordRename(nestedToolName, newName)
+ }
}
}
return true
@@ -2232,13 +2356,15 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
case "tool_search_tool_result":
toolRefs := part.Get("content.tool_references")
if toolRefs.Exists() && toolRefs.IsArray() {
- toolRefs.ForEach(func(refIndex, refPart gjson.Result) bool {
+ toolRefs.ForEach(func(refIndex, refPart ojson.Result) bool {
if refPart.Get("type").String() == "tool_reference" {
- refToolName := refPart.Get("tool_name").String()
+ refToolName := strings.Clone(refPart.Get("tool_name").String())
if newName, renamed := rewriteName(refToolName); renamed {
refPath := fmt.Sprintf("messages.%d.content.%d.content.tool_references.%d.tool_name", msgIndex.Int(), contentIndex.Int(), refIndex.Int())
- body, _ = sjson.SetBytes(body, refPath, newName)
- recordRename(refToolName, newName)
+ if doc.Set(refPath, newName) == nil {
+ changed = true
+ recordRename(refToolName, newName)
+ }
}
}
return true
@@ -2246,11 +2372,13 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
}
case "tool_addition", "tool_removal":
if namePath := claudeToolChangeNamePath(part); namePath != "" {
- changeToolName := part.Get(namePath).String()
+ changeToolName := strings.Clone(part.Get(namePath).String())
if newName, renamed := rewriteName(changeToolName); renamed {
changePath := fmt.Sprintf("messages.%d.content.%d.%s", msgIndex.Int(), contentIndex.Int(), namePath)
- body, _ = sjson.SetBytes(body, changePath, newName)
- recordRename(changeToolName, newName)
+ if doc.Set(changePath, newName) == nil {
+ changed = true
+ recordRename(changeToolName, newName)
+ }
}
}
}
@@ -2260,7 +2388,19 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
})
}
- return body, reverseMap
+ return reverseMap, changed
+}
+
+// remapOAuthToolNamesWithOptionsLegacy is the byte-for-byte compatibility
+// fallback for malformed JSON or an unexpected GJSON offset. Keep it available
+// as a differential-test oracle for the batched implementation.
+func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) {
+ doc := ojson.NewDoc(body)
+ reverseMap, changed := remapOAuthToolNamesWithOptionsDoc(doc, mcpAliases)
+ if !changed {
+ return body, reverseMap
+ }
+ return doc.Bytes(), reverseMap
}
// claudeToolChangeNamePath returns the path, relative to a mid-conversation
@@ -2271,7 +2411,7 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
// a tools[] entry; redefining a declared custom tool replaces it only under the
// same upstream name. Server tool definitions and MCP connector references keep
// their names, matching the tools[] rewrite.
-func claudeToolChangeNamePath(part gjson.Result) string {
+func claudeToolChangeNamePath(part ojson.Result) string {
switch part.Get("tool.type").String() {
case "tool_reference":
return "tool.name"
@@ -2560,13 +2700,23 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
if len(reverseMap) == 0 {
return body, nil
}
- content := gjson.GetBytes(body, "content")
+ content := ojson.GetBytesNoCopy(body, "content")
if !content.Exists() || !content.IsArray() {
return body, nil
}
resolver := newClaudeMCPAliasResolver(reverseMap)
+ var doc *ojson.Doc
var resolveErr error
- content.ForEach(func(index, part gjson.Result) bool {
+ changed := false
+ setField := func(path, val string) {
+ if doc == nil {
+ doc = ojson.NewDoc(body)
+ }
+ if doc.Set(path, val) == nil {
+ changed = true
+ }
+ }
+ content.ForEach(func(index, part ojson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
@@ -2578,7 +2728,7 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
}
if matched {
path := fmt.Sprintf("content.%d.name", index.Int())
- body, _ = sjson.SetBytes(body, path, origName)
+ setField(path, origName)
}
case "tool_reference":
toolName := part.Get("tool_name").String()
@@ -2589,12 +2739,12 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
}
if matched {
path := fmt.Sprintf("content.%d.tool_name", index.Int())
- body, _ = sjson.SetBytes(body, path, origName)
+ setField(path, origName)
}
case "tool_result":
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
- nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
+ nestedContent.ForEach(func(nestedIndex, nestedPart ojson.Result) bool {
if nestedPart.Get("type").String() != "tool_reference" {
return true
}
@@ -2606,7 +2756,7 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
}
if matched {
path := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int())
- body, _ = sjson.SetBytes(body, path, origName)
+ setField(path, origName)
}
return true
})
@@ -2614,7 +2764,7 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
case "tool_search_tool_result":
toolRefs := part.Get("content.tool_references")
if toolRefs.Exists() && toolRefs.IsArray() {
- toolRefs.ForEach(func(refIndex, refPart gjson.Result) bool {
+ toolRefs.ForEach(func(refIndex, refPart ojson.Result) bool {
if refPart.Get("type").String() != "tool_reference" {
return true
}
@@ -2626,7 +2776,7 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
}
if matched {
path := fmt.Sprintf("content.%d.content.tool_references.%d.tool_name", index.Int(), refIndex.Int())
- body, _ = sjson.SetBytes(body, path, origName)
+ setField(path, origName)
}
return true
})
@@ -2634,7 +2784,13 @@ func reverseRemapOAuthToolNames(body []byte, reverseMap map[string]string) ([]by
}
return resolveErr == nil
})
- return body, resolveErr
+ if resolveErr != nil {
+ return body, resolveErr
+ }
+ if !changed || doc == nil {
+ return body, nil
+ }
+ return doc.Bytes(), nil
}
// reverseRemapOAuthToolNamesFromStreamLine reverses the tool name mapping for SSE
@@ -2644,11 +2800,11 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string
return line, nil
}
payload := helps.JSONPayload(line)
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return line, nil
}
- contentBlock := gjson.GetBytes(payload, "content_block")
+ contentBlock := ojson.GetBytesNoCopy(payload, "content_block")
if !contentBlock.Exists() {
return line, nil
}
@@ -2668,7 +2824,7 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string
if !matched {
return line, nil
}
- updated, err = sjson.SetBytes(payload, "content_block.name", origName)
+ updated, err = ojson.SetBytes(payload, "content_block.name", origName)
case "tool_reference":
toolName := contentBlock.Get("tool_name").String()
origName, matched, errResolve := resolver.resolve(toolName)
@@ -2678,16 +2834,16 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string
if !matched {
return line, nil
}
- updated, err = sjson.SetBytes(payload, "content_block.tool_name", origName)
+ updated, err = ojson.SetBytes(payload, "content_block.tool_name", origName)
case "tool_search_tool_result":
toolRefs := contentBlock.Get("content.tool_references")
if !toolRefs.Exists() || !toolRefs.IsArray() {
return line, nil
}
- updatedPayload := payload
+ var doc *ojson.Doc
var resolveErr error
hasChange := false
- toolRefs.ForEach(func(refIndex, refPart gjson.Result) bool {
+ toolRefs.ForEach(func(refIndex, refPart ojson.Result) bool {
if refPart.Get("type").String() != "tool_reference" {
return true
}
@@ -2698,8 +2854,11 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string
return false
}
if matched {
+ if doc == nil {
+ doc = ojson.NewDoc(payload)
+ }
path := fmt.Sprintf("content_block.content.tool_references.%d.tool_name", refIndex.Int())
- updatedPayload, err = sjson.SetBytes(updatedPayload, path, origName)
+ err = doc.Set(path, origName)
if err != nil {
return false
}
@@ -2713,10 +2872,10 @@ func reverseRemapOAuthToolNamesFromStreamLine(line []byte, reverseMap map[string
if err != nil {
return line, fmt.Errorf("rewrite Claude OAuth MCP tool alias: %w", err)
}
- if !hasChange {
+ if !hasChange || doc == nil {
return line, nil
}
- updated = updatedPayload
+ updated = doc.Bytes()
default:
return line, nil
}
@@ -2735,17 +2894,29 @@ func applyClaudeToolPrefix(body []byte, prefix string) []byte {
if prefix == "" {
return body
}
+ doc := ojson.NewDoc(body)
+ if !applyClaudeToolPrefixDoc(doc, prefix) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func applyClaudeToolPrefixDoc(doc *ojson.Doc, prefix string) bool {
+ if prefix == "" || doc == nil || doc.Len() == 0 {
+ return false
+ }
+ changed := false
// Collect built-in tool names from the authoritative fallback seed list and
// augment it with any typed built-ins present in the current request body.
- builtinTools := helps.AugmentClaudeBuiltinToolRegistry(body, nil)
+ builtinTools := helps.AugmentClaudeBuiltinToolRegistry(nil, nil)
- if tools := gjson.GetBytes(body, "tools"); tools.Exists() && tools.IsArray() {
- tools.ForEach(func(index, tool gjson.Result) bool {
+ if tools := doc.Get("tools"); tools.Exists() && tools.IsArray() {
+ tools.ForEach(func(index, tool ojson.Result) bool {
// Skip built-in tools (web_search, code_execution, etc.) which have
// a "type" field and require their name to remain unchanged.
if tool.Get("type").Exists() && tool.Get("type").String() != "" {
- if n := tool.Get("name").String(); n != "" {
+ if n := strings.Clone(tool.Get("name").String()); n != "" {
builtinTools[n] = true
}
return true
@@ -2755,25 +2926,29 @@ func applyClaudeToolPrefix(body []byte, prefix string) []byte {
return true
}
path := fmt.Sprintf("tools.%d.name", index.Int())
- body, _ = sjson.SetBytes(body, path, prefix+name)
+ if doc.Set(path, prefix+name) == nil {
+ changed = true
+ }
return true
})
}
- if gjson.GetBytes(body, "tool_choice.type").String() == "tool" {
- name := gjson.GetBytes(body, "tool_choice.name").String()
+ if doc.Get("tool_choice.type").String() == "tool" {
+ name := doc.Get("tool_choice.name").String()
if name != "" && !strings.HasPrefix(name, prefix) && !builtinTools[name] && !helps.IsClaudeMCPToolName(name) {
- body, _ = sjson.SetBytes(body, "tool_choice.name", prefix+name)
+ if doc.Set("tool_choice.name", prefix+name) == nil {
+ changed = true
+ }
}
}
- if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() {
- messages.ForEach(func(msgIndex, msg gjson.Result) bool {
+ if messages := doc.Get("messages"); messages.Exists() && messages.IsArray() {
+ messages.ForEach(func(msgIndex, msg ojson.Result) bool {
content := msg.Get("content")
if !content.Exists() || !content.IsArray() {
return true
}
- content.ForEach(func(contentIndex, part gjson.Result) bool {
+ content.ForEach(func(contentIndex, part ojson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
@@ -2782,24 +2957,30 @@ func applyClaudeToolPrefix(body []byte, prefix string) []byte {
return true
}
path := fmt.Sprintf("messages.%d.content.%d.name", msgIndex.Int(), contentIndex.Int())
- body, _ = sjson.SetBytes(body, path, prefix+name)
+ if doc.Set(path, prefix+name) == nil {
+ changed = true
+ }
case "tool_reference":
toolName := part.Get("tool_name").String()
if toolName == "" || strings.HasPrefix(toolName, prefix) || builtinTools[toolName] || helps.IsClaudeMCPToolName(toolName) {
return true
}
path := fmt.Sprintf("messages.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int())
- body, _ = sjson.SetBytes(body, path, prefix+toolName)
+ if doc.Set(path, prefix+toolName) == nil {
+ changed = true
+ }
case "tool_result":
// Handle nested tool_reference blocks inside tool_result.content[]
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
- nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
+ nestedContent.ForEach(func(nestedIndex, nestedPart ojson.Result) bool {
if nestedPart.Get("type").String() == "tool_reference" {
nestedToolName := nestedPart.Get("tool_name").String()
if nestedToolName != "" && !strings.HasPrefix(nestedToolName, prefix) && !builtinTools[nestedToolName] && !helps.IsClaudeMCPToolName(nestedToolName) {
nestedPath := fmt.Sprintf("messages.%d.content.%d.content.%d.tool_name", msgIndex.Int(), contentIndex.Int(), nestedIndex.Int())
- body, _ = sjson.SetBytes(body, nestedPath, prefix+nestedToolName)
+ if doc.Set(nestedPath, prefix+nestedToolName) == nil {
+ changed = true
+ }
}
}
return true
@@ -2812,18 +2993,28 @@ func applyClaudeToolPrefix(body []byte, prefix string) []byte {
})
}
- return body
+ return changed
}
func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {
if prefix == "" {
return body
}
- content := gjson.GetBytes(body, "content")
+ content := ojson.GetBytesNoCopy(body, "content")
if !content.Exists() || !content.IsArray() {
return body
}
- content.ForEach(func(index, part gjson.Result) bool {
+ var doc *ojson.Doc
+ changed := false
+ setField := func(path, val string) {
+ if doc == nil {
+ doc = ojson.NewDoc(body)
+ }
+ if doc.Set(path, val) == nil {
+ changed = true
+ }
+ }
+ content.ForEach(func(index, part ojson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "tool_use":
@@ -2832,24 +3023,24 @@ func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {
return true
}
path := fmt.Sprintf("content.%d.name", index.Int())
- body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(name, prefix))
+ setField(path, strings.TrimPrefix(name, prefix))
case "tool_reference":
toolName := part.Get("tool_name").String()
if !strings.HasPrefix(toolName, prefix) {
return true
}
path := fmt.Sprintf("content.%d.tool_name", index.Int())
- body, _ = sjson.SetBytes(body, path, strings.TrimPrefix(toolName, prefix))
+ setField(path, strings.TrimPrefix(toolName, prefix))
case "tool_result":
// Handle nested tool_reference blocks inside tool_result.content[]
nestedContent := part.Get("content")
if nestedContent.Exists() && nestedContent.IsArray() {
- nestedContent.ForEach(func(nestedIndex, nestedPart gjson.Result) bool {
+ nestedContent.ForEach(func(nestedIndex, nestedPart ojson.Result) bool {
if nestedPart.Get("type").String() == "tool_reference" {
nestedToolName := nestedPart.Get("tool_name").String()
if strings.HasPrefix(nestedToolName, prefix) {
nestedPath := fmt.Sprintf("content.%d.content.%d.tool_name", index.Int(), nestedIndex.Int())
- body, _ = sjson.SetBytes(body, nestedPath, strings.TrimPrefix(nestedToolName, prefix))
+ setField(nestedPath, strings.TrimPrefix(nestedToolName, prefix))
}
}
return true
@@ -2858,7 +3049,10 @@ func stripClaudeToolPrefixFromResponse(body []byte, prefix string) []byte {
}
return true
})
- return body
+ if !changed || doc == nil {
+ return body
+ }
+ return doc.Bytes()
}
func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
@@ -2866,10 +3060,10 @@ func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
return line
}
payload := helps.JSONPayload(line)
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return line
}
- contentBlock := gjson.GetBytes(payload, "content_block")
+ contentBlock := ojson.GetBytesNoCopy(payload, "content_block")
if !contentBlock.Exists() {
return line
}
@@ -2884,7 +3078,7 @@ func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
if !strings.HasPrefix(name, prefix) {
return line
}
- updated, err = sjson.SetBytes(payload, "content_block.name", strings.TrimPrefix(name, prefix))
+ updated, err = ojson.SetBytes(payload, "content_block.name", strings.TrimPrefix(name, prefix))
if err != nil {
return line
}
@@ -2893,7 +3087,7 @@ func stripClaudeToolPrefixFromStreamLine(line []byte, prefix string) []byte {
if !strings.HasPrefix(toolName, prefix) {
return line
}
- updated, err = sjson.SetBytes(payload, "content_block.tool_name", strings.TrimPrefix(toolName, prefix))
+ updated, err = ojson.SetBytes(payload, "content_block.tool_name", strings.TrimPrefix(toolName, prefix))
if err != nil {
return line
}
diff --git a/internal/runtime/executor/claude_executor_request_remap_test.go b/internal/runtime/executor/claude_executor_request_remap_test.go
index 98bb88dd6f3..56fe74378c7 100644
--- a/internal/runtime/executor/claude_executor_request_remap_test.go
+++ b/internal/runtime/executor/claude_executor_request_remap_test.go
@@ -8,9 +8,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
func TestClaudeOAuthToolAliasRestoresContinuationWithoutDeclarations(t *testing.T) {
@@ -41,7 +41,7 @@ func TestClaudeOAuthToolAliasRestoresContinuationWithoutDeclarations(t *testing.
if errRestore != nil {
t.Fatalf("restore continuation tool name: %v", errRestore)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Read" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "Read" {
t.Fatalf("continuation tool name = %q, want Read; response=%s", got, restored)
}
}
@@ -64,7 +64,7 @@ func TestClaudeOAuthToolAliasMissingContinuationStateIsRequestScopedNotFound(t *
if !okResponse || len(responseErr.ResponseBody()) == 0 {
t.Fatalf("continuation error does not provide ResponseBody: %T", errPrepare)
}
- if got := gjson.GetBytes(responseErr.ResponseBody(), "error.type").String(); got != "not_found_error" {
+ if got := ojson.GetBytes(responseErr.ResponseBody(), "error.type").String(); got != "not_found_error" {
t.Fatalf("continuation error.type = %q, want not_found_error; body=%s", got, responseErr.ResponseBody())
}
}
@@ -178,8 +178,8 @@ func TestRemapOAuthToolNamesWithOptionsFallsBackForMalformedJSON(t *testing.T) {
func TestReverseRemapOAuthToolNamesRecoversMangledAliases(t *testing.T) {
body := []byte(`{"tools":[{"name":"glob","input_schema":{"type":"object"}},{"name":"read","input_schema":{"type":"object"}}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "mangled-alias-caller"})
- globAlias := gjson.GetBytes(remapped, "tools.0.name").String()
- readAlias := gjson.GetBytes(remapped, "tools.1.name").String()
+ globAlias := ojson.GetBytes(remapped, "tools.0.name").String()
+ readAlias := ojson.GetBytes(remapped, "tools.1.name").String()
globParts, ok := parseClaudeMCPAlias(globAlias)
if !ok {
t.Fatalf("glob alias is invalid: %q", globAlias)
@@ -201,13 +201,13 @@ func TestReverseRemapOAuthToolNamesRecoversMangledAliases(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "glob" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "glob" {
t.Fatalf("repeated alias restored to %q, want glob", got)
}
- if got := gjson.GetBytes(restored, "content.1.tool_name").String(); got != "read" {
+ if got := ojson.GetBytes(restored, "content.1.tool_name").String(); got != "read" {
t.Fatalf("mixed alias restored to %q, want read", got)
}
- if got := gjson.GetBytes(restored, "content.2.content.0.tool_name").String(); got != "read" {
+ if got := ojson.GetBytes(restored, "content.2.content.0.tool_name").String(); got != "read" {
t.Fatalf("nested mixed alias restored to %q, want read", got)
}
@@ -237,7 +237,7 @@ func TestReverseRemapOAuthToolNamesRecoversMangledAliases(t *testing.T) {
if errStream != nil {
t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), test.fieldPath).String(); got != test.want {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), test.fieldPath).String(); got != test.want {
t.Fatalf("restored stream name = %q, want %q", got, test.want)
}
})
@@ -271,7 +271,7 @@ func TestReverseRemapOAuthToolNamesRecoversRepeatedServerAliases(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
t.Fatalf("repeated server alias restored to %q, want Bash", got)
}
@@ -280,7 +280,7 @@ func TestReverseRemapOAuthToolNamesRecoversRepeatedServerAliases(t *testing.T) {
if errStream != nil {
t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "Bash" {
t.Fatalf("stream repeated server alias restored to %q, want Bash", got)
}
})
@@ -308,7 +308,7 @@ func TestReverseRemapOAuthToolNamesRecoversMalformedToolIDBySemanticSuffix(t *te
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
t.Fatalf("malformed tool ID alias restored to %q, want Bash", got)
}
@@ -317,7 +317,7 @@ func TestReverseRemapOAuthToolNamesRecoversMalformedToolIDBySemanticSuffix(t *te
if errStream != nil {
t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "Bash" {
t.Fatalf("stream malformed tool ID alias restored to %q, want Bash", got)
}
})
@@ -327,8 +327,8 @@ func TestReverseRemapOAuthToolNamesRecoversMalformedToolIDBySemanticSuffix(t *te
func TestReverseRemapOAuthToolNamesWithBIP39Aliases(t *testing.T) {
body := []byte(`{"tools":[{"name":"Bash","input_schema":{"type":"object"}},{"name":"fetch_url","input_schema":{"type":"object"}}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "bip39-caller"})
- bashAlias := gjson.GetBytes(remapped, "tools.0.name").String()
- fetchAlias := gjson.GetBytes(remapped, "tools.1.name").String()
+ bashAlias := ojson.GetBytes(remapped, "tools.0.name").String()
+ fetchAlias := ojson.GetBytes(remapped, "tools.1.name").String()
if !helps.IsClaudeMCPToolName(bashAlias) {
t.Fatalf("generated bash alias is invalid: %q", bashAlias)
@@ -362,19 +362,19 @@ func TestReverseRemapOAuthToolNamesWithBIP39Aliases(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
t.Fatalf("exact alias restored to %q, want Bash", got)
}
- if got := gjson.GetBytes(restored, "content.1.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.1.name").String(); got != "Bash" {
t.Fatalf("repeated alias restored to %q, want Bash", got)
}
- if got := gjson.GetBytes(restored, "content.2.tool_name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.2.tool_name").String(); got != "Bash" {
t.Fatalf("mangled toolID alias restored to %q, want Bash", got)
}
- if got := gjson.GetBytes(restored, "content.3.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.3.name").String(); got != "Bash" {
t.Fatalf("repeated toolID alias restored to %q, want Bash", got)
}
- if got := gjson.GetBytes(restored, "content.4.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.4.name").String(); got != "Bash" {
t.Fatalf("extra-word alias restored to %q, want Bash", got)
}
}
@@ -382,8 +382,8 @@ func TestReverseRemapOAuthToolNamesWithBIP39Aliases(t *testing.T) {
func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) {
body := []byte(`{"tools":[{"name":"tool.name"},{"name":"tool/name"}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "ambiguous-alias-caller"})
- firstAlias := gjson.GetBytes(remapped, "tools.0.name").String()
- secondAlias := gjson.GetBytes(remapped, "tools.1.name").String()
+ firstAlias := ojson.GetBytes(remapped, "tools.0.name").String()
+ secondAlias := ojson.GetBytes(remapped, "tools.1.name").String()
firstParts, ok := parseClaudeMCPAlias(firstAlias)
if !ok {
t.Fatalf("first alias is invalid: %q", firstAlias)
@@ -447,7 +447,7 @@ func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v, want fail-open nil", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != unrecoverableAlias {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != unrecoverableAlias {
t.Fatalf("unrecoverable alias = %q, want %q forwarded unchanged", got, unrecoverableAlias)
}
@@ -465,8 +465,8 @@ func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) {
func TestReverseRemapOAuthToolNames_OverlappingSemanticSuffix(t *testing.T) {
body := []byte(`{"tools":[{"name":"file"},{"name":"read_file"}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "overlapping-caller"})
- fileAlias := gjson.GetBytes(remapped, "tools.0.name").String()
- readFileAlias := gjson.GetBytes(remapped, "tools.1.name").String()
+ fileAlias := ojson.GetBytes(remapped, "tools.0.name").String()
+ readFileAlias := ojson.GetBytes(remapped, "tools.1.name").String()
if !helps.IsClaudeMCPToolName(fileAlias) {
t.Fatalf("fileAlias is invalid: %q", fileAlias)
@@ -485,7 +485,7 @@ func TestReverseRemapOAuthToolNames_OverlappingSemanticSuffix(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "read_file" {
t.Fatalf("restored tool name = %q, want read_file", got)
}
@@ -495,7 +495,7 @@ func TestReverseRemapOAuthToolNames_OverlappingSemanticSuffix(t *testing.T) {
if errFile != nil {
t.Fatalf("reverseRemapOAuthToolNames(file) error = %v", errFile)
}
- if got := gjson.GetBytes(restoredFile, "content.0.name").String(); got != "file" {
+ if got := ojson.GetBytes(restoredFile, "content.0.name").String(); got != "file" {
t.Fatalf("restored tool name = %q, want file", got)
}
}
@@ -509,7 +509,7 @@ func TestReverseRemapOAuthToolNamesPreservesUnrelatedMCPName(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "mcp__external__query" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "mcp__external__query" {
t.Fatalf("unrelated MCP name = %q, want unchanged", got)
}
}
@@ -547,7 +547,7 @@ func FuzzRemapOAuthToolNamesWithBatchedEditsMatchesLegacy(f *testing.F) {
}
f.Fuzz(func(t *testing.T, body []byte, secret string) {
- if len(body) > 1<<20 || !gjson.ValidBytes(body) {
+ if len(body) > 1<<20 || !ojson.ValidBytes(body) {
return
}
options := claudeMCPAliasOptions{secret: secret}
@@ -597,7 +597,7 @@ func TestReverseRemapPassesThroughCallerMCPToolsOnVirtualServerCollision(t *test
t.Fatal("read_file was not aliased, the collision scenario is not being exercised")
}
for index, name := range native {
- if got := gjson.GetBytes(upstream, fmt.Sprintf("tools.%d.name", index+1)).String(); got != name {
+ if got := ojson.GetBytes(upstream, fmt.Sprintf("tools.%d.name", index+1)).String(); got != name {
t.Fatalf("caller MCP tool %d sent upstream as %q, want %q unchanged", index, got, name)
}
}
@@ -608,7 +608,7 @@ func TestReverseRemapPassesThroughCallerMCPToolsOnVirtualServerCollision(t *test
if err != nil {
t.Fatalf("caller MCP tool %q failed to restore: %v", name, err)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != name {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != name {
t.Fatalf("caller MCP tool %q restored as %q, want it passed through unchanged", name, got)
}
@@ -617,7 +617,7 @@ func TestReverseRemapPassesThroughCallerMCPToolsOnVirtualServerCollision(t *test
if errLine != nil {
t.Fatalf("caller MCP tool %q failed to restore from stream: %v", name, errLine)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != name {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != name {
t.Fatalf("caller MCP tool %q restored from stream as %q, want unchanged", name, got)
}
}
@@ -631,7 +631,7 @@ func TestReverseRemapPassesThroughCallerMCPToolsOnVirtualServerCollision(t *test
if err != nil {
t.Fatalf("proxied alias %q failed to restore: %v", drifted, err)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "read_file" {
t.Fatalf("proxied alias %q restored as %q, want %q", drifted, got, "read_file")
}
}
@@ -688,8 +688,8 @@ func TestClaudeMCPAliasRestoreErrorStaysRequestScoped(t *testing.T) {
func TestReverseRemapOAuthToolNames_ToolSearchResult_NonStream(t *testing.T) {
body := []byte(`{"tools":[{"name":"task_list","input_schema":{"type":"object"}},{"name":"fetch_url","input_schema":{"type":"object"}}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "tool-search-caller"})
- taskAlias := gjson.GetBytes(remapped, "tools.0.name").String()
- fetchAlias := gjson.GetBytes(remapped, "tools.1.name").String()
+ taskAlias := ojson.GetBytes(remapped, "tools.0.name").String()
+ fetchAlias := ojson.GetBytes(remapped, "tools.1.name").String()
if taskAlias == "task_list" || fetchAlias == "fetch_url" {
t.Fatalf("tools were not aliased: task=%q, fetch=%q", taskAlias, fetchAlias)
@@ -726,9 +726,9 @@ func TestReverseRemapOAuthToolNames_ToolSearchResult_NonStream(t *testing.T) {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", err)
}
- ref0 := gjson.GetBytes(restored, "content.0.content.tool_references.0.tool_name").String()
- ref1 := gjson.GetBytes(restored, "content.0.content.tool_references.1.tool_name").String()
- toolUseName := gjson.GetBytes(restored, "content.1.name").String()
+ ref0 := ojson.GetBytes(restored, "content.0.content.tool_references.0.tool_name").String()
+ ref1 := ojson.GetBytes(restored, "content.0.content.tool_references.1.tool_name").String()
+ toolUseName := ojson.GetBytes(restored, "content.1.name").String()
if ref0 != "task_list" {
t.Errorf("ref0 = %q, want task_list", ref0)
@@ -769,7 +769,7 @@ func TestReverseRemapOAuthToolNames_ToolSearchResult_NonStream(t *testing.T) {
func TestReverseRemapOAuthToolNamesFromStreamLine_ToolSearchResult_Stream(t *testing.T) {
body := []byte(`{"tools":[{"name":"task_list","input_schema":{"type":"object"}}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "tool-search-stream-caller"})
- taskAlias := gjson.GetBytes(remapped, "tools.0.name").String()
+ taskAlias := ojson.GetBytes(remapped, "tools.0.name").String()
line := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_search_tool_result","tool_use_id":"srvtoolu_01","content":{"type":"tool_search_tool_search_result","tool_references":[{"type":"tool_reference","tool_name":%q}]}}}`, taskAlias))
@@ -781,7 +781,7 @@ func TestReverseRemapOAuthToolNamesFromStreamLine_ToolSearchResult_Stream(t *tes
if !bytes.HasPrefix(restoredLine, []byte("data: ")) {
t.Fatalf("restoredLine lost data: prefix: %s", string(restoredLine))
}
- gotToolName := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.content.tool_references.0.tool_name").String()
+ gotToolName := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.content.tool_references.0.tool_name").String()
if gotToolName != "task_list" {
t.Fatalf("stream restored tool_name = %q, want task_list", gotToolName)
}
@@ -820,7 +820,7 @@ func TestRemapOAuthToolNames_ToolSearchResultInMessageHistory(t *testing.T) {
}
// Verify server tools were preserved
- tools := gjson.GetBytes(remapped, "tools")
+ tools := ojson.GetBytes(remapped, "tools")
if tools.Get("1.type").String() != "advisor_20260301" || tools.Get("1.name").String() != "advisor" {
t.Fatalf("advisor server tool was altered: %s", tools.Get("1").Raw)
}
@@ -829,7 +829,7 @@ func TestRemapOAuthToolNames_ToolSearchResultInMessageHistory(t *testing.T) {
}
// Verify tool_search_tool_result in messages was remapped
- gotHistoryToolName := gjson.GetBytes(remapped, "messages.0.content.0.content.tool_references.0.tool_name").String()
+ gotHistoryToolName := ojson.GetBytes(remapped, "messages.0.content.0.content.tool_references.0.tool_name").String()
if gotHistoryToolName != taskAlias {
t.Fatalf("history tool_name = %q, want aliased %q", gotHistoryToolName, taskAlias)
}
@@ -944,8 +944,8 @@ func TestRemapOAuthToolNames_MidConversationToolChanges(t *testing.T) {
{name: "legacy", body: legacyBody, reverseMap: legacyReverseMap},
} {
declared := make(map[string]string)
- gjson.GetBytes(variant.body, "tools").ForEach(func(index, tool gjson.Result) bool {
- original := gjson.Get(tools, fmt.Sprintf("%d.name", index.Int())).String()
+ ojson.GetBytes(variant.body, "tools").ForEach(func(index, tool ojson.Result) bool {
+ original := ojson.Get(tools, fmt.Sprintf("%d.name", index.Int())).String()
declared[original] = tool.Get("name").String()
return true
})
@@ -960,14 +960,14 @@ func TestRemapOAuthToolNames_MidConversationToolChanges(t *testing.T) {
t.Fatalf("%s: reverseMap[%q] = %q, want %q", variant.name, want, got, check.original)
}
}
- if got := gjson.GetBytes(variant.body, check.path).String(); got != want {
+ if got := ojson.GetBytes(variant.body, check.path).String(); got != want {
t.Fatalf("%s: %s = %q, want %q", variant.name, check.path, got, want)
}
}
- if got := gjson.GetBytes(variant.body, "tools.2.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(variant.body, "tools.2.name").String(); got != "web_search" {
t.Fatalf("%s: server tool name = %q, want web_search", variant.name, got)
}
- if got := gjson.GetBytes(variant.body, "tools.3.name").String(); got != "mcp__context7__query-docs" {
+ if got := ojson.GetBytes(variant.body, "tools.3.name").String(); got != "mcp__context7__query-docs" {
t.Fatalf("%s: caller MCP tool name = %q, want unchanged", variant.name, got)
}
}
@@ -1018,7 +1018,7 @@ func TestReverseRemapOAuthToolNamesRestoresHybridPassthroughMCPTools(t *testing.
if err != nil {
t.Fatalf("restoreClaudeOAuthToolNamesFromResponse() error = %v, want %q", err, tc.wantName)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != tc.wantName {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != tc.wantName {
t.Fatalf("content.0.name = %q, want %q", got, tc.wantName)
}
@@ -1028,7 +1028,7 @@ func TestReverseRemapOAuthToolNamesRestoresHybridPassthroughMCPTools(t *testing.
if errStream != nil {
t.Fatalf("restoreClaudeOAuthToolNamesFromStreamLine() error = %v, want %q", errStream, tc.wantName)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != tc.wantName {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != tc.wantName {
t.Fatalf("content_block.name = %q, want %q", got, tc.wantName)
}
})
@@ -1049,7 +1049,7 @@ func TestReverseRemapOAuthToolNamesHybridPassthroughPrecedenceAndAmbiguity(t *te
if err != nil {
t.Fatalf("restoreClaudeOAuthToolNamesFromResponse() error = %v", err)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
t.Fatalf("content.0.name = %q, want %q", got, "Bash")
}
})
@@ -1076,7 +1076,7 @@ func TestReverseRemapOAuthToolNames_UndeclaredToolFailsOpen(t *testing.T) {
// forwarding the tool name unchanged without error so the stream is not aborted.
body := []byte(`{"tools":[{"name":"terminal","input_schema":{"type":"object"}}]}`)
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "undeclared-tool-caller"})
- terminalAlias := gjson.GetBytes(remapped, "tools.0.name").String()
+ terminalAlias := ojson.GetBytes(remapped, "tools.0.name").String()
parts, ok := parseClaudeMCPAlias(terminalAlias)
if !ok {
t.Fatalf("parseClaudeMCPAlias(%q) failed", terminalAlias)
@@ -1091,7 +1091,7 @@ func TestReverseRemapOAuthToolNames_UndeclaredToolFailsOpen(t *testing.T) {
if err != nil {
t.Fatalf("restoreClaudeOAuthToolNamesFromResponse() error = %v, want fail-open nil", err)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != undeclaredToolName {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != undeclaredToolName {
t.Fatalf("restored tool name = %q, want %q forwarded unchanged", got, undeclaredToolName)
}
@@ -1132,7 +1132,7 @@ func TestReverseRemapRestoresCallerMCPToolWrappedInVirtualServer(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != recall {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != recall {
t.Fatalf("wrapped caller MCP tool restored to %q, want %q", got, recall)
}
line := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}}`, wrapped))
@@ -1140,7 +1140,7 @@ func TestReverseRemapRestoresCallerMCPToolWrappedInVirtualServer(t *testing.T) {
if errStream != nil {
t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != recall {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != recall {
t.Fatalf("stream wrapped caller MCP tool restored to %q, want %q", got, recall)
}
})
diff --git a/internal/runtime/executor/claude_executor_stop_reasons_test.go b/internal/runtime/executor/claude_executor_stop_reasons_test.go
index b465aaba5f9..447bea4569e 100644
--- a/internal/runtime/executor/claude_executor_stop_reasons_test.go
+++ b/internal/runtime/executor/claude_executor_stop_reasons_test.go
@@ -2,6 +2,7 @@ package executor
import (
"context"
+ "errors"
"io"
"net/http"
"net/http/httptest"
@@ -9,11 +10,11 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// pause_turn end to end through the executor with the web search result cache:
@@ -53,7 +54,7 @@ func TestClaudeExecutorPauseTurnContinuationRestoresFinishedSearchAndKeepsPaused
executor := NewClaudeExecutor(enabledClaudeWebSearchCacheConfig())
auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-pause", "base_url": server.URL}}
- run := func(input string) gjson.Result {
+ run := func(input string) ojson.Result {
payload := []byte(`{"model":"claude-opus-5-5","stream":` + map[bool]string{false: "false", true: "true"}[stream] + `,"tools":[{"type":"web_search"}],"input":[` + input + `]}`)
req := cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: payload}
opts := cliproxyexecutor.Options{
@@ -66,20 +67,20 @@ func TestClaudeExecutorPauseTurnContinuationRestoresFinishedSearchAndKeepsPaused
if errExecute != nil {
t.Fatal(errExecute)
}
- return gjson.ParseBytes(resp.Payload)
+ return ojson.ParseBytes(resp.Payload)
}
result, errStream := executor.ExecuteStream(context.Background(), auth, req, opts)
if errStream != nil {
t.Fatal(errStream)
}
- var terminal gjson.Result
+ var terminal ojson.Result
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
for _, line := range strings.Split(string(chunk.Payload), "\n") {
- if data, ok := strings.CutPrefix(line, "data: "); ok && gjson.Get(data, "type").String() == "response.completed" {
- terminal = gjson.Get(data, "response")
+ if data, ok := strings.CutPrefix(line, "data: "); ok && ojson.Get(data, "type").String() == "response.completed" {
+ terminal = ojson.Get(data, "response")
}
}
}
@@ -106,7 +107,7 @@ func TestClaudeExecutorPauseTurnContinuationRestoresFinishedSearchAndKeepsPaused
if len(bodies) != 2 {
t.Fatalf("upstream requests = %d", len(bodies))
}
- messages := gjson.GetBytes(bodies[1], "messages").Array()
+ messages := ojson.GetBytes(bodies[1], "messages").Array()
last := messages[len(messages)-1]
blocks := last.Get("content").Array()
var types []string
@@ -146,13 +147,13 @@ func TestClaudeExecutorChatContextWindowExceededMapsToLength(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute err = %v, want nil", errExecute)
}
- if got := gjson.GetBytes(resp.Payload, "choices.0.finish_reason").String(); got != "length" {
+ if got := ojson.GetBytes(resp.Payload, "choices.0.finish_reason").String(); got != "length" {
t.Fatalf("finish_reason = %q, want length: %s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "cut" {
+ if got := ojson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "cut" {
t.Fatalf("content = %q, want cut: %s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "usage.completion_tokens").Int(); got != 2 {
+ if got := ojson.GetBytes(resp.Payload, "usage.completion_tokens").Int(); got != 2 {
t.Fatalf("completion_tokens = %d, want 2: %s", got, resp.Payload)
}
}
@@ -177,7 +178,111 @@ func TestClaudeExecutorChatRefusalStaysSuccessful(t *testing.T) {
if errExecute != nil {
t.Fatal(errExecute)
}
- if got := gjson.GetBytes(resp.Payload, "choices.0.finish_reason").String(); got != "content_filter" {
+ if got := ojson.GetBytes(resp.Payload, "choices.0.finish_reason").String(); got != "content_filter" {
t.Fatalf("finish_reason = %q: %s", got, resp.Payload)
}
}
+
+func TestWrapClaudeFastRequestErrorPreservesClassifiedStatusCode(t *testing.T) {
+ inner := statusErr{code: http.StatusBadRequest, msg: "claude refusal (cyber): blocked"}
+ wrapped := wrapClaudeFastRequestError(true, http.StatusOK, inner)
+ var statusCoder interface{ StatusCode() int }
+ if !errors.As(wrapped, &statusCoder) || statusCoder == nil {
+ t.Fatalf("expected wrapped error to implement StatusCode()")
+ }
+ if got := statusCoder.StatusCode(); got != http.StatusBadRequest {
+ t.Fatalf("statusCoder.StatusCode() = %d, want %d", got, http.StatusBadRequest)
+ }
+}
+
+func TestClaudeExecutorExecuteReturnsBadRequestOnPolicyRefusal(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"model":"claude-sonnet-5-5","id":"msg_cyber","type":"message","role":"assistant","content":[],"stop_reason":"refusal","stop_sequence":null,"stop_details":{"type":"refusal","category":"cyber","explanation":"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback."},"usage":{"input_tokens":4,"output_tokens":0}}`))
+ }))
+ defer server.Close()
+
+ auth := &cliproxyauth.Auth{ID: "cyber-refusal", Attributes: map[string]string{"api_key": "sk-ant-test", "base_url": server.URL}}
+ payload := []byte(`{"model":"claude-sonnet-5-5","stream":false,"messages":[{"role":"user","content":"test"}]}`)
+ _, errExecute := NewClaudeExecutor(&config.Config{}).Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-sonnet-5-5", Payload: payload,
+ }, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload})
+ if errExecute == nil {
+ t.Fatal("expected policy refusal error, got nil")
+ }
+ var statusCoder interface{ StatusCode() int }
+ if !errors.As(errExecute, &statusCoder) || statusCoder == nil || statusCoder.StatusCode() != http.StatusBadRequest {
+ t.Fatalf("expected HTTP 400 status error, got %v", errExecute)
+ }
+}
+
+func TestClaudeExecutorStreamSparseStopDetailsPreservesMessageStop(t *testing.T) {
+ sseBody := strings.Join([]string{
+ `event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_sparse","type":"message","model":"claude-sonnet-5-5","role":"assistant","content":[],"usage":{"input_tokens":4,"output_tokens":0}}}`,
+ `event: message_delta` + "\n" + `data: {"type":"message_delta","delta":{"stop_reason":"refusal","stop_details":{"type":"refusal"}},"usage":{"output_tokens":0}}`,
+ `event: message_stop` + "\n" + `data: {"type":"message_stop"}`,
+ }, "\n\n") + "\n\n"
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(sseBody))
+ }))
+ defer server.Close()
+
+ for _, tc := range []struct {
+ name string
+ sourceFormat sdktranslator.Format
+ responseFormat sdktranslator.Format
+ payload string
+ wantStopMarker string
+ }{
+ {
+ name: "passthrough_claude",
+ sourceFormat: sdktranslator.FormatClaude,
+ responseFormat: sdktranslator.FormatClaude,
+ payload: `{"model":"claude-sonnet-5-5","stream":true,"messages":[{"role":"user","content":"test"}]}`,
+ wantStopMarker: "message_stop",
+ },
+ {
+ name: "translated_openai",
+ sourceFormat: sdktranslator.FormatOpenAI,
+ responseFormat: sdktranslator.FormatOpenAI,
+ payload: `{"model":"claude-sonnet-5-5","stream":true,"messages":[{"role":"user","content":"test"}]}`,
+ wantStopMarker: "content_filter",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ auth := &cliproxyauth.Auth{ID: "sparse-refusal-" + tc.name, Attributes: map[string]string{"api_key": "sk-ant-test", "base_url": server.URL}}
+ result, errStream := NewClaudeExecutor(&config.Config{}).ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-sonnet-5-5",
+ Payload: []byte(tc.payload),
+ }, cliproxyexecutor.Options{
+ SourceFormat: tc.sourceFormat,
+ ResponseFormat: tc.responseFormat,
+ Stream: true,
+ OriginalRequest: []byte(tc.payload),
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+
+ var output strings.Builder
+ var streamErr error
+ for chunk := range result.Chunks {
+ if len(chunk.Payload) > 0 {
+ output.Write(chunk.Payload)
+ }
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ }
+ }
+ if !strings.Contains(output.String(), tc.wantStopMarker) {
+ t.Fatalf("expected %q in stream output before refusal error, got: %s", tc.wantStopMarker, output.String())
+ }
+ var statusCoder interface{ StatusCode() int }
+ if !errors.As(streamErr, &statusCoder) || statusCoder == nil || statusCoder.StatusCode() != http.StatusBadRequest {
+ t.Fatalf("expected HTTP 400 refusal error, got %v", streamErr)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/claude_executor_stream.go b/internal/runtime/executor/claude_executor_stream.go
index bb441d2c7bb..171e94ef1ae 100644
--- a/internal/runtime/executor/claude_executor_stream.go
+++ b/internal/runtime/executor/claude_executor_stream.go
@@ -10,13 +10,15 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
@@ -84,37 +86,39 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
ctx = helps.WithClaudeSessionID(ctx, claudeSessionID)
}
+ webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
+ ctx = helps.WithClaudeWebSearchResultRestore(ctx, webSearchSessionKey, webSearchTTL)
+
isCompat := helps.APIKeyModelIsCompat(req)
originalTranslated, body, err := helps.TranslateRequestPairReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
- body = helps.SetStringIfDifferent(body, "model", upstreamModel)
- body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body)
+ doc := ojson.NewDoc(body)
+ helps.SetStringIfDifferentDoc(doc, "model", upstreamModel)
+ helps.NormalizeClaudeToolChoiceDoc(e.upstreamRequestLogProvider(), upstreamModel, doc)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return nil, err
}
- webSearchSessionKey, webSearchTTL, webSearchLimit := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
- body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
+ helps.RestoreClaudeWebSearchResultsContextDoc(ctx, doc, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
- body = rebuildMidSystemMessagesToTopLevel(body)
+ rebuildMidSystemMessagesToTopLevelDoc(doc)
}
// Apply cloaking (system prompt injection, fake user ID, sensitive word obfuscation)
// based on client type and configuration.
- isProbeOrHelper := helps.IsClaudeProbeOrHelperRequest(body)
- explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body)
+ isProbeOrHelper := isClaudeProbeOrHelperRequestDoc(doc)
+ explicitCacheMode := isExplicitPromptCacheModeDoc(doc, originalPayload, req.Payload)
if explicitCacheMode {
ctx = withExplicitPromptCacheMode(ctx, true)
}
var cloaked bool
- body, cloaked, err = applyCloakingInternal(
+ cloaked, _, err = applyCloakingInternalDoc(
ctx,
e.cfg,
auth,
- body,
+ doc,
apiKey,
confirmedClaudeCode,
cchSigning,
@@ -128,7 +132,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
// keeps its own shape and other gateways never see this field.
diagnosticsState := claudeDiagnosticsRequestState{}
if !isProbeOrHelper {
- isProbeOrHelper = helps.IsClaudeProbeOrHelperRequest(body)
+ isProbeOrHelper = isClaudeProbeOrHelperRequestDoc(doc)
}
if continuityCtx.Initialized {
diagnosticsState = claudeDiagnosticsRequestState{
@@ -139,25 +143,25 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
contextManagementState := claudeCodeContextManagementState{
eligible: cloaked && isAnthropicUpstreamBase(baseURL),
- callerOwned: gjson.GetBytes(body, "context_management").Exists(),
+ callerOwned: doc.Get("context_management").Exists(),
}
if contextManagementState.eligible {
- body, contextManagementState.automaticallyInjected = injectClaudeCodeContextManagement(body)
+ contextManagementState.automaticallyInjected = injectClaudeCodeContextManagementDoc(doc)
if fp.InjectDiagnostics && !isProbeOrHelper {
if continuityCtx.Initialized {
- body, diagnosticsState = injectClaudeDiagnosticsWithState(body, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID)
+ diagnosticsState = injectClaudeDiagnosticsWithStateDoc(doc, continuityCtx.Key, continuityCtx.Sequence, continuityCtx.PreviousMessageID, continuityCtx.PromptID)
} else {
- body, diagnosticsState = injectClaudeDiagnostics(body, auth, claudeSessionID)
+ diagnosticsState = injectClaudeDiagnosticsDoc(doc, auth, claudeSessionID)
}
}
}
- body = ensureModelMaxTokens(body, baseModel)
+ ensureModelMaxTokensDoc(doc, baseModel)
// Disable thinking if tool_choice forces tool use (Anthropic API constraint)
- body = disableThinkingIfToolChoiceForced(body)
- body = reconcileClaudeCodeContextManagement(body, contextManagementState)
- body = normalizeClaudeSamplingForUpstream(body, confirmedClaudeCode)
+ disableThinkingIfToolChoiceForcedDoc(doc)
+ reconcileClaudeCodeContextManagementDoc(doc, contextManagementState)
+ normalizeClaudeSamplingForUpstreamDoc(doc, confirmedClaudeCode)
// Default cache_control for translated entrypoints (Responses/Chat/Gemini) and other
// non-native callers. Confirmed native Claude Code owns its marker placement and must
@@ -165,16 +169,16 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
// first-user marker cannot suppress system/latest-user breakpoints.
// cloaked and confirmedClaudeCode are mutually exclusive: resolveClaudeWirePolicy
// forces Cloak off for a confirmed native client.
- cpaOwnsCacheControl := shouldEnsureCacheControl(body, cloaked, confirmedClaudeCode, originalPayload, req.Payload)
+ cpaOwnsCacheControl := shouldEnsureCacheControlDoc(doc, cloaked, confirmedClaudeCode, originalPayload, req.Payload)
if cpaOwnsCacheControl {
- body = ensureCacheControl(body)
+ ensureCacheControlDoc(doc)
}
// Enforce Anthropic's cache_control block limit (max 4 breakpoints per request).
// Cloaking and ensureCacheControl may push the total over 4 when the client
// already sends multiple cache_control blocks.
- body = enforceCacheControlLimit(body, 4)
- body = stripPromptCacheOptions(body)
+ enforceCacheControlLimitDoc(doc, 4)
+ stripPromptCacheOptionsDoc(doc)
// Native selects the 1h cache pool only for OAuth credentials and pairs it with
// extended-cache-ttl-2025-04-11, which claudeCodeCLIBetas emits on exactly the
@@ -189,66 +193,66 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
// claude-code-cli fingerprint profiles emit extended-cache-ttl and must use the same 1h pool.
// In native Claude Code, subagents default to 5m unless 1h is explicitly configured;
// probes omit both 1h cache and extended-cache-ttl.
- isSubagent := helps.IsClaudeSubagentRequest(incomingHeaders, body)
- subagent1h := isSubagent && helps.ClaudeSubagentRequests1h(incomingHeaders, body)
+ isSubagent := isClaudeSubagentRequestDoc(incomingHeaders, doc)
+ subagent1h := isSubagent && claudeSubagentRequests1hDoc(incomingHeaders, doc)
if cpaOwnsCacheControl && fp.ProfileClaudeCodeCLI && (!isSubagent || subagent1h) && !isProbeOrHelper {
- body = upgradeClaudeCacheControlTTL(body, claudeCacheControlTTL1h)
+ upgradeClaudeCacheControlTTLDoc(doc, claudeCacheControlTTL1h)
} else if (isProbeOrHelper || (isSubagent && !subagent1h)) && !explicitCacheMode {
- body = stripClaudeCacheControlTTL(body)
+ stripClaudeCacheControlTTLDoc(doc)
}
// Normalize TTL values to prevent ordering violations under prompt-caching-scope-2026-01-05.
if !explicitCacheMode {
- body = normalizeCacheControlTTL(body)
+ normalizeCacheControlTTLDoc(doc)
}
// Extract betas from body and convert to header
var extraBetas []string
- bodyForTranslation := body
- bodyForUpstream := body
var oauthToolNamesReverseMap map[string]string
if fp.MCPAlias && cloaked {
mcpAliases := resolveClaudeMCPAliasOptions(ctx, opts.Metadata, req.Metadata)
- bodyForUpstream, oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequest(bodyForUpstream, mcpAliases)
+ oauthToolNamesReverseMap, err = e.prepareClaudeOAuthToolNamesForRequestDoc(doc, mcpAliases)
if err != nil {
return nil, err
}
}
- bodyForUpstream = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, bodyForUpstream, baseModel, helps.APIKeyModelIsCompat(req))
+ sanitizeClaudeMessagesForClaudeUpstreamWithDebugDoc(ctx, doc, baseModel, helps.APIKeyModelIsCompat(req))
if fp.ApplyCLIIdentity {
- bodyForUpstream, err = applyClaudeCLIIdentity(bodyForUpstream, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity)
+ err = applyClaudeCLIIdentityDoc(doc, auth, apiKey, url, claudeSessionID, fp.SynthesizeIdentity)
if err != nil {
return nil, err
}
}
if cloaked && len(wireSettings.sensitiveWords) > 0 {
matcher := helps.BuildSensitiveWordMatcher(wireSettings.sensitiveWords)
- bodyForUpstream = helps.ObfuscateSensitiveWords(bodyForUpstream, matcher)
+ helps.ObfuscateSensitiveWordsDoc(doc, matcher)
}
cchBilling := ""
if cchSigning {
- if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallback(bodyForUpstream) {
- cchBilling = claudeCCHFallbackBillingHeader(ctx, e.cfg, bodyForUpstream, claudeCodeDetection.Entrypoint)
+ if !claudeCodeDetection.HelperProfile || claudeBodyNeedsBillingFallbackDoc(doc) {
+ cchBilling = claudeCCHFallbackBillingHeaderDoc(ctx, e.cfg, doc, claudeCodeDetection.Entrypoint)
}
- bodyForUpstream, err = ensureClaudeBillingHeaderCCHPlaceholder(bodyForUpstream, cchBilling)
+ err = ensureClaudeBillingHeaderCCHPlaceholderDoc(doc, cchBilling)
if err != nil {
return nil, fmt.Errorf("finalize Claude CCH: %w", err)
}
}
- bodyForUpstream = stripDefaultKimiClaudeCodeAttribution(auth, url, fp.ProfileClaudeCodeCLI, bodyForUpstream)
- bodyForUpstream = applyClaudePreservedThinkingBinding(bodyForUpstream)
+ stripDefaultKimiClaudeCodeAttributionDoc(auth, url, fp.ProfileClaudeCodeCLI, doc)
+ applyClaudePreservedThinkingBindingDoc(doc)
// User rules match the fully prepared business body and are applied only once.
- var touchedPayloadPaths map[string]bool
- bodyForUpstream, touchedPayloadPaths = helps.ApplyPayloadConfigWithTrackedPaths(
- e.cfg, baseModel, to.String(), from.String(), "", bodyForUpstream, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated),
+ touchedPayloadPaths := helps.ApplyPayloadConfigWithTrackedPathsDoc(
+ e.cfg, baseModel, to.String(), from.String(), "", doc, stripTranslatedDefaultMaxTokens(from, originalPayload, originalTranslated),
helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers,
"diagnostics",
)
if touchedPayloadPaths["diagnostics"] {
diagnosticsState = claudeDiagnosticsRequestState{}
}
- extraBetas, bodyForUpstream = extractAndRemoveBetas(bodyForUpstream)
- bodyForUpstream = stripPromptCacheOptions(bodyForUpstream)
+ extraBetas, _ = extractAndRemoveBetasDoc(doc)
+ stripPromptCacheOptionsDoc(doc)
+ bodyForUpstream := doc.Bytes()
+ body = bodyForUpstream
+ bodyForTranslation := bodyForUpstream
if cchSigning {
bodyForUpstream, err = signAnthropicMessagesBody(bodyForUpstream)
if err != nil {
@@ -424,7 +428,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
emitCancellation(ctx.Err())
return
}
- if upstreamCompleted || upstreamStreamErr != nil {
+ if upstreamCompleted || (upstreamStreamErr != nil && !clienterror.IsClaudePolicyRefusalError(upstreamStreamErr)) {
break
}
}
@@ -433,7 +437,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
emitCancellation(ctx.Err())
return
}
- if !upstreamCompleted {
+ if !upstreamCompleted || upstreamStreamErr != nil {
if emitCancellation(scanner.Err()) {
return
}
@@ -524,7 +528,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
- if upstreamCompleted || upstreamStreamErr != nil {
+ if upstreamCompleted || (upstreamStreamErr != nil && !clienterror.IsClaudePolicyRefusalError(upstreamStreamErr)) {
break
}
}
@@ -536,7 +540,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
- if !upstreamCompleted {
+ if !upstreamCompleted || upstreamStreamErr != nil {
if emitCancellation(scanner.Err()) {
return
}
@@ -588,11 +592,11 @@ func validateClaudeStreamingResponse(data []byte) error {
continue
}
hasData = true
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
return statusErr{code: http.StatusBadGateway, msg: "claude executor: upstream returned malformed stream data"}
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "error":
message := strings.TrimSpace(root.Get("error.message").String())
diff --git a/internal/runtime/executor/claude_executor_subagent_ttl_regression_test.go b/internal/runtime/executor/claude_executor_subagent_ttl_regression_test.go
index dcb37a0b29b..0d995a078f8 100644
--- a/internal/runtime/executor/claude_executor_subagent_ttl_regression_test.go
+++ b/internal/runtime/executor/claude_executor_subagent_ttl_regression_test.go
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// TestClaudeExecutor_SubagentPreserves1hTTLAndExtendedCacheBetaWhenRequested verifies
@@ -76,7 +76,7 @@ func TestClaudeExecutor_SubagentPreserves1hTTLAndExtendedCacheBetaWhenRequested(
if !has1h {
t.Fatalf("subagent body must preserve ttl: 1h when requested by client, got: %s", rawBody)
}
- for _, msg := range gjson.GetBytes(seenBody, "messages").Array() {
+ for _, msg := range ojson.GetBytes(seenBody, "messages").Array() {
for _, content := range msg.Get("content").Array() {
if cc := content.Get("cache_control"); cc.Exists() {
if gotTTL := cc.Get("ttl").String(); gotTTL != "1h" {
diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go
index a090b4c65a7..c9d39fed00e 100644
--- a/internal/runtime/executor/claude_executor_test.go
+++ b/internal/runtime/executor/claude_executor_test.go
@@ -4,10 +4,10 @@ import (
"bytes"
"compress/gzip"
"context"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
@@ -16,6 +16,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/andybalholm/brotli"
"github.com/gin-gonic/gin"
"github.com/klauspost/compress/zstd"
@@ -27,8 +28,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func resetClaudeDeviceProfileCache() {
@@ -126,8 +125,8 @@ func TestApplyClaudeHeaders_FastModeBetaIsConditional(t *testing.T) {
func assertClaudeCredentialIdentity(t *testing.T, body []byte, headers http.Header, deviceIDs []string, accountUUID string) {
t.Helper()
- userID := gjson.GetBytes(body, "metadata.user_id").String()
- deviceID := gjson.Get(userID, "device_id").String()
+ userID := ojson.GetBytes(body, "metadata.user_id").String()
+ deviceID := ojson.Get(userID, "device_id").String()
inPool := false
for _, candidate := range deviceIDs {
if deviceID == candidate {
@@ -138,10 +137,10 @@ func assertClaudeCredentialIdentity(t *testing.T, body []byte, headers http.Head
if !inPool {
t.Fatalf("device_id = %q, want selected credential device pool entry", deviceID)
}
- if got := gjson.Get(userID, "account_uuid").String(); got != accountUUID {
+ if got := ojson.Get(userID, "account_uuid").String(); got != accountUUID {
t.Fatalf("account_uuid = %q, want selected credential account %q", got, accountUUID)
}
- sessionID := gjson.Get(userID, "session_id").String()
+ sessionID := ojson.Get(userID, "session_id").String()
if sessionID == "" || sessionID != headers.Get("X-Claude-Code-Session-Id") {
t.Fatalf("metadata session_id = %q, header session ID = %q", sessionID, headers.Get("X-Claude-Code-Session-Id"))
}
@@ -160,7 +159,7 @@ func assertClaudeCredentialIdentity(t *testing.T, body []byte, headers http.Head
// credential identity travels only on the header and on the Messages endpoint.
func assertClaudeCountTokensIdentity(t *testing.T, body []byte, headers http.Header) {
t.Helper()
- if got := gjson.GetBytes(body, "metadata"); got.Exists() {
+ if got := ojson.GetBytes(body, "metadata"); got.Exists() {
t.Fatalf("count_tokens metadata = %s, want it absent", got.Raw)
}
if got := headers.Get("X-Claude-Code-Session-Id"); got == "" {
@@ -825,7 +824,7 @@ func TestClaudeExecutor_NonClaudeRequestUsesClaudeCode220CLIFingerprint(t *testi
t.Fatalf("Anthropic-Beta = %q, want %q", seenHeaders.Get("Anthropic-Beta"), want)
}
- system := gjson.GetBytes(seenBody, "system").Array()
+ system := ojson.GetBytes(seenBody, "system").Array()
if len(system) != 2 {
t.Fatalf("system block count = %d, want 2: %s", len(system), seenBody)
}
@@ -847,18 +846,18 @@ func TestClaudeExecutor_NonClaudeRequestUsesClaudeCode220CLIFingerprint(t *testi
if betas := seenHeaders.Get("Anthropic-Beta"); strings.Contains(betas, claudeExtendedCacheTTLBeta) {
t.Fatalf("API-key request must not declare extended-cache-ttl: %s", betas)
}
- content := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ content := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("messages[0].content has %d blocks, want currentDate and user text", len(content))
}
assertClaudeCodeCurrentDateBlock(t, content[0])
assertEphemeralUserTextBlock(t, content[1], "x", "")
- userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
+ userID := ojson.GetBytes(seenBody, "metadata.user_id").String()
if !helps.IsValidUserID(userID) {
t.Fatalf("metadata.user_id = %q, want Claude Code JSON shape", userID)
}
- if got, want := gjson.Get(userID, "session_id").String(), seenHeaders.Get("X-Claude-Code-Session-Id"); got != want {
+ if got, want := ojson.Get(userID, "session_id").String(), seenHeaders.Get("X-Claude-Code-Session-Id"); got != want {
t.Fatalf("metadata session_id = %q, header session ID = %q", got, want)
}
}
@@ -906,13 +905,13 @@ func TestClaudeExecutor_ConfirmedNewerPatchClaudeCodeRequestPreservesInteractive
}
assertClaudeFingerprint(t, seenHeaders, "claude-cli/2.1.281 (external, cli)", "0.112.1", "v26.3.0", "MacOS", "arm64")
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "interactive-system" {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); got != "interactive-system" {
t.Fatalf("system.0.text = %q, want confirmed client system preserved", got)
}
- if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 1 {
t.Fatalf("system block count = %d, want 1", got)
}
- if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
+ if got := ojson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
t.Fatalf("metadata.user_id = %q, want preserved %q", got, userID)
}
if got := seenHeaders.Get("Anthropic-Beta"); got != incoming.Get("Anthropic-Beta") {
@@ -980,11 +979,11 @@ func TestClaudeExecutor_ConfirmedClaudeCodeWithoutCacheControlPreservesContent(t
t.Fatalf("Execute() error = %v", errExecute)
}
- content := gjson.GetBytes(seenBody, "messages.0.content")
- if content.Type != gjson.String || content.String() != "x" {
+ content := ojson.GetBytes(seenBody, "messages.0.content")
+ if content.Type != ojson.String || content.String() != "x" {
t.Fatalf("messages.0.content = %s, want native string content preserved; body=%s", content.Raw, seenBody)
}
- if gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("confirmed markerless native request received synthetic cache_control: %s", seenBody)
}
})
@@ -1032,7 +1031,7 @@ func TestClaudeExecutor_ConfirmedCLIForwardsNativeOpus55Shape(t *testing.T) {
if got := seenHeaders.Get("Anthropic-Beta"); got != betas {
t.Errorf("native betas = %q, want %q", got, betas)
}
- blocks := gjson.GetBytes(seenBody, "system").Array()
+ blocks := ojson.GetBytes(seenBody, "system").Array()
if len(blocks) != 3 {
t.Fatalf("native system blocks = %d, want 3", len(blocks))
}
@@ -1044,7 +1043,7 @@ func TestClaudeExecutor_ConfirmedCLIForwardsNativeOpus55Shape(t *testing.T) {
if blocks[1].Get("text").String() != "native identity" || blocks[2].Get("text").String() != "native model block" {
t.Error("native system prompt blocks were rebuilt")
}
- if got := gjson.GetBytes(seenBody, "fallbacks.0.model").String(); got != "claude-opus-4-8" {
+ if got := ojson.GetBytes(seenBody, "fallbacks.0.model").String(); got != "claude-opus-4-8" {
t.Errorf("native fallback = %q, want claude-opus-4-8", got)
}
}
@@ -1103,22 +1102,22 @@ func TestClaudeExecutor_ConfirmedVSCodeAgentSDKRequestPreservesIdentity(t *testi
if got := seenHeaders.Get("X-Claude-Code-Session-Id"); got != sessionID {
t.Fatalf("X-Claude-Code-Session-Id = %q, want preserved %q", got, sessionID)
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != billingHeader {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); got != billingHeader {
t.Fatalf("system.0.text = %q, want VSCode attribution preserved", got)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
t.Fatalf("system.1.text = %q, want VSCode Agent SDK identity preserved", got)
}
- if got := gjson.GetBytes(seenBody, "system.1.cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytes(seenBody, "system.1.cache_control.ttl").String(); got != "1h" {
t.Fatalf("system.1.cache_control.ttl = %q, want preserved 1h", got)
}
- if got := gjson.GetBytes(seenBody, "system.2.text").String(); got != "vscode-agent-system" {
+ if got := ojson.GetBytes(seenBody, "system.2.text").String(); got != "vscode-agent-system" {
t.Fatalf("system.2.text = %q, want VSCode Agent SDK system preserved", got)
}
- if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 3 {
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 3 {
t.Fatalf("system block count = %d, want 3", got)
}
- if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
+ if got := ojson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
t.Fatalf("metadata.user_id = %q, want preserved %q", got, userID)
}
}
@@ -1160,19 +1159,19 @@ func TestClaudeExecutor_CopiedVSCodeAgentSDKHeadersWithoutMetadataAreCloaked(t *
if got := seenHeaders.Get("User-Agent"); got != "claude-cli/2.1.280 (external, cli)" {
t.Fatalf("User-Agent = %q, want CLI cloak", got)
}
- if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 3 {
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 3 {
t.Fatalf("system block count = %d, want billing, CLI identity, and caller system", got)
}
- if got := gjson.GetBytes(seenBody, "system.2.text").String(); got != "spoofed-system" {
+ if got := ojson.GetBytes(seenBody, "system.2.text").String(); got != "spoofed-system" {
t.Fatalf("system.2.text = %q, want spoofed-system", got)
}
- content := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ content := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("messages[0].content has %d blocks, want currentDate and user text", len(content))
}
assertClaudeCodeCurrentDateBlock(t, content[0])
assertEphemeralUserTextBlock(t, content[1], "x", "")
- if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
t.Fatalf("message count = %d, want no trailing system turn", got)
}
}
@@ -1214,10 +1213,10 @@ func TestClaudeExecutor_AgentSDKEntrypointWithStrongSignalsUsesCLICloak(t *testi
if got := seenHeaders.Get("User-Agent"); got != "claude-cli/2.1.280 (external, cli)" {
t.Fatalf("User-Agent = %q, want CLI cloak", got)
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); !strings.Contains(got, "cc_entrypoint=cli;") {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); !strings.Contains(got, "cc_entrypoint=cli;") {
t.Fatalf("billing attribution = %q, want cli", got)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
t.Fatalf("system.1.text = %q, want official CLI identity", got)
}
}
@@ -1268,18 +1267,18 @@ func TestClaudeExecutor_ConfirmedVSCodeOAuthPreservesToolNames(t *testing.T) {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(seenBody, "tools.0.name").String(); got != "bash" {
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); got != "bash" {
t.Fatalf("tools.0.name = %q, want confirmed native known name preserved", got)
}
- if got := gjson.GetBytes(seenBody, "tools.1.name").String(); got != "search_web" {
+ if got := ojson.GetBytes(seenBody, "tools.1.name").String(); got != "search_web" {
t.Fatalf("tools.1.name = %q, want confirmed native unknown name preserved", got)
}
assertClaudeCredentialIdentity(t, seenBody, seenHeaders, deviceIDs, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
- upstreamUserID := gjson.GetBytes(seenBody, "metadata.user_id").String()
- if upstreamDeviceID := gjson.Get(upstreamUserID, "device_id").String(); upstreamDeviceID == strings.Repeat("c", 64) {
+ upstreamUserID := ojson.GetBytes(seenBody, "metadata.user_id").String()
+ if upstreamDeviceID := ojson.Get(upstreamUserID, "device_id").String(); upstreamDeviceID == strings.Repeat("c", 64) {
t.Fatalf("device_id = %q, want native device replaced by credential pool", upstreamDeviceID)
}
- if got := gjson.Get(upstreamUserID, "session_id").String(); got != "33333333-4444-4555-8666-777777777777" {
+ if got := ojson.Get(upstreamUserID, "session_id").String(); got != "33333333-4444-4555-8666-777777777777" {
t.Fatalf("session_id = %q, want downstream agent session", got)
}
if got := seenHeaders.Get("X-Claude-Code-Session-Id"); got != "33333333-4444-4555-8666-777777777777" {
@@ -1342,28 +1341,113 @@ func TestClaudeExecutor_ConfirmedSDKTSAgentSDKRequestPassesThroughWithRealProfil
}
assertClaudeFingerprint(t, seenHeaders, sdkTSUA, "0.128.0", "v26.3.0", "MacOS", "arm64")
- if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 3 {
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 3 {
t.Fatalf("system block count = %d, want 3: %s", got, seenBody)
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); !strings.HasPrefix(got, "x-anthropic-billing-header: cc_version=2.1.304.179; cc_entrypoint=sdk-ts; cch=") {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); !strings.HasPrefix(got, "x-anthropic-billing-header: cc_version=2.1.304.179; cc_entrypoint=sdk-ts; cch=") {
t.Fatalf("system.0.text = %q, want sdk-ts billing header with signed cch", got)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." {
t.Fatalf("system.1.text = %q, want Agent SDK CLI identity preserved", got)
}
- if got := gjson.GetBytes(seenBody, "system.2.text").String(); got != "t3-code-system-prompt" {
+ if got := ojson.GetBytes(seenBody, "system.2.text").String(); got != "t3-code-system-prompt" {
t.Fatalf("system.2.text = %q, want caller system prompt preserved", got)
}
- if got := gjson.GetBytes(seenBody, "tools.0.name").String(); got != "Agent" {
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); got != "Agent" {
t.Fatalf("tools.0.name = %q, want Agent preserved", got)
}
- if got := gjson.GetBytes(seenBody, "tools.1.name").String(); got != "Bash" {
+ if got := ojson.GetBytes(seenBody, "tools.1.name").String(); got != "Bash" {
t.Fatalf("tools.1.name = %q, want Bash preserved", got)
}
- if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
t.Fatalf("messages.# = %d, want 1 (no relocated system turn)", got)
}
- if got := gjson.GetBytes(seenBody, "messages.0.content.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "messages.0.content.#").Int(); got != 1 {
+ t.Fatalf("messages.0.content.# = %d, want 1 (no currentDate pin injected)", got)
+ }
+}
+
+func TestClaudeExecutor_ConfiguredNativeEntrypointPassesThroughWithRealProfile(t *testing.T) {
+ helps.ResetClaudeDeviceProfileCache()
+ var seenBody []byte
+ var seenHeaders http.Header
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenBody, _ = io.ReadAll(r.Body)
+ seenHeaders = r.Header.Clone()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
+ }))
+ defer server.Close()
+
+ const sessionID = "33333333-4444-4555-8666-777777777777"
+ const userID = `{"account_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","device_id":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","session_id":"33333333-4444-4555-8666-777777777777"}`
+ const t3CodeUA = "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276)"
+ const billingHeader = "x-anthropic-billing-header: cc_version=2.1.316.179; cc_entrypoint=t3-code;"
+ payload := []byte(`{"model":"claude-sonnet-5-5","max_tokens":1024,"system":[{"type":"text","text":` + fmt.Sprintf("%q", billingHeader) + `},{"type":"text","text":"You are a Claude agent, built on Anthropic's Claude Agent SDK.","cache_control":{"type":"ephemeral","ttl":"1h"}},{"type":"text","text":"t3-code-system-prompt","cache_control":{"type":"ephemeral","ttl":"1h"}}],"tools":[{"name":"Agent","description":"subagent","input_schema":{"type":"object"}},{"name":"Bash","description":"shell","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}],"metadata":{"user_id":` + fmt.Sprintf("%q", userID) + `},"thinking":{"type":"adaptive","display":"summarized"},"output_config":{"effort":"low"}}`)
+ incoming := http.Header{
+ "User-Agent": {t3CodeUA},
+ "X-App": {"cli"},
+ "Anthropic-Beta": {"claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,inline-tools-2026-09-15,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24,dangerous-tool-use-2026-09-03,afk-mode-2026-01-31,extended-cache-ttl-2025-04-11"},
+ "Anthropic-Dangerous-Direct-Browser-Access": {"true"},
+ "X-Claude-Code-Session-Id": {sessionID},
+ "X-Stainless-Package-Version": {"0.128.0"},
+ "X-Stainless-Runtime-Version": {"v26.3.0"},
+ "X-Stainless-Os": {"MacOS"},
+ "X-Stainless-Arch": {"arm64"},
+ }
+ deviceIDs := []string{"0000000000000000000000000000000000000000000000000000000000000000"}
+ executor := NewClaudeExecutor(&config.Config{
+ ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
+ NativeEntrypoints: []string{"t3-code"},
+ },
+ })
+ auth := &cliproxyauth.Auth{
+ ID: "auth-t3-code-oauth",
+ Attributes: map[string]string{
+ "api_key": "sk-ant-oat-native-t3-code",
+ "base_url": server.URL,
+ },
+ Metadata: map[string]any{
+ "account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ "claude_device_ids": deviceIDs,
+ },
+ }
+
+ _, errExecute := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "claude-sonnet-5-5",
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatClaude,
+ OriginalRequest: payload,
+ Headers: incoming,
+ })
+ if errExecute != nil {
+ t.Fatalf("Execute() error = %v", errExecute)
+ }
+
+ assertClaudeFingerprint(t, seenHeaders, t3CodeUA, "0.128.0", "v26.3.0", "MacOS", "arm64")
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 3 {
+ t.Fatalf("system block count = %d, want 3: %s", got, seenBody)
+ }
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); !strings.HasPrefix(got, "x-anthropic-billing-header: cc_version=2.1.316.179; cc_entrypoint=t3-code; cch=") {
+ t.Fatalf("system.0.text = %q, want t3-code billing header with signed cch", got)
+ }
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
+ t.Fatalf("system.1.text = %q, want Agent SDK identity preserved", got)
+ }
+ if got := ojson.GetBytes(seenBody, "system.2.text").String(); got != "t3-code-system-prompt" {
+ t.Fatalf("system.2.text = %q, want caller system prompt preserved", got)
+ }
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); got != "Agent" {
+ t.Fatalf("tools.0.name = %q, want Agent preserved", got)
+ }
+ if got := ojson.GetBytes(seenBody, "tools.1.name").String(); got != "Bash" {
+ t.Fatalf("tools.1.name = %q, want Bash preserved", got)
+ }
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
+ t.Fatalf("messages.# = %d, want 1 (no relocated system turn)", got)
+ }
+ if got := ojson.GetBytes(seenBody, "messages.0.content.#").Int(); got != 1 {
t.Fatalf("messages.0.content.# = %d, want 1 (no currentDate pin injected)", got)
}
}
@@ -1520,16 +1604,16 @@ func TestApplyClaudeToolPrefix(t *testing.T) {
input := []byte(`{"tools":[{"name":"alpha"},{"name":"proxy_bravo"}],"tool_choice":{"type":"tool","name":"charlie"},"messages":[{"role":"assistant","content":[{"type":"tool_use","name":"delta","id":"t1","input":{}}]}]}`)
out := applyClaudeToolPrefix(input, "proxy_")
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_alpha" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "proxy_alpha" {
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_alpha")
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_bravo" {
+ if got := ojson.GetBytes(out, "tools.1.name").String(); got != "proxy_bravo" {
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_bravo")
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "proxy_charlie" {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != "proxy_charlie" {
t.Fatalf("tool_choice.name = %q, want %q", got, "proxy_charlie")
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_delta" {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_delta" {
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_delta")
}
}
@@ -1538,10 +1622,10 @@ func TestApplyClaudeToolPrefix_WithToolReference(t *testing.T) {
input := []byte(`{"tools":[{"name":"alpha"}],"messages":[{"role":"user","content":[{"type":"tool_reference","tool_name":"beta"},{"type":"tool_reference","tool_name":"proxy_gamma"}]}]}`)
out := applyClaudeToolPrefix(input, "proxy_")
- if got := gjson.GetBytes(out, "messages.0.content.0.tool_name").String(); got != "proxy_beta" {
+ if got := ojson.GetBytes(out, "messages.0.content.0.tool_name").String(); got != "proxy_beta" {
t.Fatalf("messages.0.content.0.tool_name = %q, want %q", got, "proxy_beta")
}
- if got := gjson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != "proxy_gamma" {
+ if got := ojson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != "proxy_gamma" {
t.Fatalf("messages.0.content.1.tool_name = %q, want %q", got, "proxy_gamma")
}
}
@@ -1552,13 +1636,13 @@ func TestSanitizeClaudeWebSearchDomains(t *testing.T) {
input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search","allowed_domains":["anthropic.com"],"blocked_domains":[],"max_uses":8}]}`)
out := sanitizeClaudeWebSearchDomains(input)
- if gjson.GetBytes(out, "tools.0.blocked_domains").Exists() {
+ if ojson.GetBytes(out, "tools.0.blocked_domains").Exists() {
t.Fatalf("empty blocked_domains should be removed: %s", string(out))
}
- if got := gjson.GetBytes(out, "tools.0.allowed_domains").Array(); len(got) != 1 || got[0].String() != "anthropic.com" {
+ if got := ojson.GetBytes(out, "tools.0.allowed_domains").Array(); len(got) != 1 || got[0].String() != "anthropic.com" {
t.Fatalf("non-empty allowed_domains should be preserved: %s", string(out))
}
- if got := gjson.GetBytes(out, "tools.0.max_uses").Int(); got != 8 {
+ if got := ojson.GetBytes(out, "tools.0.max_uses").Int(); got != 8 {
t.Fatalf("max_uses should be preserved: got %d", got)
}
}
@@ -1568,10 +1652,10 @@ func TestSanitizeClaudeWebSearchDomains_LeavesNonBuiltinAndNonEmpty(t *testing.T
input := []byte(`{"tools":[{"type":"custom","name":"x","blocked_domains":[]},{"type":"web_search_20250305","name":"web_search","blocked_domains":["evil.com"]}]}`)
out := sanitizeClaudeWebSearchDomains(input)
- if !gjson.GetBytes(out, "tools.0.blocked_domains").Exists() {
+ if !ojson.GetBytes(out, "tools.0.blocked_domains").Exists() {
t.Fatalf("non-web_search tool fields should be untouched: %s", string(out))
}
- if got := gjson.GetBytes(out, "tools.1.blocked_domains").Array(); len(got) != 1 || got[0].String() != "evil.com" {
+ if got := ojson.GetBytes(out, "tools.1.blocked_domains").Array(); len(got) != 1 || got[0].String() != "evil.com" {
t.Fatalf("non-empty blocked_domains should be preserved: %s", string(out))
}
}
@@ -1580,10 +1664,10 @@ func TestApplyClaudeToolPrefix_SkipsBuiltinTools(t *testing.T) {
input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"},{"name":"my_custom_tool","input_schema":{"type":"object"}}]}`)
out := applyClaudeToolPrefix(input, "proxy_")
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
t.Fatalf("built-in tool name should not be prefixed: tools.0.name = %q, want %q", got, "web_search")
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_my_custom_tool" {
+ if got := ojson.GetBytes(out, "tools.1.name").String(); got != "proxy_my_custom_tool" {
t.Fatalf("custom tool should be prefixed: tools.1.name = %q, want %q", got, "proxy_my_custom_tool")
}
}
@@ -1603,16 +1687,16 @@ func TestApplyClaudeToolPrefix_BuiltinToolSkipped(t *testing.T) {
}`)
out := applyClaudeToolPrefix(body, "proxy_")
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
t.Fatalf("tools.0.name = %q, want %q", got, "web_search")
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != "web_search" {
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "web_search")
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Read" {
+ if got := ojson.GetBytes(out, "tools.1.name").String(); got != "proxy_Read" {
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Read")
}
- if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Read" {
+ if got := ojson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Read" {
t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Read")
}
}
@@ -1630,10 +1714,10 @@ func TestApplyClaudeToolPrefix_KnownBuiltinInHistoryOnly(t *testing.T) {
}`)
out := applyClaudeToolPrefix(body, "proxy_")
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != "web_search" {
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "web_search")
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Read")
}
}
@@ -1650,16 +1734,16 @@ func TestApplyClaudeToolPrefix_CustomToolsPrefixed(t *testing.T) {
}`)
out := applyClaudeToolPrefix(body, "proxy_")
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Read")
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "proxy_Write" {
+ if got := ojson.GetBytes(out, "tools.1.name").String(); got != "proxy_Write" {
t.Fatalf("tools.1.name = %q, want %q", got, "proxy_Write")
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_Read" {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != "proxy_Read" {
t.Fatalf("messages.0.content.0.name = %q, want %q", got, "proxy_Read")
}
- if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Write" {
+ if got := ojson.GetBytes(out, "messages.0.content.1.name").String(); got != "proxy_Write" {
t.Fatalf("messages.0.content.1.name = %q, want %q", got, "proxy_Write")
}
}
@@ -1674,7 +1758,7 @@ func TestApplyClaudeToolPrefix_ToolChoiceBuiltin(t *testing.T) {
}`)
out := applyClaudeToolPrefix(body, "proxy_")
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != "web_search" {
t.Fatalf("tool_choice.name = %q, want %q", got, "web_search")
}
}
@@ -1689,19 +1773,19 @@ func TestApplyClaudeToolPrefix_KnownFallbackBuiltinsRemainUnprefixed(t *testing.
}`, builtin, builtin, builtin, builtin))
out := applyClaudeToolPrefix(input, "proxy_")
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != builtin {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != builtin {
t.Fatalf("tool_choice.name = %q, want %q", got, builtin)
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != builtin {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != builtin {
t.Fatalf("messages.0.content.0.name = %q, want %q", got, builtin)
}
- if got := gjson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != builtin {
+ if got := ojson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != builtin {
t.Fatalf("messages.0.content.1.tool_name = %q, want %q", got, builtin)
}
- if got := gjson.GetBytes(out, "messages.0.content.2.content.0.tool_name").String(); got != builtin {
+ if got := ojson.GetBytes(out, "messages.0.content.2.content.0.tool_name").String(); got != builtin {
t.Fatalf("messages.0.content.2.content.0.tool_name = %q, want %q", got, builtin)
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "proxy_Read" {
t.Fatalf("tools.0.name = %q, want %q", got, "proxy_Read")
}
})
@@ -1712,10 +1796,10 @@ func TestStripClaudeToolPrefixFromResponse(t *testing.T) {
input := []byte(`{"content":[{"type":"tool_use","name":"proxy_alpha","id":"t1","input":{}},{"type":"tool_use","name":"bravo","id":"t2","input":{}}]}`)
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
- if got := gjson.GetBytes(out, "content.0.name").String(); got != "alpha" {
+ if got := ojson.GetBytes(out, "content.0.name").String(); got != "alpha" {
t.Fatalf("content.0.name = %q, want %q", got, "alpha")
}
- if got := gjson.GetBytes(out, "content.1.name").String(); got != "bravo" {
+ if got := ojson.GetBytes(out, "content.1.name").String(); got != "bravo" {
t.Fatalf("content.1.name = %q, want %q", got, "bravo")
}
}
@@ -1724,10 +1808,10 @@ func TestStripClaudeToolPrefixFromResponse_WithToolReference(t *testing.T) {
input := []byte(`{"content":[{"type":"tool_reference","tool_name":"proxy_alpha"},{"type":"tool_reference","tool_name":"bravo"}]}`)
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
- if got := gjson.GetBytes(out, "content.0.tool_name").String(); got != "alpha" {
+ if got := ojson.GetBytes(out, "content.0.tool_name").String(); got != "alpha" {
t.Fatalf("content.0.tool_name = %q, want %q", got, "alpha")
}
- if got := gjson.GetBytes(out, "content.1.tool_name").String(); got != "bravo" {
+ if got := ojson.GetBytes(out, "content.1.tool_name").String(); got != "bravo" {
t.Fatalf("content.1.tool_name = %q, want %q", got, "bravo")
}
}
@@ -1740,7 +1824,7 @@ func TestStripClaudeToolPrefixFromStreamLine(t *testing.T) {
if bytes.HasPrefix(payload, []byte("data:")) {
payload = bytes.TrimSpace(payload[len("data:"):])
}
- if got := gjson.GetBytes(payload, "content_block.name").String(); got != "alpha" {
+ if got := ojson.GetBytes(payload, "content_block.name").String(); got != "alpha" {
t.Fatalf("content_block.name = %q, want %q", got, "alpha")
}
}
@@ -1753,7 +1837,7 @@ func TestStripClaudeToolPrefixFromStreamLine_WithToolReference(t *testing.T) {
if bytes.HasPrefix(payload, []byte("data:")) {
payload = bytes.TrimSpace(payload[len("data:"):])
}
- if got := gjson.GetBytes(payload, "content_block.tool_name").String(); got != "beta" {
+ if got := ojson.GetBytes(payload, "content_block.tool_name").String(); got != "beta" {
t.Fatalf("content_block.tool_name = %q, want %q", got, "beta")
}
}
@@ -1761,7 +1845,7 @@ func TestStripClaudeToolPrefixFromStreamLine_WithToolReference(t *testing.T) {
func TestApplyClaudeToolPrefix_PreservesNestedMCPToolReference(t *testing.T) {
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":[{"type":"tool_reference","tool_name":"mcp__nia__manage_resource"}]}]}]}`)
out := applyClaudeToolPrefix(input, "proxy_")
- got := gjson.GetBytes(out, "messages.0.content.0.content.0.tool_name").String()
+ got := ojson.GetBytes(out, "messages.0.content.0.content.0.tool_name").String()
if got != "mcp__nia__manage_resource" {
t.Fatalf("nested tool_reference tool_name = %q, want MCP name preserved", got)
}
@@ -1805,7 +1889,7 @@ func TestClaudeExecutor_ExecuteStripsOpenAIEncryptedThinkingBeforeUpstream(t *te
if strings.Contains(string(seenBody), "gAAAAABopenai-encrypted-content") || strings.Contains(string(seenBody), "codex reasoning") {
t.Fatalf("invalid thinking block was forwarded: %s", string(seenBody))
}
- content := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ content := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(seenBody))
}
@@ -1856,7 +1940,7 @@ func TestClaudeExecutor_ExecuteStripsForeignToolUseSignaturesBeforeUpstream(t *t
if len(seenBody) == 0 {
t.Fatal("expected request body to be captured")
}
- toolUse := gjson.GetBytes(seenBody, "messages.0.content.0")
+ toolUse := ojson.GetBytes(seenBody, "messages.0.content.0")
if !toolUse.Get("type").Exists() || toolUse.Get("type").String() != "tool_use" {
t.Fatalf("tool_use block was not preserved: %s", string(seenBody))
}
@@ -1892,7 +1976,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_RestoresCommentaryMarkerForNonC
body := []byte(`{"model":"kimi-k2.5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"COMMENTARY_SENTINEL","signature":"cpa:commentary:v1"},{"type":"thinking","thinking":"reason","signature":"skip_thought_signature_validator"},{"type":"text","text":"FINAL_SENTINEL"}]}]}`)
out := sanitizeClaudeMessagesForClaudeUpstreamWithDebug(context.Background(), body, "kimi-k2.5")
- content := gjson.GetBytes(out, "messages.0.content")
+ content := ojson.GetBytes(out, "messages.0.content")
if len(content.Array()) != 3 {
t.Fatalf("expected 3 content blocks after marker restore: %s", out)
}
@@ -1925,7 +2009,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_BypassesUnknownModelSignatureMa
}`)
output := sanitizeClaudeMessagesForClaudeUpstreamWithDebug(context.Background(), body, "kimi-k2.5")
- parts := gjson.GetBytes(output, "messages.0.content").Array()
+ parts := ojson.GetBytes(output, "messages.0.content").Array()
if len(parts) != 3 {
t.Fatalf("content length = %d, want 3 when sanitizer is bypassed: %s", len(parts), output)
}
@@ -2016,7 +2100,7 @@ func TestClaudeExecutor_ExecuteStripsMalformedEPrefixThinkingBeforeUpstream(t *t
if strings.Contains(string(seenBody), malformedSignature) || strings.Contains(string(seenBody), "bad reasoning") {
t.Fatalf("malformed E-prefix thinking block was forwarded: %s", string(seenBody))
}
- content := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ content := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(seenBody))
}
@@ -2063,7 +2147,7 @@ func TestClaudeExecutor_ExecuteStripsInvalidBase64ThinkingBeforeUpstream(t *test
if strings.Contains(string(seenBody), "E!!!invalid!!!") || strings.Contains(string(seenBody), "bad reasoning") {
t.Fatalf("invalid-base64 thinking block was forwarded: %s", string(seenBody))
}
- content := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ content := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(seenBody))
}
@@ -2104,7 +2188,7 @@ func TestClaudeExecutor_ExecuteStripsEmptySignatureEmptyTextThinking(t *testing.
if len(seenBody) == 0 {
t.Fatal("expected request body to be captured")
}
- content := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ content := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(seenBody))
}
@@ -2376,8 +2460,8 @@ func TestClaudeExecutor_ExecuteStreamOpenAIResponseTranslatesCacheAndTrailingUsa
var trailingUsageChunk []byte
for _, chunk := range allChunks {
- choices := gjson.GetBytes(chunk, "choices")
- if choices.Exists() && len(choices.Array()) == 0 && gjson.GetBytes(chunk, "usage").Exists() {
+ choices := ojson.GetBytes(chunk, "choices")
+ if choices.Exists() && len(choices.Array()) == 0 && ojson.GetBytes(chunk, "usage").Exists() {
trailingUsageChunk = chunk
break
}
@@ -2387,16 +2471,16 @@ func TestClaudeExecutor_ExecuteStreamOpenAIResponseTranslatesCacheAndTrailingUsa
t.Fatalf("expected trailing usage chunk with choices: [], got: %s", string(bytes.Join(allChunks, []byte("\n"))))
}
- if gotCached := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cached_tokens").Int(); gotCached != 355598 {
+ if gotCached := ojson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cached_tokens").Int(); gotCached != 355598 {
t.Errorf("cached_tokens = %d, want 355598", gotCached)
}
- if gotWrite := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cache_write_tokens").Int(); gotWrite != 7185 {
+ if gotWrite := ojson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cache_write_tokens").Int(); gotWrite != 7185 {
t.Errorf("cache_write_tokens = %d, want 7185", gotWrite)
}
- if gotCreation := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cached_creation_tokens").Int(); gotCreation != 7185 {
+ if gotCreation := ojson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cached_creation_tokens").Int(); gotCreation != 7185 {
t.Errorf("cached_creation_tokens = %d, want 7185", gotCreation)
}
- if gotOutput := gjson.GetBytes(trailingUsageChunk, "usage.completion_tokens").Int(); gotOutput != 15 {
+ if gotOutput := ojson.GetBytes(trailingUsageChunk, "usage.completion_tokens").Int(); gotOutput != 15 {
t.Errorf("completion_tokens = %d, want 15", gotOutput)
}
}
@@ -2476,7 +2560,7 @@ func TestClaudeExecutor_CountTokensExcludesInvalidOpenAIThinking(t *testing.T) {
if err != nil {
t.Fatalf("CountTokens() error = %v", err)
}
- return gjson.GetBytes(resp.Payload, "input_tokens").Int()
+ return ojson.GetBytes(resp.Payload, "input_tokens").Int()
}
withInvalidThinking := []byte(`{
@@ -2550,7 +2634,7 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesAndStream(t *testing.T
return
}
kind := "messages"
- if gjson.GetBytes(body, "stream").Bool() {
+ if ojson.GetBytes(body, "stream").Bool() {
kind = "stream"
}
mu.Lock()
@@ -2600,7 +2684,7 @@ func TestClaudeExecutor_LegacySystemReminderAcrossMessagesAndStream(t *testing.T
if errCount != nil {
t.Fatalf("CountTokens() error = %v", errCount)
}
- if got := gjson.GetBytes(countResp.Payload, "input_tokens").Int(); got <= 0 {
+ if got := ojson.GetBytes(countResp.Payload, "input_tokens").Int(); got <= 0 {
t.Fatalf("local count_tokens input_tokens = %d, want positive estimate", got)
}
streamResult, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
@@ -2673,7 +2757,7 @@ func TestClaudeExecutor_CountTokensUpstreamCloakNeverPreservesCustomTool(t *test
if errCount != nil {
t.Fatalf("countTokensUpstream() error = %v", errCount)
}
- if got := gjson.GetBytes(upstreamBody, "tools.0.name").String(); got != "search_web" {
+ if got := ojson.GetBytes(upstreamBody, "tools.0.name").String(); got != "search_web" {
t.Fatalf("count_tokens tool name = %q, want cloak=never passthrough", got)
}
assertClaudeCountTokensIdentity(t, upstreamBody, upstreamHeaders)
@@ -2683,7 +2767,7 @@ func TestClaudeExecutor_CountTokensUpstreamConfirmedVSCodePreservesCustomTool(t
var upstreamName string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- upstreamName = gjson.GetBytes(body, "tools.0.name").String()
+ upstreamName = ojson.GetBytes(body, "tools.0.name").String()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"input_tokens":7}`))
}))
@@ -2737,15 +2821,15 @@ func TestClaudeExecutor_CountTokensCloakMatchesMeasuredDirectAnthropicShape(t *t
if errCount != nil {
t.Fatalf("countTokensUpstream() error = %v", errCount)
}
- if got := gjson.GetBytes(upstreamBody, "system"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "system"); got.Exists() {
t.Fatalf("cloaked direct count system = %s, want absent", got.Raw)
}
for _, field := range []string{"metadata", "context_management", "diagnostics", "betas"} {
- if got := gjson.GetBytes(upstreamBody, field); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, field); got.Exists() {
t.Fatalf("cloaked direct count %s = %s, want absent", field, got.Raw)
}
}
- if got := gjson.GetBytes(upstreamBody, "tools.0.name").String(); !helps.IsClaudeMCPToolName(got) {
+ if got := ojson.GetBytes(upstreamBody, "tools.0.name").String(); !helps.IsClaudeMCPToolName(got) {
t.Fatalf("cloaked direct count tool = %q, want OAuth MCP alias", got)
}
}
@@ -2801,18 +2885,18 @@ func TestClaudeExecutor_CountTokensCloakRelocatesCallerSystemAndObfuscates(t *te
}
// Claude Code's count_tokens never carries a system field.
- if got := gjson.GetBytes(upstreamBody, "system"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "system"); got.Exists() {
t.Fatalf("cloaked count system = %s, want absent", got.Raw)
}
// The caller's system prompt must still be counted, relocated into messages.
// Compare decoded text so JSON escaping does not affect the assertions.
var decodedTexts []string
sawSystemRole := false
- gjson.GetBytes(upstreamBody, "messages").ForEach(func(_, message gjson.Result) bool {
+ ojson.GetBytes(upstreamBody, "messages").ForEach(func(_, message ojson.Result) bool {
if message.Get("role").String() == "system" {
sawSystemRole = true
}
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
decodedTexts = append(decodedTexts, block.Get("text").String())
return true
})
@@ -2869,7 +2953,7 @@ func TestClaudeExecutor_CountTokensCloakStrictModeDropsCallerSystem(t *testing.T
if errCount != nil {
t.Fatalf("countTokensUpstream() error = %v", errCount)
}
- if got := gjson.GetBytes(upstreamBody, "system"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "system"); got.Exists() {
t.Fatalf("strict cloaked count system = %s, want absent", got.Raw)
}
if strings.Contains(string(upstreamBody), "secret directive") {
@@ -2915,7 +2999,7 @@ func TestClaudeExecutor_CountTokensConfirmedNativePreservesMeasuredOAuthBody(t *
t.Fatalf("confirmed native count body changed\n got: %s\nwant: %s", upstreamBody, payload)
}
for _, field := range []string{"system", "metadata", "context_management", "betas"} {
- if got := gjson.GetBytes(upstreamBody, field); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, field); got.Exists() {
t.Fatalf("confirmed native count body %s = %s, want absent", field, got.Raw)
}
}
@@ -2962,7 +3046,7 @@ func TestClaudeExecutor_CountTokensCountsLocallyWithoutUpstreamRequest(t *testin
if errCount != nil {
t.Fatalf("CountTokens() error = %v", errCount)
}
- if got := gjson.GetBytes(resp.Payload, "input_tokens").Int(); got != expectedCount {
+ if got := ojson.GetBytes(resp.Payload, "input_tokens").Int(); got != expectedCount {
t.Fatalf("input_tokens = %d, want %d; payload = %s", got, expectedCount, resp.Payload)
}
})
@@ -2979,10 +3063,10 @@ func TestClaudeExecutor_CountTokensCountsLocallyWithoutUpstreamRequest(t *testin
if err != nil {
t.Fatalf("CountTokens() Gemini response error = %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "totalTokens").Int(); got != expectedCount {
+ if got := ojson.GetBytes(resp.Payload, "totalTokens").Int(); got != expectedCount {
t.Fatalf("Gemini totalTokens = %d, want %d; payload = %s", got, expectedCount, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "promptTokensDetails.0.tokenCount").Int(); got != expectedCount {
+ if got := ojson.GetBytes(resp.Payload, "promptTokensDetails.0.tokenCount").Int(); got != expectedCount {
t.Fatalf("Gemini prompt token detail = %d, want %d; payload = %s", got, expectedCount, resp.Payload)
}
}
@@ -3040,7 +3124,7 @@ func TestClaudeExecutor_CountTokensRebuildsMidSystemMessagesBeforeValidation(t *
if err != nil {
t.Fatalf("CountTokens() error = %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "input_tokens").Int(); got <= 0 {
+ if got := ojson.GetBytes(resp.Payload, "input_tokens").Int(); got <= 0 {
t.Fatalf("input_tokens = %d, want positive count; payload = %s", got, resp.Payload)
}
}
@@ -3050,8 +3134,8 @@ func TestClaudeExecutor_ReusesUserIDAcrossModelsWhenCacheEnabled(t *testing.T) {
var requestModels []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- userID := gjson.GetBytes(body, "metadata.user_id").String()
- model := gjson.GetBytes(body, "model").String()
+ userID := ojson.GetBytes(body, "metadata.user_id").String()
+ model := ojson.GetBytes(body, "model").String()
userIDs = append(userIDs, userID)
requestModels = append(requestModels, model)
t.Logf("HTTP Server received request: model=%s, user_id=%s, url=%s", model, userID, r.URL.String())
@@ -3083,7 +3167,7 @@ func TestClaudeExecutor_ReusesUserIDAcrossModelsWhenCacheEnabled(t *testing.T) {
models := []string{"claude-3-5-sonnet", "claude-3-5-haiku"}
for _, model := range models {
t.Logf("Sending request for model: %s", model)
- modelPayload, _ := sjson.SetBytes(payload, "model", model)
+ modelPayload, _ := ojson.SetBytes(payload, "model", model)
if _, err := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: model,
Payload: modelPayload,
@@ -3115,7 +3199,7 @@ func TestClaudeExecutor_DefaultDoesNotInjectUserID(t *testing.T) {
var userIDs []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- userIDs = append(userIDs, gjson.GetBytes(body, "metadata.user_id").String())
+ userIDs = append(userIDs, ojson.GetBytes(body, "metadata.user_id").String())
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-3-5-sonnet","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":1,"output_tokens":1}}`))
}))
@@ -3205,16 +3289,16 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamConvertsValidClaudeStream(t *testi
if err != nil {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "id").String(); got != "msg_123" {
+ if got := ojson.GetBytes(resp.Payload, "id").String(); got != "msg_123" {
t.Fatalf("response id = %q, want msg_123; payload=%s", got, string(resp.Payload))
}
- if got := gjson.GetBytes(resp.Payload, "model").String(); got != "claude-3-5-sonnet-20241022" {
+ if got := ojson.GetBytes(resp.Payload, "model").String(); got != "claude-3-5-sonnet-20241022" {
t.Fatalf("response model = %q, want claude-3-5-sonnet-20241022", got)
}
- if got := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "ok" {
+ if got := ojson.GetBytes(resp.Payload, "choices.0.message.content").String(); got != "ok" {
t.Fatalf("response content = %q, want ok", got)
}
- if got := gjson.GetBytes(resp.Payload, "usage.total_tokens").Int(); got != 3 {
+ if got := ojson.GetBytes(resp.Payload, "usage.total_tokens").Int(); got != 3 {
t.Fatalf("usage.total_tokens = %d, want 3", got)
}
}
@@ -3295,7 +3379,7 @@ func TestClaudeExecutor_ExecutePayloadStreamOverrideDoesNotChangeResponseParser(
if err != nil {
t.Fatalf("Execute error: %v", err)
}
- stream := gjson.GetBytes(seenBody, "stream")
+ stream := ojson.GetBytes(seenBody, "stream")
if !stream.Exists() || stream.Bool() != !tt.wantStream {
t.Fatalf("upstream stream = %s, want %t; body=%s", stream.Raw, !tt.wantStream, string(seenBody))
}
@@ -3357,7 +3441,7 @@ func assertStatusErr(t *testing.T, err error, want int) {
func TestStripClaudeToolPrefixFromResponse_NestedToolReference(t *testing.T) {
input := []byte(`{"content":[{"type":"tool_result","tool_use_id":"toolu_123","content":[{"type":"tool_reference","tool_name":"proxy_mcp__nia__manage_resource"}]}]}`)
out := stripClaudeToolPrefixFromResponse(input, "proxy_")
- got := gjson.GetBytes(out, "content.0.content.0.tool_name").String()
+ got := ojson.GetBytes(out, "content.0.content.0.tool_name").String()
if got != "mcp__nia__manage_resource" {
t.Fatalf("nested tool_reference tool_name = %q, want %q", got, "mcp__nia__manage_resource")
}
@@ -3367,7 +3451,7 @@ func TestApplyClaudeToolPrefix_NestedToolReferenceWithStringContent(t *testing.T
// tool_result.content can be a string - should not be processed
input := []byte(`{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":"plain string result"}]}]}`)
out := applyClaudeToolPrefix(input, "proxy_")
- got := gjson.GetBytes(out, "messages.0.content.0.content").String()
+ got := ojson.GetBytes(out, "messages.0.content.0.content").String()
if got != "plain string result" {
t.Fatalf("string content should remain unchanged = %q", got)
}
@@ -3376,7 +3460,7 @@ func TestApplyClaudeToolPrefix_NestedToolReferenceWithStringContent(t *testing.T
func TestApplyClaudeToolPrefix_SkipsBuiltinToolReference(t *testing.T) {
input := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"tool_reference","tool_name":"web_search"}]}]}]}`)
out := applyClaudeToolPrefix(input, "proxy_")
- got := gjson.GetBytes(out, "messages.0.content.0.content.0.tool_name").String()
+ got := ojson.GetBytes(out, "messages.0.content.0.content.0.tool_name").String()
if got != "web_search" {
t.Fatalf("built-in tool_reference should not be prefixed, got %q", got)
}
@@ -3391,10 +3475,10 @@ func TestNormalizeCacheControlTTL_DowngradesLaterOneHourBlocks(t *testing.T) {
out := normalizeCacheControlTTL(payload)
- if got := gjson.GetBytes(out, "tools.0.cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytes(out, "tools.0.cache_control.ttl").String(); got != "1h" {
t.Fatalf("tools.0.cache_control.ttl = %q, want %q", got, "1h")
}
- if gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").Exists() {
+ if ojson.GetBytes(out, "messages.0.content.0.cache_control.ttl").Exists() {
t.Fatalf("messages.0.content.0.cache_control.ttl should be removed after a default-5m block")
}
}
@@ -3417,7 +3501,7 @@ func TestNormalizeCacheControlTTL_PreservesKeyOrderWhenModified(t *testing.T) {
out := normalizeCacheControlTTL(payload)
- if gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").Exists() {
+ if ojson.GetBytes(out, "messages.0.content.0.cache_control.ttl").Exists() {
t.Fatalf("messages.0.content.0.cache_control.ttl should be removed after a default-5m block")
}
@@ -3452,13 +3536,13 @@ func TestEnforceCacheControlLimit_StripsNonLastToolBeforeMessages(t *testing.T)
if got := countCacheControls(out); got != 4 {
t.Fatalf("cache_control count = %d, want 4", got)
}
- if gjson.GetBytes(out, "tools.0.cache_control").Exists() {
+ if ojson.GetBytes(out, "tools.0.cache_control").Exists() {
t.Fatalf("tools.0.cache_control should be removed first (non-last tool)")
}
- if !gjson.GetBytes(out, "tools.1.cache_control").Exists() {
+ if !ojson.GetBytes(out, "tools.1.cache_control").Exists() {
t.Fatalf("tools.1.cache_control (last tool) should be preserved")
}
- if !gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists() || !gjson.GetBytes(out, "messages.1.content.0.cache_control").Exists() {
+ if !ojson.GetBytes(out, "messages.0.content.0.cache_control").Exists() || !ojson.GetBytes(out, "messages.1.content.0.cache_control").Exists() {
t.Fatalf("message cache_control blocks should be preserved when non-last tool removal is enough")
}
}
@@ -3471,7 +3555,7 @@ func TestEnforceCacheControlLimit_PreservesKeyOrderWhenModified(t *testing.T) {
if got := countCacheControls(out); got != 4 {
t.Fatalf("cache_control count = %d, want 4", got)
}
- if gjson.GetBytes(out, "tools.0.cache_control").Exists() {
+ if ojson.GetBytes(out, "tools.0.cache_control").Exists() {
t.Fatalf("tools.0.cache_control should be removed first (non-last tool)")
}
@@ -3525,10 +3609,10 @@ func TestEnforceCacheControlLimit_ToolOnlyPayloadStillRespectsLimit(t *testing.T
if got := countCacheControls(out); got != 4 {
t.Fatalf("cache_control count = %d, want 4", got)
}
- if gjson.GetBytes(out, "tools.0.cache_control").Exists() {
+ if ojson.GetBytes(out, "tools.0.cache_control").Exists() {
t.Fatalf("tools.0.cache_control should be removed to satisfy max=4")
}
- if !gjson.GetBytes(out, "tools.4.cache_control").Exists() {
+ if !ojson.GetBytes(out, "tools.4.cache_control").Exists() {
t.Fatalf("last tool cache_control should be preserved when possible")
}
}
@@ -3561,11 +3645,11 @@ func TestClaudeExecutor_NullCacheControlIgnored(t *testing.T) {
if got := countCacheControls(out); got != 1 {
t.Fatalf("countCacheControls after enforce = %d, want 1", got)
}
- if !gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists() {
+ if !ojson.GetBytes(out, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("real cache_control at messages.0.content.0 was incorrectly stripped")
}
- if gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String() != "ephemeral" {
- t.Fatalf("messages.0.content.0.cache_control.type = %q, want ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String())
+ if ojson.GetBytes(out, "messages.0.content.0.cache_control.type").String() != "ephemeral" {
+ t.Fatalf("messages.0.content.0.cache_control.type = %q, want ephemeral", ojson.GetBytes(out, "messages.0.content.0.cache_control.type").String())
}
// Auto-injection must not be suppressed by "cache_control": null in system or tools.
@@ -3573,7 +3657,7 @@ func TestClaudeExecutor_NullCacheControlIgnored(t *testing.T) {
"tools": [{"name": "tool1", "description": "desc", "cache_control": null}]
}`)
injectedTools := injectToolsCacheControl(toolsPayload)
- if got := gjson.GetBytes(injectedTools, "tools.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(injectedTools, "tools.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("injectToolsCacheControl should inject cache_control when existing value is null, got %q", got)
}
@@ -3581,7 +3665,7 @@ func TestClaudeExecutor_NullCacheControlIgnored(t *testing.T) {
"system": [{"type": "text", "text": "sys", "cache_control": null}]
}`)
injectedSystem := injectSystemCacheControl(systemPayload)
- if got := gjson.GetBytes(injectedSystem, "system.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(injectedSystem, "system.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("injectSystemCacheControl should inject cache_control when existing value is null, got %q", got)
}
@@ -3591,7 +3675,7 @@ func TestClaudeExecutor_NullCacheControlIgnored(t *testing.T) {
]
}`)
injectedMessages := injectMessagesCacheControl(messagesPayload)
- if got := gjson.GetBytes(injectedMessages, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(injectedMessages, "messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("injectMessagesCacheControl should inject cache_control when existing value is null, got %q", got)
}
@@ -3623,10 +3707,10 @@ func TestClaudeExecutor_NullCacheControlIgnored(t *testing.T) {
if got := countCacheControls(outExcess); got != 4 {
t.Fatalf("countCacheControls after enforce with excess = %d, want 4", got)
}
- if gjson.GetBytes(outExcess, "tools.0.cache_control").Exists() {
+ if ojson.GetBytes(outExcess, "tools.0.cache_control").Exists() {
t.Fatalf("tools.0.cache_control should be removed first (non-last tool)")
}
- if !gjson.GetBytes(outExcess, "tools.2.cache_control").Exists() {
+ if !ojson.GetBytes(outExcess, "tools.2.cache_control").Exists() {
t.Fatalf("last tool cache_control should be preserved")
}
@@ -3638,7 +3722,7 @@ func TestClaudeExecutor_NullCacheControlIgnored(t *testing.T) {
]
}`)
normalized := normalizeCacheControlTTL(mixedTTLPayload)
- if got := gjson.GetBytes(normalized, "system.1.cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytes(normalized, "system.1.cache_control.ttl").String(); got != "1h" {
t.Fatalf("normalizeCacheControlTTL downgraded 1h TTL due to null cache_control: %s", normalized)
}
}
@@ -3682,7 +3766,7 @@ func TestClaudeExecutor_ExecuteSanitizesSignaturesBeforeUpstream(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- parts := gjson.GetBytes(seenBody, "messages.0.content").Array()
+ parts := ojson.GetBytes(seenBody, "messages.0.content").Array()
if len(parts) != 2 {
t.Fatalf("messages.0.content length = %d, want 2; body=%s", len(parts), seenBody)
}
@@ -3771,7 +3855,7 @@ func TestEnsureModelMaxTokens_UsesRegisteredMaxCompletionTokens(t *testing.T) {
input := []byte(`{"model":"test-claude-max-completion-tokens-model","messages":[{"role":"user","content":"hi"}]}`)
out := ensureModelMaxTokens(input, modelID)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 4096 {
+ if got := ojson.GetBytes(out, "max_tokens").Int(); got != 4096 {
t.Fatalf("max_tokens = %d, want %d", got, 4096)
}
}
@@ -3793,7 +3877,7 @@ func TestEnsureModelMaxTokens_DefaultsMissingValue(t *testing.T) {
input := []byte(`{"model":"test-claude-default-max-tokens-model","messages":[{"role":"user","content":"hi"}]}`)
out := ensureModelMaxTokens(input, modelID)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != defaultModelMaxTokens {
+ if got := ojson.GetBytes(out, "max_tokens").Int(); got != defaultModelMaxTokens {
t.Fatalf("max_tokens = %d, want %d", got, defaultModelMaxTokens)
}
}
@@ -3816,7 +3900,7 @@ func TestEnsureModelMaxTokens_PreservesExplicitValue(t *testing.T) {
input := []byte(`{"model":"test-claude-preserve-max-tokens-model","max_tokens":2048,"messages":[{"role":"user","content":"hi"}]}`)
out := ensureModelMaxTokens(input, modelID)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 2048 {
+ if got := ojson.GetBytes(out, "max_tokens").Int(); got != 2048 {
t.Fatalf("max_tokens = %d, want %d", got, 2048)
}
}
@@ -3825,8 +3909,8 @@ func TestEnsureModelMaxTokens_SkipsUnregisteredModel(t *testing.T) {
input := []byte(`{"model":"test-claude-unregistered-model","messages":[{"role":"user","content":"hi"}]}`)
out := ensureModelMaxTokens(input, "test-claude-unregistered-model")
- if gjson.GetBytes(out, "max_tokens").Exists() {
- t.Fatalf("max_tokens should remain unset, got %s", gjson.GetBytes(out, "max_tokens").Raw)
+ if ojson.GetBytes(out, "max_tokens").Exists() {
+ t.Fatalf("max_tokens should remain unset, got %s", ojson.GetBytes(out, "max_tokens").Raw)
}
}
@@ -4251,10 +4335,10 @@ func TestClaudeExecutor_ExecuteStream_AcceptEncodingOverrideCannotBypassIdentity
func assertClaudeMidConversationSystemMessage(t *testing.T, body []byte, messageIndex int, wantText, wantTTL string) {
t.Helper()
messagePath := fmt.Sprintf("messages.%d", messageIndex)
- if got := gjson.GetBytes(body, messagePath+".role").String(); got != "system" {
+ if got := ojson.GetBytes(body, messagePath+".role").String(); got != "system" {
t.Fatalf("%s.role = %q, want system", messagePath, got)
}
- content := gjson.GetBytes(body, messagePath+".content").Array()
+ content := ojson.GetBytes(body, messagePath+".content").Array()
if len(content) != 1 {
t.Fatalf("%s.content has %d blocks, want 1", messagePath, len(content))
}
@@ -4271,13 +4355,13 @@ func assertClaudeMidConversationSystemMessage(t *testing.T, body []byte, message
func assertClaudeLegacySystemReminderLayout(t *testing.T, body []byte, wantSystem, wantUser, wantTTL string) {
t.Helper()
- if got := gjson.GetBytes(body, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(body, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want billing and identity only", got)
}
- if got := gjson.GetBytes(body, "messages.#").Int(); got != 1 {
+ if got := ojson.GetBytes(body, "messages.#").Int(); got != 1 {
t.Fatalf("message count = %d, want one user turn and no role=system", got)
}
- content := gjson.GetBytes(body, "messages.0.content").Array()
+ content := ojson.GetBytes(body, "messages.0.content").Array()
if len(content) != 3 {
t.Fatalf("user content has %d blocks, want currentDate, caller reminder, and user text", len(content))
}
@@ -4291,12 +4375,12 @@ func assertClaudeLegacySystemReminderLayout(t *testing.T, body []byte, wantSyste
assertEphemeralUserTextBlock(t, content[2], wantUser, wantTTL)
}
-func assertClaudeCodeCurrentDateBlock(t *testing.T, block gjson.Result) {
+func assertClaudeCodeCurrentDateBlock(t *testing.T, block ojson.Result) {
t.Helper()
assertClaudeCodeCurrentDateBlockAt(t, block, time.Now())
}
-func assertClaudeCodeCurrentDateBlockAt(t *testing.T, block gjson.Result, now time.Time) {
+func assertClaudeCodeCurrentDateBlockAt(t *testing.T, block ojson.Result, now time.Time) {
t.Helper()
if got := block.Get("type").String(); got != "text" {
t.Fatalf("currentDate block type = %q, want text", got)
@@ -4312,7 +4396,7 @@ func assertClaudeCodeCurrentDateBlockAt(t *testing.T, block gjson.Result, now ti
// assertEphemeralUserTextBlock checks the cloaked first-user block. wantTTL is ""
// for the native default marker and "1h" once upgradeClaudeCacheControlTTL has run,
// which only happens for OAuth credentials.
-func assertEphemeralUserTextBlock(t *testing.T, block gjson.Result, wantText, wantTTL string) {
+func assertEphemeralUserTextBlock(t *testing.T, block ojson.Result, wantText, wantTTL string) {
t.Helper()
if got := block.Get("type").String(); got != "text" {
t.Fatalf("user block type = %q, want text", got)
@@ -4388,7 +4472,7 @@ func TestInjectClaudeCodeCurrentDateIsIdempotentAndAlignsFirstUserCache(t *testi
if !bytes.Equal(first, second) {
t.Fatalf("currentDate injection is not idempotent:\nfirst: %s\nsecond: %s", first, second)
}
- content := gjson.GetBytes(first, "messages.0.content").Array()
+ content := ojson.GetBytes(first, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("first user content has %d blocks, want 2: %s", len(content), first)
}
@@ -4449,7 +4533,7 @@ func TestClaudeCallerCurrentDateSeedsOnlyFirstAnchor(t *testing.T) {
func TestInjectClaudeCodeCurrentDateStripsCallerReminderFromStringContent(t *testing.T) {
payload := []byte(`{"messages":[{"role":"user","content":""}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content", claudeCodeCurrentDateReminder("2026-08-01")+"hello")
+ payload, _ = ojson.SetBytes(payload, "messages.0.content", claudeCodeCurrentDateReminder("2026-08-01")+"hello")
if got := claudeCallerCurrentDateReminderDate(payload); got != "2026-08-01" {
t.Fatalf("string caller date = %q, want 2026-08-01", got)
@@ -4466,17 +4550,17 @@ func TestInjectClaudeCodeCurrentDateStripsCallerReminderFromStringContent(t *tes
if got := strings.Count(string(stripped), "Today's date is"); got != 1 {
t.Fatalf("stripped payload has %d date reminders, want 1: %s", got, stripped)
}
- if got := gjson.GetBytes(stripped, "messages.0.content.1.text").String(); got != "hello" {
+ if got := ojson.GetBytes(stripped, "messages.0.content.1.text").String(); got != "hello" {
t.Fatalf("user text after strip = %q, want hello: %s", got, stripped)
}
// A caller reminder without the trailing blank lines is still stripped.
noTrailer := []byte(`{"messages":[{"role":"user","content":""}]}`)
- noTrailer, _ = sjson.SetBytes(noTrailer, "messages.0.content", strings.TrimRight(claudeCodeCurrentDateReminder("2026-08-01"), "\n")+"hello")
+ noTrailer, _ = ojson.SetBytes(noTrailer, "messages.0.content", strings.TrimRight(claudeCodeCurrentDateReminder("2026-08-01"), "\n")+"hello")
strippedNoTrailer := injectClaudeCodeCurrentDate(stripFirstUserClaudeCodeCurrentDateReminder(noTrailer), "2026-08-01")
if got := strings.Count(string(strippedNoTrailer), "Today's date is"); got != 1 {
t.Fatalf("no-trailer payload has %d date reminders, want 1: %s", got, strippedNoTrailer)
}
- if got := gjson.GetBytes(strippedNoTrailer, "messages.0.content.1.text").String(); got != "hello" {
+ if got := ojson.GetBytes(strippedNoTrailer, "messages.0.content.1.text").String(); got != "hello" {
t.Fatalf("user text after no-trailer strip = %q, want hello: %s", got, strippedNoTrailer)
}
}
@@ -4491,7 +4575,7 @@ func TestApplyCloakingStringCallerReminderPreservesUserText(t *testing.T) {
ctx := helps.WithClaudeSessionID(context.Background(), "apply-cloaking-string-session")
payload := []byte(`{"model":"claude-opus-5","messages":[{"role":"user","content":""}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content", claudeCodeCurrentDateReminder("2026-08-01")+"hello")
+ payload, _ = ojson.SetBytes(payload, "messages.0.content", claudeCodeCurrentDateReminder("2026-08-01")+"hello")
out, didCloak, errCloaking := applyCloaking(ctx, cfg, auth, payload, "sk-ant-oat-test", false, false)
if errCloaking != nil {
@@ -4504,7 +4588,7 @@ func TestApplyCloakingStringCallerReminderPreservesUserText(t *testing.T) {
t.Fatalf("cloaked payload has %d date reminders, want 1: %s", got, out)
}
foundHello := false
- for _, block := range gjson.GetBytes(out, "messages.0.content").Array() {
+ for _, block := range ojson.GetBytes(out, "messages.0.content").Array() {
if block.Get("text").String() == "hello" {
foundHello = true
}
@@ -4521,7 +4605,7 @@ func TestInjectClaudeCodeCurrentDateMovesExistingCopyToFirstBlock(t *testing.T)
`{"type":"text","text":"hello"},` + dateBlock + `]}]}`)
out := injectClaudeCodeCurrentDate(payload, claudeCodeLocalDate(fixed))
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("content has %d blocks, want one currentDate and user text: %s", len(content), out)
}
@@ -4537,7 +4621,7 @@ func TestInjectClaudeCodeCurrentDatePrecedesExistingReminder(t *testing.T) {
`{"type":"text","text":"continue","cache_control":{"type":"ephemeral","ttl":"1h"}}]}]}`)
out := injectClaudeCodeCurrentDate(payload, claudeCodeLocalDate(fixed))
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 3 {
t.Fatalf("content has %d blocks, want currentDate, reminder, and user text: %s", len(content), out)
}
@@ -4562,7 +4646,7 @@ func TestInjectClaudeCodeCurrentDateFollowsLeadingToolResults(t *testing.T) {
t.Fatalf("currentDate injection is not idempotent:\nfirst: %s\nsecond: %s", first, second)
}
- content := gjson.GetBytes(first, "messages.1.content").Array()
+ content := ojson.GetBytes(first, "messages.1.content").Array()
if len(content) != 3 {
t.Fatalf("content has %d blocks, want tool_result, currentDate, and user text: %s", len(content), first)
}
@@ -4587,7 +4671,7 @@ func TestInjectClaudeCodeCurrentDateFollowsAllLeadingToolResults(t *testing.T) {
`{"type":"tool_result","tool_use_id":"toolu_2","content":"ok"}]}]}`)
out := injectClaudeCodeCurrentDate(payload, claudeCodeLocalDate(fixed))
- content := gjson.GetBytes(out, "messages.1.content").Array()
+ content := ojson.GetBytes(out, "messages.1.content").Array()
if len(content) != 3 {
t.Fatalf("content has %d blocks, want two tool_results and currentDate: %s", len(content), out)
}
@@ -4609,7 +4693,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
out := checkSystemInstructionsWithMode(payload, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytes(out, "system")
if !system.IsArray() {
t.Fatalf("system should be an array, got %s", system.Type)
}
@@ -4629,7 +4713,7 @@ func TestCheckSystemInstructionsWithMode_StringSystemPreserved(t *testing.T) {
if blocks[1].Get("cache_control.ttl").Exists() {
t.Fatalf("blocks[1] cache_control must not carry a default ttl: %s", blocks[1].Raw)
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("messages[0].content has %d blocks, want currentDate and user text: %s", len(content), out)
}
@@ -4665,10 +4749,10 @@ func TestCheckSystemInstructionsWithMode_FutureModelDefaultsToMidSystem(t *testi
payload := []byte(`{"model":"claude-opus-6","system":"future instructions","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"},{"role":"user","content":"follow-up"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want 2", got)
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("user content has %d blocks, want currentDate and user text", len(content))
}
@@ -4681,13 +4765,13 @@ func TestCheckSystemInstructionsWithMode_LegacyModelUsesSystemReminder(t *testin
payload := []byte(`{"model":"claude-opus-4-6","system":"legacy instructions","messages":[{"role":"user","content":"hi"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want billing and identity only", got)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 1 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 1 {
t.Fatalf("message count = %d, want no role=system insertion", got)
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 3 {
t.Fatalf("user content has %d blocks, want currentDate, caller reminder, and user text", len(content))
}
@@ -4708,7 +4792,7 @@ func TestCheckSystemInstructionsWithMode_LegacyModelKeepsSystemBlocksSeparate(t
`"messages":[{"role":"user","content":"hi"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 4 {
t.Fatalf("user content has %d blocks, want currentDate, two caller reminders, and user text: %s", len(content), out)
}
@@ -4731,11 +4815,11 @@ func TestCheckSystemInstructionsWithMode_StringSystemStrict(t *testing.T) {
out := checkSystemInstructionsWithMode(payload, true)
- blocks := gjson.GetBytes(out, "system").Array()
+ blocks := ojson.GetBytes(out, "system").Array()
if len(blocks) != 2 {
t.Fatalf("strict mode should produce 2 injected blocks, got %d", len(blocks))
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("strict mode content has %d blocks, want currentDate and user text", len(content))
}
@@ -4749,11 +4833,11 @@ func TestCheckSystemInstructionsWithMode_EmptyStringSystemIgnored(t *testing.T)
out := checkSystemInstructionsWithMode(payload, false)
- blocks := gjson.GetBytes(out, "system").Array()
+ blocks := ojson.GetBytes(out, "system").Array()
if len(blocks) != 2 {
t.Fatalf("empty string system should still produce 2 injected blocks, got %d", len(blocks))
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("empty system content has %d blocks, want 2", len(content))
}
@@ -4767,11 +4851,11 @@ func TestCheckSystemInstructionsWithMode_ArraySystemStillWorks(t *testing.T) {
out := checkSystemInstructionsWithMode(payload, false)
- blocks := gjson.GetBytes(out, "system").Array()
+ blocks := ojson.GetBytes(out, "system").Array()
if len(blocks) != 2 {
t.Fatalf("expected 2 top-level system blocks, got %d", len(blocks))
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("messages[0].content has %d blocks, want currentDate and user text", len(content))
}
@@ -4787,10 +4871,10 @@ func TestCheckSystemInstructionsWithMode_ArraySystemKeepsBlocksAsSeparateMessage
`"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"},{"role":"user","content":"follow-up"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 5 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 5 {
t.Fatalf("message count = %d, want user, two system messages, assistant, and user: %s", got, out)
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("user content has %d blocks, want currentDate and user text: %s", len(content), out)
}
@@ -4807,17 +4891,17 @@ func TestCheckSystemInstructionsWithMode_TerminalUserRunKeepsSystemTopLevel(t *t
`"messages":[{"role":"user","content":"first"},{"role":"user","content":"second"}]}`)
out := checkSystemInstructionsWithMode(payload, false, true)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 4 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 4 {
t.Fatalf("top-level system block count = %d, want 4 (2 identity + 2 caller blocks): %s", got, out)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("message count = %d, want 2 without trailing system turns: %s", got, out)
}
for idx, want := range []string{"first guidance", "second guidance"} {
- if got := gjson.GetBytes(out, fmt.Sprintf("system.%d.text", idx+2)).String(); got != want {
+ if got := ojson.GetBytes(out, fmt.Sprintf("system.%d.text", idx+2)).String(); got != want {
t.Fatalf("system.%d.text = %q, want %q", idx+2, got, want)
}
- if got := gjson.GetBytes(out, fmt.Sprintf("system.%d.cache_control.type", idx+2)).String(); got != "ephemeral" {
+ if got := ojson.GetBytes(out, fmt.Sprintf("system.%d.cache_control.type", idx+2)).String(); got != "ephemeral" {
t.Fatalf("system.%d.cache_control.type = %q, want ephemeral", idx+2, got)
}
}
@@ -4829,10 +4913,10 @@ func TestCheckSystemInstructionsWithMode_SingleTerminalUserRelocatesSystemToMess
`"messages":[{"role":"user","content":"request"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want 2 Claude Code blocks: %s", got, out)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("message count = %d, want user and relocated system turns: %s", got, out)
}
assertClaudeMidConversationSystemMessage(t, out, 1, "caller guidance", "")
@@ -4845,10 +4929,10 @@ func TestCheckSystemInstructionsWithMode_LeadingUserRunRelocatesSystemAfterAssis
`{"role":"assistant","content":"answer"},{"role":"user","content":"follow-up"}]}`)
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want 2 Claude Code blocks: %s", got, out)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 5 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 5 {
t.Fatalf("message count = %d, want inserted system turn: %s", got, out)
}
assertClaudeMidConversationSystemMessage(t, out, 2, "caller guidance", "")
@@ -4878,13 +4962,13 @@ func TestCheckSystemInstructionsWithMode_ExplicitTopLevelPolicyKeepsAffectedSyst
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out := checkSystemInstructionsWithMode([]byte(test.payload), false, true)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 3 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 3 {
t.Fatalf("top-level system block count = %d, want 3: %s", got, out)
}
- if got := gjson.GetBytes(out, "system.2.text").String(); got != test.wantSystemTxt {
+ if got := ojson.GetBytes(out, "system.2.text").String(); got != test.wantSystemTxt {
t.Fatalf("system.2.text = %q, want %q", got, test.wantSystemTxt)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != test.wantMessages {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != test.wantMessages {
t.Fatalf("message count = %d, want %d: %s", got, test.wantMessages, out)
}
})
@@ -4898,17 +4982,17 @@ func TestRelocateClaudeSystemPromptForCountTokens_TerminalUserRunKeepsSystemTopL
`"messages":[{"role":"user","content":"first"},{"role":"user","content":"second"}]}`)
out := relocateClaudeSystemPromptForCountTokensWithPolicy(payload, false, false, true)
- if got := gjson.GetBytes(out, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want 2 caller blocks: %s", got, out)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("message count = %d, want 2 without trailing system turns: %s", got, out)
}
for idx, want := range []string{"first guidance", "second guidance"} {
- if got := gjson.GetBytes(out, fmt.Sprintf("system.%d.text", idx)).String(); got != want {
+ if got := ojson.GetBytes(out, fmt.Sprintf("system.%d.text", idx)).String(); got != want {
t.Fatalf("system.%d.text = %q, want %q", idx, got, want)
}
- if got := gjson.GetBytes(out, fmt.Sprintf("system.%d.cache_control.type", idx)).String(); got != "ephemeral" {
+ if got := ojson.GetBytes(out, fmt.Sprintf("system.%d.cache_control.type", idx)).String(); got != "ephemeral" {
t.Fatalf("system.%d.cache_control.type = %q, want ephemeral", idx, got)
}
}
@@ -4920,10 +5004,10 @@ func TestRelocateClaudeSystemPromptForCountTokens_SingleTerminalUserRelocatesSys
`"messages":[{"role":"user","content":"request"}]}`)
out := relocateClaudeSystemPromptForCountTokens(payload, false)
- if gjson.GetBytes(out, "system").Exists() {
+ if ojson.GetBytes(out, "system").Exists() {
t.Fatalf("top-level system field should be removed: %s", out)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("message count = %d, want user and relocated system turns: %s", got, out)
}
assertClaudeMidConversationSystemMessage(t, out, 1, "caller guidance", "")
@@ -4936,10 +5020,10 @@ func TestRelocateClaudeSystemPromptForCountTokens_LeadingUserRunRelocatesSystemA
`{"role":"assistant","content":"answer"},{"role":"user","content":"follow-up"}]}`)
out := relocateClaudeSystemPromptForCountTokens(payload, false)
- if gjson.GetBytes(out, "system").Exists() {
+ if ojson.GetBytes(out, "system").Exists() {
t.Fatalf("top-level system field should be removed: %s", out)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 5 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 5 {
t.Fatalf("message count = %d, want inserted system turn: %s", got, out)
}
assertClaudeMidConversationSystemMessage(t, out, 2, "caller guidance", "")
@@ -4963,11 +5047,11 @@ func TestRelocateClaudeSystemPromptForCountTokensKeepsBlocksSeparate(t *testing.
`"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"},{"role":"user","content":"follow-up"}]}`)
out := relocateClaudeSystemPromptForCountTokens(payload, false)
- if gjson.GetBytes(out, "system").Exists() {
+ if ojson.GetBytes(out, "system").Exists() {
t.Fatalf("count_tokens system must be absent: %s", out)
}
if test.legacy {
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 3 {
t.Fatalf("legacy content has %d blocks, want two reminders and user text: %s", len(content), out)
}
@@ -4982,7 +5066,7 @@ func TestRelocateClaudeSystemPromptForCountTokensKeepsBlocksSeparate(t *testing.
}
return
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 5 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 5 {
t.Fatalf("message count = %d, want user, two system messages, assistant, and user: %s", got, out)
}
assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance", "")
@@ -5027,10 +5111,10 @@ func TestCheckSystemInstructionsWithMode_AdvisorToolResultPreservesTopLevelSyste
// Messages must NOT have mid-conversation role=system turns inserted,
// so the message count stays at 4 and roles remain intact.
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 4 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 4 {
t.Fatalf("messages count = %d, want 4 (no mid-conversation system splice): %s", got, out)
}
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "assistant", "user", "assistant"}
for idx, wantRole := range wantRoles {
if got := roles[idx].String(); got != wantRole {
@@ -5039,7 +5123,7 @@ func TestCheckSystemInstructionsWithMode_AdvisorToolResultPreservesTopLevelSyste
}
// Top-level system must retain caller system blocks in addition to Claude Code identity blocks.
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 4 {
t.Fatalf("system blocks count = %d, want 4 (2 identity + 2 caller blocks): %s", len(systemBlocks), out)
}
@@ -5088,7 +5172,7 @@ func TestRelocateClaudeSystemPromptForCountTokens_AdvisorToolResultLeavesMessage
// Caller system blocks must be retained in top-level system so that
// their tokens are counted, without splicing them into messages[].
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 2 {
t.Fatalf("count_tokens system blocks count = %d, want 2: %s", len(systemBlocks), out)
}
@@ -5099,10 +5183,10 @@ func TestRelocateClaudeSystemPromptForCountTokens_AdvisorToolResultLeavesMessage
t.Fatalf("system[1].text = %q, want second guidance", got)
}
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 3 {
t.Fatalf("count_tokens messages count = %d, want 3 (no mid-conversation system splice): %s", got, out)
}
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "assistant", "user"}
for idx, wantRole := range wantRoles {
if got := roles[idx].String(); got != wantRole {
@@ -5131,10 +5215,10 @@ func TestCheckSystemInstructionsWithMode_UnicodeEscapedAdvisor(t *testing.T) {
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("messages count = %d, want 2 (no mid-conversation splice): %s", got, out)
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 3 {
t.Fatalf("system blocks count = %d, want 3: %s", len(systemBlocks), out)
}
@@ -5163,17 +5247,17 @@ func TestCheckSystemInstructionsWithMode_StandaloneAdvisorCallWithoutResult(t *t
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("messages count = %d, want 2 (no mid-conversation splice): %s", got, out)
}
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "assistant"}
for idx, wantRole := range wantRoles {
if got := roles[idx].String(); got != wantRole {
t.Fatalf("messages[%d].role = %q, want %q", idx, got, wantRole)
}
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 3 {
t.Fatalf("system blocks count = %d, want 3: %s", len(systemBlocks), out)
}
@@ -5211,17 +5295,17 @@ func TestCheckSystemInstructionsWithMode_StandaloneAdvisorResultWithoutCall(t *t
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 2 {
t.Fatalf("messages count = %d, want 2 (no mid-conversation splice): %s", got, out)
}
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "user"}
for idx, wantRole := range wantRoles {
if got := roles[idx].String(); got != wantRole {
t.Fatalf("messages[%d].role = %q, want %q", idx, got, wantRole)
}
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 3 {
t.Fatalf("system blocks count = %d, want 3: %s", len(systemBlocks), out)
}
@@ -5247,7 +5331,7 @@ func TestCheckSystemInstructionsWithMode_NormalTextWithAdvisorWordDoesNotBypassM
out := checkSystemInstructionsWithMode(payload, false)
// Since there is no advisor tool invocation/result, normal mid-conversation system insertion occurs.
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 5 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 5 {
t.Fatalf("messages count = %d, want 5 (user + 2 system messages + assistant + user): %s", got, out)
}
assertClaudeMidConversationSystemMessage(t, out, 1, "first guidance", "")
@@ -5288,17 +5372,17 @@ func TestCheckSystemInstructionsWithMode_AdvisorToolResultArrayContent(t *testin
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 3 {
t.Fatalf("messages count = %d, want 3: %s", got, out)
}
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "assistant", "user"}
for idx, wantRole := range wantRoles {
if got := roles[idx].String(); got != wantRole {
t.Fatalf("messages[%d].role = %q, want %q", idx, got, wantRole)
}
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 3 {
t.Fatalf("system blocks count = %d, want 3: %s", len(systemBlocks), out)
}
@@ -5341,17 +5425,17 @@ func TestCheckSystemInstructionsWithMode_ToolResultWithAdvisorRedactedResult(t *
out := checkSystemInstructionsWithMode(payload, false)
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 3 {
t.Fatalf("messages count = %d, want 3: %s", got, out)
}
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "assistant", "user"}
for idx, wantRole := range wantRoles {
if got := roles[idx].String(); got != wantRole {
t.Fatalf("messages[%d].role = %q, want %q", idx, got, wantRole)
}
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 3 {
t.Fatalf("system blocks count = %d, want 3: %s", len(systemBlocks), out)
}
@@ -5395,7 +5479,7 @@ func TestCheckSystemInstructionsWithMode_ClientToolNamedAdvisorRelocatesSystemPr
// Caller prompt must be relocated to a mid-conversation system message,
// so the top-level system must only have the 2 Claude Code cloak blocks.
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 2 {
t.Fatalf("system blocks count = %d, want 2 (caller prompt must be relocated, not hoisted): %s", len(systemBlocks), out)
}
@@ -5436,7 +5520,7 @@ func TestRelocateClaudeSystemPromptForCountTokens_ClientToolNamedAdvisorRelocate
out := relocateClaudeSystemPromptForCountTokens(payload, false)
- if gjson.GetBytes(out, "system").Exists() {
+ if ojson.GetBytes(out, "system").Exists() {
t.Fatalf("count_tokens system field should have been relocated out of top-level system: %s", out)
}
assertClaudeMidConversationSystemMessage(t, out, 1, "caller guidance", "")
@@ -5449,10 +5533,10 @@ func TestCheckSystemInstructionsWithMode_StringWithSpecialChars(t *testing.T) {
out := checkSystemInstructionsWithMode(payload, false)
wantSystem := `Use tags & "quotes" in output.`
- if got := gjson.GetBytes(out, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(out, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want 2", got)
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("messages[0].content has %d blocks, want 2", len(content))
}
@@ -5482,13 +5566,13 @@ func TestCheckSystemInstructionsWithSigningMode_LongPromptIsExactAndIdempotent(t
if !bytes.Equal(first, second) {
t.Fatalf("complete cloak layout is not byte-idempotent:\nfirst: %s\nsecond: %s", first, second)
}
- if got := gjson.GetBytes(first, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(first, "system.#").Int(); got != 2 {
t.Fatalf("top-level system block count = %d, want 2", got)
}
- if got := gjson.GetBytes(first, "messages.#").Int(); got != 4 {
+ if got := ojson.GetBytes(first, "messages.#").Int(); got != 4 {
t.Fatalf("message count = %d, want user, system, assistant, and user", got)
}
- content := gjson.GetBytes(first, "messages.0.content").Array()
+ content := ojson.GetBytes(first, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("user content has %d blocks, want currentDate and user text", len(content))
}
@@ -5582,7 +5666,7 @@ func TestClaudeExecutor_CustomBaseURLAPIKeyDoesNotEnableCCHSigning(t *testing.T)
if len(seenBody) == 0 {
t.Fatal("expected request body to be captured")
}
- if got := gjson.GetBytes(seenBody, "messages.0.content.0.text").String(); got != messageText {
+ if got := ojson.GetBytes(seenBody, "messages.0.content.0.text").String(); got != messageText {
t.Fatalf("message text = %q, want %q", got, messageText)
}
if strings.Contains(string(seenBody), "x-anthropic-billing-header:") {
@@ -5621,7 +5705,7 @@ func TestClaudeExecutor_CustomBaseURLOAuthGeneratesMissingCCH(t *testing.T) {
if _, ok := claudeBillingCCHDigitsOffset(seenBody); !ok {
t.Fatalf("Claude OAuth custom BaseURL body is missing generated CCH: %s", seenBody)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != "keep original system" {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != "keep original system" {
t.Fatalf("system.1.text = %q, want preserved system text", got)
}
}
@@ -5663,10 +5747,10 @@ func TestClaudeExecutor_RebuildMidSystemMessageDisabledByDefault(t *testing.T) {
if len(seenBody) == 0 {
t.Fatal("expected request body to be captured")
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "Top rule" {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); got != "Top rule" {
t.Fatalf("system.0.text = %q, want top-level system preserved", got)
}
- if got := gjson.GetBytes(seenBody, `messages.#(role=="system").content`).String(); got != "Mid rule" {
+ if got := ojson.GetBytes(seenBody, `messages.#(role=="system").content`).String(); got != "Mid rule" {
t.Fatalf("mid system message = %q, want original message preserved", got)
}
}
@@ -5710,9 +5794,9 @@ func TestClaudeExecutor_RebuildMidSystemMessageOptInMovesSystemMessages(t *testi
t.Fatal("expected request body to be captured")
}
- system := gjson.GetBytes(seenBody, "system").Array()
+ system := ojson.GetBytes(seenBody, "system").Array()
if len(system) != 3 {
- t.Fatalf("system has %d items, want 3: %s", len(system), gjson.GetBytes(seenBody, "system").Raw)
+ t.Fatalf("system has %d items, want 3: %s", len(system), ojson.GetBytes(seenBody, "system").Raw)
}
wantTexts := []string{"Top rule", "Mid string rule", "Mid array rule"}
for i, want := range wantTexts {
@@ -5720,13 +5804,13 @@ func TestClaudeExecutor_RebuildMidSystemMessageOptInMovesSystemMessages(t *testi
t.Fatalf("system[%d].text = %q, want %q", i, got, want)
}
}
- if got := gjson.GetBytes(seenBody, "system.2.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(seenBody, "system.2.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("system.2.cache_control.type = %q, want ephemeral", got)
}
- if gjson.GetBytes(seenBody, `messages.#(role=="system")`).Exists() {
- t.Fatalf("messages should not contain system role after rebuild: %s", gjson.GetBytes(seenBody, "messages").Raw)
+ if ojson.GetBytes(seenBody, `messages.#(role=="system")`).Exists() {
+ t.Fatalf("messages should not contain system role after rebuild: %s", ojson.GetBytes(seenBody, "messages").Raw)
}
- if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != 3 {
t.Fatalf("messages count = %d, want 3", got)
}
}
@@ -5791,11 +5875,11 @@ func TestApplyCloaking_PreservesConfiguredStrictModeAndSensitiveWordsWhenModeOmi
if !cloaked {
t.Fatal("applyCloaking() cloaked = false, want true")
}
- blocks := gjson.GetBytes(out, "system").Array()
+ blocks := ojson.GetBytes(out, "system").Array()
if len(blocks) != 2 {
t.Fatalf("expected strict mode to keep the 2 injected Claude CLI system blocks, got %d", len(blocks))
}
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("strict mode should add only currentDate before user text, got %d content blocks", len(content))
}
@@ -5829,18 +5913,18 @@ func TestApplyCloaking_Opus55FallbackOnlyForUnconfirmedClients(t *testing.T) {
if cloaked == tt.confirmed {
t.Fatalf("cloaked = %v, confirmed = %v", cloaked, tt.confirmed)
}
- fallbacks := gjson.GetBytes(body, "fallbacks").Array()
+ fallbacks := ojson.GetBytes(body, "fallbacks").Array()
if tt.wantModel == "" {
if len(fallbacks) != 0 {
- t.Fatalf("unexpected fallback: %s", gjson.GetBytes(body, "fallbacks").Raw)
+ t.Fatalf("unexpected fallback: %s", ojson.GetBytes(body, "fallbacks").Raw)
}
} else if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != tt.wantModel {
- t.Fatalf("fallbacks = %s, want %s", gjson.GetBytes(body, "fallbacks").Raw, tt.wantModel)
+ t.Fatalf("fallbacks = %s, want %s", ojson.GetBytes(body, "fallbacks").Raw, tt.wantModel)
}
if tt.confirmed && string(body) != tt.payload {
t.Fatal("confirmed native payload was cloaked")
}
- if !tt.confirmed && !strings.Contains(gjson.GetBytes(body, "system.0.text").String(), "cc_turn_origin=human;") {
+ if !tt.confirmed && !strings.Contains(ojson.GetBytes(body, "system.0.text").String(), "cc_turn_origin=human;") {
t.Fatal("cloaked main request is missing its CLI turn origin")
}
})
@@ -5869,7 +5953,7 @@ func TestClaudeExecutor_CloakedOpus55PairsFallbackAndBetas(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(seenBody, "fallbacks.0.model").String(); got != "claude-opus-4-8" {
+ if got := ojson.GetBytes(seenBody, "fallbacks.0.model").String(); got != "claude-opus-4-8" {
t.Errorf("fallback model = %q", got)
}
for _, beta := range []string{
@@ -5883,7 +5967,7 @@ func TestClaudeExecutor_CloakedOpus55PairsFallbackAndBetas(t *testing.T) {
t.Errorf("missing %s beta", beta)
}
}
- if text := gjson.GetBytes(seenBody, "system.0.text").String(); !strings.Contains(text, "cc_turn_origin=human;") {
+ if text := ojson.GetBytes(seenBody, "system.0.text").String(); !strings.Contains(text, "cc_turn_origin=human;") {
t.Error("cloaked request is missing human turn origin")
}
if got := seenHeaders.Get("X-Claude-Code-Request-Class"); got != "" {
@@ -5906,7 +5990,7 @@ func TestClaudeOpus55FallbackReconcilesAfterModelOverride(t *testing.T) {
} {
t.Run(tt.name, func(t *testing.T) {
got := reconcileClaudeCodeFableModelAfterPayload([]byte(tt.body), state, false, false, true, tt.probe)
- if model := gjson.GetBytes(got, "fallbacks.0.model").String(); model != tt.wantFallback {
+ if model := ojson.GetBytes(got, "fallbacks.0.model").String(); model != tt.wantFallback {
t.Errorf("fallback = %q, want %q", model, tt.wantFallback)
}
})
@@ -5914,7 +5998,7 @@ func TestClaudeOpus55FallbackReconcilesAfterModelOverride(t *testing.T) {
got := reconcileClaudeCodeFableModelAfterPayload(
[]byte(`{"model":"claude-opus-5-5","system":[]}`), claudeCodeFableState{}, false, false, true, false,
)
- if fallback := gjson.GetBytes(got, "fallbacks.0.model").String(); fallback != "claude-opus-4-8" {
+ if fallback := ojson.GetBytes(got, "fallbacks.0.model").String(); fallback != "claude-opus-4-8" {
t.Errorf("Sonnet rewritten to Opus fallback = %q", fallback)
}
}
@@ -5945,12 +6029,12 @@ func TestApplyCloaking_FableInjectsFallbacksAndDisplayUpdates(t *testing.T) {
t.Fatal("applyCloaking() cloaked = false, want true")
}
- fallbacks := gjson.GetBytes(out, "fallbacks").Array()
+ fallbacks := ojson.GetBytes(out, "fallbacks").Array()
if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" {
- t.Fatalf("expected fallbacks=[{\"model\":\"claude-opus-5\"}], got: %s", gjson.GetBytes(out, "fallbacks").Raw)
+ t.Fatalf("expected fallbacks=[{\"model\":\"claude-opus-5\"}], got: %s", ojson.GetBytes(out, "fallbacks").Raw)
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 3 {
t.Fatalf("expected 3 system blocks for Fable cloaking (billing, identity, reporting outcomes), got %d", len(systemBlocks))
}
@@ -5961,7 +6045,7 @@ func TestApplyCloaking_FableInjectsFallbacksAndDisplayUpdates(t *testing.T) {
t.Fatalf("system block 2 for Reporting outcomes should have no cache_control, got: %s", systemBlocks[2].Get("cache_control").Raw)
}
- display := gjson.GetBytes(out, "thinking.display").String()
+ display := ojson.GetBytes(out, "thinking.display").String()
if display != "updates" {
t.Fatalf("expected thinking.display=updates, got: %q", display)
}
@@ -6004,7 +6088,7 @@ func TestApplyCloaking_SonnetOmitsReportingOutcomes(t *testing.T) {
t.Fatal("applyCloaking() cloaked = false, want true")
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 2 {
t.Fatalf("expected 2 system blocks for Sonnet (billing, identity only), got %d", len(systemBlocks))
}
@@ -6038,11 +6122,11 @@ func TestApplyCloaking_DisabledByConfigLeavesFableUntouched(t *testing.T) {
if cloaked {
t.Fatal("applyCloaking() cloaked = true, want false when DisableClaudeCloakMode is true")
}
- if got := gjson.GetBytes(out, "system").String(); got != originalSystem {
+ if got := ojson.GetBytes(out, "system").String(); got != originalSystem {
t.Fatalf("expected system to be preserved unchanged, got: %s", got)
}
- if gjson.GetBytes(out, "fallbacks").Exists() {
- t.Fatalf("expected no fallbacks injected when cloaking is disabled, got: %s", gjson.GetBytes(out, "fallbacks").Raw)
+ if ojson.GetBytes(out, "fallbacks").Exists() {
+ t.Fatalf("expected no fallbacks injected when cloaking is disabled, got: %s", ojson.GetBytes(out, "fallbacks").Raw)
}
}
@@ -6072,7 +6156,7 @@ func TestApplyCloaking_NativeClaudeCodeLeavesFableUntouched(t *testing.T) {
if cloaked {
t.Fatal("applyCloaking() cloaked = true, want false for confirmed native Claude Code")
}
- if got := gjson.GetBytes(out, "system").String(); got != originalSystem {
+ if got := ojson.GetBytes(out, "system").String(); got != originalSystem {
t.Fatalf("expected system to be preserved unchanged for native client, got: %s", got)
}
}
@@ -6103,11 +6187,11 @@ func TestApplyCloaking_Fable5OmitsFallbacksAndReportingOutcomes(t *testing.T) {
t.Fatal("applyCloaking() cloaked = false, want true")
}
- if gjson.GetBytes(out, "fallbacks").Exists() {
- t.Fatalf("claude-fable-5 should not have fallbacks injected, got: %s", gjson.GetBytes(out, "fallbacks").Raw)
+ if ojson.GetBytes(out, "fallbacks").Exists() {
+ t.Fatalf("claude-fable-5 should not have fallbacks injected, got: %s", ojson.GetBytes(out, "fallbacks").Raw)
}
- systemBlocks := gjson.GetBytes(out, "system").Array()
+ systemBlocks := ojson.GetBytes(out, "system").Array()
if len(systemBlocks) != 2 {
t.Fatalf("expected 2 system blocks for claude-fable-5, got %d", len(systemBlocks))
}
@@ -6160,8 +6244,8 @@ func TestClaudeExecutor_TitleHelperWithSystemPromptIsolated(t *testing.T) {
}
// Title helper must NOT carry diagnostics
- if gjson.GetBytes(seenBody, "diagnostics").Exists() {
- t.Fatalf("expected title helper to omit diagnostics, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw)
+ if ojson.GetBytes(seenBody, "diagnostics").Exists() {
+ t.Fatalf("expected title helper to omit diagnostics, got: %s", ojson.GetBytes(seenBody, "diagnostics").Raw)
}
// Title helper must NOT advance session continuity state
@@ -6217,7 +6301,7 @@ func TestClaudeExecutor_SubagentAndProbeOmit1hCacheTTLAndBeta(t *testing.T) {
if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") {
t.Fatalf("subagent must not carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta"))
}
- for _, blk := range gjson.GetBytes(seenBody, "system").Array() {
+ for _, blk := range ojson.GetBytes(seenBody, "system").Array() {
if blk.Get("cache_control.ttl").String() == "1h" {
t.Fatalf("subagent system block must not carry ttl: 1h, got: %s", blk.Raw)
}
@@ -6237,7 +6321,7 @@ func TestClaudeExecutor_SubagentAndProbeOmit1hCacheTTLAndBeta(t *testing.T) {
if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") {
t.Fatalf("probe must not carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta"))
}
- for _, blk := range gjson.GetBytes(seenBody, "system").Array() {
+ for _, blk := range ojson.GetBytes(seenBody, "system").Array() {
if blk.Get("cache_control.ttl").String() == "1h" {
t.Fatalf("probe system block must not carry ttl: 1h, got: %s", blk.Raw)
}
@@ -6258,14 +6342,14 @@ func TestClaudeExecutor_SubagentAndProbeOmit1hCacheTTLAndBeta(t *testing.T) {
t.Fatalf("main thread request must carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta"))
}
has1h := false
- for _, blk := range gjson.GetBytes(seenBody, "system").Array() {
+ for _, blk := range ojson.GetBytes(seenBody, "system").Array() {
if blk.Get("cache_control.ttl").String() == "1h" {
has1h = true
break
}
}
if !has1h {
- t.Fatalf("main thread request system block must carry ttl: 1h, got: %s", gjson.GetBytes(seenBody, "system").Raw)
+ t.Fatalf("main thread request system block must carry ttl: 1h, got: %s", ojson.GetBytes(seenBody, "system").Raw)
}
// 4. Confirmed native Claude Code subagent: must NOT have extended-cache-ttl restored
@@ -6332,14 +6416,14 @@ func TestClaudeExecutor_PayloadOverrideFableModelLeavesBuiltinsUnchanged(t *test
}
// Model was rewritten to claude-sonnet-5
- if got := gjson.GetBytes(seenBody, "model").String(); got != "claude-sonnet-5" {
+ if got := ojson.GetBytes(seenBody, "model").String(); got != "claude-sonnet-5" {
t.Fatalf("model = %q, want claude-sonnet-5", got)
}
- if gjson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" {
+ if ojson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
+ if !strings.Contains(strings.ReplaceAll(ojson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6388,14 +6472,14 @@ func TestClaudeExecutor_PayloadOverrideNonFableModelDoesNotInjectFableBuiltins(t
}
// Model was rewritten to claude-fable-5-1
- if got := gjson.GetBytes(seenBody, "model").String(); got != "claude-fable-5-1" {
+ if got := ojson.GetBytes(seenBody, "model").String(); got != "claude-fable-5-1" {
t.Fatalf("model = %q, want claude-fable-5-1", got)
}
- if gjson.GetBytes(seenBody, "fallbacks").Exists() {
+ if ojson.GetBytes(seenBody, "fallbacks").Exists() {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if strings.Contains(gjson.GetBytes(seenBody, "system").Raw, "eporting outcomes") {
+ if strings.Contains(ojson.GetBytes(seenBody, "system").Raw, "eporting outcomes") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6447,9 +6531,9 @@ func TestClaudeExecutor_PayloadOverridePreservesExplicitFallbacks(t *testing.T)
}
// Explicit payload fallback override must be preserved!
- fallbacks := gjson.GetBytes(seenBody, "fallbacks").Array()
+ fallbacks := ojson.GetBytes(seenBody, "fallbacks").Array()
if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" {
- t.Fatalf("explicit payload fallback override must be preserved, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw)
+ t.Fatalf("explicit payload fallback override must be preserved, got: %s", ojson.GetBytes(seenBody, "fallbacks").Raw)
}
if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") {
t.Fatalf("server-side-fallback beta must be present for explicit fallback, got: %s", seenHeaders.Get("Anthropic-Beta"))
@@ -6508,10 +6592,10 @@ func TestClaudeExecutor_PayloadUnrelatedRuleLeavesBuiltinFallbacksUnchanged(t *t
t.Fatalf("Execute error = %v", err)
}
- if gjson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" {
+ if ojson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if gjson.GetBytes(seenBody, "model").String() != "claude-sonnet-5" {
+ if ojson.GetBytes(seenBody, "model").String() != "claude-sonnet-5" {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6560,11 +6644,11 @@ func TestClaudeExecutor_PayloadMaxTokensOverrideDoesNotReclassifyBuiltins(t *tes
}
// Payload rule made it max_tokens: 1
- if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "max_tokens").Int(); got != 1 {
t.Fatalf("max_tokens = %d, want 1", got)
}
- if !strings.Contains(gjson.GetBytes(seenBody, "system").Raw, `"ttl":"1h"`) {
+ if !strings.Contains(ojson.GetBytes(seenBody, "system").Raw, `"ttl":"1h"`) {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6613,13 +6697,13 @@ func TestClaudeExecutor_PayloadFableProbeOverrideKeepsBuiltinAdditions(t *testin
t.Fatalf("Execute error = %v", err)
}
- if gjson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" {
+ if ojson.GetBytes(seenBody, "fallbacks.0.model").String() != "claude-opus-5" {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
+ if !strings.Contains(strings.ReplaceAll(ojson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if gjson.GetBytes(seenBody, "max_tokens").Int() != 1 || gjson.GetBytes(seenBody, "thinking.display").String() != "updates" {
+ if ojson.GetBytes(seenBody, "max_tokens").Int() != 1 || ojson.GetBytes(seenBody, "thinking.display").String() != "updates" {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6668,7 +6752,7 @@ func TestClaudeExecutor_PayloadProbeToNormalDoesNotReinitializeBuiltins(t *testi
t.Fatalf("Execute error = %v", err)
}
- if gjson.GetBytes(seenBody, "max_tokens").Int() != 1000 || gjson.GetBytes(seenBody, "fallbacks").Exists() || strings.Contains(gjson.GetBytes(seenBody, "system").Raw, `"ttl":"1h"`) {
+ if ojson.GetBytes(seenBody, "max_tokens").Int() != 1000 || ojson.GetBytes(seenBody, "fallbacks").Exists() || strings.Contains(ojson.GetBytes(seenBody, "system").Raw, `"ttl":"1h"`) {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6709,8 +6793,8 @@ func TestClaudeExecutor_CallerOwnedDiagnosticsPreservedOnProbe(t *testing.T) {
}
// Caller-owned diagnostics must be preserved!
- if got := gjson.GetBytes(seenBody, "diagnostics.caller_custom_key").String(); got != "val123" {
- t.Fatalf("caller diagnostics must be preserved, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw)
+ if got := ojson.GetBytes(seenBody, "diagnostics.caller_custom_key").String(); got != "val123" {
+ t.Fatalf("caller diagnostics must be preserved, got: %s", ojson.GetBytes(seenBody, "diagnostics").Raw)
}
}
@@ -6759,8 +6843,8 @@ func TestClaudeExecutor_PayloadOverrideParentThinkingPreventsDisplayUpdates(t *t
}
// Payload rule replaced parent "thinking" without "display", so "display" must NOT be re-added!
- if gjson.GetBytes(seenBody, "thinking.display").Exists() {
- t.Fatalf("thinking.display must not be injected when parent thinking was overridden, got: %s", gjson.GetBytes(seenBody, "thinking").Raw)
+ if ojson.GetBytes(seenBody, "thinking.display").Exists() {
+ t.Fatalf("thinking.display must not be injected when parent thinking was overridden, got: %s", ojson.GetBytes(seenBody, "thinking").Raw)
}
}
@@ -6807,10 +6891,10 @@ func TestClaudeExecutor_PayloadSystemTTLOverrideKeepsBuiltinFableReporting(t *te
t.Fatalf("Execute error = %v", err)
}
- if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
+ if !strings.Contains(strings.ReplaceAll(ojson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if gjson.GetBytes(seenBody, "system.0.cache_control.ttl").String() != cfg.Payload.Override[0].Params["system.0.cache_control.ttl"] {
+ if ojson.GetBytes(seenBody, "system.0.cache_control.ttl").String() != cfg.Payload.Override[0].Params["system.0.cache_control.ttl"] {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6859,10 +6943,10 @@ func TestClaudeExecutor_PayloadSonnetToFableDoesNotAddReporting(t *testing.T) {
t.Fatalf("Execute error = %v", err)
}
- if strings.Contains(gjson.GetBytes(seenBody, "system").Raw, "eporting outcomes") {
+ if strings.Contains(ojson.GetBytes(seenBody, "system").Raw, "eporting outcomes") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if gjson.GetBytes(seenBody, "system.1.cache_control.ttl").String() != "1h" {
+ if ojson.GetBytes(seenBody, "system.1.cache_control.ttl").String() != "1h" {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -6915,8 +6999,8 @@ func TestClaudeExecutor_PayloadOverrideProbeWithExplicitDiagnosticsPreserved(t *
}
// The operator-provided diagnostics object must be preserved!
- if got := gjson.GetBytes(seenBody, "diagnostics.operator_custom").String(); got != "custom_value_123" {
- t.Fatalf("operator custom diagnostics must be preserved, got: %s", gjson.GetBytes(seenBody, "diagnostics").Raw)
+ if got := ojson.GetBytes(seenBody, "diagnostics.operator_custom").String(); got != "custom_value_123" {
+ t.Fatalf("operator custom diagnostics must be preserved, got: %s", ojson.GetBytes(seenBody, "diagnostics").Raw)
}
}
@@ -6963,7 +7047,7 @@ func TestClaudeExecutor_PayloadStringSystemFableWinsReportingBlock(t *testing.T)
t.Fatalf("Execute error = %v", err)
}
- if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
+ if got := ojson.GetBytes(seenBody, "system"); got.Type != ojson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
t.Fatalf("payload system override was rewritten: %s", seenBody)
}
}
@@ -7011,7 +7095,7 @@ func TestClaudeExecutor_PayloadStringSystemMentioningReportingOutcomesStaysExact
t.Fatalf("Execute error = %v", err)
}
- if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
+ if got := ojson.GetBytes(seenBody, "system"); got.Type != ojson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
t.Fatalf("payload system override was rewritten: %s", seenBody)
}
}
@@ -7059,7 +7143,7 @@ func TestClaudeExecutor_PayloadStringSystemWithExactReportingPromptDoesNotDuplic
t.Fatalf("Execute error = %v", err)
}
- if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
+ if got := ojson.GetBytes(seenBody, "system"); got.Type != ojson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
t.Fatalf("payload system override was rewritten: %s", seenBody)
}
}
@@ -7111,8 +7195,8 @@ func TestClaudeExecutor_PayloadFableThinkingAdaptiveToDisabledKeepsBuiltinDispla
}
// The scalar override does not rerun thinking normalization.
- if gjson.GetBytes(seenBody, "thinking.display").String() != "updates" {
- t.Fatalf("thinking.display must remain untouched by the thinking.type override, got: %s", gjson.GetBytes(seenBody, "thinking").Raw)
+ if ojson.GetBytes(seenBody, "thinking.display").String() != "updates" {
+ t.Fatalf("thinking.display must remain untouched by the thinking.type override, got: %s", ojson.GetBytes(seenBody, "thinking").Raw)
}
// thinking-display-updates beta header must NOT be present
betas := seenHeaders.Get("anthropic-beta")
@@ -7158,7 +7242,7 @@ func TestClaudeExecutor_UncloakedProbePreservesCallerBillingTags(t *testing.T) {
}
// Because cloaking was disabled, the caller-owned billing header must be preserved exactly!
- gotSystem := gjson.GetBytes(seenBody, "system.0.text").String()
+ gotSystem := ojson.GetBytes(seenBody, "system.0.text").String()
if !strings.Contains(gotSystem, "cc_prompt_id=00000000-0000-4000-8000-000000000001") {
t.Fatalf("uncloaked request must preserve caller billing tags on probe, got: %s", gotSystem)
}
@@ -7201,7 +7285,7 @@ func TestClaudeExecutor_FableWithSensitiveWordsHasSingleObfuscatedReportingBlock
}
reportingCount := 0
- for _, blk := range gjson.GetBytes(seenBody, "system").Array() {
+ for _, blk := range ojson.GetBytes(seenBody, "system").Array() {
raw := blk.Get("text").String()
cleaned := strings.ReplaceAll(raw, "\u200B", "")
if cleaned == claudeCodeFableReportingOutcomes {
@@ -7215,7 +7299,7 @@ func TestClaudeExecutor_FableWithSensitiveWordsHasSingleObfuscatedReportingBlock
}
}
if reportingCount != 1 {
- t.Fatalf("expected exactly 1 reporting block, got %d; system = %s", reportingCount, gjson.GetBytes(seenBody, "system").Raw)
+ t.Fatalf("expected exactly 1 reporting block, got %d; system = %s", reportingCount, ojson.GetBytes(seenBody, "system").Raw)
}
}
@@ -7264,7 +7348,7 @@ func TestClaudeExecutor_PayloadSonnetToFableWithSensitiveWordsDoesNotInjectRepor
}
reportingCount := 0
- for _, blk := range gjson.GetBytes(seenBody, "system").Array() {
+ for _, blk := range ojson.GetBytes(seenBody, "system").Array() {
raw := blk.Get("text").String()
cleaned := strings.ReplaceAll(raw, "\u200B", "")
if cleaned == claudeCodeFableReportingOutcomes {
@@ -7278,7 +7362,7 @@ func TestClaudeExecutor_PayloadSonnetToFableWithSensitiveWordsDoesNotInjectRepor
}
}
if reportingCount != 0 {
- t.Fatalf("expected no newly injected reporting block, got %d; system = %s", reportingCount, gjson.GetBytes(seenBody, "system").Raw)
+ t.Fatalf("expected no newly injected reporting block, got %d; system = %s", reportingCount, ojson.GetBytes(seenBody, "system").Raw)
}
}
@@ -7326,10 +7410,10 @@ func TestClaudeExecutor_PayloadFableToSonnetPreservesObfuscatedBuiltins(t *testi
t.Fatalf("Execute error = %v", err)
}
- if !strings.Contains(strings.ReplaceAll(gjson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
+ if !strings.Contains(strings.ReplaceAll(ojson.GetBytes(seenBody, "system").Raw, "\u200b", ""), "eporting outcomes") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
- if !strings.Contains(gjson.GetBytes(seenBody, "system").Raw, "\u200b") {
+ if !strings.Contains(ojson.GetBytes(seenBody, "system").Raw, "\u200b") {
t.Fatalf("payload override must not rerun built-ins: %s", seenBody)
}
@@ -7372,7 +7456,7 @@ func TestClaudeExecutor_SensitiveWordsDoNotCorruptBillingHeaderTags(t *testing.T
}
// system[0] billing header must NOT contain zero-width spaces in its tags
- billingText := gjson.GetBytes(seenBody, "system.0.text").String()
+ billingText := ojson.GetBytes(seenBody, "system.0.text").String()
if !strings.HasPrefix(billingText, "x-anthropic-billing-header: cc_version=") {
t.Fatalf("billing header must start cleanly without obfuscation, got: %q", billingText)
}
@@ -7520,7 +7604,7 @@ func TestClaudeExecutor_PayloadReplacesSystemOnOriginalFableWithoutReinjection(t
t.Fatalf("Execute error = %v", err)
}
- if got := gjson.GetBytes(seenBody, "system"); got.Type != gjson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
+ if got := ojson.GetBytes(seenBody, "system"); got.Type != ojson.String || got.String() != cfg.Payload.Override[0].Params["system"] {
t.Fatalf("payload system override was rewritten: %s", seenBody)
}
}
@@ -7565,20 +7649,20 @@ func TestClaudeExecutor_UserTitlePromptWithoutSchemaTreatedAsNormalTurn(t *testi
// Must NOT be treated as a title helper:
// 1. Must carry 1h cache TTL and extended-cache-ttl beta
has1h := false
- for _, blk := range gjson.GetBytes(seenBody, "system").Array() {
+ for _, blk := range ojson.GetBytes(seenBody, "system").Array() {
if blk.Get("cache_control.ttl").String() == "1h" {
has1h = true
break
}
}
if !has1h {
- t.Fatalf("user title query must carry 1h cache, got: %s", gjson.GetBytes(seenBody, "system").Raw)
+ t.Fatalf("user title query must carry 1h cache, got: %s", ojson.GetBytes(seenBody, "system").Raw)
}
if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "extended-cache-ttl-2025-04-11") {
t.Fatalf("user title query must carry extended-cache-ttl beta, got: %s", seenHeaders.Get("Anthropic-Beta"))
}
// 2. Billing header must carry cc_prompt_id
- billingText := gjson.GetBytes(seenBody, "system.0.text").String()
+ billingText := ojson.GetBytes(seenBody, "system.0.text").String()
if !strings.Contains(billingText, "cc_prompt_id=") {
t.Fatalf("user title query must carry cc_prompt_id, got: %s", billingText)
}
@@ -7630,9 +7714,9 @@ func TestClaudeExecutor_PayloadOverrideRawPreservesExplicitFallbacks(t *testing.
}
// Raw payload override setting fallbacks must be preserved!
- fallbacks := gjson.GetBytes(seenBody, "fallbacks").Array()
+ fallbacks := ojson.GetBytes(seenBody, "fallbacks").Array()
if len(fallbacks) != 1 || fallbacks[0].Get("model").String() != "claude-opus-5" {
- t.Fatalf("explicit raw payload fallback override must be preserved, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw)
+ t.Fatalf("explicit raw payload fallback override must be preserved, got: %s", ojson.GetBytes(seenBody, "fallbacks").Raw)
}
if !strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") {
t.Fatalf("server-side-fallback beta must be present for explicit raw fallback, got: %s", seenHeaders.Get("Anthropic-Beta"))
@@ -7682,8 +7766,8 @@ func TestClaudeExecutor_PayloadFilterFallbacksPreservesRemovalOnFable(t *testing
}
// fallbacks was filtered out by operator rule, must NOT be recreated!
- if gjson.GetBytes(seenBody, "fallbacks").Exists() {
- t.Fatalf("fallbacks must remain deleted after payload filter, got: %s", gjson.GetBytes(seenBody, "fallbacks").Raw)
+ if ojson.GetBytes(seenBody, "fallbacks").Exists() {
+ t.Fatalf("fallbacks must remain deleted after payload filter, got: %s", ojson.GetBytes(seenBody, "fallbacks").Raw)
}
if strings.Contains(seenHeaders.Get("Anthropic-Beta"), "server-side-fallback") {
t.Fatalf("server-side-fallback beta must be absent when fallbacks is filtered, got: %s", seenHeaders.Get("Anthropic-Beta"))
@@ -7735,7 +7819,7 @@ func TestClaudeExecutor_PayloadCustomReportingOutcomesPreservedOnRewrite(t *test
t.Fatalf("Execute error = %v", err)
}
- system := gjson.GetBytes(seenBody, "system").Array()
+ system := ojson.GetBytes(seenBody, "system").Array()
hasCustom := false
for _, blk := range system {
if strings.Contains(blk.Get("text").String(), "Custom user prompt") {
@@ -7743,7 +7827,7 @@ func TestClaudeExecutor_PayloadCustomReportingOutcomesPreservedOnRewrite(t *test
}
}
if !hasCustom {
- t.Fatalf("custom user system prompt must NOT be deleted, got: %s", gjson.GetBytes(seenBody, "system").Raw)
+ t.Fatalf("custom user system prompt must NOT be deleted, got: %s", ojson.GetBytes(seenBody, "system").Raw)
}
}
@@ -7751,7 +7835,7 @@ func TestNormalizeClaudeSamplingForUpstream_RemovesTemperature(t *testing.T) {
payload := []byte(`{"temperature":0,"thinking":{"type":"adaptive"},"output_config":{"effort":"max"}}`)
out := normalizeClaudeSamplingForUpstream(payload, false)
- if gjson.GetBytes(out, "temperature").Exists() {
+ if ojson.GetBytes(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
}
@@ -7760,7 +7844,7 @@ func TestNormalizeClaudeSamplingForUpstream_RemovesTemperatureWithThinkingEnable
payload := []byte(`{"temperature":0.2,"thinking":{"type":"enabled","budget_tokens":2048}}`)
out := normalizeClaudeSamplingForUpstream(payload, false)
- if gjson.GetBytes(out, "temperature").Exists() {
+ if ojson.GetBytes(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
}
@@ -7769,13 +7853,13 @@ func TestNormalizeClaudeSamplingForUpstream_RemovesTopPAndTopKForThinking(t *tes
payload := []byte(`{"temperature":0.2,"top_p":0.9,"top_k":40,"thinking":{"type":"adaptive"}}`)
out := normalizeClaudeSamplingForUpstream(payload, false)
- if gjson.GetBytes(out, "temperature").Exists() {
+ if ojson.GetBytes(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
- if gjson.GetBytes(out, "top_p").Exists() {
+ if ojson.GetBytes(out, "top_p").Exists() {
t.Fatalf("top_p should be removed when thinking is active")
}
- if gjson.GetBytes(out, "top_k").Exists() {
+ if ojson.GetBytes(out, "top_k").Exists() {
t.Fatalf("top_k should be removed when thinking is active")
}
}
@@ -7784,13 +7868,13 @@ func TestNormalizeClaudeSamplingForUpstream_NoThinkingRemovesTemperatureAndTopP(
payload := []byte(`{"temperature":0,"top_p":0.9,"top_k":40,"messages":[{"role":"user","content":"hi"}]}`)
out := normalizeClaudeSamplingForUpstream(payload, false)
- if gjson.GetBytes(out, "temperature").Exists() {
+ if ojson.GetBytes(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
- if gjson.GetBytes(out, "top_p").Exists() {
+ if ojson.GetBytes(out, "top_p").Exists() {
t.Fatalf("top_p should be removed")
}
- if got := gjson.GetBytes(out, "top_k").Int(); got != 40 {
+ if got := ojson.GetBytes(out, "top_k").Int(); got != 40 {
t.Fatalf("top_k = %v, want 40", got)
}
}
@@ -7800,10 +7884,10 @@ func TestNormalizeClaudeSamplingForUpstream_AfterForcedToolChoiceRemovesTemperat
out := disableThinkingIfToolChoiceForced(payload)
out = normalizeClaudeSamplingForUpstream(out, false)
- if gjson.GetBytes(out, "thinking").Exists() {
+ if ojson.GetBytes(out, "thinking").Exists() {
t.Fatalf("thinking should be removed when tool_choice forces tool use")
}
- if gjson.GetBytes(out, "temperature").Exists() {
+ if ojson.GetBytes(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
}
@@ -7815,13 +7899,13 @@ func TestNormalizeClaudeSamplingForUpstream_AfterForcedToolChoiceRemovesTemperat
func TestNormalizeClaudeSamplingForUpstreamNativeKeepsMeasuredHelperTemperature(t *testing.T) {
// Top-level key order and values mirror the measured structured helper.
payload := []byte(`{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":[{"type":"text","text":"helper probe"}]}],"system":[{"type":"text","text":"Return a short title."}],"tools":[],"metadata":{"user_id":"u"},"max_tokens":32000,"thinking":{"type":"disabled"},"temperature":1,"output_config":{"format":{"type":"json_schema"}},"stream":true}`)
- if got := gjson.GetBytes(payload, "temperature"); !got.Exists() || got.Num != 1 {
+ if got := ojson.GetBytes(payload, "temperature"); !got.Exists() || got.Num != 1 {
t.Fatalf("measured helper fixture should carry temperature=1, got %q", got.Raw)
}
out := normalizeClaudeSamplingForUpstream(payload, true)
- if got := gjson.GetBytes(out, "temperature"); !got.Exists() || got.Num != 1 {
+ if got := ojson.GetBytes(out, "temperature"); !got.Exists() || got.Num != 1 {
t.Fatalf("confirmed native must preserve the measured temperature, got %q", got.Raw)
}
}
@@ -7890,13 +7974,13 @@ func TestNormalizeClaudeSamplingForUpstreamNativeDropsOnlyRejectedCombinations(t
out := normalizeClaudeSamplingForUpstream([]byte(tc.payload), true)
for field, want := range tc.keep {
- got := gjson.GetBytes(out, field)
+ got := ojson.GetBytes(out, field)
if !got.Exists() || got.Num != want {
t.Fatalf("%s = %q, want %v preserved", field, got.Raw, want)
}
}
for _, field := range tc.dropped {
- if got := gjson.GetBytes(out, field); got.Exists() {
+ if got := ojson.GetBytes(out, field); got.Exists() {
t.Fatalf("%s = %q, want dropped because Anthropic rejects it", field, got.Raw)
}
}
@@ -7909,7 +7993,7 @@ func TestRemapOAuthToolNames_AllClientNamesUseMCPAliases(t *testing.T) {
t.Run(original, func(t *testing.T) {
body := []byte(`{"tools":[{"name":` + fmt.Sprintf("%q", original) + `,"description":"Run a client tool","input_schema":{"type":"object"}}]}`)
out, reverseMap := remapOAuthToolNames(body)
- alias := gjson.GetBytes(out, "tools.0.name").String()
+ alias := ojson.GetBytes(out, "tools.0.name").String()
if !helps.IsClaudeMCPToolName(alias) {
t.Fatalf("tools.0.name = %q, want MCP alias", alias)
}
@@ -7921,7 +8005,7 @@ func TestRemapOAuthToolNames_AllClientNamesUseMCPAliases(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(reversed, "content.0.name").String(); got != original {
+ if got := ojson.GetBytes(reversed, "content.0.name").String(); got != original {
t.Fatalf("content.0.name = %q, want %q", got, original)
}
})
@@ -7953,33 +8037,33 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) {
out, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "credential-secret"})
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "web_search" {
t.Fatalf("typed builtin = %q, want unchanged", got)
}
- bashAlias := gjson.GetBytes(out, "tools.1.name").String()
- readAlias := gjson.GetBytes(out, "tools.2.name").String()
+ bashAlias := ojson.GetBytes(out, "tools.1.name").String()
+ readAlias := ojson.GetBytes(out, "tools.2.name").String()
if !helps.IsClaudeMCPToolName(bashAlias) || !helps.IsClaudeMCPToolName(readAlias) {
t.Fatalf("former vetted names did not receive MCP aliases: bash=%q Read=%q", bashAlias, readAlias)
}
- if got := gjson.GetBytes(out, "tools.1.description").String(); got != "client shell tool" {
+ if got := ojson.GetBytes(out, "tools.1.description").String(); got != "client shell tool" {
t.Fatalf("bash description = %q, want preserved", got)
}
- if got := gjson.GetBytes(out, "tools.1.input_schema.type").String(); got != "object" {
+ if got := ojson.GetBytes(out, "tools.1.input_schema.type").String(); got != "object" {
t.Fatalf("bash schema changed: %s", out)
}
- if got := gjson.GetBytes(out, "tools.3.name").String(); got != "mcp__context7__query-docs" {
+ if got := ojson.GetBytes(out, "tools.3.name").String(); got != "mcp__context7__query-docs" {
t.Fatalf("existing MCP tool = %q, want unchanged", got)
}
- searchAlias := gjson.GetBytes(out, "tools.4.name").String()
- caseAlias := gjson.GetBytes(out, "tools.5.name").String()
+ searchAlias := ojson.GetBytes(out, "tools.4.name").String()
+ caseAlias := ojson.GetBytes(out, "tools.5.name").String()
if !helps.IsClaudeMCPToolName(searchAlias) || !helps.IsClaudeMCPToolName(caseAlias) {
t.Fatalf("generated aliases are invalid: %q, %q", searchAlias, caseAlias)
}
if searchAlias == caseAlias {
t.Fatalf("case-distinct names share alias %q", searchAlias)
}
- if got := gjson.GetBytes(out, "tools.6.name").String(); got != searchAlias {
+ if got := ojson.GetBytes(out, "tools.6.name").String(); got != searchAlias {
t.Fatalf("repeated declaration alias = %q, want %q", got, searchAlias)
}
if !strings.HasSuffix(searchAlias, "_search_web") || !strings.HasSuffix(caseAlias, "_Search_Web") {
@@ -7988,25 +8072,25 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) {
if len(searchAlias) > 64 || len(caseAlias) > 64 {
t.Fatalf("generated aliases exceed 64 characters: %q, %q", searchAlias, caseAlias)
}
- if got := gjson.GetBytes(out, "tools.4.description").String(); got != "unknown one" {
+ if got := ojson.GetBytes(out, "tools.4.description").String(); got != "unknown one" {
t.Fatalf("description = %q, want preserved", got)
}
- if got := gjson.GetBytes(out, "tools.4.input_schema.required.0").String(); got != "q" {
+ if got := ojson.GetBytes(out, "tools.4.input_schema.required.0").String(); got != "q" {
t.Fatalf("input schema was not preserved: %s", out)
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != searchAlias {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != searchAlias {
t.Fatalf("tool_choice.name = %q, want %q", got, searchAlias)
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != searchAlias {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != searchAlias {
t.Fatalf("historical tool_use.name = %q, want %q", got, searchAlias)
}
- if got := gjson.GetBytes(out, "messages.0.content.0.id").String(); got != "toolu_unknown" {
+ if got := ojson.GetBytes(out, "messages.0.content.0.id").String(); got != "toolu_unknown" {
t.Fatalf("tool_use.id = %q, want unchanged", got)
}
- if got := gjson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != caseAlias {
+ if got := ojson.GetBytes(out, "messages.0.content.1.tool_name").String(); got != caseAlias {
t.Fatalf("tool_reference.tool_name = %q, want %q", got, caseAlias)
}
- if got := gjson.GetBytes(out, "messages.1.content.0.content.0.tool_name").String(); got != searchAlias {
+ if got := ojson.GetBytes(out, "messages.1.content.0.content.0.tool_name").String(); got != searchAlias {
t.Fatalf("nested tool_reference.tool_name = %q, want %q", got, searchAlias)
}
if reverseMap[searchAlias] != "search_web" || reverseMap[caseAlias] != "Search_Web" ||
@@ -8023,13 +8107,13 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != "search_web" {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != "search_web" {
t.Fatalf("restored tool_use.name = %q, want search_web", got)
}
- if got := gjson.GetBytes(restored, "content.1.tool_name").String(); got != "Search_Web" {
+ if got := ojson.GetBytes(restored, "content.1.tool_name").String(); got != "Search_Web" {
t.Fatalf("restored tool_reference.tool_name = %q, want Search_Web", got)
}
- if got := gjson.GetBytes(restored, "content.2.content.0.tool_name").String(); got != "search_web" {
+ if got := ojson.GetBytes(restored, "content.2.content.0.tool_name").String(); got != "search_web" {
t.Fatalf("restored nested tool_reference = %q, want search_web", got)
}
@@ -8038,7 +8122,7 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errReverse)
}
- if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "search_web" {
+ if got := ojson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "search_web" {
t.Fatalf("restored stream name = %q, want search_web: %s", got, restoredLine)
}
}
@@ -8055,27 +8139,27 @@ func TestRemapOAuthToolNames_TypedCustomUsesMCPAlias(t *testing.T) {
}`)
out, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "caller-secret"})
- alias := gjson.GetBytes(out, "tools.0.name").String()
+ alias := ojson.GetBytes(out, "tools.0.name").String()
if !helps.IsClaudeMCPToolName(alias) {
t.Fatalf("typed custom alias = %q, want MCP name", alias)
}
- if gjson.GetBytes(out, "tools.0.type").Exists() {
+ if ojson.GetBytes(out, "tools.0.type").Exists() {
t.Fatalf("typed custom type was not normalized away: %s", out)
}
- if got := gjson.GetBytes(out, "tools.0.description").String(); got != "keep" {
+ if got := ojson.GetBytes(out, "tools.0.description").String(); got != "keep" {
t.Fatalf("typed custom description = %q, want preserved", got)
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(out, "tools.1.name").String(); got != "web_search" {
t.Fatalf("server builtin name = %q, want unchanged", got)
}
- extensionAlias := gjson.GetBytes(out, "tools.2.name").String()
- if !helps.IsClaudeMCPToolName(extensionAlias) || gjson.GetBytes(out, "tools.2.type").Exists() {
+ extensionAlias := ojson.GetBytes(out, "tools.2.name").String()
+ if !helps.IsClaudeMCPToolName(extensionAlias) || ojson.GetBytes(out, "tools.2.type").Exists() {
t.Fatalf("unknown typed client tool was not normalized: %s", out)
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != alias {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != alias {
t.Fatalf("tool_choice.name = %q, want %q", got, alias)
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != alias {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != alias {
t.Fatalf("historical tool_use.name = %q, want %q", got, alias)
}
if reverseMap[alias] != "client_custom" || reverseMap[extensionAlias] != "client_extension" {
@@ -8092,10 +8176,10 @@ func TestRemapOAuthToolNames_MCPAliasAvoidsClientCollision(t *testing.T) {
]}`, initialCandidate))
out, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: secret})
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != initialCandidate {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != initialCandidate {
t.Fatalf("existing MCP tool = %q, want %q", got, initialCandidate)
}
- alias := gjson.GetBytes(out, "tools.1.name").String()
+ alias := ojson.GetBytes(out, "tools.1.name").String()
if alias == initialCandidate {
t.Fatalf("generated alias collided with client MCP name %q", alias)
}
@@ -8107,7 +8191,7 @@ func TestRemapOAuthToolNames_MCPAliasAvoidsClientCollision(t *testing.T) {
func TestRemapOAuthToolNames_MCPAliasIsMandatory(t *testing.T) {
body := []byte(`{"tools":[{"name":"search_web","input_schema":{"type":"object"}}]}`)
out, reverseMap := remapOAuthToolNames(body)
- alias := gjson.GetBytes(out, "tools.0.name").String()
+ alias := ojson.GetBytes(out, "tools.0.name").String()
if !helps.IsClaudeMCPToolName(alias) {
t.Fatalf("tools.0.name = %q, want mandatory MCP alias", alias)
}
@@ -8122,7 +8206,7 @@ func TestRemapOAuthToolNames_SemanticAliasRestoresLongOriginal(t *testing.T) {
options := claudeMCPAliasOptions{secret: "stable-caller"}
out, reverseMap := remapOAuthToolNamesWithOptions(body, options)
- alias := gjson.GetBytes(out, "tools.0.name").String()
+ alias := ojson.GetBytes(out, "tools.0.name").String()
if !helps.IsClaudeMCPToolName(alias) || len(alias) > 64 {
t.Fatalf("semantic alias is invalid or too long: len=%d name=%q", len(alias), alias)
}
@@ -8134,7 +8218,7 @@ func TestRemapOAuthToolNames_SemanticAliasRestoresLongOriginal(t *testing.T) {
}
second, _ := remapOAuthToolNamesWithOptions(body, options)
- if got := gjson.GetBytes(second, "tools.0.name").String(); got != alias {
+ if got := ojson.GetBytes(second, "tools.0.name").String(); got != alias {
t.Fatalf("semantic alias is not stable across requests: %q != %q", got, alias)
}
response := []byte(`{"content":[{"type":"tool_use","id":"toolu_1","name":` + fmt.Sprintf("%q", alias) + `,"input":{}}]}`)
@@ -8142,7 +8226,7 @@ func TestRemapOAuthToolNames_SemanticAliasRestoresLongOriginal(t *testing.T) {
if errReverse != nil {
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
}
- if got := gjson.GetBytes(restored, "content.0.name").String(); got != original {
+ if got := ojson.GetBytes(restored, "content.0.name").String(); got != original {
t.Fatalf("restored tool name = %q, want exact original %q", got, original)
}
}
@@ -8155,18 +8239,18 @@ func TestPrepareClaudeOAuthToolNamesForUpstream_PreservesMCPConvention(t *testin
],"tool_choice":{"type":"tool","name":"search_web"}}`)
out, reverseMap := prepareClaudeOAuthToolNamesForUpstream(body, claudeMCPAliasOptions{secret: "credential-secret"})
- alias := gjson.GetBytes(out, "tools.0.name").String()
+ alias := ojson.GetBytes(out, "tools.0.name").String()
if !helps.IsClaudeMCPToolName(alias) || strings.HasPrefix(alias, "proxy_") {
t.Fatalf("unknown alias = %q, want bare mcp__ name", alias)
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "mcp__context7__query-docs" {
+ if got := ojson.GetBytes(out, "tools.1.name").String(); got != "mcp__context7__query-docs" {
t.Fatalf("existing MCP name = %q, want unchanged", got)
}
- bashAlias := gjson.GetBytes(out, "tools.2.name").String()
+ bashAlias := ojson.GetBytes(out, "tools.2.name").String()
if !helps.IsClaudeMCPToolName(bashAlias) || strings.HasPrefix(bashAlias, "proxy_") {
t.Fatalf("former vetted tool = %q, want bare MCP alias", bashAlias)
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != alias {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != alias {
t.Fatalf("tool_choice.name = %q, want %q", got, alias)
}
if reverseMap[alias] != "search_web" || reverseMap[bashAlias] != "bash" {
@@ -8213,7 +8297,7 @@ func TestClaudeExecutor_CallerScopeMetadataReproducesOAuthMCPToolAliases(t *test
_, _ = w.Write([]byte(`{"input_tokens":42}`))
return
}
- if gjson.GetBytes(body, "stream").Bool() {
+ if ojson.GetBytes(body, "stream").Bool() {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":1}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"))
return
@@ -8317,19 +8401,19 @@ func TestClaudeExecutor_CallerScopeMetadataReproducesOAuthMCPToolAliases(t *test
if len(seenBodies) != 5 {
t.Fatalf("seenBodies len = %d, want 5", len(seenBodies))
}
- origTool0 := gjson.GetBytes(seenBodies[0], "tools.0.name").String()
- origTool1 := gjson.GetBytes(seenBodies[0], "tools.1.name").String()
- origChoice := gjson.GetBytes(seenBodies[0], "tool_choice.name").String()
+ origTool0 := ojson.GetBytes(seenBodies[0], "tools.0.name").String()
+ origTool1 := ojson.GetBytes(seenBodies[0], "tools.1.name").String()
+ origChoice := ojson.GetBytes(seenBodies[0], "tool_choice.name").String()
if !helps.IsClaudeMCPToolName(origTool0) || !helps.IsClaudeMCPToolName(origTool1) || origChoice != origTool0 {
t.Fatalf("unexpected original MCP tool names: tool0=%q tool1=%q choice=%q", origTool0, origTool1, origChoice)
}
for idx, label := range []string{"replay_stream", "replay_execute", "replay_count_tokens"} {
body := seenBodies[idx+1]
- if got0, got1 := gjson.GetBytes(body, "tools.0.name").String(), gjson.GetBytes(body, "tools.1.name").String(); got0 != origTool0 || got1 != origTool1 {
+ if got0, got1 := ojson.GetBytes(body, "tools.0.name").String(), ojson.GetBytes(body, "tools.1.name").String(); got0 != origTool0 || got1 != origTool1 {
t.Fatalf("%s tools = (%q, %q), want (%q, %q)", label, got0, got1, origTool0, origTool1)
}
}
- defaultTool0 := gjson.GetBytes(seenBodies[4], "tools.0.name").String()
+ defaultTool0 := ojson.GetBytes(seenBodies[4], "tools.0.name").String()
if defaultTool0 == origTool0 {
t.Fatalf("default caller tool0 %q matched scoped caller tool0", defaultTool0)
}
@@ -8341,8 +8425,8 @@ func TestRemapOAuthToolNames_MixedCaseNamesRemainDistinct(t *testing.T) {
`{"name":"bash","input_schema":{"type":"object"}}` +
`]}`)
out, reverseMap := remapOAuthToolNames(body)
- upperAlias := gjson.GetBytes(out, "tools.0.name").String()
- lowerAlias := gjson.GetBytes(out, "tools.1.name").String()
+ upperAlias := ojson.GetBytes(out, "tools.0.name").String()
+ lowerAlias := ojson.GetBytes(out, "tools.1.name").String()
if !helps.IsClaudeMCPToolName(upperAlias) || !helps.IsClaudeMCPToolName(lowerAlias) || upperAlias == lowerAlias {
t.Fatalf("mixed-case aliases = %q, %q, want distinct MCP names", upperAlias, lowerAlias)
}
@@ -8390,15 +8474,15 @@ func TestPrepareClaudeOAuthToolNamesForUpstream_AllCustomToolsWithHistory(t *tes
`]}]}`)
out, reverseMap := prepareClaudeOAuthToolNamesForUpstream(body, claudeMCPAliasOptions{secret: "mixed-case-caller"})
- bashAlias := gjson.GetBytes(out, "tools.0.name").String()
- globAlias := gjson.GetBytes(out, "tools.1.name").String()
+ bashAlias := ojson.GetBytes(out, "tools.0.name").String()
+ globAlias := ojson.GetBytes(out, "tools.1.name").String()
if !helps.IsClaudeMCPToolName(bashAlias) || !helps.IsClaudeMCPToolName(globAlias) || bashAlias == globAlias {
t.Fatalf("tool aliases = %q, %q, want distinct bare MCP names", bashAlias, globAlias)
}
- if got := gjson.GetBytes(out, "messages.0.content.0.name").String(); got != bashAlias {
+ if got := ojson.GetBytes(out, "messages.0.content.0.name").String(); got != bashAlias {
t.Fatalf("messages.0.content.0.name = %q, want %q", got, bashAlias)
}
- if got := gjson.GetBytes(out, "messages.0.content.1.name").String(); got != globAlias {
+ if got := ojson.GetBytes(out, "messages.0.content.1.name").String(); got != globAlias {
t.Fatalf("messages.0.content.1.name = %q, want %q", got, globAlias)
}
if reverseMap[bashAlias] != "Bash" || reverseMap[globAlias] != "glob" {
@@ -8434,10 +8518,10 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing.
http.Error(w, errRead.Error(), http.StatusBadRequest)
return
}
- toolName := gjson.GetBytes(body, "tools.0.name").String()
+ toolName := ojson.GetBytes(body, "tools.0.name").String()
upstreamRequests <- upstreamRequest{
toolName: toolName,
- stream: gjson.GetBytes(body, "stream").Bool(),
+ stream: ojson.GetBytes(body, "stream").Bool(),
}
w.Header().Set("Content-Type", "text/event-stream")
responseBody := strings.Replace(upstreamBody, `"name":"Bash"`, `"name":`+fmt.Sprintf("%q", toolName), 1)
@@ -8474,7 +8558,7 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing.
if !helps.IsClaudeMCPToolName(upstream.toolName) || !strings.HasSuffix(upstream.toolName, "_bash") {
t.Fatalf("upstream tools.0.name = %q, want semantic MCP alias", upstream.toolName)
}
- if got := gjson.GetBytes(resp.Payload, "choices.0.message.tool_calls.0.function.name").String(); got != "bash" {
+ if got := ojson.GetBytes(resp.Payload, "choices.0.message.tool_calls.0.function.name").String(); got != "bash" {
t.Fatalf("tool_calls.0.function.name = %q, want %q; payload=%s", got, "bash", string(resp.Payload))
}
}
@@ -8487,7 +8571,7 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) {
body, _ := io.ReadAll(r.Body)
upstreamBody = bytes.Clone(body)
upstreamHeaders = r.Header.Clone()
- upstreamAlias = gjson.GetBytes(body, "tools.0.name").String()
+ upstreamAlias = ojson.GetBytes(body, "tools.0.name").String()
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(w, `{"id":"msg_1","type":"message","role":"assistant","model":"claude-opus-4-6","content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{"query":"go"}}],"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1}}`, upstreamAlias)
}))
@@ -8513,7 +8597,7 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) {
if !helps.IsClaudeMCPToolName(upstreamAlias) || strings.HasPrefix(upstreamAlias, "proxy_") || !strings.HasSuffix(upstreamAlias, "_search_web") {
t.Fatalf("upstream tool name = %q, want semantic mcp__ alias", upstreamAlias)
}
- if got := gjson.GetBytes(resp.Payload, "content.0.name").String(); got != "search_web" {
+ if got := ojson.GetBytes(resp.Payload, "content.0.name").String(); got != "search_web" {
t.Fatalf("client response tool name = %q, want search_web; payload=%s", got, resp.Payload)
}
if _, ok := claudeBillingCCHDigitsOffset(upstreamBody); !ok {
@@ -8526,19 +8610,19 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) {
if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas {
t.Fatalf("Messages Anthropic-Beta = %q, want %q", got, wantBetas)
}
- if got := gjson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
+ if got := ojson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
t.Fatalf("Messages system.1.text = %q, want official CLI identity", got)
}
- if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(upstreamBody, "system.#").Int(); got != 2 {
t.Fatalf("Messages top-level system block count = %d, want 2 Claude Code blocks", got)
}
- content := gjson.GetBytes(upstreamBody, "messages.0.content").Array()
+ content := ojson.GetBytes(upstreamBody, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("Messages first user content has %d blocks, want currentDate and user text", len(content))
}
assertClaudeCodeCurrentDateBlock(t, content[0])
assertEphemeralUserTextBlock(t, content[1], "search", "1h")
- if got := gjson.GetBytes(upstreamBody, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(upstreamBody, "messages.#").Int(); got != 2 {
t.Fatalf("Messages count = %d, want user and relocated system turns", got)
}
assertClaudeMidConversationSystemMessage(t, upstreamBody, 1, "messages-system-prompt", "1h")
@@ -8552,7 +8636,7 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing
body, _ := io.ReadAll(r.Body)
upstreamBody = bytes.Clone(body)
upstreamHeaders = r.Header.Clone()
- upstreamAlias = gjson.GetBytes(body, "tools.0.name").String()
+ upstreamAlias = ojson.GetBytes(body, "tools.0.name").String()
w.Header().Set("Content-Type", "text/event-stream")
_, _ = fmt.Fprintf(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":%q,\"input\":{}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", upstreamAlias)
}))
@@ -8606,19 +8690,19 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing
if got := upstreamHeaders.Get("Anthropic-Beta"); got != wantBetas {
t.Fatalf("streaming Anthropic-Beta = %q, want %q", got, wantBetas)
}
- if got := gjson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
+ if got := ojson.GetBytes(upstreamBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
t.Fatalf("streaming system.1.text = %q, want official CLI identity", got)
}
- if got := gjson.GetBytes(upstreamBody, "system.#").Int(); got != 2 {
+ if got := ojson.GetBytes(upstreamBody, "system.#").Int(); got != 2 {
t.Fatalf("streaming top-level system block count = %d, want 2 Claude Code blocks", got)
}
- content := gjson.GetBytes(upstreamBody, "messages.0.content").Array()
+ content := ojson.GetBytes(upstreamBody, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("streaming first user content has %d blocks, want currentDate and user text", len(content))
}
assertClaudeCodeCurrentDateBlock(t, content[0])
assertEphemeralUserTextBlock(t, content[1], "fetch", "1h")
- if got := gjson.GetBytes(upstreamBody, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytes(upstreamBody, "messages.#").Int(); got != 2 {
t.Fatalf("streaming message count = %d, want user and relocated system turns", got)
}
assertClaudeMidConversationSystemMessage(t, upstreamBody, 1, "stream-system-prompt", "1h")
@@ -8643,7 +8727,7 @@ func TestPrependClaudeSystemReminders_FollowsToolResultsAndIsIdempotent(t *testi
if !bytes.Equal(first, second) {
t.Fatalf("caller reminder insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second)
}
- content := gjson.GetBytes(first, "messages.1.content").Array()
+ content := ojson.GetBytes(first, "messages.1.content").Array()
if len(content) != 4 {
t.Fatalf("content has %d blocks, want tool_result, two caller reminders, and user text", len(content))
}
@@ -8667,10 +8751,10 @@ func TestInsertClaudeMidConversationSystemMessages_FollowsToolResultUserTurn(t *
`]}`)
out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"})
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytes(out, "messages.#").Int(); got != 3 {
t.Fatalf("message count = %d, want 3: %s", got, out)
}
- blocks := gjson.GetBytes(out, "messages.1.content")
+ blocks := ojson.GetBytes(out, "messages.1.content")
if got := blocks.Get("0.type").String(); got != "tool_result" {
t.Fatalf("first block type = %q, want tool_result: %s", got, out)
}
@@ -8688,7 +8772,7 @@ func TestInsertClaudeMidConversationSystemMessages_PrecedesExistingAssistantTurn
`]}`)
out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"})
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "system", "assistant", "user"}
if len(roles) != len(wantRoles) {
t.Fatalf("message count = %d, want %d: %s", len(roles), len(wantRoles), out)
@@ -8709,7 +8793,7 @@ func TestInsertClaudeMidConversationSystemMessages_FollowsConsecutiveUserRun(t *
`]}`)
out := insertClaudeMidConversationSystemMessages(payload, []string{"guidance"})
- roles := gjson.GetBytes(out, "messages.#.role").Array()
+ roles := ojson.GetBytes(out, "messages.#.role").Array()
wantRoles := []string{"user", "user", "system", "assistant"}
if len(roles) != len(wantRoles) {
t.Fatalf("message count = %d, want %d: %s", len(roles), len(wantRoles), out)
@@ -8730,7 +8814,7 @@ func TestInsertClaudeMidConversationSystemMessages_IsIdempotent(t *testing.T) {
if !bytes.Equal(first, second) {
t.Fatalf("mid-conversation system insertion is not idempotent:\nfirst: %s\nsecond: %s", first, second)
}
- if got := gjson.GetBytes(first, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytes(first, "messages.#").Int(); got != 3 {
t.Fatalf("message count = %d, want user and two system messages: %s", got, first)
}
assertClaudeMidConversationSystemMessage(t, first, 1, texts[0], "")
@@ -9218,7 +9302,7 @@ func TestInjectClaudeCodeContextManagement(t *testing.T) {
if !automaticallyInjected {
t.Fatal("automatic context_management injection was not reported")
}
- if diff := gjson.GetBytes(got, "context_management").Raw; diff != captured {
+ if diff := ojson.GetBytes(got, "context_management").Raw; diff != captured {
t.Fatalf("context_management = %s, want the captured object %s", diff, captured)
}
})
@@ -9253,7 +9337,7 @@ func TestInjectClaudeCodeContextManagement(t *testing.T) {
if !bytes.Equal(got, ineligible) {
t.Errorf("ineligible payload was modified: %s", got)
}
- if cm := gjson.GetBytes(got, "context_management"); cm.Exists() {
+ if cm := ojson.GetBytes(got, "context_management"); cm.Exists() {
t.Errorf("context_management = %s, want absent", cm.Raw)
}
})
@@ -9293,9 +9377,9 @@ func TestClaudeCodeContextManagementNeverOutlivesEligibleThinking(t *testing.T)
body = disableThinkingIfToolChoiceForced(body)
body = reconcileClaudeCodeContextManagement(body, state)
- thinkingEligible := gjson.GetBytes(body, "thinking.type").String() == "enabled" ||
- gjson.GetBytes(body, "thinking.type").String() == "adaptive"
- cm := gjson.GetBytes(body, "context_management")
+ thinkingEligible := ojson.GetBytes(body, "thinking.type").String() == "enabled" ||
+ ojson.GetBytes(body, "thinking.type").String() == "adaptive"
+ cm := ojson.GetBytes(body, "context_management")
if cm.Exists() && !thinkingEligible {
t.Fatalf("context_management = %s survived ineligible thinking; Anthropic would reject this: %s", cm.Raw, body)
}
@@ -9380,7 +9464,7 @@ func TestReconcileClaudeCodeContextManagement(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
got := reconcileClaudeCodeContextManagement([]byte(test.payload), test.state)
- if raw := gjson.GetBytes(got, "context_management").Raw; raw != test.wantRaw {
+ if raw := ojson.GetBytes(got, "context_management").Raw; raw != test.wantRaw {
t.Fatalf("context_management = %s, want %s; body=%s", raw, test.wantRaw, got)
}
})
@@ -9405,10 +9489,10 @@ func TestClaudeExecutorPayloadOverrideDisabledThinking(t *testing.T) {
Params: map[string]any{"thinking.type": "disabled"},
}}}}
upstreamBody := executeClaudeContextManagementRequest(t, cfg, basePayload, test.stream)
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "disabled" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "disabled" {
t.Fatalf("final upstream thinking.type = %q, want disabled; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Errorf("final upstream context_management = %s with disabled thinking, want absent", got.Raw)
}
})
@@ -9421,7 +9505,7 @@ func TestClaudeExecutorPayloadOverrideDisabledThinking(t *testing.T) {
}}}}
payload := []byte(`{"model":"claude-opus-5","max_tokens":16,"messages":[{"role":"user","content":"hi"}],"context_management":{"edits":[{"type":"caller_owned"}]}}`)
upstreamBody := executeClaudeContextManagementRequest(t, cfg, payload, false)
- if got := gjson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "caller_owned" {
+ if got := ojson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "caller_owned" {
t.Fatalf("caller context_management type = %q, want caller_owned; body=%s", got, upstreamBody)
}
})
@@ -9435,7 +9519,7 @@ func TestClaudeExecutorPayloadOverrideDisabledThinking(t *testing.T) {
},
}}}}
upstreamBody := executeClaudeContextManagementRequest(t, cfg, basePayload, false)
- if got := gjson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "payload_rule" {
+ if got := ojson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "payload_rule" {
t.Fatalf("payload-rule context_management type = %q, want payload_rule; body=%s", got, upstreamBody)
}
})
@@ -9503,10 +9587,10 @@ func TestClaudeExecutorPayloadOverrideDisabledThinking(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
upstreamBody := executeClaudeContextManagementRequest(t, ownership.cfg, basePayload, stream)
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "disabled" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "disabled" {
t.Fatalf("final upstream thinking.type = %q, want disabled; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "context_management").Raw; got != claudeCodeContextManagement {
+ if got := ojson.GetBytes(upstreamBody, "context_management").Raw; got != claudeCodeContextManagement {
t.Fatalf("%s context_management = %s, want payload-rule-owned %s; body=%s", ownership.name, got, claudeCodeContextManagement, upstreamBody)
}
})
@@ -9520,7 +9604,7 @@ func TestClaudeExecutorPayloadOverrideDisabledThinking(t *testing.T) {
Params: []string{"context_management"},
}}}}
upstreamBody := executeClaudeContextManagementRequest(t, cfg, basePayload, false)
- if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Fatalf("filtered context_management = %s, want absent", got.Raw)
}
})
@@ -9537,13 +9621,13 @@ func TestClaudeExecutorPayloadOverrideDisabledThinking(t *testing.T) {
t.Run(name, func(t *testing.T) {
payload := []byte(`{"model":"claude-opus-5","max_tokens":16,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"adaptive"},"tool_choice":{"type":"any"}}`)
upstreamBody := executeClaudeContextManagementRequest(t, &config.Config{}, payload, stream)
- if got := gjson.GetBytes(upstreamBody, "thinking"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "thinking"); got.Exists() {
t.Fatalf("forced tool choice thinking = %s, want absent", got.Raw)
}
- if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Fatalf("forced tool choice context_management = %s, want absent because Anthropic rejects it without thinking", got.Raw)
}
- if got := gjson.GetBytes(upstreamBody, "tool_choice.type").String(); got != "any" {
+ if got := ojson.GetBytes(upstreamBody, "tool_choice.type").String(); got != "any" {
t.Fatalf("forced tool_choice.type = %q, want any", got)
}
})
@@ -9571,10 +9655,10 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) {
Params: map[string]any{"thinking.type": test.thinkingType},
}}}}
upstreamBody := executeClaudeContextManagementRequest(t, cfg, basePayload, test.stream)
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != test.thinkingType {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != test.thinkingType {
t.Fatalf("final upstream thinking.type = %q, want %q; body=%s", got, test.thinkingType, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "context_management").Raw; got != "" {
+ if got := ojson.GetBytes(upstreamBody, "context_management").Raw; got != "" {
t.Fatalf("payload must not reinject context_management after enabling thinking: %s", upstreamBody)
}
})
@@ -9593,7 +9677,7 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) {
}}}}
payload := []byte(`{"model":"claude-opus-5","max_tokens":16,"messages":[{"role":"user","content":"hi"}],"thinking":{"type":"disabled"},"context_management":{"edits":[{"type":"caller_owned"}]}}`)
upstreamBody := executeClaudeContextManagementRequest(t, cfg, payload, stream)
- if got := gjson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "caller_owned" {
+ if got := ojson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "caller_owned" {
t.Fatalf("caller context_management type = %q, want caller_owned; body=%s", got, upstreamBody)
}
})
@@ -9607,7 +9691,7 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) {
},
}}}}
upstreamBody := executeClaudeContextManagementRequest(t, cfg, basePayload, stream)
- if got := gjson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "payload_rule" {
+ if got := ojson.GetBytes(upstreamBody, "context_management.edits.0.type").String(); got != "payload_rule" {
t.Fatalf("payload-rule context_management type = %q, want payload_rule; body=%s", got, upstreamBody)
}
})
@@ -9624,10 +9708,10 @@ func TestClaudeExecutorPayloadOverrideReenablesThinking(t *testing.T) {
}},
}}
upstreamBody := executeClaudeContextManagementRequest(t, cfg, basePayload, stream)
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "enabled" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "enabled" {
t.Fatalf("final upstream thinking.type = %q, want enabled; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Fatalf("filtered context_management = %s after re-enabling, want absent; body=%s", got.Raw, upstreamBody)
}
})
@@ -9696,7 +9780,7 @@ func TestValidateClaudeCallerSystemBlocksAcceptsTextOnly(t *testing.T) {
if test.system != "" {
payload = `{"model":"claude-opus-5","system":` + test.system + `}`
}
- if err := validateClaudeCallerSystemBlocks(gjson.Get(payload, "system")); err != nil {
+ if err := validateClaudeCallerSystemBlocks(ojson.Get(payload, "system")); err != nil {
t.Fatalf("validateClaudeCallerSystemBlocks() error = %v, want nil", err)
}
})
@@ -9736,7 +9820,7 @@ func TestValidateClaudeCallerSystemBlocksRejectsNonTextBlock(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- err := validateClaudeCallerSystemBlocks(gjson.Parse(test.system))
+ err := validateClaudeCallerSystemBlocks(ojson.Parse(test.system))
if err == nil {
t.Fatal("validateClaudeCallerSystemBlocks() error = nil, want rejection")
}
@@ -9791,7 +9875,7 @@ func TestApplyCloakingStrictModeIgnoresNonTextCallerSystemBlock(t *testing.T) {
if !cloaked {
t.Fatal("applyCloaking() cloaked = false, want true")
}
- if got := len(gjson.GetBytes(out, "system").Array()); got != 2 {
+ if got := len(ojson.GetBytes(out, "system").Array()); got != 2 {
t.Fatalf("system blocks = %d, want the 2 Claude Code blocks", got)
}
}
@@ -9877,11 +9961,11 @@ func TestClaudeExecutor_CacheTTLIsPairedWithExtendedCacheTTLBeta(t *testing.T) {
t.Fatalf("Execute() error = %v", errExecute)
}
- gotTTL := gjson.GetBytes(seenBody, "system.1.cache_control.ttl").String()
+ gotTTL := ojson.GetBytes(seenBody, "system.1.cache_control.ttl").String()
if gotTTL != test.wantTTL {
t.Fatalf("system[1].cache_control.ttl = %q, want %q: %s", gotTTL, test.wantTTL, seenBody)
}
- if got := gjson.GetBytes(seenBody, "system.1.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytes(seenBody, "system.1.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("system[1].cache_control.type = %q, want ephemeral: %s", got, seenBody)
}
gotBeta := strings.Contains(seenHeaders.Get("Anthropic-Beta"), claudeExtendedCacheTTLBeta)
@@ -10282,7 +10366,7 @@ func TestClaudeExecutor_CloakModePrefersStoredPrevReqOverCallerFake(t *testing.T
if len(seenBodies) < 2 {
t.Fatalf("expected at least 2 requests, got %d", len(seenBodies))
}
- turn2System := gjson.GetBytes(seenBodies[1], "system.0.text").String()
+ turn2System := ojson.GetBytes(seenBodies[1], "system.0.text").String()
if !strings.Contains(turn2System, "cc_prev_req=req_real_upstream_001") {
t.Fatalf("CPA in cloak mode must use real storedPrevReq req_real_upstream_001, got: %s", turn2System)
}
@@ -10321,7 +10405,7 @@ func TestClaudeExecutor_ChatCompletionsDefaultMaxTokensAndPayloadDefaultRule(t *
if errExec != nil {
t.Fatalf("Execute() error = %v", errExec)
}
- if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 128000 {
+ if got := ojson.GetBytes(seenBody, "max_tokens").Int(); got != 128000 {
t.Fatalf("Execute max_tokens = %d, want 128000; body=%s", got, seenBody)
}
})
@@ -10348,7 +10432,7 @@ func TestClaudeExecutor_ChatCompletionsDefaultMaxTokensAndPayloadDefaultRule(t *
if errExec != nil {
t.Fatalf("Execute() omitted error = %v", errExec)
}
- if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 16384 {
+ if got := ojson.GetBytes(seenBody, "max_tokens").Int(); got != 16384 {
t.Fatalf("Execute omitted max_tokens = %d, want payload.default 16384; body=%s", got, seenBody)
}
@@ -10369,7 +10453,7 @@ func TestClaudeExecutor_ChatCompletionsDefaultMaxTokensAndPayloadDefaultRule(t *
t.Fatalf("ExecuteStream() chunk error = %v", chunk.Err)
}
}
- if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != 16384 {
+ if got := ojson.GetBytes(seenBody, "max_tokens").Int(); got != 16384 {
t.Fatalf("ExecuteStream omitted max_tokens = %d, want payload.default 16384; body=%s", got, seenBody)
}
@@ -10424,7 +10508,7 @@ func TestClaudeExecutor_ChatCompletionsDefaultMaxTokensAndPayloadDefaultRule(t *
if errCase != nil {
t.Fatalf("%s: Execute() error = %v", pc.name, errCase)
}
- if got := gjson.GetBytes(seenBody, "max_tokens").Int(); got != pc.wantLimit {
+ if got := ojson.GetBytes(seenBody, "max_tokens").Int(); got != pc.wantLimit {
t.Fatalf("%s: max_tokens = %d, want %d; body=%s", pc.name, got, pc.wantLimit, seenBody)
}
}
@@ -10504,7 +10588,7 @@ func TestClaudeExecutor_PreservedThinkingReplayInjectsDropBlock(t *testing.T) {
for _, model := range []string{"claude-haiku-5-5", "claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5-1"} {
t.Run(model, func(t *testing.T) {
seenBody, seenHeaders := runClaudeExecutorCapture(t, nil, "", false, model, preservedThinkingReplayPayload(model))
- if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
+ if got := ojson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
t.Fatalf("thinking.block_binding.prefix_mismatch_behavior = %q, want drop_block; body=%s", got, seenBody)
}
if betas := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(betas, claudeThinkingBindingBeta) {
@@ -10519,7 +10603,7 @@ func TestClaudeExecutor_PreservedThinkingReplayInjectsDropBlock(t *testing.T) {
func TestClaudeExecutor_PreservedThinkingReplayInjectsDropBlockOnStream(t *testing.T) {
seenBody, seenHeaders := runClaudeExecutorCapture(t, nil, "", true, "claude-haiku-5-5", preservedThinkingReplayPayload("claude-haiku-5-5"))
- if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
+ if got := ojson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
t.Fatalf("stream thinking.block_binding.prefix_mismatch_behavior = %q, want drop_block; body=%s", got, seenBody)
}
if betas := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(betas, claudeThinkingBindingBeta) {
@@ -10529,7 +10613,7 @@ func TestClaudeExecutor_PreservedThinkingReplayInjectsDropBlockOnStream(t *testi
func TestClaudeExecutor_PreservedThinkingInjectionSkipsNonPreservedModel(t *testing.T) {
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-opus-5", preservedThinkingReplayPayload("claude-opus-5"))
- if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("non-preserved-thinking model must not gain block_binding: %s", seenBody)
}
}
@@ -10550,7 +10634,7 @@ func TestClaudeExecutor_PreservedThinkingRequiresSignedBlock(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", []byte(tc.payload))
- if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("request without replayed signed thinking must not gain block_binding: %s", seenBody)
}
})
@@ -10575,10 +10659,10 @@ func TestClaudeExecutor_PreservedThinkingOmittedThinkingSynthesizesAdaptiveBindi
t.Run(model+"_"+tc.name, func(t *testing.T) {
payload := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[` + tc.block + `,{"type":"text","text":"Answer"}]},{"role":"user","content":"continue"}]}`)
seenBody, seenHeaders := runClaudeExecutorCapture(t, nil, "", false, model, payload)
- if got := gjson.GetBytes(seenBody, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(seenBody, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, seenBody)
}
- if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
+ if got := ojson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "drop_block" {
t.Fatalf("thinking.block_binding.prefix_mismatch_behavior = %q, want drop_block; body=%s", got, seenBody)
}
if betas := seenHeaders.Get("Anthropic-Beta"); !strings.Contains(betas, claudeThinkingBindingBeta) {
@@ -10592,7 +10676,7 @@ func TestClaudeExecutor_PreservedThinkingOmittedThinkingSynthesizesAdaptiveBindi
func TestClaudeExecutor_PreservedThinkingTypelessThinkingObjectUntouched(t *testing.T) {
payload := []byte(`{"model":"claude-haiku-5-5","thinking":{},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
- if gjson.GetBytes(seenBody, "thinking.type").Exists() || gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.type").Exists() || ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("thinking object without type must stay untouched: %s", seenBody)
}
}
@@ -10625,7 +10709,7 @@ func TestClaudeExecutor_PreservedThinkingForcedToolChoiceKeepsThinkingOff(t *tes
t.Run(tc.name, func(t *testing.T) {
payload := []byte(`{"model":"claude-haiku-5-5",` + tc.thinking + `"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"tool_choice":` + tc.toolChoice + `,"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
- if gjson.GetBytes(seenBody, "thinking").Exists() {
+ if ojson.GetBytes(seenBody, "thinking").Exists() {
t.Fatalf("forced tool_choice must keep thinking off: %s", seenBody)
}
})
@@ -10635,7 +10719,7 @@ func TestClaudeExecutor_PreservedThinkingForcedToolChoiceKeepsThinkingOff(t *tes
func TestClaudeExecutor_PreservedThinkingInjectionKeepsCallerError(t *testing.T) {
payload := []byte(`{"model":"claude-haiku-5-5","thinking":{"type":"adaptive","block_binding":{"prefix_mismatch_behavior":"error"}},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
- if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "error" {
+ if got := ojson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "error" {
t.Fatalf("caller prefix_mismatch_behavior = %q, want error preserved; body=%s", got, seenBody)
}
}
@@ -10648,7 +10732,7 @@ func TestClaudeExecutor_PayloadOverrideWinsOverPreservedThinkingInjection(t *tes
}},
}}
seenBody, _ := runClaudeExecutorCapture(t, cfg, "", false, "claude-haiku-5-5", preservedThinkingReplayPayload("claude-haiku-5-5"))
- if got := gjson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "error" {
+ if got := ojson.GetBytes(seenBody, "thinking.block_binding.prefix_mismatch_behavior").String(); got != "error" {
t.Fatalf("payload override must be the final barrier, got %q; body=%s", got, seenBody)
}
}
@@ -10656,7 +10740,7 @@ func TestClaudeExecutor_PayloadOverrideWinsOverPreservedThinkingInjection(t *tes
func TestClaudeExecutor_PreservedThinkingInjectionSkipsDisabledHaikuThinking(t *testing.T) {
payload := []byte(`{"model":"claude-haiku-5-5","thinking":{"type":"disabled"},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-haiku-5-5", payload)
- if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("block_binding with disabled thinking is rejected upstream on haiku-5-5: %s", seenBody)
}
}
@@ -10664,14 +10748,14 @@ func TestClaudeExecutor_PreservedThinkingInjectionSkipsDisabledHaikuThinking(t *
func TestClaudeExecutor_PreservedThinkingInjectionSkipsBetweenToolsSonnetThinking(t *testing.T) {
payload := []byte(`{"model":"claude-sonnet-5-5","thinking":{"type":"between_tools"},"messages":[{"role":"user","content":"hi"},{"role":"assistant","content":[{"type":"thinking","thinking":"earlier","signature":"` + preservedThinkingTestSignature + `"}]},{"role":"user","content":"continue"}]}`)
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-sonnet-5-5", payload)
- if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("block_binding with between_tools thinking is rejected upstream on sonnet-5-5: %s", seenBody)
}
}
func TestClaudeExecutor_PreservedThinkingInjectionSkipsMythosModel(t *testing.T) {
seenBody, _ := runClaudeExecutorCapture(t, nil, "", false, "claude-mythos-5-1", preservedThinkingReplayPayload("claude-mythos-5-1"))
- if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("mythos-5-1 does not run the prefix check and must not gain block_binding: %s", seenBody)
}
}
@@ -10684,7 +10768,7 @@ func TestClaudeExecutor_PayloadFilterRemovesPreservedThinkingInjection(t *testin
}},
}}
seenBody, _ := runClaudeExecutorCapture(t, cfg, "", false, "claude-haiku-5-5", preservedThinkingReplayPayload("claude-haiku-5-5"))
- if gjson.GetBytes(seenBody, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(seenBody, "thinking.block_binding").Exists() {
t.Fatalf("payload filter must remove injected block_binding: %s", seenBody)
}
}
diff --git a/internal/runtime/executor/claude_executor_thinking_signature_test.go b/internal/runtime/executor/claude_executor_thinking_signature_test.go
index afbc89c212f..7d5aa56c769 100644
--- a/internal/runtime/executor/claude_executor_thinking_signature_test.go
+++ b/internal/runtime/executor/claude_executor_thinking_signature_test.go
@@ -8,8 +8,8 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
// thinkingSignatureFixtures are signature shapes that survive a JSON round trip
@@ -32,8 +32,8 @@ func thinkingSignatureFixtures() []string {
func collectThinkingSignatures(t *testing.T, body []byte) []string {
t.Helper()
var found []string
- gjson.GetBytes(body, "messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ ojson.GetBytes(body, "messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
if signature := block.Get("signature"); signature.Exists() {
found = append(found, signature.String())
}
@@ -160,8 +160,8 @@ func TestClaudeThinkingSignaturesSurviveSensitiveWordObfuscation(t *testing.T) {
}
var obfuscatedUserText bool
- gjson.GetBytes(out, "messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, contentBlock gjson.Result) bool {
+ ojson.GetBytes(out, "messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, contentBlock ojson.Result) bool {
if contentBlock.Get("type").String() != "text" {
return true
}
diff --git a/internal/runtime/executor/claude_executor_tokens.go b/internal/runtime/executor/claude_executor_tokens.go
index a4855194d9a..5fd9fd6b969 100644
--- a/internal/runtime/executor/claude_executor_tokens.go
+++ b/internal/runtime/executor/claude_executor_tokens.go
@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
@@ -15,8 +16,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
@@ -38,6 +37,13 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
to := sdktranslator.FromString("claude")
+ originalPayload := req.Payload
+ if len(opts.OriginalRequest) > 0 {
+ originalPayload = opts.OriginalRequest
+ }
+ webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
+ ctx = helps.WithClaudeWebSearchResultRestore(ctx, webSearchSessionKey, webSearchTTL)
+
// Use streaming translation to preserve function calling, except for claude.
stream := from != to
body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req))
@@ -48,26 +54,23 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
if len(opts.OriginalRequest) > 0 {
originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, stream, helps.APIKeyModelIsCompat(req))
}
- body = helps.SetStringIfDifferent(body, "model", upstreamModel)
- body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body)
- var errThinking error
- body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if errThinking != nil {
+ doc := ojson.NewDoc(body)
+ helps.SetStringIfDifferentDoc(doc, "model", upstreamModel)
+ helps.NormalizeClaudeToolChoiceDoc(e.upstreamRequestLogProvider(), upstreamModel, doc)
+ if errThinking := helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); errThinking != nil {
return cliproxyexecutor.Response{}, errThinking
}
- originalPayload := req.Payload
- if len(opts.OriginalRequest) > 0 {
- originalPayload = opts.OriginalRequest
- }
- webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
- body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
+ helps.RestoreClaudeWebSearchResultsContextDoc(ctx, doc, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
- body = rebuildMidSystemMessagesToTopLevel(body)
- }
- body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req))
- body = applyClaudePreservedThinkingBinding(body)
- body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body)
- body = stripPromptCacheOptions(body)
+ rebuildMidSystemMessagesToTopLevelDoc(doc)
+ }
+ sanitizeClaudeMessagesForClaudeUpstreamWithDebugDoc(ctx, doc, baseModel, helps.APIKeyModelIsCompat(req))
+ applyClaudePreservedThinkingBindingDoc(doc)
+ finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)
+ ctx = helps.WithPayloadFinalizer(ctx, finalize)
+ helps.FinalizePayloadDoc(ctx, doc)
+ stripPromptCacheOptionsDoc(doc)
+ body = doc.Bytes()
if errValidate := validateClaudeTokenCountRequest(body); errValidate != nil {
return cliproxyexecutor.Response{}, errValidate
}
@@ -97,10 +100,10 @@ func newClaudeTokenCountValidationError(message string) error {
}
func validateClaudeTokenCountRequest(body []byte) error {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return newClaudeTokenCountValidationError("invalid Claude token count request JSON")
}
- root := gjson.ParseBytes(body)
+ root := util.ParseGJSONBytesNoCopy(body)
if !root.IsObject() {
return newClaudeTokenCountValidationError("Claude token count request must be a JSON object")
}
@@ -117,14 +120,14 @@ func validateClaudeTokenCountRequest(body []byte) error {
return newClaudeTokenCountValidationError("Claude token count request message role must be user or assistant")
}
content := message.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
continue
}
if !content.IsArray() {
return newClaudeTokenCountValidationError("Claude token count request message content must be a string or array")
}
for _, block := range content.Array() {
- if !block.IsObject() || block.Get("type").Type != gjson.String || block.Get("type").String() == "" {
+ if !block.IsObject() || block.Get("type").Type != ojson.String || block.Get("type").String() == "" {
return newClaudeTokenCountValidationError("Claude token count request content blocks must be typed objects")
}
}
@@ -163,6 +166,8 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
if fp.ProfileClaudeCodeCLI {
claudeSessionID = helps.ClaudeAgentSessionUUIDForRequest(incomingHeaders, originalPayload, req.Payload, confirmedClaudeCode, opts.Metadata, req.Metadata)
}
+ webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
+ ctx = helps.WithClaudeWebSearchResultRestore(ctx, webSearchSessionKey, webSearchTTL)
// Use streaming translation to preserve function calling, except for claude.
stream := from != to
body, err := helps.TranslateRequestReturningError(ctx, opts.Headers, e.cfg, from, to, baseModel, req.Payload, stream, helps.APIKeyModelIsCompat(req))
@@ -173,17 +178,15 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
if len(opts.OriginalRequest) > 0 {
originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, stream, helps.APIKeyModelIsCompat(req))
}
- body = helps.SetStringIfDifferent(body, "model", upstreamModel)
- body = helps.NormalizeClaudeToolChoice(e.upstreamRequestLogProvider(), upstreamModel, body)
- var errThinking error
- body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if errThinking != nil {
+ doc := ojson.NewDoc(body)
+ helps.SetStringIfDifferentDoc(doc, "model", upstreamModel)
+ helps.NormalizeClaudeToolChoiceDoc(e.upstreamRequestLogProvider(), upstreamModel, doc)
+ if errThinking := helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); errThinking != nil {
return cliproxyexecutor.Response{}, errThinking
}
- webSearchSessionKey, webSearchTTL, _ := e.claudeWebSearchResultCacheScope(ctx, auth, from, req, opts, originalPayload)
- body = helps.RestoreClaudeWebSearchResultsContext(ctx, body, webSearchSessionKey, webSearchTTL)
+ helps.RestoreClaudeWebSearchResultsContextDoc(ctx, doc, webSearchSessionKey, webSearchTTL)
if rebuildMidSystemMessageEnabled(e.cfg, auth) {
- body = rebuildMidSystemMessagesToTopLevel(body)
+ rebuildMidSystemMessagesToTopLevelDoc(doc)
}
directAnthropic := isAnthropicUpstreamBase(baseURL)
@@ -194,39 +197,38 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
// Messages path. Kimi opt-in uses the same contract.
policy, settings := resolveClaudeWirePolicy(e.cfg, auth, apiKey, confirmedClaudeCode)
cloaked := policy.Cloak
- explicitCacheMode := isExplicitPromptCacheMode(originalPayload, req.Payload, body)
+ explicitCacheMode := isExplicitPromptCacheModeDoc(doc, originalPayload, req.Payload)
if cloaked {
if !settings.strictMode {
- if errSystem := validateClaudeCallerSystemBlocks(gjson.GetBytes(body, "system")); errSystem != nil {
+ if errSystem := validateClaudeCallerSystemBlocks(doc.Get("system")); errSystem != nil {
return cliproxyexecutor.Response{}, errSystem
}
}
- body = relocateClaudeSystemPromptForCountTokensWithPolicy(body, settings.strictMode, explicitCacheMode, !policy.OAuth)
+ relocateClaudeSystemPromptForCountTokensWithPolicyDoc(doc, settings.strictMode, explicitCacheMode, !policy.OAuth)
if len(settings.sensitiveWords) > 0 {
- body = helps.ObfuscateSensitiveWords(body, helps.BuildSensitiveWordMatcher(settings.sensitiveWords))
+ helps.ObfuscateSensitiveWordsDoc(doc, helps.BuildSensitiveWordMatcher(settings.sensitiveWords))
}
} else if policy.Uncloak {
- body = uncloakClaudeCodePayload(body, confirmedClaudeCode)
+ uncloakClaudeCodePayloadDoc(doc, confirmedClaudeCode)
}
// Keep count_tokens requests compatible with Anthropic cache-control constraints too.
- body = enforceCacheControlLimit(body, 4)
+ enforceCacheControlLimitDoc(doc, 4)
if !explicitCacheMode {
- body = normalizeCacheControlTTL(body)
+ normalizeCacheControlTTLDoc(doc)
}
// Extract betas from body and convert to header (for count_tokens too)
- var extraBetas []string
- extraBetas, body = extractAndRemoveBetas(body)
+ extraBetas, _ := extractAndRemoveBetasDoc(doc)
// Claude Code 2.1.220's beta.messages.countTokens() always appends this beta.
extraBetas = append(extraBetas, claudeTokenCountingBeta)
if fp.MCPAlias && cloaked {
mcpAliases := resolveClaudeMCPAliasOptions(ctx, opts.Metadata, req.Metadata)
- body, _ = prepareClaudeOAuthToolNamesForUpstream(body, mcpAliases)
+ prepareClaudeOAuthToolNamesForUpstreamDoc(doc, mcpAliases)
}
- body = sanitizeClaudeMessagesForClaudeUpstreamWithDebug(ctx, body, baseModel, helps.APIKeyModelIsCompat(req))
- body = applyClaudePreservedThinkingBinding(body)
- body = stripPromptCacheOptions(body)
+ sanitizeClaudeMessagesForClaudeUpstreamWithDebugDoc(ctx, doc, baseModel, helps.APIKeyModelIsCompat(req))
+ applyClaudePreservedThinkingBindingDoc(doc)
+ stripPromptCacheOptionsDoc(doc)
// Two different reasons converge on the same deletions, and they must stay
// separable.
//
@@ -243,21 +245,24 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
// messages and tools, never a system block.
alignCLICountTokensShape := fp.ProfileClaudeCodeCLI
if directAnthropic || alignCLICountTokensShape {
- body, _ = sjson.DeleteBytes(body, "metadata")
- body, _ = sjson.DeleteBytes(body, "context_management")
- body, _ = sjson.DeleteBytes(body, "diagnostics")
+ _ = doc.Delete("metadata")
+ _ = doc.Delete("context_management")
+ _ = doc.Delete("diagnostics")
}
if alignCLICountTokensShape {
- body = util.StripClaudeCodeAttributionSystem(body)
+ stripClaudeCodeAttributionSystemDoc(doc)
}
// Runs on the finished body: payload rules can rewrite model and messages
// long after translation, so an earlier check would not describe the request
// that is about to be sent.
+ finalize := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)
+ ctx = helps.WithPayloadFinalizer(ctx, finalize)
+ helps.FinalizePayloadDoc(ctx, doc)
+ stripPromptCacheOptionsDoc(doc)
+ body = doc.Bytes()
if errMidSystem := validateClaudeMidSystemMessageModel(body, confirmedClaudeCode, directAnthropic); errMidSystem != nil {
return cliproxyexecutor.Response{}, errMidSystem
}
- body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body)
- body = stripPromptCacheOptions(body)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return cliproxyexecutor.Response{}, err
@@ -333,7 +338,7 @@ func (e *ClaudeExecutor) countTokensUpstream(ctx context.Context, auth *cliproxy
return cliproxyexecutor.Response{}, err
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
- count := gjson.GetBytes(data, "input_tokens").Int()
+ count := ojson.GetBytesNoCopy(data, "input_tokens").Int()
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, data)
return cliproxyexecutor.Response{Payload: out, Headers: resp.Header.Clone()}, nil
}
diff --git a/internal/runtime/executor/claude_executor_tool_search_disabled_test.go b/internal/runtime/executor/claude_executor_tool_search_disabled_test.go
index 8c0c9b42c6b..ad33597f521 100644
--- a/internal/runtime/executor/claude_executor_tool_search_disabled_test.go
+++ b/internal/runtime/executor/claude_executor_tool_search_disabled_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -19,8 +20,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// toolSearchExecutorGoldenPayload is a Codex Responses turn with a client
@@ -91,7 +90,7 @@ func renderToolSearchExecutorGolden(t *testing.T) map[string]string {
}
// OAuth cloaking derives metadata.user_id from a random device id,
// and the billing header's cch hash covers it.
- body, _ := sjson.DeleteBytes(upstream.body, "metadata.user_id")
+ body, _ := ojson.DeleteBytes(upstream.body, "metadata.user_id")
golden[authName+"/"+path] = toolSearchExecutorGoldenCCH.ReplaceAllString(string(body), "cch=0;")
}
}
@@ -162,7 +161,7 @@ func toolSearchReloadContext(t *testing.T, flipTo bool) (context.Context, *[]byt
responsesclaude.SetClientToolSearchEnabled(flipTo)
contentType := "application/json"
response := `{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"tool_use","id":"toolu_search","name":"tool_search","input":{"query":"calendar"}}],"stop_reason":"tool_use","usage":{"input_tokens":1,"output_tokens":1}}`
- if gjson.GetBytes(body, "stream").Bool() {
+ if ojson.GetBytes(body, "stream").Bool() {
contentType = "text/event-stream"
response = strings.Join([]string{
`event: message_start` + "\n" + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[],"stop_reason":null,"usage":{"input_tokens":1,"output_tokens":0}}}`,
@@ -201,10 +200,10 @@ func TestClaudeExecutor_ToolSearchSwitchIsPinnedPerRequest(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute: %v", errExecute)
}
- if got := gjson.GetBytes(*sent, `tools.#(name=="tool_search")`).Exists(); got != tc.wantTools {
+ if got := ojson.GetBytes(*sent, `tools.#(name=="tool_search")`).Exists(); got != tc.wantTools {
t.Fatalf("upstream tool_search declared = %v, want %v: %s", got, tc.wantTools, *sent)
}
- if got := gjson.GetBytes(resp.Payload, "output.0.type").String(); got != tc.wantItem {
+ if got := ojson.GetBytes(resp.Payload, "output.0.type").String(); got != tc.wantItem {
t.Fatalf("output item = %s, want %s: %s", got, tc.wantItem, resp.Payload)
}
})
@@ -221,13 +220,13 @@ func TestClaudeExecutor_ToolSearchSwitchIsPinnedPerRequest(t *testing.T) {
t.Fatalf("stream chunk: %v", chunk.Err)
}
for _, line := range strings.Split(string(chunk.Payload), "\n") {
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "response.output_item.done" {
items = append(items, data.Get("item.type").String())
}
}
}
- if got := gjson.GetBytes(*sent, `tools.#(name=="tool_search")`).Exists(); got != tc.wantTools {
+ if got := ojson.GetBytes(*sent, `tools.#(name=="tool_search")`).Exists(); got != tc.wantTools {
t.Fatalf("upstream tool_search declared = %v, want %v: %s", got, tc.wantTools, *sent)
}
if len(items) != 1 || items[0] != tc.wantItem {
@@ -269,8 +268,8 @@ func TestClaudeExecutor_ToolSearchFallbackPinsTranslation(t *testing.T) {
t.Fatalf("translate: %v", errTranslate)
}
for name, want := range tc.wantToolNames {
- if got := gjson.GetBytes(body, `tools.#(name=="`+name+`")`).Exists(); got != want {
- t.Errorf("tool %s declared = %v, want %v: %s", name, got, want, gjson.GetBytes(body, "tools").Raw)
+ if got := ojson.GetBytes(body, `tools.#(name=="`+name+`")`).Exists(); got != want {
+ t.Errorf("tool %s declared = %v, want %v: %s", name, got, want, ojson.GetBytes(body, "tools").Raw)
}
}
})
diff --git a/internal/runtime/executor/claude_executor_tool_search_test.go b/internal/runtime/executor/claude_executor_tool_search_test.go
index d5f1037ad6e..228695725fd 100644
--- a/internal/runtime/executor/claude_executor_tool_search_test.go
+++ b/internal/runtime/executor/claude_executor_tool_search_test.go
@@ -6,13 +6,13 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
responsesclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/claude/openai/responses"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// nativeToolSearchResponsesPayload is a Codex Responses turn after one client
@@ -92,14 +92,14 @@ func captureNativeToolSearchBody(t *testing.T, cfg *config.Config, send nativeTo
func assertNativeToolSearchBody(t *testing.T, body []byte) {
t.Helper()
- calendar := gjson.GetBytes(body, `tools.#(name=="`+nativeToolSearchCalendar+`")`)
+ calendar := ojson.GetBytes(body, `tools.#(name=="`+nativeToolSearchCalendar+`")`)
if !calendar.Get("defer_loading").Bool() {
- t.Fatalf("discovered tool is not deferred: %s", gjson.GetBytes(body, "tools").Raw)
+ t.Fatalf("discovered tool is not deferred: %s", ojson.GetBytes(body, "tools").Raw)
}
if !bytes.Contains(body, []byte(`{"type":"tool_reference","tool_name":"`+nativeToolSearchCalendar+`"}`)) {
- t.Fatalf("tool_reference missing: %s", gjson.GetBytes(body, "messages").Raw)
+ t.Fatalf("tool_reference missing: %s", ojson.GetBytes(body, "messages").Raw)
}
- gjson.GetBytes(body, "tools").ForEach(func(_, tool gjson.Result) bool {
+ ojson.GetBytes(body, "tools").ForEach(func(_, tool ojson.Result) bool {
if tool.Get("defer_loading").Bool() && tool.Get("cache_control").Exists() {
t.Fatalf("deferred tool carries cache_control: %s", tool.Raw)
}
@@ -109,7 +109,7 @@ func assertNativeToolSearchBody(t *testing.T, body []byte) {
func assertDevToolSearchBody(t *testing.T, body []byte) {
t.Helper()
- if gjson.GetBytes(body, `tools.#(name=="tool_search")`).Exists() || bytes.Contains(body, []byte(`"tool_reference"`)) || bytes.Contains(body, []byte(`"defer_loading"`)) {
+ if ojson.GetBytes(body, `tools.#(name=="tool_search")`).Exists() || bytes.Contains(body, []byte(`"tool_reference"`)) || bytes.Contains(body, []byte(`"defer_loading"`)) {
t.Fatalf("fallback body still uses tool search: %s", body)
}
}
@@ -236,11 +236,11 @@ func TestClaudeExecutor_NativeToolSearchPayloadRuleIsFinal(t *testing.T) {
Params: map[string]any{"tools.3.defer_loading": false},
}}
body := captureNativeToolSearchBody(t, cfg, sendNativeToolSearchExecute, nativeToolSearchAuth(nil), "claude-sonnet-4-5", []byte(nativeToolSearchResponsesPayload), nil)
- if got := gjson.GetBytes(body, "tools.3.name").String(); got != nativeToolSearchCalendar {
- t.Fatalf("tools.3.name = %q, want %s: %s", got, nativeToolSearchCalendar, gjson.GetBytes(body, "tools").Raw)
+ if got := ojson.GetBytes(body, "tools.3.name").String(); got != nativeToolSearchCalendar {
+ t.Fatalf("tools.3.name = %q, want %s: %s", got, nativeToolSearchCalendar, ojson.GetBytes(body, "tools").Raw)
}
- if deferred := gjson.GetBytes(body, "tools.3.defer_loading"); !deferred.Exists() || deferred.Bool() {
- t.Fatalf("payload rule did not win over native tool search: %s", gjson.GetBytes(body, "tools.3").Raw)
+ if deferred := ojson.GetBytes(body, "tools.3.defer_loading"); !deferred.Exists() || deferred.Bool() {
+ t.Fatalf("payload rule did not win over native tool search: %s", ojson.GetBytes(body, "tools.3").Raw)
}
}
@@ -252,14 +252,14 @@ func TestClaudeExecutor_NativeToolSearchOAuthAliasesReferences(t *testing.T) {
}
body := captureNativeToolSearchBody(t, &config.Config{}, sendNativeToolSearchExecute, auth, "claude-sonnet-4-5", []byte(nativeToolSearchResponsesPayload), nil)
declared := map[string]bool{}
- gjson.GetBytes(body, "tools").ForEach(func(_, tool gjson.Result) bool {
+ ojson.GetBytes(body, "tools").ForEach(func(_, tool ojson.Result) bool {
declared[tool.Get("name").String()] = true
return true
})
references := 0
- gjson.GetBytes(body, "messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
- block.Get("content").ForEach(func(_, part gjson.Result) bool {
+ ojson.GetBytes(body, "messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
+ block.Get("content").ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() != "tool_reference" {
return true
}
@@ -274,7 +274,7 @@ func TestClaudeExecutor_NativeToolSearchOAuthAliasesReferences(t *testing.T) {
return true
})
if references == 0 {
- t.Fatalf("no tool_reference in OAuth body: %s", gjson.GetBytes(body, "messages").Raw)
+ t.Fatalf("no tool_reference in OAuth body: %s", ojson.GetBytes(body, "messages").Raw)
}
}
@@ -295,11 +295,11 @@ func TestClaudeExecutor_NativeToolSearchOAuthAliasesRenamedReferences(t *testing
Metadata: map[string]any{"account_uuid": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"},
}
body := captureNativeToolSearchBody(t, &config.Config{}, sendNativeToolSearchExecute, auth, "claude-sonnet-4-5", payload, nil)
- if gjson.GetBytes(body, `tools.#(name=="lookup_order")`).Exists() {
- t.Fatalf("OAuth request kept the unaliased tool name: %s", gjson.GetBytes(body, "tools").Raw)
+ if ojson.GetBytes(body, `tools.#(name=="lookup_order")`).Exists() {
+ t.Fatalf("OAuth request kept the unaliased tool name: %s", ojson.GetBytes(body, "tools").Raw)
}
var deferred []string
- gjson.GetBytes(body, "tools").ForEach(func(_, tool gjson.Result) bool {
+ ojson.GetBytes(body, "tools").ForEach(func(_, tool ojson.Result) bool {
// The constant deferred anchor is aliased too but never referenced.
if tool.Get("defer_loading").Bool() && !strings.HasSuffix(tool.Get("name").String(), "tool_search_deferred_anchor") {
deferred = append(deferred, tool.Get("name").String())
@@ -310,9 +310,9 @@ func TestClaudeExecutor_NativeToolSearchOAuthAliasesRenamedReferences(t *testing
t.Fatalf("deferred tools = %v, want exactly the aliased lookup_order", deferred)
}
var references []string
- gjson.GetBytes(body, "messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
- block.Get("content").ForEach(func(_, part gjson.Result) bool {
+ ojson.GetBytes(body, "messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
+ block.Get("content").ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "tool_reference" {
references = append(references, part.Get("tool_name").String())
}
diff --git a/internal/runtime/executor/claude_executor_tool_state.go b/internal/runtime/executor/claude_executor_tool_state.go
index ccb457fb1b5..bcb9ae8baa5 100644
--- a/internal/runtime/executor/claude_executor_tool_state.go
+++ b/internal/runtime/executor/claude_executor_tool_state.go
@@ -2,10 +2,11 @@ package executor
import (
"net/http"
+ "strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
const claudeOAuthToolAliasStateLimit = 1024
@@ -84,8 +85,21 @@ func (e *ClaudeExecutor) claudeOAuthToolAliasStore() *claudeOAuthToolAliasStore
func claudeOAuthToolAliasKeys(payload []byte, messageID string) []string {
keys := make([]string, 0, 2)
- if previousMessageID := gjson.GetBytes(payload, "thread.previous_message_id").String(); previousMessageID != "" {
- keys = append(keys, "message:"+previousMessageID)
+ if previousMessageID := ojson.GetBytesNoCopy(payload, "thread.previous_message_id").String(); previousMessageID != "" {
+ keys = append(keys, "message:"+strings.Clone(previousMessageID))
+ }
+ if messageID != "" {
+ keys = append(keys, "message:"+messageID)
+ }
+ return keys
+}
+
+func claudeOAuthToolAliasKeysDoc(doc *ojson.Doc, messageID string) []string {
+ keys := make([]string, 0, 2)
+ if doc != nil {
+ if previousMessageID := doc.Get("thread.previous_message_id").String(); previousMessageID != "" {
+ keys = append(keys, "message:"+strings.Clone(previousMessageID))
+ }
}
if messageID != "" {
keys = append(keys, "message:"+messageID)
@@ -94,14 +108,26 @@ func claudeOAuthToolAliasKeys(payload []byte, messageID string) []string {
}
func claudeThreadContinuationNeedsAliasState(payload []byte) bool {
- if gjson.GetBytes(payload, "thread.type").String() != "continue" {
+ if ojson.GetBytesNoCopy(payload, "thread.type").String() != "continue" {
+ return false
+ }
+ previousMessageID := ojson.GetBytesNoCopy(payload, "thread.previous_message_id")
+ if !previousMessageID.Exists() || previousMessageID.String() == "" {
+ return false
+ }
+ tools := ojson.GetBytesNoCopy(payload, "tools")
+ return !tools.Exists() || !tools.IsArray() || len(tools.Array()) == 0
+}
+
+func claudeThreadContinuationNeedsAliasStateDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Get("thread.type").String() != "continue" {
return false
}
- previousMessageID := gjson.GetBytes(payload, "thread.previous_message_id")
+ previousMessageID := doc.Get("thread.previous_message_id")
if !previousMessageID.Exists() || previousMessageID.String() == "" {
return false
}
- tools := gjson.GetBytes(payload, "tools")
+ tools := doc.Get("tools")
return !tools.Exists() || !tools.IsArray() || len(tools.Array()) == 0
}
@@ -117,8 +143,20 @@ func (e *ClaudeExecutor) prepareClaudeOAuthToolNamesForRequest(payload []byte, o
return remapped, aliases, nil
}
+func (e *ClaudeExecutor) prepareClaudeOAuthToolNamesForRequestDoc(doc *ojson.Doc, options claudeMCPAliasOptions) (map[string]string, error) {
+ if claudeThreadContinuationNeedsAliasStateDoc(doc) {
+ aliases, ok := e.claudeOAuthToolAliasStore().load(claudeOAuthToolAliasKeysDoc(doc, ""))
+ if !ok {
+ return nil, newClaudeThreadNotFoundError()
+ }
+ return aliases, nil
+ }
+ aliases, _ := prepareClaudeOAuthToolNamesForUpstreamDoc(doc, options)
+ return aliases, nil
+}
+
func (e *ClaudeExecutor) rememberClaudeOAuthToolAliases(payload []byte, aliases map[string]string, messageID string) {
- if gjson.GetBytes(payload, "thread.type").String() == "" {
+ if ojson.GetBytesNoCopy(payload, "thread.type").String() == "" {
return
}
keys := claudeOAuthToolAliasKeys(payload, messageID)
diff --git a/internal/runtime/executor/claude_executor_web_search_results_test.go b/internal/runtime/executor/claude_executor_web_search_results_test.go
index 8a5cb448b1e..98b2057c6e1 100644
--- a/internal/runtime/executor/claude_executor_web_search_results_test.go
+++ b/internal/runtime/executor/claude_executor_web_search_results_test.go
@@ -9,12 +9,12 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const claudeWebSearchExecutorResults = `[{"type":"web_search_result","url":"https://example.com/a","title":"A","encrypted_content":"enc-a","page_age":null},{"type":"web_search_result","url":"https://example.com/b","title":"B","encrypted_content":"enc-b","page_age":null}]`
@@ -42,7 +42,7 @@ func claudeWebSearchUpstream(t *testing.T, bodies *[][]byte, mu *sync.Mutex) htt
`{"type":"text","text":"found two pages"}`,
}
}
- if !gjson.GetBytes(body, "stream").Bool() {
+ if !ojson.GetBytes(body, "stream").Bool() {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"msg_ws","type":"message","role":"assistant","model":"claude-opus-5-5","content":[` + strings.Join(blocks, ",") + `],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":5}}`))
return
@@ -58,7 +58,7 @@ func claudeWebSearchUpstream(t *testing.T, bodies *[][]byte, mu *sync.Mutex) htt
}
events = append(events, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":"+index+",\"content_block\":"+start+"}")
if strings.Contains(block, `"type":"text"`) {
- events = append(events, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":"+index+",\"delta\":{\"type\":\"text_delta\",\"text\":"+gjson.Get(block, "text").Raw+"}}")
+ events = append(events, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":"+index+",\"delta\":{\"type\":\"text_delta\",\"text\":"+ojson.Get(block, "text").Raw+"}}")
}
events = append(events, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":"+index+"}")
}
@@ -143,10 +143,11 @@ func runClaudeWebSearchScenario(t *testing.T, cfg *config.Config, stream bool, r
const (
claudeWebSearchExecutorUser = `{"type":"message","role":"user","content":[{"type":"input_text","text":"search for q"}]}`
- // The history as Codex stores it: the web_search_call has no results.
+ // The history as Codex stores it: the web_search_call has no results, while
+ // the assistant message keeps its web_search_result_location annotations.
claudeWebSearchExecutorHistory = claudeWebSearchExecutorUser +
`,{"type":"web_search_call","id":"ws_srvtoolu_01abc","status":"completed","action":{"type":"search","query":"q"}},` +
- `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"found two pages"}]},` +
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"found two pages","annotations":[{"type":"web_search_result_location","url":"https://example.com/a","title":"A","cited_text":"found two pages","encrypted_index":"IDX_A"}]}]},` +
`{"type":"message","role":"user","content":[{"type":"input_text","text":"how many results?"}]}`
)
@@ -185,12 +186,21 @@ func TestClaudeExecutor_RestoresWebSearchResultsDroppedByResponsesClient(t *test
if got := claudeWebSearchResultContent(t, bodies[1]); got != claudeWebSearchExecutorResults {
t.Fatalf("same session: web_search_tool_result content = %s, want original results", got)
}
+ if got := ojson.GetBytes(bodies[1], "messages.1.content.2.citations.0.encrypted_index").String(); got != "IDX_A" {
+ t.Fatalf("same session: expected restored search results to keep citations, got %q in %s", got, bodies[1])
+ }
if got := claudeWebSearchResultContent(t, bodies[2]); got != "[]" {
t.Fatalf("other session: web_search_tool_result content = %s, want []", got)
}
+ if citations := ojson.GetBytes(bodies[2], "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("other session: expected unbacked citations to be dropped, got %s", citations.Raw)
+ }
if got := claudeWebSearchResultContent(t, bodies[3]); got != "[]" {
t.Fatalf("no session identity: web_search_tool_result content = %s, want []", got)
}
+ if citations := ojson.GetBytes(bodies[3], "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("no session identity: expected unbacked citations to be dropped, got %s", citations.Raw)
+ }
})
}
}
@@ -208,6 +218,12 @@ func TestClaudeExecutor_WebSearchResultCacheDisabledByDefault(t *testing.T) {
if got := claudeWebSearchResultContent(t, bodies[1]); got != "[]" {
t.Fatalf("disabled cache: web_search_tool_result content = %s, want []", got)
}
+ if citations := ojson.GetBytes(bodies[1], "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("disabled cache: expected unbacked citations to be dropped, got %s", citations.Raw)
+ }
+ if got := ojson.GetBytes(bodies[1], "messages.1.content.2.text").String(); got != "found two pages" {
+ t.Fatalf("disabled cache: expected text to remain intact, got %q", got)
+ }
})
}
}
@@ -229,7 +245,7 @@ func TestClaudeExecutor_WebSearchResultRestoreRunsBeforePayloadRules(t *testing.
if len(bodies) != 2 {
t.Fatalf("upstream requests = %d, want 2", len(bodies))
}
- block := gjson.GetBytes(bodies[1], "messages.1.content.1")
+ block := ojson.GetBytes(bodies[1], "messages.1.content.1")
if block.Get("type").String() != "web_search_tool_result" {
t.Fatalf("messages.1.content.1 = %s, want the web_search_tool_result block", block.Raw)
}
@@ -267,9 +283,15 @@ func TestClaudeExecutor_WebSearchResultCacheHonorsConfiguredLimit(t *testing.T)
if got := claudeWebSearchResultContent(t, bodies[2]); got != "[]" {
t.Fatalf("evicted session-1: web_search_tool_result content = %s, want []", got)
}
+ if citations := ojson.GetBytes(bodies[2], "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("evicted session-1: expected citations to be dropped, got %s", citations.Raw)
+ }
if got := claudeWebSearchResultContent(t, bodies[3]); got != claudeWebSearchExecutorResults {
t.Fatalf("retained session-2: web_search_tool_result content = %s, want original results", got)
}
+ if got := ojson.GetBytes(bodies[3], "messages.1.content.2.citations.0.encrypted_index").String(); got != "IDX_A" {
+ t.Fatalf("retained session-2: expected citations to be kept, got %q", got)
+ }
}
func TestClaudeExecutor_CountTokensRestoresWebSearchResultsWhenEnabled(t *testing.T) {
@@ -313,8 +335,8 @@ func TestClaudeExecutor_CountTokensRestoresWebSearchResultsWhenEnabled(t *testin
if errDisabledCount != nil {
t.Fatalf("disabled CountTokens() error = %v", errDisabledCount)
}
- enabledTokens := gjson.GetBytes(enabledCountResp.Payload, "input_tokens").Int()
- disabledTokens := gjson.GetBytes(disabledCountResp.Payload, "input_tokens").Int()
+ enabledTokens := ojson.GetBytes(enabledCountResp.Payload, "input_tokens").Int()
+ disabledTokens := ojson.GetBytes(disabledCountResp.Payload, "input_tokens").Int()
if enabledTokens <= disabledTokens {
t.Fatalf("enabled input_tokens (%d) <= disabled input_tokens (%d); want restored search results counted", enabledTokens, disabledTokens)
}
@@ -323,7 +345,7 @@ func TestClaudeExecutor_CountTokensRestoresWebSearchResultsWhenEnabled(t *testin
if errOtherCredCount != nil {
t.Fatalf("other credential CountTokens() error = %v", errOtherCredCount)
}
- otherCredTokens := gjson.GetBytes(otherCredCountResp.Payload, "input_tokens").Int()
+ otherCredTokens := ojson.GetBytes(otherCredCountResp.Payload, "input_tokens").Int()
if otherCredTokens != disabledTokens {
t.Fatalf("other credential input_tokens = %d, want %d (unrestored empty search results)", otherCredTokens, disabledTokens)
}
@@ -351,15 +373,27 @@ func TestClaudeExecutor_WebSearchResultCacheScopesByCredential(t *testing.T) {
if got := claudeWebSearchResultContent(t, bodies[1]); got != "[]" {
t.Fatalf("different credential ID in same session: web_search_tool_result content = %s, want []", got)
}
+ if citations := ojson.GetBytes(bodies[1], "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("different credential ID in same session: expected citations to be dropped, got %s", citations.Raw)
+ }
if got := claudeWebSearchResultContent(t, bodies[2]); got != claudeWebSearchExecutorResults {
t.Fatalf("original credential ID in same session: web_search_tool_result content = %s, want original results", got)
}
+ if got := ojson.GetBytes(bodies[2], "messages.1.content.2.citations.0.encrypted_index").String(); got != "IDX_A" {
+ t.Fatalf("original credential ID in same session: expected citations to be kept, got %q", got)
+ }
if got := claudeWebSearchResultContent(t, bodies[4]); got != "[]" {
t.Fatalf("different API key in same session: web_search_tool_result content = %s, want []", got)
}
+ if citations := ojson.GetBytes(bodies[4], "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("different API key in same session: expected citations to be dropped, got %s", citations.Raw)
+ }
if got := claudeWebSearchResultContent(t, bodies[5]); got != claudeWebSearchExecutorResults {
t.Fatalf("original API key in same session: web_search_tool_result content = %s, want original results", got)
}
+ if got := ojson.GetBytes(bodies[5], "messages.1.content.2.citations.0.encrypted_index").String(); got != "IDX_A" {
+ t.Fatalf("original API key in same session: expected citations to be kept, got %q", got)
+ }
})
}
}
@@ -367,8 +401,8 @@ func TestClaudeExecutor_WebSearchResultCacheScopesByCredential(t *testing.T) {
func claudeWebSearchResultContent(t *testing.T, body []byte) string {
t.Helper()
var found string
- gjson.GetBytes(body, "messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ ojson.GetBytes(body, "messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "web_search_tool_result" {
found = block.Get("content").Raw
return false
diff --git a/internal/runtime/executor/claude_fingerprint_policy.go b/internal/runtime/executor/claude_fingerprint_policy.go
index 05e8e11001e..80f27be91a4 100644
--- a/internal/runtime/executor/claude_fingerprint_policy.go
+++ b/internal/runtime/executor/claude_fingerprint_policy.go
@@ -5,6 +5,7 @@ import (
"strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -131,17 +132,24 @@ func resolveClaudeFingerprintPolicy(cfg *config.Config, auth *cliproxyauth.Auth,
// providers such as Kimi seed from the stable auth identity, so an access-token
// rotation does not rotate the device fingerprint.
func applyClaudeCLIIdentity(body []byte, auth *cliproxyauth.Auth, apiKey, upstreamURL, sessionID string, synthesize bool) ([]byte, error) {
+ doc := ojson.NewDoc(body)
+ if errApply := applyClaudeCLIIdentityDoc(doc, auth, apiKey, upstreamURL, sessionID, synthesize); errApply != nil {
+ return nil, errApply
+ }
+ return doc.Bytes(), nil
+}
+
+func applyClaudeCLIIdentityDoc(doc *ojson.Doc, auth *cliproxyauth.Auth, apiKey, upstreamURL, sessionID string, synthesize bool) error {
identitySeed := apiKey
if isKimiMessagesUpstream(auth, upstreamURL) {
identitySeed = helps.ClaudeCLIAuthIdentitySeed(auth)
}
identityAuth, errIdentity := helps.PrepareClaudeCLIFingerprintAuth(auth, identitySeed, synthesize)
if errIdentity != nil {
- return nil, fmt.Errorf("ensure Claude CLI fingerprint identity: %w", errIdentity)
+ return fmt.Errorf("ensure Claude CLI fingerprint identity: %w", errIdentity)
}
- updated, _, errApply := helps.ApplyClaudeCredentialMetadata(body, identityAuth, sessionID)
- if errApply != nil {
- return nil, fmt.Errorf("apply Claude credential metadata: %w", errApply)
+ if _, errApply := helps.ApplyClaudeCredentialMetadataDoc(doc, identityAuth, sessionID); errApply != nil {
+ return fmt.Errorf("apply Claude credential metadata: %w", errApply)
}
- return updated, nil
+ return nil
}
diff --git a/internal/runtime/executor/claude_fingerprint_policy_test.go b/internal/runtime/executor/claude_fingerprint_policy_test.go
index b071dda46bd..a187eb43d18 100644
--- a/internal/runtime/executor/claude_fingerprint_policy_test.go
+++ b/internal/runtime/executor/claude_fingerprint_policy_test.go
@@ -12,13 +12,13 @@ import (
log "github.com/sirupsen/logrus"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestResolveClaudeFingerprintPolicy(t *testing.T) {
@@ -319,7 +319,7 @@ func TestClaudeExecutor_ClaudeCodeCLIFingerprintOnThirdPartyGateway(t *testing.T
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seenBody, _ = io.ReadAll(r.Body)
seenHeaders = r.Header.Clone()
- upstreamToolName := gjson.GetBytes(seenBody, "tools.0.name").String()
+ upstreamToolName := ojson.GetBytes(seenBody, "tools.0.name").String()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(
`{"id":"msg_1","type":"message","model":"claude-sonnet-5","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"` +
@@ -364,7 +364,7 @@ func TestClaudeExecutor_ClaudeCodeCLIFingerprintOnThirdPartyGateway(t *testing.T
t.Fatalf("Anthropic-Beta = %q, want %q", got, wantBetas)
}
- billing := gjson.GetBytes(seenBody, "system.0.text").String()
+ billing := ojson.GetBytes(seenBody, "system.0.text").String()
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") {
t.Fatalf("system.0.text = %q, want billing header", billing)
}
@@ -375,30 +375,30 @@ func TestClaudeExecutor_ClaudeCodeCLIFingerprintOnThirdPartyGateway(t *testing.T
if strings.Contains(billing, "cch=") {
t.Fatalf("billing = %q, want no cch on a third-party gateway", billing)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
t.Fatalf("system.1.text = %q, want CLI identity", got)
}
- userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
+ userID := ojson.GetBytes(seenBody, "metadata.user_id").String()
if !helps.IsValidUserID(userID) {
t.Fatalf("metadata.user_id = %q, want valid", userID)
}
- if got := gjson.Get(userID, "account_uuid").String(); got == "" {
+ if got := ojson.Get(userID, "account_uuid").String(); got == "" {
t.Fatal("account_uuid is empty for claude-code-cli fingerprint identity")
}
sessionHeader := seenHeaders.Get("X-Claude-Code-Session-Id")
if sessionHeader == "" {
t.Fatal("missing X-Claude-Code-Session-Id")
}
- if got := gjson.Get(userID, "session_id").String(); got != sessionHeader {
+ if got := ojson.Get(userID, "session_id").String(); got != sessionHeader {
t.Fatalf("metadata session_id = %q, header = %q", got, sessionHeader)
}
- upstreamToolName := gjson.GetBytes(seenBody, "tools.0.name").String()
+ upstreamToolName := ojson.GetBytes(seenBody, "tools.0.name").String()
if !strings.HasPrefix(upstreamToolName, "mcp__") {
t.Fatalf("upstream tool name = %q, want OAuth CLI MCP alias", upstreamToolName)
}
- if got := gjson.GetBytes(response.Payload, "content.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytes(response.Payload, "content.0.name").String(); got != "read_file" {
t.Fatalf("downstream tool name = %q, want restored caller name", got)
}
}
@@ -452,17 +452,17 @@ func TestClaudeExecutor_OfficialAPIKeyDefaultRespectsClient(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() {
+ if diagnostics := ojson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() {
t.Fatalf("diagnostics = %s, default official API key must not inject CLI diagnostics", diagnostics.Raw)
}
- if got := gjson.GetBytes(seenBody, "tools.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); got != "read_file" {
t.Fatalf("tools.0.name = %q, want caller name", got)
}
if strings.Contains(string(seenBody), "x-anthropic-billing-header:") || strings.Contains(string(seenBody), "cch=") {
t.Fatalf("default official API key must not inject billing/CCH: %s", seenBody)
}
- userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
- if userID != "" && gjson.Get(userID, "account_uuid").String() != "" {
+ userID := ojson.GetBytes(seenBody, "metadata.user_id").String()
+ if userID != "" && ojson.Get(userID, "account_uuid").String() != "" {
t.Fatalf("metadata.user_id = %q, default official API key must not synthesize CLI account_uuid", userID)
}
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
@@ -498,10 +498,10 @@ func TestClaudeExecutor_OfficialAPIKeyDefaultPreservesCallerCCH(t *testing.T) {
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, OriginalRequest: payload}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "x-anthropic-billing-header: cc_version=caller; cch=abcde;" {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); got != "x-anthropic-billing-header: cc_version=caller; cch=abcde;" {
t.Fatalf("caller billing/CCH = %q, want byte-preserved text", got)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != "Keep this rule." {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != "Keep this rule." {
t.Fatalf("caller system text = %q, want preserved", got)
}
}
@@ -545,20 +545,20 @@ func TestClaudeExecutor_OfficialAPIKeyClaudeCodeCLIFingerprintIncludesDiagnostic
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); !diagnostics.IsObject() {
+ if diagnostics := ojson.GetBytes(seenBody, "diagnostics"); !diagnostics.IsObject() {
t.Fatalf("diagnostics = %s, want object after fingerprint-profile opt-in", diagnostics.Raw)
}
// api.anthropic.com is the one API-key origin where native emits cch, so the
// opt-in must produce a finalized signature here.
- billing := gjson.GetBytes(seenBody, "system.0.text").String()
+ billing := ojson.GetBytes(seenBody, "system.0.text").String()
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") || !strings.Contains(billing, "cch=") {
t.Fatalf("billing = %q, want signed cch on api.anthropic.com", billing)
}
if strings.Contains(billing, "cch=00000") {
t.Fatalf("billing = %q, want finalized cch signature", billing)
}
- userID := gjson.GetBytes(seenBody, "metadata.user_id").String()
- if !helps.IsValidUserID(userID) || gjson.Get(userID, "account_uuid").String() == "" {
+ userID := ojson.GetBytes(seenBody, "metadata.user_id").String()
+ if !helps.IsValidUserID(userID) || ojson.Get(userID, "account_uuid").String() == "" {
t.Fatalf("metadata.user_id = %q, want synthesized CLI identity", userID)
}
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
@@ -617,10 +617,10 @@ func TestClaudeExecutor_ClaudeCodeCLIFingerprintStreamMatchesWirePolicy(t *testi
t.Fatalf("stream chunk error = %v", chunk.Err)
}
}
- if diagnostics := gjson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() {
+ if diagnostics := ojson.GetBytes(seenBody, "diagnostics"); diagnostics.Exists() {
t.Fatalf("diagnostics = %s, custom gateway must not inherit official diagnostics", diagnostics.Raw)
}
- if got := gjson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") {
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") {
t.Fatalf("stream tool name = %q, want OAuth CLI MCP alias", got)
}
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
@@ -667,14 +667,14 @@ func TestClaudeExecutor_ClaudeCodeCLIFingerprintCountTokensKeepsNativeShape(t *t
t.Fatalf("countTokensUpstream() error = %v", errCount)
}
for _, field := range []string{"system", "metadata", "context_management", "diagnostics"} {
- if got := gjson.GetBytes(seenBody, field); got.Exists() {
+ if got := ojson.GetBytes(seenBody, field); got.Exists() {
t.Fatalf("count_tokens %s = %s, want absent", field, got.Raw)
}
}
if strings.Contains(string(seenBody), "cch=") {
t.Fatalf("count_tokens body contains CCH: %s", seenBody)
}
- if got := gjson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") {
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); !strings.HasPrefix(got, "mcp__") {
t.Fatalf("count_tokens tool name = %q, want OAuth CLI MCP alias after fingerprint-profile opt-in", got)
}
if got, want := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta"), claudeCountTokensBetasForCredential(true); got != want {
@@ -754,7 +754,7 @@ func TestKimiExecutor_ClaudeMessagesWithAndWithoutClaudeCodeCLIFingerprint(t *te
}
// Default Kimi preserves caller fingerprint headers and strips billing/CCH.
- if got := gjson.GetBytes(seenBodies[0], "metadata.user_id").String(); got != "" {
+ if got := ojson.GetBytes(seenBodies[0], "metadata.user_id").String(); got != "" {
t.Fatalf("default Kimi request should not have metadata.user_id: %q", got)
}
if strings.Contains(string(seenBodies[0]), "cch=") || strings.Contains(string(seenBodies[0]), "x-anthropic-billing-header:") {
@@ -770,14 +770,14 @@ func TestKimiExecutor_ClaudeMessagesWithAndWithoutClaudeCodeCLIFingerprint(t *te
// Opt-in Kimi requests use the complete CLI fingerprint. Kimi is not a native
// cch origin, so the billing header goes out unsigned, exactly as native does
// against a non-first-party base URL.
- userID := gjson.GetBytes(seenBodies[1], "metadata.user_id").String()
+ userID := ojson.GetBytes(seenBodies[1], "metadata.user_id").String()
if !helps.IsValidUserID(userID) {
t.Fatalf("opt-in Kimi metadata.user_id = %q, want valid synthesized user_id", userID)
}
- if got := gjson.Get(userID, "account_uuid").String(); got == "" {
+ if got := ojson.Get(userID, "account_uuid").String(); got == "" {
t.Fatal("opt-in Kimi request should have non-empty synthesized account_uuid")
}
- billing := gjson.GetBytes(seenBodies[1], "system.0.text").String()
+ billing := ojson.GetBytes(seenBodies[1], "system.0.text").String()
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") {
t.Fatalf("opt-in Kimi request must carry Claude billing attribution: %s", seenBodies[1])
}
@@ -840,7 +840,7 @@ func TestKimiExecutor_ClaudeCodeCLIProfileKeepsUnsignedBillingAndNativeCountToke
betas := claudeFingerprintHeaderValue(seenHeaders, "Anthropic-Beta")
if test.name == "count tokens" {
for _, field := range []string{"system", "metadata", "context_management", "diagnostics"} {
- if got := gjson.GetBytes(seenBody, field); got.Exists() {
+ if got := ojson.GetBytes(seenBody, field); got.Exists() {
t.Fatalf("count_tokens %s = %s, want absent", field, got.Raw)
}
}
@@ -852,7 +852,7 @@ func TestKimiExecutor_ClaudeCodeCLIProfileKeepsUnsignedBillingAndNativeCountToke
}
return
}
- billing := gjson.GetBytes(seenBody, "system.0.text").String()
+ billing := ojson.GetBytes(seenBody, "system.0.text").String()
if !strings.HasPrefix(billing, "x-anthropic-billing-header:") {
t.Fatalf("upstream body is missing opt-in billing attribution: %s", seenBody)
}
@@ -1026,18 +1026,18 @@ func TestKimiExecutor_DefaultCountTokensRespectsCallerBody(t *testing.T) {
}
// Positive pins first: assert the request really arrived for this model, so the
// preservation assertions below cannot pass vacuously.
- if got := gjson.GetBytes(seenBody, "model").String(); got == "" {
+ if got := ojson.GetBytes(seenBody, "model").String(); got == "" {
t.Fatalf("upstream model is empty: %s", seenBody)
}
- if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
t.Fatalf("upstream messages length = %d, want 1: %s", got, seenBody)
}
for _, field := range []string{"system", "metadata", "context_management"} {
- if !gjson.GetBytes(seenBody, field).Exists() {
+ if !ojson.GetBytes(seenBody, field).Exists() {
t.Fatalf("default count_tokens dropped caller-owned %q: %s", field, seenBody)
}
}
- if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != "caller-user" {
+ if got := ojson.GetBytes(seenBody, "metadata.user_id").String(); got != "caller-user" {
t.Fatalf("metadata.user_id = %q, want the caller value preserved", got)
}
// Default mode must not add the CLI billing/CCH attribution either.
@@ -1071,11 +1071,11 @@ func TestClaudeExecutor_DefaultCountTokensStillStripsAnthropicRejectedFields(t *
if len(seenBody) == 0 {
t.Fatal("expected an upstream count_tokens request")
}
- if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != 1 {
t.Fatalf("upstream messages length = %d, want 1: %s", got, seenBody)
}
for _, field := range []string{"metadata", "context_management", "diagnostics"} {
- if got := gjson.GetBytes(seenBody, field); got.Exists() {
+ if got := ojson.GetBytes(seenBody, field); got.Exists() {
t.Fatalf("api.anthropic.com count_tokens %s = %s, want stripped", field, got.Raw)
}
}
@@ -1113,10 +1113,10 @@ func TestKimiExecutor_ClaudeCodeCLIIdentitySurvivesAccessTokenRotation(t *testin
if len(seenBodies) != 2 {
t.Fatalf("captured %d requests, want 2", len(seenBodies))
}
- firstUserID := gjson.GetBytes(seenBodies[0], "metadata.user_id").String()
- secondUserID := gjson.GetBytes(seenBodies[1], "metadata.user_id").String()
+ firstUserID := ojson.GetBytes(seenBodies[0], "metadata.user_id").String()
+ secondUserID := ojson.GetBytes(seenBodies[1], "metadata.user_id").String()
for _, field := range []string{"account_uuid", "device_id"} {
- if first, second := gjson.Get(firstUserID, field).String(), gjson.Get(secondUserID, field).String(); first == "" || first != second {
+ if first, second := ojson.Get(firstUserID, field).String(), ojson.Get(secondUserID, field).String(); first == "" || first != second {
t.Fatalf("%s changed across access token refresh: %q vs %q", field, first, second)
}
}
@@ -1404,17 +1404,17 @@ func TestClaudeExecutor_OfficialAPIKeyCloakNeverStripsClaudeCodeIdentity(t *test
if strings.Contains(string(seenBody), claudeCodeCLIIdentity) {
t.Fatalf("upstream body still contains Claude Code CLI identity block: %s", seenBody)
}
- if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 1 {
t.Fatalf("system block count = %d, want 1 preserved instruction block: %s", got, seenBody)
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != "You are an interactive CLI tool that helps users with software engineering tasks." {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); got != "You are an interactive CLI tool that helps users with software engineering tasks." {
t.Fatalf("system.0.text = %q, want preserved caller instructions", got)
}
- if gjson.GetBytes(seenBody, "metadata").Exists() {
- t.Fatalf("metadata = %s, want Claude Code metadata stripped", gjson.GetBytes(seenBody, "metadata").Raw)
+ if ojson.GetBytes(seenBody, "metadata").Exists() {
+ t.Fatalf("metadata = %s, want Claude Code metadata stripped", ojson.GetBytes(seenBody, "metadata").Raw)
}
- if gjson.GetBytes(seenBody, "diagnostics").Exists() {
- t.Fatalf("diagnostics = %s, want Claude Code diagnostics stripped", gjson.GetBytes(seenBody, "diagnostics").Raw)
+ if ojson.GetBytes(seenBody, "diagnostics").Exists() {
+ t.Fatalf("diagnostics = %s, want Claude Code diagnostics stripped", ojson.GetBytes(seenBody, "diagnostics").Raw)
}
ua := claudeFingerprintHeaderValue(seenHeaders, "User-Agent")
@@ -1518,13 +1518,13 @@ func TestClaudeExecutor_GlobalDisableClaudeCloakModePreservesConfirmedCallerIden
t.Fatalf("Execute() error = %v", errExec)
}
- if got := gjson.GetBytes(seenBody, "system.0.text").String(); got != billing {
+ if got := ojson.GetBytes(seenBody, "system.0.text").String(); got != billing {
t.Fatalf("system.0.text = %q, want preserved caller billing header %q", got, billing)
}
- if got := gjson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
+ if got := ojson.GetBytes(seenBody, "system.1.text").String(); got != claudeCodeCLIIdentity {
t.Fatalf("system.1.text = %q, want preserved caller CLI identity", got)
}
- if got := gjson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
+ if got := ojson.GetBytes(seenBody, "metadata.user_id").String(); got != userID {
t.Fatalf("metadata.user_id = %q, want preserved %q", got, userID)
}
if got := claudeFingerprintHeaderValue(seenHeaders, "X-App"); got != "cli" {
diff --git a/internal/runtime/executor/claude_header_overrides_issue5093_test.go b/internal/runtime/executor/claude_header_overrides_issue5093_test.go
index c100661cb9c..64079614a09 100644
--- a/internal/runtime/executor/claude_header_overrides_issue5093_test.go
+++ b/internal/runtime/executor/claude_header_overrides_issue5093_test.go
@@ -8,13 +8,13 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func parseIssue5093Config(t *testing.T, raw string) *config.Config {
@@ -252,7 +252,7 @@ func TestClaudeExecutor_InjectDiagnosticsDisabledOmitsDiagnosticsAndCacheDiagnos
if len(upstreamBody) == 0 {
t.Fatalf("%s: upstream request was not captured", tc.name)
}
- if got := gjson.GetBytes(upstreamBody, "diagnostics").Exists(); got != tc.inject {
+ if got := ojson.GetBytes(upstreamBody, "diagnostics").Exists(); got != tc.inject {
t.Fatalf("%s: diagnostics present = %v, want %v; body = %s", tc.name, got, tc.inject, upstreamBody)
}
if got := strings.Contains(upstreamBetas, claudeCacheDiagnosisBeta); got != tc.inject {
diff --git a/internal/runtime/executor/claude_issue7000_regression_test.go b/internal/runtime/executor/claude_issue7000_regression_test.go
index 62b9a33ab9d..b9fb3303298 100644
--- a/internal/runtime/executor/claude_issue7000_regression_test.go
+++ b/internal/runtime/executor/claude_issue7000_regression_test.go
@@ -8,14 +8,13 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const claudeIssue7000Tools = `[{"name":"lookup_weather","input_schema":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]`
@@ -49,11 +48,11 @@ func captureClaudeIssue7000(t *testing.T, executor claudeIssue7000Executor, auth
}
captured, headers = body, req.Header.Clone()
contentType := "application/json"
- model := gjson.GetBytes(body, "model").String()
+ model := ojson.GetBytes(body, "model").String()
response := `{"id":"msg_issue7000","type":"message","role":"assistant","model":"` + model + `","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":17,"output_tokens":1}}`
if strings.HasSuffix(req.URL.Path, "/count_tokens") {
response = `{"input_tokens":17}`
- } else if gjson.GetBytes(body, "stream").Bool() {
+ } else if ojson.GetBytes(body, "stream").Bool() {
contentType, response = "text/event-stream", strings.ReplaceAll(claudeIssue6698SSE, "claude-opus-5-5", model)
}
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(response)), Request: req}, nil
@@ -79,13 +78,13 @@ func captureClaudeIssue7000(t *testing.T, executor claudeIssue7000Executor, auth
if errCount != nil {
t.Fatalf("CountTokens: %v", errCount)
}
- if got := gjson.GetBytes(response.Payload, "input_tokens").Int(); got != 17 {
+ if got := ojson.GetBytes(response.Payload, "input_tokens").Int(); got != 17 {
t.Fatalf("upstream input_tokens = %d, want 17", got)
}
default:
t.Fatalf("unknown method %q", method)
}
- if !gjson.ValidBytes(captured) {
+ if !ojson.ValidBytes(captured) {
t.Fatalf("expected captured upstream JSON, got %q", captured)
}
return captured, headers
@@ -125,7 +124,7 @@ func TestClaudeExecutor_Issue7000ModelAndChoiceBoundaries(t *testing.T) {
bodyModel = tc.route
}
body, _ := captureClaudeIssue7000(t, executor, &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-issue7000"}}, cliproxyexecutor.Request{Model: tc.route, Payload: claudeIssue7000Payload(bodyModel, tc.choice)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}, "Execute")
- if got, want := gjson.GetBytes(body, "tool_choice").Value(), gjson.Parse(tc.want).Value(); !reflect.DeepEqual(got, want) {
+ if got, want := ojson.GetBytes(body, "tool_choice").Value(), ojson.Parse(tc.want).Value(); !reflect.DeepEqual(got, want) {
t.Fatalf("tool_choice = %#v, want %#v; body=%s", got, want, body)
}
})
@@ -135,7 +134,7 @@ func TestClaudeExecutor_Issue7000ModelAndChoiceBoundaries(t *testing.T) {
func TestKimiExecutor_Issue7000DoesNotRewriteDelegatedToolChoice(t *testing.T) {
const choice = `{"type":"tool","name":"lookup_weather","disable_parallel_tool_use":true}`
body, _ := captureClaudeIssue7000(t, NewKimiExecutor(&config.Config{}), &cliproxyauth.Auth{Provider: "kimi", Attributes: map[string]string{"api_key": "kimi-issue7000"}}, cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: claudeIssue7000Payload("claude-opus-5-5", choice)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}, "Execute")
- if got := gjson.GetBytes(body, "tool_choice").Value(); !reflect.DeepEqual(got, gjson.Parse(choice).Value()) {
+ if got := ojson.GetBytes(body, "tool_choice").Value(); !reflect.DeepEqual(got, ojson.Parse(choice).Value()) {
t.Fatalf("delegated provider tool_choice changed: %s", body)
}
}
@@ -162,17 +161,17 @@ func TestClaudeExecutor_Issue7000OpenAITranslation(t *testing.T) {
}
body, _ := captureClaudeIssue7000(t, NewClaudeExecutor(&config.Config{}), &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-issue7000"}}, cliproxyexecutor.Request{Model: "claude-sonnet-5-5", Payload: []byte(payload + `}`)}, cliproxyexecutor.Options{SourceFormat: source.format}, source.method)
for path, want := range map[string]any{"tool_choice.type": "auto", "tool_choice.disable_parallel_tool_use": true, "tools.0.name": "lookup_weather", "tools.0.input_schema.required.0": "city", "thinking.type": "adaptive", "output_config.effort": "high"} {
- if got := gjson.GetBytes(body, path).Value(); !reflect.DeepEqual(got, want) {
+ if got := ojson.GetBytes(body, path).Value(); !reflect.DeepEqual(got, want) {
t.Errorf("%s = %#v, want %#v; body=%s", path, got, want, body)
}
}
- if gjson.GetBytes(body, "tool_choice.name").Exists() {
+ if ojson.GetBytes(body, "tool_choice.name").Exists() {
t.Errorf("forced tool name survived: %s", body)
}
- if withSchema && (!strings.Contains(gjson.GetBytes(body, "system").Raw, "issue7000_title") || !strings.Contains(gjson.GetBytes(body, "system").Raw, "JSON")) {
+ if withSchema && (!strings.Contains(ojson.GetBytes(body, "system").Raw, "issue7000_title") || !strings.Contains(ojson.GetBytes(body, "system").Raw, "JSON")) {
t.Errorf("existing schema prompt fallback missing: %s", body)
}
- if gjson.GetBytes(body, "output_config.format").Exists() {
+ if ojson.GetBytes(body, "output_config.format").Exists() {
t.Errorf("translated schema should retain prompt fallback, not native output format: %s", body)
}
})
@@ -183,14 +182,15 @@ func TestClaudeExecutor_Issue7000OpenAITranslation(t *testing.T) {
func TestClaudeExecutor_Issue7000KeepsNativeOutputFormat(t *testing.T) {
const format = `{"type":"json_schema","schema":{"type":"object","properties":{"title":{"type":"string"}},"required":["title"]}}`
- payload := claudeIssue7000Payload("claude-opus-5-5", `{"type":"any"}`)
- payload, _ = sjson.SetRawBytes(payload, "output_config", []byte(`{"effort":"high","format":`+format+`}`))
- payload, _ = sjson.SetRawBytes(payload, "thinking", []byte(`{"type":"adaptive"}`))
+ payloadDoc := ojson.NewDoc(claudeIssue7000Payload("claude-opus-5-5", `{"type":"any"}`))
+ _ = payloadDoc.SetRawBytes("output_config", []byte(`{"effort":"high","format":`+format+`}`))
+ _ = payloadDoc.SetRawBytes("thinking", []byte(`{"type":"adaptive"}`))
+ payload := payloadDoc.Bytes()
body, _ := captureClaudeIssue7000(t, NewClaudeExecutor(&config.Config{}), &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-issue7000"}}, cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}, "Execute")
- if got := gjson.GetBytes(body, "output_config.format").Value(); !reflect.DeepEqual(got, gjson.Parse(format).Value()) {
+ if got := ojson.GetBytes(body, "output_config.format").Value(); !reflect.DeepEqual(got, ojson.Parse(format).Value()) {
t.Errorf("native output format changed: %s", body)
}
- if gjson.GetBytes(body, "tool_choice.type").String() != "auto" || gjson.GetBytes(body, "output_config.effort").String() != "high" {
+ if ojson.GetBytes(body, "tool_choice.type").String() != "auto" || ojson.GetBytes(body, "output_config.effort").String() != "high" {
t.Errorf("native schema must not prevent tool-choice compatibility or remove effort: %s", body)
}
}
@@ -198,19 +198,20 @@ func TestClaudeExecutor_Issue7000KeepsNativeOutputFormat(t *testing.T) {
func TestClaudeExecutor_Issue7000OAuthPreservesThinkingHistory(t *testing.T) {
for _, method := range []string{"Execute", "ExecuteStream", "CountTokens"} {
t.Run(method, func(t *testing.T) {
- payload := preservedThinkingReplayPayload("claude-opus-5-5")
- payload, _ = sjson.DeleteBytes(payload, "thinking")
- payload, _ = sjson.SetRawBytes(payload, "tools", []byte(claudeIssue7000Tools))
- payload, _ = sjson.SetRawBytes(payload, "tool_choice", []byte(`{"type":"tool","name":"lookup_weather","disable_parallel_tool_use":true}`))
+ payloadDoc := ojson.NewDoc(preservedThinkingReplayPayload("claude-opus-5-5"))
+ _ = payloadDoc.Delete("thinking")
+ _ = payloadDoc.SetRawBytes("tools", []byte(claudeIssue7000Tools))
+ _ = payloadDoc.SetRawBytes("tool_choice", []byte(`{"type":"tool","name":"lookup_weather","disable_parallel_tool_use":true}`))
+ payload := payloadDoc.Bytes()
metadata := claudeOAuthTestMetadata()
metadata["access_token"] = "sk-ant-oat01-issue7000"
body, headers := captureClaudeIssue7000(t, NewClaudeExecutor(&config.Config{}), &cliproxyauth.Auth{Provider: "claude", Metadata: metadata}, cliproxyexecutor.Request{Model: "claude-opus-5-5", Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}, method)
for path, want := range map[string]any{"tool_choice.type": "auto", "tool_choice.disable_parallel_tool_use": true, "thinking.type": "adaptive", "thinking.block_binding.prefix_mismatch_behavior": "drop_block"} {
- if got := gjson.GetBytes(body, path).Value(); !reflect.DeepEqual(got, want) {
+ if got := ojson.GetBytes(body, path).Value(); !reflect.DeepEqual(got, want) {
t.Errorf("%s = %#v, want %#v; body=%s", path, got, want, body)
}
}
- if gjson.GetBytes(body, "tool_choice.name").Exists() || !strings.Contains(string(body), preservedThinkingTestSignature) {
+ if ojson.GetBytes(body, "tool_choice.name").Exists() || !strings.Contains(string(body), preservedThinkingTestSignature) {
t.Errorf("forced name survived or signed thinking was lost: %s", body)
}
if headers.Get("Authorization") != "Bearer sk-ant-oat01-issue7000" {
@@ -226,10 +227,10 @@ func TestClaudeExecutor_Issue7000CountTokensUpstreamAndLocal(t *testing.T) {
t.Run(choice, func(t *testing.T) {
payload := claudeIssue7000Payload("spoofed-body-model", choice)
body, headers := captureClaudeIssue7000(t, NewClaudeExecutor(&config.Config{}), &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-issue7000"}}, cliproxyexecutor.Request{Model: model, Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}, "CountTokens")
- if got := gjson.GetBytes(body, "tool_choice").Value(); !reflect.DeepEqual(got, gjson.Parse(`{"type":"auto","disable_parallel_tool_use":true}`).Value()) {
+ if got := ojson.GetBytes(body, "tool_choice").Value(); !reflect.DeepEqual(got, ojson.Parse(`{"type":"auto","disable_parallel_tool_use":true}`).Value()) {
t.Errorf("upstream count choice = %#v, want auto with parallel flag", got)
}
- if gjson.GetBytes(body, "model").String() != model || headers.Get("X-Api-Key") != "key-issue7000" {
+ if ojson.GetBytes(body, "model").String() != model || headers.Get("X-Api-Key") != "key-issue7000" {
t.Error("upstream count did not preserve routed model/API-key authentication")
}
executor := NewClaudeExecutor(&config.Config{})
@@ -244,7 +245,7 @@ func TestClaudeExecutor_Issue7000CountTokensUpstreamAndLocal(t *testing.T) {
if errCount != nil {
t.Fatalf("local CountTokens: %v", errCount)
}
- return gjson.GetBytes(response.Payload, "input_tokens").Int()
+ return ojson.GetBytes(response.Payload, "input_tokens").Int()
}
want := count(claudeIssue7000Payload(model, `{"type":"auto","disable_parallel_tool_use":true}`))
if got := count(payload); got != want || got <= 0 {
@@ -269,17 +270,18 @@ func TestClaudeExecutor_Issue7000PayloadRulesRemainFinal(t *testing.T) {
if filter {
cfg.Payload = config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: models, Params: []string{"tool_choice", "thinking", "output_config.effort", "tools"}}}}
}
- payload := claudeIssue7000Payload(model, `{"type":"tool","name":"lookup_weather","disable_parallel_tool_use":true}`)
- payload, _ = sjson.SetRawBytes(payload, "thinking", []byte(`{"type":"adaptive"}`))
- payload, _ = sjson.SetRawBytes(payload, "output_config", []byte(`{"effort":"high"}`))
+ payloadDoc := ojson.NewDoc(claudeIssue7000Payload(model, `{"type":"tool","name":"lookup_weather","disable_parallel_tool_use":true}`))
+ _ = payloadDoc.SetRawBytes("thinking", []byte(`{"type":"adaptive"}`))
+ _ = payloadDoc.SetRawBytes("output_config", []byte(`{"effort":"high"}`))
+ payload := payloadDoc.Bytes()
body, _ := captureClaudeIssue7000(t, NewClaudeExecutor(cfg), &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-issue7000"}}, cliproxyexecutor.Request{Model: model, Payload: payload}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}, method)
if filter {
for _, path := range []string{"tool_choice", "thinking", "output_config.effort", "tools"} {
- if gjson.GetBytes(body, path).Exists() {
+ if ojson.GetBytes(body, path).Exists() {
t.Errorf("filtered %s was restored: %s", path, body)
}
}
- } else if gjson.GetBytes(body, "tool_choice.type").String() != "tool" || gjson.GetBytes(body, "tool_choice.name").String() != "user_override" || !gjson.GetBytes(body, "observed_builtin").Bool() || gjson.GetBytes(body, "thinking.type").String() != "adaptive" {
+ } else if ojson.GetBytes(body, "tool_choice.type").String() != "tool" || ojson.GetBytes(body, "tool_choice.name").String() != "user_override" || !ojson.GetBytes(body, "observed_builtin").Bool() || ojson.GetBytes(body, "thinking.type").String() != "adaptive" {
t.Errorf("payload override did not remain final with thinking intact: %s", body)
}
})
@@ -310,7 +312,7 @@ func TestClaudeExecutor_Issue7000LocalCountPayloadRulesRemainFinal(t *testing.T)
if errCount != nil {
t.Fatalf("local CountTokens: %v", errCount)
}
- return gjson.GetBytes(resp.Payload, "input_tokens").Int()
+ return ojson.GetBytes(resp.Payload, "input_tokens").Int()
}
// The legacy model supplies an independent baseline without 5.5 normalization.
want := count(NewClaudeExecutor(&config.Config{}), "claude-opus-5", claudeIssue7000Payload("claude-opus-5", controlChoice))
@@ -380,11 +382,11 @@ func TestClaudeExecutor_Issue7000ManagerRetryKeepsAuto(t *testing.T) {
t.Fatalf("upstream attempts = %d, want initial plus one retry", len(bodies))
}
for attempt, body := range bodies {
- if got := gjson.GetBytes(body, "tool_choice").Value(); !reflect.DeepEqual(got, gjson.Parse(`{"type":"auto","disable_parallel_tool_use":true}`).Value()) {
+ if got := ojson.GetBytes(body, "tool_choice").Value(); !reflect.DeepEqual(got, ojson.Parse(`{"type":"auto","disable_parallel_tool_use":true}`).Value()) {
t.Errorf("attempt %d restored forced choice: %s", attempt+1, body)
}
}
- if gjson.GetBytes(payload, "tool_choice.type").String() != "tool" {
+ if ojson.GetBytes(payload, "tool_choice.type").String() != "tool" {
t.Error("executor mutated the caller payload reused by retries")
}
})
diff --git a/internal/runtime/executor/claude_issue7000_test.go b/internal/runtime/executor/claude_issue7000_test.go
index 394c4f85c7c..a88dfa61158 100644
--- a/internal/runtime/executor/claude_issue7000_test.go
+++ b/internal/runtime/executor/claude_issue7000_test.go
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestClaudeExecutor_Issue7000ForcedToolChoiceFallsBackToAuto(t *testing.T) {
@@ -74,7 +74,7 @@ func TestClaudeExecutor_Issue7000ForcedToolChoiceFallsBackToAuto(t *testing.T) {
t.Fatalf("Execute() error = %v", errExecute)
}
- if !gjson.ValidBytes(upstreamBody) {
+ if !ojson.ValidBytes(upstreamBody) {
t.Fatalf("expected a captured JSON upstream request, got %q", upstreamBody)
}
for _, assertion := range []struct {
@@ -85,14 +85,14 @@ func TestClaudeExecutor_Issue7000ForcedToolChoiceFallsBackToAuto(t *testing.T) {
{path: "tool_choice.disable_parallel_tool_use", want: true},
{path: "thinking.type", want: "adaptive"},
{path: "output_config.effort", want: "high"},
- {path: "tools", want: gjson.Parse(tools).Value()},
- {path: "messages", want: gjson.Parse(messages).Value()},
+ {path: "tools", want: ojson.Parse(tools).Value()},
+ {path: "messages", want: ojson.Parse(messages).Value()},
} {
- if got := gjson.GetBytes(upstreamBody, assertion.path).Value(); !reflect.DeepEqual(got, assertion.want) {
+ if got := ojson.GetBytes(upstreamBody, assertion.path).Value(); !reflect.DeepEqual(got, assertion.want) {
t.Errorf("upstream %s = %#v, want %#v", assertion.path, got, assertion.want)
}
}
- if got := gjson.GetBytes(upstreamBody, "tool_choice.name"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "tool_choice.name"); got.Exists() {
t.Errorf("upstream tool_choice.name = %s, want absent after fallback to auto", got.Raw)
}
})
diff --git a/internal/runtime/executor/claude_issue7279_test.go b/internal/runtime/executor/claude_issue7279_test.go
new file mode 100644
index 00000000000..de912c00c97
--- /dev/null
+++ b/internal/runtime/executor/claude_issue7279_test.go
@@ -0,0 +1,73 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+func TestClaudeExecutorIssue7279GeminiToolResultsAndPayloadRules(t *testing.T) {
+ const model = "claude-sonnet-4"
+ const response = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4\",\"content\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n" +
+ "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n" +
+ "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}\n\n" +
+ "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" +
+ "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" +
+ "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
+ const payload = `{"contents":[{"role":"user","parts":[{"text":"read a file"}]},{"role":"model","parts":[{"functionCall":{"name":"read_file","id":"toolu_1","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"name":"read_file","id":"toolu_1","response":{"result":"original"}}}]}],"tools":[{"functionDeclarations":[{"name":"read_file","parameters":{"type":"object","properties":{}}}]}]}`
+
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
+ var upstreamBody []byte
+ transport := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ upstreamBody, _ = io.ReadAll(req.Body)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(response)),
+ Request: req,
+ }, nil
+ })
+ ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", http.RoundTripper(transport))
+ models := []config.PayloadModelRule{{Name: model, Match: []map[string]any{{"messages.2.role": "user", "messages.2.content.0.type": "tool_result"}}}}
+ cfg := &config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: models, Params: map[string]any{"messages.2.content.0.content": "configured"}}}}}
+ executor := NewClaudeExecutor(cfg)
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"api_key": "key-issue7279", "cloak_mode": "never"}}
+ request := cliproxyexecutor.Request{Model: model, Payload: []byte(payload)}
+ options := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini, Stream: stream}
+ if stream {
+ result, errStream := executor.ExecuteStream(ctx, auth, request, options)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream failed: %v", errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream failed: %v", chunk.Err)
+ }
+ }
+ } else if _, errExecute := executor.Execute(ctx, auth, request, options); errExecute != nil {
+ t.Fatalf("Execute failed: %v", errExecute)
+ }
+
+ messages := ojson.GetBytesNoCopy(upstreamBody, "messages").Array()
+ if len(messages) != 3 || messages[1].Get("role").String() != "assistant" || messages[1].Get("content.0.type").String() != "tool_use" || messages[2].Get("role").String() != "user" || messages[2].Get("content.0.type").String() != "tool_result" {
+ t.Fatalf("upstream messages do not separate the call and result: %s", ojson.GetBytesNoCopy(upstreamBody, "messages").Raw)
+ }
+ if got := messages[2].Get("content.0.tool_use_id").String(); got != "toolu_1" {
+ t.Fatalf("tool result id = %q, want toolu_1", got)
+ }
+ if got := messages[2].Get("content.0.content").String(); got != "configured" {
+ t.Fatalf("tool result content = %q, want final payload override", got)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/claude_issue_6120_test.go b/internal/runtime/executor/claude_issue_6120_test.go
index c3bcc0b1c2c..dd132a330ab 100644
--- a/internal/runtime/executor/claude_issue_6120_test.go
+++ b/internal/runtime/executor/claude_issue_6120_test.go
@@ -7,11 +7,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloak_Issue6120 verifies Option A for Issue #6120:
@@ -30,7 +30,7 @@ func TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloak_Issue6120(t *testing.
if !cloaked {
t.Fatal("applyCloaking() cloaked = false, want true (Issue #6120: unconfirmed OAuth direct Messages callers must be cloaked by default)")
}
- if !gjson.GetBytes(gotPayload, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
+ if !ojson.GetBytes(gotPayload, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
t.Fatal("expected cloaked payload to contain Claude Code CLI identity system block")
}
@@ -77,7 +77,7 @@ func TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloak_Issue6120(t *testing.
if !strings.Contains(betas, "claude-code-20250219") || !strings.Contains(betas, "oauth-2025-04-20") {
t.Fatalf("Anthropic-Beta = %q, want claude-code-20250219 and oauth-2025-04-20 for cloaked OAuth", betas)
}
- if !gjson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
+ if !ojson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
t.Fatal("expected upstream body to contain Claude Code CLI identity system block")
}
}
@@ -136,7 +136,7 @@ func TestClaudeExecutor_DirectMessagesOAuthDefaultsToCloakStream_Issue6120(t *te
if !strings.Contains(betas, "claude-code-20250219") || !strings.Contains(betas, "oauth-2025-04-20") {
t.Fatalf("streaming Anthropic-Beta = %q, want claude-code-20250219 and oauth-2025-04-20", betas)
}
- if !gjson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
+ if !ojson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
t.Fatal("expected streaming upstream body to contain Claude Code CLI identity system block")
}
}
diff --git a/internal/runtime/executor/claude_issue_6698_test.go b/internal/runtime/executor/claude_issue_6698_test.go
index 00de95ce50f..c50dbd8cadc 100644
--- a/internal/runtime/executor/claude_issue_6698_test.go
+++ b/internal/runtime/executor/claude_issue_6698_test.go
@@ -7,11 +7,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const claudeIssue6698SSE = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_test\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-opus-5-5\",\"content\":[],\"stop_reason\":null,\"usage\":{\"input_tokens\":1,\"output_tokens\":0}}}\n\n" +
@@ -44,7 +44,7 @@ func TestClaudeExecutor_Sonnet55ThinkingOffSendsBetweenTools(t *testing.T) {
upstreamBody, _ = io.ReadAll(req.Body)
contentType := "application/json"
body := `{"id":"msg_test","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
- if gjson.GetBytes(upstreamBody, "stream").Bool() {
+ if ojson.GetBytes(upstreamBody, "stream").Bool() {
contentType = "text/event-stream"
body = claudeIssue6698SSE
}
@@ -69,13 +69,13 @@ func TestClaudeExecutor_Sonnet55ThinkingOffSendsBetweenTools(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "thinking").Raw; got != `{"type":"between_tools"}` {
+ if got := ojson.GetBytes(upstreamBody, "thinking").Raw; got != `{"type":"between_tools"}` {
t.Fatalf("upstream thinking = %s, want {\"type\":\"between_tools\"}; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "output_config.effort"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "output_config.effort"); got.Exists() {
t.Errorf("upstream output_config.effort = %s, want absent", got.Raw)
}
- if got := gjson.GetBytes(upstreamBody, "context_management"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "context_management"); got.Exists() {
t.Errorf("upstream context_management = %s, want absent with between_tools", got.Raw)
}
})
@@ -102,7 +102,7 @@ func TestClaudeExecutor_AdaptiveOnlyThinkingOffKeepsThinkingHidden(t *testing.T)
upstreamBody, _ = io.ReadAll(req.Body)
contentType := "application/json"
body := `{"id":"msg_test","type":"message","role":"assistant","model":"claude-opus-5-5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
- if gjson.GetBytes(upstreamBody, "stream").Bool() {
+ if ojson.GetBytes(upstreamBody, "stream").Bool() {
contentType = "text/event-stream"
body = claudeIssue6698SSE
}
@@ -117,13 +117,13 @@ func TestClaudeExecutor_AdaptiveOnlyThinkingOffKeepsThinkingHidden(t *testing.T)
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
t.Fatalf("upstream thinking.type = %q, want adaptive; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "output_config.effort").String(); got != "low" {
+ if got := ojson.GetBytes(upstreamBody, "output_config.effort").String(); got != "low" {
t.Errorf("upstream output_config.effort = %q, want low", got)
}
- if got := gjson.GetBytes(upstreamBody, "thinking.display").String(); got != "omitted" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.display").String(); got != "omitted" {
t.Errorf("upstream thinking.display = %q, want omitted; body=%s", got, upstreamBody)
}
})
@@ -152,13 +152,13 @@ func TestClaudeExecutor_AdaptiveOnlyGeminiBudgetAndAuto(t *testing.T) {
if _, err := executor.Execute(ctx, auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
t.Fatalf("upstream thinking.type = %q, want adaptive; body=%s", got, upstreamBody)
}
- if gjson.GetBytes(upstreamBody, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(upstreamBody, "thinking.budget_tokens").Exists() {
t.Fatalf("upstream thinking.budget_tokens still present: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "output_config.effort").String(); got != "xhigh" {
+ if got := ojson.GetBytes(upstreamBody, "output_config.effort").String(); got != "xhigh" {
t.Fatalf("upstream output_config.effort = %q, want xhigh; body=%s", got, upstreamBody)
}
})
@@ -182,13 +182,13 @@ func TestClaudeExecutor_AdaptiveOnlyGeminiBudgetAndAuto(t *testing.T) {
if _, err := executor.Execute(ctx, auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "adaptive" {
t.Fatalf("upstream thinking.type = %q, want adaptive; body=%s", got, upstreamBody)
}
- if gjson.GetBytes(upstreamBody, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(upstreamBody, "thinking.budget_tokens").Exists() {
t.Fatalf("upstream thinking.budget_tokens still present: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "output_config.effort"); got.Exists() {
+ if got := ojson.GetBytes(upstreamBody, "output_config.effort"); got.Exists() {
t.Fatalf("upstream output_config.effort = %s, want absent; body=%s", got.Raw, upstreamBody)
}
})
diff --git a/internal/runtime/executor/claude_messages_passthrough_test.go b/internal/runtime/executor/claude_messages_passthrough_test.go
index b4a01352fb8..88508f77ef0 100644
--- a/internal/runtime/executor/claude_messages_passthrough_test.go
+++ b/internal/runtime/executor/claude_messages_passthrough_test.go
@@ -8,12 +8,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func directClaudeMessagesContext() context.Context {
@@ -107,19 +107,19 @@ func TestClaudeExecutor_DirectMessagesOfficialUpstreamPreservesCallerShape(t *te
if got := helps.HeaderValueCaseInsensitive(seenHeaders, "Anthropic-Beta"); got != incoming.Get("Anthropic-Beta") {
t.Fatalf("Anthropic-Beta = %q, want %q; headers=%v", got, incoming.Get("Anthropic-Beta"), seenHeaders)
}
- if got := gjson.GetBytes(seenBody, "system.#").Int(); got != 1 {
+ if got := ojson.GetBytes(seenBody, "system.#").Int(); got != 1 {
t.Fatalf("system block count = %d, want the caller block only", got)
}
- if gjson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
+ if ojson.GetBytes(seenBody, "system.#(text==\"You are Claude Code, Anthropic's official CLI for Claude.\")").Exists() {
t.Fatal("direct Messages request gained the CLI identity system block")
}
- if got := gjson.GetBytes(seenBody, "thinking.display").String(); got != "summarized" {
+ if got := ojson.GetBytes(seenBody, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized", got)
}
- if got := gjson.GetBytes(seenBody, "tools.0.name").String(); got != "caller_tool" {
+ if got := ojson.GetBytes(seenBody, "tools.0.name").String(); got != "caller_tool" {
t.Fatalf("tool name = %q, want caller_tool", got)
}
- if gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatal("direct Messages request gained a synthetic cache breakpoint")
}
}
@@ -213,8 +213,8 @@ func TestClaudeExecutor_PreserveCallerFingerprintAlignsSessionIDWithBody(t *test
}
// Verify header session matches session_id embedded in metadata.user_id:
- rawUserID := gjson.GetBytes(seenBody, "metadata.user_id").String()
- bodySession := gjson.Get(rawUserID, "session_id").String()
+ rawUserID := ojson.GetBytes(seenBody, "metadata.user_id").String()
+ bodySession := ojson.Get(rawUserID, "session_id").String()
if bodySession == "" {
t.Fatalf("metadata.user_id has no session_id: %s", rawUserID)
}
@@ -246,7 +246,7 @@ func TestApplyClaudeCloakThinkingDisplay(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := applyClaudeCloakThinkingDisplay([]byte(test.body), false)
- if display := gjson.GetBytes(got, "thinking.display").String(); display != test.want {
+ if display := ojson.GetBytes(got, "thinking.display").String(); display != test.want {
t.Fatalf("model=%s thinking.display = %q, want %q; body=%s", test.model, display, test.want, got)
}
})
diff --git a/internal/runtime/executor/claude_mid_system_model_test.go b/internal/runtime/executor/claude_mid_system_model_test.go
index 10ca9c0b358..cef94f69c9d 100644
--- a/internal/runtime/executor/claude_mid_system_model_test.go
+++ b/internal/runtime/executor/claude_mid_system_model_test.go
@@ -8,12 +8,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// midSystemLegacyPayload is a caller body pairing a legacy model with a
@@ -58,7 +58,7 @@ func (u *midSystemUpstream) context(t *testing.T, headers http.Header) context.C
responseBody := `{"id":"msg_1","type":"message","role":"assistant","model":"m","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`
if strings.Contains(req.URL.Path, "count_tokens") {
responseBody = `{"input_tokens":18}`
- } else if gjson.GetBytes(payload, "stream").Bool() {
+ } else if ojson.GetBytes(payload, "stream").Bool() {
contentType = "text/event-stream"
// A translated caller aggregates the stream back into one message, so
// the stub has to complete the block and report a stop reason.
@@ -278,7 +278,7 @@ func TestClaudeExecutor_ConfirmedNativeLegacyMidSystemMessageForwarded(t *testin
if !upstream.called {
t.Fatal("expected the native request to reach the upstream")
}
- if !gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() {
+ if !ojson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() {
t.Fatalf("confirmed native caller lost its role=system turn; body=%s", upstream.body)
}
}
@@ -342,7 +342,7 @@ func TestClaudeExecutor_LegacyMidSystemMessageOptInStillRebuilds(t *testing.T) {
if !upstream.called {
t.Fatal("expected the rebuilt request to reach the upstream")
}
- if gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() {
+ if ojson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists() {
t.Fatalf("opt-in rebuild left a role=system turn; body=%s", upstream.body)
}
}
@@ -385,13 +385,13 @@ func TestTranslatedRequestNeverPairsLegacyModelWithMidSystemMessage(t *testing.T
// Without these the subject under test could drift away: a body that
// no longer addresses the legacy model, or that lost the caller's
// turns, would satisfy the role assertions for the wrong reason.
- if got := gjson.GetBytes(upstream.body, "model").String(); got != legacyModel {
+ if got := ojson.GetBytes(upstream.body, "model").String(); got != legacyModel {
t.Fatalf("upstream model = %q, want the legacy model %q under test", got, legacyModel)
}
- if got := len(gjson.GetBytes(upstream.body, "messages").Array()); got == 0 {
+ if got := len(ojson.GetBytes(upstream.body, "messages").Array()); got == 0 {
t.Fatalf("upstream messages are empty, so the role assertions prove nothing; body=%s", upstream.body)
}
- for _, role := range gjson.GetBytes(upstream.body, "messages.#.role").Array() {
+ for _, role := range ojson.GetBytes(upstream.body, "messages.#.role").Array() {
if strings.EqualFold(role.String(), "system") {
t.Fatalf("translated body carries a system role; body=%s", upstream.body)
}
@@ -463,7 +463,7 @@ func TestValidateClaudeMidSystemMessageModel(t *testing.T) {
if err == nil {
return
}
- if !strings.Contains(err.Error(), gjson.Get(test.payload, "model").String()) {
+ if !strings.Contains(err.Error(), ojson.Get(test.payload, "model").String()) {
t.Fatalf("error = %v, want the offending model named", err)
}
})
@@ -512,13 +512,13 @@ func TestClaudeExecutorPayloadRepairsLegacyMidSystemMessage(t *testing.T) {
if !upstream.called {
t.Fatal("repaired body did not reach upstream")
}
- if got := gjson.GetBytes(upstream.body, "model").String(); got != repair.wantModel {
+ if got := ojson.GetBytes(upstream.body, "model").String(); got != repair.wantModel {
t.Fatalf("final model = %q, want %q", got, repair.wantModel)
}
- if got := gjson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists(); got != repair.wantSystem {
+ if got := ojson.GetBytes(upstream.body, `messages.#(role=="system")`).Exists(); got != repair.wantSystem {
t.Fatalf("final system-turn presence = %v, want %v; body=%s", got, repair.wantSystem, upstream.body)
}
- if !gjson.GetBytes(upstream.body, `messages.#(role=="user")`).Exists() {
+ if !ojson.GetBytes(upstream.body, `messages.#(role=="user")`).Exists() {
t.Fatalf("repair removed caller user turn: %s", upstream.body)
}
})
diff --git a/internal/runtime/executor/claude_prompt_cache_options_test.go b/internal/runtime/executor/claude_prompt_cache_options_test.go
index 28c1d79d6a6..b232a10bb4b 100644
--- a/internal/runtime/executor/claude_prompt_cache_options_test.go
+++ b/internal/runtime/executor/claude_prompt_cache_options_test.go
@@ -10,11 +10,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func writeClaudeSSEMockResponse(w http.ResponseWriter) {
@@ -65,7 +65,7 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_SuppressesAutoCacheContr
if count := countCacheControls(seenBody); count != 0 {
t.Fatalf("expected 0 cache_control blocks with mode: explicit and no client breakpoints, got %d: %s", count, string(seenBody))
}
- if gjson.GetBytes(seenBody, "prompt_cache_options").Exists() {
+ if ojson.GetBytes(seenBody, "prompt_cache_options").Exists() {
t.Fatalf("prompt_cache_options should not be forwarded upstream: %s", string(seenBody))
}
}
@@ -113,13 +113,13 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_PreservesClientBreakpoin
if count := countCacheControls(seenBody); count != 1 {
t.Fatalf("expected exactly 1 cache_control block (the client-specified one), got %d: %s", count, string(seenBody))
}
- if !gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if !ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("client cache_control was not preserved: %s", string(seenBody))
}
- if gjson.GetBytes(seenBody, "system.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "system.0.cache_control").Exists() {
t.Fatalf("system prompt unexpectedly received automatic cache_control: %s", string(seenBody))
}
- if gjson.GetBytes(seenBody, "messages.1.content.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "messages.1.content.0.cache_control").Exists() {
t.Fatalf("second message unexpectedly received automatic cache_control: %s", string(seenBody))
}
}
@@ -167,7 +167,7 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Stream_Explicit_SuppressesAutoCac
if count := countCacheControls(seenBody); count != 0 {
t.Fatalf("expected 0 cache_control blocks in stream with mode: explicit, got %d: %s", count, string(seenBody))
}
- if gjson.GetBytes(seenBody, "prompt_cache_options").Exists() {
+ if ojson.GetBytes(seenBody, "prompt_cache_options").Exists() {
t.Fatalf("prompt_cache_options should not be forwarded upstream: %s", string(seenBody))
}
}
@@ -228,10 +228,10 @@ func TestClaudeExecutor_PromptCacheOptionsMode_ImplicitOrMissing_PreservesAutoCa
}
// In normal mode without client markers, system and message should receive auto-injected cache_control.
- if !gjson.GetBytes(seenBody, "system.0.cache_control").Exists() {
+ if !ojson.GetBytes(seenBody, "system.0.cache_control").Exists() {
t.Fatalf("expected system cache_control in implicit mode: %s", string(seenBody))
}
- if !gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if !ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("expected message cache_control in implicit mode: %s", string(seenBody))
}
})
@@ -326,13 +326,13 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_ClientToolBreakpointPres
if count := countCacheControls(seenBody); count != 1 {
t.Fatalf("expected 1 cache_control block on client-specified tool, got %d: %s", count, string(seenBody))
}
- if !gjson.GetBytes(seenBody, "tools.0.cache_control").Exists() {
+ if !ojson.GetBytes(seenBody, "tools.0.cache_control").Exists() {
t.Fatalf("tools.0 should have client-specified cache_control: %s", string(seenBody))
}
- if gjson.GetBytes(seenBody, "tools.1.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "tools.1.cache_control").Exists() {
t.Fatalf("tools.1 should NOT have cache_control: %s", string(seenBody))
}
- if gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("message should NOT receive automatic cache_control: %s", string(seenBody))
}
}
@@ -383,7 +383,7 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_Cloaked_SuppressesAllAut
if count := countCacheControls(seenBody); count != 0 {
t.Fatalf("expected 0 cache_control blocks in cloaked mode with explicit mode and no client breakpoints, got %d: %s", count, string(seenBody))
}
- if gjson.GetBytes(seenBody, "prompt_cache_options").Exists() {
+ if ojson.GetBytes(seenBody, "prompt_cache_options").Exists() {
t.Fatalf("prompt_cache_options should not be forwarded upstream: %s", string(seenBody))
}
}
@@ -438,10 +438,10 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_Cloaked_PreservesClientB
if count := countCacheControls(seenBody); count != 1 {
t.Fatalf("expected exactly 1 cache_control block (the client-specified one), got %d: %s", count, string(seenBody))
}
- if gjson.GetBytes(seenBody, "system.0.cache_control").Exists() || gjson.GetBytes(seenBody, "system.1.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "system.0.cache_control").Exists() || ojson.GetBytes(seenBody, "system.1.cache_control").Exists() {
t.Fatalf("system blocks should not have automatic cache_control: %s", string(seenBody))
}
- if !gjson.GetBytes(seenBody, "messages.3.content.0.cache_control").Exists() {
+ if !ojson.GetBytes(seenBody, "messages.3.content.0.cache_control").Exists() {
t.Fatalf("client specified cache_control on user turn should be preserved: %s", string(seenBody))
}
}
@@ -500,22 +500,22 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_Cloaked_PreservesClientS
t.Fatalf("expected exactly 1 cache_control block (the client system one), got %d: %s", count, string(seenBody))
}
// Verify CPA synthetic agent block does NOT have cache_control
- if gjson.GetBytes(seenBody, "system.1.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "system.1.cache_control").Exists() {
t.Fatalf("CPA synthetic agent block must not have cache_control: %s", string(seenBody))
}
// Verify user message does NOT have cache_control
- if gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("first user message must not receive auto cache_control: %s", string(seenBody))
}
// Verify client system prompt has cache_control preserved
foundClientSystemCC := false
- messages := gjson.GetBytes(seenBody, "messages")
+ messages := ojson.GetBytes(seenBody, "messages")
if messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
if msg.Get("role").String() == "system" {
content := msg.Get("content")
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.Get("text").String() == "Special client system prompt with breakpoint" && part.Get("cache_control.type").String() == "ephemeral" && part.Get("cache_control.scope").String() == "global" {
foundClientSystemCC = true
}
@@ -526,9 +526,9 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_Cloaked_PreservesClientS
return true
})
}
- system := gjson.GetBytes(seenBody, "system")
+ system := ojson.GetBytes(seenBody, "system")
if system.IsArray() {
- system.ForEach(func(_, part gjson.Result) bool {
+ system.ForEach(func(_, part ojson.Result) bool {
if part.Get("text").String() == "Special client system prompt with breakpoint" && part.Get("cache_control.type").String() == "ephemeral" && part.Get("cache_control.scope").String() == "global" {
foundClientSystemCC = true
}
@@ -595,7 +595,7 @@ func TestClaudeExecutor_CountTokens_StripsPromptCacheOptions(t *testing.T) {
if len(upstreamResp.Payload) == 0 {
t.Fatal("expected non-empty countTokensUpstream payload")
}
- if gjson.GetBytes(seenBody, "prompt_cache_options").Exists() {
+ if ojson.GetBytes(seenBody, "prompt_cache_options").Exists() {
t.Fatalf("prompt_cache_options should not be forwarded in count_tokens upstream: %s", string(seenBody))
}
}
@@ -648,7 +648,7 @@ func TestClaudeExecutor_CountTokensUpstream_StripsPromptCacheOptions_EvenWithPay
if len(upstreamResp.Payload) == 0 {
t.Fatal("expected non-empty countTokensUpstream payload")
}
- if gjson.GetBytes(seenBody, "prompt_cache_options").Exists() {
+ if ojson.GetBytes(seenBody, "prompt_cache_options").Exists() {
t.Fatalf("prompt_cache_options should not be forwarded in count_tokens upstream even if injected by payload rule: %s", string(seenBody))
}
}
@@ -695,7 +695,7 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_ProbePreservesClient1hTT
t.Fatalf("Execute() error = %v", err)
}
- if gotTTL := gjson.GetBytes(seenBody, "messages.0.content.0.cache_control.ttl").String(); gotTTL != "1h" {
+ if gotTTL := ojson.GetBytes(seenBody, "messages.0.content.0.cache_control.ttl").String(); gotTTL != "1h" {
t.Fatalf("client specified 1h TTL must be preserved in explicit mode even on probe request, got %q: %s", gotTTL, string(seenBody))
}
}
@@ -758,14 +758,14 @@ func TestClaudeExecutor_CountTokensUpstream_Cloaked_ExplicitPreservesClientSyste
t.Fatalf("expected exactly 1 cache_control in count_tokens explicit mode, got %d: %s", count, string(seenBody))
}
// Verify user message did not get auto-cached
- if gjson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "messages.0.content.0.cache_control").Exists() {
t.Fatalf("user message must not receive auto cache_control: %s", string(seenBody))
}
// Verify client system breakpoint was preserved with scope: global.
foundClientSystemCC := false
- system := gjson.GetBytes(seenBody, "system")
+ system := ojson.GetBytes(seenBody, "system")
if system.IsArray() {
- system.ForEach(func(_, part gjson.Result) bool {
+ system.ForEach(func(_, part ojson.Result) bool {
if part.Get("text").String() == "Count tokens system instructions" &&
part.Get("cache_control.type").String() == "ephemeral" &&
part.Get("cache_control.scope").String() == "global" {
@@ -834,17 +834,17 @@ func TestClaudeExecutor_PromptCacheOptionsMode_Explicit_Cloaked_LegacyModelPrese
t.Fatalf("expected exactly 1 cache_control block in legacy model explicit mode, got %d: %s", count, string(seenBody))
}
// Verify system.1 agent block does not have cache_control
- if gjson.GetBytes(seenBody, "system.1.cache_control").Exists() {
+ if ojson.GetBytes(seenBody, "system.1.cache_control").Exists() {
t.Fatalf("agent block must not have cache_control: %s", string(seenBody))
}
// Verify the prepended reminder on user message preserves scope: global
foundReminderCC := false
- messages := gjson.GetBytes(seenBody, "messages")
+ messages := ojson.GetBytes(seenBody, "messages")
if messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if strings.Contains(part.Get("text").String(), "Legacy system instructions with scope") &&
part.Get("cache_control.type").String() == "ephemeral" &&
part.Get("cache_control.scope").String() == "global" {
@@ -919,12 +919,12 @@ func TestClaudeExecutor_CountTokensUpstream_Cloaked_LegacyModel_ExplicitPreserve
t.Fatalf("expected exactly 1 cache_control in legacy count_tokens explicit mode, got %d: %s", count, string(seenBody))
}
foundReminderCC := false
- messages := gjson.GetBytes(seenBody, "messages")
+ messages := ojson.GetBytes(seenBody, "messages")
if messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if strings.Contains(part.Get("text").String(), "Count tokens legacy system instructions") &&
part.Get("cache_control.type").String() == "ephemeral" &&
part.Get("cache_control.scope").String() == "global" {
@@ -1001,17 +1001,17 @@ func TestClaudeExecutor_CountTokensUpstream_OAuthRelocatesAffectedSystemPrompts(
if len(resp.Payload) == 0 {
t.Fatal("expected non-empty countTokensUpstream payload")
}
- if gjson.GetBytes(seenBody, "system").Exists() {
+ if ojson.GetBytes(seenBody, "system").Exists() {
t.Fatalf("OAuth caller system prompt must not remain top-level: %s", seenBody)
}
- if got := gjson.GetBytes(seenBody, "messages.#").Int(); got != test.wantMessages {
+ if got := ojson.GetBytes(seenBody, "messages.#").Int(); got != test.wantMessages {
t.Fatalf("message count = %d, want %d: %s", got, test.wantMessages, seenBody)
}
messagePath := fmt.Sprintf("messages.%d", test.wantSystemIdx)
- if got := gjson.GetBytes(seenBody, messagePath+".role").String(); got != "system" {
+ if got := ojson.GetBytes(seenBody, messagePath+".role").String(); got != "system" {
t.Fatalf("%s.role = %q, want system: %s", messagePath, got, seenBody)
}
- if got := gjson.GetBytes(seenBody, messagePath+".content.0.text").String(); got != "caller guidance" {
+ if got := ojson.GetBytes(seenBody, messagePath+".content.0.text").String(); got != "caller guidance" {
t.Fatalf("%s.content.0.text = %q, want caller guidance: %s", messagePath, got, seenBody)
}
})
diff --git a/internal/runtime/executor/claude_signing.go b/internal/runtime/executor/claude_signing.go
index 084cc08d466..6d4c7601ac3 100644
--- a/internal/runtime/executor/claude_signing.go
+++ b/internal/runtime/executor/claude_signing.go
@@ -8,12 +8,11 @@ import (
"sort"
"strings"
+ "github.com/MoeForge/moego/ojson"
xxHash64 "github.com/pierrec/xxHash/xxHash64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -67,12 +66,16 @@ func finalizeAnthropicMessagesBodyCCH(body []byte, fallbackBilling string) ([]by
// system prompt, the fallback comes back and the request cannot go upstream with a
// system block that native would never send unsigned.
func claudeBodyNeedsBillingFallback(body []byte) bool {
- return gjson.GetBytes(body, "system").Exists()
+ return ojson.GetBytesNoCopy(body, "system").Exists()
+}
+
+func claudeBodyNeedsBillingFallbackDoc(doc *ojson.Doc) bool {
+ return doc != nil && doc.Get("system").Exists()
}
func ensureClaudeBillingHeaderCCHPlaceholder(body []byte, fallbackBilling string) ([]byte, error) {
- billing := gjson.GetBytes(body, "system.0.text")
- if billing.Type != gjson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
+ billing := ojson.GetBytesNoCopy(body, "system.0.text")
+ if billing.Type != ojson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
if fallbackBilling == "" {
return body, nil
}
@@ -81,7 +84,7 @@ func ensureClaudeBillingHeaderCCHPlaceholder(body []byte, fallbackBilling string
if errPrepend != nil {
return nil, errPrepend
}
- billing = gjson.GetBytes(body, "system.0.text")
+ billing = ojson.GetBytesNoCopy(body, "system.0.text")
}
if _, ok := claudeBillingCCHDigitsOffset(body); ok {
return body, nil
@@ -98,19 +101,65 @@ func ensureClaudeBillingHeaderCCHPlaceholder(body []byte, fallbackBilling string
}
insertAt := entrypoint + entrypointEnd + 1
billingText = billingText[:insertAt] + " cch=00000;" + billingText[insertAt:]
- updated, err := sjson.SetBytes(body, "system.0.text", billingText)
+ updated, err := ojson.SetBytes(body, "system.0.text", billingText)
if err != nil {
return nil, fmt.Errorf("insert Claude CCH placeholder: %w", err)
}
return updated, nil
}
+func ensureClaudeBillingHeaderCCHPlaceholderDoc(doc *ojson.Doc, fallbackBilling string) error {
+ if doc == nil {
+ return nil
+ }
+ billing := doc.Get("system.0.text")
+ if billing.Type != ojson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
+ if fallbackBilling == "" {
+ return nil
+ }
+ if errPrepend := prependClaudeBillingSystemBlockDoc(doc, fallbackBilling); errPrepend != nil {
+ return errPrepend
+ }
+ billing = doc.Get("system.0.text")
+ }
+ if claudeBillingRawHasCCHDigits(billing.Raw) {
+ return nil
+ }
+
+ billingText := billing.String()
+ entrypoint := strings.Index(billingText, "cc_entrypoint=")
+ if entrypoint < 0 {
+ return nil
+ }
+ entrypointEnd := strings.IndexByte(billingText[entrypoint:], ';')
+ if entrypointEnd < 0 {
+ return nil
+ }
+ insertAt := entrypoint + entrypointEnd + 1
+ billingText = billingText[:insertAt] + " cch=00000;" + billingText[insertAt:]
+ if errSet := doc.Set("system.0.text", billingText); errSet != nil {
+ return fmt.Errorf("insert Claude CCH placeholder: %w", errSet)
+ }
+ return nil
+}
+
func prependClaudeBillingSystemBlock(body []byte, billingText string) ([]byte, error) {
+ doc := ojson.NewDoc(body)
+ if errPrepend := prependClaudeBillingSystemBlockDoc(doc, billingText); errPrepend != nil {
+ return nil, errPrepend
+ }
+ return doc.Bytes(), nil
+}
+
+func prependClaudeBillingSystemBlockDoc(doc *ojson.Doc, billingText string) error {
+ if doc == nil {
+ return nil
+ }
billingBlock := []byte(buildTextBlock(billingText, nil))
- system := gjson.GetBytes(body, "system")
+ system := doc.Get("system")
var systemArray []byte
switch {
- case system.Type == gjson.String:
+ case system.Type == ojson.String:
originalBlock := []byte(buildTextBlock(system.String(), nil))
systemArray = make([]byte, 0, len(billingBlock)+len(originalBlock)+3)
systemArray = append(systemArray, '[')
@@ -139,11 +188,10 @@ func prependClaudeBillingSystemBlock(body []byte, billingText string) ([]byte, e
systemArray = append(systemArray, ']')
}
- updated, err := sjson.SetRawBytes(body, "system", systemArray)
- if err != nil {
- return nil, fmt.Errorf("prepend Claude CCH billing block: %w", err)
+ if errSet := doc.SetRawBytes("system", systemArray); errSet != nil {
+ return fmt.Errorf("prepend Claude CCH billing block: %w", errSet)
}
- return updated, nil
+ return nil
}
func isKimiAPIEndpoint(endpoint string) bool {
@@ -177,6 +225,51 @@ func stripDefaultKimiClaudeCodeAttribution(auth *cliproxyauth.Auth, endpoint str
return util.StripClaudeCodeAttributionSystem(body)
}
+func stripDefaultKimiClaudeCodeAttributionDoc(auth *cliproxyauth.Auth, endpoint string, cliFingerprint bool, doc *ojson.Doc) bool {
+ if cliFingerprint || !isKimiMessagesUpstream(auth, endpoint) {
+ return false
+ }
+ return stripClaudeCodeAttributionSystemDoc(doc)
+}
+
+func stripClaudeCodeAttributionSystemDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ system := doc.Get("system")
+ if !system.Exists() {
+ return false
+ }
+ if system.Type == ojson.String {
+ if !util.IsClaudeCodeAttributionSystemText(system.String()) {
+ return false
+ }
+ return doc.Delete("system") == nil
+ }
+ if !system.IsArray() {
+ return false
+ }
+ kept := make([]string, 0, len(system.Array()))
+ removed := false
+ system.ForEach(func(_, block ojson.Result) bool {
+ if block.Get("type").String() == "text" && util.IsClaudeCodeAttributionSystemText(block.Get("text").String()) {
+ removed = true
+ return true
+ }
+ if block.Raw != "" {
+ kept = append(kept, block.Raw)
+ }
+ return true
+ })
+ if !removed {
+ return false
+ }
+ if len(kept) == 0 {
+ return doc.Delete("system") == nil
+ }
+ return doc.SetRawBytes("system", []byte("["+strings.Join(kept, ",")+"]")) == nil
+}
+
// claudeCCHSigningEnabled applies CPA's CCH policy.
//
// Native gate, identical in Claude Code 2.1.220 through 2.1.234:
@@ -239,9 +332,27 @@ func signAnthropicMessagesBody(body []byte) ([]byte, error) {
return unsignedBody, nil
}
+func claudeBillingRawHasCCHDigits(rawStr string) bool {
+ raw := []byte(rawStr)
+ for searchFrom := 0; searchFrom < len(raw); {
+ relative := bytes.Index(raw[searchFrom:], []byte("cch="))
+ if relative < 0 {
+ return false
+ }
+ prefix := searchFrom + relative
+ digits := prefix + len("cch=")
+ end := digits + claudeCCHLength
+ if end < len(raw) && raw[end] == ';' && isLowerHex(raw[digits:end]) {
+ return true
+ }
+ searchFrom = prefix + len("cch=")
+ }
+ return false
+}
+
func claudeBillingCCHDigitsOffset(body []byte) (int, bool) {
- billing := gjson.GetBytes(body, "system.0.text")
- if billing.Type != gjson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
+ billing := ojson.GetBytesNoCopy(body, "system.0.text")
+ if billing.Type != ojson.String || !strings.HasPrefix(billing.String(), "x-anthropic-billing-header:") {
return 0, false
}
diff --git a/internal/runtime/executor/claude_signing_test.go b/internal/runtime/executor/claude_signing_test.go
index ec93149b281..693ba3079dc 100644
--- a/internal/runtime/executor/claude_signing_test.go
+++ b/internal/runtime/executor/claude_signing_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const claudeCCH21220BaseBody = `{"model":"model-a","messages":[{"role":"user","content":[{"type":"text","text":"x"}]}],"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.220.test; cc_entrypoint=sdk-cli; cch=00000;"},{"type":"text","text":"system-x"}],"tools":[],"metadata":{"user_id":"meta-x"},"max_tokens":1,"thinking":{"type":"adaptive","display":"omitted"},"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"output_config":{"effort":"high"},"stream":true}`
@@ -72,7 +72,7 @@ func TestSignAnthropicMessagesBody_PreservesFinalSerializedBytes(t *testing.T) {
if err != nil {
t.Fatalf("signAnthropicMessagesBody() error = %v", err)
}
- if got := gjson.GetBytes(signed, "messages.0.content.0.text").String(); got != literal {
+ if got := ojson.GetBytes(signed, "messages.0.content.0.text").String(); got != literal {
t.Fatalf("message text = %q, want %q", got, literal)
}
@@ -98,7 +98,7 @@ func TestFinalizeAnthropicMessagesBodyCCH_InsertsMissingPlaceholder(t *testing.T
if got := claudeCCHFromBody(t, signed); got != "7ee87" {
t.Fatalf("cch = %q, want %q", got, "7ee87")
}
- billing := gjson.GetBytes(signed, "system.0.text").String()
+ billing := ojson.GetBytes(signed, "system.0.text").String()
if !strings.Contains(billing, "cc_entrypoint=sdk-cli; cch=7ee87;") {
t.Fatalf("billing header = %q, want CCH after entrypoint", billing)
}
@@ -113,10 +113,10 @@ func TestFinalizeAnthropicMessagesBodyCCH_AddsMissingBillingBlock(t *testing.T)
if err != nil {
t.Fatalf("finalizeAnthropicMessagesBodyCCH() error = %v", err)
}
- if got := gjson.GetBytes(signed, "system.0.text").String(); !strings.HasPrefix(got, "x-anthropic-billing-header:") {
+ if got := ojson.GetBytes(signed, "system.0.text").String(); !strings.HasPrefix(got, "x-anthropic-billing-header:") {
t.Fatalf("system.0.text = %q, want billing block", got)
}
- if got := gjson.GetBytes(signed, "system.1.text").String(); got != "keep this system text" {
+ if got := ojson.GetBytes(signed, "system.1.text").String(); got != "keep this system text" {
t.Fatalf("system.1.text = %q, want preserved system text", got)
}
if _, ok := claudeBillingCCHDigitsOffset(signed); !ok {
diff --git a/internal/runtime/executor/claude_thinking_replay.go b/internal/runtime/executor/claude_thinking_replay.go
index 00176c547d7..cfc486f390f 100644
--- a/internal/runtime/executor/claude_thinking_replay.go
+++ b/internal/runtime/executor/claude_thinking_replay.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"strings"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
@@ -13,7 +14,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
// claudeThinkingReplayScope reuses the bounded replay state shape shared with Kimi.
@@ -90,18 +90,24 @@ func prepareClaudeThinkingReplayRequest(ctx context.Context, auth *cliproxyauth.
}
func restoreClaudeThinkingReplayContents(body []byte, cachedContents [][]byte) ([]byte, bool) {
- updated := body
+ if len(cachedContents) == 0 {
+ return body, false
+ }
+ doc := ojson.NewDoc(body)
restored := false
for _, cachedContent := range cachedContents {
- var restoredTurn bool
- updated, restoredTurn = restoreKimiThinkingReplayContent(updated, cachedContent)
- restored = restored || restoredTurn
+ if restoreKimiThinkingReplayContentDoc(doc, cachedContent) {
+ restored = true
+ }
+ }
+ if !restored {
+ return body, false
}
- return updated, restored
+ return doc.Bytes(), true
}
func cacheClaudeThinkingReplayResponse(ctx context.Context, scope claudeThinkingReplayScope, response []byte) {
- content := gjson.GetBytes(response, "content")
+ content := ojson.GetBytesNoCopy(response, "content")
if content.IsArray() {
cacheClaudeThinkingReplayContent(ctx, scope, []byte(content.Raw))
return
diff --git a/internal/runtime/executor/claude_thinking_replay_test.go b/internal/runtime/executor/claude_thinking_replay_test.go
index 7f04adc21f6..9968b8e22a4 100644
--- a/internal/runtime/executor/claude_thinking_replay_test.go
+++ b/internal/runtime/executor/claude_thinking_replay_test.go
@@ -9,12 +9,12 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const claudeReplayResolvedModelInfoKey = "cliproxy.resolved_api_key_model_info"
@@ -33,17 +33,17 @@ func claudeReplayTestAuth(baseURL string) *cliproxyauth.Auth {
func claudeReplayTestRequest(payload []byte, sessionID string, isCompat bool, source sdktranslator.Format) (cliproxyexecutor.Request, cliproxyexecutor.Options) {
return cliproxyexecutor.Request{
- Model: "claude-synthetic-4772",
- Payload: payload,
- Metadata: map[string]any{
- claudeReplayResolvedModelInfoKey: ®istry.ModelInfo{IsCompat: isCompat},
- },
- }, cliproxyexecutor.Options{
- SourceFormat: source,
- Metadata: map[string]any{
- cliproxyexecutor.ExecutionSessionMetadataKey: sessionID,
- },
- }
+ Model: "claude-synthetic-4772",
+ Payload: payload,
+ Metadata: map[string]any{
+ claudeReplayResolvedModelInfoKey: ®istry.ModelInfo{IsCompat: isCompat},
+ },
+ }, cliproxyexecutor.Options{
+ SourceFormat: source,
+ Metadata: map[string]any{
+ cliproxyexecutor.ExecutionSessionMetadataKey: sessionID,
+ },
+ }
}
func TestClaudeThinkingReplayEnabledRequiresCompatClaudeAPIKey(t *testing.T) {
@@ -165,9 +165,9 @@ func TestClaudeExecutorCompatThinkingReplayRestoresOmittedBlock(t *testing.T) {
if len(requestBodies) != 2 {
t.Fatalf("upstream request count = %d, want 2", len(requestBodies))
}
- content := gjson.GetBytes(requestBodies[1], "messages.1.content").Array()
+ content := ojson.GetBytes(requestBodies[1], "messages.1.content").Array()
if len(content) != 2 {
- t.Fatalf("second assistant content = %s, want thinking and tool_use", gjson.GetBytes(requestBodies[1], "messages.1.content").Raw)
+ t.Fatalf("second assistant content = %s, want thinking and tool_use", ojson.GetBytes(requestBodies[1], "messages.1.content").Raw)
}
if got := content[0].Get("type").String(); got != "thinking" {
t.Fatalf("restored first content type = %q, want thinking", got)
@@ -236,9 +236,9 @@ func TestClaudeExecutorCompatThinkingReplayRestoresOmittedBlockInStream(t *testi
if len(requestBodies) != 2 {
t.Fatalf("upstream request count = %d, want 2", len(requestBodies))
}
- content := gjson.GetBytes(requestBodies[1], "messages.1.content").Array()
+ content := ojson.GetBytes(requestBodies[1], "messages.1.content").Array()
if len(content) != 2 || content[0].Get("type").String() != "thinking" {
- t.Fatalf("second streamed assistant content = %s, want restored thinking and tool_use", gjson.GetBytes(requestBodies[1], "messages.1.content").Raw)
+ t.Fatalf("second streamed assistant content = %s, want restored thinking and tool_use", ojson.GetBytes(requestBodies[1], "messages.1.content").Raw)
}
if got := content[0].Get("signature").String(); got != "EgI=" {
t.Fatalf("restored streamed signature = %q, want EgI=", got)
@@ -357,13 +357,13 @@ func TestClaudeExecutorCompatThinkingReplayRestoresMultipleOmittedBlocks(t *test
if len(requestBodies) != 3 {
t.Fatalf("upstream request count = %d, want 3", len(requestBodies))
}
- firstContent := gjson.GetBytes(requestBodies[2], "messages.1.content").Array()
- secondContent := gjson.GetBytes(requestBodies[2], "messages.3.content").Array()
+ firstContent := ojson.GetBytes(requestBodies[2], "messages.1.content").Array()
+ secondContent := ojson.GetBytes(requestBodies[2], "messages.3.content").Array()
if len(firstContent) != 2 || firstContent[0].Get("type").String() != "thinking" || firstContent[0].Get("signature").String() != "EgI=" {
- t.Fatalf("first omitted turn was not restored: %s", gjson.GetBytes(requestBodies[2], "messages.1.content").Raw)
+ t.Fatalf("first omitted turn was not restored: %s", ojson.GetBytes(requestBodies[2], "messages.1.content").Raw)
}
if len(secondContent) != 2 || secondContent[0].Get("type").String() != "thinking" || secondContent[0].Get("signature").String() != "EgM=" {
- t.Fatalf("second omitted turn was not restored: %s", gjson.GetBytes(requestBodies[2], "messages.3.content").Raw)
+ t.Fatalf("second omitted turn was not restored: %s", ojson.GetBytes(requestBodies[2], "messages.3.content").Raw)
}
}
diff --git a/internal/runtime/executor/codex_executor_auth.go b/internal/runtime/executor/codex_executor_auth.go
index dd1477d3455..6d630e4cb3f 100644
--- a/internal/runtime/executor/codex_executor_auth.go
+++ b/internal/runtime/executor/codex_executor_auth.go
@@ -292,10 +292,18 @@ func (e *CodexExecutor) refreshLocked(ctx context.Context, auth *cliproxyauth.Au
needSIWC = hasSIWC
}
}
+ suppressedRejectedOuter := false
if !diskRotatedOuter && auth.RejectedRefreshToken != "" && outerRefreshToken == strings.TrimSpace(auth.RejectedRefreshToken) {
+ if needOuter {
+ suppressedRejectedOuter = true
+ }
needOuter = false
}
+ suppressedRejectedSIWC := false
if !diskRotatedSIWC && auth.RejectedSIWCRefreshToken != "" && siwcRefreshToken == strings.TrimSpace(auth.RejectedSIWCRefreshToken) {
+ if needSIWC {
+ suppressedRejectedSIWC = true
+ }
needSIWC = false
}
@@ -309,6 +317,13 @@ func (e *CodexExecutor) refreshLocked(ctx context.Context, auth *cliproxyauth.Au
clonedStorage.RefreshPatch = patch
auth.Storage = &clonedStorage
}
+ return auth, nil
+ }
+ if suppressedRejectedOuter || suppressedRejectedSIWC {
+ if auth.RefreshError != nil {
+ return nil, auth.RefreshError
+ }
+ return nil, statusErr{code: http.StatusUnauthorized, msg: "codex executor: refresh token is invalid; sign-in required"}
}
return auth, nil
}
diff --git a/internal/runtime/executor/codex_executor_auth_test.go b/internal/runtime/executor/codex_executor_auth_test.go
index 62ecaf07fc7..4046cf937d9 100644
--- a/internal/runtime/executor/codex_executor_auth_test.go
+++ b/internal/runtime/executor/codex_executor_auth_test.go
@@ -3,8 +3,8 @@ package executor
import (
"context"
"crypto/tls"
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"io"
"net"
"net/http"
@@ -746,7 +746,10 @@ func TestCodexExecutorUseSIWCExecuteAndStream(t *testing.T) {
if got := gotHeaders.Get("User-Agent"); got != codexSIWCUserAgent(&customCfg) {
t.Fatalf("custom User-Agent = %q, want %q", got, codexSIWCUserAgent(&customCfg))
}
- for _, forbidden := range []string{"max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
+ if got := gotBody["max_output_tokens"]; got != float64(1024) {
+ t.Fatalf("max_output_tokens = %v, want 1024", got)
+ }
+ for _, forbidden := range []string{"temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
if _, exists := gotBody[forbidden]; exists {
t.Fatalf("field %q should be omitted in SIWC mode, got %#v", forbidden, gotBody[forbidden])
}
@@ -781,7 +784,10 @@ func TestCodexExecutorUseSIWCExecuteAndStream(t *testing.T) {
if gotBody["service_tier"] != "priority" {
t.Fatalf("expected user payload rule to set service_tier=priority after SIWC body cleanup, got %v", gotBody["service_tier"])
}
- for _, forbidden := range []string{"max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
+ if got := gotBody["max_output_tokens"]; got != float64(1024) {
+ t.Fatalf("ExecuteStream max_output_tokens = %v, want 1024", got)
+ }
+ for _, forbidden := range []string{"temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
if _, exists := gotBody[forbidden]; exists {
t.Fatalf("ExecuteStream field %q should be omitted in SIWC mode, got %#v", forbidden, gotBody[forbidden])
}
@@ -905,7 +911,10 @@ func TestCodexWebsocketsExecutorUseSIWCExecuteAndStream(t *testing.T) {
if gotFrame["type"] != "response.create" || gotFrame["store"] != false || gotFrame["service_tier"] != "priority" {
t.Fatalf("unexpected SIWC WebSocket frame: %#v", gotFrame)
}
- for _, forbidden := range []string{"stream", "background", "max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
+ if got := gotFrame["max_output_tokens"]; got != float64(1024) {
+ t.Fatalf("WebSocket max_output_tokens = %v, want 1024", got)
+ }
+ for _, forbidden := range []string{"stream", "background", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
if _, exists := gotFrame[forbidden]; exists {
t.Fatalf("field %q should be omitted in SIWC WebSocket frame: %#v", forbidden, gotFrame)
}
@@ -928,7 +937,10 @@ func TestCodexWebsocketsExecutorUseSIWCExecuteAndStream(t *testing.T) {
if gotFrame["type"] != "response.create" || gotFrame["store"] != false || gotFrame["service_tier"] != "priority" {
t.Fatalf("unexpected SIWC WebSocket stream frame: %#v", gotFrame)
}
- for _, forbidden := range []string{"stream", "background", "max_output_tokens", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
+ if got := gotFrame["max_output_tokens"]; got != float64(1024) {
+ t.Fatalf("WebSocket ExecuteStream max_output_tokens = %v, want 1024", got)
+ }
+ for _, forbidden := range []string{"stream", "background", "temperature", "prompt_cache_retention", "prompt_cache_options", "instructions", "tools"} {
if _, exists := gotFrame[forbidden]; exists {
t.Fatalf("ExecuteStream field %q should be omitted in SIWC WebSocket frame: %#v", forbidden, gotFrame)
}
@@ -991,17 +1003,19 @@ func TestCodexExecutorUseSIWCFailsClosedWhenMissingSIWCToken(t *testing.T) {
}
}
-func TestCodexExecutorUseSIWCImageRequestsRouteThroughResponsesSSE(t *testing.T) {
+func TestCodexExecutorUseSIWCImageRequestsBypassSIWCAndUseDirectImagesEndpoint(t *testing.T) {
var gotPath string
var gotHeaders http.Header
- var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotHeaders = r.Header.Clone()
- raw, _ := io.ReadAll(r.Body)
- _ = json.Unmarshal(raw, &gotBody)
- w.Header().Set("Content-Type", "text/event-stream")
- _, _ = w.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_img\",\"created_at\":1700000000,\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"output_format\":\"png\"}]}}\n\n"))
+ if r.Header.Get("Accept") == "text/event-stream" {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("event: image_generation.completed\ndata: {\"type\":\"image_generation.completed\",\"b64_json\":\"aGVsbG8=\"}\n\n"))
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"created":1700000000,"data":[{"b64_json":"aGVsbG8="}],"usage":{"total_tokens":10}}`))
}))
defer srv.Close()
@@ -1010,13 +1024,15 @@ func TestCodexExecutorUseSIWCImageRequestsRouteThroughResponsesSSE(t *testing.T)
ID: "codex-siwc-img",
Provider: "codex",
Attributes: map[string]string{
- "base_url": srv.URL + "/v1",
+ "base_url": srv.URL,
},
Metadata: map[string]any{
- "type": "codex",
- "use_siwc": true,
+ "type": "codex",
+ "access_token": "outer-img-token",
+ "account_id": "acct-img-1",
+ "use_siwc": true,
"siwc": map[string]any{
- "access_token": "siwc-img-token",
+ "access_token": "siwc-img-token-must-not-be-used",
"client_id": "oaiapp_img",
},
},
@@ -1024,47 +1040,71 @@ func TestCodexExecutorUseSIWCImageRequestsRouteThroughResponsesSSE(t *testing.T)
imgOpts := cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai-image"),
+ Headers: http.Header{"User-Agent": []string{"Python-urllib/3.14"}},
Metadata: map[string]any{"request_path": "/v1/images/generations"},
}
- // Even with gpt-image-1.5 (which normally uses direct /images/generations), use_siwc=true must route via /v1/responses SSE
+ // Image requests must never use SIWC even for non-Codex client User-Agents; they must use the outer token and direct /images/generations endpoint.
_, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gpt-image-1.5",
- Payload: []byte(`{"model":"gpt-image-1.5","prompt":"a cute cat"}`),
+ Model: "gpt-image-2.6-sunburst",
+ Payload: []byte(`{"model":"gpt-image-2.6-sunburst","prompt":"a cute cat"}`),
}, imgOpts)
if errExec != nil {
t.Fatalf("Execute image with use_siwc error: %v", errExec)
}
- if gotPath != "/v1/responses" {
- t.Fatalf("image request path = %q, want /v1/responses", gotPath)
+ if gotPath != "/images/generations" {
+ t.Fatalf("image request path = %q, want /images/generations", gotPath)
}
- if gotHeaders.Get("Authorization") != "Bearer siwc-img-token" {
- t.Fatalf("Authorization = %q, want Bearer siwc-img-token", gotHeaders.Get("Authorization"))
+ if gotHeaders.Get("Authorization") != "Bearer outer-img-token" {
+ t.Fatalf("Authorization = %q, want Bearer outer-img-token", gotHeaders.Get("Authorization"))
}
- if _, hasInst := gotBody["instructions"]; hasInst {
- t.Fatalf("empty instructions should be stripped on SIWC image request: %#v", gotBody)
+ if gotHeaders.Get("Chatgpt-Account-Id") != "acct-img-1" {
+ t.Fatalf("Chatgpt-Account-Id = %q, want acct-img-1", gotHeaders.Get("Chatgpt-Account-Id"))
}
sr, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
- Model: "gpt-image-1.5",
- Payload: []byte(`{"model":"gpt-image-1.5","prompt":"a cute cat","stream":true}`),
+ Model: "gpt-image-2.6-sunburst",
+ Payload: []byte(`{"model":"gpt-image-2.6-sunburst","prompt":"a cute cat","stream":true}`),
}, imgOpts)
if errStream != nil {
t.Fatalf("ExecuteStream image with use_siwc error: %v", errStream)
}
for range sr.Chunks {
}
- if gotPath != "/v1/responses" {
- t.Fatalf("image stream request path = %q, want /v1/responses", gotPath)
+ if gotPath != "/images/generations" {
+ t.Fatalf("image stream request path = %q, want /images/generations", gotPath)
+ }
+ if gotHeaders.Get("Authorization") != "Bearer outer-img-token" {
+ t.Fatalf("stream Authorization = %q, want Bearer outer-img-token", gotHeaders.Get("Authorization"))
}
- // Missing SIWC token must fail closed on image requests too
- authNoSIWCToken := auth.Clone()
- delete(authNoSIWCToken.Metadata, "siwc")
- if _, errMissing := executor.Execute(context.Background(), authNoSIWCToken, cliproxyexecutor.Request{
- Model: "gpt-image-1.5",
- Payload: []byte(`{"model":"gpt-image-1.5","prompt":"a cute cat"}`),
+ // /v1/images/edits with non-Codex UA must also bypass SIWC and hit /images/edits with outer token.
+ editOpts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-image"),
+ Headers: http.Header{"User-Agent": []string{"Python-urllib/3.14"}, "Content-Type": []string{"application/json"}},
+ Metadata: map[string]any{"request_path": "/v1/images/edits"},
+ }
+ _, errEdit := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-image-2.6-sunburst",
+ Payload: []byte(`{"model":"gpt-image-2.6-sunburst","prompt":"edit cat","images":[{"image_url":"https://example.com/cat.png"}]}`),
+ }, editOpts)
+ if errEdit != nil {
+ t.Fatalf("Execute image edit with use_siwc error: %v", errEdit)
+ }
+ if gotPath != "/images/edits" {
+ t.Fatalf("image edit request path = %q, want /images/edits", gotPath)
+ }
+ if gotHeaders.Get("Authorization") != "Bearer outer-img-token" {
+ t.Fatalf("edit Authorization = %q, want Bearer outer-img-token", gotHeaders.Get("Authorization"))
+ }
+
+ // Missing outer token must fail closed on image requests even if SIWC token is present
+ authNoOuterToken := auth.Clone()
+ delete(authNoOuterToken.Metadata, "access_token")
+ if _, errMissing := executor.Execute(context.Background(), authNoOuterToken, cliproxyexecutor.Request{
+ Model: "gpt-image-2.6-sunburst",
+ Payload: []byte(`{"model":"gpt-image-2.6-sunburst","prompt":"a cute cat"}`),
}, imgOpts); errMissing == nil {
- t.Fatal("expected image Execute to fail when use_siwc=true and siwc token is missing")
+ t.Fatal("expected image Execute to fail when outer access_token is missing")
}
}
@@ -1621,3 +1661,40 @@ func TestCodexExecutorCodexClientIgnoresUseSIWC(t *testing.T) {
})
}
}
+
+func TestCodexExecutorForceRefreshRejectedRefreshTokenWithoutDiskRotation_Issue7135(t *testing.T) {
+ now := time.Now().UTC().Truncate(time.Second)
+ tmpDir := t.TempDir()
+ diskPath := tmpDir + "/codex-dead.json"
+ diskData := map[string]any{
+ "type": "codex",
+ "access_token": "revoked-access-token",
+ "refresh_token": "revoked-refresh-token",
+ "expired": now.Add(2 * time.Hour).Format(time.RFC3339),
+ }
+ if errWrite := codexauth.WriteJSONFileAtomic(diskPath, diskData); errWrite != nil {
+ t.Fatalf("WriteJSONFileAtomic: %v", errWrite)
+ }
+
+ executor := NewCodexExecutor(&config.Config{AuthDir: tmpDir})
+ auth := &cliproxyauth.Auth{
+ ID: "codex-dead.json",
+ FileName: "codex-dead.json",
+ Provider: "codex",
+ Status: cliproxyauth.StatusError,
+ Unavailable: true,
+ RequiresSignIn: true,
+ RejectedAccessToken: "revoked-access-token",
+ RejectedRefreshToken: "revoked-refresh-token",
+ StatusMessage: "unauthorized (refresh token invalid)",
+ RefreshError: &cliproxyauth.Error{Code: "refresh_token_reused", Message: "refresh token was already used", HTTPStatus: http.StatusUnauthorized},
+ Attributes: map[string]string{cliproxyauth.AttributePath: diskPath, cliproxyauth.AttributeSource: diskPath},
+ Metadata: cloneSIWCMap(diskData),
+ }
+
+ // Forced refresh when disk file has NOT rotated and refresh_token matches RejectedRefreshToken must fail.
+ _, errRefresh := executor.Refresh(cliproxyauth.WithForceRefresh(context.Background()), auth.Clone())
+ if errRefresh == nil {
+ t.Fatal("expected forced Refresh on unchanged rejected refresh_token without disk rotation to return an error, got nil")
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_cache_test.go b/internal/runtime/executor/codex_executor_cache_test.go
index 4c8f7034a45..09d95e67b2a 100644
--- a/internal/runtime/executor/codex_executor_cache_test.go
+++ b/internal/runtime/executor/codex_executor_cache_test.go
@@ -7,13 +7,13 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFromAPIKey(t *testing.T) {
@@ -41,7 +41,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
}
expectedKey := uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:test-api-key")).String()
- gotKey := gjson.GetBytes(body, "prompt_cache_key").String()
+ gotKey := ojson.GetBytes(body, "prompt_cache_key").String()
if gotKey != expectedKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, expectedKey)
}
@@ -63,7 +63,7 @@ func TestCodexExecutorCacheHelper_OpenAIChatCompletions_StablePromptCacheKeyFrom
if errRead2 != nil {
t.Fatalf("read request body (second call): %v", errRead2)
}
- gotKey2 := gjson.GetBytes(body2, "prompt_cache_key").String()
+ gotKey2 := ojson.GetBytes(body2, "prompt_cache_key").String()
if gotKey2 != expectedKey {
t.Fatalf("prompt_cache_key (second call) = %q, want %q", gotKey2, expectedKey)
}
@@ -84,7 +84,7 @@ func TestCodexExecutorCacheHelper_UsesDerivedSessionUUID(t *testing.T) {
if err != nil {
t.Fatalf("cacheHelper error: %v", err)
}
- if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != expectedKey {
+ if got := ojson.GetBytes(body, "prompt_cache_key").String(); got != expectedKey {
t.Fatalf("prompt_cache_key = %q, want %q", got, expectedKey)
}
if got := httpReq.Header.Get("Session-Id"); got != expectedKey {
@@ -134,8 +134,8 @@ func TestCodexExecutorCacheHelper_ClaudeUsesClaudeCodeSessionID(t *testing.T) {
if errRead != nil {
t.Fatalf("read second request body: %v", errRead)
}
- firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String()
- secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String()
+ firstKey := ojson.GetBytes(firstBody, "prompt_cache_key").String()
+ secondKey := ojson.GetBytes(secondBody, "prompt_cache_key").String()
if firstKey == "" {
t.Fatalf("first prompt_cache_key is empty; body=%s", string(firstBody))
}
@@ -166,7 +166,7 @@ func TestCodexExecutorCacheHelper_ClaudeRejectsBareUserID(t *testing.T) {
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
- if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "" {
+ if got := ojson.GetBytes(body, "prompt_cache_key").String(); got != "" {
t.Fatalf("bare metadata.user_id must not create prompt_cache_key, got %q; body=%s", got, string(body))
}
if got := httpReq.Header["Session-Id"]; len(got) != 0 {
@@ -227,8 +227,8 @@ func TestCodexExecutorCacheHelper_ClaudeUsesSessionHeader(t *testing.T) {
if errRead != nil {
t.Fatalf("read second request body: %v", errRead)
}
- firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String()
- secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String()
+ firstKey := ojson.GetBytes(firstBody, "prompt_cache_key").String()
+ secondKey := ojson.GetBytes(secondBody, "prompt_cache_key").String()
if firstKey == "" {
t.Fatalf("first prompt_cache_key is empty; body=%s", string(firstBody))
}
@@ -258,7 +258,7 @@ func TestCodexExecutorCacheHelper_ClaudeAgentScopeUsesResolvedModelAcrossHTTPAnd
if errReadRoot != nil {
t.Fatalf("read root body: %v", errReadRoot)
}
- rootKey := gjson.GetBytes(rootBody, "prompt_cache_key").String()
+ rootKey := ojson.GetBytes(rootBody, "prompt_cache_key").String()
childRequest, _, errChild := executor.cacheHelper(context.Background(), sdktranslator.FromString("claude"), url, req, rawJSON, nil, childHeaders)
if errChild != nil {
@@ -268,7 +268,7 @@ func TestCodexExecutorCacheHelper_ClaudeAgentScopeUsesResolvedModelAcrossHTTPAnd
if errReadChild != nil {
t.Fatalf("read child body: %v", errReadChild)
}
- childKey := gjson.GetBytes(childBody, "prompt_cache_key").String()
+ childKey := ojson.GetBytes(childBody, "prompt_cache_key").String()
if rootKey == "" || childKey == "" || rootKey == childKey {
t.Fatalf("agent prompt keys are not isolated: root=%q child=%q", rootKey, childKey)
}
@@ -283,7 +283,7 @@ func TestCodexExecutorCacheHelper_ClaudeAgentScopeUsesResolvedModelAcrossHTTPAnd
if errReadAlias != nil {
t.Fatalf("read alias body: %v", errReadAlias)
}
- if aliasKey := gjson.GetBytes(aliasBody, "prompt_cache_key").String(); aliasKey != childKey {
+ if aliasKey := ojson.GetBytes(aliasBody, "prompt_cache_key").String(); aliasKey != childKey {
t.Fatalf("resolved model key fragmented by request alias: first=%q alias=%q", childKey, aliasKey)
}
@@ -291,7 +291,7 @@ func TestCodexExecutorCacheHelper_ClaudeAgentScopeUsesResolvedModelAcrossHTTPAnd
if errWebsocket != nil {
t.Fatalf("websocket prompt cache error: %v", errWebsocket)
}
- if websocketKey := gjson.GetBytes(websocketBody, "prompt_cache_key").String(); websocketKey != childKey {
+ if websocketKey := ojson.GetBytes(websocketBody, "prompt_cache_key").String(); websocketKey != childKey {
t.Fatalf("HTTP/WebSocket prompt keys differ: http=%q websocket=%q", childKey, websocketKey)
}
}
diff --git a/internal/runtime/executor/codex_executor_claude_effort_test.go b/internal/runtime/executor/codex_executor_claude_effort_test.go
index c4c746548f8..9c1f3456539 100644
--- a/internal/runtime/executor/codex_executor_claude_effort_test.go
+++ b/internal/runtime/executor/codex_executor_claude_effort_test.go
@@ -8,10 +8,10 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorClaudeStandaloneEffortFinalPayload(t *testing.T) {
@@ -72,7 +72,7 @@ func TestCodexExecutorClaudeStandaloneEffortFinalPayload(t *testing.T) {
t.Fatalf("Execute: %v", errExecute)
}
body := <-bodies
- effort := gjson.GetBytes(body, "reasoning.effort")
+ effort := ojson.GetBytes(body, "reasoning.effort")
if effort.String() != tt.wantEffort || (tt.wantEffort == "" && effort.Exists()) {
t.Fatalf("upstream reasoning.effort = %s, want %q; body=%s", effort.Raw, tt.wantEffort, body)
}
diff --git a/internal/runtime/executor/codex_executor_claude_output_budget_test.go b/internal/runtime/executor/codex_executor_claude_output_budget_test.go
new file mode 100644
index 00000000000..871d5393830
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_claude_output_budget_test.go
@@ -0,0 +1,185 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/gorilla/websocket"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ _ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+func TestCodexClaudeModelSelectionMinimumOutputBudget(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
+ requests := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read upstream request: %v", errRead)
+ return
+ }
+ requests <- body
+ if ojson.GetBytes(body, "max_output_tokens").Int() < 16 {
+ http.Error(w, `Invalid 'max_output_tokens': integer below minimum value`, http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, errWrite := io.WriteString(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"gpt-5.6-sol\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hi\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n")
+ if errWrite != nil {
+ t.Errorf("write upstream response: %v", errWrite)
+ }
+ }))
+ t.Cleanup(server.Close)
+
+ executor := NewCodexExecutor(&config.Config{})
+ req := cliproxyexecutor.Request{
+ Model: "gpt-5.6-sol",
+ Payload: []byte(`{"model":"gpt-5.6-sol","max_tokens":1,"messages":[{"role":"user","content":[{"type":"text","text":"Hi","cache_control":{"type":"ephemeral"}}]}]}`),
+ }
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, Stream: stream}
+ if stream {
+ result, errStream := executor.ExecuteStream(context.Background(), codexOAuthTestAuth(server.URL), req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream: %v", errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk: %v", chunk.Err)
+ }
+ }
+ } else if _, errExecute := executor.Execute(context.Background(), codexOAuthTestAuth(server.URL), req, opts); errExecute != nil {
+ t.Fatalf("Execute: %v", errExecute)
+ }
+
+ body := <-requests
+ if got := ojson.GetBytes(body, "max_output_tokens").Int(); got != 16 {
+ t.Fatalf("upstream max_output_tokens = %d, want 16; body=%s", got, body)
+ }
+ })
+ }
+}
+
+func TestCodexClaudeOutputBudgetBoundariesAndPayloadRules(t *testing.T) {
+ modelRule := []config.PayloadModelRule{{Name: "gpt-5.6-sol", Protocol: "codex"}}
+ matchedRule := []config.PayloadModelRule{{
+ Name: "gpt-5.6-sol", Protocol: "codex", Match: []map[string]any{{"max_output_tokens": 16}},
+ }}
+ cases := []struct {
+ name string
+ field string
+ from sdktranslator.Format
+ payload config.PayloadConfig
+ want string
+ }{
+ {name: "one", field: `,"max_tokens":1`, want: "16"},
+ {name: "fifteen", field: `,"max_tokens":15`, want: "16"},
+ {name: "sixteen", field: `,"max_tokens":16`, want: "16"},
+ {name: "above minimum", field: `,"max_tokens":17`, want: "17"},
+ {name: "normal budget", field: `,"max_tokens":1024`, want: "1024"},
+ {name: "absent"},
+ {name: "fallback field", field: `,"max_output_tokens":1`, want: "16"},
+ {name: "zero remains invalid", field: `,"max_tokens":0`, want: "0"},
+ {name: "negative remains invalid", field: `,"max_tokens":-1`, want: "-1"},
+ {name: "fractional input is not normalized", field: `,"max_tokens":1.5`, want: "1"},
+ {name: "non-numeric input", field: `,"max_tokens":"1"`},
+ {
+ name: "override matches normalized value", field: `,"max_tokens":1`, want: "8",
+ payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: matchedRule, Params: map[string]any{"max_output_tokens": 8}}}},
+ },
+ {
+ name: "final filter wins", field: `,"max_tokens":1`,
+ payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: modelRule, Params: []string{"max_output_tokens"}}}},
+ },
+ {name: "native responses unchanged", field: `,"max_output_tokens":1`, from: sdktranslator.FormatOpenAIResponse, want: "1"},
+ }
+ for _, tc := range cases {
+ for _, useWebsocket := range []bool{false, true} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/websocket=%t/stream=%t", tc.name, useWebsocket, stream), func(t *testing.T) {
+ requests := make(chan []byte, 1)
+ completed := `{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","model":"gpt-5.6-sol","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if useWebsocket {
+ upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+ conn, errUpgrade := upgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Errorf("upgrade: %v", errUpgrade)
+ return
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Errorf("close websocket: %v", errClose)
+ }
+ }()
+ _, body, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Errorf("read websocket request: %v", errRead)
+ return
+ }
+ requests <- body
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(completed)); errWrite != nil {
+ t.Errorf("write websocket response: %v", errWrite)
+ }
+ return
+ }
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read HTTP request: %v", errRead)
+ return
+ }
+ requests <- body
+ w.Header().Set("Content-Type", "text/event-stream")
+ if _, errWrite := io.WriteString(w, "data: "+completed+"\n\n"); errWrite != nil {
+ t.Errorf("write HTTP response: %v", errWrite)
+ }
+ }))
+ t.Cleanup(server.Close)
+
+ cfg := &config.Config{Payload: tc.payload}
+ var executor cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg)
+ if useWebsocket {
+ executor = NewCodexWebsocketsExecutor(cfg)
+ }
+ from := tc.from
+ if from == "" {
+ from = sdktranslator.FormatClaude
+ }
+ payload := `{"model":"gpt-5.6-sol"` + tc.field + `,"messages":[{"role":"user","content":"Hi"}]}`
+ if from == sdktranslator.FormatOpenAIResponse {
+ payload = `{"model":"gpt-5.6-sol"` + tc.field + `,"input":"Hi"}`
+ }
+ req := cliproxyexecutor.Request{Model: "gpt-5.6-sol", Payload: []byte(payload)}
+ opts := cliproxyexecutor.Options{SourceFormat: from, Stream: stream}
+ auth := codexOAuthTestAuth(server.URL)
+ auth.ID = "codex-output-budget-test"
+ if stream {
+ result, errStream := executor.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream: %v", errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk: %v", chunk.Err)
+ }
+ }
+ } else if _, errExecute := executor.Execute(context.Background(), auth, req, opts); errExecute != nil {
+ t.Fatalf("Execute: %v", errExecute)
+ }
+ body := <-requests
+ if got := ojson.GetBytes(body, "max_output_tokens").Raw; got != tc.want {
+ t.Fatalf("upstream max_output_tokens = %q, want %q; body=%s", got, tc.want, body)
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_commentary_test.go b/internal/runtime/executor/codex_executor_commentary_test.go
new file mode 100644
index 00000000000..a29e582ab1b
--- /dev/null
+++ b/internal/runtime/executor/codex_executor_commentary_test.go
@@ -0,0 +1,110 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ _ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+func TestCodexExecutorCommentaryConfigStreamAndNonStream(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "")
+ for _, enabled := range []bool{false, true} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("visible_%t/stream_%t", enabled, stream), func(t *testing.T) {
+ var requestItems atomic.Int64
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Error(errRead)
+ return
+ }
+ requestItems.Store(int64(len(ojson.GetBytes(body, "input").Array())))
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, errWrite := io.WriteString(w, `data: {"type":"response.created","response":{"id":"r","model":"gpt-6.1-sol"}}
+
+data: {"type":"response.output_item.added","item":{"type":"message","role":"assistant","phase":"commentary"}}
+
+data: {"type":"response.output_text.delta","delta":"Public progress"}
+
+data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Public progress"}]}}
+
+data: {"type":"response.output_item.added","item":{"type":"message","role":"assistant","phase":"final_answer"}}
+
+data: {"type":"response.output_text.delta","delta":"Final answer"}
+
+data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"Final answer"}]}}
+
+data: {"type":"response.completed","response":{"id":"r","model":"gpt-6.1-sol","status":"completed","output":[{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Public progress"}]},{"type":"message","role":"assistant","phase":"final_answer","content":[{"type":"output_text","text":"Final answer"}]}],"usage":{"input_tokens":5,"output_tokens":7}}}
+
+`)
+ if errWrite != nil {
+ t.Error(errWrite)
+ }
+ }))
+ t.Cleanup(server.Close)
+ cfg := &config.Config{}
+ cfg.Client.Claude.ShowCodexCommentary = enabled
+ exec := NewCodexExecutor(cfg)
+ auth := &cliproxyauth.Auth{Attributes: map[string]string{"base_url": server.URL, "api_key": "test"}}
+ req := cliproxyexecutor.Request{Model: "gpt-6.1-sol", Payload: []byte(`{"model":"gpt-6.1-sol","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"Previous progress","signature":"cpa:commentary:v1"},{"type":"text","text":"Previous progress"}]},{"role":"user","content":"Status?"}]}`)}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude, Stream: stream}
+ var text strings.Builder
+ if stream {
+ result, err := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatal(chunk.Err)
+ }
+ for _, line := range strings.Split(string(chunk.Payload), "\n") {
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ item := ojson.Parse(strings.TrimPrefix(line, "data: "))
+ if item.Get("delta.type").String() == "text_delta" {
+ text.WriteString(item.Get("delta.text").String())
+ }
+ }
+ }
+ } else {
+ result, err := exec.Execute(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, block := range ojson.GetBytes(result.Payload, "content").Array() {
+ if block.Get("type").String() == "text" {
+ text.WriteString(block.Get("text").String())
+ }
+ }
+ if ojson.GetBytes(result.Payload, "usage.output_tokens").Int() != 7 {
+ t.Fatal("display mirroring changed output token accounting")
+ }
+ }
+ if strings.Contains(text.String(), "Public progress") != enabled || !strings.Contains(text.String(), "Final answer") {
+ t.Fatalf("visible=%t: unexpected text %q", enabled, text.String())
+ }
+ wantItems := 3
+ if enabled {
+ wantItems = 2
+ }
+ if gotItems := requestItems.Load(); gotItems != int64(wantItems) {
+ t.Fatalf("request replay items=%d, want %d", gotItems, wantItems)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/runtime/executor/codex_executor_compact_test.go b/internal/runtime/executor/codex_executor_compact_test.go
index 1ca9b367a0e..abdadae93be 100644
--- a/internal/runtime/executor/codex_executor_compact_test.go
+++ b/internal/runtime/executor/codex_executor_compact_test.go
@@ -10,13 +10,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorCompactAddsDefaultInstructionsWithoutInjectingImageTool(t *testing.T) {
@@ -67,13 +67,13 @@ func TestCodexExecutorCompactAddsDefaultInstructionsWithoutInjectingImageTool(t
if gotPath != "/responses/compact" {
t.Fatalf("path = %q, want %q", gotPath, "/responses/compact")
}
- if instructions := gjson.GetBytes(gotBody, "instructions"); instructions.Type != gjson.String || instructions.String() != "" {
+ if instructions := ojson.GetBytes(gotBody, "instructions"); instructions.Type != ojson.String || instructions.String() != "" {
t.Fatalf("instructions = %s, want empty string; body=%s", instructions.Raw, gotBody)
}
- if gjson.GetBytes(gotBody, "tools").Exists() {
+ if ojson.GetBytes(gotBody, "tools").Exists() {
t.Fatalf("compact request injected image_generation tool: %s", gotBody)
}
- input := gjson.GetBytes(gotBody, "input").Array()
+ input := ojson.GetBytes(gotBody, "input").Array()
if len(input) != 2 || input[1].Get("type").String() != "compaction_trigger" {
t.Fatalf("compact input order changed: %s", gotBody)
}
@@ -147,20 +147,23 @@ func TestCodexExecutorCompactUseSIWCAppliesSIWCRulesBeforePayloadFinalizer(t *te
if gotHeaders.Get("Chatgpt-Account-Id") != "" || gotHeaders.Get("Originator") != "" {
t.Fatalf("unexpected Codex headers on SIWC compact request: %v", gotHeaders)
}
- for _, omitted := range []string{"instructions", "max_output_tokens", "prompt_cache_retention", "prompt_cache_options", "stream", "store"} {
- if gjson.GetBytes(gotBody, omitted).Exists() {
+ if maxOutputTokens := ojson.GetBytes(gotBody, "max_output_tokens").Int(); maxOutputTokens != 512 {
+ t.Fatalf("max_output_tokens = %d, want 512; body=%s", maxOutputTokens, gotBody)
+ }
+ for _, omitted := range []string{"instructions", "prompt_cache_retention", "prompt_cache_options", "stream", "store"} {
+ if ojson.GetBytes(gotBody, omitted).Exists() {
t.Fatalf("field %q should be omitted in SIWC compact body: %s", omitted, gotBody)
}
}
// PayloadFinalizer runs last and can override temperature after built-in SIWC stripping.
- if gotTemp := gjson.GetBytes(gotBody, "temperature").Float(); gotTemp != 0.25 {
+ if gotTemp := ojson.GetBytes(gotBody, "temperature").Float(); gotTemp != 0.25 {
t.Fatalf("PayloadFinalizer temperature override = %v, want 0.25; body=%s", gotTemp, gotBody)
}
}
func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770(t *testing.T) {
hasWebSearchCallInInput := func(body []byte) bool {
- for _, item := range gjson.GetBytes(body, "input").Array() {
+ for _, item := range ojson.GetBytes(body, "input").Array() {
if item.Get("type").String() == "web_search_call" {
return true
}
@@ -169,7 +172,7 @@ func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770
}
countWebSearchTools := func(body []byte) int {
count := 0
- for _, tool := range gjson.GetBytes(body, "tools").Array() {
+ for _, tool := range ojson.GetBytes(body, "tools").Array() {
if tool.Get("type").String() == "web_search" {
count++
}
@@ -258,7 +261,7 @@ func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770
if got := countWebSearchTools(gotBody); got != 1 {
t.Fatalf("web_search tool count = %d, want 1; body=%s", got, gotBody)
}
- if got := len(gjson.GetBytes(gotBody, "tools").Array()); got != 1 {
+ if got := len(ojson.GetBytes(gotBody, "tools").Array()); got != 1 {
t.Fatalf("tools len = %d, want 1; body=%s", got, gotBody)
}
})
@@ -351,7 +354,7 @@ func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770
if got := countWebSearchTools(prepared.clientBody); got != 1 {
t.Fatalf("web_search tool count = %d, want 1; body=%s", got, prepared.clientBody)
}
- if !gjson.GetBytes(prepared.clientBody, "tools.0.external_web_access").Bool() {
+ if !ojson.GetBytes(prepared.clientBody, "tools.0.external_web_access").Bool() {
t.Fatalf("existing web_search options not preserved: %s", prepared.clientBody)
}
})
@@ -380,13 +383,13 @@ func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770
if err != nil {
t.Fatalf("prepareCodexWebsocketStream() error = %v", err)
}
- if gjson.GetBytes(prepared.clientBody, "tools").Exists() {
+ if ojson.GetBytes(prepared.clientBody, "tools").Exists() {
t.Fatalf("Responses Lite request must not inject top-level tools: %s", prepared.clientBody)
}
if hasWebSearchCallInInput(prepared.clientBody) {
t.Fatalf("Responses Lite request must strip replayed web_search_call items: %s", prepared.clientBody)
}
- inputItems := gjson.GetBytes(prepared.clientBody, "input").Array()
+ inputItems := ojson.GetBytes(prepared.clientBody, "input").Array()
if len(inputItems) != 5 {
t.Fatalf("input len = %d, want 5; body=%s", len(inputItems), prepared.clientBody)
}
@@ -433,7 +436,7 @@ func TestCodexExecutorReplayedWebSearchCallHistoryEnsuresWebSearchTool_Issue6770
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if gjson.GetBytes(gotBody, "tools").Exists() {
+ if ojson.GetBytes(gotBody, "tools").Exists() {
t.Fatalf("expected user payload filter rule to remove tools after built-in injection, got body=%s", gotBody)
}
})
@@ -457,7 +460,7 @@ func TestCodexExecutorExpandsRecognizedCPACompactionCapsules_Issue7118(t *testin
if bytes.Contains(body, []byte("cpa-ag-compact-v1:")) || bytes.Contains(body, []byte("cmp_ag_compact_1")) {
t.Fatalf("CPA compaction capsule leaked to Codex upstream: %s", body)
}
- input := gjson.GetBytes(body, "input").Array()
+ input := ojson.GetBytes(body, "input").Array()
if len(input) != 3 {
t.Fatalf("input len = %d, want 3; body=%s", len(input), body)
}
@@ -604,7 +607,7 @@ func TestCodexExecutorExpandsRecognizedCPACompactionCapsules_Issue7118(t *testin
if errExpandedCount != nil {
t.Fatalf("CountTokens(expanded) error = %v", errExpandedCount)
}
- if gjson.GetBytes(expandedCountResp.Payload, "response.usage.input_tokens").Int() <= gjson.GetBytes(baseCountResp.Payload, "response.usage.input_tokens").Int() {
+ if ojson.GetBytes(expandedCountResp.Payload, "response.usage.input_tokens").Int() <= ojson.GetBytes(baseCountResp.Payload, "response.usage.input_tokens").Int() {
t.Fatalf("expected expanded capsule token count (%s) > base token count (%s)", expandedCountResp.Payload, baseCountResp.Payload)
}
})
@@ -768,7 +771,7 @@ func TestCodexExecutorExpandsRecognizedCPACompactionCapsules_Issue7118(t *testin
sawStreamErr = true
break
}
- evType := gjson.GetBytes(chunk.Payload, "type").String()
+ evType := ojson.GetBytes(chunk.Payload, "type").String()
switch evType {
case "response.created":
inputCh <- cliproxyexecutor.WebsocketInput{Payload: []byte(steerPayload)}
@@ -794,7 +797,7 @@ func TestCodexExecutorExpandsRecognizedCPACompactionCapsules_Issue7118(t *testin
if bytes.Contains(frame, []byte("cpa-ag-compact-v1:")) || bytes.Contains(frame, []byte("cmp_ag_compact_")) {
t.Fatalf("frame[%d] leaked CPA compaction capsule: %s", idx, frame)
}
- input := gjson.GetBytes(frame, "input").Array()
+ input := ojson.GetBytes(frame, "input").Array()
if len(input) != 3 {
t.Fatalf("frame[%d] input len = %d, want 3; frame=%s", idx, len(input), frame)
}
diff --git a/internal/runtime/executor/codex_executor_compression_review_test.go b/internal/runtime/executor/codex_executor_compression_review_test.go
index a09cbfe9d95..b655e300820 100644
--- a/internal/runtime/executor/codex_executor_compression_review_test.go
+++ b/internal/runtime/executor/codex_executor_compression_review_test.go
@@ -9,10 +9,10 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/klauspost/compress/zstd"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
func TestCodexImageCompressionRejectedFallback(t *testing.T) {
@@ -42,7 +42,7 @@ func TestCodexImageCompressionRejectedFallback(t *testing.T) {
if !direct && !bytes.Equal(wire, firstPlain) {
t.Error("retry changed the final business payload")
}
- if got := gjson.GetBytes(wire, "audit").Raw; got != `["once"]` {
+ if got := ojson.GetBytes(wire, "audit").Raw; got != `["once"]` {
t.Errorf("payload rules must run exactly once: audit=%s", got)
}
if got := req.Header.Get("Authorization"); got != "Bearer test-oauth" {
diff --git a/internal/runtime/executor/codex_executor_compression_test.go b/internal/runtime/executor/codex_executor_compression_test.go
index 379577368ed..cab3dff7a9a 100644
--- a/internal/runtime/executor/codex_executor_compression_test.go
+++ b/internal/runtime/executor/codex_executor_compression_test.go
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -20,7 +21,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func codexCompressionConfig() *config.Config {
@@ -222,7 +222,7 @@ func TestCodexExecutor_CompressedUpstreamResponseDecoded(t *testing.T) {
t.Fatal(errExecute)
}
headers = result.Headers
- if !gjson.ValidBytes(result.Payload) || gjson.GetBytes(result.Payload, "id").String() != "resp_1" {
+ if !ojson.ValidBytes(result.Payload) || ojson.GetBytes(result.Payload, "id").String() != "resp_1" {
t.Fatalf("invalid decoded payload: %s", result.Payload)
}
}
@@ -264,7 +264,7 @@ func TestCodexExecutor_CompressionRejectedFallback(t *testing.T) {
if len(encodings) > 1 && !bytes.Equal(wire, firstPlain) {
t.Error("retry changed the final business payload")
}
- if got := gjson.GetBytes(wire, "audit").Raw; got != `["once"]` {
+ if got := ojson.GetBytes(wire, "audit").Raw; got != `["once"]` {
t.Errorf("payload rules must run exactly once: audit=%s", got)
}
if r.Header.Get(codexRoutingHintHeader) == "" || r.Header.Get("Authorization") != "Bearer test-oauth" {
@@ -505,7 +505,7 @@ func TestCodexUpstreamCompression_DirectImagePayloadRewrite(t *testing.T) {
t.Errorf("rewritten image body retained Content-Encoding=%q", got)
}
body := readCodexCompressionBody(t, req.Body)
- if !gjson.ValidBytes(body) || gjson.GetBytes(body, "prompt").String() != "operator-prompt" || gjson.GetBytes(body, "audit").Raw != `["once"]` {
+ if !ojson.ValidBytes(body) || ojson.GetBytes(body, "prompt").String() != "operator-prompt" || ojson.GetBytes(body, "audit").Raw != `["once"]` {
t.Errorf("image payload finalization was not preserved: %s", body)
}
output := `{"created":1713833628,"data":[{"b64_json":"AA=="}]}`
diff --git a/internal/runtime/executor/codex_executor_execute.go b/internal/runtime/executor/codex_executor_execute.go
index 2c3fbb36e90..31b4c909ce8 100644
--- a/internal/runtime/executor/codex_executor_execute.go
+++ b/internal/runtime/executor/codex_executor_execute.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
@@ -14,12 +15,11 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
+ ctx = helps.WithCodexCommentaryPolicy(ctx, e.cfg)
if errExpand := helps.ExpandRecognizedResponsesCompactionCapsules(&req, &opts); errExpand != nil {
return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
}
@@ -52,59 +52,72 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntentContext(ctx, from, to, targetModel, originalPayload, req.Payload, false, isCompat)
if err != nil {
return resp, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
+ doc := ojson.NewDoc(body)
+ normalizeCodexClaudeOutputBudgetDoc(doc, from, req.Payload)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return resp, err
}
ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts))
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.SetBoolIfDifferent(body, "stream", true)
- body, _ = sjson.DeleteBytes(body, "previous_response_id")
- body, _ = sjson.DeleteBytes(body, "generate")
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "safety_identifier")
- body, _ = sjson.DeleteBytes(body, "stream_options")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
+ _ = doc.Delete("previous_response_id")
+ _ = doc.Delete("generate")
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ _ = doc.Delete("stream_options")
useSIWC := codexUseSIWCForRequest(ctx, auth, opts.Headers)
- hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
- body = ensureWebSearchToolForHistory(body, opts.Headers)
+ instructionsResult := ojson.GetBytesNoCopy(req.Payload, "instructions")
+ hasExplicitInstructions := instructionsResult.Exists() && strings.TrimSpace(instructionsResult.String()) != ""
+ ensureWebSearchToolForHistoryDoc(doc, opts.Headers)
if !useSIWC {
- body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts))
+ normalizeCodexInstructionsDoc(doc, helps.IsNativeCodexRequest(req.Payload, opts))
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
- body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
+ ensureImageGenerationToolDoc(doc, baseModel, auth, opts.Headers)
+ if !codexAuthUsesAPIKey(auth) {
+ normalizeCodexImageGenerationToolsDoc(doc)
+ }
}
} else {
- body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
+ applyCodexSIWCRequestBodyRulesDoc(doc, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
- body = normalizeCodexParallelToolCalls(body, opts.Headers)
- body = helps.NormalizeCodexToolSchemas(body)
- body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
- body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
+ normalizeCodexParallelToolCallsDoc(doc, opts.Headers)
+ helps.NormalizeCodexToolSchemasDoc(doc)
+ optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuthDoc(doc, ctx, opts.Headers, e.cfg, auth, isCompat)
+ replayScope, _, errReplay := applyCodexReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
if errReplay != nil {
return resp, errReplay
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, targetModel, baseURL)
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "codex executor", doc, isCompat, targetModel, baseURL)
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, auth, opts.Headers)
+ httpReq, upstreamBody, err := e.cacheHelperDoc(ctx, from, url, req, doc, auth, opts.Headers)
if err != nil {
return resp, err
}
+ body = upstreamBody
+ upstreamModel := strings.TrimSpace(doc.Get("model").String())
+ if upstreamModel != "" && upstreamModel != baseModel {
+ reporter.SetUpstreamModel(upstreamModel)
+ }
reporter.SetTranslatedReasoningEffort(upstreamBody, to.String())
prepareHeaders := func(request *http.Request) {
applyCodexHeaders(request, auth, apiKey, true, e.cfg, opts.Headers)
- applyCodexRoutingHint(ctx, request.Header, auth, baseModel, upstreamBody, opts.Headers)
- applyModelHeaderOverrides(request.Header, baseModel)
+ applyCodexRoutingHint(ctx, request.Header, auth, upstreamModel, upstreamBody, opts.Headers)
+ applyModelHeaderOverrides(request.Header, upstreamModel)
alignCodexContentEncoding(request)
}
prepareHeaders(httpReq)
+ if errValidate := helps.ValidateCodexHostedImageGenerationDoc(doc, httpReq.Header, e.cfg, !useSIWC && !codexAuthUsesAPIKey(auth)); errValidate != nil {
+ return resp, errValidate
+ }
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -161,7 +174,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
eventData := bytes.TrimSpace(line[5:])
eventData = helps.RestoreCodexMultiAgentV2Response(eventData, optimizeMultiAgentV2)
reporter.ObserveResponseModel(eventData)
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
if helps.HasMeaningfulCodexOutputDelta(eventData) {
sawOutputDelta = true
@@ -176,11 +189,11 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
}
if eventType == "response.output_item.done" {
- itemResult := gjson.GetBytes(eventData, "item")
- if !itemResult.Exists() || itemResult.Type != gjson.JSON {
+ itemResult := ojson.GetBytesNoCopy(eventData, "item")
+ if !itemResult.Exists() || itemResult.Type != ojson.JSON {
continue
}
- outputIndexResult := gjson.GetBytes(eventData, "output_index")
+ outputIndexResult := ojson.GetBytesNoCopy(eventData, "output_index")
if outputIndexResult.Exists() {
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
} else {
@@ -257,37 +270,39 @@ func (e *CodexExecutor) executeCompact(ctx context.Context, auth *cliproxyauth.A
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntentContext(ctx, from, to, targetModel, originalPayload, req.Payload, false, isCompat)
if err != nil {
return resp, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return resp, err
}
ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts))
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body, _ = sjson.DeleteBytes(body, "stream")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ _ = doc.Delete("stream")
if !codexUseSIWCForRequest(ctx, auth, opts.Headers) {
- body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts))
+ normalizeCodexInstructionsDoc(doc, helps.IsNativeCodexRequest(req.Payload, opts))
} else {
- hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
- body = applyCodexSIWCCompactBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
- }
- body = ensureWebSearchToolForHistory(body, opts.Headers)
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL)
- body = normalizeCodexParallelToolCalls(body, opts.Headers)
- body = helps.NormalizeCodexToolSchemas(body)
- body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
+ instructionsResult := ojson.GetBytesNoCopy(req.Payload, "instructions")
+ hasExplicitInstructions := instructionsResult.Exists() && strings.TrimSpace(instructionsResult.String()) != ""
+ applyCodexSIWCCompactBodyRulesDoc(doc, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
+ }
+ ensureWebSearchToolForHistoryDoc(doc, opts.Headers)
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "codex executor", doc, isCompat, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL)
+ normalizeCodexParallelToolCallsDoc(doc, opts.Headers)
+ helps.NormalizeCodexToolSchemasDoc(doc)
+ optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuthDoc(doc, ctx, opts.Headers, e.cfg, auth, isCompat)
url := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
- httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, auth, opts.Headers)
+ httpReq, upstreamBody, err := e.cacheHelperDoc(ctx, from, url, req, doc, auth, opts.Headers)
if err != nil {
return resp, err
}
+ body = upstreamBody
reporter.SetTranslatedReasoningEffort(upstreamBody, to.String())
prepareHeaders := func(request *http.Request) {
applyCodexHeaders(request, auth, apiKey, false, e.cfg, opts.Headers)
diff --git a/internal/runtime/executor/codex_executor_execute_usage_test.go b/internal/runtime/executor/codex_executor_execute_usage_test.go
index e289a4444dd..18fd2f1af27 100644
--- a/internal/runtime/executor/codex_executor_execute_usage_test.go
+++ b/internal/runtime/executor/codex_executor_execute_usage_test.go
@@ -8,12 +8,12 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorExecutePublishesMainUsageBeforeImageUsage(t *testing.T) {
@@ -141,7 +141,7 @@ func TestCodexExecutorExecuteRecordsFinalizedPayloadOverrideReasoningEffort(t *t
t.Fatal("Execute returned an empty response")
}
- if effort := gjson.GetBytes(upstreamBody, "reasoning.effort").String(); effort != "high" {
+ if effort := ojson.GetBytes(upstreamBody, "reasoning.effort").String(); effort != "high" {
t.Fatalf("upstream payload reasoning.effort = %q, want %q", effort, "high")
}
@@ -198,7 +198,7 @@ func TestCodexExecutorExecuteStreamRecordsFinalizedPayloadOverrideReasoningEffor
for range streamRes.Chunks {
}
- if effort := gjson.GetBytes(upstreamBody, "reasoning.effort").String(); effort != "high" {
+ if effort := ojson.GetBytes(upstreamBody, "reasoning.effort").String(); effort != "high" {
t.Fatalf("upstream payload reasoning.effort = %q, want %q", effort, "high")
}
@@ -256,7 +256,7 @@ func TestCodexExecutorExecuteCompactRecordsFinalizedPayloadOverrideReasoningEffo
t.Fatal("Execute returned an empty response")
}
- if effort := gjson.GetBytes(upstreamBody, "reasoning.effort").String(); effort != "high" {
+ if effort := ojson.GetBytes(upstreamBody, "reasoning.effort").String(); effort != "high" {
t.Fatalf("upstream payload reasoning.effort = %q, want %q", effort, "high")
}
diff --git a/internal/runtime/executor/codex_executor_imagegen_test.go b/internal/runtime/executor/codex_executor_imagegen_test.go
index 43a84b954bd..405a2d158db 100644
--- a/internal/runtime/executor/codex_executor_imagegen_test.go
+++ b/internal/runtime/executor/codex_executor_imagegen_test.go
@@ -5,13 +5,15 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorExecuteResponsesLiteHeaderDoesNotInjectImageGenerationTool(t *testing.T) {
@@ -49,10 +51,10 @@ func TestCodexExecutorExecuteResponsesLiteHeaderDoesNotInjectImageGenerationTool
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if tools := gjson.GetBytes(gotBody, "tools"); tools.Exists() {
+ if tools := ojson.GetBytes(gotBody, "tools"); tools.Exists() {
t.Fatalf("unexpected tools in responses-lite upstream payload: %s", tools.Raw)
}
- parallelToolCalls := gjson.GetBytes(gotBody, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(gotBody, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", gotBody)
}
@@ -99,7 +101,7 @@ func TestCodexExecutorExecuteStreamResponsesLiteHeaderForcesParallelToolCallsFal
}
}
- parallelToolCalls := gjson.GetBytes(gotBody, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(gotBody, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", gotBody)
}
@@ -112,8 +114,8 @@ func TestEnsureImageGenerationTool_ResponsesLiteMetadataDoesNotInjectTool(t *tes
if string(result) != string(body) {
t.Fatalf("expected responses-lite body to be unchanged, got %s", string(result))
}
- if gjson.GetBytes(result, "tools").Exists() {
- t.Fatalf("expected no injected tools for responses-lite request, got %s", gjson.GetBytes(result, "tools").Raw)
+ if ojson.GetBytes(result, "tools").Exists() {
+ t.Fatalf("expected no injected tools for responses-lite request, got %s", ojson.GetBytes(result, "tools").Raw)
}
}
@@ -138,19 +140,29 @@ func TestEnsureImageGenerationTool_ResponsesLiteHeaderDoesNotInjectTool(t *testi
}
func TestEnsureImageGenerationTool_ResponsesLiteFalseMetadataStillInjectsTool(t *testing.T) {
- body := []byte(`{"model":"gpt-5.6-sol","client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"false"},"input":"hello"}`)
- result := ensureImageGenerationTool(body, "gpt-5.6-sol", nil, nil)
+ body := []byte(`{"model":"gpt-5.4","client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"false"},"input":"hello"}`)
+ result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- if got := gjson.GetBytes(result, "tools.0.type").String(); got != "image_generation" {
+ if got := ojson.GetBytes(result, "tools.0.type").String(); got != "image_generation" {
t.Fatalf("tools.0.type = %q, want image_generation; body=%s", got, result)
}
+
+ for _, liteModel := range []string{"gpt-6-astra", "gpt-5.6-sol"} {
+ liteBody := []byte(`{"model":"` + liteModel + `","client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"false"},"input":"hello"}`)
+ liteHeaders := make(http.Header)
+ liteHeaders.Set(codexResponsesLiteHeader, "false")
+ liteResult := ensureImageGenerationTool(liteBody, liteModel, nil, liteHeaders)
+ if ojson.GetBytes(liteResult, "tools").Exists() {
+ t.Fatalf("for %s: expected no auto-injected tools even when responses-lite is explicitly false, got %s", liteModel, liteResult)
+ }
+ }
}
func TestEnsureImageGenerationTool_NoTools(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","input":"draw a cat"}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- tools := gjson.GetBytes(result, "tools")
+ tools := ojson.GetBytes(result, "tools")
if !tools.IsArray() {
t.Fatalf("expected tools array, got %v", tools.Type)
}
@@ -170,7 +182,7 @@ func TestEnsureImageGenerationTool_ExistingToolsWithoutImageGen(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"get_weather","parameters":{}}]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- tools := gjson.GetBytes(result, "tools")
+ tools := ojson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 2 {
t.Fatalf("expected 2 tools, got %d", len(arr))
@@ -187,7 +199,7 @@ func TestEnsureImageGenerationTool_AlreadyPresent(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","output_format":"webp"},{"type":"function","name":"f1"}]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- tools := gjson.GetBytes(result, "tools")
+ tools := ojson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 2 {
t.Fatalf("expected 2 tools (no duplicate), got %d", len(arr))
@@ -219,7 +231,7 @@ func TestEnsureImageGenerationTool_SimilarNamespaceStillInjectsTool(t *testing.T
body := []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"image_tools","tools":[{"type":"function","name":"imagegen","parameters":{}}]}]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- tools := gjson.GetBytes(result, "tools").Array()
+ tools := ojson.GetBytes(result, "tools").Array()
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d", len(tools))
}
@@ -232,7 +244,7 @@ func TestEnsureImageGenerationTool_EmptyToolsArray(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- tools := gjson.GetBytes(result, "tools")
+ tools := ojson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 1 {
t.Fatalf("expected 1 tool, got %d", len(arr))
@@ -246,7 +258,7 @@ func TestEnsureImageGenerationTool_WebSearchAndImageGen(t *testing.T) {
body := []byte(`{"model":"gpt-5.4","tools":[{"type":"web_search"}]}`)
result := ensureImageGenerationTool(body, "gpt-5.4", nil, nil)
- tools := gjson.GetBytes(result, "tools")
+ tools := ojson.GetBytes(result, "tools")
arr := tools.Array()
if len(arr) != 2 {
t.Fatalf("expected 2 tools, got %d", len(arr))
@@ -266,8 +278,8 @@ func TestEnsureImageGenerationTool_GPT53CodexSparkDoesNotInjectTool(t *testing.T
if string(result) != string(body) {
t.Fatalf("expected body to be unchanged, got %s", string(result))
}
- if gjson.GetBytes(result, "tools").Exists() {
- t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", gjson.GetBytes(result, "tools").Raw)
+ if ojson.GetBytes(result, "tools").Exists() {
+ t.Fatalf("expected no tools for gpt-5.3-codex-spark, got %s", ojson.GetBytes(result, "tools").Raw)
}
}
@@ -282,7 +294,386 @@ func TestEnsureImageGenerationTool_FreeCodexAuthDoesNotInjectTool(t *testing.T)
if string(result) != string(body) {
t.Fatalf("expected body to be unchanged, got %s", string(result))
}
- if gjson.GetBytes(result, "tools").Exists() {
- t.Fatalf("expected no tools for free codex auth, got %s", gjson.GetBytes(result, "tools").Raw)
+ if ojson.GetBytes(result, "tools").Exists() {
+ t.Fatalf("expected no tools for free codex auth, got %s", ojson.GetBytes(result, "tools").Raw)
+ }
+}
+
+func TestEnsureImageGenerationTool_ResponsesLiteModelsDoNotInjectToolByDefault(t *testing.T) {
+ for _, model := range []string{
+ "gpt-6-astra",
+ "gpt-6-sol",
+ "gpt-6.1-sol",
+ "gpt-6.1-terra",
+ "gpt-6-luna",
+ "gpt-6-chat-thinking",
+ "gpt-5.6-sol",
+ "gpt-5.6-terra",
+ "gpt-5.6-luna",
+ "gpt-reserve",
+ "codex-auto-review",
+ "codex/gpt-6-astra(medium)",
+ "codex/gpt-5.6-sol(high)",
+ } {
+ t.Run(model, func(t *testing.T) {
+ body := []byte(`{"model":"` + model + `","input":"hello"}`)
+ result := ensureImageGenerationTool(body, model, nil, nil)
+ if ojson.GetBytes(result, "tools").Exists() {
+ t.Fatalf("expected no auto-injected tools for %s, got %s", model, result)
+ }
+ })
+ }
+
+ for _, nonLiteModel := range []string{"gpt-5.5", "gpt-5.4"} {
+ t.Run("non-lite/"+nonLiteModel, func(t *testing.T) {
+ body := []byte(`{"model":"` + nonLiteModel + `","input":"hello"}`)
+ result := ensureImageGenerationTool(body, nonLiteModel, nil, nil)
+ if got := ojson.GetBytes(result, "tools.0.type").String(); got != "image_generation" {
+ t.Fatalf("for %s: tools.0.type = %q, want image_generation; body=%s", nonLiteModel, got, result)
+ }
+ })
+ }
+}
+
+func TestCodexExecutorHostedImageGenerationSIWCAndAPIKeyPreserveUpstreamModelAndParams(t *testing.T) {
+ for _, credKind := range []string{"api_key", "siwc"} {
+ for _, transport := range []string{"http", "websocket"} {
+ for _, stream := range []bool{false, true} {
+ streamLabel := "false"
+ if stream {
+ streamLabel = "true"
+ }
+ t.Run(credKind+"/"+transport+"/stream="+streamLabel, func(t *testing.T) {
+ capturedBody := make(chan []byte, 1)
+ completedEvent := []byte(`{"type":"response.completed","response":{"id":"resp_siwc_apikey_7270","object":"response","status":"completed","model":"gpt-6-astra","output":[],"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if transport == "websocket" {
+ upgrader := websocket.Upgrader{}
+ conn, errUpgrade := upgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Errorf("upgrade websocket: %v", errUpgrade)
+ return
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+ _, msg, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Errorf("read websocket message: %v", errRead)
+ return
+ }
+ capturedBody <- msg
+ if errWrite := conn.WriteMessage(websocket.TextMessage, completedEvent); errWrite != nil {
+ t.Errorf("write websocket message: %v", errWrite)
+ }
+ return
+ }
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read http body: %v", errRead)
+ return
+ }
+ capturedBody <- body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write(append(append([]byte("data: "), completedEvent...), '\n', '\n'))
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{}
+ var exec cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg)
+ if transport == "websocket" {
+ exec = NewCodexWebsocketsExecutor(cfg)
+ }
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "plan_type": "pro",
+ },
+ }
+ if credKind == "api_key" {
+ auth.Attributes["api_key"] = "sk-custom-key"
+ } else {
+ auth.Metadata = map[string]any{
+ "access_token": "outer-token",
+ "account_id": "acct-1",
+ "use_siwc": true,
+ "siwc": map[string]any{
+ "access_token": "siwc-token",
+ "base_url": server.URL,
+ },
+ }
+ }
+
+ payload := []byte(`{"model":"gpt-6-astra","reasoning":{"effort":"medium"},"input":"画一只小猫咪。","stream":true,"tools":[{"type":"image_generation","output_format":"png","complexity":"high"}],"tool_choice":{"type":"image_generation"}}`)
+ req := cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: payload,
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: stream,
+ Headers: http.Header{"User-Agent": []string{"curl/8.7.1"}},
+ }
+ if stream {
+ res, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range res.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else {
+ if _, errExec := exec.Execute(context.Background(), auth, req, opts); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+ }
+
+ gotBody := <-capturedBody
+ if gotModel := ojson.GetBytes(gotBody, "model").String(); gotModel != "gpt-6-astra" {
+ t.Fatalf("%s upstream model = %q, want gpt-6-astra; body=%s", credKind, gotModel, gotBody)
+ }
+ if gotComplexity := ojson.GetBytes(gotBody, "tools.0.complexity").String(); gotComplexity != "high" {
+ t.Fatalf("%s tools.0.complexity = %q, want high; body=%s", credKind, gotComplexity, gotBody)
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestCodexExecutorHostedImageGenerationPassthroughPreservesModelAndToolParams(t *testing.T) {
+ for _, transport := range []string{"http", "websocket"} {
+ for _, stream := range []bool{false, true} {
+ streamLabel := "false"
+ if stream {
+ streamLabel = "true"
+ }
+ t.Run(transport+"/stream="+streamLabel, func(t *testing.T) {
+ capturedBody := make(chan []byte, 1)
+ capturedHint := make(chan string, 1)
+ completedEvent := []byte(`{"type":"response.completed","response":{"id":"resp_passthrough_7270","object":"response","status":"completed","model":"gpt-6-astra","output":[],"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}`)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHint <- r.Header.Get(codexRoutingHintHeader)
+ if transport == "websocket" {
+ upgrader := websocket.Upgrader{}
+ conn, errUpgrade := upgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Errorf("upgrade websocket: %v", errUpgrade)
+ return
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+ _, msg, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Errorf("read websocket message: %v", errRead)
+ return
+ }
+ capturedBody <- msg
+ if errWrite := conn.WriteMessage(websocket.TextMessage, completedEvent); errWrite != nil {
+ t.Errorf("write websocket message: %v", errWrite)
+ }
+ return
+ }
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read http body: %v", errRead)
+ return
+ }
+ capturedBody <- body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write(append(append([]byte("data: "), completedEvent...), '\n', '\n'))
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{
+ DisableImageGeneration: config.DisableImageGenerationPassthrough,
+ },
+ }
+ var exec cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg)
+ if transport == "websocket" {
+ exec = NewCodexWebsocketsExecutor(cfg)
+ }
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "plan_type": "pro",
+ },
+ Metadata: map[string]any{
+ "access_token": "test-token",
+ "account_id": "acct-1",
+ },
+ }
+ payload := []byte(`{"model":"gpt-6-astra","reasoning":{"effort":"max"},"input":"画一只小猫咪。","stream":true,"tools":[{"type":"image_generation","output_format":"png","complexity":"high"}],"tool_choice":{"type":"image_generation"}}`)
+ req := cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: payload,
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: stream,
+ }
+ if stream {
+ res, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range res.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else {
+ if _, errExec := exec.Execute(context.Background(), auth, req, opts); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+ }
+
+ gotBody := <-capturedBody
+ gotHint := <-capturedHint
+ if gotModel := ojson.GetBytes(gotBody, "model").String(); gotModel != "gpt-6-astra" {
+ t.Fatalf("passthrough upstream model = %q, want gpt-6-astra; body=%s", gotModel, gotBody)
+ }
+ if gotHint != "model=gpt-6-astra" {
+ t.Fatalf("passthrough X-Codex-Routing-Hint = %q, want model=gpt-6-astra", gotHint)
+ }
+ if gotComplexity := ojson.GetBytes(gotBody, "tools.0.complexity").String(); gotComplexity != "high" {
+ t.Fatalf("passthrough tools.0.complexity = %q, want high; body=%s", gotComplexity, gotBody)
+ }
+ if gotEffort := ojson.GetBytes(gotBody, "reasoning.effort").String(); gotEffort != "max" {
+ t.Fatalf("passthrough reasoning.effort = %q, want max; body=%s", gotEffort, gotBody)
+ }
+ })
+ }
+ }
+}
+
+func TestCodexExecutorOptionalImageGenerationPreservesChatModelAndGuidesOnAutoTrigger(t *testing.T) {
+ for _, transport := range []string{"http", "websocket"} {
+ for _, stream := range []bool{false, true} {
+ streamLabel := "false"
+ if stream {
+ streamLabel = "true"
+ }
+ t.Run("normal_chat_with_optional_tool/"+transport+"/stream="+streamLabel, func(t *testing.T) {
+ capturedBody := make(chan []byte, 1)
+ capturedHint := make(chan string, 1)
+ completedEvent := []byte(`{"type":"response.completed","response":{"id":"resp_chat_7270","object":"response","status":"completed","model":"gpt-6-astra","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello!"}]}],"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15}}}`)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHint <- r.Header.Get(codexRoutingHintHeader)
+ if transport == "websocket" {
+ upgrader := websocket.Upgrader{}
+ conn, errUpgrade := upgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Errorf("upgrade websocket: %v", errUpgrade)
+ return
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+ _, msg, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Errorf("read websocket message: %v", errRead)
+ return
+ }
+ capturedBody <- msg
+ if errWrite := conn.WriteMessage(websocket.TextMessage, completedEvent); errWrite != nil {
+ t.Errorf("write websocket message: %v", errWrite)
+ }
+ return
+ }
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read http body: %v", errRead)
+ return
+ }
+ capturedBody <- body
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write(append(append([]byte("data: "), completedEvent...), '\n', '\n'))
+ }))
+ defer server.Close()
+
+ cfg := &config.Config{}
+ var exec cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg)
+ if transport == "websocket" {
+ exec = NewCodexWebsocketsExecutor(cfg)
+ }
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "codex",
+ Attributes: map[string]string{
+ "base_url": server.URL,
+ "plan_type": "pro",
+ },
+ Metadata: map[string]any{
+ "access_token": "test-token",
+ "account_id": "acct-1",
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "gpt-6-astra",
+ Payload: []byte(`{"model":"gpt-6-astra","reasoning":{"effort":"medium"},"input":"Hello","stream":true,"tools":[{"type":"image_generation","output_format":"png","complexity":"high"}],"tool_choice":"auto"}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: stream,
+ }
+ if stream {
+ res, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ for chunk := range res.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ }
+ } else {
+ if _, errExec := exec.Execute(context.Background(), auth, req, opts); errExec != nil {
+ t.Fatalf("Execute() error = %v", errExec)
+ }
+ }
+
+ gotBody := <-capturedBody
+ gotHint := <-capturedHint
+ if gotModel := ojson.GetBytes(gotBody, "model").String(); gotModel != "gpt-6-astra" {
+ t.Fatalf("upstream model = %q, want gpt-6-astra; body=%s", gotModel, gotBody)
+ }
+ if gotHint != "model=gpt-6-astra" {
+ t.Fatalf("X-Codex-Routing-Hint = %q, want model=gpt-6-astra", gotHint)
+ }
+ if ojson.GetBytes(gotBody, "tools.0.complexity").Exists() {
+ t.Fatalf("tools.0.complexity should be stripped; body=%s", gotBody)
+ }
+ })
+
+ }
+ }
+}
+
+func TestCodexTerminalFailureStatus_ImageGenerationUserErrorMapsToBadRequest(t *testing.T) {
+ failedEvent := []byte(`{"type":"response.failed","response":{"status":"failed","error":{"type":"image_generation_user_error","code":"invalid_value","message":"Complexity 'medium' is not supported for this image model.","param":"complexity"}}}`)
+ errTerminal, _, ok := codexTerminalFailureErrWithCooling(failedEvent, false)
+ if !ok {
+ t.Fatal("expected terminal error for response.failed")
+ }
+ if !strings.Contains(errTerminal.Error(), "Complexity 'medium' is not supported for this image model.") {
+ t.Fatalf("unexpected error message: %v", errTerminal)
+ }
+ if !strings.Contains(errTerminal.Error(), "/v1/images/generations") || !strings.Contains(errTerminal.Error(), "gpt-5.4-mini") {
+ t.Fatalf("expected remediation guidance in error message, got: %v", errTerminal)
+ }
+ if got := errTerminal.StatusCode(); got != http.StatusBadRequest {
+ t.Fatalf("StatusCode() = %d, want %d (%s)", got, http.StatusBadRequest, errTerminal.Error())
}
}
diff --git a/internal/runtime/executor/codex_executor_input_ids_test.go b/internal/runtime/executor/codex_executor_input_ids_test.go
index e459209df37..07e3f87f341 100644
--- a/internal/runtime/executor/codex_executor_input_ids_test.go
+++ b/internal/runtime/executor/codex_executor_input_ids_test.go
@@ -8,17 +8,18 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorExecuteStreamSanitizesOverlongInputItemIDs(t *testing.T) {
longReasoningItemID := "rs_" + strings.Repeat("a", 64)
longCallItemID := strings.Repeat("grok-call-item-", 6)
longOutputItemID := strings.Repeat("grok-output-item-", 6)
+ longBase64MessageID := "msg_" + strings.Repeat("A", 30) + "+/" + strings.Repeat("A", 414)
encryptedContent := validOpenAIResponsesReasoningEncryptedContentForTest()
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -40,6 +41,7 @@ func TestCodexExecutorExecuteStreamSanitizesOverlongInputItemIDs(t *testing.T) {
`{"type":"reasoning","id":"` + longReasoningItemID + `","encrypted_content":"` + encryptedContent + `","summary":[]},` +
`{"type":"function_call","id":"` + longCallItemID + `","call_id":"call-1","name":"lookup","arguments":"{}"},` +
`{"type":"function_call_output","id":"` + longOutputItemID + `","call_id":"call-1","output":"ok"},` +
+ `{"type":"message","id":"` + longBase64MessageID + `","role":"assistant","content":[{"type":"output_text","text":"Hello"}]},` +
`{"type":"message","id":"item_74ec40c883248ebb4885ec84","role":"user","content":"continue"}]}`),
}, cliproxyexecutor.Options{
SourceFormat: sdktranslator.FromString("openai-response"),
@@ -51,10 +53,10 @@ func TestCodexExecutorExecuteStreamSanitizesOverlongInputItemIDs(t *testing.T) {
for range result.Chunks {
}
- if input := gjson.GetBytes(gotBody, "input").Array(); len(input) != 3 {
- t.Fatalf("upstream input length = %d, want 3: %s", len(input), gotBody)
+ if input := ojson.GetBytes(gotBody, "input").Array(); len(input) != 4 {
+ t.Fatalf("upstream input length = %d, want 4: %s", len(input), gotBody)
}
- if gotType := gjson.GetBytes(gotBody, "input.0.type").String(); gotType != "function_call" {
+ if gotType := ojson.GetBytes(gotBody, "input.0.type").String(); gotType != "function_call" {
t.Fatalf("input.0.type = %q, want function_call: %s", gotType, gotBody)
}
@@ -64,19 +66,23 @@ func TestCodexExecutorExecuteStreamSanitizesOverlongInputItemIDs(t *testing.T) {
}{
{path: "input.0.id", originalID: longCallItemID},
{path: "input.1.id", originalID: longOutputItemID},
+ {path: "input.2.id", originalID: longBase64MessageID},
} {
- actual := gjson.GetBytes(gotBody, testCase.path).String()
+ actual := ojson.GetBytes(gotBody, testCase.path).String()
if len([]rune(actual)) > 64 || actual == testCase.originalID {
t.Fatalf("input.%d.id was not shortened to at most 64 characters: %q", index, actual)
}
+ if strings.ContainsAny(actual, "+/") {
+ t.Fatalf("input.%d.id still contains + or /: %q", index, actual)
+ }
}
- if got := gjson.GetBytes(gotBody, "input.0.call_id").String(); got != "call-1" {
+ if got := ojson.GetBytes(gotBody, "input.0.call_id").String(); got != "call-1" {
t.Fatalf("function call_id = %q, want call-1", got)
}
- if got := gjson.GetBytes(gotBody, "input.1.call_id").String(); got != "call-1" {
+ if got := ojson.GetBytes(gotBody, "input.1.call_id").String(); got != "call-1" {
t.Fatalf("function call output call_id = %q, want call-1", got)
}
- if got := gjson.GetBytes(gotBody, "input.2.id").String(); got != "msg_item_74ec40c883248ebb4885ec84" {
+ if got := ojson.GetBytes(gotBody, "input.3.id").String(); got != "msg_item_74ec40c883248ebb4885ec84" {
t.Fatalf("message input item ID was not normalized: %q", got)
}
}
diff --git a/internal/runtime/executor/codex_executor_instructions_test.go b/internal/runtime/executor/codex_executor_instructions_test.go
index 362a6818fb6..7f9e1808510 100644
--- a/internal/runtime/executor/codex_executor_instructions_test.go
+++ b/internal/runtime/executor/codex_executor_instructions_test.go
@@ -7,11 +7,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorExecuteNormalizesNullInstructions(t *testing.T) {
@@ -45,11 +45,11 @@ func TestCodexExecutorExecuteNormalizesNullInstructions(t *testing.T) {
if gotPath != "/responses" {
t.Fatalf("path = %q, want %q", gotPath, "/responses")
}
- if gjson.GetBytes(gotBody, "instructions").Type != gjson.String {
- t.Fatalf("instructions type = %v, want string", gjson.GetBytes(gotBody, "instructions").Type)
+ if ojson.GetBytes(gotBody, "instructions").Type != ojson.String {
+ t.Fatalf("instructions type = %v, want string", ojson.GetBytes(gotBody, "instructions").Type)
}
- if gjson.GetBytes(gotBody, "instructions").String() != "" {
- t.Fatalf("instructions = %q, want empty string", gjson.GetBytes(gotBody, "instructions").String())
+ if ojson.GetBytes(gotBody, "instructions").String() != "" {
+ t.Fatalf("instructions = %q, want empty string", ojson.GetBytes(gotBody, "instructions").String())
}
}
@@ -86,11 +86,11 @@ func TestCodexExecutorExecuteStreamNormalizesNullInstructions(t *testing.T) {
if gotPath != "/responses" {
t.Fatalf("path = %q, want %q", gotPath, "/responses")
}
- if gjson.GetBytes(gotBody, "instructions").Type != gjson.String {
- t.Fatalf("instructions type = %v, want string", gjson.GetBytes(gotBody, "instructions").Type)
+ if ojson.GetBytes(gotBody, "instructions").Type != ojson.String {
+ t.Fatalf("instructions type = %v, want string", ojson.GetBytes(gotBody, "instructions").Type)
}
- if gjson.GetBytes(gotBody, "instructions").String() != "" {
- t.Fatalf("instructions = %q, want empty string", gjson.GetBytes(gotBody, "instructions").String())
+ if ojson.GetBytes(gotBody, "instructions").String() != "" {
+ t.Fatalf("instructions = %q, want empty string", ojson.GetBytes(gotBody, "instructions").String())
}
}
diff --git a/internal/runtime/executor/codex_executor_parallel_tool_calls_test.go b/internal/runtime/executor/codex_executor_parallel_tool_calls_test.go
index f64d2329453..52aedc00729 100644
--- a/internal/runtime/executor/codex_executor_parallel_tool_calls_test.go
+++ b/internal/runtime/executor/codex_executor_parallel_tool_calls_test.go
@@ -4,7 +4,7 @@ import (
"net/http"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestNormalizeCodexParallelToolCallsForTools_DropsWhenToolsMissing(t *testing.T) {
@@ -12,7 +12,7 @@ func TestNormalizeCodexParallelToolCallsForTools_DropsWhenToolsMissing(t *testin
out := normalizeCodexParallelToolCallsForTools(body)
- if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
+ if ojson.GetBytes(out, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls should be removed when tools are missing: %s", string(out))
}
}
@@ -22,10 +22,10 @@ func TestNormalizeCodexParallelToolCallsForTools_DropsWhenToolsEmpty(t *testing.
out := normalizeCodexParallelToolCallsForTools(body)
- if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
+ if ojson.GetBytes(out, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls should be removed when tools are empty: %s", string(out))
}
- if !gjson.GetBytes(out, "tools").Exists() {
+ if !ojson.GetBytes(out, "tools").Exists() {
t.Fatalf("tools should be preserved: %s", string(out))
}
}
@@ -35,7 +35,7 @@ func TestNormalizeCodexParallelToolCallsForTools_PreservesWhenToolsPresent(t *te
out := normalizeCodexParallelToolCallsForTools(body)
- if !gjson.GetBytes(out, "parallel_tool_calls").Bool() {
+ if !ojson.GetBytes(out, "parallel_tool_calls").Bool() {
t.Fatalf("parallel_tool_calls should be preserved when tools are present: %s", string(out))
}
}
@@ -45,7 +45,7 @@ func TestNormalizeCodexParallelToolCalls_ResponsesLiteMetadataForcesFalse(t *tes
out := normalizeCodexParallelToolCalls(body, nil)
- parallelToolCalls := gjson.GetBytes(out, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(out, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", string(out))
}
@@ -58,7 +58,7 @@ func TestNormalizeCodexParallelToolCalls_ResponsesLiteHeaderForcesFalse(t *testi
out := normalizeCodexParallelToolCalls(body, headers)
- parallelToolCalls := gjson.GetBytes(out, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(out, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", string(out))
}
diff --git a/internal/runtime/executor/codex_executor_reasoning.go b/internal/runtime/executor/codex_executor_reasoning.go
index 825b36e9b14..5c4d1f1eb12 100644
--- a/internal/runtime/executor/codex_executor_reasoning.go
+++ b/internal/runtime/executor/codex_executor_reasoning.go
@@ -9,6 +9,7 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
@@ -18,8 +19,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type codexReasoningReplayScope struct {
@@ -38,30 +37,36 @@ func applyCodexReasoningReplayCache(ctx context.Context, from sdktranslator.Form
}
func applyCodexReasoningReplayCacheRequired(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) ([]byte, codexReasoningReplayScope, error) {
- scope := codexReasoningReplayScopeFromRequest(ctx, from, req, opts, body)
+ doc := ojson.NewDoc(body)
+ scope, changed, errReplay := applyCodexReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
+ if errReplay != nil || !changed {
+ return body, scope, errReplay
+ }
+ return doc.Bytes(), scope, nil
+}
+
+func applyCodexReasoningReplayCacheRequiredDoc(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) (codexReasoningReplayScope, bool, error) {
+ scope := codexReasoningReplayScopeFromRequestDoc(ctx, from, req, opts, doc)
if !scope.valid() {
- return body, scope, nil
+ return scope, false, nil
}
items, ok, errReplay := internalcache.GetCodexReasoningReplayItemsRequired(ctx, scope.modelName, scope.sessionKey)
if errReplay != nil || !ok {
- return body, scope, errReplay
- }
- updated, ok := insertCodexReasoningReplayTurns(body, items)
- if !ok {
- return body, scope, nil
+ return scope, false, errReplay
}
- return updated, scope, nil
+ changed := insertCodexReasoningReplayTurnsDoc(doc, items)
+ return scope, changed, nil
}
func codexReasoningReplayScopeFromRequest(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) codexReasoningReplayScope {
if !codexReasoningReplayEnabledForSource(from) {
return codexReasoningReplayScope{}
}
- modelName := strings.TrimSpace(gjson.GetBytes(body, "model").String())
+ modelName := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(body, "model").String()))
if modelName == "" {
modelName = thinking.ParseSuffix(req.Model).ModelName
}
- inputItems := gjson.GetBytes(body, "input").Array()
+ inputItems := ojson.GetBytesNoCopy(body, "input").Array()
return codexReasoningReplayScope{
modelName: modelName,
sessionKey: codexReasoningReplaySessionKey(ctx, from, req, opts, body),
@@ -69,6 +74,22 @@ func codexReasoningReplayScopeFromRequest(ctx context.Context, from sdktranslato
}
}
+func codexReasoningReplayScopeFromRequestDoc(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) codexReasoningReplayScope {
+ if !codexReasoningReplayEnabledForSource(from) || doc == nil {
+ return codexReasoningReplayScope{}
+ }
+ modelName := strings.Clone(strings.TrimSpace(doc.Get("model").String()))
+ if modelName == "" {
+ modelName = thinking.ParseSuffix(req.Model).ModelName
+ }
+ inputItems := doc.Get("input").Array()
+ return codexReasoningReplayScope{
+ modelName: modelName,
+ sessionKey: codexReasoningReplaySessionKeyDoc(ctx, from, req, opts, doc),
+ requestFingerprint: codexReplayInputPrefixFingerprint(inputItems, len(inputItems)),
+ }
+}
+
func codexReasoningReplayEnabledForSource(from sdktranslator.Format) bool {
return sourceFormatEqual(from, sdktranslator.FormatClaude)
}
@@ -119,6 +140,59 @@ func codexReasoningReplaySessionKey(ctx context.Context, from sdktranslator.Form
return ""
}
+func codexReasoningReplaySessionKeyDoc(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) string {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if sourceFormatEqual(from, sdktranslator.FormatClaude) {
+ if sessionKey := codexClaudeCodeReplaySessionKey(ctx, req.Payload, opts.Headers); sessionKey != "" {
+ return sessionKey
+ }
+ }
+ if value := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
+ return "execution:" + value
+ }
+ if value := metadataString(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
+ return "execution:" + value
+ }
+ if value := codexReasoningReplaySessionKeyFromDoc(doc); value != "" {
+ return value
+ }
+ if value := codexReasoningReplaySessionKeyFromPayload(req.Payload); value != "" {
+ return value
+ }
+ if value := codexReasoningReplaySessionKeyFromHeaders(opts.Headers); value != "" {
+ return value
+ }
+ if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
+ if value := codexReasoningReplaySessionKeyFromHeaders(ginCtx.Request.Header); value != "" {
+ return value
+ }
+ }
+ if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
+ if apiKey := strings.TrimSpace(helps.APIKeyFromContext(ctx)); apiKey != "" {
+ return "prompt-cache:" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("cli-proxy-api:codex:prompt-cache:"+apiKey)).String()
+ }
+ }
+ return ""
+}
+
+func codexReasoningReplaySessionKeyFromDoc(doc *ojson.Doc) string {
+ if doc == nil || doc.Len() == 0 {
+ return ""
+ }
+ if promptCacheKey := strings.TrimSpace(doc.Get("prompt_cache_key").String()); promptCacheKey != "" {
+ return "prompt-cache:" + promptCacheKey
+ }
+ if windowID := strings.TrimSpace(doc.Get("client_metadata.x-codex-window-id").String()); windowID != "" {
+ return "window:" + windowID
+ }
+ if turnMetadata := strings.TrimSpace(doc.Get("client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
+ return codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata)
+ }
+ return ""
+}
+
func metadataString(metadata map[string]any, key string) string {
if len(metadata) == 0 {
return ""
@@ -141,13 +215,13 @@ func codexReasoningReplaySessionKeyFromPayload(payload []byte) string {
if len(payload) == 0 {
return ""
}
- if promptCacheKey := strings.TrimSpace(gjson.GetBytes(payload, "prompt_cache_key").String()); promptCacheKey != "" {
+ if promptCacheKey := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "prompt_cache_key").String()); promptCacheKey != "" {
return "prompt-cache:" + promptCacheKey
}
- if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
+ if windowID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
return "window:" + windowID
}
- if turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
+ if turnMetadata := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "client_metadata.x-codex-turn-metadata").String()); turnMetadata != "" {
return codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata)
}
return ""
@@ -177,17 +251,20 @@ func codexReasoningReplaySessionKeyFromHeaders(headers http.Header) string {
}
func codexReasoningReplaySessionKeyFromTurnMetadata(turnMetadata string) string {
- if promptCacheKey := strings.TrimSpace(gjson.Get(turnMetadata, "prompt_cache_key").String()); promptCacheKey != "" {
+ if promptCacheKey := strings.TrimSpace(ojson.Get(turnMetadata, "prompt_cache_key").String()); promptCacheKey != "" {
return "prompt-cache:" + promptCacheKey
}
- if windowID := strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String()); windowID != "" {
+ if windowID := strings.TrimSpace(ojson.Get(turnMetadata, "window_id").String()); windowID != "" {
return "window:" + windowID
}
return ""
}
func codexInputHasValidReasoningEncryptedContent(body []byte) bool {
- input := gjson.GetBytes(body, "input")
+ return codexInputResultHasValidReasoningEncryptedContent(ojson.GetBytesNoCopy(body, "input"))
+}
+
+func codexInputResultHasValidReasoningEncryptedContent(input ojson.Result) bool {
if !input.IsArray() {
return false
}
@@ -196,7 +273,7 @@ func codexInputHasValidReasoningEncryptedContent(body []byte) bool {
continue
}
encryptedContent := item.Get("encrypted_content")
- if encryptedContent.Type != gjson.String {
+ if encryptedContent.Type != ojson.String {
continue
}
rawSig := signature.SignaturePayloadWithoutProviderPrefix(encryptedContent.String())
@@ -216,10 +293,21 @@ type codexReasoningReplayTurn struct {
}
func insertCodexReasoningReplayTurns(body []byte, replayItems [][]byte) ([]byte, bool) {
- input := gjson.GetBytes(body, "input")
- if !input.IsArray() || len(replayItems) == 0 {
+ doc := ojson.NewDoc(body)
+ if !insertCodexReasoningReplayTurnsDoc(doc, replayItems) {
return body, false
}
+ return doc.Bytes(), true
+}
+
+func insertCodexReasoningReplayTurnsDoc(doc *ojson.Doc, replayItems [][]byte) bool {
+ if doc == nil || len(replayItems) == 0 {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
inputItems := input.Array()
turns := splitCodexReasoningReplayTurns(replayItems)
insertions := make(map[int][][]byte)
@@ -233,7 +321,7 @@ func insertCodexReasoningReplayTurns(body []byte, replayItems [][]byte) ([]byte,
continue
}
if !turn.marked {
- items := filterCodexReasoningReplayItemsForInput(body, turn.items)
+ items := filterCodexReasoningReplayItemsForInputResult(input, turn.items)
if len(items) == 0 {
continue
}
@@ -261,7 +349,7 @@ func insertCodexReasoningReplayTurns(body []byte, replayItems [][]byte) ([]byte,
inserted = true
}
if !inserted {
- return body, false
+ return false
}
items := make([]string, 0, len(inputItems)+len(replayItems))
@@ -274,11 +362,7 @@ func insertCodexReasoningReplayTurns(body []byte, replayItems [][]byte) ([]byte,
for _, replayItem := range insertions[len(inputItems)] {
items = append(items, string(replayItem))
}
- updated, err := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(items, ",")+"]"))
- if err != nil {
- return body, false
- }
- return updated, true
+ return doc.SetRawBytes("input", []byte("["+strings.Join(items, ",")+"]")) == nil
}
func splitCodexReasoningReplayTurns(items [][]byte) []codexReasoningReplayTurn {
@@ -290,18 +374,18 @@ func splitCodexReasoningReplayTurns(items [][]byte) []codexReasoningReplayTurn {
}
}
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
if strings.TrimSpace(itemResult.Get("type").String()) == internalcache.CodexReasoningReplayTurnType {
appendCurrent()
current = codexReasoningReplayTurn{
marked: true,
- assistantFingerprint: strings.TrimSpace(itemResult.Get("assistant_fingerprint").String()),
- requestFingerprint: strings.TrimSpace(itemResult.Get("request_fingerprint").String()),
+ assistantFingerprint: strings.Clone(strings.TrimSpace(itemResult.Get("assistant_fingerprint").String())),
+ requestFingerprint: strings.Clone(strings.TrimSpace(itemResult.Get("request_fingerprint").String())),
}
if callIDs := itemResult.Get("call_ids"); callIDs.IsArray() {
for _, callIDResult := range callIDs.Array() {
if callID := strings.TrimSpace(callIDResult.String()); callID != "" {
- current.callIDs = append(current.callIDs, callID)
+ current.callIDs = append(current.callIDs, strings.Clone(callID))
}
}
}
@@ -313,7 +397,7 @@ func splitCodexReasoningReplayTurns(items [][]byte) []codexReasoningReplayTurn {
return turns
}
-func codexReasoningReplayTurnAnchorIndex(inputItems []gjson.Result, turn codexReasoningReplayTurn, fallbackEnd int, used map[int]bool, prefixFingerprints *codexReplayPrefixFingerprints) (int, bool) {
+func codexReasoningReplayTurnAnchorIndex(inputItems []ojson.Result, turn codexReasoningReplayTurn, fallbackEnd int, used map[int]bool, prefixFingerprints *codexReplayPrefixFingerprints) (int, bool) {
searchEnd := fallbackEnd
if turn.requestFingerprint != "" {
searchEnd = len(inputItems) - 1
@@ -376,7 +460,7 @@ func canonicalCodexReasoningReplaySignatureKey(rawSignature string) string {
return sig
}
-func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]byte) [][]byte {
+func filterCodexReasoningReplayTurnItems(inputItems []ojson.Result, items [][]byte) [][]byte {
existingReasoning := make(map[string]bool)
existingCalls := make(map[string]bool)
existingOutputs := make(map[string]bool)
@@ -399,7 +483,7 @@ func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]by
filtered := make([][]byte, 0, len(items))
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "reasoning":
if existingReasoning[canonicalCodexReasoningReplaySignatureKey(itemResult.Get("encrypted_content").String())] {
@@ -431,7 +515,7 @@ func filterCodexReasoningReplayTurnItems(inputItems []gjson.Result, items [][]by
return filtered
}
-func codexReplayAssistantMessageFingerprint(item gjson.Result) string {
+func codexReplayAssistantMessageFingerprint(item ojson.Result) string {
itemType := strings.TrimSpace(item.Get("type").String())
if itemType != "" && itemType != "message" {
return ""
@@ -441,7 +525,7 @@ func codexReplayAssistantMessageFingerprint(item gjson.Result) string {
}
content := item.Get("content")
var builder strings.Builder
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
builder.WriteString(content.String())
} else if content.IsArray() {
for _, part := range content.Array() {
@@ -465,7 +549,7 @@ func codexReplayAssistantMessageFingerprint(item gjson.Result) string {
return hex.EncodeToString(sum[:])
}
-func codexReplayInputPrefixFingerprint(inputItems []gjson.Result, end int) string {
+func codexReplayInputPrefixFingerprint(inputItems []ojson.Result, end int) string {
if end < 0 || end > len(inputItems) {
return ""
}
@@ -482,13 +566,13 @@ func codexReplayInputPrefixFingerprint(inputItems []gjson.Result, end int) strin
// turn; recomputing each prefix from scratch is O(n^2) hashing and stalled large
// long-context requests for minutes before anything was sent upstream.
type codexReplayPrefixFingerprints struct {
- items []gjson.Result
+ items []ojson.Result
hasher hash.Hash
// sums[end] is the fingerprint of items[0:end]; extended lazily.
sums []string
}
-func newCodexReplayPrefixFingerprints(items []gjson.Result) *codexReplayPrefixFingerprints {
+func newCodexReplayPrefixFingerprints(items []ojson.Result) *codexReplayPrefixFingerprints {
hasher := sha256.New()
return &codexReplayPrefixFingerprints{
items: items,
@@ -513,12 +597,15 @@ func (f *codexReplayPrefixFingerprints) at(end int) string {
}
func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte {
- input := gjson.GetBytes(body, "input")
+ return filterCodexReasoningReplayItemsForInputResult(ojson.GetBytesNoCopy(body, "input"), items)
+}
+
+func filterCodexReasoningReplayItemsForInputResult(input ojson.Result, items [][]byte) [][]byte {
if !input.IsArray() {
return nil
}
- hasInputReasoning := codexInputHasValidReasoningEncryptedContent(body)
+ hasInputReasoning := codexInputResultHasValidReasoningEncryptedContent(input)
existingCalls := make(map[string]bool)
existingOutputs := make(map[string]bool)
for _, inputItem := range input.Array() {
@@ -538,7 +625,7 @@ func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]by
filtered := make([][]byte, 0, len(items))
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "reasoning":
if hasInputReasoning {
@@ -575,10 +662,21 @@ func filterCodexReasoningReplayItemsForInput(body []byte, items [][]byte) [][]by
}
func insertCodexReasoningReplayItems(body []byte, replayItems [][]byte) ([]byte, bool) {
- input := gjson.GetBytes(body, "input")
- if !input.IsArray() || len(replayItems) == 0 {
+ doc := ojson.NewDoc(body)
+ if !insertCodexReasoningReplayItemsDoc(doc, replayItems) {
return body, false
}
+ return doc.Bytes(), true
+}
+
+func insertCodexReasoningReplayItemsDoc(doc *ojson.Doc, replayItems [][]byte) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() || len(replayItems) == 0 {
+ return false
+ }
inputItems := input.Array()
insertIndex := codexReasoningReplayInsertIndex(inputItems, replayItems)
replayItems = codexAlignReasoningReplayToolCallIDs(inputItems, replayItems)
@@ -596,17 +694,13 @@ func insertCodexReasoningReplayItems(body []byte, replayItems [][]byte) ([]byte,
items = append(items, string(replayItem))
}
}
- updated, err := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(items, ",")+"]"))
- if err != nil {
- return body, false
- }
- return updated, true
+ return doc.SetRawBytes("input", []byte("["+strings.Join(items, ",")+"]")) == nil
}
-func codexReasoningReplayInsertIndex(inputItems []gjson.Result, replayItems [][]byte) int {
+func codexReasoningReplayInsertIndex(inputItems []ojson.Result, replayItems [][]byte) int {
replayCallIDs := make(map[string]bool)
for _, replayItem := range replayItems {
- itemResult := gjson.ParseBytes(replayItem)
+ itemResult := util.ParseGJSONBytesNoCopy(replayItem)
itemType := strings.TrimSpace(itemResult.Get("type").String())
if itemType != "function_call" && itemType != "custom_tool_call" {
continue
@@ -641,7 +735,7 @@ func codexReasoningReplayInsertIndex(inputItems []gjson.Result, replayItems [][]
return len(inputItems)
}
-func codexAlignReasoningReplayToolCallIDs(inputItems []gjson.Result, replayItems [][]byte) [][]byte {
+func codexAlignReasoningReplayToolCallIDs(inputItems []ojson.Result, replayItems [][]byte) [][]byte {
outputCallIDs := codexReplayOutputCallIDs(inputItems)
if len(outputCallIDs) == 0 {
return replayItems
@@ -649,7 +743,7 @@ func codexAlignReasoningReplayToolCallIDs(inputItems []gjson.Result, replayItems
aligned := make([][]byte, 0, len(replayItems))
for _, replayItem := range replayItems {
- itemResult := gjson.ParseBytes(replayItem)
+ itemResult := util.ParseGJSONBytesNoCopy(replayItem)
itemType := strings.TrimSpace(itemResult.Get("type").String())
if itemType != "function_call" && itemType != "custom_tool_call" {
aligned = append(aligned, replayItem)
@@ -669,7 +763,7 @@ func codexAlignReasoningReplayToolCallIDs(inputItems []gjson.Result, replayItems
continue
}
- updated, err := sjson.SetBytes(replayItem, "call_id", outputCallID)
+ updated, err := ojson.SetBytes(replayItem, "call_id", outputCallID)
if err != nil {
aligned = append(aligned, replayItem)
continue
@@ -679,7 +773,7 @@ func codexAlignReasoningReplayToolCallIDs(inputItems []gjson.Result, replayItems
return aligned
}
-func codexReplayOutputCallIDs(inputItems []gjson.Result) map[string]string {
+func codexReplayOutputCallIDs(inputItems []ojson.Result) map[string]string {
outputCallIDs := make(map[string]string)
for _, inputItem := range inputItems {
itemType := strings.TrimSpace(inputItem.Get("type").String())
@@ -697,7 +791,7 @@ func codexReplayOutputCallIDs(inputItems []gjson.Result) map[string]string {
return outputCallIDs
}
-func shouldInsertCodexReasoningReplayBefore(item gjson.Result) bool {
+func shouldInsertCodexReasoningReplayBefore(item ojson.Result) bool {
role, ok := codexReplayMessageRole(item)
if !ok {
return true
@@ -710,7 +804,7 @@ func shouldInsertCodexReasoningReplayBefore(item gjson.Result) bool {
}
}
-func codexReplayMessageRole(item gjson.Result) (string, bool) {
+func codexReplayMessageRole(item ojson.Result) (string, bool) {
itemType := strings.TrimSpace(item.Get("type").String())
role := strings.ToLower(strings.TrimSpace(item.Get("role").String()))
if role == "" || (itemType != "" && itemType != "message") {
@@ -719,7 +813,7 @@ func codexReplayMessageRole(item gjson.Result) (string, bool) {
return role, true
}
-func codexReplayToolCallKeys(item gjson.Result) []string {
+func codexReplayToolCallKeys(item ojson.Result) []string {
itemType := strings.TrimSpace(item.Get("type").String())
if itemType != "function_call" && itemType != "custom_tool_call" {
return nil
@@ -776,7 +870,7 @@ func cacheCodexReasoningReplayFromCompleted(scope codexReasoningReplayScope, com
if !scope.valid() {
return
}
- output := gjson.GetBytes(completedData, "response.output")
+ output := ojson.GetBytesNoCopy(completedData, "response.output")
if !output.IsArray() {
return
}
@@ -790,7 +884,7 @@ func cacheCodexReasoningReplayFromCompleted(scope codexReasoningReplayScope, com
case "function_call", "custom_tool_call":
replayItems = append(replayItems, []byte(item.Raw))
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
- callIDs = append(callIDs, callID)
+ callIDs = append(callIDs, strings.Clone(callID))
}
case "message":
if fingerprint := codexReplayAssistantMessageFingerprint(item); fingerprint != "" {
@@ -812,19 +906,19 @@ func cacheCodexReasoningReplayFromCompleted(scope codexReasoningReplayScope, com
_, _ = hasher.Write([]byte("\x00item\x00"))
_, _ = hasher.Write(item)
}
- marker := []byte(`{"type":"` + internalcache.CodexReasoningReplayTurnType + `"}`)
- marker, _ = sjson.SetBytes(marker, "id", hex.EncodeToString(hasher.Sum(nil)))
+ markerDoc := ojson.NewDoc([]byte(`{"type":"` + internalcache.CodexReasoningReplayTurnType + `"}`))
+ _ = markerDoc.Set("id", hex.EncodeToString(hasher.Sum(nil)))
if assistantFingerprint != "" {
- marker, _ = sjson.SetBytes(marker, "assistant_fingerprint", assistantFingerprint)
+ _ = markerDoc.Set("assistant_fingerprint", assistantFingerprint)
}
if scope.requestFingerprint != "" {
- marker, _ = sjson.SetBytes(marker, "request_fingerprint", scope.requestFingerprint)
+ _ = markerDoc.Set("request_fingerprint", scope.requestFingerprint)
}
for _, callID := range callIDs {
- marker, _ = sjson.SetBytes(marker, "call_ids.-1", callID)
+ _ = markerDoc.Set("call_ids.-1", callID)
}
items := make([][]byte, 0, len(replayItems)+1)
- items = append(items, marker)
+ items = append(items, markerDoc.Bytes())
items = append(items, replayItems...)
internalcache.AppendCodexReasoningReplayItemsBestEffort(context.Background(), scope.modelName, scope.sessionKey, items)
}
diff --git a/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go b/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
index c236e344dc2..4e9200fe803 100644
--- a/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
+++ b/internal/runtime/executor/codex_executor_reasoning_replay_cache_test.go
@@ -3,14 +3,15 @@ package executor
import (
"context"
"crypto/sha256"
- "encoding/base64"
"encoding/hex"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -18,7 +19,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func validCodexReasoningEncryptedContentForTestSeed(seed byte) string {
@@ -99,13 +99,13 @@ func TestCodexExecutorReasoningReplayCacheStoresFinalDoneAndInjectsNextClaudeReq
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
t.Fatalf("input.0.type = %q, want reasoning; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
+ if got := ojson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
t.Fatalf("injected encrypted_content = %q, want final done %q; body=%s", got, doneEncryptedContent, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(secondBody, "input.1.role").String(); got != "user" {
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(secondBody))
}
}
@@ -133,10 +133,10 @@ func TestCodexExecutorReasoningReplayCacheSharesSameSessionAcrossClientKeys(t *t
if secondScope != firstScope {
t.Fatalf("replay scope should ignore client API key for the same session: first=%#v second=%#v", firstScope, secondScope)
}
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
t.Fatalf("input.0.type = %q, want same-session replay; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != encryptedContent {
+ if got := ojson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != encryptedContent {
t.Fatalf("injected encrypted_content = %q, want cached value", got)
}
}
@@ -290,10 +290,10 @@ func TestCodexExecutorReasoningReplayCacheSharesSameSessionAcrossCodexAuths(t *t
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
t.Fatalf("input.0.type = %q, want same-session replay across auths; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != encryptedContent {
+ if got := ojson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != encryptedContent {
t.Fatalf("injected encrypted_content = %q, want cached value", got)
}
}
@@ -344,10 +344,10 @@ func TestCodexExecutorReasoningReplayCacheDoesNotInjectNativeResponsesRequest(t
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "input.0.type").String(); got == "reasoning" {
+ if got := ojson.GetBytes(gotBody, "input.0.type").String(); got == "reasoning" {
t.Fatalf("native Responses request should not receive cached reasoning; body=%s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want user; body=%s", got, string(gotBody))
}
}
@@ -425,11 +425,11 @@ func TestCodexExecutorReasoningReplayCacheDoesNotDuplicateClaudeClientReasoning(
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "input.0.encrypted_content").String(); got != clientEncryptedContent {
+ if got := ojson.GetBytes(gotBody, "input.0.encrypted_content").String(); got != clientEncryptedContent {
t.Fatalf("client reasoning should be preserved, got %q want %q; body=%s", got, clientEncryptedContent, string(gotBody))
}
reasoningCount := 0
- for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ for _, item := range ojson.GetBytes(gotBody, "input").Array() {
if item.Get("type").String() == "reasoning" {
reasoningCount++
}
@@ -484,19 +484,19 @@ func TestCodexExecutorReasoningReplayCacheInsertsReasoningBeforeAssistantOutputI
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want first user message; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.1.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(gotBody, "input.1.type").String(); got != "reasoning" {
t.Fatalf("input.1.type = %q, want cached reasoning before assistant output; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.1.encrypted_content").String(); got != cachedEncryptedContent {
+ if got := ojson.GetBytes(gotBody, "input.1.encrypted_content").String(); got != cachedEncryptedContent {
t.Fatalf("input.1.encrypted_content = %q, want cached reasoning; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.2.role").String(); got != "assistant" {
+ if got := ojson.GetBytes(gotBody, "input.2.role").String(); got != "assistant" {
t.Fatalf("input.2.role = %q, want assistant output after cached reasoning; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.3.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.3.role").String(); got != "user" {
t.Fatalf("input.3.role = %q, want final user message; body=%s", got, string(gotBody))
}
}
@@ -562,7 +562,7 @@ func TestCodexExecutorReasoningReplayCacheExecuteStreamStoresFinalDoneForClaude(
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
+ if got := ojson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
t.Fatalf("stream cached encrypted_content = %q, want final done %q; body=%s", got, doneEncryptedContent, string(secondBody))
}
}
@@ -716,22 +716,22 @@ func TestCodexExecutorReasoningReplayCacheReplaysFunctionCallForClaudeToolResult
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
t.Fatalf("input.0.type = %q, want initial user message; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
t.Fatalf("input.1.type = %q, want cached reasoning; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
+ if got := ojson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
t.Fatalf("input.2.type = %q, want cached function_call; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.2.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytes(secondBody, "input.2.call_id").String(); got != "call_1" {
t.Fatalf("input.2.call_id = %q, want call_1; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
+ if got := ojson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
t.Fatalf("input.3.type = %q, want function_call_output after cached call; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.3.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytes(secondBody, "input.3.call_id").String(); got != "call_1" {
t.Fatalf("input.3.call_id = %q, want call_1; body=%s", got, string(secondBody))
}
}
@@ -771,7 +771,7 @@ func TestCodexExecutorReasoningReplayCacheRestoresCumulativeToolTurns(t *testing
t.Fatalf("replay scope = %#v, want model/session %#v", gotScope, scope)
}
wantTypes := []string{"message", "reasoning", "function_call", "function_call_output", "message", "reasoning", "function_call", "function_call_output", "message"}
- gotItems := gjson.GetBytes(updated, "input").Array()
+ gotItems := ojson.GetBytes(updated, "input").Array()
if len(gotItems) != len(wantTypes) {
t.Fatalf("input length = %d, want %d; body=%s", len(gotItems), len(wantTypes), updated)
}
@@ -820,7 +820,7 @@ func TestCodexExecutorReasoningReplayCacheRestoresCumulativeAssistantTurns(t *te
t.Fatalf("replay scope = %#v, want model/session %#v", gotScope, scope)
}
wantTypes := []string{"message", "reasoning", "message", "message", "reasoning", "message", "message"}
- gotItems := gjson.GetBytes(updated, "input").Array()
+ gotItems := ojson.GetBytes(updated, "input").Array()
if len(gotItems) != len(wantTypes) {
t.Fatalf("input length = %d, want %d; body=%s", len(gotItems), len(wantTypes), updated)
}
@@ -863,7 +863,7 @@ func TestCodexExecutorReasoningReplayCacheSkipsDetachedTurnAfterCompaction(t *te
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-compacted\"}"}}`),
}
updated, _ := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
- gotItems := gjson.GetBytes(updated, "input").Array()
+ gotItems := ojson.GetBytes(updated, "input").Array()
if len(gotItems) != 4 || gotItems[1].Get("encrypted_content").String() != retainedEncrypted {
t.Fatalf("retained turn reasoning was not restored: %s", updated)
}
@@ -901,7 +901,7 @@ func TestCodexExecutorReasoningReplayCacheMatchesNewestDuplicateAssistantAfterCo
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-duplicate-compaction\"}"}}`),
}
updated, _ := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
- gotItems := gjson.GetBytes(updated, "input").Array()
+ gotItems := ojson.GetBytes(updated, "input").Array()
if len(gotItems) != 4 || gotItems[1].Get("encrypted_content").String() != newEncrypted {
t.Fatalf("newest duplicate assistant turn was not retained: %s", updated)
}
@@ -923,7 +923,7 @@ func TestCodexExecutorReasoningReplayCacheUsesRequestPrefixForDuplicateOutOfOrde
`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Done"}]},` +
`{"type":"message","role":"user","content":"third"}` +
`]}`)
- inputItems := gjson.GetBytes(body, "input").Array()
+ inputItems := ojson.GetBytes(body, "input").Array()
baseScope := codexReasoningReplayScope{
modelName: "gpt-5.4",
sessionKey: "claude:session-duplicate-prefix:agent:main",
@@ -948,7 +948,7 @@ func TestCodexExecutorReasoningReplayCacheUsesRequestPrefixForDuplicateOutOfOrde
Payload: []byte(`{"metadata":{"user_id":"{\"session_id\":\"session-duplicate-prefix\"}"}}`),
}
updated, _ := applyCodexReasoningReplayCache(context.Background(), sdktranslator.FromString("claude"), req, cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")}, body)
- gotItems := gjson.GetBytes(updated, "input").Array()
+ gotItems := ojson.GetBytes(updated, "input").Array()
if len(gotItems) != 7 || gotItems[1].Get("encrypted_content").String() != oldEncrypted || gotItems[4].Get("encrypted_content").String() != newEncrypted {
t.Fatalf("duplicate out-of-order turns were not matched by request prefix: %s", updated)
}
@@ -988,13 +988,13 @@ func TestCodexExecutorReasoningReplayCacheDropsFunctionCallWithoutMatchingOutput
if replayScope.modelName != scope.modelName || replayScope.sessionKey != scope.sessionKey {
t.Fatalf("replay scope = %#v, want model/session %#v", replayScope, scope)
}
- if got := gjson.GetBytes(updated, "input.0.role").String(); got != "user" {
+ if got := ojson.GetBytes(updated, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want detached turn to be skipped; body=%s", got, string(updated))
}
- if gjson.GetBytes(updated, `input.#(type=="reasoning")`).Exists() {
+ if ojson.GetBytes(updated, `input.#(type=="reasoning")`).Exists() {
t.Fatalf("detached turn reasoning should not move to the front; body=%s", string(updated))
}
- if gjson.GetBytes(updated, `input.#(call_id=="call_dropped")`).Exists() {
+ if ojson.GetBytes(updated, `input.#(call_id=="call_dropped")`).Exists() {
t.Fatalf("cached function_call without matching output should not be replayed; body=%s", string(updated))
}
}
@@ -1071,32 +1071,32 @@ func TestCodexExecutorReasoningReplayCacheMatchesShortenedClaudeToolResultCallID
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
t.Fatalf("input.0.type = %q, want initial user message; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
t.Fatalf("input.1.type = %q, want cached reasoning; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
+ if got := ojson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
t.Fatalf("input.2.type = %q, want cached function_call; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.2.call_id").String(); got != shortCallID {
+ if got := ojson.GetBytes(secondBody, "input.2.call_id").String(); got != shortCallID {
t.Fatalf("input.2.call_id = %q, want shortened call_id %q; body=%s", got, shortCallID, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
+ if got := ojson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
t.Fatalf("input.3.type = %q, want function_call_output after cached call; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.3.call_id").String(); got != shortCallID {
+ if got := ojson.GetBytes(secondBody, "input.3.call_id").String(); got != shortCallID {
t.Fatalf("input.3.call_id = %q, want shortened call_id %q; body=%s", got, shortCallID, string(secondBody))
}
}
func TestCodexReplayPrefixFingerprintsMatchesDirectComputation(t *testing.T) {
- items := []gjson.Result{
- gjson.Parse(`{"type":"message","role":"user","content":"a"}`),
- gjson.Parse(`{"type":"reasoning","encrypted_content":"abc"}`),
- gjson.Parse(`{"type":"function_call","call_id":"call_1"}`),
- gjson.Parse(`{"type":"function_call_output","call_id":"call_1","output":"ok"}`),
+ items := []ojson.Result{
+ ojson.Parse(`{"type":"message","role":"user","content":"a"}`),
+ ojson.Parse(`{"type":"reasoning","encrypted_content":"abc"}`),
+ ojson.Parse(`{"type":"function_call","call_id":"call_1"}`),
+ ojson.Parse(`{"type":"function_call_output","call_id":"call_1","output":"ok"}`),
}
cache := newCodexReplayPrefixFingerprints(items)
// Out-of-order and repeated probes mirror the downward anchor scan.
diff --git a/internal/runtime/executor/codex_executor_request.go b/internal/runtime/executor/codex_executor_request.go
index 3f9bb2d9c18..a87a36555be 100644
--- a/internal/runtime/executor/codex_executor_request.go
+++ b/internal/runtime/executor/codex_executor_request.go
@@ -11,6 +11,7 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/klauspost/compress/zstd"
@@ -24,8 +25,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -43,8 +42,14 @@ func translateCodexRequestPair(from, to sdktranslator.Format, model string, orig
}
func translateCodexRequestPairWithUpdateIntent(from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte, bool, error) {
+ return translateCodexRequestPairWithUpdateIntentContext(context.Background(), from, to, model, originalPayload, payload, stream, preserveEmptyThinkingBlocks...)
+}
+
+func translateCodexRequestPairWithUpdateIntentContext(ctx context.Context, from, to sdktranslator.Format, model string, originalPayload, payload []byte, stream bool, preserveEmptyThinkingBlocks ...bool) ([]byte, []byte, bool, error) {
isCompat := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
- ctx := context.Background()
+ if ctx == nil {
+ ctx = context.Background()
+ }
translate := func(raw []byte) ([]byte, bool, error) {
if isCompat && from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex {
body, err := helps.TranslateRequestReturningError(ctx, nil, nil, from, to, model, raw, stream, true)
@@ -62,6 +67,28 @@ func translateCodexRequestPairWithUpdateIntent(from, to sdktranslator.Format, mo
return originalTranslated, body, changed, err
}
+// normalizeCodexClaudeOutputBudgetDoc bridges Claude's smaller valid budgets to Codex's minimum.
+func normalizeCodexClaudeOutputBudgetDoc(doc *ojson.Doc, from sdktranslator.Format, payload []byte) {
+ if doc == nil || from != sdktranslator.FormatClaude {
+ return
+ }
+ requested := ojson.GetBytesNoCopy(payload, "max_tokens")
+ if !requested.Exists() {
+ requested = ojson.GetBytesNoCopy(payload, "max_output_tokens")
+ }
+ if requested.Type != ojson.Number {
+ return
+ }
+ requestedTokens, errParse := strconv.ParseInt(requested.Raw, 10, 64)
+ if errParse != nil || requestedTokens < 1 || requestedTokens >= 16 {
+ return
+ }
+ translated := doc.Get("max_output_tokens")
+ if translated.Type == ojson.Number && translated.Int() > 0 && translated.Int() < 16 {
+ _ = doc.Set("max_output_tokens", 16)
+ }
+}
+
// PrepareRequest injects Codex credentials into the outgoing HTTP request.
func (e *CodexExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
@@ -109,13 +136,21 @@ func (e *CodexExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth
}
func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, rawJSON []byte, auth *cliproxyauth.Auth, headerSets ...http.Header) (*http.Request, []byte, error) {
+ doc := ojson.NewDoc(rawJSON)
+ return e.cacheHelperDoc(ctx, from, url, req, doc, auth, headerSets...)
+}
+
+func (e *CodexExecutor) cacheHelperDoc(ctx context.Context, from sdktranslator.Format, url string, req cliproxyexecutor.Request, doc *ojson.Doc, auth *cliproxyauth.Auth, headerSets ...http.Header) (*http.Request, []byte, error) {
var headers http.Header
if len(headerSets) > 0 {
headers = headerSets[0]
}
var cache helps.CodexCache
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
- modelName := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ modelName := ""
+ if doc != nil {
+ modelName = strings.TrimSpace(doc.Get("model").String())
+ }
if modelName == "" {
modelName = thinking.ParseSuffix(req.Model).ModelName
}
@@ -127,13 +162,13 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
cache = cached
}
} else if sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) {
- promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key")
+ promptCacheKey := ojson.GetBytesNoCopy(req.Payload, "prompt_cache_key")
if promptCacheKey.Exists() {
- cache.ID = promptCacheKey.String()
+ cache.ID = strings.Clone(promptCacheKey.String())
}
} else if sourceFormatEqual(from, sdktranslator.FormatOpenAI) {
- if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
- cache.ID = strings.TrimSpace(promptCacheKey.String())
+ if promptCacheKey := ojson.GetBytesNoCopy(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
+ cache.ID = strings.Clone(strings.TrimSpace(promptCacheKey.String()))
}
if cache.ID == "" {
cache.ID = helps.ProviderSessionUUID("codex", req.Metadata)
@@ -149,10 +184,14 @@ func (e *CodexExecutor) cacheHelper(ctx context.Context, from sdktranslator.Form
}
if cache.ID != "" {
- rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID)
+ helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", cache.ID)
+ }
+ helps.SanitizeCodexInputItemIDsDoc(doc)
+ helps.FinalizePayloadDoc(ctx, doc)
+ var rawJSON []byte
+ if doc != nil {
+ rawJSON = doc.Bytes()
}
- rawJSON = helps.SanitizeCodexInputItemIDs(rawJSON)
- rawJSON = helps.FinalizePayload(ctx, rawJSON)
wireBody := rawJSON
if e.cfg != nil && e.cfg.Codex.UpstreamRequestCompression && auth != nil &&
!codexAuthUsesAPIKey(auth) && !codexUseSIWCForRequest(ctx, auth, headers) &&
@@ -342,7 +381,6 @@ func applyModelHeaderOverrides(headers http.Header, modelName string) {
// applyCodexDirectImageHeaders sets Codex upstream headers for direct /images/* calls.
// Downstream client User-Agent values are not forwarded to reduce Cloudflare 1010 blocks.
func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, clientHeaders ...http.Header) {
- useSIWC := codexUseSIWCForRequest(r.Context(), auth, clientHeaders...)
var ginHeaders http.Header
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
ginHeaders = clientHeaders[0].Clone()
@@ -351,7 +389,7 @@ func applyCodexDirectImageHeaders(r *http.Request, auth *cliproxyauth.Auth, toke
ginHeaders = ginCtx.Request.Header.Clone()
ginHeaders.Del("User-Agent")
}
- applyCodexHeadersWithSIWC(r, auth, useSIWC, token, stream, cfg, ginHeaders)
+ applyCodexHeadersWithSIWC(r, auth, false, token, stream, cfg, ginHeaders)
}
func codexSIWCUserAgent(cfg *config.Config) string {
@@ -363,42 +401,105 @@ func codexSIWCUserAgent(cfg *config.Config) string {
}
func applyCodexSIWCRequestBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
- body = helps.SetBoolIfDifferent(body, "store", false)
- body = helps.SetBoolIfDifferent(body, "stream", true)
- return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...)
+ doc := ojson.NewDoc(body)
+ if !applyCodexSIWCRequestBodyRulesDoc(doc, hasExplicitInstructions, opts...) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func applyCodexSIWCRequestBodyRulesDoc(doc *ojson.Doc, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) bool {
+ if doc == nil {
+ return false
+ }
+ changed := helps.SetBoolIfDifferentDoc(doc, "store", false)
+ if helps.SetBoolIfDifferentDoc(doc, "stream", true) {
+ changed = true
+ }
+ if stripCodexSIWCUnsupportedFieldsDoc(doc, hasExplicitInstructions, opts...) {
+ changed = true
+ }
+ return changed
}
func applyCodexSIWCWebsocketBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
- body = helps.SetBoolIfDifferent(body, "store", false)
- body, _ = sjson.DeleteBytes(body, "stream")
- body, _ = sjson.DeleteBytes(body, "background")
- return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...)
+ doc := ojson.NewDoc(body)
+ if !applyCodexSIWCWebsocketBodyRulesDoc(doc, hasExplicitInstructions, opts...) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func applyCodexSIWCWebsocketBodyRulesDoc(doc *ojson.Doc, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) bool {
+ if doc == nil {
+ return false
+ }
+ changed := helps.SetBoolIfDifferentDoc(doc, "store", false)
+ if doc.Delete("stream") == nil {
+ changed = true
+ }
+ if doc.Delete("background") == nil {
+ changed = true
+ }
+ if stripCodexSIWCUnsupportedFieldsDoc(doc, hasExplicitInstructions, opts...) {
+ changed = true
+ }
+ return changed
}
func applyCodexSIWCCompactBodyRules(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
- body, _ = sjson.DeleteBytes(body, "stream")
- body, _ = sjson.DeleteBytes(body, "store")
- return stripCodexSIWCUnsupportedFields(body, hasExplicitInstructions, opts...)
+ doc := ojson.NewDoc(body)
+ if !applyCodexSIWCCompactBodyRulesDoc(doc, hasExplicitInstructions, opts...) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func applyCodexSIWCCompactBodyRulesDoc(doc *ojson.Doc, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
+ if doc.Delete("stream") == nil {
+ changed = true
+ }
+ if doc.Delete("store") == nil {
+ changed = true
+ }
+ if stripCodexSIWCUnsupportedFieldsDoc(doc, hasExplicitInstructions, opts...) {
+ changed = true
+ }
+ return changed
}
func stripCodexSIWCUnsupportedFields(body []byte, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) []byte {
+ doc := ojson.NewDoc(body)
+ if !stripCodexSIWCUnsupportedFieldsDoc(doc, hasExplicitInstructions, opts...) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func stripCodexSIWCUnsupportedFieldsDoc(doc *ojson.Doc, hasExplicitInstructions bool, opts ...cliproxyexecutor.Options) bool {
+ if doc == nil {
+ return false
+ }
// SIWC subscriptions reject configuration_update input items, so degrade
// them to the classic top-level reasoning.effort and accept the cache loss.
// An explicit model thinking suffix is an operator override: the thinking
// layer has already resolved it into the top-level effort, so only the
// unsupported items are removed in that case.
- body = thinking.CollapseConfigurationUpdates(body, len(opts) > 0 && opts[0].KeepTopLevelEffort)
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "prompt_cache_options")
- body, _ = sjson.DeleteBytes(body, "max_output_tokens")
- body, _ = sjson.DeleteBytes(body, "max_completion_tokens")
- body, _ = sjson.DeleteBytes(body, "temperature")
+ thinking.CollapseConfigurationUpdatesDoc(doc, len(opts) > 0 && opts[0].KeepTopLevelEffort)
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("prompt_cache_options")
+ _ = doc.Delete("max_completion_tokens")
+ _ = doc.Delete("temperature")
if !hasExplicitInstructions {
- if inst := gjson.GetBytes(body, "instructions"); inst.Exists() && strings.TrimSpace(inst.String()) == "" {
- body, _ = sjson.DeleteBytes(body, "instructions")
+ if inst := doc.Get("instructions"); inst.Exists() && strings.TrimSpace(inst.String()) == "" {
+ _ = doc.Delete("instructions")
}
}
- return body
+ return true
}
func applyCodexHeadersFromSources(r *http.Request, auth *cliproxyauth.Auth, token string, stream bool, cfg *config.Config, ginHeaders http.Header) {
@@ -509,7 +610,7 @@ func applyCodexRoutingHint(ctx context.Context, headers http.Header, auth *clipr
return
}
hint := "model=" + model
- if tier := gjson.GetBytes(upstreamBody, "service_tier"); tier.Type == gjson.String {
+ if tier := ojson.GetBytesNoCopy(upstreamBody, "service_tier"); tier.Type == ojson.String {
if value := strings.TrimSpace(tier.String()); value != "" {
hint += ";tier=" + value
}
@@ -561,11 +662,30 @@ func normalizeCodexInstructions(body []byte, nativeRequest ...bool) []byte {
if len(nativeRequest) > 0 && nativeRequest[0] {
return body
}
- instructions := gjson.GetBytes(body, "instructions")
- if !instructions.Exists() || instructions.Type == gjson.Null {
- body, _ = sjson.SetBytes(body, "instructions", "")
+ doc := ojson.NewDoc(body)
+ if !normalizeCodexInstructionsDoc(doc, nativeRequest...) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func normalizeCodexInstructionsDoc(doc *ojson.Doc, nativeRequest ...bool) bool {
+ if doc == nil || (len(nativeRequest) > 0 && nativeRequest[0]) {
+ return false
}
- return body
+ instructions := doc.Get("instructions")
+ if !instructions.Exists() || instructions.Type == ojson.Null {
+ return doc.Set("instructions", "") == nil
+ }
+ return false
+}
+
+func isCodexResponsesLiteRequestDoc(doc *ojson.Doc, headers http.Header) bool {
+ return helps.CodexResponsesLiteRequestDoc(doc, headers)
+}
+
+func isCodexResponsesLiteModel(model string) bool {
+ return helps.CodexModelUsesResponsesLite(model)
}
var imageGenToolJSON = []byte(`{"type":"image_generation","output_format":"png"}`)
@@ -591,12 +711,23 @@ func ensureWebSearchToolForHistory(body []byte, headers http.Header) []byte {
if !bytes.Contains(body, []byte("web_search_call")) {
return body
}
- input := util.GetGJSONBytesNoCopy(body, "input")
- if !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !ensureWebSearchToolForHistoryDoc(doc, headers) {
return body
}
+ return doc.Bytes()
+}
+
+func ensureWebSearchToolForHistoryDoc(doc *ojson.Doc, headers http.Header) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() || !strings.Contains(input.Raw, "web_search_call") {
+ return false
+ }
hasWebSearchCall := false
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if strings.TrimSpace(item.Get("type").String()) == "web_search_call" {
hasWebSearchCall = true
return false
@@ -604,19 +735,19 @@ func ensureWebSearchToolForHistory(body []byte, headers http.Header) []byte {
return true
})
if !hasWebSearchCall {
- return body
+ return false
}
- tools := util.GetGJSONBytesNoCopy(body, "tools")
+ tools := doc.Get("tools")
if tools.IsArray() {
for _, t := range tools.Array() {
if isCodexWebSearchToolType(t.Get("type").String()) {
- return body
+ return false
}
}
}
- if util.IsCodexResponsesLiteRequest(body, headers) {
+ if isCodexResponsesLiteRequestDoc(doc, headers) {
items := input.Array()
rebuilt := make([]string, 0, len(items))
for _, item := range items {
@@ -625,25 +756,13 @@ func ensureWebSearchToolForHistory(body []byte, headers http.Header) []byte {
}
rebuilt = append(rebuilt, item.Raw)
}
- updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(rebuilt, ",")+"]"))
- if errSet != nil {
- return body
- }
- return updated
+ return doc.SetRawBytes("input", []byte("["+strings.Join(rebuilt, ",")+"]")) == nil
}
if !tools.Exists() || !tools.IsArray() {
- updated, errSet := sjson.SetRawBytes(body, "tools", webSearchToolArrayJSON)
- if errSet != nil {
- return body
- }
- return updated
- }
- updated, errSet := sjson.SetRawBytes(body, "tools.-1", webSearchToolJSON)
- if errSet != nil {
- return body
+ return doc.SetRawBytes("tools", webSearchToolArrayJSON) == nil
}
- return updated
+ return doc.SetRawBytes("tools.-1", webSearchToolJSON) == nil
}
func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool {
@@ -656,7 +775,7 @@ func isCodexFreePlanAuth(auth *cliproxyauth.Auth) bool {
return strings.EqualFold(strings.TrimSpace(auth.Attributes["plan_type"]), "free")
}
-func isImageGenerationFunctionTool(tool gjson.Result) bool {
+func isImageGenerationFunctionTool(tool ojson.Result) bool {
switch tool.Get("type").String() {
case "function":
return tool.Get("name").String() == "image_gen.imagegen"
@@ -678,51 +797,125 @@ func isImageGenerationFunctionTool(tool gjson.Result) bool {
}
func ensureImageGenerationTool(body []byte, baseModel string, auth *cliproxyauth.Auth, headers http.Header) []byte {
- if util.IsCodexResponsesLiteRequest(body, headers) {
+ doc := ojson.NewDoc(body)
+ if !ensureImageGenerationToolDoc(doc, baseModel, auth, headers) {
return body
}
+ return doc.Bytes()
+}
+
+func ensureImageGenerationToolDoc(doc *ojson.Doc, baseModel string, auth *cliproxyauth.Auth, headers http.Header) bool {
+ if doc == nil || isCodexResponsesLiteRequestDoc(doc, headers) || isCodexResponsesLiteModel(baseModel) {
+ return false
+ }
if strings.HasSuffix(baseModel, "spark") {
- return body
+ return false
}
if isCodexFreePlanAuth(auth) {
- return body
+ return false
}
- tools := gjson.GetBytes(body, "tools")
+ tools := doc.Get("tools")
if !tools.Exists() || !tools.IsArray() {
- body, _ = sjson.SetRawBytes(body, "tools", imageGenToolArrayJSON)
- return body
+ return doc.SetRawBytes("tools", imageGenToolArrayJSON) == nil
}
for _, t := range tools.Array() {
if t.Get("type").String() == "image_generation" || isImageGenerationFunctionTool(t) {
- return body
+ return false
+ }
+ }
+ return doc.SetRawBytes("tools.-1", imageGenToolJSON) == nil
+}
+
+func resolveCodexGPTImage2BaseModel(cfg *config.Config) string {
+ if cfg == nil {
+ return codexOpenAIImagesMainModel
+ }
+ model := strings.TrimSpace(cfg.GPTImage2BaseModel)
+ if model == "" {
+ return codexOpenAIImagesMainModel
+ }
+ if !strings.HasPrefix(strings.ToLower(model), "gpt-") || isCodexResponsesLiteModel(model) {
+ return codexOpenAIImagesMainModel
+ }
+ return model
+}
+
+// normalizeCodexImageGenerationToolsDoc strips parameters rejected by the
+// hosted image_generation tool on Codex /responses (which binds to
+// gpt-image-2-codex and rejects the complexity parameter).
+func normalizeCodexImageGenerationToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
+ stripAtPath := func(toolsPath string) {
+ tools := doc.Get(toolsPath)
+ if !tools.IsArray() {
+ return
+ }
+ for idx, tool := range tools.Array() {
+ if strings.TrimSpace(tool.Get("type").String()) != "image_generation" {
+ continue
+ }
+ if tool.Get("complexity").Exists() {
+ if doc.Delete(fmt.Sprintf("%s.%d.complexity", toolsPath, idx)) == nil {
+ changed = true
+ }
+ }
}
}
- body, _ = sjson.SetRawBytes(body, "tools.-1", imageGenToolJSON)
- return body
+ stripAtPath("tools")
+ input := doc.Get("input")
+ if input.IsArray() {
+ for idx, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
+ continue
+ }
+ stripAtPath(fmt.Sprintf("input.%d.tools", idx))
+ }
+ }
+ return changed
}
func normalizeCodexParallelToolCalls(body []byte, headers http.Header) []byte {
- if util.IsCodexResponsesLiteRequest(body, headers) {
- body = helps.SetBoolIfDifferent(body, "parallel_tool_calls", false)
+ doc := ojson.NewDoc(body)
+ if !normalizeCodexParallelToolCallsDoc(doc, headers) {
return body
}
- return normalizeCodexParallelToolCallsForTools(body)
+ return doc.Bytes()
+}
+
+func normalizeCodexParallelToolCallsDoc(doc *ojson.Doc, headers http.Header) bool {
+ if doc == nil {
+ return false
+ }
+ if isCodexResponsesLiteRequestDoc(doc, headers) {
+ return helps.SetBoolIfDifferentDoc(doc, "parallel_tool_calls", false)
+ }
+ return normalizeCodexParallelToolCallsForToolsDoc(doc)
}
func normalizeCodexParallelToolCallsForTools(body []byte) []byte {
- if !gjson.GetBytes(body, "parallel_tool_calls").Exists() {
+ doc := ojson.NewDoc(body)
+ if !normalizeCodexParallelToolCallsForToolsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
- tools := gjson.GetBytes(body, "tools")
+func normalizeCodexParallelToolCallsForToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil || !doc.Get("parallel_tool_calls").Exists() {
+ return false
+ }
+
+ tools := doc.Get("tools")
hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
if hasTools {
- return body
+ return false
}
- body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
- return body
+ return doc.Delete("parallel_tool_calls") == nil
}
func publishCodexImageToolUsage(ctx context.Context, reporter *helps.UsageReporter, body []byte, completedData []byte) {
@@ -739,14 +932,14 @@ func publishCodexImageToolUsageWithOutputItems(ctx context.Context, reporter *he
}
func codexImageGenerationToolModel(body []byte) string {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if tools.IsArray() {
for _, tool := range tools.Array() {
if tool.Get("type").String() != "image_generation" {
continue
}
if model := strings.TrimSpace(tool.Get("model").String()); model != "" {
- return model
+ return strings.Clone(model)
}
break
}
diff --git a/internal/runtime/executor/codex_executor_routing_hint_test.go b/internal/runtime/executor/codex_executor_routing_hint_test.go
index a163e7989a0..b1f951e1210 100644
--- a/internal/runtime/executor/codex_executor_routing_hint_test.go
+++ b/internal/runtime/executor/codex_executor_routing_hint_test.go
@@ -7,20 +7,20 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type capturedCodexRequest struct {
path string
- model gjson.Result
+ model ojson.Result
routingHint string
hasHint bool
- serviceTier gjson.Result
+ serviceTier ojson.Result
}
func newCodexRoutingHintServer(t *testing.T, captured *capturedCodexRequest) *httptest.Server {
@@ -28,10 +28,10 @@ func newCodexRoutingHintServer(t *testing.T, captured *capturedCodexRequest) *ht
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
captured.path = r.URL.Path
- captured.model = gjson.GetBytes(body, "model")
+ captured.model = ojson.GetBytes(body, "model")
_, captured.hasHint = r.Header[http.CanonicalHeaderKey(codexRoutingHintHeader)]
captured.routingHint = r.Header.Get(codexRoutingHintHeader)
- captured.serviceTier = gjson.GetBytes(body, "service_tier")
+ captured.serviceTier = ojson.GetBytes(body, "service_tier")
if r.URL.Path == "/responses/compact" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"cmp_1","object":"response.compaction","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
@@ -113,7 +113,7 @@ func TestCodexExecutorRoutingHintCarriesRequestedTier(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if captured.model.Type != gjson.String || captured.model.String() != "gpt-5.5" {
+ if captured.model.Type != ojson.String || captured.model.String() != "gpt-5.5" {
t.Fatalf("body model = %s, want gpt-5.5", captured.model.Raw)
}
if tc.wantHint == "" {
@@ -153,7 +153,7 @@ func TestCodexExecutorCompactRoutingHintCarriesRequestedTier(t *testing.T) {
if captured.path != "/responses/compact" {
t.Fatalf("upstream path = %q, want /responses/compact", captured.path)
}
- if captured.model.Type != gjson.String || captured.model.String() != "gpt-5.5" {
+ if captured.model.Type != ojson.String || captured.model.String() != "gpt-5.5" {
t.Fatalf("body model = %s, want gpt-5.5", captured.model.Raw)
}
if captured.serviceTier.String() != "priority" {
diff --git a/internal/runtime/executor/codex_executor_signature_test.go b/internal/runtime/executor/codex_executor_signature_test.go
index 9947580ad1f..ff23e2f1af7 100644
--- a/internal/runtime/executor/codex_executor_signature_test.go
+++ b/internal/runtime/executor/codex_executor_signature_test.go
@@ -2,18 +2,18 @@ package executor
import (
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func validCodexReasoningEncryptedContentForTest() string {
@@ -63,22 +63,22 @@ func TestCodexExecutorDropsInvalidReasoningEncryptedContentFromFinalRequest(t *t
t.Fatalf("Execute error: %v", err)
}
- if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
t.Fatalf("invalid reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.0.id").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.id").Exists() {
t.Fatalf("invalid reasoning id should be stripped under store=false default; body=%s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
t.Fatalf("non-string reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.1.id").Exists() {
+ if ojson.GetBytes(gotBody, "input.1.id").Exists() {
t.Fatalf("non-string reasoning id should be stripped under store=false default; body=%s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.2.encrypted_content").String(); got != validEncryptedContent {
+ if got := ojson.GetBytes(gotBody, "input.2.encrypted_content").String(); got != validEncryptedContent {
t.Fatalf("valid reasoning encrypted_content = %q, want preserved", got)
}
- if got := gjson.GetBytes(gotBody, "input.3.encrypted_content").String(); got != "leave-message-alone" {
+ if got := ojson.GetBytes(gotBody, "input.3.encrypted_content").String(); got != "leave-message-alone" {
t.Fatalf("non-reasoning encrypted_content = %q, want untouched", got)
}
}
@@ -109,7 +109,7 @@ func TestCodexExecutorExecuteStreamDropsInvalidReasoningEncryptedContentFromFina
}
for range result.Chunks {
}
- if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
t.Fatalf("invalid stream reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
}
}
@@ -139,7 +139,7 @@ func TestCodexExecutorCompactDropsInvalidReasoningEncryptedContentFromFinalReque
if err != nil {
t.Fatalf("Execute compact error: %v", err)
}
- if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
t.Fatalf("invalid compact reasoning encrypted_content exists, want removed; body=%s", string(gotBody))
}
}
@@ -198,7 +198,7 @@ func TestCodexExecutorPreservesReasoningTextForCompatModelInResponsesRequest(t *
t.Fatalf("Execute error: %v", err)
}
- reasoningItem := gjson.GetBytes(gotBody, "input.0")
+ reasoningItem := ojson.GetBytes(gotBody, "input.0")
if !reasoningItem.Exists() || reasoningItem.Get("type").String() != "reasoning" {
t.Fatalf("expected reasoning item at input.0, got: %s", string(gotBody))
}
@@ -266,7 +266,7 @@ func TestCodexExecutorExecuteStreamPreservesReasoningTextForCompatModelInRespons
for range streamResult.Chunks {
}
- reasoningItem := gjson.GetBytes(gotBody, "input.0")
+ reasoningItem := ojson.GetBytes(gotBody, "input.0")
if !reasoningItem.Exists() || reasoningItem.Get("type").String() != "reasoning" {
t.Fatalf("expected reasoning item at input.0, got: %s", string(gotBody))
}
@@ -333,7 +333,7 @@ func TestCodexExecutorCompactPreservesReasoningTextForCompatModelInResponsesRequ
t.Fatalf("Execute compact error: %v", err)
}
- reasoningItem := gjson.GetBytes(gotBody, "input.0")
+ reasoningItem := ojson.GetBytes(gotBody, "input.0")
if !reasoningItem.Exists() || reasoningItem.Get("type").String() != "reasoning" {
t.Fatalf("expected reasoning item at input.0, got: %s", string(gotBody))
}
@@ -401,11 +401,11 @@ func TestCodexExecutorHonorsAuthoritativeResolvedModelInfoIsCompatFalse(t *testi
t.Fatalf("Execute error: %v", err)
}
- content := gjson.GetBytes(gotBody, "input.0.content")
+ content := ojson.GetBytes(gotBody, "input.0.content")
if !content.Exists() || !content.IsArray() || len(content.Array()) != 0 {
t.Fatalf("content should be cleared to [] when authoritative IsCompat is false; body=%s", string(gotBody))
}
- if gotSummary := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); gotSummary != "should be promoted and cleared" {
+ if gotSummary := ojson.GetBytes(gotBody, "input.0.summary.0.text").String(); gotSummary != "should be promoted and cleared" {
t.Fatalf("summary should have promoted reasoning_text; body=%s", string(gotBody))
}
}
@@ -472,7 +472,7 @@ func TestCodexExecutorDisambiguatesConfigIndex(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- content := gjson.GetBytes(gotBody, "input.0.content")
+ content := ojson.GetBytes(gotBody, "input.0.content")
if !content.Exists() || !content.IsArray() || len(content.Array()) == 0 {
t.Fatalf("expected index 1 (IsCompat:true) to preserve content; body=%s", string(gotBody))
}
diff --git a/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go b/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go
index 86610c98888..5794e263c55 100644
--- a/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go
+++ b/internal/runtime/executor/codex_executor_siwc_configuration_update_test.go
@@ -8,12 +8,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// SIWC (Sign in with ChatGPT) subscriptions reject configuration_update input
@@ -78,10 +78,10 @@ func TestStripCodexSIWCUnsupportedFieldsConfigurationUpdates(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
got := stripCodexSIWCUnsupportedFields([]byte(tc.body), false)
- if input := gjson.GetBytes(got, "input").Raw; input != tc.wantInput {
+ if input := ojson.GetBytes(got, "input").Raw; input != tc.wantInput {
t.Fatalf("input = %s, want %s; body=%s", input, tc.wantInput, got)
}
- if effort := gjson.GetBytes(got, "reasoning.effort").String(); effort != tc.wantEffort {
+ if effort := ojson.GetBytes(got, "reasoning.effort").String(); effort != tc.wantEffort {
t.Fatalf("reasoning.effort = %q, want %q; body=%s", effort, tc.wantEffort, got)
}
})
@@ -90,10 +90,10 @@ func TestStripCodexSIWCUnsupportedFieldsConfigurationUpdates(t *testing.T) {
t.Run("summary and other reasoning fields are preserved", func(t *testing.T) {
body := `{"reasoning":{"effort":"medium","summary":"auto","other":7},"input":[{"type":"configuration_update","reasoning":{"effort":"high"}},{"role":"user","content":"hi"}]}`
got := stripCodexSIWCUnsupportedFields([]byte(body), false)
- if summary := gjson.GetBytes(got, "reasoning.summary").String(); summary != "auto" {
+ if summary := ojson.GetBytes(got, "reasoning.summary").String(); summary != "auto" {
t.Fatalf("reasoning.summary = %q, want auto; body=%s", summary, got)
}
- if other := gjson.GetBytes(got, "reasoning.other").Int(); other != 7 {
+ if other := ojson.GetBytes(got, "reasoning.other").Int(); other != 7 {
t.Fatalf("reasoning.other = %d, want 7; body=%s", other, got)
}
})
@@ -142,12 +142,12 @@ func assertCodexSIWCUpdateStripped(t *testing.T, gotBody []byte, wantEffort stri
if len(gotBody) == 0 {
t.Fatal("upstream body was not captured")
}
- for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ for _, item := range ojson.GetBytes(gotBody, "input").Array() {
if item.Get("type").String() == "configuration_update" {
t.Fatalf("SIWC upstream body still contains configuration_update: %s", gotBody)
}
}
- if effort := gjson.GetBytes(gotBody, "reasoning.effort").String(); effort != wantEffort {
+ if effort := ojson.GetBytes(gotBody, "reasoning.effort").String(); effort != wantEffort {
t.Fatalf("SIWC upstream reasoning.effort = %q, want %q; body=%s", effort, wantEffort, gotBody)
}
}
@@ -256,7 +256,7 @@ func TestCodexWebsocketsExecuteUseSIWCStripsConfigurationUpdates(t *testing.T) {
select {
case gotBody := <-captured:
assertCodexSIWCUpdateStripped(t, gotBody, "high")
- if cacheKey := gjson.GetBytes(gotBody, "prompt_cache_key").String(); cacheKey != codexSIWCUpdatePromptCacheKey {
+ if cacheKey := ojson.GetBytes(gotBody, "prompt_cache_key").String(); cacheKey != codexSIWCUpdatePromptCacheKey {
t.Fatalf("prompt_cache_key = %q, want %q; body=%s", cacheKey, codexSIWCUpdatePromptCacheKey, gotBody)
}
case <-time.After(5 * time.Second):
@@ -367,7 +367,7 @@ func TestCodexExecutorWithoutSIWCKeepsConfigurationUpdates(t *testing.T) {
}
updates := 0
- for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ for _, item := range ojson.GetBytes(gotBody, "input").Array() {
if item.Get("type").String() == "configuration_update" {
updates++
}
@@ -375,7 +375,7 @@ func TestCodexExecutorWithoutSIWCKeepsConfigurationUpdates(t *testing.T) {
if updates != 2 {
t.Fatalf("non-SIWC upstream configuration_update count = %d, want 2; body=%s", updates, gotBody)
}
- if effort := gjson.GetBytes(gotBody, "reasoning.effort").String(); effort != "medium" {
+ if effort := ojson.GetBytes(gotBody, "reasoning.effort").String(); effort != "medium" {
t.Fatalf("non-SIWC upstream reasoning.effort = %q, want pinned %q; body=%s", effort, "medium", gotBody)
}
}
diff --git a/internal/runtime/executor/codex_executor_spawn_agent_test.go b/internal/runtime/executor/codex_executor_spawn_agent_test.go
index f9c81b1d3f8..217e6a287a5 100644
--- a/internal/runtime/executor/codex_executor_spawn_agent_test.go
+++ b/internal/runtime/executor/codex_executor_spawn_agent_test.go
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -16,7 +17,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorOptimizeMultiAgentV2(t *testing.T) {
@@ -37,13 +37,13 @@ func TestCodexExecutorOptimizeMultiAgentV2(t *testing.T) {
upstreamBody, _ = io.ReadAll(request.Body)
if request.URL.Path == "/responses/compact" {
w.Header().Set("Content-Type", "application/json")
- namespace := gjson.GetBytes(upstreamBody, "input.0.tools.0.name").String()
+ namespace := ojson.GetBytes(upstreamBody, "input.0.tools.0.name").String()
compact := fmt.Sprintf(`{"id":"resp_1","object":"response.compaction","output":[{"type":"function_call","name":"spawn_agent","namespace":%q,"arguments":"{}","call_id":"call_1"}]}`, namespace)
_, _ = w.Write([]byte(compact))
return
}
w.Header().Set("Content-Type", "text/event-stream")
- namespace := gjson.GetBytes(upstreamBody, "input.0.tools.0.name").String()
+ namespace := ojson.GetBytes(upstreamBody, "input.0.tools.0.name").String()
completed := fmt.Sprintf(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"type":"function_call","name":"spawn_agent","namespace":%q,"arguments":"{}","call_id":"call_1"}]}}`+"\n\n", namespace)
_, _ = w.Write([]byte(completed))
}))
@@ -191,7 +191,7 @@ func TestCodexExecutorIsCompatConvertsAgentMessage(t *testing.T) {
if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != tt.wantType {
t.Fatalf("input.1.type = %q, want %q; body=%s", message.Get("type").String(), tt.wantType, upstreamBody)
}
@@ -309,7 +309,7 @@ func TestCodexExecutorsMultiAgentV2UsesSelectedHomeModel(t *testing.T) {
}
}
upstreamPayload := <-capturedPayload
- message := gjson.GetBytes(upstreamPayload, "input.1")
+ message := ojson.GetBytes(upstreamPayload, "input.1")
if tc.isCompat {
if message.Get("type").String() != "message" || message.Get("role").String() != "user" {
t.Fatalf("selected compat model must receive a user message: %s", message.Raw)
@@ -372,7 +372,7 @@ func TestCodexExecutor_IsCompat_StripsAuthorAndRecipient_Issue6136(t *testing.T)
if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "message" {
t.Fatalf("input.1.type = %q, want message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -409,7 +409,7 @@ func TestCodexExecutor_IsCompat_StripsAuthorAndRecipient_Issue6136(t *testing.T)
for range streamResult.Chunks {
}
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "message" {
t.Fatalf("stream input.1.type = %q, want message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -438,7 +438,7 @@ func TestCodexExecutor_IsCompat_StripsAuthorAndRecipient_Issue6136(t *testing.T)
if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil {
t.Fatalf("Execute() compact error = %v", errExecute)
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "message" {
t.Fatalf("compact input.1.type = %q, want message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -467,7 +467,7 @@ func TestCodexExecutor_IsCompat_StripsAuthorAndRecipient_Issue6136(t *testing.T)
if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "message" {
t.Fatalf("input.1.type = %q, want message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -498,7 +498,7 @@ func TestCodexExecutor_IsCompat_StripsAuthorAndRecipient_Issue6136(t *testing.T)
if _, errExecute := executor.Execute(ctx, auth, req, opts); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "agent_message" {
t.Fatalf("input.1.type = %q, want agent_message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -567,7 +567,7 @@ func TestCodexExecutor_IsCompat_V8Layout_ClientScope_Issue6233(t *testing.T) {
t.Fatalf("Execute() error = %v", errExecute)
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "message" {
t.Fatalf("input.1.type = %q, want message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -599,7 +599,7 @@ func TestCodexExecutor_IsCompat_V8Layout_ClientScope_Issue6233(t *testing.T) {
}
}
- message := gjson.GetBytes(upstreamBody, "input.1")
+ message := ojson.GetBytes(upstreamBody, "input.1")
if message.Get("type").String() != "message" {
t.Fatalf("stream input.1.type = %q, want message; body=%s", message.Get("type").String(), upstreamBody)
}
@@ -662,7 +662,7 @@ func assertCodexSpawnAgentClientNamespace(t *testing.T, payload []byte) {
func assertCodexSpawnAgentRequestMessage(t *testing.T, payload []byte, enabled bool) {
t.Helper()
- message := gjson.GetBytes(payload, "input.1")
+ message := ojson.GetBytes(payload, "input.1")
if message.Get("type").String() != "agent_message" || message.Get("role").Exists() {
t.Fatalf("Codex executor changed outer agent message: %s", payload)
}
@@ -685,9 +685,9 @@ func assertCodexSpawnAgentRequestMessage(t *testing.T, payload []byte, enabled b
func assertCodexSpawnAgentOptimization(t *testing.T, payload []byte, modelID string, enabled bool) {
t.Helper()
- namespace := gjson.GetBytes(payload, "input.0.tools.0.name").String()
- description := gjson.GetBytes(payload, "input.0.tools.0.tools.0.description").String()
- encrypted := gjson.GetBytes(payload, "input.0.tools.0.tools.0.parameters.properties.message.encrypted")
+ namespace := ojson.GetBytes(payload, "input.0.tools.0.name").String()
+ description := ojson.GetBytes(payload, "input.0.tools.0.tools.0.description").String()
+ encrypted := ojson.GetBytes(payload, "input.0.tools.0.tools.0.parameters.properties.message.encrypted")
if enabled {
if namespace != "collaboration-optimize" {
t.Fatalf("optimized namespace = %q, want collaboration-optimize", namespace)
@@ -742,7 +742,7 @@ func TestCodexExecutorOptimizeMultiAgentV2RestoresDottedFlatToolName(t *testing.
streamClientPayload = append(streamClientPayload, chunk.Payload...)
}
assertCodexSpawnAgentClientNamespace(t, streamClientPayload)
- if name := gjson.GetBytes(streamClientPayload, "response.output.0.name").String(); name != "spawn_agent" {
+ if name := ojson.GetBytes(streamClientPayload, "response.output.0.name").String(); name != "spawn_agent" {
t.Fatalf("stream output name = %q, want spawn_agent", name)
}
@@ -752,9 +752,9 @@ func TestCodexExecutorOptimizeMultiAgentV2RestoresDottedFlatToolName(t *testing.
t.Fatalf("Execute() error = %v", errExecute)
}
assertCodexSpawnAgentClientNamespace(t, response.Payload)
- executeName := gjson.GetBytes(response.Payload, "output.0.name").String()
+ executeName := ojson.GetBytes(response.Payload, "output.0.name").String()
if executeName == "" {
- executeName = gjson.GetBytes(response.Payload, "response.output.0.name").String()
+ executeName = ojson.GetBytes(response.Payload, "response.output.0.name").String()
}
if executeName != "spawn_agent" {
t.Fatalf("execute output name = %q, want spawn_agent; payload=%s", executeName, response.Payload)
diff --git a/internal/runtime/executor/codex_executor_sse_events_test.go b/internal/runtime/executor/codex_executor_sse_events_test.go
index 28309d07cdd..200eef69b58 100644
--- a/internal/runtime/executor/codex_executor_sse_events_test.go
+++ b/internal/runtime/executor/codex_executor_sse_events_test.go
@@ -576,7 +576,7 @@ func TestCodexVerifyInferenceTranslatesPayloadAndRequiresCompletedEvent_Issue578
}
cfg.Codex.StreamBootstrapBuffering = false
- // 2. Real completed stream succeeds and translated upstream body strips max_output_tokens while setting stream=true.
+ // 2. Real completed stream succeeds and the translated body preserves VerifyInference's max_output_tokens=1 while setting stream=true.
sseResponse = "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"r2\",\"model\":\"gpt-5.4\"}}\n\n" +
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"pong\"}\n\n" +
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"r2\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"pong\"}]}]}}\n\n"
@@ -594,8 +594,8 @@ func TestCodexVerifyInferenceTranslatesPayloadAndRequiresCompletedEvent_Issue578
if upstreamJSON["stream"] != true || upstreamJSON["store"] != false {
t.Fatalf("expected translated Codex body with stream=true and store=false, got: %s", string(capturedBody))
}
- if _, hasMaxOutput := upstreamJSON["max_output_tokens"]; hasMaxOutput {
- t.Fatalf("translated Codex body must strip unsupported max_output_tokens, got: %s", string(capturedBody))
+ if maxOutputTokens := upstreamJSON["max_output_tokens"]; maxOutputTokens != float64(1) {
+ t.Fatalf("translated Codex body must preserve max_output_tokens=1, got: %s", string(capturedBody))
}
}
diff --git a/internal/runtime/executor/codex_executor_stream.go b/internal/runtime/executor/codex_executor_stream.go
index d65c94c875c..4a38a5934b9 100644
--- a/internal/runtime/executor/codex_executor_stream.go
+++ b/internal/runtime/executor/codex_executor_stream.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/client/grokbuild"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -16,12 +17,11 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
+ ctx = helps.WithCodexCommentaryPolicy(ctx, e.cfg)
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"}
}
@@ -56,62 +56,80 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, true, isCompat)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntentContext(ctx, from, to, targetModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
+ doc := ojson.NewDoc(body)
+ normalizeCodexClaudeOutputBudgetDoc(doc, from, req.Payload)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return nil, err
}
ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts))
- body, _ = sjson.DeleteBytes(body, "previous_response_id")
- body, _ = sjson.DeleteBytes(body, "generate")
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "safety_identifier")
- reasoningSummaryDelivery := gjson.GetBytes(body, "stream_options.reasoning_summary_delivery")
- body, _ = sjson.DeleteBytes(body, "stream_options")
- if reasoningSummaryDelivery.Exists() {
- body, _ = sjson.SetBytes(body, "stream_options.reasoning_summary_delivery", reasoningSummaryDelivery.Value())
- }
- body = helps.SetStringIfDifferent(body, "model", baseModel)
+ _ = doc.Delete("previous_response_id")
+ _ = doc.Delete("generate")
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ reasoningSummaryDelivery := doc.Get("stream_options.reasoning_summary_delivery")
+ hasReasoningSummaryDelivery := reasoningSummaryDelivery.Exists()
+ var reasoningSummaryDeliveryVal any
+ if hasReasoningSummaryDelivery {
+ reasoningSummaryDeliveryVal = reasoningSummaryDelivery.Value()
+ }
+ _ = doc.Delete("stream_options")
+ if hasReasoningSummaryDelivery {
+ _ = doc.Set("stream_options.reasoning_summary_delivery", reasoningSummaryDeliveryVal)
+ }
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
useSIWC := codexUseSIWCForRequest(ctx, auth, opts.Headers)
- hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
- body = ensureWebSearchToolForHistory(body, opts.Headers)
+ instructionsResult := ojson.GetBytesNoCopy(req.Payload, "instructions")
+ hasExplicitInstructions := instructionsResult.Exists() && strings.TrimSpace(instructionsResult.String()) != ""
+ ensureWebSearchToolForHistoryDoc(doc, opts.Headers)
if !useSIWC {
- body = normalizeCodexInstructions(body, preserveNativeOutput)
+ normalizeCodexInstructionsDoc(doc, preserveNativeOutput)
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
- body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
+ ensureImageGenerationToolDoc(doc, baseModel, auth, opts.Headers)
+ if !codexAuthUsesAPIKey(auth) {
+ normalizeCodexImageGenerationToolsDoc(doc)
+ }
}
} else {
- body = applyCodexSIWCRequestBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
+ applyCodexSIWCRequestBodyRulesDoc(doc, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
- body = normalizeCodexParallelToolCalls(body, opts.Headers)
- body = helps.NormalizeCodexToolSchemas(body)
- body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
- body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
+ normalizeCodexParallelToolCallsDoc(doc, opts.Headers)
+ helps.NormalizeCodexToolSchemasDoc(doc)
+ optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuthDoc(doc, ctx, opts.Headers, e.cfg, auth, isCompat)
+ replayScope, _, errReplay := applyCodexReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
if errReplay != nil {
return nil, errReplay
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", body, isCompat, targetModel, baseURL)
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "codex executor", doc, isCompat, targetModel, baseURL)
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- httpReq, upstreamBody, err := e.cacheHelper(ctx, from, url, req, body, auth, opts.Headers)
+ httpReq, upstreamBody, err := e.cacheHelperDoc(ctx, from, url, req, doc, auth, opts.Headers)
if err != nil {
return nil, err
}
+ body = upstreamBody
+ upstreamModel := strings.TrimSpace(doc.Get("model").String())
+ if upstreamModel != "" && upstreamModel != baseModel {
+ reporter.SetUpstreamModel(upstreamModel)
+ }
reporter.SetTranslatedReasoningEffort(upstreamBody, to.String())
prepareHeaders := func(request *http.Request) {
applyCodexHeaders(request, auth, apiKey, true, e.cfg, opts.Headers)
- applyCodexRoutingHint(ctx, request.Header, auth, baseModel, upstreamBody, opts.Headers)
- applyModelHeaderOverrides(request.Header, baseModel)
+ applyCodexRoutingHint(ctx, request.Header, auth, upstreamModel, upstreamBody, opts.Headers)
+ applyModelHeaderOverrides(request.Header, upstreamModel)
alignCodexContentEncoding(request)
}
prepareHeaders(httpReq)
+ if errValidate := helps.ValidateCodexHostedImageGenerationDoc(doc, httpReq.Header, e.cfg, !useSIWC && !codexAuthUsesAPIKey(auth)); errValidate != nil {
+ return nil, errValidate
+ }
var authID, authLabel, authType, authValue string
if auth != nil {
authID = auth.ID
@@ -241,7 +259,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2)
observeCodexTokenEvent(reporter, data)
translatedLine = append([]byte("data: "), data...)
- eventType := gjson.GetBytes(data, "type").String()
+ eventType := ojson.GetBytesNoCopy(data, "type").String()
if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(data, e.modelLevelCooling()); ok {
closeBootstrapBody()
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
@@ -441,7 +459,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
data = helps.RestoreCodexMultiAgentV2Response(data, optimizeMultiAgentV2)
observeCodexTokenEvent(reporter, data)
translatedLine = append([]byte("data: "), data...)
- eventType := gjson.GetBytes(data, "type").String()
+ eventType := ojson.GetBytesNoCopy(data, "type").String()
if streamErr, terminalBody, ok := codexTerminalFailureErrWithCooling(data, e.modelLevelCooling()); ok {
if errClearReplay := clearCodexReasoningReplayOnInvalidSignature(ctx, replayScope, streamErr.StatusCode(), terminalBody); errClearReplay != nil {
helps.RecordAPIResponseError(ctx, e.cfg, errClearReplay)
diff --git a/internal/runtime/executor/codex_executor_stream_alloc_test.go b/internal/runtime/executor/codex_executor_stream_alloc_test.go
index a0f4bcfefbf..d3b41bcce6d 100644
--- a/internal/runtime/executor/codex_executor_stream_alloc_test.go
+++ b/internal/runtime/executor/codex_executor_stream_alloc_test.go
@@ -9,11 +9,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func codexAllocationStream(deltas, deltaSize int) string {
@@ -76,11 +76,11 @@ func TestCodexStreamChunksRemainOwnedAfterScannerAdvances(t *testing.T) {
continue
}
data := bytes.TrimSpace(line[5:])
- switch gjson.GetBytes(data, "type").String() {
+ switch ojson.GetBytes(data, "type").String() {
case "response.output_text.delta":
- text.WriteString(gjson.GetBytes(data, "delta").String())
+ text.WriteString(ojson.GetBytes(data, "delta").String())
case "content_block_delta":
- text.WriteString(gjson.GetBytes(data, "delta.text").String())
+ text.WriteString(ojson.GetBytes(data, "delta.text").String())
}
}
}
diff --git a/internal/runtime/executor/codex_executor_stream_output_test.go b/internal/runtime/executor/codex_executor_stream_output_test.go
index ba2dfe7b57f..01cf3f2be92 100644
--- a/internal/runtime/executor/codex_executor_stream_output_test.go
+++ b/internal/runtime/executor/codex_executor_stream_output_test.go
@@ -10,12 +10,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexExecutorExecute_NonEmptyCompletionOutputHydratesMissingItemID(t *testing.T) {
@@ -44,13 +44,13 @@ func TestCodexExecutorExecute_NonEmptyCompletionOutputHydratesMissingItemID(t *t
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "output.0.id").String(); got != "fc_123" {
+ if got := ojson.GetBytes(resp.Payload, "output.0.id").String(); got != "fc_123" {
t.Fatalf("output[0].id = %q, want %q; payload=%s", got, "fc_123", resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.0.name").String(); got != "weather-terminal" {
+ if got := ojson.GetBytes(resp.Payload, "output.0.name").String(); got != "weather-terminal" {
t.Fatalf("output[0].name = %q, want terminal value; payload=%s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.1.id").String(); got != "fc_existing" {
+ if got := ojson.GetBytes(resp.Payload, "output.1.id").String(); got != "fc_existing" {
t.Fatalf("output[1].id = %q, want existing value; payload=%s", got, resp.Payload)
}
}
@@ -80,7 +80,7 @@ func TestCodexExecutorExecute_EmptyStreamCompletionOutputUsesOutputItemDone(t *t
t.Fatalf("Execute error: %v", err)
}
- gotContent := gjson.GetBytes(resp.Payload, "choices.0.message.content").String()
+ gotContent := ojson.GetBytes(resp.Payload, "choices.0.message.content").String()
if gotContent != "ok" {
t.Fatalf("choices.0.message.content = %q, want %q; payload=%s", gotContent, "ok", string(resp.Payload))
}
@@ -146,7 +146,7 @@ func TestCodexExecutorExecuteIncompleteResponseIsSuccessful(t *testing.T) {
if err != nil {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "stop_reason").String(); got != "max_tokens" {
+ if got := ojson.GetBytes(resp.Payload, "stop_reason").String(); got != "max_tokens" {
t.Fatalf("stop_reason = %q, want %q; payload=%s", got, "max_tokens", resp.Payload)
}
}
@@ -295,12 +295,12 @@ func TestCodexAutoExecutorHTTPFallbackForwardsSequentialCutoffReasoningSummaryDe
t.Errorf("read request body: %v", errRead)
return
}
- if gjson.GetBytes(body, "stream_options.include_usage").Exists() {
+ if ojson.GetBytes(body, "stream_options.include_usage").Exists() {
t.Errorf("unsupported stream option was forwarded: %s", body)
}
w.Header().Set("Content-Type", "text/event-stream")
- if delivery := gjson.GetBytes(body, "stream_options.reasoning_summary_delivery").String(); delivery == "sequential_cutoff" {
+ if delivery := ojson.GetBytes(body, "stream_options.reasoning_summary_delivery").String(); delivery == "sequential_cutoff" {
_, _ = w.Write([]byte(`data: {"type":"response.reasoning_summary_text.done","item_id":"rs_1","summary_index":0,"text":"Checking"}` + "\n\n"))
} else {
_, _ = w.Write([]byte(`data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_1","summary_index":0,"delta":"Checking"}` + "\n\n"))
@@ -419,7 +419,7 @@ func TestCodexExecutorExecuteIgnoresTransportErrorAfterCompletion(t *testing.T)
if err != nil {
t.Fatalf("unexpected error after response.completed: %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "id").String(); got != "resp_1" {
+ if got := ojson.GetBytes(resp.Payload, "id").String(); got != "resp_1" {
t.Fatalf("response id = %q, want resp_1; payload=%s", got, resp.Payload)
}
}
@@ -705,7 +705,7 @@ func TestCodexExecutorExecuteStream_EmptyStreamCompletionOutputUsesOutputItemDon
continue
}
data := bytes.TrimSpace(payload[5:])
- if gjson.GetBytes(data, "type").String() == "response.completed" {
+ if ojson.GetBytes(data, "type").String() == "response.completed" {
completed = append([]byte(nil), data...)
}
}
@@ -714,7 +714,7 @@ func TestCodexExecutorExecuteStream_EmptyStreamCompletionOutputUsesOutputItemDon
t.Fatal("missing response.completed chunk")
}
- gotContent := gjson.GetBytes(completed, "response.output.0.content.0.text").String()
+ gotContent := ojson.GetBytes(completed, "response.output.0.content.0.text").String()
if gotContent != "ok" {
t.Fatalf("response.output[0].content[0].text = %q, want %q; completed=%s", gotContent, "ok", string(completed))
}
diff --git a/internal/runtime/executor/codex_executor_terminal.go b/internal/runtime/executor/codex_executor_terminal.go
index 82ca00bfaee..6e513dd6460 100644
--- a/internal/runtime/executor/codex_executor_terminal.go
+++ b/internal/runtime/executor/codex_executor_terminal.go
@@ -9,9 +9,9 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const codexIncompleteStreamMessage = "stream error: stream disconnected before completion: stream closed before response.completed"
@@ -50,11 +50,11 @@ func (codexEmptyIncompleteStreamError) IsRequestScoped() bool {
// response.completed.response.output empty. Keep the stream path aligned with the
// already-patched non-stream path by reconstructing response.output from those items.
func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
- itemResult := gjson.GetBytes(eventData, "item")
- if !itemResult.Exists() || itemResult.Type != gjson.JSON {
+ itemResult := ojson.GetBytesNoCopy(eventData, "item")
+ if !itemResult.Exists() || itemResult.Type != ojson.JSON {
return
}
- outputIndexResult := gjson.GetBytes(eventData, "output_index")
+ outputIndexResult := ojson.GetBytesNoCopy(eventData, "output_index")
if outputIndexResult.Exists() {
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
return
@@ -62,12 +62,12 @@ func collectCodexOutputItemDone(eventData []byte, outputItemsByIndex map[int64][
*outputItemsFallback = append(*outputItemsFallback, []byte(itemResult.Raw))
}
-func hydrateCodexCompletedOutputItemIDs(eventData []byte, outputItems []gjson.Result, outputItemsByIndex map[int64][]byte) []byte {
- patchedData := eventData
+func hydrateCodexCompletedOutputItemIDs(eventData []byte, outputItems []ojson.Result, outputItemsByIndex map[int64][]byte) []byte {
+ var doc *ojson.Doc
+ changed := false
for outputIndex, outputItem := range outputItems {
- itemData := []byte(outputItem.Raw)
- itemID := gjson.GetBytes(itemData, "id")
- if itemID.Exists() && itemID.Type != gjson.Null && (itemID.Type != gjson.String || strings.TrimSpace(itemID.String()) != "") {
+ itemID := outputItem.Get("id")
+ if itemID.Exists() && itemID.Type != ojson.Null && (itemID.Type != ojson.String || strings.TrimSpace(itemID.String()) != "") {
continue
}
@@ -75,22 +75,26 @@ func hydrateCodexCompletedOutputItemIDs(eventData []byte, outputItems []gjson.Re
if !ok {
continue
}
- completedID := gjson.GetBytes(completedItem, "id")
- if completedID.Type != gjson.String || strings.TrimSpace(completedID.String()) == "" {
+ completedID := ojson.GetBytesNoCopy(completedItem, "id")
+ if completedID.Type != ojson.String || strings.TrimSpace(completedID.String()) == "" {
continue
}
- updatedData, errSet := sjson.SetRawBytes(patchedData, "response.output."+strconv.Itoa(outputIndex)+".id", []byte(completedID.Raw))
- if errSet != nil {
- continue
+ if doc == nil {
+ doc = ojson.NewDoc(eventData)
+ }
+ if errSet := doc.SetRawBytes("response.output."+strconv.Itoa(outputIndex)+".id", []byte(completedID.Raw)); errSet == nil {
+ changed = true
}
- patchedData = updatedData
}
- return patchedData
+ if !changed || doc == nil {
+ return eventData
+ }
+ return doc.Bytes()
}
func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
- outputResult := gjson.GetBytes(eventData, "response.output")
+ outputResult := ojson.GetBytesNoCopy(eventData, "response.output")
if outputResult.Exists() && outputResult.IsArray() && len(outputResult.Array()) > 0 {
return hydrateCodexCompletedOutputItemIDs(eventData, outputResult.Array(), outputItemsByIndex)
}
@@ -136,7 +140,7 @@ func patchCodexCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]
outputArray = buf.Bytes()
}
- completedDataPatched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
+ completedDataPatched, _ := ojson.SetRawBytes(eventData, "response.output", outputArray)
return completedDataPatched
}
@@ -177,13 +181,13 @@ func codexTerminalFailureErrWithCooling(eventData []byte, modelLevelCooling bool
func codexTerminalFailureStatus(body []byte) int {
for _, path := range []string{"error.status_code", "error.status"} {
- if status := int(gjson.GetBytes(body, path).Int()); status >= 400 && status <= 599 {
+ if status := int(ojson.GetBytesNoCopy(body, path).Int()); status >= 400 && status <= 599 {
return status
}
}
- errorType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
- errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
+ errorType := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.type").String()))
+ errorCode := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.code").String()))
switch {
case errorCode == "cyber_policy":
return http.StatusBadRequest
@@ -195,7 +199,7 @@ func codexTerminalFailureStatus(body []byte) int {
return http.StatusForbidden
case errorType == "rate_limit_error", errorCode == "rate_limit_exceeded":
return http.StatusTooManyRequests
- case errorType == "invalid_request_error", errorType == "bad_request_error":
+ case errorType == "invalid_request_error", errorType == "bad_request_error", errorType == "image_generation_user_error", errorCode == "invalid_value", errorCode == "invalid_request_error":
return http.StatusBadRequest
default:
return http.StatusBadGateway
@@ -203,7 +207,7 @@ func codexTerminalFailureStatus(body []byte) int {
}
func codexTerminalFailureBody(eventData []byte) ([]byte, bool) {
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
var body []byte
switch eventType {
case "error":
@@ -222,8 +226,8 @@ func codexTerminalFailureBody(eventData []byte) ([]byte, bool) {
if len(body) == 0 {
body = []byte(`{"error":{"message":"upstream stream failed without error details"}}`)
}
- if seq := gjson.GetBytes(eventData, "sequence_number"); seq.Exists() {
- body, _ = sjson.SetBytes(body, "sequence_number", seq.Int())
+ if seq := ojson.GetBytesNoCopy(eventData, "sequence_number"); seq.Exists() {
+ body, _ = ojson.SetBytes(body, "sequence_number", seq.Int())
}
return body, true
}
@@ -240,69 +244,76 @@ func codexTerminalStreamErrShouldHandle(body []byte) bool {
}
func codexTerminalErrorBody(eventData []byte, path string) []byte {
- errorResult := gjson.GetBytes(eventData, path)
+ errorResult := ojson.GetBytesNoCopy(eventData, path)
if !errorResult.Exists() {
return nil
}
- body := []byte(`{"error":{}}`)
- if errorResult.Type == gjson.JSON {
- body, _ = sjson.SetRawBytes(body, "error", []byte(errorResult.Raw))
+ doc := ojson.NewDoc([]byte(`{"error":{}}`))
+ if errorResult.Type == ojson.JSON {
+ _ = doc.SetRawBytes("error", []byte(errorResult.Raw))
} else if message := strings.TrimSpace(errorResult.String()); message != "" {
- body, _ = sjson.SetBytes(body, "error.message", message)
+ _ = doc.Set("error.message", message)
+ }
+ if strings.TrimSpace(doc.Get("error.message").String()) == "" {
+ if message := strings.TrimSpace(ojson.GetBytesNoCopy(eventData, "response.error.message").String()); message != "" {
+ _ = doc.Set("error.message", message)
+ }
}
- if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
- if message := strings.TrimSpace(gjson.GetBytes(eventData, "response.error.message").String()); message != "" {
- body, _ = sjson.SetBytes(body, "error.message", message)
+ if strings.TrimSpace(doc.Get("error.message").String()) == "" {
+ if code := strings.TrimSpace(doc.Get("error.code").String()); code != "" {
+ _ = doc.Set("error.message", code)
}
}
- if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
- if code := strings.TrimSpace(gjson.GetBytes(body, "error.code").String()); code != "" {
- body, _ = sjson.SetBytes(body, "error.message", code)
+ if strings.TrimSpace(doc.Get("error.message").String()) == "" {
+ if errorType := strings.TrimSpace(doc.Get("error.type").String()); errorType != "" {
+ _ = doc.Set("error.message", errorType)
}
}
- if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
- if errorType := strings.TrimSpace(gjson.GetBytes(body, "error.type").String()); errorType != "" {
- body, _ = sjson.SetBytes(body, "error.message", errorType)
+ if strings.EqualFold(strings.TrimSpace(doc.Get("error.type").String()), "image_generation_user_error") {
+ msg := strings.TrimSpace(doc.Get("error.message").String())
+ param := strings.ToLower(strings.TrimSpace(doc.Get("error.param").String()))
+ if (param == "complexity" || strings.Contains(strings.ToLower(msg), "complexity")) && !strings.Contains(msg, "/v1/images/generations") {
+ _ = doc.Set("error.message", msg+` (Set tool_choice to {"type":"image_generation"}, use a compatible Responses model such as gpt-5.4-mini, or call /v1/images/generations directly.)`)
}
}
- return body
+ return doc.Bytes()
}
func codexTerminalTopLevelErrorBody(eventData []byte) []byte {
- message := strings.TrimSpace(gjson.GetBytes(eventData, "message").String())
- code := strings.TrimSpace(gjson.GetBytes(eventData, "code").String())
- errorType := strings.TrimSpace(gjson.GetBytes(eventData, "error_type").String())
- param := strings.TrimSpace(gjson.GetBytes(eventData, "param").String())
+ message := strings.TrimSpace(ojson.GetBytesNoCopy(eventData, "message").String())
+ code := strings.TrimSpace(ojson.GetBytesNoCopy(eventData, "code").String())
+ errorType := strings.TrimSpace(ojson.GetBytesNoCopy(eventData, "error_type").String())
+ param := strings.TrimSpace(ojson.GetBytesNoCopy(eventData, "param").String())
if message == "" && code == "" && errorType == "" && param == "" {
return nil
}
- body := []byte(`{"error":{}}`)
+ doc := ojson.NewDoc([]byte(`{"error":{}}`))
if message != "" {
- body, _ = sjson.SetBytes(body, "error.message", message)
+ _ = doc.Set("error.message", message)
}
if code != "" {
- body, _ = sjson.SetBytes(body, "error.code", code)
+ _ = doc.Set("error.code", code)
}
if errorType != "" {
- body, _ = sjson.SetBytes(body, "error.type", errorType)
+ _ = doc.Set("error.type", errorType)
}
if param != "" {
- body, _ = sjson.SetBytes(body, "error.param", param)
+ _ = doc.Set("error.param", param)
}
- if strings.TrimSpace(gjson.GetBytes(body, "error.message").String()) == "" {
+ if strings.TrimSpace(doc.Get("error.message").String()) == "" {
if code != "" {
- body, _ = sjson.SetBytes(body, "error.message", code)
+ _ = doc.Set("error.message", code)
} else if errorType != "" {
- body, _ = sjson.SetBytes(body, "error.message", errorType)
+ _ = doc.Set("error.message", errorType)
}
}
- return body
+ return doc.Bytes()
}
func codexTerminalErrorIsContextLength(body []byte) bool {
- errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
- message := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
+ errorCode := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.code").String()))
+ message := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.message").String()))
return errorCode == "context_length_exceeded" ||
errorCode == "context_too_large" ||
strings.Contains(message, "context window") ||
@@ -350,9 +361,9 @@ func classifyCodexStatusError(statusCode int, body []byte) []byte {
if !ok {
return body
}
- message := gjson.GetBytes(body, "error.message").String()
+ message := ojson.GetBytesNoCopy(body, "error.message").String()
if message == "" {
- message = gjson.GetBytes(body, "message").String()
+ message = ojson.GetBytesNoCopy(body, "message").String()
}
if message == "" {
message = strings.TrimSpace(string(body))
@@ -360,21 +371,21 @@ func classifyCodexStatusError(statusCode int, body []byte) []byte {
if message == "" {
message = http.StatusText(statusCode)
}
- out := []byte(`{"error":{}}`)
- out, _ = sjson.SetBytes(out, "error.message", message)
- out, _ = sjson.SetBytes(out, "error.type", errType)
- out, _ = sjson.SetBytes(out, "error.code", code)
- return out
+ doc := ojson.NewDoc([]byte(`{"error":{}}`))
+ _ = doc.Set("error.message", message)
+ _ = doc.Set("error.type", errType)
+ _ = doc.Set("error.code", code)
+ return doc.Bytes()
}
func codexStatusErrorClassification(statusCode int, body []byte) (code string, errType string, ok bool) {
- errorMessage := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
+ errorMessage := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.message").String()))
if errorMessage == "" {
- errorMessage = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "message").String()))
+ errorMessage = strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "message").String()))
}
lower := strings.ToLower(strings.TrimSpace(string(body)))
- upstreamCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
- upstreamType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
+ upstreamCode := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.code").String()))
+ upstreamType := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.type").String()))
isInvalidRequest := upstreamType == "" || upstreamType == "invalid_request_error"
switch {
@@ -403,9 +414,9 @@ func isCodexTurnRateLimitError(errorBody []byte) bool {
return false
}
codes := []string{
- gjson.GetBytes(errorBody, "error.code").String(),
- gjson.GetBytes(errorBody, "code").String(),
- gjson.GetBytes(errorBody, "body.error.code").String(),
+ ojson.GetBytesNoCopy(errorBody, "error.code").String(),
+ ojson.GetBytesNoCopy(errorBody, "code").String(),
+ ojson.GetBytesNoCopy(errorBody, "body.error.code").String(),
}
hasOtherCode := false
for _, candidate := range codes {
@@ -424,9 +435,9 @@ func isCodexTurnRateLimitError(errorBody []byte) bool {
return false
}
errorTypes := []string{
- gjson.GetBytes(errorBody, "error.type").String(),
- gjson.GetBytes(errorBody, "type").String(),
- gjson.GetBytes(errorBody, "body.error.type").String(),
+ ojson.GetBytesNoCopy(errorBody, "error.type").String(),
+ ojson.GetBytesNoCopy(errorBody, "type").String(),
+ ojson.GetBytesNoCopy(errorBody, "body.error.type").String(),
}
rateLimitTyped := false
for _, candidate := range errorTypes {
@@ -439,9 +450,9 @@ func isCodexTurnRateLimitError(errorBody []byte) bool {
return false
}
messages := []string{
- gjson.GetBytes(errorBody, "error.message").String(),
- gjson.GetBytes(errorBody, "message").String(),
- gjson.GetBytes(errorBody, "body.error.message").String(),
+ ojson.GetBytesNoCopy(errorBody, "error.message").String(),
+ ojson.GetBytesNoCopy(errorBody, "message").String(),
+ ojson.GetBytesNoCopy(errorBody, "body.error.message").String(),
}
for _, candidate := range messages {
if strings.Contains(strings.ToLower(strings.TrimSpace(candidate)), "at capacity") {
@@ -455,8 +466,8 @@ func isCodexTurnRateLimitError(errorBody []byte) bool {
// Codex upstream uses.
func codexErrorCodeOf(errorBody []byte) string {
for _, path := range []string{"error.code", "code", "body.error.code"} {
- if code := strings.TrimSpace(gjson.GetBytes(errorBody, path).String()); code != "" {
- return code
+ if code := strings.TrimSpace(ojson.GetBytesNoCopy(errorBody, path).String()); code != "" {
+ return strings.Clone(code)
}
}
return ""
@@ -482,8 +493,8 @@ func isCodexModelCapacityError(errorBody []byte) bool {
return false
}
candidates := []string{
- gjson.GetBytes(errorBody, "error.message").String(),
- gjson.GetBytes(errorBody, "message").String(),
+ ojson.GetBytesNoCopy(errorBody, "error.message").String(),
+ ojson.GetBytesNoCopy(errorBody, "message").String(),
string(errorBody),
}
for _, candidate := range candidates {
@@ -512,9 +523,9 @@ func isCodexUsageLimitError(errorBody []byte) bool {
return false
}
candidates := []string{
- gjson.GetBytes(errorBody, "error.type").String(),
- gjson.GetBytes(errorBody, "type").String(),
- gjson.GetBytes(errorBody, "body.error.type").String(),
+ ojson.GetBytesNoCopy(errorBody, "error.type").String(),
+ ojson.GetBytesNoCopy(errorBody, "type").String(),
+ ojson.GetBytesNoCopy(errorBody, "body.error.type").String(),
}
for _, candidate := range candidates {
if strings.EqualFold(strings.TrimSpace(candidate), "usage_limit_reached") {
@@ -528,7 +539,7 @@ func parseCodexRetryAfter(statusCode int, errorBody []byte, now time.Time) *time
if statusCode != http.StatusTooManyRequests || len(errorBody) == 0 {
return nil
}
- for _, quota := range []gjson.Result{gjson.GetBytes(errorBody, "error"), gjson.ParseBytes(errorBody), gjson.GetBytes(errorBody, "body.error")} {
+ for _, quota := range []ojson.Result{ojson.GetBytesNoCopy(errorBody, "error"), util.ParseGJSONBytesNoCopy(errorBody), ojson.GetBytesNoCopy(errorBody, "body.error")} {
if !strings.EqualFold(strings.TrimSpace(quota.Get("type").String()), "usage_limit_reached") {
continue
}
@@ -640,7 +651,7 @@ func isCodexBootstrapBufferableEvent(eventType string, payload []byte) bool {
// immediately, and failing the attempt over after one would run it again on another credential - and
// errs the same way for anything else this list has not been taught about.
func isCodexBufferableOutputItem(payload []byte) bool {
- item := gjson.GetBytes(payload, "item")
+ item := ojson.GetBytesNoCopy(payload, "item")
switch item.Get("type").String() {
case "message":
return isCodexEmptyContentList(item.Get("content"))
@@ -662,7 +673,7 @@ func isCodexBufferableOutputItem(payload []byte) bool {
// textual shape this list knows about and is still empty. An entry whose type is not on the list may
// carry content in a field this check cannot see - an output_audio entry keeps it in "audio" - so it
// is treated as already produced.
-func isCodexEmptyContentList(list gjson.Result) bool {
+func isCodexEmptyContentList(list ojson.Result) bool {
for _, entry := range list.Array() {
switch entry.Get("type").String() {
case "output_text", "summary_text", "text", "reasoning_text":
@@ -685,7 +696,7 @@ func isCodexEmptyContentList(list gjson.Result) bool {
// list has not been taught about may carry content in a field the emptiness check cannot see, so it
// releases the stream instead.
func isCodexEmptyPart(payload []byte) bool {
- part := gjson.GetBytes(payload, "part")
+ part := ojson.GetBytesNoCopy(payload, "part")
switch part.Get("type").String() {
case "output_text", "summary_text", "text", "reasoning_text":
return part.Get("text").String() == ""
@@ -721,11 +732,11 @@ func isCodexOverloadBootstrapFailure(body []byte) bool {
if isCodexTransientCapacityError(body) {
return true
}
- errorType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.type").String()))
- errorCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
- errorMessage := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
+ errorType := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.type").String()))
+ errorCode := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.code").String()))
+ errorMessage := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.message").String()))
if errorMessage == "" {
- errorMessage = strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "message").String()))
+ errorMessage = strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "message").String()))
}
switch {
case errorType == "service_unavailable_error", errorCode == "server_is_overloaded":
diff --git a/internal/runtime/executor/codex_executor_tokens.go b/internal/runtime/executor/codex_executor_tokens.go
index 4719e1a9da0..22c73eb72af 100644
--- a/internal/runtime/executor/codex_executor_tokens.go
+++ b/internal/runtime/executor/codex_executor_tokens.go
@@ -6,17 +6,18 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"github.com/tiktoken-go/tokenizer"
)
func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ ctx = helps.WithCodexCommentaryPolicy(ctx, e.cfg)
if errExpand := helps.ExpandRecognizedResponsesCompactionCapsules(&req, &opts); errExpand != nil {
return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
}
@@ -34,21 +35,23 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
originalTranslatedForPayload, _, _ = helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, baseModel, opts.OriginalRequest, false, helps.APIKeyModelIsCompat(req))
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return cliproxyexecutor.Response{}, err
}
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body, _ = sjson.DeleteBytes(body, "previous_response_id")
- body, _ = sjson.DeleteBytes(body, "generate")
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "safety_identifier")
- body, _ = sjson.DeleteBytes(body, "stream_options")
- body = helps.SetBoolIfDifferent(body, "stream", false)
- body = normalizeCodexInstructions(body, helps.IsNativeCodexRequest(req.Payload, opts))
-
- body = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(body)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ _ = doc.Delete("previous_response_id")
+ _ = doc.Delete("generate")
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ _ = doc.Delete("stream_options")
+ helps.SetBoolIfDifferentDoc(doc, "stream", false)
+ normalizeCodexInstructionsDoc(doc, helps.IsNativeCodexRequest(req.Payload, opts))
+
+ ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts))
+ helps.FinalizePayloadDoc(ctx, doc)
+ body = doc.Bytes()
enc, err := tokenizerForCodexModel(baseModel)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("codex executor: tokenizer init failed: %w", err)
@@ -92,7 +95,7 @@ func countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
return 0, nil
}
- root := gjson.ParseBytes(body)
+ root := util.ParseGJSONBytesNoCopy(body)
var segments []string
if inst := strings.TrimSpace(root.Get("instructions").String()); inst != "" {
@@ -148,7 +151,7 @@ func countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
}
if params := tool.Get("parameters"); params.Exists() {
val := params.Raw
- if params.Type == gjson.String {
+ if params.Type == ojson.String {
val = params.String()
}
if trimmed := strings.TrimSpace(val); trimmed != "" {
@@ -165,7 +168,7 @@ func countCodexInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
}
if schema := textFormat.Get("schema"); schema.Exists() {
val := schema.Raw
- if schema.Type == gjson.String {
+ if schema.Type == ojson.String {
val = schema.String()
}
if trimmed := strings.TrimSpace(val); trimmed != "" {
diff --git a/internal/runtime/executor/codex_executor_tokens_test.go b/internal/runtime/executor/codex_executor_tokens_test.go
index a73f8674bac..cba44dd1c77 100644
--- a/internal/runtime/executor/codex_executor_tokens_test.go
+++ b/internal/runtime/executor/codex_executor_tokens_test.go
@@ -7,11 +7,11 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type codexTokenSchemaHooks struct {
@@ -68,7 +68,7 @@ func TestCodexExecutorCountTokensPreservesToolNumberSchemas(t *testing.T) {
}
// Token counts alone can miss a schema rewrite: number and integer
// may have the same token length. Inspect the translated schema too.
- if got := gjson.GetBytes(hooks.body, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(hooks.body, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("yield_time_ms.type = %q, want number; body=%s", got, hooks.body)
}
if len(resp.Payload) == 0 {
diff --git a/internal/runtime/executor/codex_executor_tool_schema_test.go b/internal/runtime/executor/codex_executor_tool_schema_test.go
index b9054660363..4f1873e3464 100644
--- a/internal/runtime/executor/codex_executor_tool_schema_test.go
+++ b/internal/runtime/executor/codex_executor_tool_schema_test.go
@@ -9,12 +9,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// Issue #5551: Tool schema with 13-branch oneOf causes ChatGPT Codex upstream
@@ -97,13 +97,13 @@ func TestCodexExecutorSimplifiesComplexOneOfToolSchema(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
if len(tools) == 0 {
t.Fatalf("expected tools in upstream payload, got: %s", gotBody)
}
// Find tool t1
- var t1Tool gjson.Result
+ var t1Tool ojson.Result
for _, tool := range tools {
if tool.Get("name").String() == "t1" {
t1Tool = tool
@@ -502,8 +502,8 @@ func TestCodexExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAgent(t *test
t.Fatalf("Execute(non-codex) error = %v", errExecuteNonCodex)
}
- toolMapNonCodex := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(gotBody, "tools").Array() {
+ toolMapNonCodex := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytes(gotBody, "tools").Array() {
toolMapNonCodex[tool.Get("name").String()] = tool
}
if gotType := toolMapNonCodex["exec_command"].Get("parameters.properties.yield_time_ms.type").String(); gotType != "number" {
@@ -524,8 +524,8 @@ func TestCodexExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAgent(t *test
t.Fatalf("Execute(codex) error = %v", errExecuteCodex)
}
- toolMap := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(gotBody, "tools").Array() {
+ toolMap := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytes(gotBody, "tools").Array() {
toolMap[tool.Get("name").String()] = tool
}
@@ -559,15 +559,15 @@ func TestCodexExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAgent(t *test
}
// Namespace collaboration tool wait_agent must retain number (Issue #6244)
- if gotType := gjson.GetBytes(gotBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
t.Fatalf("collaboration.wait_agent timeout_ms type = %q, want number", gotType)
}
// input[].additional_tools must retain number
- if gotType := gjson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("input additional_tools yield_time_ms type = %q, want number", gotType)
}
- if gotType := gjson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
t.Fatalf("input additional_tools collaboration.wait_agent timeout_ms type = %q, want number", gotType)
}
@@ -585,13 +585,13 @@ func TestCodexExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAgent(t *test
}
for range streamRes.Chunks {
}
- if gotType := gjson.GetBytes(gotBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("HTTP stream exec_command yield_time_ms type = %q, want number", gotType)
}
- if gotType := gjson.GetBytes(gotBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
t.Fatalf("HTTP stream collaboration.wait_agent timeout_ms type = %q, want number", gotType)
}
- if gotType := gjson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("HTTP stream input additional_tools yield_time_ms type = %q, want number", gotType)
}
@@ -608,13 +608,13 @@ func TestCodexExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAgent(t *test
if errCompact != nil {
t.Fatalf("ExecuteCompact(codex) error = %v", errCompact)
}
- if gotType := gjson.GetBytes(gotBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("HTTP compact exec_command yield_time_ms type = %q, want number", gotType)
}
- if gotType := gjson.GetBytes(gotBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); gotType != "number" {
t.Fatalf("HTTP compact collaboration.wait_agent timeout_ms type = %q, want number", gotType)
}
- if gotType := gjson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("HTTP compact input additional_tools yield_time_ms type = %q, want number", gotType)
}
}
@@ -752,16 +752,16 @@ func TestCodexWebsocketsExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAge
select {
case payload := <-capturedPayload:
- if got := gjson.GetBytes(payload, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("WS stream exec_command yield_time_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(payload, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
t.Fatalf("WS stream collaboration.wait_agent timeout_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(payload, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("WS stream input additional_tools yield_time_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(payload, "input.#(type==\"additional_tools\").tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "input.#(type==\"additional_tools\").tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
t.Fatalf("WS stream input additional_tools collaboration.wait_agent timeout_ms type = %q, want number", got)
}
case <-time.After(5 * time.Second):
@@ -775,13 +775,13 @@ func TestCodexWebsocketsExecutor_DoesNotNormalizeToolIntegerTypesForCodexUserAge
}
select {
case payload := <-capturedPayload:
- if got := gjson.GetBytes(payload, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("WS non-stream exec_command yield_time_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(payload, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
t.Fatalf("WS non-stream collaboration.wait_agent timeout_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(payload, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(payload, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("WS non-stream input additional_tools yield_time_ms type = %q, want number", got)
}
case <-time.After(5 * time.Second):
@@ -884,13 +884,13 @@ func TestCodexWebsocketsExecutor_UpgradeFallbackToHTTP_PreservesNumber(t *testin
}
for range streamRes.Chunks {
}
- if got := gjson.GetBytes(httpCapturedBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(httpCapturedBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("stream fallback tools exec_command yield_time_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(httpCapturedBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(httpCapturedBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
t.Fatalf("stream fallback collaboration.wait_agent timeout_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(httpCapturedBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(httpCapturedBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("stream fallback input additional_tools yield_time_ms type = %q, want number", got)
}
@@ -900,13 +900,13 @@ func TestCodexWebsocketsExecutor_UpgradeFallbackToHTTP_PreservesNumber(t *testin
if errNonStream != nil {
t.Fatalf("Execute fallback error = %v", errNonStream)
}
- if got := gjson.GetBytes(httpCapturedBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(httpCapturedBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("non-stream fallback tools exec_command yield_time_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(httpCapturedBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(httpCapturedBody, "tools.#(name==\"collaboration\").tools.0.parameters.properties.timeout_ms.type").String(); got != "number" {
t.Fatalf("non-stream fallback collaboration.wait_agent timeout_ms type = %q, want number", got)
}
- if got := gjson.GetBytes(httpCapturedBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(httpCapturedBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("non-stream fallback input additional_tools yield_time_ms type = %q, want number", got)
}
}
@@ -965,10 +965,10 @@ func TestOpenAICompatExecutor_NormalizesToolIntegerTypesForCodexUserAgent_NonCod
t.Fatalf("Execute() error = %v", errExec)
}
- if got := gjson.GetBytes(gotBody, "tools.0.function.parameters.properties.yield_time_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(gotBody, "tools.0.function.parameters.properties.yield_time_ms.type").String(); got != "integer" {
t.Fatalf("expected non-Codex executor to normalize yield_time_ms to integer, got: %s (body=%s)", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "tools.0.function.parameters.properties.timeout_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(gotBody, "tools.0.function.parameters.properties.timeout_ms.type").String(); got != "integer" {
t.Fatalf("expected non-Codex executor to normalize timeout_ms to integer, got: %s (body=%s)", got, string(gotBody))
}
}
diff --git a/internal/runtime/executor/codex_hosted_images_regression_test.go b/internal/runtime/executor/codex_hosted_images_regression_test.go
new file mode 100644
index 00000000000..b5b70f0dd04
--- /dev/null
+++ b/internal/runtime/executor/codex_hosted_images_regression_test.go
@@ -0,0 +1,366 @@
+package executor
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/gorilla/websocket"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+type codexHostedImageCapturedRequest struct {
+ body []byte
+ headers http.Header
+}
+
+func newCodexHostedImageTestServer(t *testing.T, transport string) (*httptest.Server, <-chan codexHostedImageCapturedRequest, *atomic.Int32) {
+ t.Helper()
+ captured := make(chan codexHostedImageCapturedRequest, 16)
+ calls := &atomic.Int32{}
+ completed := []byte(`{"type":"response.completed","response":{"id":"resp_hosted_image","object":"response","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ if transport == "websocket" {
+ upgrader := websocket.Upgrader{}
+ conn, errUpgrade := upgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Error(errUpgrade)
+ return
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Error(errClose)
+ }
+ }()
+ _, body, errRead := conn.ReadMessage()
+ if errRead != nil {
+ t.Error(errRead)
+ return
+ }
+ captured <- codexHostedImageCapturedRequest{body: body, headers: r.Header.Clone()}
+ if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
+ t.Error(errWrite)
+ }
+ return
+ }
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Error(errRead)
+ return
+ }
+ captured <- codexHostedImageCapturedRequest{body: body, headers: r.Header.Clone()}
+ w.Header().Set("Content-Type", "text/event-stream")
+ if _, errWrite := w.Write(append(append([]byte("data: "), completed...), '\n', '\n')); errWrite != nil {
+ t.Error(errWrite)
+ }
+ }))
+ t.Cleanup(server.Close)
+ return server, captured, calls
+}
+
+func assertCodexHostedImageRequestError(t *testing.T, err error) {
+ t.Helper()
+ var scoped cliproxyexecutor.RequestScopedError
+ var status interface{ StatusCode() int }
+ if !errors.As(err, &scoped) || !scoped.IsRequestScoped() || !errors.As(err, &status) || status.StatusCode() != http.StatusBadRequest {
+ t.Fatalf("expected request-scoped HTTP 400, got %v", err)
+ }
+ if !strings.Contains(err.Error(), "/v1/images/generations") {
+ t.Fatalf("missing Images API guidance: %v", err)
+ }
+}
+
+func TestCodexHostedImageRejectionDoesNotRotateCooldownOrHideModel(t *testing.T) {
+ for _, transport := range []string{"http", "websocket"} {
+ for _, stream := range []bool{false, true} {
+ for _, model := range []string{"gpt-6-sol", "gpt-6.1-sol"} {
+ t.Run(transport+"/"+model+map[bool]string{false: "/execute", true: "/stream"}[stream], func(t *testing.T) {
+ server, _, calls := newCodexHostedImageTestServer(t, transport)
+ cfg := &config.Config{}
+ m := cliproxyauth.NewManager(nil, nil, nil)
+ m.SetConfig(cfg)
+ m.SetRetryConfig(3, time.Second, 0)
+ if transport == "websocket" {
+ m.RegisterExecutor(NewCodexWebsocketsExecutor(cfg))
+ } else {
+ m.RegisterExecutor(NewCodexExecutor(cfg))
+ }
+ reg := registry.GetGlobalRegistry()
+ authIDs := []string{t.Name() + "/a", t.Name() + "/b"}
+ for _, id := range authIDs {
+ auth := &cliproxyauth.Auth{
+ ID: id, Provider: "codex", Status: cliproxyauth.StatusActive,
+ Attributes: map[string]string{"base_url": server.URL, "plan_type": "pro"},
+ Metadata: map[string]any{"access_token": "test-token", "account_id": "test-account"},
+ }
+ if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ reg.RegisterClient(id, "codex", []*registry.ModelInfo{{ID: model, Type: "codex", Object: "model", OwnedBy: "openai"}})
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ }
+ doc := ojson.NewDocString(`{"input":"draw a cat","tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"}}`)
+ _ = doc.Set("model", model)
+ req := cliproxyexecutor.Request{Model: model, Payload: doc.Bytes()}
+ selected := 0
+ opts := cliproxyexecutor.Options{
+ Stream: stream, SourceFormat: sdktranslator.FormatOpenAIResponse,
+ Metadata: map[string]any{cliproxyexecutor.SelectedAuthCallbackMetadataKey: func(string) { selected++ }},
+ }
+ var errExecute error
+ if stream {
+ _, errExecute = m.ExecuteStream(context.Background(), []string{"codex"}, req, opts)
+ } else {
+ _, errExecute = m.Execute(context.Background(), []string{"codex"}, req, opts)
+ }
+ assertCodexHostedImageRequestError(t, errExecute)
+ if selected != 1 || calls.Load() != 0 {
+ t.Fatalf("selected=%d upstream calls=%d, want one selection and no upstream calls", selected, calls.Load())
+ }
+ for _, id := range authIDs {
+ auth, ok := m.GetByID(id)
+ if !ok || auth.Unavailable || !auth.NextRetryAfter.IsZero() || len(auth.ModelStates) != 0 || len(cliproxyauth.CooldownSnapshotForAuth(auth, time.Now())) != 0 {
+ t.Fatalf("request rejection changed credential availability: %#v", auth)
+ }
+ if reg.IsModelSuspendedForClient(id, model) {
+ t.Fatalf("original model suspended for %s", id)
+ }
+ }
+ visible := false
+ for _, info := range reg.GetAvailableModels("codex") {
+ visible = visible || info["id"] == model
+ }
+ if !visible {
+ t.Fatal("original model disappeared from the Codex model list")
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestCodexHostedImageContinuationRejectsBeforeWritingUpstream(t *testing.T) {
+ var connections, frames atomic.Int32
+ done := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ connections.Add(1)
+ defer close(done)
+ conn, errUpgrade := (&websocket.Upgrader{}).Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Error(errUpgrade)
+ return
+ }
+ defer func() {
+ if errClose := conn.Close(); errClose != nil {
+ t.Error(errClose)
+ }
+ }()
+ if _, _, errRead := conn.ReadMessage(); errRead != nil {
+ t.Error(errRead)
+ return
+ }
+ frames.Add(1)
+ for _, event := range []string{
+ `{"type":"response.created","response":{"id":"started","output":[]}}`,
+ `{"type":"response.completed","response":{"id":"started","output":[]}}`,
+ } {
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(event)); errWrite != nil {
+ t.Error(errWrite)
+ return
+ }
+ }
+ if _, _, errRead := conn.ReadMessage(); errRead == nil {
+ frames.Add(1)
+ }
+ }))
+ defer server.Close()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ input := make(chan cliproxyexecutor.WebsocketInput, 1)
+ ctx = cliproxyexecutor.WithWebsocketInput(cliproxyexecutor.WithDownstreamWebsocket(ctx), input)
+ cfg := &config.Config{}
+ cfg.Codex.ResponseSteering = true
+ cfg.CodexResponseSteering = true
+ exec := NewCodexWebsocketsExecutor(cfg)
+ exec.store = &codexWebsocketSessionStore{sessions: make(map[string]*codexWebsocketSession)}
+ m := cliproxyauth.NewManager(nil, &cliproxyauth.FillFirstSelector{}, nil)
+ m.SetConfig(cfg)
+ m.SetRetryConfig(3, time.Second, 0)
+ m.RegisterExecutor(exec)
+ reg := registry.GetGlobalRegistry()
+ authIDs := []string{t.Name() + "/a", t.Name() + "/b"}
+ for _, id := range authIDs {
+ auth := &cliproxyauth.Auth{ID: id, Provider: "codex", Status: cliproxyauth.StatusActive, Attributes: map[string]string{"base_url": server.URL, "websockets": "true"}, Metadata: map[string]any{"access_token": "test-token", "account_id": "test-account"}}
+ if _, errRegister := m.Register(ctx, auth); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ reg.RegisterClient(id, "codex", []*registry.ModelInfo{{ID: "gpt-6-sol"}})
+ t.Cleanup(func() { reg.UnregisterClient(id) })
+ }
+ result, errStream := m.ExecuteStream(ctx, []string{"codex"}, cliproxyexecutor.Request{Model: "gpt-6-sol", Payload: []byte(`{"model":"gpt-6-sol","input":[]}`)}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatCodex, Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: t.Name()}})
+ if errStream != nil {
+ t.Fatal(errStream)
+ }
+ completed := false
+ var continuationErr error
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ continuationErr = chunk.Err
+ continue
+ }
+ if !completed && ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ completed = true
+ input <- cliproxyexecutor.WebsocketInput{Payload: []byte(`{"type":"response.create","previous_response_id":"started","input":[],"tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"}}`)}
+ }
+ }
+ if !completed {
+ t.Fatal("initial response never completed")
+ }
+ assertCodexHostedImageRequestError(t, continuationErr)
+ select {
+ case <-done:
+ case <-ctx.Done():
+ t.Fatal("rejected continuation did not release the upstream socket")
+ }
+ if connections.Load() != 1 || frames.Load() != 1 {
+ t.Fatalf("continuation was forwarded or replayed: connections=%d frames=%d", connections.Load(), frames.Load())
+ }
+ for _, id := range authIDs {
+ auth, _ := m.GetByID(id)
+ if auth.Unavailable || len(cliproxyauth.CooldownSnapshotForAuth(auth, time.Now())) != 0 || reg.IsModelSuspendedForClient(id, "gpt-6-sol") {
+ t.Fatalf("rejected continuation changed credential availability: %#v", auth)
+ }
+ }
+}
+
+func TestCodexHostedImageValidationUsesFinalPayload(t *testing.T) {
+ imageBody := `{"input":[{"role":"user","content":"first"},{"role":"user","content":"second"}],"tools":[{"type":"image_generation","complexity":"medium"}],"tool_choice":{"type":"image_generation"}}`
+ for _, tc := range []struct {
+ name string
+ body string
+ model string
+ cfg config.Config
+ headers http.Header
+ attrs map[string]string
+ reject bool
+ wantModel string
+ wantLite string
+ check func(*testing.T, []byte)
+ }{
+ {name: "forced", reject: true},
+ {name: "legacy_hosted_images", model: "gpt-5.4", check: func(t *testing.T, body []byte) {
+ if ojson.GetBytes(body, "tools.0.type").String() != "image_generation" || ojson.GetBytes(body, "tools.0.complexity").Exists() {
+ t.Fatalf("legacy hosted image normalization changed: %s", body)
+ }
+ }},
+ {name: "legacy_additional_tools", model: "gpt-5.4", body: `{"input":[{"type":"additional_tools","tools":[{"type":"image_generation","complexity":"high"}]},{"role":"user","content":"draw a cat"}],"tool_choice":{"type":"image_generation"}}`, check: func(t *testing.T, body []byte) {
+ if ojson.GetBytes(body, "input.0.tools.0.type").String() != "image_generation" || ojson.GetBytes(body, "input.0.tools.0.complexity").Exists() {
+ t.Fatalf("legacy additional image tool normalization changed: %s", body)
+ }
+ }},
+ {name: "image_base_override_does_not_replace_chat_model", cfg: config.Config{SDKConfig: config.SDKConfig{GPTImage2BaseModel: "gpt-5.5"}}, reject: true},
+ {name: "filtered", cfg: config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: []string{"tools", "tool_choice", "input.0"}}}}}, check: func(t *testing.T, body []byte) {
+ if ojson.GetBytes(body, "tools").Exists() || ojson.GetBytes(body, "tool_choice").Exists() || ojson.GetBytes(body, "input.#").Int() != 1 || ojson.GetBytes(body, "input.0.content").String() != "second" {
+ t.Fatalf("filter was undone or applied more than once: %s", body)
+ }
+ }},
+ {name: "filter_header_matches", headers: http.Header{"X-Drop-Image": {"true"}}, cfg: config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*", Headers: map[string]string{"X-Drop-Image": "true"}}}, Params: []string{"tools", "tool_choice"}}}}}},
+ {name: "filter_header_misses", headers: http.Header{"X-Drop-Image": {"false"}}, cfg: config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*", Headers: map[string]string{"X-Drop-Image": "true"}}}, Params: []string{"tools", "tool_choice"}}}}}, reject: true},
+ {name: "filter_protocol_misses", cfg: config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "*", Protocol: "claude"}}, Params: []string{"tools", "tool_choice"}}}}}, reject: true},
+ {name: "override_auto", cfg: config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"tool_choice": "auto", "tools.0.complexity": "high"}}}}}, check: func(t *testing.T, body []byte) {
+ if ojson.GetBytes(body, "tool_choice").String() != "auto" || ojson.GetBytes(body, "tools.0.complexity").String() != "high" {
+ t.Fatalf("built-in normalization overwrote the configured payload: %s", body)
+ }
+ }},
+ {name: "override_supported_model", cfg: config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"model": "gpt-5.5"}}}}}, wantModel: "gpt-5.5"},
+ {name: "override_lite_model", model: "gpt-5.4", cfg: config.Config{Payload: config.PayloadConfig{Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"model": "gpt-6-sol"}}}}}, reject: true},
+ {name: "override_raw_injects_force", body: `{"input":"hello"}`, cfg: config.Config{Payload: config.PayloadConfig{OverrideRaw: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"tools": `[{"type":"image_generation"}]`, "tool_choice": `{"type":"image_generation"}`}}}}}, reject: true},
+ {name: "default_injects_force", body: `{"input":"hello"}`, cfg: config.Config{Payload: config.PayloadConfig{Default: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "*"}}, Params: map[string]any{"tools": []any{map[string]any{"type": "image_generation"}}, "tool_choice": map[string]any{"type": "image_generation"}}}}}}, reject: true},
+ {name: "lite_header", model: "gpt-5.4", headers: http.Header{http.CanonicalHeaderKey(codexResponsesLiteHeader): {"true"}}, reject: true},
+ {name: "provider_enables_lite", model: "gpt-5.4", attrs: map[string]string{"header:" + codexResponsesLiteHeader: "true"}, reject: true},
+ {name: "provider_disables_lite", model: "gpt-5.4", headers: http.Header{http.CanonicalHeaderKey(codexResponsesLiteHeader): {"true"}}, attrs: map[string]string{"header:" + codexResponsesLiteHeader: "false"}, wantLite: "false"},
+ } {
+ for _, transport := range []string{"http", "websocket"} {
+ for _, stream := range []bool{false, true} {
+ t.Run(tc.name+"/"+transport+map[bool]string{false: "/execute", true: "/stream"}[stream], func(t *testing.T) {
+ server, captured, calls := newCodexHostedImageTestServer(t, transport)
+ cfg := tc.cfg
+ var exec cliproxyauth.ProviderExecutor = NewCodexExecutor(&cfg)
+ if transport == "websocket" {
+ exec = NewCodexWebsocketsExecutor(&cfg)
+ }
+ model := tc.model
+ if model == "" {
+ model = "gpt-6-sol"
+ }
+ body := tc.body
+ if body == "" {
+ body = imageBody
+ }
+ doc := ojson.NewDocString(body)
+ _ = doc.Set("model", model)
+ req := cliproxyexecutor.Request{Model: model, Payload: doc.Bytes()}
+ auth := &cliproxyauth.Auth{ID: t.Name(), Provider: "codex", Attributes: map[string]string{"base_url": server.URL, "plan_type": "pro"}, Metadata: map[string]any{"access_token": "test-token", "account_id": "test-account"}}
+ for key, value := range tc.attrs {
+ auth.Attributes[key] = value
+ }
+ finalized := 0
+ opts := cliproxyexecutor.Options{Stream: stream, SourceFormat: sdktranslator.FormatOpenAIResponse, Headers: tc.headers, Metadata: map[string]any{cliproxyexecutor.FinalizedResponseFormatCallbackMetadataKey: func(string) { finalized++ }}}
+ var errExecute error
+ if stream {
+ var result *cliproxyexecutor.StreamResult
+ result, errExecute = exec.ExecuteStream(context.Background(), auth, req, opts)
+ if result != nil {
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ errExecute = chunk.Err
+ }
+ }
+ }
+ } else {
+ _, errExecute = exec.Execute(context.Background(), auth, req, opts)
+ }
+ if finalized != 1 {
+ t.Fatalf("payload rules evaluated %d times, want once", finalized)
+ }
+ if tc.reject {
+ assertCodexHostedImageRequestError(t, errExecute)
+ if calls.Load() != 0 {
+ t.Fatalf("rejected payload reached upstream %d times", calls.Load())
+ }
+ return
+ }
+ if errExecute != nil {
+ t.Fatal(errExecute)
+ }
+ got := <-captured
+ wantModel := tc.wantModel
+ if wantModel == "" {
+ wantModel = model
+ }
+ if ojson.GetBytes(got.body, "model").String() != wantModel || got.headers.Get("X-Codex-Routing-Hint") != "model="+wantModel {
+ t.Fatalf("model or routing hint changed: body=%s hint=%s", got.body, got.headers.Get("X-Codex-Routing-Hint"))
+ }
+ if tc.wantLite != "" && got.headers.Get(codexResponsesLiteHeader) != tc.wantLite {
+ t.Fatalf("Responses-Lite header = %q, want %q", got.headers.Get(codexResponsesLiteHeader), tc.wantLite)
+ }
+ if tc.check != nil {
+ tc.check(t, got.body)
+ }
+ })
+ }
+ }
+ }
+}
diff --git a/internal/runtime/executor/codex_native_fidelity_test.go b/internal/runtime/executor/codex_native_fidelity_test.go
index 8e84638297b..1a113b4e43a 100644
--- a/internal/runtime/executor/codex_native_fidelity_test.go
+++ b/internal/runtime/executor/codex_native_fidelity_test.go
@@ -9,11 +9,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexNativeStreamFidelity(t *testing.T) {
@@ -91,10 +91,10 @@ func testCodexNativeStreamFidelity(t *testing.T, source sdktranslator.Format) {
}
for _, line := range bytes.Split(chunk.Payload, []byte("\n")) {
data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
- if gjson.GetBytes(data, "type").String() == "codex.response.metadata" {
+ if ojson.GetBytes(data, "type").String() == "codex.response.metadata" {
metadataEvents = append(metadataEvents, string(data))
}
- if gjson.GetBytes(data, "type").String() == "response.completed" {
+ if ojson.GetBytes(data, "type").String() == "response.completed" {
terminal = bytes.Clone(data)
}
}
@@ -122,13 +122,13 @@ func testCodexNativeStreamFidelity(t *testing.T, source sdktranslator.Format) {
}
t.Logf("upstream request: %s; downstream completion: %s", body, terminal)
if native {
- if gjson.GetBytes(body, "instructions").Exists() {
+ if ojson.GetBytes(body, "instructions").Exists() {
t.Errorf("native request gained instructions: %s", body)
}
if string(terminal) != completed {
t.Errorf("native completion changed: %s", terminal)
}
- } else if gjson.GetBytes(body, "instructions").Type != gjson.String || gjson.GetBytes(terminal, "response.output.0.id").String() != "msg_1" {
+ } else if ojson.GetBytes(body, "instructions").Type != ojson.String || ojson.GetBytes(terminal, "response.output.0.id").String() != "msg_1" {
t.Errorf("compatibility normalization/backfill lost: %s; %s", body, terminal)
}
})
diff --git a/internal/runtime/executor/codex_openai_images.go b/internal/runtime/executor/codex_openai_images.go
index abea9f74b7e..11e60c3d96b 100644
--- a/internal/runtime/executor/codex_openai_images.go
+++ b/internal/runtime/executor/codex_openai_images.go
@@ -4,9 +4,9 @@ import (
"bufio"
"bytes"
"context"
- "encoding/base64"
"encoding/json"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"mime"
"mime/multipart"
@@ -16,15 +16,15 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -37,6 +37,9 @@ const (
codexGPTImage25FlareModel = "gpt-image-2.5-flare"
codexGPTImage25SunburstModel = "gpt-image-2.5-sunburst"
codexGPTImage25Model = "gpt-image-2.5"
+ codexGPTImage26FlareModel = "gpt-image-2.6-flare"
+ codexGPTImage26SunburstModel = "gpt-image-2.6-sunburst"
+ codexGPTImage26Model = "gpt-image-2.6"
codexOpenAIImagesMainModel = "gpt-5.4-mini"
)
@@ -71,28 +74,20 @@ func codexIsImagesEndpointPath(path string) bool {
}
func (e *CodexExecutor) resolveGPTImage2BaseModel() string {
- if e == nil || e.cfg == nil {
+ if e == nil {
return codexOpenAIImagesMainModel
}
- model := strings.TrimSpace(e.cfg.GPTImage2BaseModel)
- if model == "" {
- return codexOpenAIImagesMainModel
- }
- if strings.HasPrefix(strings.ToLower(model), "gpt-") {
- return model
- }
- return codexOpenAIImagesMainModel
+ return resolveCodexGPTImage2BaseModel(e.cfg)
}
func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
- apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers)
- if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil {
+ ctx = cliproxyauth.WithCodexClientRequest(ctx, true)
+ apiKey, baseURL := codexCredsForRequest(ctx, auth)
+ if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey); errSIWC != nil {
return resp, errSIWC
}
- if !codexUseSIWCForRequest(ctx, auth, opts.Headers) {
- if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" {
- return e.executeDirectOpenAIImage(ctx, auth, req, opts, directEndpoint)
- }
+ if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" {
+ return e.executeDirectOpenAIImage(ctx, auth, req, opts, directEndpoint)
}
prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts)
@@ -108,7 +103,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
reporter := helps.NewExecutorUsageReporter(ctx, e, mainModel, auth)
defer reporter.TrackFailure(ctx, &err)
- body, errBuild := e.prepareCodexOpenAIImageBody(ctx, auth, prepared.Body, req, opts, mainModel)
+ doc, errBuild := e.prepareCodexOpenAIImageDoc(ctx, auth, prepared.Body, req, opts, mainModel)
if errBuild != nil {
return resp, errBuild
}
@@ -117,7 +112,7 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
payloadOpts := opts
payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat)
ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), mainModel, "codex", "", prepared.Body, req, payloadOpts))
- httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body, auth, opts.Headers)
+ httpReq, body, errCache := e.cacheHelperDoc(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, doc, auth, opts.Headers)
if errCache != nil {
return resp, errCache
}
@@ -164,7 +159,11 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
}
eventData := bytes.TrimSpace(line[len(dataTag):])
reporter.ObserveResponseModel(eventData)
- switch gjson.GetBytes(eventData, "type").String() {
+ if errEvent, _, ok := codexTerminalFailureErrWithCooling(eventData, e.modelLevelCooling()); ok {
+ err = errEvent
+ return resp, err
+ }
+ switch ojson.GetBytesNoCopy(eventData, "type").String() {
case "response.output_item.done":
collectCodexOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
case "response.completed":
@@ -192,14 +191,13 @@ func (e *CodexExecutor) executeOpenAIImage(ctx context.Context, auth *cliproxyau
}
func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
- apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers)
- if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey, opts.Headers); errSIWC != nil {
+ ctx = cliproxyauth.WithCodexClientRequest(ctx, true)
+ apiKey, baseURL := codexCredsForRequest(ctx, auth)
+ if errSIWC := ensureCodexSIWCCredentialsForRequest(ctx, auth, apiKey); errSIWC != nil {
return nil, errSIWC
}
- if !codexUseSIWCForRequest(ctx, auth, opts.Headers) {
- if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" {
- return e.executeDirectOpenAIImageStream(ctx, auth, req, opts, directEndpoint)
- }
+ if directEndpoint := codexDirectOpenAIImageEndpoint(req, opts); directEndpoint != "" {
+ return e.executeDirectOpenAIImageStream(ctx, auth, req, opts, directEndpoint)
}
prepared, errPrepare := codexPrepareOpenAIImageRequest(req, opts)
@@ -215,7 +213,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
reporter := helps.NewExecutorUsageReporter(ctx, e, mainModel, auth)
defer reporter.TrackFailure(ctx, &err)
- body, errBuild := e.prepareCodexOpenAIImageBody(ctx, auth, prepared.Body, req, opts, mainModel)
+ doc, errBuild := e.prepareCodexOpenAIImageDoc(ctx, auth, prepared.Body, req, opts, mainModel)
if errBuild != nil {
return nil, errBuild
}
@@ -224,7 +222,7 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
payloadOpts := opts
payloadOpts.SourceFormat = sdktranslator.FromString(codexOpenAIImageSourceFormat)
ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), mainModel, "codex", "", prepared.Body, req, payloadOpts))
- httpReq, body, errCache := e.cacheHelper(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, body, auth, opts.Headers)
+ httpReq, body, errCache := e.cacheHelperDoc(ctx, sdktranslator.FromString(codexOpenAIImageSourceFormat), url, req, doc, auth, opts.Headers)
if errCache != nil {
return nil, errCache
}
@@ -298,7 +296,13 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
}
eventData := bytes.TrimSpace(line[len(dataTag):])
reporter.ObserveResponseModel(eventData)
- switch gjson.GetBytes(eventData, "type").String() {
+ if errEvent, _, ok := codexTerminalFailureErrWithCooling(eventData, e.modelLevelCooling()); ok {
+ helps.RecordAPIResponseError(ctx, e.cfg, errEvent)
+ reporter.PublishFailure(ctx, errEvent)
+ sendError(errEvent)
+ return
+ }
+ switch ojson.GetBytesNoCopy(eventData, "type").String() {
case "response.output_item.done":
collectCodexOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
case "response.image_generation_call.partial_image":
@@ -339,12 +343,13 @@ func (e *CodexExecutor) executeOpenAIImageStream(ctx context.Context, auth *clip
}
func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) {
+ ctx = cliproxyauth.WithCodexClientRequest(ctx, true)
body, contentType, model, errPrepare := e.prepareDirectOpenAIImageBody(auth, req, opts, false)
if errPrepare != nil {
return resp, errPrepare
}
- apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers)
+ apiKey, baseURL := codexCredsForRequest(ctx, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
@@ -411,12 +416,13 @@ func (e *CodexExecutor) executeDirectOpenAIImage(ctx context.Context, auth *clip
}
func (e *CodexExecutor) executeDirectOpenAIImageStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (_ *cliproxyexecutor.StreamResult, err error) {
+ ctx = cliproxyauth.WithCodexClientRequest(ctx, true)
body, contentType, model, errPrepare := e.prepareDirectOpenAIImageBody(auth, req, opts, true)
if errPrepare != nil {
return nil, errPrepare
}
- apiKey, baseURL := codexCredsForRequest(ctx, auth, opts.Headers)
+ apiKey, baseURL := codexCredsForRequest(ctx, auth)
if baseURL == "" {
baseURL = "https://chatgpt.com/backend-api/codex"
}
@@ -584,10 +590,10 @@ func codexRewriteOpenAIImageEditMultipartToJSON(payload []byte, model string, bo
}
}()
- out := []byte(`{}`)
- out, _ = sjson.SetBytes(out, "model", model)
+ doc := ojson.NewDoc([]byte(`{}`))
+ _ = doc.Set("model", model)
if stream {
- out, _ = sjson.SetBytes(out, "stream", true)
+ _ = doc.Set("stream", true)
}
for key, values := range form.Value {
@@ -595,7 +601,7 @@ func codexRewriteOpenAIImageEditMultipartToJSON(payload []byte, model string, bo
if key == "" || key == "model" || key == "stream" {
continue
}
- out = codexSetOpenAIImageEditFormValues(out, key, values)
+ codexSetOpenAIImageEditFormValuesDoc(doc, key, values)
}
if maskFiles := form.File["mask"]; len(maskFiles) > 0 && maskFiles[0] != nil {
@@ -603,11 +609,11 @@ func codexRewriteOpenAIImageEditMultipartToJSON(payload []byte, model string, bo
if errData != nil {
return nil, "", errData
}
- out, _ = sjson.SetBytes(out, "mask.image_url", dataURL)
+ _ = doc.Set("mask.image_url", dataURL)
}
imageFiles := codexMultipartImageFiles(form)
- if existingImages := gjson.GetBytes(out, "images"); !existingImages.Exists() || existingImages.IsArray() {
+ if existingImages := doc.Get("images"); !existingImages.Exists() || existingImages.IsArray() {
existingItems := existingImages.Array()
imageItems := make([][]byte, 0, len(existingItems)+len(imageFiles))
for _, image := range existingItems {
@@ -618,12 +624,11 @@ func codexRewriteOpenAIImageEditMultipartToJSON(payload []byte, model string, bo
if errData != nil {
return nil, "", errData
}
- item := []byte(`{"image_url":""}`)
- item, _ = sjson.SetBytes(item, "image_url", dataURL)
+ item, _ := ojson.SetBytes([]byte(`{"image_url":""}`), "image_url", dataURL)
imageItems = append(imageItems, item)
}
if len(imageFiles) > 0 {
- out, _ = sjson.SetRawBytes(out, "images", helps.JoinRawJSONArray(imageItems))
+ _ = doc.SetRawBytes("images", helps.JoinRawJSONArray(imageItems))
}
} else {
for _, fileHeader := range imageFiles {
@@ -631,38 +636,53 @@ func codexRewriteOpenAIImageEditMultipartToJSON(payload []byte, model string, bo
if errData != nil {
return nil, "", errData
}
- out, _ = sjson.SetBytes(out, "images.-1.image_url", dataURL)
+ _ = doc.Set("images.-1.image_url", dataURL)
}
}
- return out, "application/json", nil
+ return doc.Bytes(), "application/json", nil
}
func codexSetOpenAIImageEditFormValues(out []byte, key string, values []string) []byte {
- if len(values) == 0 {
+ doc := ojson.NewDoc(out)
+ if !codexSetOpenAIImageEditFormValuesDoc(doc, key, values) {
return out
}
+ return doc.Bytes()
+}
+
+func codexSetOpenAIImageEditFormValuesDoc(doc *ojson.Doc, key string, values []string) bool {
+ if doc == nil || len(values) == 0 {
+ return false
+ }
path := codexOpenAIImageEditFormJSONPath(key)
if path == "" {
- return out
+ return false
}
if len(values) == 1 {
- return codexSetOpenAIImageEditFormValue(out, path, values[0])
+ return codexSetOpenAIImageEditFormValueDoc(doc, path, values[0])
}
items := make([][]byte, 0, len(values))
for _, value := range values {
items = append(items, codexOpenAIImageEditFormJSONValue(key, value))
}
- out, _ = sjson.SetRawBytes(out, path, helps.JoinRawJSONArray(items))
- return out
+ return doc.SetRawBytes(path, helps.JoinRawJSONArray(items)) == nil
}
func codexSetOpenAIImageEditFormValue(out []byte, path string, value string) []byte {
item := codexOpenAIImageEditFormJSONValue(path, value)
- out, _ = sjson.SetRawBytes(out, path, item)
+ out, _ = ojson.SetRawBytes(out, path, item)
return out
}
+func codexSetOpenAIImageEditFormValueDoc(doc *ojson.Doc, path string, value string) bool {
+ if doc == nil {
+ return false
+ }
+ item := codexOpenAIImageEditFormJSONValue(path, value)
+ return doc.SetRawBytes(path, item) == nil
+}
+
func codexOpenAIImageEditFormJSONValue(key string, value string) []byte {
value = strings.TrimSpace(value)
switch strings.ToLower(strings.TrimSpace(key)) {
@@ -693,7 +713,7 @@ func codexDirectOpenAIImageRouteModel(req cliproxyexecutor.Request, opts cliprox
requestedModel := helps.PayloadRequestedModel(opts, "")
payloadModel := ""
if json.Valid(req.Payload) {
- payloadModel = gjson.GetBytes(req.Payload, "model").String()
+ payloadModel = ojson.GetBytesNoCopy(req.Payload, "model").String()
}
for _, model := range []string{requestedModel, payloadModel, req.Model} {
baseModel := codexOpenAIImageBaseModel(model)
@@ -743,7 +763,7 @@ func codexOpenAIImageBaseModel(model string) string {
func codexIsDirectOpenAIImageModel(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
- case codexGPTImage15Model, codexDefaultImageToolModel, codexGPTImage25FlareModel, codexGPTImage25SunburstModel, codexGPTImage25Model:
+ case codexGPTImage15Model, codexDefaultImageToolModel, codexGPTImage25FlareModel, codexGPTImage25SunburstModel, codexGPTImage25Model, codexGPTImage26FlareModel, codexGPTImage26SunburstModel, codexGPTImage26Model:
return true
default:
return false
@@ -751,27 +771,31 @@ func codexIsDirectOpenAIImageModel(model string) bool {
}
func (e *CodexExecutor) prepareCodexOpenAIImageBody(ctx context.Context, auth *cliproxyauth.Auth, body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, mainModel string) ([]byte, error) {
- out := body
+ doc, err := e.prepareCodexOpenAIImageDoc(ctx, auth, body, req, opts, mainModel)
+ if err != nil {
+ return nil, err
+ }
+ return doc.Bytes(), nil
+}
+
+func (e *CodexExecutor) prepareCodexOpenAIImageDoc(ctx context.Context, auth *cliproxyauth.Auth, body []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, mainModel string) (*ojson.Doc, error) {
+ doc := ojson.NewDoc(body)
mainModel = strings.TrimSpace(mainModel)
if mainModel == "" {
mainModel = codexOpenAIImagesMainModel
}
- var errThinking error
- out, errThinking = helps.ApplyThinkingWithSourcePayload(out, body, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier())
- if errThinking != nil {
+ if errThinking := helps.ApplyThinkingWithSourcePayloadDoc(doc, body, body, mainModel, codexOpenAIImageSourceFormat, "codex", e.Identifier()); errThinking != nil {
return nil, errThinking
}
- out = helps.SetStringIfDifferent(out, "model", mainModel)
- out = helps.SetBoolIfDifferent(out, "stream", true)
- out, _ = sjson.DeleteBytes(out, "previous_response_id")
- out, _ = sjson.DeleteBytes(out, "prompt_cache_retention")
- out, _ = sjson.DeleteBytes(out, "safety_identifier")
- out, _ = sjson.DeleteBytes(out, "stream_options")
- if codexUseSIWCForRequest(ctx, auth, opts.Headers) {
- return applyCodexSIWCRequestBodyRules(out, false), nil
- }
- return normalizeCodexInstructions(out), nil
+ helps.SetStringIfDifferentDoc(doc, "model", mainModel)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
+ _ = doc.Delete("previous_response_id")
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ _ = doc.Delete("stream_options")
+ normalizeCodexInstructionsDoc(doc)
+ return doc, nil
}
func recordCodexOpenAIImageRequest(ctx context.Context, cfg *config.Config, provider string, auth *cliproxyauth.Auth, url string, headers http.Header, body []byte) {
@@ -815,7 +839,7 @@ func codexPrepareOpenAIImageGenerationJSON(rawJSON []byte, routeModel string) (c
if !json.Valid(rawJSON) {
return codexOpenAIImagePreparedRequest{}, fmt.Errorf("invalid OpenAI image generation request JSON")
}
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ prompt := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "prompt").String())
tool := codexBuildOpenAIImageTool(rawJSON, routeModel, "generate", []string{"size", "quality", "background", "output_format", "moderation"}, []string{"output_compression", "partial_images"})
body := codexBuildImagesResponsesRequest(prompt, nil, tool)
return codexOpenAIImagePreparedRequest{
@@ -829,9 +853,9 @@ func codexPrepareOpenAIImageEditJSON(rawJSON []byte, routeModel string) (codexOp
if !json.Valid(rawJSON) {
return codexOpenAIImagePreparedRequest{}, fmt.Errorf("invalid OpenAI image edit request JSON")
}
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ prompt := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "prompt").String())
images := make([]string, 0)
- if imagesResult := gjson.GetBytes(rawJSON, "images"); imagesResult.IsArray() {
+ if imagesResult := ojson.GetBytesNoCopy(rawJSON, "images"); imagesResult.IsArray() {
for _, img := range imagesResult.Array() {
url := strings.TrimSpace(img.Get("image_url").String())
if url != "" {
@@ -840,8 +864,8 @@ func codexPrepareOpenAIImageEditJSON(rawJSON []byte, routeModel string) (codexOp
}
}
tool := codexBuildOpenAIImageTool(rawJSON, routeModel, "edit", []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"}, []string{"output_compression", "partial_images"})
- if mask := strings.TrimSpace(gjson.GetBytes(rawJSON, "mask.image_url").String()); mask != "" {
- tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", mask)
+ if mask := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "mask.image_url").String()); mask != "" {
+ tool, _ = ojson.SetBytes(tool, "input_image_mask.image_url", mask)
}
body := codexBuildImagesResponsesRequest(prompt, images, tool)
return codexOpenAIImagePreparedRequest{
@@ -873,17 +897,17 @@ func codexPrepareOpenAIImageEditMultipart(rawBody []byte, routeModel string, con
prompt := strings.TrimSpace(codexFormValue(form, "prompt"))
responseFormat := codexNormalizeImageResponseFormat(codexFormValue(form, "response_format"))
- tool := []byte(`{"type":"image_generation","action":"edit"}`)
- tool, _ = sjson.SetBytes(tool, "model", codexOpenAIImageToolModel(codexFormValue(form, "model"), routeModel))
+ toolDoc := ojson.NewDoc([]byte(`{"type":"image_generation","action":"edit"}`))
+ _ = toolDoc.Set("model", codexOpenAIImageToolModel(codexFormValue(form, "model"), routeModel))
for _, field := range []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"} {
if value := strings.TrimSpace(codexFormValue(form, field)); value != "" {
- tool, _ = sjson.SetBytes(tool, field, value)
+ _ = toolDoc.Set(field, value)
}
}
for _, field := range []string{"output_compression", "partial_images"} {
if value := strings.TrimSpace(codexFormValue(form, field)); value != "" {
if parsed, errParse := strconv.ParseInt(value, 10, 64); errParse == nil {
- tool, _ = sjson.SetBytes(tool, field, parsed)
+ _ = toolDoc.Set(field, parsed)
}
}
}
@@ -901,10 +925,10 @@ func codexPrepareOpenAIImageEditMultipart(rawBody []byte, routeModel string, con
if errData != nil {
return codexOpenAIImagePreparedRequest{}, errData
}
- tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", dataURL)
+ _ = toolDoc.Set("input_image_mask.image_url", dataURL)
}
- body := codexBuildImagesResponsesRequest(prompt, images, tool)
+ body := codexBuildImagesResponsesRequest(prompt, images, toolDoc.Bytes())
return codexOpenAIImagePreparedRequest{
Body: body,
ResponseFormat: responseFormat,
@@ -920,7 +944,7 @@ func codexImageContentType(headers http.Header) string {
}
func codexOpenAIImageResponseFormatFromJSON(rawJSON []byte) string {
- return codexNormalizeImageResponseFormat(gjson.GetBytes(rawJSON, "response_format").String())
+ return codexNormalizeImageResponseFormat(ojson.GetBytesNoCopy(rawJSON, "response_format").String())
}
func codexNormalizeImageResponseFormat(responseFormat string) string {
@@ -942,39 +966,37 @@ func codexOpenAIImageToolModel(requestModel string, routeModel string) string {
}
func codexBuildOpenAIImageTool(rawJSON []byte, routeModel string, action string, stringFields []string, numberFields []string) []byte {
- tool := []byte(`{"type":"image_generation","action":""}`)
- tool, _ = sjson.SetBytes(tool, "action", action)
- tool, _ = sjson.SetBytes(tool, "model", codexOpenAIImageToolModel(gjson.GetBytes(rawJSON, "model").String(), routeModel))
+ toolDoc := ojson.NewDoc([]byte(`{"type":"image_generation","action":""}`))
+ _ = toolDoc.Set("action", action)
+ _ = toolDoc.Set("model", codexOpenAIImageToolModel(ojson.GetBytesNoCopy(rawJSON, "model").String(), routeModel))
for _, field := range stringFields {
- if value := strings.TrimSpace(gjson.GetBytes(rawJSON, field).String()); value != "" {
- tool, _ = sjson.SetBytes(tool, field, value)
+ if value := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, field).String()); value != "" {
+ _ = toolDoc.Set(field, value)
}
}
for _, field := range numberFields {
- if value := gjson.GetBytes(rawJSON, field); value.Exists() && value.Type == gjson.Number {
- tool, _ = sjson.SetBytes(tool, field, value.Int())
+ if value := ojson.GetBytesNoCopy(rawJSON, field); value.Exists() && value.Type == ojson.Number {
+ _ = toolDoc.Set(field, value.Int())
}
}
- return tool
+ return toolDoc.Bytes()
}
func codexBuildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte {
- req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"},"tools":[]}`)
- req, _ = sjson.SetBytes(req, "model", codexOpenAIImagesMainModel)
+ reqDoc := ojson.NewDoc([]byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"},"tools":[]}`))
+ _ = reqDoc.Set("model", codexOpenAIImagesMainModel)
if len(toolJSON) > 0 && json.Valid(toolJSON) {
- req, _ = sjson.SetRawBytes(req, "tools", helps.JoinRawJSONArray([][]byte{toolJSON}))
+ _ = reqDoc.SetRawBytes("tools", helps.JoinRawJSONArray([][]byte{toolJSON}))
}
- textPart := []byte(`{"type":"input_text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", prompt)
+ textPart, _ := ojson.SetBytes([]byte(`{"type":"input_text","text":""}`), "text", prompt)
contentItems := make([][]byte, 0, len(images)+1)
contentItems = append(contentItems, textPart)
for _, img := range images {
if strings.TrimSpace(img) == "" {
continue
}
- part := []byte(`{"type":"input_image","image_url":""}`)
- part, _ = sjson.SetBytes(part, "image_url", img)
+ part, _ := ojson.SetBytes([]byte(`{"type":"input_image","image_url":""}`), "image_url", img)
contentItems = append(contentItems, part)
}
inputSize := len(`[{"type":"message","role":"user","content":[]}]`) + len(contentItems)
@@ -990,8 +1012,8 @@ func codexBuildImagesResponsesRequest(prompt string, images []string, toolJSON [
input = append(input, item...)
}
input = append(input, ']', '}', ']')
- req, _ = sjson.SetRawBytes(req, "input", input)
- return req
+ _ = reqDoc.SetRawBytes("input", input)
+ return reqDoc.Bytes()
}
func codexFormValue(form *multipart.Form, key string) string {
@@ -1033,7 +1055,7 @@ func codexMultipartFileToDataURL(fileHeader *multipart.FileHeader) (string, erro
if mediaType == "" {
mediaType = http.DetectContentType(data)
}
- return "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
+ return base64.EncodeDataURL(mediaType, data), nil
}
// codexExtractImageResults extracts image generation results directly from the
@@ -1046,15 +1068,15 @@ func codexMultipartFileToDataURL(fileHeader *multipart.FileHeader) (string, erro
// extractor. Skipping the concatenate-and-reparse step avoids two large copies of
// the base64 payload, which matters for multi-megabyte generated images.
func codexExtractImageResults(completed []byte, itemsByIndex map[int64][]byte, fallback [][]byte) (results []codexImageCallResult, createdAt int64, usageRaw []byte, firstMeta codexImageCallResult, err error) {
- if gjson.GetBytes(completed, "type").String() != "response.completed" {
+ if ojson.GetBytesNoCopy(completed, "type").String() != "response.completed" {
return nil, 0, nil, codexImageCallResult{}, fmt.Errorf("unexpected event type")
}
- createdAt = gjson.GetBytes(completed, "response.created_at").Int()
+ createdAt = ojson.GetBytesNoCopy(completed, "response.created_at").Int()
if createdAt <= 0 {
createdAt = time.Now().Unix()
}
- appendItem := func(item gjson.Result) {
+ appendItem := func(item ojson.Result) {
if item.Get("type").String() != "image_generation_call" {
return
}
@@ -1063,12 +1085,12 @@ func codexExtractImageResults(completed []byte, itemsByIndex map[int64][]byte, f
return
}
entry := codexImageCallResult{
- Result: res,
- RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
- OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
- Size: strings.TrimSpace(item.Get("size").String()),
- Background: strings.TrimSpace(item.Get("background").String()),
- Quality: strings.TrimSpace(item.Get("quality").String()),
+ Result: strings.Clone(res),
+ RevisedPrompt: strings.Clone(strings.TrimSpace(item.Get("revised_prompt").String())),
+ OutputFormat: strings.Clone(strings.TrimSpace(item.Get("output_format").String())),
+ Size: strings.Clone(strings.TrimSpace(item.Get("size").String())),
+ Background: strings.Clone(strings.TrimSpace(item.Get("background").String())),
+ Quality: strings.Clone(strings.TrimSpace(item.Get("quality").String())),
}
if len(results) == 0 {
firstMeta = entry
@@ -1076,8 +1098,8 @@ func codexExtractImageResults(completed []byte, itemsByIndex map[int64][]byte, f
results = append(results, entry)
}
- var outputItems []gjson.Result
- if output := gjson.GetBytes(completed, "response.output"); output.Exists() && output.IsArray() {
+ var outputItems []ojson.Result
+ if output := ojson.GetBytesNoCopy(completed, "response.output"); output.Exists() && output.IsArray() {
outputItems = output.Array()
}
if len(outputItems) > 0 {
@@ -1097,88 +1119,88 @@ func codexExtractImageResults(completed []byte, itemsByIndex map[int64][]byte, f
}
sort.Slice(indexes, func(i, j int) bool { return indexes[i] < indexes[j] })
for _, idx := range indexes {
- appendItem(gjson.ParseBytes(itemsByIndex[idx]))
+ appendItem(util.ParseGJSONBytesNoCopy(itemsByIndex[idx]))
}
}
for _, raw := range fallback {
- appendItem(gjson.ParseBytes(raw))
+ appendItem(util.ParseGJSONBytesNoCopy(raw))
}
}
- if usage := gjson.GetBytes(completed, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
+ if usage := ojson.GetBytesNoCopy(completed, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
usageRaw = []byte(usage.Raw)
}
return results, createdAt, usageRaw, firstMeta, nil
}
func codexBuildImagesAPIResponse(results []codexImageCallResult, createdAt int64, usageRaw []byte, firstMeta codexImageCallResult, responseFormat string) ([]byte, error) {
- out := []byte(`{"created":0,"data":[]}`)
- out, _ = sjson.SetBytes(out, "created", createdAt)
+ outDoc := ojson.NewDoc([]byte(`{"created":0,"data":[]}`))
+ _ = outDoc.Set("created", createdAt)
if firstMeta.Background != "" {
- out, _ = sjson.SetBytes(out, "background", firstMeta.Background)
+ _ = outDoc.Set("background", firstMeta.Background)
}
if firstMeta.OutputFormat != "" {
- out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat)
+ _ = outDoc.Set("output_format", firstMeta.OutputFormat)
}
if firstMeta.Quality != "" {
- out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality)
+ _ = outDoc.Set("quality", firstMeta.Quality)
}
if firstMeta.Size != "" {
- out, _ = sjson.SetBytes(out, "size", firstMeta.Size)
+ _ = outDoc.Set("size", firstMeta.Size)
}
if len(usageRaw) > 0 && json.Valid(usageRaw) {
- out, _ = sjson.SetRawBytes(out, "usage", usageRaw)
+ _ = outDoc.SetRawBytes("usage", usageRaw)
}
responseFormat = codexNormalizeImageResponseFormat(responseFormat)
items := make([][]byte, 0, len(results))
for _, img := range results {
- item := []byte(`{}`)
+ itemDoc := ojson.NewDoc([]byte(`{}`))
if img.RevisedPrompt != "" {
- item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt)
+ _ = itemDoc.Set("revised_prompt", img.RevisedPrompt)
}
if responseFormat == "url" {
- item, _ = sjson.SetBytes(item, "url", "data:"+codexMimeTypeFromOutputFormat(img.OutputFormat)+";base64,"+img.Result)
+ _ = itemDoc.Set("url", "data:"+codexMimeTypeFromOutputFormat(img.OutputFormat)+";base64,"+img.Result)
} else {
- item, _ = sjson.SetBytes(item, "b64_json", img.Result)
+ _ = itemDoc.Set("b64_json", img.Result)
}
- items = append(items, item)
+ items = append(items, itemDoc.Bytes())
}
- out, _ = sjson.SetRawBytes(out, "data", helps.JoinRawJSONArray(items))
- return out, nil
+ _ = outDoc.SetRawBytes("data", helps.JoinRawJSONArray(items))
+ return outDoc.Bytes(), nil
}
func codexBuildImagePartialFrame(payload []byte, responseFormat string, streamPrefix string) []byte {
- b64 := strings.TrimSpace(gjson.GetBytes(payload, "partial_image_b64").String())
+ b64 := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "partial_image_b64").String())
if b64 == "" {
return nil
}
- outputFormat := strings.TrimSpace(gjson.GetBytes(payload, "output_format").String())
+ outputFormat := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "output_format").String())
eventName := strings.TrimSpace(streamPrefix) + ".partial_image"
- data := []byte(`{"type":"","partial_image_index":0}`)
- data, _ = sjson.SetBytes(data, "type", eventName)
- data, _ = sjson.SetBytes(data, "partial_image_index", gjson.GetBytes(payload, "partial_image_index").Int())
+ dataDoc := ojson.NewDoc([]byte(`{"type":"","partial_image_index":0}`))
+ _ = dataDoc.Set("type", eventName)
+ _ = dataDoc.Set("partial_image_index", ojson.GetBytesNoCopy(payload, "partial_image_index").Int())
if codexNormalizeImageResponseFormat(responseFormat) == "url" {
- data, _ = sjson.SetBytes(data, "url", "data:"+codexMimeTypeFromOutputFormat(outputFormat)+";base64,"+b64)
+ _ = dataDoc.Set("url", "data:"+codexMimeTypeFromOutputFormat(outputFormat)+";base64,"+b64)
} else {
- data, _ = sjson.SetBytes(data, "b64_json", b64)
+ _ = dataDoc.Set("b64_json", b64)
}
- return codexBuildSSEFrame(eventName, data)
+ return codexBuildSSEFrame(eventName, dataDoc.Bytes())
}
func codexBuildImageCompletedFrame(img codexImageCallResult, usageRaw []byte, responseFormat string, streamPrefix string) []byte {
eventName := strings.TrimSpace(streamPrefix) + ".completed"
- data := []byte(`{"type":""}`)
- data, _ = sjson.SetBytes(data, "type", eventName)
+ dataDoc := ojson.NewDoc([]byte(`{"type":""}`))
+ _ = dataDoc.Set("type", eventName)
if len(usageRaw) > 0 && json.Valid(usageRaw) {
- data, _ = sjson.SetRawBytes(data, "usage", usageRaw)
+ _ = dataDoc.SetRawBytes("usage", usageRaw)
}
if codexNormalizeImageResponseFormat(responseFormat) == "url" {
- data, _ = sjson.SetBytes(data, "url", "data:"+codexMimeTypeFromOutputFormat(img.OutputFormat)+";base64,"+img.Result)
+ _ = dataDoc.Set("url", "data:"+codexMimeTypeFromOutputFormat(img.OutputFormat)+";base64,"+img.Result)
} else {
- data, _ = sjson.SetBytes(data, "b64_json", img.Result)
+ _ = dataDoc.Set("b64_json", img.Result)
}
- return codexBuildSSEFrame(eventName, data)
+ return codexBuildSSEFrame(eventName, dataDoc.Bytes())
}
func codexBuildSSEFrame(eventName string, data []byte) []byte {
diff --git a/internal/runtime/executor/codex_openai_images_test.go b/internal/runtime/executor/codex_openai_images_test.go
index ae50a90caee..46ef4b667bc 100644
--- a/internal/runtime/executor/codex_openai_images_test.go
+++ b/internal/runtime/executor/codex_openai_images_test.go
@@ -12,12 +12,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func newCodexOpenAIImageTestAuth(serverURL string) *cliproxyauth.Auth {
@@ -51,7 +51,7 @@ func TestCodexImagePayloadPreservesResolvedAlias(t *testing.T) {
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(body, "model").String(); got != "openai/gpt-image-2-cpr" {
+ if got := ojson.GetBytes(body, "model").String(); got != "openai/gpt-image-2-cpr" {
t.Fatalf("upstream model = %q, want %q", got, "openai/gpt-image-2-cpr")
}
})
@@ -178,26 +178,26 @@ func TestCodexImageResolvedModelOnWire(t *testing.T) {
if gotContentType != "application/json" || !json.Valid(gotBody) {
t.Fatalf("invalid upstream JSON: Content-Type=%q body=%s", gotContentType, gotBody)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != tc.want {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != tc.want {
t.Errorf("upstream model = %q, want %q; body=%s", got, tc.want, gotBody)
}
- if tc.payloadRule == "override" && gjson.GetBytes(gotBody, "audit").Raw != `["once"]` {
+ if tc.payloadRule == "override" && ojson.GetBytes(gotBody, "audit").Raw != `["once"]` {
t.Errorf("payload override must run exactly once: %s", gotBody)
}
- if tc.payloadRule == "filter" && gjson.GetBytes(gotBody, "model").Exists() {
+ if tc.payloadRule == "filter" && ojson.GetBytes(gotBody, "model").Exists() {
t.Errorf("filtered model must not be restored: %s", gotBody)
}
- if got := gjson.GetBytes(gotBody, "prompt").String(); got != "diagnostic" {
+ if got := ojson.GetBytes(gotBody, "prompt").String(); got != "diagnostic" {
t.Errorf("prompt = %q, want diagnostic", got)
}
- if flag := gjson.GetBytes(gotBody, "stream"); flag.Exists() != stream || flag.Bool() != stream {
+ if flag := ojson.GetBytes(gotBody, "stream"); flag.Exists() != stream || flag.Bool() != stream {
t.Errorf("unexpected stream flag: %s", gotBody)
}
if format == "edit multipart" {
- if got := gjson.GetBytes(gotBody, "images.0.image_url").String(); got != "data:application/octet-stream;base64,cG5nLWRhdGE=" {
+ if got := ojson.GetBytes(gotBody, "images.0.image_url").String(); got != "data:application/octet-stream;base64,cG5nLWRhdGE=" {
t.Errorf("image URL = %q, want original file data URL", got)
}
- } else if format == "edit JSON" && gjson.GetBytes(gotBody, "images.0.file_id").String() != "source-image" {
+ } else if format == "edit JSON" && ojson.GetBytes(gotBody, "images.0.file_id").String() != "source-image" {
t.Errorf("original image missing: %s", gotBody)
}
})
@@ -276,16 +276,16 @@ func TestCodexExecutorDirectOpenAIImageGenerationUsesImagesEndpoint(t *testing.T
if gotOriginator != codexOriginator {
t.Fatalf("Originator = %q, want %q", gotOriginator, codexOriginator)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" {
t.Fatalf("model = %q, want gpt-image-1.5; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "extra.preserve").Bool(); !got {
+ if got := ojson.GetBytes(gotBody, "extra.preserve").Bool(); !got {
t.Fatalf("extra.preserve missing from body: %s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "output_compression").Int(); got != 70 {
+ if got := ojson.GetBytes(gotBody, "output_compression").Int(); got != 70 {
t.Fatalf("output_compression = %d, want 70; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "stream").Exists() {
+ if ojson.GetBytes(gotBody, "stream").Exists() {
t.Fatalf("stream should be removed for non-stream execution: %s", string(gotBody))
}
if !bytes.Equal(resp.Payload, upstreamBody) {
@@ -334,10 +334,10 @@ func TestCodexExecutorDirectOpenAIImageGenerationStreamsImagesEndpoint(t *testin
if gotAccept != "text/event-stream" {
t.Fatalf("Accept = %q, want text/event-stream", gotAccept)
}
- if !gjson.GetBytes(gotBody, "stream").Bool() {
+ if !ojson.GetBytes(gotBody, "stream").Bool() {
t.Fatalf("stream flag missing from upstream body: %s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "partial_images").Int(); got != 2 {
+ if got := ojson.GetBytes(gotBody, "partial_images").Int(); got != 2 {
t.Fatalf("partial_images = %d, want 2; body=%s", got, string(gotBody))
}
out := combined.String()
@@ -373,16 +373,16 @@ func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForJSON(t *test
if gotPath != "/images/edits" {
t.Fatalf("path = %q, want /images/edits", gotPath)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-2" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "gpt-image-2" {
t.Fatalf("model = %q, want gpt-image-2; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "images.0.file_id").String(); got != "file-abc123" {
+ if got := ojson.GetBytes(gotBody, "images.0.file_id").String(); got != "file-abc123" {
t.Fatalf("images.0.file_id = %q, want file-abc123; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "mask.file_id").String(); got != "file-mask123" {
+ if got := ojson.GetBytes(gotBody, "mask.file_id").String(); got != "file-mask123" {
t.Fatalf("mask.file_id = %q, want file-mask123; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "stream").Exists() {
+ if ojson.GetBytes(gotBody, "stream").Exists() {
t.Fatalf("stream should be removed for non-stream execution: %s", string(gotBody))
}
}
@@ -459,41 +459,49 @@ func TestCodexExecutorDirectOpenAIImageEditUsesImagesEditEndpointForMultipart(t
if !json.Valid(gotBody) {
t.Fatalf("body is not valid JSON: %s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "gpt-image-1.5" {
t.Fatalf("model = %q, want gpt-image-1.5; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "prompt").String(); got != "Create a lovely gift basket" {
+ if got := ojson.GetBytes(gotBody, "prompt").String(); got != "Create a lovely gift basket" {
t.Fatalf("prompt = %q", got)
}
- if got := gjson.GetBytes(gotBody, "output_format").String(); got != "webp" {
+ if got := ojson.GetBytes(gotBody, "output_format").String(); got != "webp" {
t.Fatalf("output_format = %q, want webp; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "n").Int(); got != 2 {
+ if got := ojson.GetBytes(gotBody, "n").Int(); got != 2 {
t.Fatalf("n = %d, want 2; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "stream").Exists() {
+ if ojson.GetBytes(gotBody, "stream").Exists() {
t.Fatalf("stream should be removed for non-stream execution: %s", string(gotBody))
}
- imageURL := gjson.GetBytes(gotBody, "images.0.image_url").String()
+ imageURL := ojson.GetBytes(gotBody, "images.0.image_url").String()
if !strings.Contains(imageURL, ";base64,cG5nLWRhdGE=") {
t.Fatalf("images.0.image_url = %q, want png-data data URL; body=%s", imageURL, string(gotBody))
}
- maskURL := gjson.GetBytes(gotBody, "mask.image_url").String()
+ maskURL := ojson.GetBytes(gotBody, "mask.image_url").String()
if !strings.Contains(maskURL, ";base64,bWFzay1kYXRh") {
t.Fatalf("mask.image_url = %q, want mask-data data URL; body=%s", maskURL, string(gotBody))
}
}
-func TestCodexExecutorDirectOpenAIImage25Models(t *testing.T) {
+func TestCodexExecutorDirectOpenAIImage25And26Models(t *testing.T) {
testModels := []string{
"gpt-image-2.5",
"gpt-image-2.5-flare",
"gpt-image-2.5-sunburst",
+ "gpt-image-2.6",
+ "gpt-image-2.6-flare",
+ "gpt-image-2.6-sunburst",
"codex/gpt-image-2.5",
"codex/gpt-image-2.5-flare",
"codex/gpt-image-2.5-sunburst",
+ "codex/gpt-image-2.6",
+ "codex/gpt-image-2.6-flare",
+ "codex/gpt-image-2.6-sunburst",
"GPT-Image-2.5(medium)",
"codex/GPT-Image-2.5-Flare(high)",
+ "GPT-Image-2.6(medium)",
+ "codex/GPT-Image-2.6-Flare(high)",
}
for _, model := range testModels {
@@ -529,7 +537,7 @@ func TestCodexExecutorDirectOpenAIImage25Models(t *testing.T) {
if gotPath != "/images/generations" {
t.Fatalf("path = %q, want /images/generations", gotPath)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != baseModel {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != baseModel {
t.Fatalf("model = %q, want %s; body=%s", got, baseModel, string(gotBody))
}
})
@@ -562,7 +570,7 @@ func TestCodexExecutorDirectOpenAIImage25Models(t *testing.T) {
if gotPath != "/images/edits" {
t.Fatalf("path = %q, want /images/edits", gotPath)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != baseModel {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != baseModel {
t.Fatalf("model = %q, want %s; body=%s", got, baseModel, string(gotBody))
}
})
@@ -600,7 +608,7 @@ func TestCodexExecutorDirectOpenAIImage25Models(t *testing.T) {
if gotPath != "/images/generations" {
t.Fatalf("path = %q, want /images/generations", gotPath)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != baseModel {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != baseModel {
t.Fatalf("model = %q, want %s; body=%s", got, baseModel, string(gotBody))
}
})
diff --git a/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go b/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go
index 7a3742c165f..965f5b0ad6f 100644
--- a/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go
+++ b/internal/runtime/executor/codex_stream_bootstrap_buffering_test.go
@@ -11,13 +11,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -922,7 +922,7 @@ func TestIsCodexBootstrapBufferableEvent(t *testing.T) {
` `,
}
for _, payload := range hold {
- eventType := gjson.GetBytes([]byte(payload), "type").String()
+ eventType := ojson.GetBytes([]byte(payload), "type").String()
if !isCodexBootstrapBufferableEvent(eventType, []byte(payload)) {
t.Errorf("must stay bufferable: %s", payload)
}
@@ -958,7 +958,7 @@ func TestIsCodexBootstrapBufferableEvent(t *testing.T) {
`{"type":"response.some_future_event_we_have_never_seen"}`,
}
for _, payload := range release {
- eventType := gjson.GetBytes([]byte(payload), "type").String()
+ eventType := ojson.GetBytes([]byte(payload), "type").String()
if isCodexBootstrapBufferableEvent(eventType, []byte(payload)) {
t.Errorf("must release the stream: %s", payload)
}
diff --git a/internal/runtime/executor/codex_websocket_home_loss_test.go b/internal/runtime/executor/codex_websocket_home_loss_test.go
index ac6384da4d9..dd097dab1eb 100644
--- a/internal/runtime/executor/codex_websocket_home_loss_test.go
+++ b/internal/runtime/executor/codex_websocket_home_loss_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -20,7 +21,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// completedWriteBarrierListener holds the first downstream frame carrying response.completed
@@ -124,7 +124,7 @@ func newHomeLossFixture(t *testing.T) *homeLossFixture {
if dial == 1 {
return // EOF right after response.completed
}
- if gjson.GetBytes(body, "previous_response_id").Exists() {
+ if ojson.GetBytes(body, "previous_response_id").Exists() {
t.Errorf("continuation reached upstream: %s", body)
}
_, _, _ = conn.ReadMessage()
@@ -198,7 +198,7 @@ func (f *homeLossFixture) read(t *testing.T, kind string) []byte {
if errRead != nil {
t.Fatalf("downstream closed waiting for %s: %v", kind, errRead)
}
- if gjson.GetBytes(raw, "type").String() != kind {
+ if ojson.GetBytes(raw, "type").String() != kind {
t.Fatalf("got %s, want %s", raw, kind)
}
return raw
@@ -244,7 +244,7 @@ func TestCodexHomeUpstreamLossDoesNotReplay(t *testing.T) {
f.send(t, `{"type":"response.create","model":"model-a","previous_response_id":"old","input":[{"role":"user","content":"follow up"}]}`)
errFrame := f.read(t, "error")
- if got := gjson.GetBytes(errFrame, "error.code").String(); got != "previous_response_not_found" {
+ if got := ojson.GetBytes(errFrame, "error.code").String(); got != "previous_response_not_found" {
t.Fatalf("continuation error = %s, want previous_response_not_found", errFrame)
}
if f.dispatcher.calls.Load() != 1 || f.creates.Load() != 1 || f.dials.Load() != 1 {
@@ -275,7 +275,7 @@ func TestCodexHomeUpstreamLossDoesNotReplay(t *testing.T) {
f.send(t, `{"type":"response.create","model":"model-a","input":[{"role":"user","content":"fresh full context"}]}`)
f.read(t, "response.created")
completed := f.read(t, "response.completed")
- if got := gjson.GetBytes(completed, "response.id").String(); got != "new" {
+ if got := ojson.GetBytes(completed, "response.id").String(); got != "new" {
t.Fatalf("reconnected response id = %q, want new (%s)", got, completed)
}
if got, _ := f.dispatcher.pinnedAuthID.Load().(string); got != "accounted-websocket-auth" {
diff --git a/internal/runtime/executor/codex_websockets_connection.go b/internal/runtime/executor/codex_websockets_connection.go
index be7e7805d7e..05d22e9b051 100644
--- a/internal/runtime/executor/codex_websockets_connection.go
+++ b/internal/runtime/executor/codex_websockets_connection.go
@@ -13,6 +13,7 @@ import (
"syscall"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -21,8 +22,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"golang.org/x/net/proxy"
)
@@ -164,7 +163,7 @@ func codexWebsocketTurnEventID(err error, payload []byte) error {
return err
}
withID := *turnErr
- withID.EventID = gjson.GetBytes(payload, "event_id").String()
+ withID.EventID = strings.Clone(ojson.GetBytesNoCopy(payload, "event_id").String())
return &withID
}
@@ -176,6 +175,13 @@ func normalizeCodexWebsocketParallelToolCalls(body []byte, headers http.Header)
return body
}
+func normalizeCodexWebsocketParallelToolCallsDoc(doc *ojson.Doc, headers http.Header) bool {
+ if !isCodexResponsesLiteRequestDoc(doc, headers) {
+ return false
+ }
+ return helps.SetBoolIfDifferentDoc(doc, "parallel_tool_calls", false)
+}
+
func buildCodexWebsocketRequestBody(body []byte) []byte {
return frameCodexWebsocketRequestBody(helps.SanitizeCodexInputItemIDs(body))
}
@@ -185,7 +191,7 @@ func frameCodexWebsocketRequestBody(body []byte) []byte {
if len(body) == 0 {
return nil
}
- wsReqBody, errSet := sjson.SetBytes(body, "type", "response.create")
+ wsReqBody, errSet := ojson.SetBytes(body, "type", "response.create")
if errSet == nil && len(wsReqBody) > 0 {
return wsReqBody
}
diff --git a/internal/runtime/executor/codex_websockets_duplex.go b/internal/runtime/executor/codex_websockets_duplex.go
index 6c70a8bc8ee..41aa876a158 100644
--- a/internal/runtime/executor/codex_websockets_duplex.go
+++ b/internal/runtime/executor/codex_websockets_duplex.go
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
@@ -18,8 +19,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// codexDuplexConnectionError keeps a connection failure from cooling the shared
@@ -153,7 +152,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
initialModel := ""
if initial != nil {
- initialModel = strings.TrimSpace(gjson.GetBytes(initial.originalPayload, "model").String())
+ initialModel = strings.TrimSpace(ojson.GetBytesNoCopy(initial.originalPayload, "model").String())
}
boundModel := helps.PayloadRequestedModel(opts, initialModel)
if boundModel == "" {
@@ -161,9 +160,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
authorizePayload := func(payload []byte, forceBoundModel bool) (allowed bool, keepOpen bool) {
authPayload := payload
- explicitModel := strings.TrimSpace(gjson.GetBytes(authPayload, "model").String())
+ explicitModel := strings.TrimSpace(ojson.GetBytesNoCopy(authPayload, "model").String())
if boundModel != "" && json.Valid(authPayload) && (forceBoundModel || explicitModel == "" || explicitModel == req.Model) {
- if withModel, errSet := sjson.SetBytes(bytes.Clone(authPayload), "model", boundModel); errSet == nil {
+ if withModel, errSet := ojson.SetBytes(bytes.Clone(authPayload), "model", boundModel); errSet == nil {
authPayload = withModel
}
}
@@ -234,7 +233,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
msg := "request terminated by plugin"
if len(resp.ResponseBody) > 0 {
- if parsed := strings.TrimSpace(gjson.GetBytes(resp.ResponseBody, "error.message").String()); parsed != "" {
+ if parsed := strings.TrimSpace(ojson.GetBytesNoCopy(resp.ResponseBody, "error.message").String()); parsed != "" {
msg = parsed
} else if trimmed := strings.TrimSpace(string(resp.ResponseBody)); trimmed != "" {
msg = trimmed
@@ -300,11 +299,11 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
return keepOpen
}
metadataMu.Lock()
- isAppend := gjson.GetBytes(payload, "type").String() == "response.append"
- prevID := strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String())
+ isAppend := ojson.GetBytesNoCopy(payload, "type").String() == "response.append"
+ prevID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "previous_response_id").String())
if isAppend && prevID == "" && responseID != "" {
prevID = responseID
- payload, _ = sjson.SetBytes(payload, "previous_response_id", prevID)
+ payload, _ = ojson.SetBytes(payload, "previous_response_id", prevID)
}
wrongParent := false
for _, accepted := range acceptedSteers {
@@ -319,7 +318,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
return true
}
- model := strings.TrimSpace(gjson.GetBytes(payload, "model").String())
+ model := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "model").String())
if model != "" && model != req.Model && model != boundModel {
fail(cliproxyexecutor.NewUpstreamWebsocketReplayRequiredError())
return false
@@ -336,8 +335,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
model = boundModel
}
}
- payload, _ = sjson.SetBytes(payload, "model", model)
- if isAppend && !gjson.GetBytes(payload, "instructions").Exists() {
+ createDoc := ojson.NewDoc(payload)
+ _ = createDoc.Set("model", model)
+ if isAppend && !createDoc.Get("instructions").Exists() {
metadataMu.Lock()
parentSettings := responseSettings[prevID]
metadataMu.Unlock()
@@ -345,23 +345,24 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
if targetSettings == nil {
targetSettings = initial
}
- var inst gjson.Result
+ var inst ojson.Result
if targetSettings != nil {
- inst = gjson.GetBytes(targetSettings.clientBody, "instructions")
+ inst = ojson.GetBytesNoCopy(targetSettings.clientBody, "instructions")
if !inst.Exists() {
- inst = gjson.GetBytes(targetSettings.originalPayload, "instructions")
+ inst = ojson.GetBytesNoCopy(targetSettings.originalPayload, "instructions")
}
}
if !inst.Exists() && initial != nil {
- inst = gjson.GetBytes(initial.clientBody, "instructions")
+ inst = ojson.GetBytesNoCopy(initial.clientBody, "instructions")
if !inst.Exists() {
- inst = gjson.GetBytes(initial.originalPayload, "instructions")
+ inst = ojson.GetBytesNoCopy(initial.originalPayload, "instructions")
}
}
if inst.Exists() {
- payload, _ = sjson.SetRawBytes(payload, "instructions", []byte(inst.Raw))
+ _ = createDoc.SetRawBytes("instructions", []byte(inst.Raw))
}
}
+ payload = createDoc.Bytes()
nextReq, nextOpts := req, opts
nextReq.Payload = payload
nextOpts.OriginalRequest = payload
@@ -399,7 +400,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
fail(mapCodexWebsocketWriteError(sess, conn, errWrite))
return false
}
- log.Infof("codex websockets: request forwarded session=%s auth=%s url=%s event=%s", sess.sessionID, auth.ID, initial.wsURL, gjson.GetBytes(payload, "type").String())
+ log.Infof("codex websockets: request forwarded session=%s auth=%s url=%s event=%s", sess.sessionID, auth.ID, initial.wsURL, ojson.GetBytesNoCopy(payload, "type").String())
return true
}
flushPendingCreates := func() bool {
@@ -453,7 +454,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
continue
}
- switch gjson.GetBytes(payload, "type").String() {
+ switch ojson.GetBytesNoCopy(payload, "type").String() {
case "response.steer":
if allowed, keepOpen := authorizePayload(payload, true); !allowed {
if !keepOpen {
@@ -480,13 +481,16 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
fail(statusErr{code: http.StatusBadRequest, msg: errExpand.Error()})
return
}
- payload = steerReq.Payload
+ steerDoc := ojson.NewDoc(steerReq.Payload)
if codexUseSIWCForRequest(ctx, auth, steerHeaders) {
- payload = thinking.StripConfigurationUpdates(payload)
+ thinking.StripConfigurationUpdatesDoc(steerDoc)
}
- payload = helps.NewPayloadFinalizer(e.cfg, "codex-websockets", thinking.ParseSuffix(req.Model).ModelName, "codex", "", payload, steerReq, steerOpts)(payload)
- payload, _ = sjson.SetBytes(payload, "type", "response.steer")
- parent := gjson.GetBytes(payload, "previous_response_id").String()
+ steerCtx := helps.WithPayloadFinalizer(streamCtx, helps.NewPayloadFinalizer(e.cfg, "codex-websockets", thinking.ParseSuffix(req.Model).ModelName, "codex", "", steerReq.Payload, steerReq, steerOpts))
+ helps.FinalizePayloadDoc(steerCtx, steerDoc)
+ _ = steerDoc.Set("type", "response.steer")
+ parent := strings.Clone(steerDoc.Get("previous_response_id").String())
+ steerEventID := strings.Clone(steerDoc.Get("event_id").String())
+ payload = steerDoc.Bytes()
metadataMu.Lock()
settings := responseSettings[parent]
metadataMu.Unlock()
@@ -499,7 +503,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
metadataMu.Unlock()
}
metadataMu.Lock()
- unacknowledgedSteers = append(unacknowledgedSteers, codexSteerSubmission{parent: parent, eventID: gjson.GetBytes(payload, "event_id").String()})
+ unacknowledgedSteers = append(unacknowledgedSteers, codexSteerSubmission{parent: parent, eventID: steerEventID})
consumedPayload = nil
if settings != nil {
steeringSettings[parent] = settings
@@ -538,7 +542,7 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
continue
default:
- if !reject(fmt.Sprintf("unsupported websocket request type: %s", gjson.GetBytes(payload, "type").String())) {
+ if !reject(fmt.Sprintf("unsupported websocket request type: %s", ojson.GetBytesNoCopy(payload, "type").String())) {
return
}
continue
@@ -591,10 +595,10 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
idle := !firstResponse && !responseActive && len(pending) == 0 && len(unacknowledgedSteers) == 0 && len(acceptedSteers) == 0 && len(pendingCreates) == 0 && len(consumedPayload) == 0
turnErr := &cliproxyexecutor.UpstreamWebsocketTurnError{Cause: errRead, Idle: idle}
if firstResponse || responseActive {
- turnErr.EventID = gjson.GetBytes(current.originalPayload, "event_id").String()
+ turnErr.EventID = strings.Clone(ojson.GetBytesNoCopy(current.originalPayload, "event_id").String())
}
abandon := func(payload []byte, notSent bool) {
- if eventID := gjson.GetBytes(payload, "event_id").String(); eventID != "" {
+ if eventID := strings.Clone(ojson.GetBytesNoCopy(payload, "event_id").String()); eventID != "" {
turnErr.Abandoned = append(turnErr.Abandoned, &cliproxyexecutor.UpstreamWebsocketTurnError{EventID: eventID, NotSent: notSent})
}
}
@@ -648,11 +652,11 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
continue
}
payload = bytes.TrimSpace(payload)
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
establishing := firstResponse && eventType == "response.created"
if eventType == "response.created" {
metadataMu.Lock()
- parent := gjson.GetBytes(payload, "response.previous_response_id").String()
+ parent := strings.Clone(ojson.GetBytesNoCopy(payload, "response.previous_response_id").String())
if parent == "" {
parent = responseID
}
@@ -670,9 +674,10 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
current = settings
}
+ createdResponseID := strings.Clone(ojson.GetBytesNoCopy(payload, "response.id").String())
for id, accepted := range acceptedSteers {
if accepted.parent == parent && accepted.successorID == "" {
- accepted.successorID = gjson.GetBytes(payload, "response.id").String()
+ accepted.successorID = createdResponseID
acceptedSteers[id] = accepted
}
}
@@ -681,21 +686,22 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
if len(pending) > 0 {
current, pending = pending[0], pending[1:]
}
- responseID = gjson.GetBytes(payload, "response.id").String()
+ responseID = createdResponseID
// Retain response settings, not request history or authorization headers.
// In-flight steering pins its parent's settings independently of this window.
snapshot := *current
snapshot.originalPayload, snapshot.wsHeaders = nil, nil
- snapshot.clientBody = []byte("{}")
- if reasoning := gjson.GetBytes(current.clientBody, "reasoning"); reasoning.Exists() {
- snapshot.clientBody, _ = sjson.SetRawBytes(snapshot.clientBody, "reasoning", []byte(reasoning.Raw))
+ snapshotDoc := ojson.NewDoc([]byte("{}"))
+ if reasoning := ojson.GetBytesNoCopy(current.clientBody, "reasoning"); reasoning.Exists() {
+ _ = snapshotDoc.SetRawBytes("reasoning", []byte(reasoning.Raw))
}
- if inst := gjson.GetBytes(current.clientBody, "instructions"); inst.Exists() {
- snapshot.clientBody, _ = sjson.SetRawBytes(snapshot.clientBody, "instructions", []byte(inst.Raw))
+ if inst := ojson.GetBytesNoCopy(current.clientBody, "instructions"); inst.Exists() {
+ _ = snapshotDoc.SetRawBytes("instructions", []byte(inst.Raw))
}
- if serviceTier := gjson.GetBytes(current.clientBody, "service_tier"); serviceTier.Exists() {
- snapshot.clientBody, _ = sjson.SetRawBytes(snapshot.clientBody, "service_tier", []byte(serviceTier.Raw))
+ if serviceTier := ojson.GetBytesNoCopy(current.clientBody, "service_tier"); serviceTier.Exists() {
+ _ = snapshotDoc.SetRawBytes("service_tier", []byte(serviceTier.Raw))
}
+ snapshot.clientBody = snapshotDoc.Bytes()
responseSettings[responseID] = &snapshot
responseOrder = append(responseOrder, responseID)
if len(responseOrder) > 16 {
@@ -721,8 +727,8 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
// Steering acknowledgements, pending notifications and failures are opaque:
// preserve their IDs, input, sequence numbers and event types byte-for-byte.
if strings.HasPrefix(eventType, "response.steer.") {
- id := gjson.GetBytes(payload, "steer.id").String()
- parent := gjson.GetBytes(payload, "steer.previous_response_id").String()
+ id := strings.Clone(ojson.GetBytesNoCopy(payload, "steer.id").String())
+ parent := strings.Clone(ojson.GetBytesNoCopy(payload, "steer.previous_response_id").String())
if parent == "" {
parent = responseID
}
@@ -779,9 +785,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
}
eventPrepared, eventReporter := current, reporter
if !firstResponse && (eventType == "response.failed" || eventType == "error") {
- failedID := gjson.GetBytes(payload, "response.id").String()
+ failedID := ojson.GetBytesNoCopy(payload, "response.id").String()
if failedID == "" {
- failedID = gjson.GetBytes(payload, "response_id").String()
+ failedID = ojson.GetBytesNoCopy(payload, "response_id").String()
}
metadataMu.Lock()
// A failure for the running response must not consume a queued create.
@@ -844,9 +850,9 @@ func (e *CodexWebsocketsExecutor) streamCodexDuplex(
collectCodexOutputItemDone(payload, outputItems, &outputFallback)
}
if isCodexTerminalEventType(eventType) {
- endedID := gjson.GetBytes(payload, "response.id").String()
+ endedID := ojson.GetBytesNoCopy(payload, "response.id").String()
if endedID == "" {
- endedID = gjson.GetBytes(payload, "response_id").String()
+ endedID = ojson.GetBytesNoCopy(payload, "response_id").String()
}
if endedID == "" && !responseActive {
// An id-less failure that ended the running response.
diff --git a/internal/runtime/executor/codex_websockets_duplex_bootstrap_input_test.go b/internal/runtime/executor/codex_websockets_duplex_bootstrap_input_test.go
index 8633d32498f..c4872eccfb5 100644
--- a/internal/runtime/executor/codex_websockets_duplex_bootstrap_input_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_bootstrap_input_test.go
@@ -9,13 +9,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
auth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
translator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexDuplexBootstrapPreservesFollowup(t *testing.T) {
@@ -66,7 +66,7 @@ func TestCodexDuplexBootstrapPreservesFollowup(t *testing.T) {
return
}
healthyFollowups.Add(1)
- if gjson.GetBytes(payload, "type").String() != kind || gjson.GetBytes(payload, "input.0.content.0.text").String() != "PRESERVE_ME" {
+ if ojson.GetBytes(payload, "type").String() != kind || ojson.GetBytes(payload, "input.0.content.0.text").String() != "PRESERVE_ME" {
t.Errorf("follow-up changed: %s", payload)
}
if err = c.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.completed","response":{"id":"healthy","output":[]}}`)); err != nil {
@@ -112,7 +112,7 @@ func TestCodexDuplexBootstrapPreservesFollowup(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
completed = true
cancel()
}
diff --git a/internal/runtime/executor/codex_websockets_duplex_credential_failure_test.go b/internal/runtime/executor/codex_websockets_duplex_credential_failure_test.go
index 534ee006e7e..c3ba0905f30 100644
--- a/internal/runtime/executor/codex_websockets_duplex_credential_failure_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_credential_failure_test.go
@@ -10,13 +10,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
auth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
translator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexDuplexLaterCredentialFailure(t *testing.T) {
@@ -118,7 +118,7 @@ func TestCodexDuplexLaterCredentialFailure(t *testing.T) {
}
continue
}
- event := gjson.GetBytes(chunk.Payload, "type").String()
+ event := ojson.GetBytes(chunk.Payload, "type").String()
if event == "response.created" && queued {
input <- core.WebsocketInput{Payload: []byte(`{"type":"response.create","input":[]}`)}
}
diff --git a/internal/runtime/executor/codex_websockets_duplex_health_test.go b/internal/runtime/executor/codex_websockets_duplex_health_test.go
index 5bd3ab0caa9..fdd09734574 100644
--- a/internal/runtime/executor/codex_websockets_duplex_health_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_health_test.go
@@ -10,13 +10,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
auth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
translator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexDuplexConnectionTimeoutDoesNotCoolHealthyAccount(t *testing.T) {
@@ -63,7 +63,7 @@ func TestCodexDuplexConnectionTimeoutDoesNotCoolHealthyAccount(t *testing.T) {
}
sawCompleted, sawTimeout := false, false
for chunk := range stream.Chunks {
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
sawCompleted = true
// Inject a real net.Error through the input seam after a successful response.
// No wall-clock sleep or production network deadline is needed.
diff --git a/internal/runtime/executor/codex_websockets_duplex_initial_failure_test.go b/internal/runtime/executor/codex_websockets_duplex_initial_failure_test.go
index aaa850e5c4d..4323435c150 100644
--- a/internal/runtime/executor/codex_websockets_duplex_initial_failure_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_initial_failure_test.go
@@ -10,13 +10,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
auth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
translator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexDuplexInitialFailure(t *testing.T) {
@@ -147,10 +147,10 @@ func TestCodexDuplexInitialFailure(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
- completed = gjson.GetBytes(chunk.Payload, "response.id").String() == "healthy-response"
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ completed = ojson.GetBytes(chunk.Payload, "response.id").String() == "healthy-response"
cancel()
- } else if gjson.GetBytes(chunk.Payload, "type").String() != "response.created" {
+ } else if ojson.GetBytes(chunk.Payload, "type").String() != "response.created" {
t.Fatalf("rejected account payload escaped bootstrap: %s", chunk.Payload)
}
}
diff --git a/internal/runtime/executor/codex_websockets_duplex_rejection_test.go b/internal/runtime/executor/codex_websockets_duplex_rejection_test.go
index 1408f700b08..62c4fcd2541 100644
--- a/internal/runtime/executor/codex_websockets_duplex_rejection_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_rejection_test.go
@@ -11,13 +11,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
auth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
translator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// The server waits for all explicit creates before rejecting one. This makes
@@ -45,7 +45,7 @@ func TestCodexDuplexRejectedCreateMetadata(t *testing.T) {
t.Error(e)
return ""
}
- return gjson.GetBytes(p, "prompt_cache_key").String()
+ return ojson.GetBytes(p, "prompt_cache_key").String()
}
write := func(kind, id, key string) {
p := fmt.Sprintf(`{"type":%q,"response":{"id":%q,"prompt_cache_key":%q,"output":[],"error":{"type":"invalid_request_error","message":"rejected"}}}`, kind, id, key)
@@ -99,8 +99,8 @@ func TestCodexDuplexRejectedCreateMetadata(t *testing.T) {
terminated = true
continue
}
- kind := gjson.GetBytes(chunk.Payload, "type").String()
- id := gjson.GetBytes(chunk.Payload, "response.id").String()
+ kind := ojson.GetBytes(chunk.Payload, "type").String()
+ id := ojson.GetBytes(chunk.Payload, "response.id").String()
if !sent && id == "first" && ((activeFailure && kind == "response.created") || (!activeFailure && kind == "response.completed")) {
sent = true
if !activeFailure {
@@ -117,13 +117,13 @@ func TestCodexDuplexRejectedCreateMetadata(t *testing.T) {
if activeFailure {
want = "first-key"
}
- if got := gjson.GetBytes(chunk.Payload, "response.prompt_cache_key").String(); got != want {
+ if got := ojson.GetBytes(chunk.Payload, "response.prompt_cache_key").String(); got != want {
t.Errorf("failure metadata = %q, want %q", got, want)
}
}
if kind == "response.completed" && id == "good" {
completed = true
- if got := gjson.GetBytes(chunk.Payload, "response.prompt_cache_key").String(); got != "good-key" {
+ if got := ojson.GetBytes(chunk.Payload, "response.prompt_cache_key").String(); got != "good-key" {
t.Errorf("success consumed another create's metadata: %q", got)
}
cancel()
@@ -224,8 +224,8 @@ func TestCodexDuplexLaterInvalidSignatureClearsReplay(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- event := gjson.GetBytes(chunk.Payload, "type").String()
- id := gjson.GetBytes(chunk.Payload, "response.id").String()
+ event := ojson.GetBytes(chunk.Payload, "type").String()
+ id := ojson.GetBytes(chunk.Payload, "response.id").String()
if event == "response.completed" && id == "first" {
input <- core.WebsocketInput{Payload: rejected}
}
diff --git a/internal/runtime/executor/codex_websockets_duplex_successor_metadata_test.go b/internal/runtime/executor/codex_websockets_duplex_successor_metadata_test.go
index e971552cae3..2c7aaeeed1f 100644
--- a/internal/runtime/executor/codex_websockets_duplex_successor_metadata_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_successor_metadata_test.go
@@ -8,12 +8,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
auth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
translator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// Explicit creates can arrive before pending notifications. They must not
@@ -53,11 +53,11 @@ func TestCodexDuplexAutomaticSuccessorMetadata(t *testing.T) {
write(fmt.Sprintf(`{"type":%q,"response":{"id":%q,"previous_response_id":%q,"prompt_cache_key":%q,"output":[],"incomplete_details":{"reason":"steered"}}}`, kind, id, parent, key))
}
first := read()
- firstKey := gjson.GetBytes(first, "prompt_cache_key").String()
+ firstKey := ojson.GetBytes(first, "prompt_cache_key").String()
response("response.created", "first", firstKey)
if scenario == "create_before_steer" {
middle := read()
- if gjson.GetBytes(middle, "type").String() != "response.create" {
+ if ojson.GetBytes(middle, "type").String() != "response.create" {
t.Errorf("expected middle create: %s", middle)
return
}
@@ -67,18 +67,18 @@ func TestCodexDuplexAutomaticSuccessorMetadata(t *testing.T) {
t.Error("steering was not queued")
return
}
- middleKey := gjson.GetBytes(middle, "prompt_cache_key").String()
+ middleKey := ojson.GetBytes(middle, "prompt_cache_key").String()
response("response.completed", "first", firstKey)
response("response.created", "middle", middleKey)
response("response.completed", "middle", middleKey)
}
- if p := read(); gjson.GetBytes(p, "type").String() != "response.steer" {
+ if p := read(); ojson.GetBytes(p, "type").String() != "response.steer" {
t.Errorf("expected steering: %s", p)
return
}
write(`{"type":"response.steer.accepted","steer":{"id":"s1","previous_response_id":"first"}}`)
if scenario == "multiple_steers" {
- if p := read(); gjson.GetBytes(p, "type").String() != "response.steer" {
+ if p := read(); ojson.GetBytes(p, "type").String() != "response.steer" {
t.Errorf("expected second steering: %s", p)
return
}
@@ -108,11 +108,11 @@ func TestCodexDuplexAutomaticSuccessorMetadata(t *testing.T) {
response("response.completed", "automatic", firstKey)
}
next := read()
- if gjson.GetBytes(next, "type").String() != "response.create" {
+ if ojson.GetBytes(next, "type").String() != "response.create" {
t.Errorf("expected explicit create: %s", next)
return
}
- nextKey := gjson.GetBytes(next, "prompt_cache_key").String()
+ nextKey := ojson.GetBytes(next, "prompt_cache_key").String()
response("response.created", "explicit", nextKey)
response("response.completed", "explicit", nextKey)
_, _, _ = c.ReadMessage()
@@ -140,8 +140,8 @@ func TestCodexDuplexAutomaticSuccessorMetadata(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- event := gjson.GetBytes(chunk.Payload, "type").String()
- id := gjson.GetBytes(chunk.Payload, "response.id").String()
+ event := ojson.GetBytes(chunk.Payload, "type").String()
+ id := ojson.GetBytes(chunk.Payload, "response.id").String()
if event == "response.created" && id == "first" {
if scenario == "create_before_steer" {
input <- core.WebsocketInput{Payload: request("middle-key")}
@@ -152,7 +152,7 @@ func TestCodexDuplexAutomaticSuccessorMetadata(t *testing.T) {
}
}
if event == "response.steer.accepted" {
- steerID := gjson.GetBytes(chunk.Payload, "steer.id").String()
+ steerID := ojson.GetBytes(chunk.Payload, "steer.id").String()
if scenario == "multiple_steers" && steerID == "s1" {
input <- core.WebsocketInput{Payload: []byte(`{"type":"response.steer","previous_response_id":"first","input":"second"}`)}
} else if !queuedCreate {
@@ -169,7 +169,7 @@ func TestCodexDuplexAutomaticSuccessorMetadata(t *testing.T) {
if id == "middle" {
want = "middle-key"
}
- if got := gjson.GetBytes(chunk.Payload, "response.prompt_cache_key").String(); got != want {
+ if got := ojson.GetBytes(chunk.Payload, "response.prompt_cache_key").String(); got != want {
t.Errorf("%s %s settings = %q, want %q", event, id, got, want)
}
if id == "automatic" && event == "response.completed" {
diff --git a/internal/runtime/executor/codex_websockets_duplex_test.go b/internal/runtime/executor/codex_websockets_duplex_test.go
index 3ae4c3eb9bc..4790bad0306 100644
--- a/internal/runtime/executor/codex_websockets_duplex_test.go
+++ b/internal/runtime/executor/codex_websockets_duplex_test.go
@@ -10,12 +10,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// The upstream deliberately cannot finish the initial response until it reads
@@ -54,7 +54,7 @@ func TestCodexDuplexSteeringLifecycle(t *testing.T) {
}
}
first := read()
- if gjson.GetBytes(first, "type").String() != "response.create" {
+ if ojson.GetBytes(first, "type").String() != "response.create" {
t.Errorf("initial create: %s", first)
}
write([]byte(`{"type":"response.created","response":{"id":"r1","output":[]}}`))
@@ -65,17 +65,17 @@ func TestCodexDuplexSteeringLifecycle(t *testing.T) {
write([]byte(fmt.Sprintf(`{"type":%q,"response":{"id":"r1","output":[],"incomplete_details":{"reason":"steered"}}}`, boundary)))
write([]byte(`{"type":"response.created","response":{"id":"r2","output":[]}}`))
got := read()
- if gjson.GetBytes(got, "type").String() != "response.steer" {
+ if ojson.GetBytes(got, "type").String() != "response.steer" {
t.Errorf("second control: %s", got)
}
write([]byte(`{"type":"response.steer.accepted","steer":{"id":"s2","previous_response_id":"r2"}}`))
write([]byte(`{"type":"response.completed","response":{"id":"r2","output":[{"type":"function_call","call_id":"c1","name":"lookup","arguments":"{}"}]}}`))
write(pending)
got = read()
- if gjson.GetBytes(got, "type").String() != "response.create" || gjson.GetBytes(got, "previous_response_id").String() != "r2" || gjson.GetBytes(got, "input.0.call_id").String() != "c1" {
+ if ojson.GetBytes(got, "type").String() != "response.create" || ojson.GetBytes(got, "previous_response_id").String() != "r2" || ojson.GetBytes(got, "input.0.call_id").String() != "c1" {
t.Errorf("tool continuation: %s", got)
}
- if gjson.GetBytes(got, "instructions").String() != "New settings" {
+ if ojson.GetBytes(got, "instructions").String() != "New settings" {
t.Errorf("explicit settings not applied: %s", got)
}
write([]byte(`{"type":"response.created","response":{"id":"r3","output":[]}}`))
@@ -83,7 +83,7 @@ func TestCodexDuplexSteeringLifecycle(t *testing.T) {
write([]byte(`{"type":"response.completed","response":{"id":"r3","output":[]}}`))
// Idle steering must also reach the same live upstream connection.
got = read()
- if gjson.GetBytes(got, "previous_response_id").String() != "missing" {
+ if ojson.GetBytes(got, "previous_response_id").String() != "missing" {
t.Errorf("idle control missing: %s", got)
}
write(failed)
@@ -114,12 +114,12 @@ func TestCodexDuplexSteeringLifecycle(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- event := gjson.GetBytes(chunk.Payload, "type").String()
- id := gjson.GetBytes(chunk.Payload, "response.id").String()
+ event := ojson.GetBytes(chunk.Payload, "type").String()
+ id := ojson.GetBytes(chunk.Payload, "response.id").String()
switch {
case event == "response.created" && id == "r1":
input <- core.WebsocketInput{Payload: steer}
- case event == "response.steer.accepted" && gjson.GetBytes(chunk.Payload, "steer.id").String() == "s1":
+ case event == "response.steer.accepted" && ojson.GetBytes(chunk.Payload, "steer.id").String() == "s1":
if !bytes.Equal(chunk.Payload, accepted) {
t.Fatal("accepted event changed")
}
@@ -135,7 +135,7 @@ func TestCodexDuplexSteeringLifecycle(t *testing.T) {
seen["pending"] = true
input <- core.WebsocketInput{Payload: []byte(`{"type":"response.create","previous_response_id":"r2","instructions":"New settings","input":[{"type":"function_call_output","call_id":"c1","output":"ok"}]}`)}
case event == "response.completed" && id == "r3":
- if gjson.GetBytes(chunk.Payload, "response.output.0.content.0.text").String() != "STEER_OK" {
+ if ojson.GetBytes(chunk.Payload, "response.output.0.content.0.text").String() != "STEER_OK" {
t.Fatal("successor output missing or contaminated by preceding response")
}
seen["successor"] = true
@@ -193,7 +193,7 @@ func TestCodexDuplexSIWCSteerStripsConfigurationUpdates(t *testing.T) {
t.Errorf("read initial create: %v", errRead)
return
}
- if gjson.GetBytes(first, "type").String() != "response.create" {
+ if ojson.GetBytes(first, "type").String() != "response.create" {
t.Errorf("initial create: %s", first)
}
if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"r1","output":[]}}`)); errWrite != nil {
@@ -245,8 +245,8 @@ func TestCodexDuplexSIWCSteerStripsConfigurationUpdates(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- event := gjson.GetBytes(chunk.Payload, "type").String()
- id := gjson.GetBytes(chunk.Payload, "response.id").String()
+ event := ojson.GetBytes(chunk.Payload, "type").String()
+ id := ojson.GetBytes(chunk.Payload, "response.id").String()
if event == "response.created" && id == "r1" {
input <- core.WebsocketInput{Payload: []byte(`{"type":"response.steer","previous_response_id":"r1","input":[{"type":"configuration_update","reasoning":{"effort":"high"}},{"role":"user","content":[{"type":"input_text","text":"STEER_OK"}]}]}`)}
}
@@ -266,17 +266,17 @@ func TestCodexDuplexSIWCSteerStripsConfigurationUpdates(t *testing.T) {
select {
case got := <-captured:
- if gjson.GetBytes(got, "type").String() != "response.steer" || gjson.GetBytes(got, "previous_response_id").String() != "r1" {
+ if ojson.GetBytes(got, "type").String() != "response.steer" || ojson.GetBytes(got, "previous_response_id").String() != "r1" {
t.Fatalf("steer routing changed: %s", got)
}
- items := gjson.GetBytes(got, "input").Array()
+ items := ojson.GetBytes(got, "input").Array()
if len(items) != 1 || items[0].Get("role").String() != "user" || items[0].Get("content.0.text").String() != "STEER_OK" {
t.Fatalf("steer input must contain only the user message: %s", got)
}
- if gjson.GetBytes(got, "reasoning").Exists() {
+ if ojson.GetBytes(got, "reasoning").Exists() {
t.Fatalf("steer must not promote reasoning: %s", got)
}
- if gjson.GetBytes(got, "store").Exists() || gjson.GetBytes(got, "stream").Exists() {
+ if ojson.GetBytes(got, "store").Exists() || ojson.GetBytes(got, "stream").Exists() {
t.Fatalf("steer must not inherit response.create defaults: %s", got)
}
default:
@@ -302,8 +302,8 @@ func TestCodexDuplexAppendInheritsContextAndInstructions(t *testing.T) {
t.Errorf("read first create: %v", err)
return
}
- if gjson.GetBytes(first, "instructions").String() != "Initial system instructions" {
- t.Errorf("first instructions = %q", gjson.GetBytes(first, "instructions").String())
+ if ojson.GetBytes(first, "instructions").String() != "Initial system instructions" {
+ t.Errorf("first instructions = %q", ojson.GetBytes(first, "instructions").String())
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"resp-1","output":[]}}`))
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[]}}`))
@@ -314,16 +314,16 @@ func TestCodexDuplexAppendInheritsContextAndInstructions(t *testing.T) {
t.Errorf("read second create (from append): %v", err)
return
}
- if got := gjson.GetBytes(second, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(second, "type").String(); got != "response.create" {
t.Errorf("second type = %q, want response.create", got)
}
- if got := gjson.GetBytes(second, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytes(second, "previous_response_id").String(); got != "resp-1" {
t.Errorf("second previous_response_id = %q, want resp-1", got)
}
- if got := gjson.GetBytes(second, "model").String(); got != "gpt-6-astra" {
+ if got := ojson.GetBytes(second, "model").String(); got != "gpt-6-astra" {
t.Errorf("second model = %q, want gpt-6-astra", got)
}
- if got := gjson.GetBytes(second, "instructions").String(); got != "Initial system instructions" {
+ if got := ojson.GetBytes(second, "instructions").String(); got != "Initial system instructions" {
t.Errorf("second instructions = %q, want 'Initial system instructions'", got)
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"resp-2","output":[]}}`))
@@ -356,8 +356,8 @@ func TestCodexDuplexAppendInheritsContextAndInstructions(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- eventType := gjson.GetBytes(chunk.Payload, "type").String()
- eventID := gjson.GetBytes(chunk.Payload, "response.id").String()
+ eventType := ojson.GetBytes(chunk.Payload, "type").String()
+ eventID := ojson.GetBytes(chunk.Payload, "response.id").String()
if eventType == "response.completed" && eventID == "resp-1" {
// Client sends response.append omitting previous_response_id, model, instructions
appendPayload := []byte(`{"type":"response.append","input":[{"role":"user","content":"turn 2"}]}`)
@@ -403,10 +403,10 @@ func TestCodexDuplexStandaloneCreateDoesNotInheritParentID(t *testing.T) {
t.Errorf("read second create: %v", err)
return
}
- if got := gjson.GetBytes(second, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(second, "type").String(); got != "response.create" {
t.Errorf("second type = %q, want response.create", got)
}
- if gjson.GetBytes(second, "previous_response_id").Exists() {
+ if ojson.GetBytes(second, "previous_response_id").Exists() {
t.Errorf("standalone create must not gain previous_response_id: %s", second)
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"resp-2","output":[]}}`))
@@ -439,8 +439,8 @@ func TestCodexDuplexStandaloneCreateDoesNotInheritParentID(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- eventType := gjson.GetBytes(chunk.Payload, "type").String()
- eventID := gjson.GetBytes(chunk.Payload, "response.id").String()
+ eventType := ojson.GetBytes(chunk.Payload, "type").String()
+ eventID := ojson.GetBytes(chunk.Payload, "response.id").String()
if eventType == "response.completed" && eventID == "resp-1" {
// Client sends standalone response.create without previous_response_id
standalonePayload := []byte(`{"type":"response.create","model":"gpt-6-astra","input":[{"role":"user","content":"turn 2 standalone"}]}`)
@@ -477,7 +477,7 @@ func TestCodexDuplexQueuedCreateDoesNotBlockSubsequentSteer(t *testing.T) {
t.Errorf("read first create: %v", err)
return
}
- if gjson.GetBytes(first, "type").String() != "response.create" {
+ if ojson.GetBytes(first, "type").String() != "response.create" {
t.Errorf("expected response.create: %s", first)
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"r1","output":[]}}`))
@@ -488,7 +488,7 @@ func TestCodexDuplexQueuedCreateDoesNotBlockSubsequentSteer(t *testing.T) {
t.Errorf("read steer1: %v", err)
return
}
- if gjson.GetBytes(steer1, "type").String() != "response.steer" {
+ if ojson.GetBytes(steer1, "type").String() != "response.steer" {
t.Errorf("expected response.steer: %s", steer1)
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.steer.accepted","steer":{"id":"s1","previous_response_id":"r1"}}`))
@@ -504,7 +504,7 @@ func TestCodexDuplexQueuedCreateDoesNotBlockSubsequentSteer(t *testing.T) {
t.Errorf("read steer: %v", err)
return
}
- if got := gjson.GetBytes(steerFrame, "type").String(); got != "response.steer" {
+ if got := ojson.GetBytes(steerFrame, "type").String(); got != "response.steer" {
t.Errorf("expected response.steer to arrive before blocked create, got: %s", steerFrame)
return
}
@@ -519,7 +519,7 @@ func TestCodexDuplexQueuedCreateDoesNotBlockSubsequentSteer(t *testing.T) {
t.Errorf("read queued create: %v", err)
return
}
- if got := gjson.GetBytes(nextCreate, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(nextCreate, "type").String(); got != "response.create" {
t.Errorf("queued create type = %q, want response.create", got)
}
_ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"r2","output":[]}}`))
@@ -552,8 +552,8 @@ func TestCodexDuplexQueuedCreateDoesNotBlockSubsequentSteer(t *testing.T) {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
- eventType := gjson.GetBytes(chunk.Payload, "type").String()
- eventID := gjson.GetBytes(chunk.Payload, "response.id").String()
+ eventType := ojson.GetBytes(chunk.Payload, "type").String()
+ eventID := ojson.GetBytes(chunk.Payload, "response.id").String()
if eventType == "response.created" && eventID == "r1" {
input <- core.WebsocketInput{Payload: []byte(`{"type":"response.steer","previous_response_id":"r1","input":"steer 1"}`)}
}
diff --git a/internal/runtime/executor/codex_websockets_errors.go b/internal/runtime/executor/codex_websockets_errors.go
index 7b993e42be8..718d56b1523 100644
--- a/internal/runtime/executor/codex_websockets_errors.go
+++ b/internal/runtime/executor/codex_websockets_errors.go
@@ -7,11 +7,10 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type statusErrWithHeaders struct {
@@ -34,12 +33,12 @@ func parseCodexWebsocketErrorWithCooling(payload []byte, modelLevelCooling bool)
if len(payload) == 0 {
return nil, false
}
- if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "error" {
+ if strings.TrimSpace(ojson.GetBytesNoCopy(payload, "type").String()) != "error" {
return nil, false
}
- status := int(gjson.GetBytes(payload, "status").Int())
+ status := int(ojson.GetBytesNoCopy(payload, "status").Int())
if status == 0 {
- status = int(gjson.GetBytes(payload, "status_code").Int())
+ status = int(ojson.GetBytesNoCopy(payload, "status_code").Int())
}
if status <= 0 {
return nil, false
@@ -74,9 +73,9 @@ func parseCodexWebsocketErrorWithCooling(payload []byte, modelLevelCooling bool)
}
func clearCodexReasoningReplayOnWebsocketError(ctx context.Context, scope codexReasoningReplayScope, payload []byte) error {
- status := int(gjson.GetBytes(payload, "status").Int())
+ status := int(ojson.GetBytesNoCopy(payload, "status").Int())
if status == 0 {
- status = int(gjson.GetBytes(payload, "status_code").Int())
+ status = int(ojson.GetBytesNoCopy(payload, "status_code").Int())
}
if status <= 0 {
return nil
@@ -85,25 +84,25 @@ func clearCodexReasoningReplayOnWebsocketError(ctx context.Context, scope codexR
}
func buildCodexWebsocketErrorPayload(payload []byte, status int) []byte {
- out := []byte(`{}`)
- out, _ = sjson.SetBytes(out, "status", status)
+ doc := ojson.NewDoc([]byte(`{}`))
+ _ = doc.Set("status", status)
- if bodyNode := gjson.GetBytes(payload, "body"); bodyNode.Exists() {
- out, _ = sjson.SetRawBytes(out, "body", []byte(bodyNode.Raw))
+ if bodyNode := ojson.GetBytesNoCopy(payload, "body"); bodyNode.Exists() {
+ _ = doc.SetRawBytes("body", []byte(bodyNode.Raw))
if bodyErrorNode := bodyNode.Get("error"); bodyErrorNode.Exists() {
- out, _ = sjson.SetRawBytes(out, "error", []byte(bodyErrorNode.Raw))
- return out
+ _ = doc.SetRawBytes("error", []byte(bodyErrorNode.Raw))
+ return doc.Bytes()
}
}
- if errNode := gjson.GetBytes(payload, "error"); errNode.Exists() {
- out, _ = sjson.SetRawBytes(out, "error", []byte(errNode.Raw))
- return out
+ if errNode := ojson.GetBytesNoCopy(payload, "error"); errNode.Exists() {
+ _ = doc.SetRawBytes("error", []byte(errNode.Raw))
+ return doc.Bytes()
}
- out, _ = sjson.SetBytes(out, "error.type", "server_error")
- out, _ = sjson.SetBytes(out, "error.message", http.StatusText(status))
- return out
+ _ = doc.Set("error.type", "server_error")
+ _ = doc.Set("error.message", http.StatusText(status))
+ return doc.Bytes()
}
func isCodexWebsocketConnectionLimitError(payload []byte) bool {
@@ -111,7 +110,7 @@ func isCodexWebsocketConnectionLimitError(payload []byte) bool {
return false
}
for _, path := range []string{"error.code", "error.type", "body.error.code", "body.error.type", "code", "error"} {
- if strings.TrimSpace(gjson.GetBytes(payload, path).String()) == "websocket_connection_limit_reached" {
+ if strings.TrimSpace(ojson.GetBytesNoCopy(payload, path).String()) == "websocket_connection_limit_reached" {
return true
}
}
@@ -119,24 +118,24 @@ func isCodexWebsocketConnectionLimitError(payload []byte) bool {
}
func parseCodexWebsocketErrorHeaders(payload []byte) http.Header {
- headersNode := gjson.GetBytes(payload, "headers")
+ headersNode := ojson.GetBytesNoCopy(payload, "headers")
if !headersNode.Exists() || !headersNode.IsObject() {
return nil
}
mapped := make(http.Header)
- headersNode.ForEach(func(key, value gjson.Result) bool {
+ headersNode.ForEach(func(key, value ojson.Result) bool {
name := strings.TrimSpace(key.String())
if name == "" {
return true
}
switch value.Type {
- case gjson.String:
+ case ojson.String:
if v := strings.TrimSpace(value.String()); v != "" {
- mapped.Set(name, v)
+ mapped.Set(strings.Clone(name), strings.Clone(v))
}
- case gjson.Number, gjson.True, gjson.False:
+ case ojson.Number, ojson.True, ojson.False:
if v := strings.TrimSpace(value.Raw); v != "" {
- mapped.Set(name, v)
+ mapped.Set(strings.Clone(name), strings.Clone(v))
}
default:
}
@@ -149,8 +148,8 @@ func parseCodexWebsocketErrorHeaders(payload []byte) http.Header {
}
func normalizeCodexWebsocketCompletion(payload []byte) []byte {
- if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.done" {
- updated, err := sjson.SetBytes(payload, "type", "response.completed")
+ if strings.TrimSpace(ojson.GetBytesNoCopy(payload, "type").String()) == "response.done" {
+ updated, err := ojson.SetBytes(payload, "type", "response.completed")
if err == nil && len(updated) > 0 {
return updated
}
diff --git a/internal/runtime/executor/codex_websockets_execute.go b/internal/runtime/executor/codex_websockets_execute.go
index a8087033835..c9092c92400 100644
--- a/internal/runtime/executor/codex_websockets_execute.go
+++ b/internal/runtime/executor/codex_websockets_execute.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -14,14 +15,13 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
if ctx == nil {
ctx = context.Background()
}
+ ctx = helps.WithCodexCommentaryPolicy(ctx, e.cfg)
if errExpand := helps.ExpandRecognizedResponsesCompactionCapsules(&req, &opts); errExpand != nil {
return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
}
@@ -57,40 +57,45 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, false, isCompat)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntentContext(ctx, from, to, targetModel, originalPayload, req.Payload, false, isCompat)
if err != nil {
return resp, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
+ doc := ojson.NewDoc(body)
+ normalizeCodexClaudeOutputBudgetDoc(doc, from, req.Payload)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return resp, err
}
- hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
- finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.SetBoolIfDifferent(body, "stream", true)
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "safety_identifier")
- body = ensureWebSearchToolForHistory(body, opts.Headers)
+ instructionsResult := ojson.GetBytesNoCopy(req.Payload, "instructions")
+ hasExplicitInstructions := instructionsResult.Exists() && strings.TrimSpace(instructionsResult.String()) != ""
+ ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts))
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ ensureWebSearchToolForHistoryDoc(doc, opts.Headers)
if !useSIWC {
- body = normalizeCodexInstructions(body, nativeRequest)
+ normalizeCodexInstructionsDoc(doc, nativeRequest)
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
- body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
+ ensureImageGenerationToolDoc(doc, baseModel, auth, opts.Headers)
+ if !codexAuthUsesAPIKey(auth) {
+ normalizeCodexImageGenerationToolsDoc(doc)
+ }
}
}
- body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers)
- body = helps.NormalizeCodexToolSchemas(body)
- multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body)
- body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
- body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
+ normalizeCodexWebsocketParallelToolCallsDoc(doc, opts.Headers)
+ helps.NormalizeCodexToolSchemasDoc(doc)
+ multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflictDoc(doc)
+ optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuthDoc(doc, ctx, opts.Headers, e.cfg, auth, isCompat)
+ replayScope, _, errReplay := applyCodexReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
if errReplay != nil {
return resp, errReplay
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex websockets executor", body, isCompat, targetModel, baseURL)
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "codex websockets executor", doc, isCompat, targetModel, baseURL)
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
@@ -98,19 +103,27 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
return resp, err
}
- body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body, opts.Headers)
+ wsHeaders, _, errPromptCache := applyCodexPromptCacheHeadersWithContextDoc(ctx, from, req, doc, opts.Headers)
if errPromptCache != nil {
return resp, errPromptCache
}
- body = helps.SanitizeCodexInputItemIDs(body)
+ helps.SanitizeCodexInputItemIDsDoc(doc)
if useSIWC {
- body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
+ applyCodexSIWCWebsocketBodyRulesDoc(doc, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
+ }
+ helps.FinalizePayloadDoc(ctx, doc)
+ body = doc.Bytes()
+ upstreamModel := strings.TrimSpace(doc.Get("model").String())
+ if upstreamModel != "" && upstreamModel != baseModel {
+ reporter.SetUpstreamModel(upstreamModel)
}
- body = finalizePayload(body)
reporter.SetTranslatedReasoningEffort(body, to.String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, nativeRequest, opts.Headers)
- applyCodexRoutingHint(ctx, wsHeaders, auth, baseModel, body, opts.Headers)
- applyModelHeaderOverrides(wsHeaders, baseModel)
+ applyCodexRoutingHint(ctx, wsHeaders, auth, upstreamModel, body, opts.Headers)
+ applyModelHeaderOverrides(wsHeaders, upstreamModel)
+ if errValidate := helps.ValidateCodexHostedImageGenerationDoc(doc, wsHeaders, e.cfg, !useSIWC && !codexAuthUsesAPIKey(auth)); errValidate != nil {
+ return resp, errValidate
+ }
var authID, authLabel, authType, authValue string
if auth != nil {
@@ -351,7 +364,7 @@ func (e *CodexWebsocketsExecutor) Execute(ctx context.Context, auth *cliproxyaut
}
payload = normalizeCodexWebsocketCompletion(payload)
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
if helps.HasMeaningfulCodexOutputDelta(payload) {
sawOutputDelta = true
}
diff --git a/internal/runtime/executor/codex_websockets_executor.go b/internal/runtime/executor/codex_websockets_executor.go
index b7a2c832b4b..a8114f9e865 100644
--- a/internal/runtime/executor/codex_websockets_executor.go
+++ b/internal/runtime/executor/codex_websockets_executor.go
@@ -9,11 +9,11 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
// CodexWebsocketsExecutor executes Codex Responses requests using a WebSocket transport.
@@ -227,7 +227,7 @@ func (e *CodexWebsocketsExecutor) InterruptExecutionSession(ctx context.Context,
return cliproxyexecutor.ErrNoActiveUpstreamWebsocket
}
sess.activeMu.Lock()
- targetID := gjson.GetBytes(payload, "response_id").String()
+ targetID := ojson.GetBytesNoCopy(payload, "response_id").String()
_, alreadyTerminal := sess.terminalResponseIDs[targetID]
alreadyTerminal = alreadyTerminal && sess.responseStateConn == conn
// activeResponseID/Terminal track the running response at read-loop
diff --git a/internal/runtime/executor/codex_websockets_executor_test.go b/internal/runtime/executor/codex_websockets_executor_test.go
index 47e957f3f75..0c580548227 100644
--- a/internal/runtime/executor/codex_websockets_executor_test.go
+++ b/internal/runtime/executor/codex_websockets_executor_test.go
@@ -13,6 +13,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
@@ -24,7 +25,6 @@ import (
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
var benchmarkBuildCodexWebsocketRequestBodyOutput []byte
@@ -34,16 +34,16 @@ func TestBuildCodexWebsocketRequestBodyPreservesPreviousResponseID(t *testing.T)
wsReqBody := buildCodexWebsocketRequestBody(body)
- if got := gjson.GetBytes(wsReqBody, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(wsReqBody, "type").String(); got != "response.create" {
t.Fatalf("type = %s, want response.create", got)
}
- if got := gjson.GetBytes(wsReqBody, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytes(wsReqBody, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("previous_response_id = %s, want resp-1", got)
}
- if gjson.GetBytes(wsReqBody, "input.0.id").String() != "msg-1" {
+ if ojson.GetBytes(wsReqBody, "input.0.id").String() != "msg-1" {
t.Fatalf("input item id mismatch")
}
- if got := gjson.GetBytes(wsReqBody, "type").String(); got == "response.append" {
+ if got := ojson.GetBytes(wsReqBody, "type").String(); got == "response.append" {
t.Fatalf("unexpected websocket request type: %s", got)
}
}
@@ -67,15 +67,15 @@ func TestBuildCodexWebsocketRequestBodySanitizesOverlongInputItemIDs(t *testing.
first := buildCodexWebsocketRequestBody(body)
second := buildCodexWebsocketRequestBody(body)
- if input := gjson.GetBytes(first, "input").Array(); len(input) != 3 {
+ if input := ojson.GetBytes(first, "input").Array(); len(input) != 3 {
t.Fatalf("input length = %d, want 3: %s", len(input), first)
}
- if gotType := gjson.GetBytes(first, "input.0.type").String(); gotType != "function_call" {
+ if gotType := ojson.GetBytes(first, "input.0.type").String(); gotType != "function_call" {
t.Fatalf("input.0.type = %q, want function_call: %s", gotType, first)
}
- shortCallItemID := gjson.GetBytes(first, "input.0.id").String()
- shortOutputItemID := gjson.GetBytes(first, "input.1.id").String()
+ shortCallItemID := ojson.GetBytes(first, "input.0.id").String()
+ shortOutputItemID := ojson.GetBytes(first, "input.1.id").String()
if len([]rune(shortCallItemID)) > 64 || shortCallItemID == longCallItemID {
t.Fatalf("input.0.id was not shortened to at most 64 characters: %q", shortCallItemID)
}
@@ -85,16 +85,16 @@ func TestBuildCodexWebsocketRequestBodySanitizesOverlongInputItemIDs(t *testing.
if shortCallItemID == shortOutputItemID {
t.Fatalf("distinct long IDs produced the same shortened ID: %q", shortCallItemID)
}
- if got := gjson.GetBytes(second, "input.0.id").String(); got != shortCallItemID {
+ if got := ojson.GetBytes(second, "input.0.id").String(); got != shortCallItemID {
t.Fatalf("input item ID shortening is not deterministic: first=%q second=%q", shortCallItemID, got)
}
- if got := gjson.GetBytes(first, "input.0.call_id").String(); got != "call-1" {
+ if got := ojson.GetBytes(first, "input.0.call_id").String(); got != "call-1" {
t.Fatalf("function call_id = %q, want call-1", got)
}
- if got := gjson.GetBytes(first, "input.1.call_id").String(); got != "call-1" {
+ if got := ojson.GetBytes(first, "input.1.call_id").String(); got != "call-1" {
t.Fatalf("function call output call_id = %q, want call-1", got)
}
- if got := gjson.GetBytes(first, "input.2.id").String(); got != "msg_item_74ec40c883248ebb4885ec84" {
+ if got := ojson.GetBytes(first, "input.2.id").String(); got != "msg_item_74ec40c883248ebb4885ec84" {
t.Fatalf("message input item ID was not normalized: %q", got)
}
}
@@ -157,7 +157,7 @@ func TestCodexWebsocketsExecuteRestoresClaudeAgentReasoningReplay(t *testing.T)
select {
case payload := <-capturedPayload:
- input := gjson.GetBytes(payload, "input").Array()
+ input := ojson.GetBytes(payload, "input").Array()
if len(input) != 4 {
t.Fatalf("upstream input length = %d, want 4; payload=%s", len(input), payload)
}
@@ -234,19 +234,19 @@ func TestCodexWebsocketsExecuteResponsesLiteDoesNotInjectImageGenerationTool(t *
select {
case payload := <-capturedPayload:
- if instructions := gjson.GetBytes(payload, "instructions"); instructions.Exists() {
+ if instructions := ojson.GetBytes(payload, "instructions"); instructions.Exists() {
t.Errorf("unexpected instructions in responses-lite upstream payload: %s", payload)
}
- if tools := gjson.GetBytes(payload, "tools"); tools.Exists() {
+ if tools := ojson.GetBytes(payload, "tools"); tools.Exists() {
t.Fatalf("unexpected tools in responses-lite upstream payload: %s", tools.Raw)
}
- if got := gjson.GetBytes(payload, "input.0.type").String(); got != "additional_tools" {
+ if got := ojson.GetBytes(payload, "input.0.type").String(); got != "additional_tools" {
t.Fatalf("input.0.type = %q, want additional_tools; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite").String(); got != "true" {
+ if got := ojson.GetBytes(payload, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite").String(); got != "true" {
t.Fatalf("responses-lite metadata = %q, want true; payload=%s", got, payload)
}
- parallelToolCalls := gjson.GetBytes(payload, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(payload, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", payload)
}
@@ -317,7 +317,7 @@ func TestCodexWebsocketsExecuteStreamResponsesLiteForcesParallelToolCallsFalse(t
select {
case payload := <-capturedPayload:
- parallelToolCalls := gjson.GetBytes(payload, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(payload, "parallel_tool_calls")
if !parallelToolCalls.Exists() || parallelToolCalls.Bool() {
t.Fatalf("responses-lite parallel_tool_calls should be false: %s", payload)
}
@@ -369,10 +369,10 @@ func TestCodexWebsocketsExecutePreservesPreviousResponseIDUpstream(t *testing.T)
select {
case payload := <-capturedPayload:
- if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(payload, "type").String(); got != "response.create" {
t.Fatalf("upstream type = %s, want response.create; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytes(payload, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("upstream previous_response_id = %s, want resp-1; payload=%s", got, payload)
}
case <-time.After(5 * time.Second):
@@ -545,7 +545,7 @@ func TestCodexAutoExecutorRequiredUpstreamWebsocketRejectsHTTPFallback(t *testin
if !ok || statusErr.StatusCode() != http.StatusUpgradeRequired {
t.Fatalf("ExecuteStream() error = %T %v, want status 426", errExecute, errExecute)
}
- if got := gjson.Get(errExecute.Error(), "error.code").String(); got != "upstream_http_replay_required" {
+ if got := ojson.Get(errExecute.Error(), "error.code").String(); got != "upstream_http_replay_required" {
t.Fatalf("ExecuteStream() error code = %q, want upstream_http_replay_required", got)
}
requestScoped, ok := errExecute.(cliproxyexecutor.RequestScopedError)
@@ -717,10 +717,10 @@ func TestCodexWebsocketsExecuteStreamPassesThroughUpstreamWebsocketPayloadForDow
select {
case payload := <-capturedPayload:
- if got := gjson.GetBytes(payload, "model").String(); got != "gpt-5-codex" {
+ if got := ojson.GetBytes(payload, "model").String(); got != "gpt-5-codex" {
t.Fatalf("upstream model = %s, want gpt-5-codex; payload=%s", got, payload)
}
- parallelToolCalls := gjson.GetBytes(payload, "parallel_tool_calls")
+ parallelToolCalls := ojson.GetBytes(payload, "parallel_tool_calls")
if !parallelToolCalls.Exists() || !parallelToolCalls.Bool() {
t.Fatalf("non-lite parallel_tool_calls should be preserved: %s", payload)
}
@@ -1012,7 +1012,7 @@ func TestCodexWebsocketsExecuteStreamMapsMessageTooBigClose(t *testing.T) {
if got := statusErr.StatusCode(); got != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", got, http.StatusRequestEntityTooLarge)
}
- if got := gjson.Get(chunk.Err.Error(), "error.code").String(); got != "message_too_big" {
+ if got := ojson.Get(chunk.Err.Error(), "error.code").String(); got != "message_too_big" {
t.Fatalf("error code = %q, want message_too_big; err=%v", got, chunk.Err)
}
requestErr, ok := chunk.Err.(interface{ IsRequestScoped() bool })
@@ -1492,7 +1492,7 @@ func TestApplyCodexPromptCacheHeadersUsesDerivedSessionUUID(t *testing.T) {
Metadata: map[string]any{cliproxyexecutor.DerivedSessionIDMetadataKey: "ctx:v1:derived-root"},
}
body, headers := applyCodexPromptCacheHeaders(sdktranslator.FormatInteractions, req, []byte(`{"model":"gpt-5-codex"}`))
- cacheKey := gjson.GetBytes(body, "prompt_cache_key").String()
+ cacheKey := ojson.GetBytes(body, "prompt_cache_key").String()
if _, errParse := uuid.Parse(cacheKey); errParse != nil {
t.Fatalf("prompt_cache_key %q is not a UUID: %v", cacheKey, errParse)
}
@@ -1525,8 +1525,8 @@ func TestApplyCodexPromptCacheHeadersKeepsExecutionSessionAcrossIncrementalRoots
}
firstBody, _ := applyCodexPromptCacheHeaders(sdktranslator.FormatOpenAIResponse, firstReq, []byte(`{"model":"gpt-5-codex"}`))
secondBody, _ := applyCodexPromptCacheHeaders(sdktranslator.FormatOpenAIResponse, secondReq, []byte(`{"model":"gpt-5-codex"}`))
- firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String()
- secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String()
+ firstKey := ojson.GetBytes(firstBody, "prompt_cache_key").String()
+ secondKey := ojson.GetBytes(secondBody, "prompt_cache_key").String()
if firstKey == "" || firstKey != secondKey {
t.Fatalf("incremental websocket roots changed prompt cache key: first=%q second=%q", firstKey, secondKey)
}
@@ -1551,8 +1551,8 @@ func TestApplyCodexPromptCacheHeadersClaudeUsesClaudeCodeSessionID(t *testing.T)
firstBody, firstHeaders := applyCodexPromptCacheHeaders("claude", firstReq, []byte(`{"model":"gpt-5-codex"}`))
secondBody, secondHeaders := applyCodexPromptCacheHeaders("claude", secondReq, []byte(`{"model":"gpt-5-codex"}`))
- firstKey := gjson.GetBytes(firstBody, "prompt_cache_key").String()
- secondKey := gjson.GetBytes(secondBody, "prompt_cache_key").String()
+ firstKey := ojson.GetBytes(firstBody, "prompt_cache_key").String()
+ secondKey := ojson.GetBytes(secondBody, "prompt_cache_key").String()
if firstKey == "" {
t.Fatalf("first prompt_cache_key is empty; body=%s", string(firstBody))
}
@@ -1575,7 +1575,7 @@ func TestApplyCodexPromptCacheHeadersClaudeRejectsBareUserID(t *testing.T) {
body, headers := applyCodexPromptCacheHeaders("claude", req, []byte(`{"model":"gpt-5-codex"}`))
- if got := gjson.GetBytes(body, "prompt_cache_key").String(); got != "" {
+ if got := ojson.GetBytes(body, "prompt_cache_key").String(); got != "" {
t.Fatalf("bare metadata.user_id must not create websocket prompt_cache_key, got %q; body=%s", got, string(body))
}
if got := headers["session_id"]; len(got) != 0 {
@@ -1644,7 +1644,7 @@ func TestParseCodexWebsocketErrorPreservesWrappedBodyAndHeaders(t *testing.T) {
t.Fatalf("expected websocket error")
}
- parsed := gjson.Parse(err.Error())
+ parsed := ojson.Parse(err.Error())
if got := parsed.Get("status").Int(); got != http.StatusTooManyRequests {
t.Fatalf("wrapped status = %d, want 429; payload=%s", got, err.Error())
}
diff --git a/internal/runtime/executor/codex_websockets_request.go b/internal/runtime/executor/codex_websockets_request.go
index e2b95b2699b..5322cf4c51e 100644
--- a/internal/runtime/executor/codex_websockets_request.go
+++ b/internal/runtime/executor/codex_websockets_request.go
@@ -5,6 +5,7 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -15,7 +16,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func applyCodexPromptCacheHeaders(from sdktranslator.Format, req cliproxyexecutor.Request, rawJSON []byte) ([]byte, http.Header) {
@@ -28,6 +28,19 @@ func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktransl
if len(rawJSON) == 0 {
return rawJSON, headers, nil
}
+ doc := ojson.NewDoc(rawJSON)
+ headers, changed, err := applyCodexPromptCacheHeadersWithContextDoc(ctx, from, req, doc, headerSets...)
+ if err != nil || !changed {
+ return rawJSON, headers, err
+ }
+ return doc.Bytes(), headers, nil
+}
+
+func applyCodexPromptCacheHeadersWithContextDoc(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, doc *ojson.Doc, headerSets ...http.Header) (http.Header, bool, error) {
+ headers := http.Header{}
+ if doc == nil || doc.Len() == 0 {
+ return headers, false, nil
+ }
var requestHeaders http.Header
if len(headerSets) > 0 {
@@ -35,33 +48,34 @@ func applyCodexPromptCacheHeadersWithContext(ctx context.Context, from sdktransl
}
var cache helps.CodexCache
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
- modelName := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ modelName := strings.TrimSpace(doc.Get("model").String())
if modelName == "" {
modelName = thinking.ParseSuffix(req.Model).ModelName
}
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, requestHeaders)
if errCache != nil {
- return nil, nil, errCache
+ return nil, false, errCache
}
if ok {
cache = cached
}
} else if sourceFormatEqual(from, sdktranslator.FormatOpenAIResponse) {
- if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
- cache.ID = promptCacheKey.String()
+ if promptCacheKey := ojson.GetBytesNoCopy(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
+ cache.ID = strings.Clone(promptCacheKey.String())
}
}
if cache.ID == "" {
cache.ID = helps.ProviderSessionUUID("codex", req.Metadata)
}
+ changed := false
if cache.ID != "" {
- rawJSON = helps.SetStringIfDifferent(rawJSON, "prompt_cache_key", cache.ID)
+ changed = helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", cache.ID)
setHeaderCasePreserved(headers, "session_id", cache.ID)
headers.Set("Conversation_id", cache.ID)
}
- return rawJSON, headers, nil
+ return headers, changed, nil
}
func applyCodexWebsocketHeaders(ctx context.Context, headers http.Header, auth *cliproxyauth.Auth, token string, cfg *config.Config, nativeRequest bool, clientHeaders ...http.Header) http.Header {
diff --git a/internal/runtime/executor/codex_websockets_routing_hint_test.go b/internal/runtime/executor/codex_websockets_routing_hint_test.go
index f2c684b845e..6f145e4b7de 100644
--- a/internal/runtime/executor/codex_websockets_routing_hint_test.go
+++ b/internal/runtime/executor/codex_websockets_routing_hint_test.go
@@ -7,11 +7,11 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type codexWebsocketHintFrame struct {
@@ -49,9 +49,9 @@ func newCodexWebsocketHintServer(t *testing.T, rec *codexWebsocketHintRecorder)
rec.mu.Lock()
rec.frames = append(rec.frames, codexWebsocketHintFrame{
conn: connIndex,
- model: gjson.GetBytes(payload, "model").String(),
- tier: gjson.GetBytes(payload, "service_tier").String(),
- previousResponseID: gjson.GetBytes(payload, "previous_response_id").String(),
+ model: ojson.GetBytes(payload, "model").String(),
+ tier: ojson.GetBytes(payload, "service_tier").String(),
+ previousResponseID: ojson.GetBytes(payload, "previous_response_id").String(),
})
rec.mu.Unlock()
completed := []byte(`{"type":"response.completed","response":{"id":"resp-1","object":"response","status":"completed","model":"gpt-5.5","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
diff --git a/internal/runtime/executor/codex_websockets_session.go b/internal/runtime/executor/codex_websockets_session.go
index 633ee1d8c38..e0a634057ac 100644
--- a/internal/runtime/executor/codex_websockets_session.go
+++ b/internal/runtime/executor/codex_websockets_session.go
@@ -9,12 +9,12 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
type codexWebsocketSessionStore struct {
@@ -849,7 +849,7 @@ func (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession,
payload = bytes.TrimSpace(payload)
if len(payload) > 0 {
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := strings.Clone(ojson.GetBytesNoCopy(payload, "type").String())
if eventType != "" {
sess.setLastEventType(conn, eventType)
}
@@ -866,7 +866,7 @@ func (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession,
// id-less failure ends the running one. Success terminals only
// carry response.id, and one without an id is left unattributed,
// as before.
- responseID := gjson.GetBytes(payload, "response.id").String()
+ responseID := strings.Clone(ojson.GetBytesNoCopy(payload, "response.id").String())
if eventType == "response.created" {
if responseID != "" {
sess.activeMu.Lock()
@@ -879,7 +879,7 @@ func (e *CodexWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession,
} else if isTerminalEvent(eventType) {
failure := eventType == "error" || eventType == "response.failed"
if failure && responseID == "" {
- responseID = gjson.GetBytes(payload, "response_id").String()
+ responseID = strings.Clone(ojson.GetBytesNoCopy(payload, "response_id").String())
}
if responseID != "" || failure {
sess.activeMu.Lock()
diff --git a/internal/runtime/executor/codex_websockets_spawn_agent_test.go b/internal/runtime/executor/codex_websockets_spawn_agent_test.go
index 0acee66293e..d574170a22e 100644
--- a/internal/runtime/executor/codex_websockets_spawn_agent_test.go
+++ b/internal/runtime/executor/codex_websockets_spawn_agent_test.go
@@ -8,13 +8,13 @@ import (
"sync/atomic"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexWebsocketsExecutorRestoresMultiAgentV2NamespaceAcrossIncrementalTurns(t *testing.T) {
@@ -103,7 +103,7 @@ func TestCodexWebsocketsExecutorRestoresMultiAgentV2NamespaceAcrossIncrementalTu
firstClientPayload := execute(codexSpawnAgentTestPayload())
firstUpstreamPayload := <-capturedPayload
- if namespace := gjson.GetBytes(firstUpstreamPayload, "input.0.tools.0.name").String(); namespace != "collaboration-optimize" {
+ if namespace := ojson.GetBytes(firstUpstreamPayload, "input.0.tools.0.name").String(); namespace != "collaboration-optimize" {
t.Fatalf("first upstream namespace = %q, want collaboration-optimize", namespace)
}
assertCodexSpawnAgentClientNamespace(t, firstClientPayload)
@@ -119,7 +119,7 @@ func TestCodexWebsocketsExecutorRestoresMultiAgentV2NamespaceAcrossIncrementalTu
conflictingRequest := []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"collaboration-optimize","tools":[{"type":"function","name":"spawn_agent","description":"User-defined tool."}]}],"input":[{"type":"message","role":"user","content":"use the user-defined namespace"}]}`)
conflictingClientPayload := execute(conflictingRequest)
conflictingUpstreamPayload := <-capturedPayload
- if namespace := gjson.GetBytes(conflictingUpstreamPayload, "tools.0.name").String(); namespace != "collaboration-optimize" {
+ if namespace := ojson.GetBytes(conflictingUpstreamPayload, "tools.0.name").String(); namespace != "collaboration-optimize" {
t.Fatalf("conflicting upstream namespace = %q, want collaboration-optimize", namespace)
}
if !strings.Contains(string(conflictingClientPayload), `"namespace":"collaboration-optimize"`) {
@@ -138,7 +138,7 @@ func TestCodexWebsocketsExecutorRestoresMultiAgentV2NamespaceAcrossIncrementalTu
fifthClientPayload := execute(codexSpawnAgentTestPayload())
fifthUpstreamPayload := <-capturedPayload
- if namespace := gjson.GetBytes(fifthUpstreamPayload, "input.0.tools.0.name").String(); namespace != "collaboration-optimize" {
+ if namespace := ojson.GetBytes(fifthUpstreamPayload, "input.0.tools.0.name").String(); namespace != "collaboration-optimize" {
t.Fatalf("re-enabled upstream namespace = %q, want collaboration-optimize", namespace)
}
assertCodexSpawnAgentClientNamespace(t, fifthClientPayload)
@@ -186,7 +186,7 @@ func TestCodexWebsocketsExecutorOptimizeMultiAgentV2(t *testing.T) {
return
}
capturedPayload <- payload
- namespace := gjson.GetBytes(payload, "input.0.tools.0.name").String()
+ namespace := ojson.GetBytes(payload, "input.0.tools.0.name").String()
completed := []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[{"type":"function_call","name":"spawn_agent","namespace":%q,"arguments":"{}","call_id":"call_1"}]}}`, namespace))
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
t.Errorf("write websocket response: %v", errWrite)
diff --git a/internal/runtime/executor/codex_websockets_stream.go b/internal/runtime/executor/codex_websockets_stream.go
index 4aa68f321e3..abffde7380b 100644
--- a/internal/runtime/executor/codex_websockets_stream.go
+++ b/internal/runtime/executor/codex_websockets_stream.go
@@ -8,6 +8,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -16,7 +17,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
@@ -24,6 +24,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
if ctx == nil {
ctx = context.Background()
}
+ ctx = helps.WithCodexCommentaryPolicy(ctx, e.cfg)
if opts.Alt == "responses/compact" {
return nil, statusErr{code: http.StatusBadRequest, msg: "streaming not supported for /responses/compact"}
}
@@ -37,6 +38,9 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
if err != nil {
return nil, err
}
+ if prepared.upstreamModel != "" && prepared.upstreamModel != baseModel {
+ reporter.SetUpstreamModel(prepared.upstreamModel)
+ }
from := prepared.from
responseFormat := prepared.responseFormat
to := prepared.to
@@ -483,7 +487,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
break
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "response.incomplete" || eventType == "response.failed" || eventType == "error"
if helps.HasMeaningfulCodexOutputDelta(payload) {
sawOutputDelta = true
@@ -747,7 +751,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
return
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "response.incomplete" || eventType == "response.failed" || eventType == "error"
if helps.HasMeaningfulCodexOutputDelta(payload) {
sawOutputDelta = true
@@ -806,7 +810,7 @@ func (e *CodexWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *clipr
if eventType == "response.completed" || eventType == "response.done" || eventType == "response.incomplete" {
payload = completedPayload
}
- eventType = gjson.GetBytes(payload, "type").String()
+ eventType = ojson.GetBytesNoCopy(payload, "type").String()
isIncomplete := eventType == "response.incomplete"
line := encodeCodexWebsocketAsSSE(payload)
chunks := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, originalPayload, clientBody, line, ¶m, claudeInputTokens)
@@ -843,6 +847,7 @@ type codexWebsocketPrepared struct {
preserveNativeOutput bool
originalPayload []byte
clientBody []byte
+ upstreamModel string
wsURL string
wsHeaders http.Header
useSIWC bool
@@ -880,37 +885,42 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
originalPayload := originalPayloadSource
isCompat := e.resolveCodexModelIsCompat(auth, req, baseModel)
targetModel := resolveResponsesSignatureTargetModel(baseModel, req, opts)
- originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntent(from, to, targetModel, originalPayload, req.Payload, true, isCompat)
+ originalTranslated, body, updatesChanged, err := translateCodexRequestPairWithUpdateIntentContext(ctx, from, to, targetModel, originalPayload, req.Payload, true, isCompat)
if err != nil {
return nil, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
+ doc := ojson.NewDoc(body)
+ normalizeCodexClaudeOutputBudgetDoc(doc, from, req.Payload)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return nil, err
}
- hasExplicitInstructions := gjson.GetBytes(req.Payload, "instructions").Exists() && strings.TrimSpace(gjson.GetBytes(req.Payload, "instructions").String()) != ""
- finalizePayload := helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = ensureWebSearchToolForHistory(body, opts.Headers)
+ instructionsResult := ojson.GetBytesNoCopy(req.Payload, "instructions")
+ hasExplicitInstructions := instructionsResult.Exists() && strings.TrimSpace(instructionsResult.String()) != ""
+ ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, "codex-websockets", baseModel, to.String(), "", originalTranslated, req, opts))
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ ensureWebSearchToolForHistoryDoc(doc, opts.Headers)
if !useSIWC {
- body = normalizeCodexInstructions(body, preserveNativeOutput)
+ normalizeCodexInstructionsDoc(doc, preserveNativeOutput)
if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff {
- body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers)
+ ensureImageGenerationToolDoc(doc, baseModel, auth, opts.Headers)
+ if !codexAuthUsesAPIKey(auth) {
+ normalizeCodexImageGenerationToolsDoc(doc)
+ }
}
}
- body = normalizeCodexWebsocketParallelToolCalls(body, opts.Headers)
- body = helps.NormalizeCodexToolSchemas(body)
- multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflict(body)
- body, optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuth(ctx, opts.Headers, body, e.cfg, auth, isCompat)
- body, replayScope, errReplay := applyCodexReasoningReplayCacheRequired(ctx, from, req, opts, body)
+ normalizeCodexWebsocketParallelToolCallsDoc(doc, opts.Headers)
+ helps.NormalizeCodexToolSchemasDoc(doc)
+ multiAgentV2Conflict := helps.HasCodexMultiAgentV2NamespaceConflictDoc(doc)
+ optimizeMultiAgentV2 := helps.OptimizeCodexMultiAgentV2RequestForAuthDoc(doc, ctx, opts.Headers, e.cfg, auth, isCompat)
+ replayScope, _, errReplay := applyCodexReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
if errReplay != nil {
return nil, errReplay
}
- body = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex websockets executor", body, isCompat, targetModel, baseURL)
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "codex websockets executor", doc, isCompat, targetModel, baseURL)
httpURL := strings.TrimSuffix(baseURL, "/") + "/responses"
wsURL, err := buildCodexResponsesWebsocketURL(httpURL)
@@ -918,18 +928,23 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
return nil, err
}
- body, wsHeaders, errPromptCache := applyCodexPromptCacheHeadersWithContext(ctx, from, req, body, opts.Headers)
+ wsHeaders, _, errPromptCache := applyCodexPromptCacheHeadersWithContextDoc(ctx, from, req, doc, opts.Headers)
if errPromptCache != nil {
return nil, errPromptCache
}
- body = helps.SanitizeCodexInputItemIDs(body)
+ helps.SanitizeCodexInputItemIDsDoc(doc)
if useSIWC {
- body = applyCodexSIWCWebsocketBodyRules(body, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
+ applyCodexSIWCWebsocketBodyRulesDoc(doc, hasExplicitInstructions, cliproxyexecutor.Options{KeepTopLevelEffort: thinking.HasThinkingSuffix(req.Model)})
}
- body = finalizePayload(body)
+ helps.FinalizePayloadDoc(ctx, doc)
+ body = doc.Bytes()
+ upstreamModel := strings.TrimSpace(doc.Get("model").String())
wsHeaders = applyCodexWebsocketHeaders(ctx, wsHeaders, auth, apiKey, e.cfg, preserveNativeOutput, opts.Headers)
- applyCodexRoutingHint(ctx, wsHeaders, auth, baseModel, body, opts.Headers)
- applyModelHeaderOverrides(wsHeaders, baseModel)
+ applyCodexRoutingHint(ctx, wsHeaders, auth, upstreamModel, body, opts.Headers)
+ applyModelHeaderOverrides(wsHeaders, upstreamModel)
+ if errValidate := helps.ValidateCodexHostedImageGenerationDoc(doc, wsHeaders, e.cfg, !useSIWC && !codexAuthUsesAPIKey(auth)); errValidate != nil {
+ return nil, errValidate
+ }
return &codexWebsocketPrepared{
from: from,
@@ -938,6 +953,7 @@ func (e *CodexWebsocketsExecutor) prepareCodexWebsocketStream(ctx context.Contex
preserveNativeOutput: preserveNativeOutput,
originalPayload: originalPayload,
clientBody: body,
+ upstreamModel: upstreamModel,
wsURL: wsURL,
wsHeaders: wsHeaders,
useSIWC: useSIWC,
diff --git a/internal/runtime/executor/devin_apply_patch_diagnostic_test.go b/internal/runtime/executor/devin_apply_patch_diagnostic_test.go
index 07cc989998c..8b671d32167 100644
--- a/internal/runtime/executor/devin_apply_patch_diagnostic_test.go
+++ b/internal/runtime/executor/devin_apply_patch_diagnostic_test.go
@@ -8,12 +8,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -241,7 +241,7 @@ func TestDevinApplyPatchRecoversCompleteBarePatchFromInvalidJSONStr(t *testing.T
if errExecute != nil {
t.Fatalf("Execute failed: %v", errExecute)
}
- outputs := gjson.GetBytes(resp.Payload, "output").Array()
+ outputs := ojson.GetBytes(resp.Payload, "output").Array()
if tc.wantShell {
if len(outputs) != 2 || outputs[0].Get("type").String() != "function_call" || outputs[0].Get("name").String() != "shell" {
t.Fatalf("unexpected mixed output items: %s", resp.Payload)
@@ -273,8 +273,8 @@ func TestDevinApplyPatchRecoversCompleteBarePatchFromInvalidJSONStr(t *testing.T
continue
}
payload := bytes.TrimSpace(line[5:])
- if gjson.GetBytes(payload, "type").String() == "response.custom_tool_call_input.done" {
- doneInputs = append(doneInputs, gjson.GetBytes(payload, "input").String())
+ if ojson.GetBytes(payload, "type").String() == "response.custom_tool_call_input.done" {
+ doneInputs = append(doneInputs, ojson.GetBytes(payload, "input").String())
}
}
}
diff --git a/internal/runtime/executor/devin_effort_directive_test.go b/internal/runtime/executor/devin_effort_directive_test.go
new file mode 100644
index 00000000000..69ad51916de
--- /dev/null
+++ b/internal/runtime/executor/devin_effort_directive_test.go
@@ -0,0 +1,57 @@
+package executor
+
+import (
+ "context"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+func TestDevinExecutorUsesClaudeEffortDirective(t *testing.T) {
+ const model = "devin/claude-opus-5-5"
+ const topLevelOnly = `{"model":"` + model + `","max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},` +
+ `"messages":[{"role":"user","content":"hi"}]}`
+ const withDirectives = `{"model":"` + model + `","max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},` +
+ `"messages":[{"role":"system","content":[],"output_config":{"effort":"medium"}},{"role":"user","content":"first"},` +
+ `{"role":"assistant","content":"ok"},{"role":"system","content":[],"output_config":{"effort":"high"}},{"role":"user","content":"hi"}]}`
+ const disabledWithDirective = `{"model":"` + model + `","max_tokens":1024,"thinking":{"type":"disabled"},` +
+ `"messages":[{"role":"system","content":[],"output_config":{"effort":"high"}},{"role":"user","content":"hi"}]}`
+
+ wantLow := helps.ResolveDevinChatModelUID(model, "low", 0)
+ wantMedium := helps.ResolveDevinChatModelUID(model, "medium", 0)
+ wantNone := helps.ResolveDevinChatModelUID(model, "none", 0)
+ wantHigh := helps.ResolveDevinChatModelUID(model, "high", 0)
+ if wantLow == wantHigh || wantNone == wantHigh || wantMedium == wantHigh {
+ t.Fatalf("fixture model does not separate low %q, medium %q, none %q and high %q", wantLow, wantMedium, wantNone, wantHigh)
+ }
+
+ exec := NewDevinExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{ID: "devin-effort-directive", Provider: "devin", Attributes: map[string]string{"api_key": "test-key"}}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatClaude}
+ for _, tc := range []struct {
+ name string
+ model string
+ payload string
+ want string
+ }{
+ {name: "top-level effort only", model: model, payload: topLevelOnly, want: wantLow},
+ {name: "last directive wins over the top-level placeholder", model: model, payload: withDirectives, want: wantHigh},
+ {name: "model suffix wins over directive", model: model + "(medium)", payload: withDirectives, want: wantMedium},
+ {name: "disabled thinking keeps a historical directive from selecting a variant", model: model, payload: disabledWithDirective, want: wantNone},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := cliproxyexecutor.Request{Model: tc.model, Payload: []byte(tc.payload)}
+ _, chatModelUID, _, err := exec.prepareDevinHTTPRequest(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatalf("prepareDevinHTTPRequest: %v", err)
+ }
+ if chatModelUID != tc.want {
+ t.Fatalf("chat model uid = %q, want %q", chatModelUID, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/devin_executor.go b/internal/runtime/executor/devin_executor.go
index c3258d98b9e..88209c5458d 100644
--- a/internal/runtime/executor/devin_executor.go
+++ b/internal/runtime/executor/devin_executor.go
@@ -3,10 +3,10 @@ package executor
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"net"
"net/http"
@@ -16,6 +16,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
devinauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/devin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -29,8 +30,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// DevinExecutor executes requests against the Codeium/Devin Connect-RPC backend.
@@ -237,6 +236,12 @@ func (e *DevinExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, req
// Execute performs non-streaming execution against the Devin backend.
func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "devin"); errExpand != nil {
+ return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeDevinResponsesCompaction(ctx, auth, req, opts)
+ }
targetModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, targetModel, auth)
@@ -327,6 +332,12 @@ func (e *DevinExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
// ExecuteStream performs streaming execution against the Devin backend.
func (e *DevinExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, "devin"); errExpand != nil {
+ return nil, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeDevinResponsesCompactionStream(ctx, auth, req, opts)
+ }
targetModel := thinking.ParseSuffix(req.Model).ModelName
reporter := helps.NewExecutorUsageReporter(ctx, e, targetModel, auth)
@@ -416,6 +427,13 @@ func (e *DevinExecutor) prepareDevinHTTPRequest(ctx context.Context, auth *clipr
payload = translated.Body
}
systemPrompt, prompts, tools, temp, maxTokens, sessionID, cascadeID, thinkingLevel, budgetTokens := parseInteractionsPayload(payload, opts.OriginalRequest)
+ // Translation drops Claude effort directives and keeps only the top-level
+ // effort, which directive-sending clients pin to a cache placeholder.
+ if opts.SourceFormat == sdktranslator.FormatClaude {
+ if directive := thinking.ClaudeEffortDirective(req.Payload); directive != "" {
+ thinkingLevel = directive
+ }
+ }
// Devin cannot fetch a remote uri or take audio, video or documents. A user turn left
// with nothing else to send is refused here, before any upstream call.
prompts, errTurns := helps.CheckDevinUserTurns(prompts)
@@ -576,7 +594,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
}
trimmed := bytes.TrimSpace(rawJSON)
- eventType := gjson.GetBytes(trimmed, "event_type").String()
+ eventType := ojson.GetBytesNoCopy(trimmed, "event_type").String()
isFailedEvent := eventType == "response.failed" || eventType == "interaction.failed"
// If a failure occurs before any stream content has started, suppress the payload event
@@ -587,9 +605,10 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
if !createdSent && eventType != "interaction.created" {
createdSent = true
- createdEvent, _ := sjson.SetBytes([]byte(`{"event_type":"interaction.created","interaction":{"id":"","model":""}}`), "interaction.id", interactionID)
- createdEvent, _ = sjson.SetBytes(createdEvent, "interaction.model", req.Model)
- if !emitInteractionsEvent(createdEvent) {
+ createdDoc := ojson.NewDoc([]byte(`{"event_type":"interaction.created","interaction":{"id":"","model":""}}`))
+ _ = createdDoc.Set("interaction.id", interactionID)
+ _ = createdDoc.Set("interaction.model", req.Model)
+ if !emitInteractionsEvent(createdDoc.Bytes()) {
return false
}
}
@@ -668,7 +687,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
flushPendingActions := func() bool {
if thoughtStarted {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", thoughtStepIndex)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", thoughtStepIndex)
if !emitInteractionsEvent(stopEvent) {
return false
}
@@ -702,7 +721,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
// so output_item.done and response.completed contain the same item.
deferredThoughtStops = append(deferredThoughtStops, stopIdx)
} else {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stopIdx)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stopIdx)
if !emitInteractionsEvent(stopEvent) {
return false
}
@@ -715,21 +734,22 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
return true
}
if !contentStarted {
- startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"model_output"}}`), "index", stepIndex)
+ startEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"model_output"}}`), "index", stepIndex)
if !emitInteractionsEvent(startEvent) {
return false
}
contentStarted = true
}
- deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"text","text":""}}`), "index", stepIndex)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.text", chunk)
- return emitInteractionsEvent(deltaEvent)
+ deltaDoc := ojson.NewDoc([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"text","text":""}}`))
+ _ = deltaDoc.Set("index", stepIndex)
+ _ = deltaDoc.Set("delta.text", chunk)
+ return emitInteractionsEvent(deltaDoc.Bytes())
}
emitToolCall := func(tc helps.DevinToolCallDelta, diagKey int) bool {
tc.ID = helps.EncodeDevinToolCallID(tc.ID)
if thoughtStarted {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
if !emitInteractionsEvent(stopEvent) {
return false
}
@@ -737,7 +757,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
stepIndex++
}
if contentStarted {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
if !emitInteractionsEvent(stopEvent) {
return false
}
@@ -776,11 +796,12 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
activeCallByID[tc.ID] = slot
}
activeCallSlot = slot
- startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`), "index", sIdx)
- startEvent, _ = sjson.SetBytes(startEvent, "step.name", tc.Name)
- startEvent, _ = sjson.SetBytes(startEvent, "step.id", tc.ID)
- startEvent, _ = sjson.SetBytes(startEvent, "step.call_id", tc.ID)
- if !emitInteractionsEvent(startEvent) {
+ startDoc := ojson.NewDoc([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`))
+ _ = startDoc.Set("index", sIdx)
+ _ = startDoc.Set("step.name", tc.Name)
+ _ = startDoc.Set("step.id", tc.ID)
+ _ = startDoc.Set("step.call_id", tc.ID)
+ if !emitInteractionsEvent(startDoc.Bytes()) {
return false
}
} else {
@@ -796,23 +817,25 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
updated = true
}
if updated {
- updateEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`), "index", slot.stepIndex)
- updateEvent, _ = sjson.SetBytes(updateEvent, "step.name", slot.name)
- updateEvent, _ = sjson.SetBytes(updateEvent, "step.id", slot.id)
- updateEvent, _ = sjson.SetBytes(updateEvent, "step.call_id", slot.id)
- if !emitInteractionsEvent(updateEvent) {
+ updateDoc := ojson.NewDoc([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}}`))
+ _ = updateDoc.Set("index", slot.stepIndex)
+ _ = updateDoc.Set("step.name", slot.name)
+ _ = updateDoc.Set("step.id", slot.id)
+ _ = updateDoc.Set("step.call_id", slot.id)
+ if !emitInteractionsEvent(updateDoc.Bytes()) {
return false
}
}
}
emitSlotArgumentsDelta := func(stepIdx int, arguments string, invalidJSON bool) bool {
- deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", stepIdx)
- deltaEvent, _ = translatorcommon.SetStringWithoutHTMLEscape(deltaEvent, "delta.arguments", arguments)
+ deltaDoc := ojson.NewDoc([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`))
+ _ = deltaDoc.Set("index", stepIdx)
+ _ = setDevinStringWithoutHTMLEscapeDoc(deltaDoc, "delta.arguments", arguments)
if invalidJSON {
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.invalid_json_str", true)
+ _ = deltaDoc.Set("delta.invalid_json_str", true)
}
- return emitInteractionsEvent(deltaEvent)
+ return emitInteractionsEvent(deltaDoc.Bytes())
}
if diagKey >= 0 {
@@ -875,7 +898,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
closeOpenSteps := func() {
for _, index := range deferredThoughtStops {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", index)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", index)
_ = emitInteractionsEvent(stopEvent)
}
deferredThoughtStops = nil
@@ -892,24 +915,25 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
if slot := activeToolSlots[sIdx]; slot != nil && slot.invalidJSON && slot.invalidArgs.Len() > 0 {
rawInvalid := slot.invalidArgs.String()
slot.invalidArgs.Reset()
- deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`), "index", sIdx)
+ deltaDoc := ojson.NewDoc([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":""}}`))
+ _ = deltaDoc.Set("index", sIdx)
if sawEOS && streamErr == nil && ctx.Err() == nil && !slot.hasJSON && slot.name != "" && helps.IsApplyPatchUpstreamTool(originalReq, slot.name) {
if wrapped, ok := helps.RecoverDevinBareApplyPatch(rawInvalid); ok {
patchDiagnostics.MarkRecovered(slot.diagKey)
- deltaEvent, _ = translatorcommon.SetStringWithoutHTMLEscape(deltaEvent, "delta.arguments", wrapped)
+ _ = setDevinStringWithoutHTMLEscapeDoc(deltaDoc, "delta.arguments", wrapped)
} else {
- deltaEvent, _ = translatorcommon.SetStringWithoutHTMLEscape(deltaEvent, "delta.arguments", rawInvalid)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.invalid_json_str", true)
+ _ = setDevinStringWithoutHTMLEscapeDoc(deltaDoc, "delta.arguments", rawInvalid)
+ _ = deltaDoc.Set("delta.invalid_json_str", true)
}
} else {
- deltaEvent, _ = translatorcommon.SetStringWithoutHTMLEscape(deltaEvent, "delta.arguments", rawInvalid)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.invalid_json_str", true)
+ _ = setDevinStringWithoutHTMLEscapeDoc(deltaDoc, "delta.arguments", rawInvalid)
+ _ = deltaDoc.Set("delta.invalid_json_str", true)
}
- if !emitInteractionsEvent(deltaEvent) {
+ if !emitInteractionsEvent(deltaDoc.Bytes()) {
break
}
}
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", sIdx)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", sIdx)
if !emitInteractionsEvent(stopEvent) {
break
}
@@ -919,18 +943,19 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
activeCallSlot = nil
}
if len(postToolBufferedContent) > 0 {
- startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"model_output"}}`), "index", stepIndex)
+ startEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"model_output"}}`), "index", stepIndex)
_ = emitInteractionsEvent(startEvent)
contentStarted = true
for _, chunk := range postToolBufferedContent {
- deltaEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"text","text":""}}`), "index", stepIndex)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.text", chunk)
- _ = emitInteractionsEvent(deltaEvent)
+ deltaDoc := ojson.NewDoc([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"text","text":""}}`))
+ _ = deltaDoc.Set("index", stepIndex)
+ _ = deltaDoc.Set("delta.text", chunk)
+ _ = emitInteractionsEvent(deltaDoc.Bytes())
}
postToolBufferedContent = nil
}
if contentStarted {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
_ = emitInteractionsEvent(stopEvent)
contentStarted = false
}
@@ -965,9 +990,10 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
}
log.Warnf("devin executor: trailer error (%d): %v", code, errTrailer)
helps.RecordAPIResponseError(ctx, e.cfg, errTrailer)
- failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":""}}`), "error.message", errTrailer.Error())
- failedEvent, _ = sjson.SetBytes(failedEvent, "error.code", fmt.Sprintf("%d", code))
- _ = emitInteractionsEvent(failedEvent)
+ failedDoc := ojson.NewDoc([]byte(`{"event_type":"response.failed","error":{"message":"","code":""}}`))
+ _ = failedDoc.Set("error.message", errTrailer.Error())
+ _ = failedDoc.Set("error.code", fmt.Sprintf("%d", code))
+ _ = emitInteractionsEvent(failedDoc.Bytes())
emitStreamError(devinTrailerStatusError(code, errTrailer.Error(), auth))
return
}
@@ -1056,7 +1082,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
chunk := thinkingBuf.Feed([]byte(frameRes.ThinkingText))
if chunk != "" {
if contentStarted {
- stopEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
+ stopEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.stop","index":0}`), "index", stepIndex)
if !emitInteractionsEvent(stopEvent) {
return
}
@@ -1065,17 +1091,17 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
}
if !thoughtStarted {
thoughtStepIndex = stepIndex
- startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"thought"}}`), "index", stepIndex)
+ startEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"thought"}}`), "index", stepIndex)
if !emitInteractionsEvent(startEvent) {
return
}
thoughtStarted = true
}
- deltaEvent := []byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_summary","text":"","content":{"type":"text","text":""}}}`)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "index", thoughtStepIndex)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.text", chunk)
- deltaEvent, _ = sjson.SetBytes(deltaEvent, "delta.content.text", chunk)
- if !emitInteractionsEvent(deltaEvent) {
+ deltaDoc := ojson.NewDoc([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_summary","text":"","content":{"type":"text","text":""}}}`))
+ _ = deltaDoc.Set("index", thoughtStepIndex)
+ _ = deltaDoc.Set("delta.text", chunk)
+ _ = deltaDoc.Set("delta.content.text", chunk)
+ if !emitInteractionsEvent(deltaDoc.Bytes()) {
return
}
}
@@ -1085,7 +1111,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
if len(frameRes.DeltaSignature) > 0 {
if thoughtStepIndex == -1 && !contentStarted {
thoughtStepIndex = stepIndex
- startEvent, _ := sjson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"thought"}}`), "index", stepIndex)
+ startEvent, _ := ojson.SetBytes([]byte(`{"event_type":"step.start","index":0,"step":{"type":"thought"}}`), "index", stepIndex)
if !emitInteractionsEvent(startEvent) {
return
}
@@ -1095,19 +1121,19 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
if targetIdx < 0 {
targetIdx = 0
}
- sigEvent := []byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`)
- sigEvent, _ = sjson.SetBytes(sigEvent, "index", targetIdx)
+ sigDoc := ojson.NewDoc([]byte(`{"event_type":"step.delta","index":0,"delta":{"type":"thought_signature","signature":""}}`))
+ _ = sigDoc.Set("index", targetIdx)
sig := string(frameRes.DeltaSignature)
if responseFormat == sdktranslator.FormatOpenAIResponse {
// The Responses translator accepts complete signatures, not fragments.
responseThoughtSignatures[targetIdx] += sig
sig = responseThoughtSignatures[targetIdx]
}
- sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature", sig)
+ _ = sigDoc.Set("delta.signature", sig)
if frameRes.DeltaSignatureType != "" {
- sigEvent, _ = sjson.SetBytes(sigEvent, "delta.signature_type", frameRes.DeltaSignatureType)
+ _ = sigDoc.Set("delta.signature_type", frameRes.DeltaSignatureType)
}
- if !emitInteractionsEvent(sigEvent) {
+ if !emitInteractionsEvent(sigDoc.Bytes()) {
return
}
}
@@ -1165,7 +1191,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
// If stream encountered an abnormal read error mid-flight, record failure and emit response.failed
if streamErr != nil && ctx.Err() == nil {
helps.RecordAPIResponseError(ctx, e.cfg, streamErr)
- failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":"stream_read_error"}}`), "error.message", streamErr.Error())
+ failedEvent, _ := ojson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"","code":"stream_read_error"}}`), "error.message", streamErr.Error())
_ = emitInteractionsEvent(failedEvent)
emitStreamError(streamErr)
return
@@ -1176,7 +1202,7 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
if !sawEOS && ctx.Err() == nil {
truncErr := fmt.Errorf("devin stream terminated prematurely before EOS trailer")
helps.RecordAPIResponseError(ctx, e.cfg, truncErr)
- failedEvent, _ := sjson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"devin stream terminated prematurely before EOS trailer","code":"stream_truncated"}}`), "error.message", truncErr.Error())
+ failedEvent, _ := ojson.SetBytes([]byte(`{"event_type":"response.failed","error":{"message":"devin stream terminated prematurely before EOS trailer","code":"stream_truncated"}}`), "error.message", truncErr.Error())
_ = emitInteractionsEvent(failedEvent)
emitStreamError(truncErr)
return
@@ -1197,29 +1223,30 @@ func (e *DevinExecutor) streamDevinFramesForAuth(
completionFinishReason = "content_filter"
}
- completedEvent := []byte(`{"event_type":"interaction.completed","interaction":{"id":"","model":"","status":"completed","usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}}`)
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.id", interactionID)
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.model", req.Model)
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.status", completionStatus)
+ completedDoc := ojson.NewDoc([]byte(`{"event_type":"interaction.completed","interaction":{"id":"","model":"","status":"completed","usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}}`))
+ _ = completedDoc.Set("interaction.id", interactionID)
+ _ = completedDoc.Set("interaction.model", req.Model)
+ _ = completedDoc.Set("interaction.status", completionStatus)
if completionFinishReason != "" {
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.finish_reason", completionFinishReason)
+ _ = completedDoc.Set("interaction.finish_reason", completionFinishReason)
}
if finalUsage != nil {
totalInput := finalUsage.PromptTokens + finalUsage.CachedTokens
totalOutput := finalUsage.CompletionTokens
totalTokens := totalInput + totalOutput
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_input_tokens", totalInput)
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_output_tokens", totalOutput)
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_cached_tokens", finalUsage.CachedTokens)
+ _ = completedDoc.Set("interaction.usage.total_input_tokens", totalInput)
+ _ = completedDoc.Set("interaction.usage.total_output_tokens", totalOutput)
+ _ = completedDoc.Set("interaction.usage.total_cached_tokens", finalUsage.CachedTokens)
if finalUsage.CacheWriteTokens > 0 {
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.cache_write_tokens", finalUsage.CacheWriteTokens)
+ _ = completedDoc.Set("interaction.usage.cache_write_tokens", finalUsage.CacheWriteTokens)
}
- completedEvent, _ = sjson.SetBytes(completedEvent, "interaction.usage.total_tokens", totalTokens)
+ _ = completedDoc.Set("interaction.usage.total_tokens", totalTokens)
if reporter != nil && finalUsage.ModelName != "" {
reporter.SetResponseModel(finalUsage.ModelName)
}
}
+ completedEvent := completedDoc.Bytes()
if !emitInteractionsEvent(completedEvent) {
return
}
@@ -1568,56 +1595,56 @@ func consumeDevinFramesToInteractionsForAuth(body io.Reader, model, chatModelUID
completionFinishReason = "content_filter"
}
- out := []byte(`{"id":"","model":"","status":"completed","steps":[],"usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}`)
- out, _ = sjson.SetBytes(out, "id", interactionID)
- out, _ = sjson.SetBytes(out, "model", model)
- out, _ = sjson.SetBytes(out, "status", completionStatus)
+ outDoc := ojson.NewDoc([]byte(`{"id":"","model":"","status":"completed","steps":[],"usage":{"total_input_tokens":0,"total_output_tokens":0,"total_cached_tokens":0}}`))
+ _ = outDoc.Set("id", interactionID)
+ _ = outDoc.Set("model", model)
+ _ = outDoc.Set("status", completionStatus)
if completionFinishReason != "" {
- out, _ = sjson.SetBytes(out, "finish_reason", completionFinishReason)
+ _ = outDoc.Set("finish_reason", completionFinishReason)
}
var steps [][]byte
if len(thinkingParts) > 0 || len(accumulatedSignature) > 0 {
- thoughtStep := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
+ thoughtDoc := ojson.NewDoc([]byte(`{"type":"thought","content":[{"type":"text","text":""}]}`))
if len(thinkingParts) > 0 {
- thoughtStep, _ = sjson.SetBytes(thoughtStep, "content.0.text", strings.Join(thinkingParts, ""))
+ _ = thoughtDoc.Set("content.0.text", strings.Join(thinkingParts, ""))
} else {
- thoughtStep, _ = sjson.DeleteBytes(thoughtStep, "content")
+ _ = thoughtDoc.Delete("content")
}
if len(accumulatedSignature) > 0 {
sigStr := string(accumulatedSignature)
- thoughtStep, _ = sjson.SetBytes(thoughtStep, "signature", sigStr)
- thoughtStep, _ = sjson.SetBytes(thoughtStep, "thought_signature", sigStr)
+ _ = thoughtDoc.Set("signature", sigStr)
+ _ = thoughtDoc.Set("thought_signature", sigStr)
}
- steps = append(steps, thoughtStep)
+ steps = append(steps, thoughtDoc.Bytes())
}
if len(preToolTextParts) > 0 {
modelStep := []byte(`{"type":"model_output","content":[{"type":"text","text":""}]}`)
- modelStep, _ = sjson.SetBytes(modelStep, "content.0.text", strings.Join(preToolTextParts, ""))
+ modelStep, _ = ojson.SetBytes(modelStep, "content.0.text", strings.Join(preToolTextParts, ""))
steps = append(steps, modelStep)
}
for _, tc := range toolCalls {
id := helps.EncodeDevinToolCallID(tc.ID)
- fnStep := []byte(`{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}`)
- fnStep, _ = sjson.SetBytes(fnStep, "name", tc.Name)
- fnStep, _ = sjson.SetBytes(fnStep, "id", id)
- fnStep, _ = sjson.SetBytes(fnStep, "call_id", id)
+ fnDoc := ojson.NewDoc([]byte(`{"type":"function_call","name":"","id":"","call_id":"","arguments":{}}`))
+ _ = fnDoc.Set("name", tc.Name)
+ _ = fnDoc.Set("id", id)
+ _ = fnDoc.Set("call_id", id)
if tc.Arguments != "" {
if json.Valid([]byte(tc.Arguments)) {
- fnStep, _ = sjson.SetRawBytes(fnStep, "arguments", []byte(tc.Arguments))
+ _ = fnDoc.SetRawBytes("arguments", []byte(tc.Arguments))
} else {
- fnStep, _ = translatorcommon.SetStringWithoutHTMLEscape(fnStep, "arguments", tc.Arguments)
+ _ = setDevinStringWithoutHTMLEscapeDoc(fnDoc, "arguments", tc.Arguments)
}
}
- steps = append(steps, fnStep)
+ steps = append(steps, fnDoc.Bytes())
}
if len(postToolTextParts) > 0 {
modelStep := []byte(`{"type":"model_output","content":[{"type":"text","text":""}]}`)
- modelStep, _ = sjson.SetBytes(modelStep, "content.0.text", strings.Join(postToolTextParts, ""))
+ modelStep, _ = ojson.SetBytes(modelStep, "content.0.text", strings.Join(postToolTextParts, ""))
steps = append(steps, modelStep)
}
@@ -1631,7 +1658,7 @@ func consumeDevinFramesToInteractionsForAuth(body io.Reader, model, chatModelUID
stepsRaw.Write(s)
}
stepsRaw.WriteString("]")
- out, _ = sjson.SetRawBytes(out, "steps", []byte(stepsRaw.String()))
+ _ = outDoc.SetRawBytes("steps", []byte(stepsRaw.String()))
}
if finalUsage != nil {
@@ -1639,14 +1666,15 @@ func consumeDevinFramesToInteractionsForAuth(body io.Reader, model, chatModelUID
totalOutput := finalUsage.CompletionTokens
totalTokens := totalInput + totalOutput
- out, _ = sjson.SetBytes(out, "usage.total_input_tokens", totalInput)
- out, _ = sjson.SetBytes(out, "usage.total_output_tokens", totalOutput)
- out, _ = sjson.SetBytes(out, "usage.total_cached_tokens", finalUsage.CachedTokens)
+ _ = outDoc.Set("usage.total_input_tokens", totalInput)
+ _ = outDoc.Set("usage.total_output_tokens", totalOutput)
+ _ = outDoc.Set("usage.total_cached_tokens", finalUsage.CachedTokens)
if finalUsage.CacheWriteTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_write_tokens", finalUsage.CacheWriteTokens)
+ _ = outDoc.Set("usage.cache_write_tokens", finalUsage.CacheWriteTokens)
}
- out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
+ _ = outDoc.Set("usage.total_tokens", totalTokens)
}
+ out := outDoc.Bytes()
respLog := &helps.DevinUpstreamResponseLog{
Status: "completed",
@@ -1675,7 +1703,7 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
thinkingLevel string,
budgetTokens int,
) {
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
// 1. System prompt
systemPrompt = strings.TrimSpace(root.Get("system_instruction").String())
@@ -1698,7 +1726,7 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
budgetTokens = int(genCfg.Get("thinking_config.thinking_budget").Int())
}
if temperature == nil {
- if origRoot := gjson.ParseBytes(originalRequest); origRoot.Get("temperature").Exists() {
+ if origRoot := util.ParseGJSONBytesNoCopy(originalRequest); origRoot.Get("temperature").Exists() {
val := origRoot.Get("temperature").Float()
temperature = &val
} else if root.Get("temperature").Exists() {
@@ -1721,7 +1749,7 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
root.Get("previous_interaction_id").String(),
))
if sessionID == "" && len(originalRequest) > 0 {
- origRoot := gjson.ParseBytes(originalRequest)
+ origRoot := util.ParseGJSONBytesNoCopy(originalRequest)
sessionID = strings.TrimSpace(firstNonEmpty(
origRoot.Get("session_id").String(),
origRoot.Get("sessionId").String(),
@@ -1826,7 +1854,7 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
id := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String())
argsRes := step.Get("arguments")
var args string
- if argsRes.Type == gjson.String {
+ if argsRes.Type == ojson.String {
args = argsRes.String()
} else if argsRes.Exists() {
args = argsRes.Raw
@@ -1899,7 +1927,7 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
if !fnArgs.Exists() {
fnArgs = tcItem.Get("arguments")
}
- if fnArgs.Type == gjson.String {
+ if fnArgs.Type == ojson.String {
tcArgs = fnArgs.String()
} else if fnArgs.Exists() {
tcArgs = fnArgs.Raw
@@ -1952,7 +1980,7 @@ func parseInteractionsPayload(payload, originalRequest []byte) (
// 6. Tools
toolsRes := root.Get("tools")
if toolsRes.IsArray() {
- appendDevinTool := func(t gjson.Result) {
+ appendDevinTool := func(t ojson.Result) {
name := t.Get("name").String()
if name == "" || translatorcommon.IsDevinCodexAppAutomationUpdate("", name) {
return
@@ -2037,7 +2065,7 @@ func mimeExtension(mime string) string {
}
}
-func extractDevinImage(part gjson.Result) (helps.DevinImage, bool) {
+func extractDevinImage(part ojson.Result) (helps.DevinImage, bool) {
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
if partType != "image" && partType != "input_image" && partType != "image_url" {
return helps.DevinImage{}, false
@@ -2084,7 +2112,7 @@ func extractDevinImage(part gjson.Result) (helps.DevinImage, bool) {
const devinEmptyToolResultPlaceholder = "{}"
-func isProtocolWrapperObject(obj gjson.Result, wrapperKey string) bool {
+func isProtocolWrapperObject(obj ojson.Result, wrapperKey string) bool {
if !obj.IsObject() {
return false
}
@@ -2096,7 +2124,7 @@ func isProtocolWrapperObject(obj gjson.Result, wrapperKey string) bool {
// Case 1: Explicit tool_result protocol block (e.g. Claude tool_result)
if strings.EqualFold(strings.TrimSpace(obj.Get("type").String()), "tool_result") {
allAllowed := true
- obj.ForEach(func(k, _ gjson.Result) bool {
+ obj.ForEach(func(k, _ ojson.Result) bool {
key := k.String()
if key == wrapperKey || key == "type" || key == "tool_use_id" || key == "id" || key == "is_error" || key == "cache_control" {
return true
@@ -2110,7 +2138,7 @@ func isProtocolWrapperObject(obj gjson.Result, wrapperKey string) bool {
// Case 2: Pure single-key envelope containing only wrapperKey (and optional cache_control)
allAllowed := true
hasWrapper := false
- obj.ForEach(func(k, _ gjson.Result) bool {
+ obj.ForEach(func(k, _ ojson.Result) bool {
key := k.String()
if key == wrapperKey {
hasWrapper = true
@@ -2125,11 +2153,11 @@ func isProtocolWrapperObject(obj gjson.Result, wrapperKey string) bool {
return hasWrapper && allAllowed
}
-func extractFunctionResultTarget(target gjson.Result) (string, []helps.DevinImage) {
+func extractFunctionResultTarget(target ojson.Result) (string, []helps.DevinImage) {
if !target.Exists() {
return "", nil
}
- if target.Type == gjson.String {
+ if target.Type == ojson.String {
return target.String(), nil
}
if img, ok := extractDevinImage(target); ok {
@@ -2148,7 +2176,7 @@ func extractFunctionResultTarget(target gjson.Result) (string, []helps.DevinImag
itemType := strings.ToLower(strings.TrimSpace(target.Get("type").String()))
if itemType == "text" {
isPureTextPart := true
- target.ForEach(func(k, _ gjson.Result) bool {
+ target.ForEach(func(k, _ ojson.Result) bool {
key := k.String()
if key != "type" && key != "text" && key != "cache_control" {
isPureTextPart = false
@@ -2210,7 +2238,7 @@ func extractFunctionResultTarget(target gjson.Result) (string, []helps.DevinImag
itemType := strings.ToLower(strings.TrimSpace(item.Get("type").String()))
if itemType == "text" {
isPureTextPart := true
- item.ForEach(func(k, _ gjson.Result) bool {
+ item.ForEach(func(k, _ ojson.Result) bool {
key := k.String()
if key != "type" && key != "text" && key != "cache_control" {
isPureTextPart = false
@@ -2248,7 +2276,7 @@ func extractFunctionResultTarget(target gjson.Result) (string, []helps.DevinImag
return target.Raw, nil
}
-func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage) {
+func extractFunctionResultContent(step ojson.Result) (string, []helps.DevinImage) {
target := step.Get("result")
if !target.Exists() {
target = step.Get("output")
@@ -2286,7 +2314,7 @@ func extractFunctionResultContent(step gjson.Result) (string, []helps.DevinImage
// devinUnsendableMediaType names the type of a media part that Devin cannot send, or
// returns "" for text and for parts that are not media. Devin only takes inline images,
// so a remote uri or any audio, video or document part is unsendable.
-func devinUnsendableMediaType(part gjson.Result) string {
+func devinUnsendableMediaType(part ojson.Result) string {
if part.Get("text").String() != "" {
return ""
}
@@ -2299,13 +2327,13 @@ func devinUnsendableMediaType(part gjson.Result) string {
// extractInteractionsStepContent returns the text and inline images of a user step. The
// third result is the type of the first media part Devin could not send, or "".
-func extractInteractionsStepContent(step gjson.Result) (string, []helps.DevinImage, string) {
+func extractInteractionsStepContent(step ojson.Result) (string, []helps.DevinImage, string) {
content := step.Get("content")
var textParts []string
var images []helps.DevinImage
droppedPart := ""
- extractFromPart := func(p gjson.Result) {
+ extractFromPart := func(p ojson.Result) {
if img, ok := extractDevinImage(p); ok {
images = append(images, img)
return
@@ -2318,7 +2346,7 @@ func extractInteractionsStepContent(step gjson.Result) (string, []helps.DevinIma
}
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
textParts = append(textParts, content.String())
} else if content.IsArray() {
for _, p := range content.Array() {
@@ -2347,9 +2375,9 @@ func extractInteractionsStepContent(step gjson.Result) (string, []helps.DevinIma
return text, images, droppedPart
}
-func extractInteractionsStepText(step gjson.Result) string {
+func extractInteractionsStepText(step ojson.Result) string {
content := step.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String()
}
if content.IsArray() {
@@ -2365,7 +2393,7 @@ func extractInteractionsStepText(step gjson.Result) string {
}
func supplementImagesFromOriginal(original []byte, prompts []helps.DevinPrompt) {
- origRoot := gjson.ParseBytes(original)
+ origRoot := util.ParseGJSONBytesNoCopy(original)
messages := origRoot.Get("messages")
if !messages.IsArray() {
return
@@ -2608,7 +2636,7 @@ type originalAssistantMeta struct {
}
func supplementSignaturesFromOriginal(original []byte, prompts []helps.DevinPrompt) {
- origRoot := gjson.ParseBytes(original)
+ origRoot := util.ParseGJSONBytesNoCopy(original)
messages := origRoot.Get("messages")
if !messages.IsArray() {
return
@@ -2868,5 +2896,19 @@ func firstNonEmpty(values ...string) string {
return ""
}
+func setDevinStringWithoutHTMLEscapeDoc(doc *ojson.Doc, path, value string) error {
+ if doc == nil {
+ return nil
+ }
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ if errEncode := enc.Encode(value); errEncode != nil {
+ return doc.Set(path, value)
+ }
+ raw := bytes.TrimRight(buf.Bytes(), "\n")
+ return doc.SetRawBytes(path, raw)
+}
+
// SupportsApplyPatch reports the actual executor contract, independent of its provider name.
func (e *DevinExecutor) SupportsApplyPatch() bool { return e != nil }
diff --git a/internal/runtime/executor/devin_executor_compaction.go b/internal/runtime/executor/devin_executor_compaction.go
new file mode 100644
index 00000000000..f1a3c43f961
--- /dev/null
+++ b/internal/runtime/executor/devin_executor_compaction.go
@@ -0,0 +1,72 @@
+package executor
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// executeDevinResponsesCompaction handles Codex remote compaction v2 for the
+// native Devin route. Devin has no native compaction endpoint, so the summary
+// is generated with the same model through the normal Execute path and sealed
+// into a CPA compaction capsule, mirroring the Kimi/Meta/OpenAI-compat routes.
+func (e *DevinExecutor) executeDevinResponsesCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ var resp cliproxyexecutor.Response
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryPayload := helps.PrepareAntigravityCompactionSummaryPayload(helps.ResponsesCompactionSourcePayload(req, opts), baseModel)
+ summaryReq := cliproxyexecutor.Request{
+ Model: req.Model,
+ Payload: summaryPayload,
+ Metadata: req.Metadata,
+ }
+ summaryOpts := opts
+ summaryOpts.Alt = ""
+ summaryOpts.Stream = false
+ summaryOpts.OriginalRequest = nil
+ summaryOpts.SourceFormat = sdktranslator.FormatOpenAIResponse
+ summaryOpts.ResponseFormat = sdktranslator.FormatOpenAIResponse
+
+ summaryResp, errSummary := e.Execute(ctx, auth, summaryReq, summaryOpts)
+ if errSummary != nil {
+ return resp, errSummary
+ }
+ respPayload, errBuild := helps.BuildResponsesCompactionResponseFromSummary(baseModel, summaryResp.Payload)
+ if errBuild != nil {
+ return resp, statusErr{code: http.StatusBadGateway, msg: errBuild.Error()}
+ }
+ return cliproxyexecutor.Response{
+ Payload: respPayload,
+ Headers: summaryResp.Headers,
+ }, nil
+}
+
+// executeDevinResponsesCompactionStream emits the sealed compaction response
+// as the Responses SSE event sequence Codex expects for remote compaction v2.
+func (e *DevinExecutor) executeDevinResponsesCompactionStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryResp, errSummary := e.executeDevinResponsesCompaction(ctx, auth, req, opts)
+ if errSummary != nil {
+ return nil, errSummary
+ }
+ chunks, errChunks := helps.BuildResponsesCompactionStreamChunksFromResponse(baseModel, summaryResp.Payload)
+ if errChunks != nil {
+ return nil, statusErr{code: http.StatusBadGateway, msg: errChunks.Error()}
+ }
+ out := make(chan cliproxyexecutor.StreamChunk, len(chunks))
+ for _, chunk := range chunks {
+ out <- cliproxyexecutor.StreamChunk{Payload: chunk}
+ }
+ close(out)
+
+ headers := summaryResp.Headers.Clone()
+ if headers == nil {
+ headers = make(http.Header)
+ }
+ headers.Set("Content-Type", "text/event-stream")
+ return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil
+}
diff --git a/internal/runtime/executor/devin_executor_compaction_test.go b/internal/runtime/executor/devin_executor_compaction_test.go
new file mode 100644
index 00000000000..a2c4408a726
--- /dev/null
+++ b/internal/runtime/executor/devin_executor_compaction_test.go
@@ -0,0 +1,429 @@
+package executor
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+ "google.golang.org/protobuf/encoding/protowire"
+)
+
+const devinCompactionSummaryMarker = "DEVIN_COMPACTION_SUMMARY_42"
+
+// devinCompactionTextFrames builds Devin Connect-RPC frames carrying one
+// assistant text message and a usage frame, then the end-stream trailer.
+func devinCompactionTextFrames(text string) []byte {
+ var textFrame []byte
+ textFrame = protowire.AppendTag(textFrame, 3, protowire.BytesType)
+ textFrame = protowire.AppendString(textFrame, text)
+
+ var usageInner []byte
+ usageInner = protowire.AppendTag(usageInner, 2, protowire.VarintType)
+ usageInner = protowire.AppendVarint(usageInner, 12)
+ usageInner = protowire.AppendTag(usageInner, 3, protowire.VarintType)
+ usageInner = protowire.AppendVarint(usageInner, 6)
+
+ var usageFrame []byte
+ usageFrame = protowire.AppendTag(usageFrame, 7, protowire.BytesType)
+ usageFrame = protowire.AppendBytes(usageFrame, usageInner)
+
+ var buf bytes.Buffer
+ buf.Write(helps.WrapConnectEnvelope(textFrame))
+ buf.Write(helps.WrapConnectEnvelope(usageFrame))
+ buf.Write(helps.WrapConnectEnvelopeWithFlag(helps.ConnectFlagEndStream, []byte(`{}`)))
+ return buf.Bytes()
+}
+
+// devinCompactionContext returns a context whose Devin HTTP client is served
+// by the given frames, recording every upstream request body.
+func devinCompactionContext(frames []byte, seenBodies *[][]byte) context.Context {
+ mockRT := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ body, errRead := io.ReadAll(req.Body)
+ if errRead != nil {
+ return nil, errRead
+ }
+ *seenBodies = append(*seenBodies, body)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/connect+proto"}},
+ Body: io.NopCloser(bytes.NewReader(frames)),
+ }, nil
+ })
+ return context.WithValue(context.Background(), "cliproxy.roundtripper", mockRT)
+}
+
+// devinCompactionWireField returns the first length-delimited field value.
+func devinCompactionWireField(data []byte, target protowire.Number) []byte {
+ for len(data) > 0 {
+ number, kind, n := protowire.ConsumeTag(data)
+ if n < 0 {
+ return nil
+ }
+ data = data[n:]
+ if kind == protowire.BytesType {
+ value, m := protowire.ConsumeBytes(data)
+ if m < 0 {
+ return nil
+ }
+ if number == target {
+ return value
+ }
+ data = data[m:]
+ } else {
+ m := protowire.ConsumeFieldValue(number, kind, data)
+ if m < 0 {
+ return nil
+ }
+ data = data[m:]
+ }
+ }
+ return nil
+}
+
+// devinCompactionWireVarint returns the first varint field value, or 0.
+func devinCompactionWireVarint(data []byte, target protowire.Number) uint64 {
+ for len(data) > 0 {
+ number, kind, n := protowire.ConsumeTag(data)
+ if n < 0 {
+ return 0
+ }
+ data = data[n:]
+ if kind == protowire.VarintType {
+ value, m := protowire.ConsumeVarint(data)
+ if m < 0 {
+ return 0
+ }
+ if number == target {
+ return value
+ }
+ data = data[m:]
+ } else {
+ m := protowire.ConsumeFieldValue(number, kind, data)
+ if m < 0 {
+ return 0
+ }
+ data = data[m:]
+ }
+ }
+ return 0
+}
+
+// devinCompactionAssertSummaryRequest checks one captured Devin summary
+// request: no trigger item, original history and tool call/result preserved,
+// and user payload rules applied as the final semantic barrier.
+func devinCompactionAssertSummaryRequest(t *testing.T, body []byte, label string) {
+ t.Helper()
+ if bytes.Contains(body, []byte("compaction_trigger")) {
+ t.Fatalf("compaction_trigger reached Devin %s summary request", label)
+ }
+ if !bytes.Contains(body, []byte("summary of the preceding conversation")) {
+ t.Fatalf("expected summary prompt in Devin %s summary request", label)
+ }
+ if !bytes.Contains(body, []byte("Remember the exact test marker")) {
+ t.Fatalf("expected original user history in Devin %s summary request", label)
+ }
+ if !bytes.Contains(body, []byte("DEVIN_TOOL_CALL_42")) {
+ t.Fatalf("expected tool call history in Devin %s summary request", label)
+ }
+ if !bytes.Contains(body, []byte("DEVIN_TOOL_RESULT_42")) {
+ t.Fatalf("expected tool result history in Devin %s summary request", label)
+ }
+ if len(body) < 5 {
+ t.Fatalf("missing Connect envelope in Devin %s summary request", label)
+ }
+ completionConfig := devinCompactionWireField(body[5:], 8)
+ maxTokens := devinCompactionWireVarint(completionConfig, 2)
+ if maxTokens != 4321 {
+ t.Fatalf("%s summary request completion_config.max_tokens = %d, want payload rule 4321", label, maxTokens)
+ }
+}
+
+func devinCompactionCompleted(t *testing.T, sse string) ojson.Result {
+ t.Helper()
+ for _, line := range strings.Split(sse, "\n") {
+ line = strings.TrimSpace(line)
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
+ if data == "[DONE]" {
+ continue
+ }
+ ev := ojson.Parse(data)
+ if ev.Get("type").String() == "response.completed" {
+ return ev
+ }
+ }
+ t.Fatalf("no response.completed event in stream output: %s", sse)
+ return ojson.Result{}
+}
+
+func TestDevinExecutor_ResponsesCompactionTriggerAndReplay_Issue7291(t *testing.T) {
+ var seenBodies [][]byte
+ ctx := devinCompactionContext(devinCompactionTextFrames("Summary retaining "+devinCompactionSummaryMarker+"."), &seenBodies)
+
+ executor := NewDevinExecutor(&config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "devin/swe-2"}},
+ Params: map[string]any{
+ "completion_config.max_tokens": 4321,
+ },
+ }},
+ },
+ })
+ auth := &cliproxyauth.Auth{
+ ID: "devin-compaction",
+ Provider: "devin",
+ Attributes: map[string]string{
+ "api_key": "test-devin-key",
+ },
+ }
+
+ triggerPayload := []byte(`{
+ "model": "devin/swe-2",
+ "stream": true,
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "Remember the exact test marker DEVIN_COMPACTION_SUMMARY_42."}]
+ },
+ {"type": "function_call", "call_id": "call_7291", "name": "exec_command", "arguments": "{\"cmd\":\"echo DEVIN_TOOL_CALL_42\"}"},
+ {"type": "function_call_output", "call_id": "call_7291", "output": "DEVIN_TOOL_RESULT_42"},
+ {"type": "compaction_trigger"}
+ ]
+ }`)
+
+ streamResult, errStream := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "devin/swe-2",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(compaction_trigger) error = %v", errStream)
+ }
+
+ var streamBuf bytes.Buffer
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ streamBuf.Write(chunk.Payload)
+ }
+
+ completed := devinCompactionCompleted(t, streamBuf.String())
+ outputItems := completed.Get("response.output").Array()
+ if len(outputItems) != 1 || outputItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in stream response, got: %s", completed.Raw)
+ }
+ capsule := outputItems[0].Get("encrypted_content").String()
+ if !strings.HasPrefix(capsule, "cpa-ag-compact-v1:") {
+ t.Fatalf("expected CPA compaction capsule prefix, got %q", capsule)
+ }
+ itemID := outputItems[0].Get("id").String()
+
+ // The streaming summary request must satisfy the summary invariants. It is
+ // inspected now, before any later call appends more captured bodies.
+ if len(seenBodies) != 1 {
+ t.Fatalf("expected exactly one upstream request after streaming compaction, got %d", len(seenBodies))
+ }
+ devinCompactionAssertSummaryRequest(t, seenBodies[0], "streaming")
+
+ nonStreamResp, errExec := executor.Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "devin/swe-2",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute(compaction_trigger) error = %v", errExec)
+ }
+ nonStreamItems := ojson.GetBytes(nonStreamResp.Payload, "output").Array()
+ if len(nonStreamItems) != 1 || nonStreamItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in non-stream response, got: %s", nonStreamResp.Payload)
+ }
+
+ // The non-streaming summary request must satisfy the same invariants. Each
+ // executor call appends exactly one captured upstream body.
+ if len(seenBodies) != 2 {
+ t.Fatalf("expected exactly two upstream requests after non-streaming compaction, got %d", len(seenBodies))
+ }
+ devinCompactionAssertSummaryRequest(t, seenBodies[1], "non-streaming")
+
+ // Replay: the CPA capsule must expand into summary context, and a foreign
+ // compaction item must be dropped before reaching the Devin upstream.
+ replayPayload := []byte(fmt.Sprintf(`{
+ "model": "devin/swe-2",
+ "stream": true,
+ "input": [
+ {"type": "compaction", "encrypted_content": "foreign-opaque-token"},
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "What marker did I ask you to remember?"}]
+ }
+ ]
+ }`, itemID, capsule))
+
+ replayStream, errReplay := executor.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
+ Model: "devin/swe-2",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: replayPayload,
+ })
+ if errReplay != nil {
+ t.Fatalf("ExecuteStream(replay compaction) error = %v", errReplay)
+ }
+ for chunk := range replayStream.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("replay stream chunk error = %v", chunk.Err)
+ }
+ }
+
+ replaySeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(replaySeen, []byte(devinCompactionSummaryMarker)) {
+ t.Fatalf("expected expanded compaction summary in Devin upstream request")
+ }
+ if bytes.Contains(replaySeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("expected foreign compaction item to be dropped")
+ }
+ if bytes.Contains(replaySeen, []byte("cpa-ag-compact-v1:")) {
+ t.Fatalf("expected CPA compaction capsule to be expanded, not forwarded raw")
+ }
+
+ // Repeated compaction: a replayed capsule followed by another trigger must
+ // expand the first capsule into the second summary request and again emit
+ // exactly one compaction item.
+ secondTriggerPayload := []byte(fmt.Sprintf(`{
+ "model": "devin/swe-2",
+ "input": [
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "One more turn before compacting again."}]
+ },
+ {"type": "compaction_trigger"}
+ ]
+ }`, itemID, capsule))
+
+ secondResp, errSecond := executor.Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "devin/swe-2",
+ Payload: secondTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: secondTriggerPayload,
+ })
+ if errSecond != nil {
+ t.Fatalf("Execute(second compaction_trigger) error = %v", errSecond)
+ }
+ secondItems := ojson.GetBytes(secondResp.Payload, "output").Array()
+ if len(secondItems) != 1 || secondItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in second compaction response, got: %s", secondResp.Payload)
+ }
+ secondCapsule := secondItems[0].Get("encrypted_content").String()
+ if !strings.HasPrefix(secondCapsule, "cpa-ag-compact-v1:") {
+ t.Fatalf("expected CPA compaction capsule prefix in second compaction, got %q", secondCapsule)
+ }
+ secondItemID := secondItems[0].Get("id").String()
+
+ // Inspect the second summary request itself (the last upstream call so
+ // far): it must carry the first capsule's expanded summary plus the newly
+ // added turn and the summarization prompt, without the trigger or the raw
+ // capsule.
+ secondSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(secondSeen, []byte(devinCompactionSummaryMarker)) {
+ t.Fatalf("expected first capsule summary in second compaction request")
+ }
+ if !bytes.Contains(secondSeen, []byte("One more turn before compacting again")) {
+ t.Fatalf("expected newly added turn in second compaction request")
+ }
+ if !bytes.Contains(secondSeen, []byte("summary of the preceding conversation")) {
+ t.Fatalf("expected summary prompt in second compaction request")
+ }
+ if bytes.Contains(secondSeen, []byte("compaction_trigger")) {
+ t.Fatalf("second compaction_trigger reached Devin upstream request")
+ }
+ if bytes.Contains(secondSeen, []byte("cpa-ag-compact-v1:")) {
+ t.Fatalf("expected first CPA compaction capsule to be expanded in second compaction request, not forwarded raw")
+ }
+
+ // The capsule minted by repeated compaction must itself replay into
+ // summary context on a later ordinary request.
+ secondReplayPayload := []byte(fmt.Sprintf(`{
+ "model": "devin/swe-2",
+ "input": [
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "Continue after the second compaction."}]
+ }
+ ]
+ }`, secondItemID, secondCapsule))
+ if _, errSecondReplay := executor.Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "devin/swe-2",
+ Payload: secondReplayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: secondReplayPayload,
+ }); errSecondReplay != nil {
+ t.Fatalf("Execute(second capsule replay) error = %v", errSecondReplay)
+ }
+ secondReplaySeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(secondReplaySeen, []byte(devinCompactionSummaryMarker)) {
+ t.Fatalf("expected second capsule summary expanded in Devin upstream request")
+ }
+ if bytes.Contains(secondReplaySeen, []byte("cpa-ag-compact-v1:")) {
+ t.Fatalf("expected second CPA compaction capsule to be expanded, not forwarded raw")
+ }
+
+ // OriginalRequest-only trigger: the pipeline may deliver a translated
+ // payload without the trigger while the original Responses request still
+ // carries it; compaction must still be synthesized.
+ origOnlyResp, errOrigOnly := executor.Execute(ctx, auth, cliproxyexecutor.Request{
+ Model: "devin/swe-2",
+ Payload: []byte(`{"model":"devin/swe-2"}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errOrigOnly != nil {
+ t.Fatalf("Execute(OriginalRequest-only trigger) error = %v", errOrigOnly)
+ }
+ origOnlyItems := ojson.GetBytes(origOnlyResp.Payload, "output").Array()
+ if len(origOnlyItems) != 1 || origOnlyItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item for OriginalRequest-only trigger, got: %s", origOnlyResp.Payload)
+ }
+ origOnlySeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(origOnlySeen, []byte("Remember the exact test marker")) {
+ t.Fatalf("expected OriginalRequest history in Devin summary request")
+ }
+ if !bytes.Contains(origOnlySeen, []byte("DEVIN_TOOL_CALL_42")) || !bytes.Contains(origOnlySeen, []byte("DEVIN_TOOL_RESULT_42")) {
+ t.Fatalf("expected OriginalRequest tool history in Devin summary request")
+ }
+}
diff --git a/internal/runtime/executor/devin_executor_test.go b/internal/runtime/executor/devin_executor_test.go
index 8a3d99f6287..2364a790ea0 100644
--- a/internal/runtime/executor/devin_executor_test.go
+++ b/internal/runtime/executor/devin_executor_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
devinauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/devin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -23,7 +24,6 @@ import (
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -433,7 +433,7 @@ func TestConsumeDevinFramesToInteractions(t *testing.T) {
t.Errorf("FramesCount = %d, want 3", respLog.FramesCount)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
if root.Get("status").String() != "completed" {
t.Errorf("status = %q, want completed", root.Get("status").String())
}
@@ -825,7 +825,7 @@ func TestConsumeDevinFramesToInteractions_MultiToolCallsNoPanic(t *testing.T) {
t.Fatal("expected non-nil respLog")
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
steps := root.Get("steps").Array()
if len(steps) != 2 {
t.Fatalf("expected 2 function_call steps, got %d", len(steps))
@@ -886,7 +886,7 @@ func TestStreamDevinFrames_InterleavedThinkingAndContent(t *testing.T) {
)
}()
- var events []gjson.Result
+ var events []ojson.Result
for chunk := range out {
if chunk.Err != nil {
t.Fatalf("unexpected chunk error: %v", chunk.Err)
@@ -895,7 +895,7 @@ func TestStreamDevinFrames_InterleavedThinkingAndContent(t *testing.T) {
if strings.HasPrefix(raw, "data: ") && !strings.Contains(raw, "[DONE]") {
data := strings.TrimPrefix(raw, "data: ")
data = strings.TrimSpace(data)
- events = append(events, gjson.Parse(data))
+ events = append(events, ojson.Parse(data))
}
}
@@ -1003,14 +1003,14 @@ func TestStreamDevinFrames_SequentialToolCallsSameIndexDifferentID(t *testing.T)
chunks = append(chunks, chunk)
}
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range chunks {
lines := strings.Split(string(chunk.Payload), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
if strings.TrimSpace(data) != "[DONE]" {
- events = append(events, gjson.Parse(data))
+ events = append(events, ojson.Parse(data))
}
}
}
@@ -1102,7 +1102,7 @@ func TestConsumeDevinFramesToInteractions_SequentialToolCallsSameIndexDifferentI
t.Errorf("tool call 1 = %+v, want bash_1/bash", respLog.ToolCalls[1])
}
- steps := gjson.GetBytes(interactionsJSON, "steps").Array()
+ steps := ojson.GetBytes(interactionsJSON, "steps").Array()
if len(steps) != 2 {
t.Fatalf("expected 2 steps in interactions JSON, got %d", len(steps))
}
@@ -1298,7 +1298,7 @@ func TestStreamDevinFrames_StopReasonMaxTokens(t *testing.T) {
)
}()
- var completedEvent gjson.Result
+ var completedEvent ojson.Result
for chunk := range out {
if chunk.Err != nil {
t.Fatalf("unexpected chunk error: %v", chunk.Err)
@@ -1307,7 +1307,7 @@ func TestStreamDevinFrames_StopReasonMaxTokens(t *testing.T) {
if strings.HasPrefix(raw, "data: ") && !strings.Contains(raw, "[DONE]") {
data := strings.TrimPrefix(raw, "data: ")
data = strings.TrimSpace(data)
- parsed := gjson.Parse(data)
+ parsed := ojson.Parse(data)
if parsed.Get("event_type").String() == "interaction.completed" {
completedEvent = parsed
}
@@ -1342,7 +1342,7 @@ func TestConsumeDevinFramesToInteractions_StopReasonMaxTokens(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
- parsed := gjson.ParseBytes(out)
+ parsed := ojson.ParseBytes(out)
if got := parsed.Get("status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete. Output: %s", got, string(out))
}
@@ -1383,7 +1383,7 @@ func TestStreamDevinFrames_StopReasonContentFilter(t *testing.T) {
)
}()
- var completedEvent gjson.Result
+ var completedEvent ojson.Result
for chunk := range out {
if chunk.Err != nil {
t.Fatalf("unexpected chunk error: %v", chunk.Err)
@@ -1392,7 +1392,7 @@ func TestStreamDevinFrames_StopReasonContentFilter(t *testing.T) {
if strings.HasPrefix(raw, "data: ") && !strings.Contains(raw, "[DONE]") {
data := strings.TrimPrefix(raw, "data: ")
data = strings.TrimSpace(data)
- parsed := gjson.Parse(data)
+ parsed := ojson.Parse(data)
if parsed.Get("event_type").String() == "interaction.completed" {
completedEvent = parsed
}
@@ -1427,7 +1427,7 @@ func TestConsumeDevinFramesToInteractions_StopReasonContentFilter(t *testing.T)
t.Fatalf("unexpected error: %v", err)
}
- parsed := gjson.ParseBytes(out)
+ parsed := ojson.ParseBytes(out)
if got := parsed.Get("status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete. Output: %s", got, string(out))
}
@@ -1750,7 +1750,7 @@ func TestStreamDevinFrames_LateThinkingSignaturesToClaudeStreaming(t *testing.T)
if data == "" || data == "[DONE]" {
continue
}
- parsed := gjson.Parse(data)
+ parsed := ojson.Parse(data)
eventType := parsed.Get("type").String()
switch eventType {
case "content_block_start":
@@ -1953,7 +1953,7 @@ func TestStreamDevinFrames_LateThinkingSignatures_TrailerErrorClosesBlocks(t *te
if data == "" || data == "[DONE]" {
continue
}
- parsed := gjson.Parse(data)
+ parsed := ojson.Parse(data)
eventType := parsed.Get("type").String()
if eventType == "content_block_start" {
idx := int(parsed.Get("index").Int())
@@ -2154,6 +2154,56 @@ func TestDevinExecutor_ResponsesToolsFilterAndObfuscate(t *testing.T) {
}
}
+func TestDevinExecutor_PrepareDevinHTTPRequest_ObfuscatesCodeModeExecDescription(t *testing.T) {
+ responsesPayload := []byte(`{
+ "model": "devin/claude-opus-5-5",
+ "tools": [
+ {
+ "type": "custom",
+ "name": "exec",
+ "description": "Run JavaScript code to orchestrate/compose tool calls\n### exec_command\nRuns a command in a PTY, returning output or a session ID for ongoing interaction.\n### write_stdin\nWrites characters to an existing unified exec session and returns recent output.\n"
+ }
+ ],
+ "input": "Reply with exactly: OK"
+ }`)
+
+ exec := NewDevinExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Attributes: map[string]string{
+ "api_key": "test-session-token",
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "devin/claude-opus-5-5",
+ Payload: responsesPayload,
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: responsesPayload,
+ }
+
+ httpReq, _, logBody, errPrep := exec.prepareDevinHTTPRequest(context.Background(), auth, req, opts)
+ if errPrep != nil {
+ t.Fatalf("prepareDevinHTTPRequest failed: %v", errPrep)
+ }
+ bodyBytes, errRead := io.ReadAll(httpReq.Body)
+ if errRead != nil {
+ t.Fatalf("read request body: %v", errRead)
+ }
+ flag, wireBytes, errUnwrap := helps.ReadConnectFrame(bytes.NewReader(bodyBytes))
+ if errUnwrap != nil || flag != 0 {
+ t.Fatalf("unwrap connect frame: flag=%d err=%v", flag, errUnwrap)
+ }
+ for label, raw := range map[string]string{"wire": string(wireBytes), "log": string(logBody)} {
+ if strings.Contains(raw, "a session ID") || strings.Contains(raw, "to an existing unified") {
+ t.Fatalf("%s still contains unsanitized exec_command/write_stdin phrase: %s", label, raw)
+ }
+ if !strings.Contains(raw, "an session ID") || !strings.Contains(raw, "to a existing unified") {
+ t.Fatalf("%s missing obfuscated exec_command/write_stdin phrase: %s", label, raw)
+ }
+ }
+}
+
func TestDevinExecutor_ToolResultImagesInInteractionsPayload(t *testing.T) {
interactionsJSON := []byte(`{
"model": "devin/swe-2",
@@ -2919,7 +2969,7 @@ func TestRegressionIssue5910_CustomToolCallInvalidJSONStr(t *testing.T) {
t.Errorf("expected Arguments %q from invalid_json_str, got %q", "ls -la", respLog.ToolCalls[0].Arguments)
}
- steps := gjson.GetBytes(interactionsJSON, "steps").Array()
+ steps := ojson.GetBytes(interactionsJSON, "steps").Array()
if len(steps) != 1 {
t.Fatalf("expected 1 step in interactions JSON, got %d", len(steps))
}
@@ -2964,8 +3014,8 @@ func TestRegressionIssue5910_CustomToolCallInvalidJSONStr(t *testing.T) {
for _, line := range lines {
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
- if gjson.Get(data, "event_type").String() == "step.delta" && gjson.Get(data, "delta.type").String() == "arguments_delta" {
- streamArgs.WriteString(gjson.Get(data, "delta.arguments").String())
+ if ojson.Get(data, "event_type").String() == "step.delta" && ojson.Get(data, "delta.type").String() == "arguments_delta" {
+ streamArgs.WriteString(ojson.Get(data, "delta.arguments").String())
}
}
}
@@ -3048,14 +3098,14 @@ func TestRegressionIssue5910_StreamInterleavedToolCallsByCallID(t *testing.T) {
chunks = append(chunks, chunk)
}
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range chunks {
lines := strings.Split(string(chunk.Payload), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "data: ") {
data := strings.TrimPrefix(line, "data: ")
if strings.TrimSpace(data) != "[DONE]" {
- events = append(events, gjson.Parse(data))
+ events = append(events, ojson.Parse(data))
}
}
}
@@ -3145,7 +3195,7 @@ func TestRegressionIssue5910_UsageStatsCacheWriteTokensInResponses(t *testing.T)
t.Errorf("respLog.Usage.CacheWriteTokens = %d, want 14361", respLog.Usage.CacheWriteTokens)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
if root.Get("usage.total_input_tokens").Int() != 53 {
t.Errorf("usage.total_input_tokens = %d, want 53", root.Get("usage.total_input_tokens").Int())
}
@@ -3194,7 +3244,7 @@ func TestRegressionIssue5910_UsageStatsCacheWriteTokensInResponses(t *testing.T)
for _, line := range lines {
if strings.HasPrefix(line, "data: ") {
data := []byte(strings.TrimPrefix(line, "data: "))
- if gjson.GetBytes(data, "event_type").String() == "interaction.completed" {
+ if ojson.GetBytes(data, "event_type").String() == "interaction.completed" {
completedEvent = data
}
}
@@ -3204,7 +3254,7 @@ func TestRegressionIssue5910_UsageStatsCacheWriteTokensInResponses(t *testing.T)
if len(completedEvent) == 0 {
t.Fatal("expected interaction.completed event in stream")
}
- cRoot := gjson.ParseBytes(completedEvent)
+ cRoot := ojson.ParseBytes(completedEvent)
if cRoot.Get("interaction.usage.cache_write_tokens").Int() != 14361 {
t.Errorf("interaction.usage.cache_write_tokens = %d, want 14361", cRoot.Get("interaction.usage.cache_write_tokens").Int())
}
@@ -3908,7 +3958,7 @@ func TestRegressionIssue5951_DevinToolActivityOrderedBeforeAssistantMessage(t *t
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.output_item.done" {
doneItemTypes = append(doneItemTypes, ev.Get("item.type").String())
}
@@ -3937,9 +3987,9 @@ func TestRegressionIssue5951_DevinToolActivityOrderedBeforeAssistantMessage(t *t
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.completed" {
- ev.Get("response.output").ForEach(func(_, item gjson.Result) bool {
+ ev.Get("response.output").ForEach(func(_, item ojson.Result) bool {
completedOutputTypes = append(completedOutputTypes, item.Get("type").String())
return true
})
@@ -3967,9 +4017,9 @@ func TestRegressionIssue5951_DevinToolActivityOrderedBeforeAssistantMessage(t *t
if err != nil {
t.Fatalf("consumeDevinFramesToInteractions failed: %v", err)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
var stepTypes []string
- root.Get("steps").ForEach(func(_, step gjson.Result) bool {
+ root.Get("steps").ForEach(func(_, step ojson.Result) bool {
stepTypes = append(stepTypes, step.Get("type").String())
return true
})
@@ -4066,7 +4116,7 @@ func TestRegressionIssue5951_InterleavedToolArgumentsAndText(t *testing.T) {
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.output_item.added" && ev.Get("item.type").String() == "function_call" {
callCount++
}
@@ -4108,7 +4158,7 @@ func TestRegressionIssue5951_InterleavedToolArgumentsAndText(t *testing.T) {
if err != nil {
t.Fatalf("consumeDevinFramesToInteractions failed: %v", err)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
steps := root.Get("steps").Array()
if len(steps) != 2 {
t.Fatalf("steps count = %d, want 2 [function_call, model_output]. Steps: %s", len(steps), string(interactionsJSON))
@@ -4181,12 +4231,12 @@ func TestRegressionIssue5951_SameFrameToolAndContent(t *testing.T) {
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.output_item.done" {
doneItemTypes = append(doneItemTypes, ev.Get("item.type").String())
}
if ev.Get("type").String() == "response.completed" {
- ev.Get("response.output").ForEach(func(_, item gjson.Result) bool {
+ ev.Get("response.output").ForEach(func(_, item ojson.Result) bool {
completedOutputTypes = append(completedOutputTypes, item.Get("type").String())
return true
})
@@ -4222,9 +4272,9 @@ func TestRegressionIssue5951_SameFrameToolAndContent(t *testing.T) {
if err != nil {
t.Fatalf("consumeDevinFramesToInteractions failed: %v", err)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
var stepTypes []string
- root.Get("steps").ForEach(func(_, step gjson.Result) bool {
+ root.Get("steps").ForEach(func(_, step ojson.Result) bool {
stepTypes = append(stepTypes, step.Get("type").String())
return true
})
@@ -4308,12 +4358,12 @@ func TestRegressionIssue5951_PreToolExplanationAndPostToolAnswer(t *testing.T) {
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.output_item.done" {
doneItemTypes = append(doneItemTypes, ev.Get("item.type").String())
}
if ev.Get("type").String() == "response.completed" {
- ev.Get("response.output").ForEach(func(_, item gjson.Result) bool {
+ ev.Get("response.output").ForEach(func(_, item ojson.Result) bool {
completedOutputTypes = append(completedOutputTypes, item.Get("type").String())
return true
})
@@ -4351,9 +4401,9 @@ func TestRegressionIssue5951_PreToolExplanationAndPostToolAnswer(t *testing.T) {
if err != nil {
t.Fatalf("consumeDevinFramesToInteractions failed: %v", err)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
var stepTypes []string
- root.Get("steps").ForEach(func(_, step gjson.Result) bool {
+ root.Get("steps").ForEach(func(_, step ojson.Result) bool {
stepTypes = append(stepTypes, step.Get("type").String())
return true
})
@@ -4451,12 +4501,12 @@ func TestRegressionIssue5951_MultiStageToolAndIntermediateExplanation(t *testing
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.output_item.done" {
doneItemTypes = append(doneItemTypes, ev.Get("item.type").String())
}
if ev.Get("type").String() == "response.completed" {
- ev.Get("response.output").ForEach(func(_, item gjson.Result) bool {
+ ev.Get("response.output").ForEach(func(_, item ojson.Result) bool {
completedOutputTypes = append(completedOutputTypes, item.Get("type").String())
return true
})
@@ -4495,9 +4545,9 @@ func TestRegressionIssue5951_MultiStageToolAndIntermediateExplanation(t *testing
if err != nil {
t.Fatalf("consumeDevinFramesToInteractions failed: %v", err)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
var stepTypes []string
- root.Get("steps").ForEach(func(_, step gjson.Result) bool {
+ root.Get("steps").ForEach(func(_, step ojson.Result) bool {
stepTypes = append(stepTypes, step.Get("type").String())
return true
})
@@ -4608,7 +4658,7 @@ func TestRegressionIssue5951_InterleavedToolsWithTextAndContinuation(t *testing.
if strings.TrimSpace(data) == "[DONE]" {
continue
}
- ev := gjson.Parse(data)
+ ev := ojson.Parse(data)
if ev.Get("type").String() == "response.output_item.added" && ev.Get("item.type").String() == "function_call" {
callCount++
}
@@ -4654,7 +4704,7 @@ func TestRegressionIssue5951_InterleavedToolsWithTextAndContinuation(t *testing.
if err != nil {
t.Fatalf("consumeDevinFramesToInteractions failed: %v", err)
}
- root := gjson.ParseBytes(interactionsJSON)
+ root := ojson.ParseBytes(interactionsJSON)
steps := root.Get("steps").Array()
if len(steps) != 3 {
t.Fatalf("steps count = %d, want 3 [call_1, call_2, text]. Steps: %s", len(steps), string(interactionsJSON))
@@ -4849,7 +4899,7 @@ func TestDevinApplyPatchExecutorReuse(t *testing.T) {
}
tool := wireField(body[5:], 10)
name, desc, params := wireField(tool, 1), wireField(tool, 2), wireField(tool, 3)
- if string(name) != "functions__apply_patch" || !strings.Contains(string(desc), "*** Begin Patch") || !strings.Contains(string(desc), "@@") || !strings.Contains(string(desc), "start: patch") || gjson.GetBytes(params, "properties.input.type").String() != "string" || !gjson.GetBytes(params, "additionalProperties").Exists() || gjson.GetBytes(params, "additionalProperties").Bool() {
+ if string(name) != "functions__apply_patch" || !strings.Contains(string(desc), "*** Begin Patch") || !strings.Contains(string(desc), "@@") || !strings.Contains(string(desc), "start: patch") || ojson.GetBytes(params, "properties.input.type").String() != "string" || !ojson.GetBytes(params, "additionalProperties").Exists() || ojson.GetBytes(params, "additionalProperties").Bool() {
t.Fatalf("wire contract: name=%s desc=%s params=%s", name, desc, params)
}
}
@@ -4927,7 +4977,7 @@ func TestRegressionIssue6505_DevinClaudeStreamingNoFullInputEstimate(t *testing.
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data: ") {
data := []byte(strings.TrimPrefix(line, "data: "))
- eventType := gjson.GetBytes(data, "type").String()
+ eventType := ojson.GetBytes(data, "type").String()
if eventType == "message_start" {
messageStartEvent = data
} else if eventType == "message_delta" {
@@ -4940,7 +4990,7 @@ func TestRegressionIssue6505_DevinClaudeStreamingNoFullInputEstimate(t *testing.
if len(messageStartEvent) == 0 {
t.Fatal("expected message_start event in Claude stream")
}
- startRoot := gjson.ParseBytes(messageStartEvent)
+ startRoot := ojson.ParseBytes(messageStartEvent)
inputTokensNode := startRoot.Get("message.usage.input_tokens")
if !inputTokensNode.Exists() || inputTokensNode.Int() != 0 {
t.Fatalf("message_start message.usage.input_tokens = %v (exists=%v), want 0 (must not seed full-input estimate)", inputTokensNode.Value(), inputTokensNode.Exists())
@@ -4949,7 +4999,7 @@ func TestRegressionIssue6505_DevinClaudeStreamingNoFullInputEstimate(t *testing.
if len(messageDeltaEvent) == 0 {
t.Fatal("expected message_delta event in Claude stream")
}
- deltaRoot := gjson.ParseBytes(messageDeltaEvent)
+ deltaRoot := ojson.ParseBytes(messageDeltaEvent)
deltaInputNode := deltaRoot.Get("usage.input_tokens")
if !deltaInputNode.Exists() || deltaInputNode.Int() != promptTokens {
t.Errorf("message_delta usage.input_tokens = %v (exists=%v), want %d", deltaInputNode.Value(), deltaInputNode.Exists(), promptTokens)
diff --git a/internal/runtime/executor/devin_stream_content_test.go b/internal/runtime/executor/devin_stream_content_test.go
index 0e8c674bd6e..cf0e021bc87 100644
--- a/internal/runtime/executor/devin_stream_content_test.go
+++ b/internal/runtime/executor/devin_stream_content_test.go
@@ -3,16 +3,16 @@ package executor
import (
"bytes"
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"io"
"slices"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -39,10 +39,10 @@ func TestDevinStreamEmitsContentBeforeThinkingCloses(t *testing.T) {
}
for _, line := range strings.Split(string(chunk.Payload), "\n") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
- if !gjson.Valid(data) {
+ if !ojson.Valid(data) {
continue
}
- event := gjson.Parse(data)
+ event := ojson.Parse(data)
var text string
switch format {
case sdktranslator.FormatOpenAI:
@@ -121,8 +121,8 @@ func devinContentTestFrame(field protowire.Number, text string) []byte {
return helps.WrapConnectEnvelope(protowire.AppendString(frame, text))
}
-func devinContentTestEvents(out <-chan cliproxyexecutor.StreamChunk) ([]gjson.Result, []error) {
- var events []gjson.Result
+func devinContentTestEvents(out <-chan cliproxyexecutor.StreamChunk) ([]ojson.Result, []error) {
+ var events []ojson.Result
var errs []error
for len(out) > 0 {
chunk := <-out
@@ -131,8 +131,8 @@ func devinContentTestEvents(out <-chan cliproxyexecutor.StreamChunk) ([]gjson.Re
}
for _, line := range strings.Split(string(chunk.Payload), "\n") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
- if gjson.Valid(data) {
- events = append(events, gjson.Parse(data))
+ if ojson.Valid(data) {
+ events = append(events, ojson.Parse(data))
}
}
}
@@ -163,7 +163,7 @@ func TestDevinStreamContentLateSignatures(t *testing.T) {
tailSignature = signature[20:]
}
prefix.Write(devinContentTestFrame(3, "甲"))
- var events []gjson.Result
+ var events []ojson.Result
checkpointReached := false
checkpoint := devinContentCheckpointReader(func(_ []byte) (int, error) {
checkpointReached = true
@@ -211,7 +211,7 @@ func TestDevinStreamContentLateSignatures(t *testing.T) {
if (len(errs) == 1) != wantError || len(errs) > 1 {
t.Errorf("stream errors = %v, want exactly one error: %v", errs, wantError)
}
- var reasoningDone, completedReasoning gjson.Result
+ var reasoningDone, completedReasoning ojson.Result
doneCount, messageDoneCount, completedCount, failedCount := 0, 0, 0, 0
var text strings.Builder
for _, event := range events {
diff --git a/internal/runtime/executor/executor_payload_optimization_test.go b/internal/runtime/executor/executor_payload_optimization_test.go
index c60b934fff4..6a27bacd4c3 100644
--- a/internal/runtime/executor/executor_payload_optimization_test.go
+++ b/internal/runtime/executor/executor_payload_optimization_test.go
@@ -2,11 +2,16 @@ package executor
import (
"bytes"
+ "context"
+ "fmt"
"mime/multipart"
+ "runtime"
"strings"
"testing"
+ "time"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
)
func TestEnsureColonSpacedJSONLeavesInvalidPayloadUnchanged(t *testing.T) {
@@ -34,13 +39,13 @@ func TestNormalizeKimiToolMessageLinksPreservesLargeArguments(t *testing.T) {
if errNormalize != nil {
t.Fatalf("normalizeKimiToolMessageLinks returned error: %v", errNormalize)
}
- if got := gjson.GetBytes(output, "messages.0.tool_calls.0.function.arguments.id").Raw; got != "9007199254740993" {
+ if got := ojson.GetBytes(output, "messages.0.tool_calls.0.function.arguments.id").Raw; got != "9007199254740993" {
t.Fatalf("argument id = %s, want exact large integer", got)
}
- if got := gjson.GetBytes(output, "messages.1.tool_call_id").String(); got != "call_1" {
+ if got := ojson.GetBytes(output, "messages.1.tool_call_id").String(); got != "call_1" {
t.Fatalf("tool_call_id = %q, want call_1", got)
}
- if got := gjson.GetBytes(output, "messages.0.reasoning_content").String(); got != "lookup" {
+ if got := ojson.GetBytes(output, "messages.0.reasoning_content").String(); got != "lookup" {
t.Fatalf("reasoning_content = %q, want lookup", got)
}
}
@@ -68,13 +73,13 @@ func TestCodexMultipartImageEditAppendsExistingImages(t *testing.T) {
if errRewrite != nil {
t.Fatalf("rewrite multipart payload: %v", errRewrite)
}
- if got := gjson.GetBytes(output, "images.0").String(); got != "existing-1" {
+ if got := ojson.GetBytes(output, "images.0").String(); got != "existing-1" {
t.Fatalf("images.0 = %q", got)
}
- if got := gjson.GetBytes(output, "images.1").String(); got != "existing-2" {
+ if got := ojson.GetBytes(output, "images.1").String(); got != "existing-2" {
t.Fatalf("images.1 = %q", got)
}
- if got := gjson.GetBytes(output, "images.2.image_url").String(); !strings.HasPrefix(got, "data:application/octet-stream;base64,") {
+ if got := ojson.GetBytes(output, "images.2.image_url").String(); !strings.HasPrefix(got, "data:application/octet-stream;base64,") {
t.Fatalf("images.2.image_url = %q", got)
}
}
@@ -82,16 +87,16 @@ func TestCodexMultipartImageEditAppendsExistingImages(t *testing.T) {
func TestCodexImageBuildersPreservePayloads(t *testing.T) {
tool := []byte(`{"type":"image_generation","model":"gpt-image-2"}`)
request := codexBuildImagesResponsesRequest(`draw "this"`, []string{"data:image/png;base64,AA==", "", "data:image/jpeg;base64,BB=="}, tool)
- if !gjson.ValidBytes(request) {
+ if !ojson.ValidBytes(request) {
t.Fatalf("request is invalid JSON: %s", request)
}
- if got := gjson.GetBytes(request, "input.0.content.0.text").String(); got != `draw "this"` {
+ if got := ojson.GetBytes(request, "input.0.content.0.text").String(); got != `draw "this"` {
t.Fatalf("prompt = %q", got)
}
- if got := gjson.GetBytes(request, "input.0.content.#").Int(); got != 3 {
+ if got := ojson.GetBytes(request, "input.0.content.#").Int(); got != 3 {
t.Fatalf("content count = %d, want 3", got)
}
- if got := gjson.GetBytes(request, "tools.0.model").String(); got != "gpt-image-2" {
+ if got := ojson.GetBytes(request, "tools.0.model").String(); got != "gpt-image-2" {
t.Fatalf("tool model = %q", got)
}
@@ -100,16 +105,16 @@ func TestCodexImageBuildersPreservePayloads(t *testing.T) {
if errBuild != nil {
t.Fatalf("codexBuildImagesAPIResponse returned error: %v", errBuild)
}
- if !gjson.ValidBytes(response) {
+ if !ojson.ValidBytes(response) {
t.Fatalf("response is invalid JSON: %s", response)
}
- if got := gjson.GetBytes(response, "data.0.b64_json").String(); got != "AA==" {
+ if got := ojson.GetBytes(response, "data.0.b64_json").String(); got != "AA==" {
t.Fatalf("b64_json = %q", got)
}
- if got := gjson.GetBytes(response, "data.0.revised_prompt").String(); got != `revised "prompt"` {
+ if got := ojson.GetBytes(response, "data.0.revised_prompt").String(); got != `revised "prompt"` {
t.Fatalf("revised_prompt = %q", got)
}
- if got := gjson.GetBytes(response, "usage.images").Int(); got != 1 {
+ if got := ojson.GetBytes(response, "usage.images").Int(); got != 1 {
t.Fatalf("usage.images = %d", got)
}
}
@@ -192,3 +197,509 @@ func BenchmarkNormalizeKimiToolMessageLinksLargeCanonicalPayload(b *testing.B) {
benchmarkExecutorPayloadOutput, _ = normalizeKimiToolMessageLinks(input)
}
}
+
+func TestObserveClaudeStreamJSONPayloadClonesMessageID(t *testing.T) {
+ buf := []byte(`{"type":"message_start","message":{"id":"msg_reusable_buffer_01"}}`)
+ var msgID string
+ var sawMessageStop bool
+
+ observeClaudeStreamJSONPayload(buf, false, &msgID, &sawMessageStop)
+ if msgID != "msg_reusable_buffer_01" {
+ t.Fatalf("msgID = %q, want msg_reusable_buffer_01", msgID)
+ }
+ for i := range buf {
+ buf[i] = 'X'
+ }
+ if msgID != "msg_reusable_buffer_01" {
+ t.Fatalf("msgID mutated after buffer overwrite: got %q, want msg_reusable_buffer_01", msgID)
+ }
+}
+
+func TestExecutorPayloadSingleDocPipelinesByteConsistency(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("claude doc pipeline matches byte pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"claude-sonnet-4-5","betas":["custom-beta-1"],"prompt_cache_options":{"ttl":"5m"},"tool_choice":{"type":"any"},"thinking":{"type":"enabled","budget_tokens":2048},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
+
+ byteOut := bytes.Clone(raw)
+ byteOut = checkSystemInstructionsWithMode(byteOut, false)
+ byteOut = ensureModelMaxTokens(byteOut, "claude-sonnet-4-5")
+ byteOut = disableThinkingIfToolChoiceForced(byteOut)
+ byteOut = ensureCacheControl(byteOut)
+ byteOut = enforceCacheControlLimit(byteOut, 4)
+ byteOut = stripPromptCacheOptions(byteOut)
+ betasByte, byteOut := extractAndRemoveBetas(byteOut)
+
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ checkSystemInstructionsWithModeDoc(doc, false)
+ ensureModelMaxTokensDoc(doc, "claude-sonnet-4-5")
+ disableThinkingIfToolChoiceForcedDoc(doc)
+ ensureCacheControlDoc(doc)
+ enforceCacheControlLimitDoc(doc, 4)
+ stripPromptCacheOptionsDoc(doc)
+ betasDoc, _ := extractAndRemoveBetasDoc(doc)
+ docOut := doc.Bytes()
+
+ if !bytes.Equal(docOut, byteOut) {
+ t.Fatalf("claude doc output mismatch:\ndoc: %s\nbyte: %s", docOut, byteOut)
+ }
+ if strings.Join(betasDoc, ",") != strings.Join(betasByte, ",") {
+ t.Fatalf("claude betas mismatch: doc=%v byte=%v", betasDoc, betasByte)
+ }
+ })
+
+ t.Run("codex doc pipeline matches byte pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"gpt-5","instructions":null,"parallel_tool_calls":true,"temperature":0.7,"input":[{"type":"reasoning","id":"rs_1","content":[{"type":"reasoning_text","text":"step 1"}],"encrypted_content":"invalid"},{"type":"web_search_call","id":"ws_1"},{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`)
+ opts := cliproxyexecutor.Options{}
+
+ byteOut := bytes.Clone(raw)
+ byteOut = normalizeCodexInstructions(byteOut)
+ byteOut = ensureWebSearchToolForHistory(byteOut, opts.Headers)
+ byteOut = ensureImageGenerationTool(byteOut, "gpt-5", nil, opts.Headers)
+ byteOut = normalizeCodexParallelToolCalls(byteOut, opts.Headers)
+ byteOut = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "codex executor", byteOut, false, "gpt-5", "")
+ byteOut = applyCodexSIWCRequestBodyRules(byteOut, false, opts)
+
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ normalizeCodexInstructionsDoc(doc)
+ ensureWebSearchToolForHistoryDoc(doc, opts.Headers)
+ ensureImageGenerationToolDoc(doc, "gpt-5", nil, opts.Headers)
+ normalizeCodexParallelToolCallsDoc(doc, opts.Headers)
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "codex executor", doc, false, "gpt-5", "")
+ applyCodexSIWCRequestBodyRulesDoc(doc, false, opts)
+ docOut := doc.Bytes()
+
+ if !bytes.Equal(docOut, byteOut) {
+ t.Fatalf("codex doc output mismatch:\ndoc: %s\nbyte: %s", docOut, byteOut)
+ }
+ })
+
+ t.Run("xai doc pipeline matches byte pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"grok-4","tool_choice":"auto","tools":[{"type":"custom","name":"my_tool"}],"input":[{"type":"additional_tools","tools":[{"type":"function","name":"extra_fn","parameters":{"type":"object","properties":{}}}]},{"type":"custom_tool_call","call_id":"call_1","name":"my_tool","input":"{\"a\":1}"},{"type":"reasoning","content":null,"encrypted_content":"invalid"}],"image":{"image_url":"https://example.com/a.png"}}`)
+
+ byteOut := bytes.Clone(raw)
+ byteOut = normalizeXAIToolsWithFold(byteOut, false)
+ byteOut = promoteXAIAdditionalTools(byteOut)
+ byteOut = normalizeXAIToolChoiceForTools(byteOut)
+ byteOut = normalizeXAIInputCustomToolCalls(byteOut)
+ byteOut = normalizeXAIInputReasoningItems(byteOut)
+ byteOut = sanitizeXAIInputEncryptedContentForBaseURL(byteOut, "")
+ byteOut = normalizeXAIImageRefs(byteOut)
+
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ normalizeXAIToolsWithFoldDoc(doc, false)
+ promoteXAIAdditionalToolsDoc(doc)
+ normalizeXAIToolChoiceForToolsDoc(doc)
+ normalizeXAIInputCustomToolCallsDoc(doc)
+ normalizeXAIInputReasoningItemsDoc(doc)
+ sanitizeXAIInputEncryptedContentForBaseURLDoc(doc, "")
+ normalizeXAIImageRefsDoc(doc)
+ docOut := doc.Bytes()
+
+ if !bytes.Equal(docOut, byteOut) {
+ t.Fatalf("xai doc output mismatch:\ndoc: %s\nbyte: %s", docOut, byteOut)
+ }
+ })
+
+ t.Run("kimi doc pipeline matches byte pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"kimi-k2.5","temperature":0.2,"thinking":{"type":"enabled"},"tools":[{"type":"function","function":{"name":"lookup","parameters":{"$defs":{" item":{"type":"string"}},"properties":{"x":{"$ref":"#/$defs/ item"}}}}}],"messages":[{"role":"assistant","content":"thinking","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},{"role":"tool","call_id":"call_1","content":"ok"}]}`)
+
+ byteOut, errByte := normalizeKimiToolMessageLinks(bytes.Clone(raw))
+ if errByte != nil {
+ t.Fatalf("normalizeKimiToolMessageLinks: %v", errByte)
+ }
+ byteOut = normalizeKimiTools(byteOut)
+ byteOut = normalizeKimiTemperature(byteOut)
+
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ if errDoc := normalizeKimiToolMessageLinksDoc(doc); errDoc != nil {
+ t.Fatalf("normalizeKimiToolMessageLinksDoc: %v", errDoc)
+ }
+ normalizeKimiToolsDoc(doc)
+ normalizeKimiTemperatureDoc(doc)
+ docOut := doc.Bytes()
+
+ if !bytes.Equal(docOut, byteOut) {
+ t.Fatalf("kimi doc output mismatch:\ndoc: %s\nbyte: %s", docOut, byteOut)
+ }
+ })
+
+ t.Run("gemini and aistudio doc pipeline matches byte pipeline", func(t *testing.T) {
+ raw := []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"medium"},"responseModalities":["TEXT","IMAGE"],"imageConfig":{"aspectRatio":"1:1"}},"contents":[{"role":"user","parts":[{"text":"draw"}]}]}`)
+
+ byteOut := fixGeminiImageAspectRatio("gemini-2.5-flash-image-preview", bytes.Clone(raw))
+ byteOut = normalizeAIStudioThinkingLevel(byteOut)
+
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ fixGeminiImageAspectRatioDoc("gemini-2.5-flash-image-preview", doc)
+ normalizeAIStudioThinkingLevelDoc(doc)
+ docOut := doc.Bytes()
+
+ if !bytes.Equal(docOut, byteOut) {
+ t.Fatalf("gemini/aistudio doc output mismatch:\ndoc: %s\nbyte: %s", docOut, byteOut)
+ }
+ })
+}
+
+func TestAntigravityReasoningReplayAccumulatorClonesStreamSignatures(t *testing.T) {
+ scope := antigravityReasoningReplayScope{
+ modelName: "gemini-2.5-pro",
+ sessionKey: "session:test-reusable-buf",
+ }
+ reqPayload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}}`)
+ acc := newAntigravityReasoningReplayAccumulator(scope, reqPayload)
+ if acc == nil {
+ t.Fatal("expected non-nil accumulator")
+ }
+
+ line1 := []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_detached_reusable_01"}]}}]}}`)
+ acc.ObserveSSELine(line1)
+ for i := range line1 {
+ line1[i] = 'X'
+ }
+
+ line2 := []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"call_1","name":"lookup","args":{"q":"test"}}}]},"finishReason":"STOP"}]}}`)
+ acc.ObserveSSELine(line2)
+ for i := range line2 {
+ line2[i] = 'Y'
+ }
+
+ if !acc.seenSignatures["sig_detached_reusable_01"] {
+ t.Fatalf("seenSignatures missing sig_detached_reusable_01 after buffer overwrite: %+v", acc.seenSignatures)
+ }
+ if len(acc.items) != 1 {
+ t.Fatalf("len(acc.items) = %d, want 1", len(acc.items))
+ }
+ if got := ojson.GetBytesNoCopy(acc.items[0], "thoughtSignature").String(); got != "sig_detached_reusable_01" {
+ t.Fatalf("replayed item thoughtSignature = %q, want sig_detached_reusable_01", got)
+ }
+}
+
+func TestXAIResponseFilterAndNamespaceRestorerCloneStreamItemIDs(t *testing.T) {
+ filter := newXAIInternalXSearchResponseFilter(true, nil)
+ addedBuf := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"ctc_reusable_01","call_id":"xs_call_reusable_01","type":"custom_tool_call","name":"x_keyword_search"}}`)
+ if got := filter.apply(addedBuf); got != nil {
+ t.Fatalf("expected initial internal x_search item to be dropped, got %s", got)
+ }
+ for i := range addedBuf {
+ addedBuf[i] = 'X'
+ }
+
+ deltaByItemID := []byte(`{"type":"response.custom_tool_call_input.delta","item_id":"ctc_reusable_01","delta":"{}"}`)
+ if got := filter.apply(deltaByItemID); got != nil {
+ t.Fatalf("expected delta referencing ctc_reusable_01 to be dropped after buffer overwrite, got %s", got)
+ }
+ doneByCallID := []byte(`{"type":"response.custom_tool_call_input.done","call_id":"xs_call_reusable_01"}`)
+ if got := filter.apply(doneByCallID); got != nil {
+ t.Fatalf("expected done referencing xs_call_reusable_01 to be dropped after buffer overwrite, got %s", got)
+ }
+
+ restorer := newXAINamespaceRestorer(map[string]xaiNamespaceToolRef{
+ "mcp__codex_app": {
+ namespace: "mcp__codex_app",
+ isDispatcher: true,
+ children: map[string]struct{}{"lookup": {}},
+ },
+ })
+ dispAddedBuf := []byte(`{"type":"response.output_item.added","item":{"id":"fc_disp_reusable_01","type":"function_call","name":"mcp__codex_app"}}`)
+ _ = restorer.restore(dispAddedBuf)
+ for i := range dispAddedBuf {
+ dispAddedBuf[i] = 'X'
+ }
+ argsDone := []byte(`{"type":"response.function_call_arguments.done","item_id":"fc_disp_reusable_01","arguments":"{\"name\":\"lookup\",\"arguments\":{\"k\":\"v\"}}"}`)
+ restored := restorer.restore(argsDone)
+ if got := ojson.GetBytesNoCopy(restored, "arguments").String(); got != `{"k":"v"}` {
+ t.Fatalf("restored arguments = %q, want {\"k\":\"v\"}", got)
+ }
+}
+
+func TestNormalizeXAIImageRefsDocArbitraryNestedExtensionPath(t *testing.T) {
+ raw := []byte(`{"model":"grok-4","extension":{"image":{"image_url":"https://example.com/ext.png"}},"deep":{"arr":[{"reference_images":[{"image_url":{"url":"https://example.com/ref.png"}}]}]}}`)
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ _ = doc.Set("model", "grok-3")
+ _ = doc.Set("stream", true)
+ if !normalizeXAIImageRefsDoc(doc) {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "extension.image.url").String(); got != "https://example.com/ext.png" {
+ t.Fatalf("extension.image.url = %q, want https://example.com/ext.png", got)
+ }
+ if ojson.GetBytesNoCopy(out, "extension.image.image_url").Exists() {
+ t.Fatalf("extension.image.image_url should be removed: %s", out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "deep.arr.0.reference_images.0.url").String(); got != "https://example.com/ref.png" {
+ t.Fatalf("deep.arr.0.reference_images.0.url = %q, want https://example.com/ref.png", got)
+ }
+ if ojson.GetBytesNoCopy(out, "deep.arr.0.reference_images.0.image_url").Exists() {
+ t.Fatalf("deep.arr.0.reference_images.0.image_url should be removed: %s", out)
+ }
+
+ nestedInsideDeleted := ojson.NewDoc([]byte(`{"image":{"image_url":{"url":"outer","image":{"image_url":"inner"}}}}`))
+ if !normalizeXAIImageRefsDoc(nestedInsideDeleted) {
+ t.Fatal("expected nestedInsideDeleted to be normalized")
+ }
+ if got := string(nestedInsideDeleted.Bytes()); got != `{"image":{"url":"outer"}}` {
+ t.Fatalf("nestedInsideDeleted = %s, want {\"image\":{\"url\":\"outer\"}}", got)
+ }
+
+ unicodeEscapedKey := ojson.NewDoc([]byte(`{"image":{"url":"keep"},"extension":{"im\u0061ge":{"image_url":"ext"}}}`))
+ if !normalizeXAIImageRefsDoc(unicodeEscapedKey) {
+ t.Fatal("expected unicodeEscapedKey to be normalized")
+ }
+ unicodeOut := unicodeEscapedKey.Bytes()
+ if got := ojson.GetBytesNoCopy(unicodeOut, "extension.image.url").String(); got != "ext" {
+ t.Fatalf("extension.image.url = %q, want ext; body=%s", got, unicodeOut)
+ }
+ if ojson.GetBytesNoCopy(unicodeOut, "extension.image.image_url").Exists() {
+ t.Fatalf("extension.image.image_url should be removed: %s", unicodeOut)
+ }
+
+ newlyAddedTopKey := ojson.NewDoc([]byte(`{"model":"grok-4"}`))
+ _ = newlyAddedTopKey.Set("extension.image.image_url", "new")
+ if !normalizeXAIImageRefsDoc(newlyAddedTopKey) {
+ t.Fatal("expected newlyAddedTopKey to be normalized")
+ }
+ if got := string(newlyAddedTopKey.Bytes()); got != `{"model":"grok-4","extension":{"image":{"url":"new"}}}` {
+ t.Fatalf("newlyAddedTopKey = %s, want {\"model\":\"grok-4\",\"extension\":{\"image\":{\"url\":\"new\"}}}", got)
+ }
+
+ mutatedEmptyKey := ojson.NewDoc([]byte(`{"":{"image":{"image_url":"old"}}}`))
+ _ = mutatedEmptyKey.Set(".image.image_url", "new")
+ if !normalizeXAIImageRefsDoc(mutatedEmptyKey) {
+ t.Fatal("expected mutatedEmptyKey to be normalized")
+ }
+ if got := string(mutatedEmptyKey.Bytes()); got != `{"":{"image":{"url":"new"}}}` {
+ t.Fatalf("mutatedEmptyKey = %s, want {\"\":{\"image\":{\"url\":\"new\"}}}", got)
+ }
+
+ deletedEmptyKey := ojson.NewDoc([]byte(`{"":{"image":{"image_url":"old"}},"other":1}`))
+ _ = deletedEmptyKey.Delete(".image")
+ if normalizeXAIImageRefsDoc(deletedEmptyKey) {
+ t.Fatal("expected deletedEmptyKey to report false after .image was deleted")
+ }
+ if got := string(deletedEmptyKey.Bytes()); got != `{"":{},"other":1}` {
+ t.Fatalf("deletedEmptyKey = %s, want {\"\":{},\"other\":1}", got)
+ }
+}
+
+func TestClaudeCacheControlDocChecksDoNotMaterializeLargeMessagesArray(t *testing.T) {
+ t.Run("split messages container avoids 1MiB materialization", func(t *testing.T) {
+ chunk := strings.Repeat("a", 64*1024)
+ var b strings.Builder
+ b.Grow(16*len(chunk) + 1024)
+ b.WriteString(`{"model":"claude-sonnet-4-5","system":[{"type":"text","text":"sys"}],"messages":[`)
+ for i := 0; i < 16; i++ {
+ if i > 0 {
+ b.WriteByte(',')
+ }
+ b.WriteString(`{"role":"user","content":[{"type":"text","text":"`)
+ b.WriteString(chunk)
+ b.WriteString(`"}]}`)
+ }
+ b.WriteString(`]}`)
+ raw := []byte(b.String())
+
+ doc := ojson.NewDoc(raw)
+ // Mutate messages.0 so "messages" splits into an ojson container (v.c != nil).
+ _ = doc.Set("messages.0.content.0.text", "short")
+
+ var mBefore, mAfter runtime.MemStats
+ runtime.ReadMemStats(&mBefore)
+ start := time.Now()
+ ensureCacheControlDoc(doc)
+ if got := countCacheControlsDoc(doc); got != 2 {
+ t.Fatalf("countCacheControlsDoc = %d, want 2", got)
+ }
+ enforceCacheControlLimitDoc(doc, 4)
+ upgradeClaudeCacheControlTTLDoc(doc, claudeCacheControlTTL1h)
+ normalizeCacheControlTTLDoc(doc)
+ stripClaudeCacheControlTTLDoc(doc)
+ elapsed := time.Since(start)
+ runtime.ReadMemStats(&mAfter)
+
+ allocated := mAfter.TotalAlloc - mBefore.TotalAlloc
+ if allocated > 64*1024 {
+ t.Fatalf("split messages: cache control doc helpers allocated %d bytes (> 64 KiB)", allocated)
+ }
+ if elapsed > 20*time.Millisecond {
+ t.Fatalf("split messages took %v (> 20ms); expected O(N) single-pass traversal", elapsed)
+ }
+ })
+
+ t.Run("unsplit 1024 messages runs in single-pass O(N) time and <64KiB allocs", func(t *testing.T) {
+ chunk := strings.Repeat("b", 1024)
+ var b strings.Builder
+ b.Grow(1024*(len(chunk)+64) + 256)
+ b.WriteString(`{"model":"claude-sonnet-4-5","system":[{"type":"text","text":"sys","cache_control":{"type":"ephemeral"}}],"messages":[`)
+ for i := 0; i < 1024; i++ {
+ if i > 0 {
+ b.WriteByte(',')
+ }
+ b.WriteString(`{"role":"user","content":[{"type":"text","text":"`)
+ b.WriteString(chunk)
+ if i == 1023 {
+ b.WriteString(`","cache_control":{"type":"ephemeral"}}]}`)
+ } else {
+ b.WriteString(`"}]}`)
+ }
+ }
+ b.WriteString(`]}`)
+ raw := []byte(b.String())
+
+ doc := ojson.NewDoc(raw)
+ // Mutate only "model" so "messages" remains unsplit in root tail.
+ _ = doc.Set("model", "claude-sonnet-4-5-20250929")
+
+ var mBefore, mAfter runtime.MemStats
+ runtime.ReadMemStats(&mBefore)
+ start := time.Now()
+ if got := countCacheControlsDoc(doc); got != 2 {
+ t.Fatalf("countCacheControlsDoc = %d, want 2", got)
+ }
+ enforceCacheControlLimitDoc(doc, 4)
+ normalizeCacheControlTTLDoc(doc)
+ elapsed := time.Since(start)
+ runtime.ReadMemStats(&mAfter)
+
+ allocated := mAfter.TotalAlloc - mBefore.TotalAlloc
+ if allocated > 64*1024 {
+ t.Fatalf("unsplit 1024 messages: allocated %d bytes (> 64 KiB)", allocated)
+ }
+ if elapsed > 20*time.Millisecond {
+ t.Fatalf("unsplit 1024 messages took %v (> 20ms); expected O(N) single-pass traversal", elapsed)
+ }
+ })
+}
+
+func TestStripClaudeCacheControlTTLThenInjectMessagesPreservesExistingCacheControl(t *testing.T) {
+ t.Run("single-block message preserves scope after ttl strip", func(t *testing.T) {
+ raw := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"hello","cache_control":{"type":"ephemeral","ttl":"5m","scope":"global"}}]}]}`)
+ doc := ojson.NewDoc(raw)
+ if !stripClaudeCacheControlTTLDoc(doc) {
+ t.Fatal("expected stripClaudeCacheControlTTLDoc to report true")
+ }
+ if injectMessagesCacheControlDoc(doc) {
+ t.Fatal("expected injectMessagesCacheControlDoc to report false when message already has valid cache_control")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.cache_control.scope").String(); got != "global" {
+ t.Fatalf("scope = %q, want global; body=%s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "messages.0.content.0.cache_control.ttl").Exists() {
+ t.Fatalf("ttl should be stripped: %s", out)
+ }
+ })
+
+ t.Run("multi-block message does not inject duplicate breakpoint after ttl strip", func(t *testing.T) {
+ raw := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"first","cache_control":{"type":"ephemeral","ttl":"1h"}},{"type":"text","text":"second"}]}]}`)
+ doc := ojson.NewDoc(raw)
+ if !stripClaudeCacheControlTTLDoc(doc) {
+ t.Fatal("expected stripClaudeCacheControlTTLDoc to report true")
+ }
+ if injectMessagesCacheControlDoc(doc) {
+ t.Fatal("expected injectMessagesCacheControlDoc to report false when first block already has valid cache_control")
+ }
+ out := doc.Bytes()
+ if count := countCacheControls(out); count != 1 {
+ t.Fatalf("countCacheControls = %d, want 1; body=%s", count, out)
+ }
+ if ojson.GetBytesNoCopy(out, "messages.0.content.1.cache_control").Exists() {
+ t.Fatalf("unexpected duplicate cache_control on second block: %s", out)
+ }
+ })
+}
+
+func TestClaudeStreamDocScannerEdgeCases(t *testing.T) {
+ t.Run("large cache_control objects >300 bytes are counted limited to 4 and have ttl stripped", func(t *testing.T) {
+ pad := strings.Repeat(" ", 320)
+ largeCC := `{"type":"ephemeral","ttl":"1h"` + pad + `}`
+ raw := []byte(fmt.Sprintf(
+ `{"model":"claude-sonnet-4-5","system":[{"type":"text","text":"s0","cache_control":%s},{"type":"text","text":"s1","cache_control":%s}],"messages":[{"role":"user","content":[{"type":"text","text":"m0","cache_control":%s},{"type":"text","text":"m1","cache_control":%s},{"type":"text","text":"m2","cache_control":%s}]}]}`,
+ largeCC, largeCC, largeCC, largeCC, largeCC,
+ ))
+ doc := ojson.NewDoc(raw)
+ if got := countCacheControlsDoc(doc); got != 5 {
+ t.Fatalf("countCacheControlsDoc = %d, want 5", got)
+ }
+ if !enforceCacheControlLimitDoc(doc, 4) {
+ t.Fatal("expected enforceCacheControlLimitDoc to report true")
+ }
+ if got := countCacheControlsDoc(doc); got != 4 {
+ t.Fatalf("countCacheControlsDoc after limit = %d, want 4", got)
+ }
+ if !stripClaudeCacheControlTTLDoc(doc) {
+ t.Fatal("expected stripClaudeCacheControlTTLDoc to report true")
+ }
+ out := doc.Bytes()
+ if ojson.GetBytesNoCopy(out, "system.1.cache_control.ttl").Exists() ||
+ ojson.GetBytesNoCopy(out, "messages.0.content.0.cache_control.ttl").Exists() ||
+ ojson.GetBytesNoCopy(out, "messages.0.content.1.cache_control.ttl").Exists() ||
+ ojson.GetBytesNoCopy(out, "messages.0.content.2.cache_control.ttl").Exists() {
+ t.Fatalf("expected all remaining cache_control ttl fields to be stripped: %s", out)
+ }
+ })
+
+ t.Run("unicode escaped redacted_thinking is not treated as cacheable", func(t *testing.T) {
+ escapedRedactedThinking := `\u0072\u0065\u0064\u0061\u0063\u0074\u0065\u0064\u005f\u0074\u0068\u0069\u006e\u006b\u0069\u006e\u0067`
+ raw := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"question"}]},{"role":"assistant","content":[{"type":"text","text":"answer"},{"type":"` + escapedRedactedThinking + `","data":"enc"}]}]}`)
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ if !injectMessagesCacheControlDoc(doc) {
+ t.Fatal("expected injectMessagesCacheControlDoc to report true")
+ }
+ out := doc.Bytes()
+ if ojson.GetBytesNoCopy(out, "messages.1.content.0.cache_control").Exists() ||
+ ojson.GetBytesNoCopy(out, "messages.1.content.1.cache_control").Exists() {
+ t.Fatalf("assistant message ending with unicode-escaped redacted_thinking must not receive cache_control: %s", out)
+ }
+ if !ojson.GetBytesNoCopy(out, "messages.0.content.0.cache_control").Exists() {
+ t.Fatalf("expected user message to receive rolling cache_control: %s", out)
+ }
+ })
+
+ t.Run("unescaped ideographic space is treated as whitespace identically to strings.TrimSpace", func(t *testing.T) {
+ raw := []byte("{\"model\":\"claude-sonnet-4-5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"first\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"\u3000\u3000\"}]}]}")
+ want := injectMessagesCacheControl(bytes.Clone(raw))
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ injectMessagesCacheControlDoc(doc)
+ got := doc.Bytes()
+ if !bytes.Equal(got, want) {
+ t.Fatalf("injectMessagesCacheControlDoc mismatch for ideographic space:\ngot: %s\nwant: %s", got, want)
+ }
+ if ojson.GetBytesNoCopy(got, "messages.1.content.0.cache_control").Exists() {
+ t.Fatalf("whitespace-only \u3000 block must not receive cache_control: %s", got)
+ }
+ if !ojson.GetBytesNoCopy(got, "messages.0.content.0.cache_control").Exists() {
+ t.Fatalf("expected first user message to receive cache_control: %s", got)
+ }
+ })
+}
+
+func TestNormalizeXAIImageRefsDocAvoidsLargePayloadCopy(t *testing.T) {
+ largePrompt := strings.Repeat("x", 1<<20)
+ raw := []byte(`{"model":"grok-imagine-image","prompt":"` + largePrompt + `","image":{"image_url":"https://example.com/a.png"}}`)
+ doc := ojson.NewDoc(raw)
+
+ // Warm up ojson WriteTo buffer pool before measuring allocations.
+ warmDoc := ojson.NewDoc([]byte(`{"model":"grok-imagine-image","image":{"image_url":"https://example.com/w.png"}}`))
+ normalizeXAIImageRefsDoc(warmDoc)
+
+ var mBefore, mAfter runtime.MemStats
+ runtime.ReadMemStats(&mBefore)
+ if !normalizeXAIImageRefsDoc(doc) {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true")
+ }
+ runtime.ReadMemStats(&mAfter)
+
+ allocated := mAfter.TotalAlloc - mBefore.TotalAlloc
+ if allocated > 32*1024 {
+ t.Fatalf("normalizeXAIImageRefsDoc allocated %d bytes for 1MiB payload, want < 32 KiB", allocated)
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "image.url").String(); got != "https://example.com/a.png" {
+ t.Fatalf("image.url = %q, want https://example.com/a.png", got)
+ }
+ if ojson.GetBytesNoCopy(out, "image.image_url").Exists() {
+ t.Fatalf("image.image_url should be deleted")
+ }
+}
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index 32954d8756c..f832fb54e79 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -13,6 +13,8 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -23,8 +25,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -154,17 +154,17 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
return resp, err
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return resp, err
}
- body = fixGeminiImageAspectRatio(baseModel, body)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = capGeminiMaxOutputTokens(body, baseModel)
- body = internalsignature.SanitizeNonVertexGeminiRequestThoughtSignatures(body, "contents")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ capGeminiMaxOutputTokensDoc(doc, baseModel)
+ internalsignature.SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc, "contents")
action := "generateContent"
if req.Metadata != nil {
@@ -172,9 +172,9 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
action = "countTokens"
}
}
- body = helps.EnsureGeminiLeadingUserContent(body, "contents")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
if action != "countTokens" {
- body = helps.EnsureGeminiTrailingUserContent(body, "contents")
+ helps.EnsureGeminiTrailingUserContentDoc(doc, "contents")
}
baseURL := resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, action)
@@ -182,9 +182,10 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
- body, _ = sjson.DeleteBytes(body, "session_id")
+ _ = doc.Delete("session_id")
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -296,18 +297,18 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
return nil, err
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return nil, err
}
- body = fixGeminiImageAspectRatio(baseModel, body)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = capGeminiMaxOutputTokens(body, baseModel)
- body = internalsignature.SanitizeNonVertexGeminiRequestThoughtSignatures(body, "contents")
- body = helps.EnsureGeminiBoundaryUserContent(body, "contents")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ capGeminiMaxOutputTokensDoc(doc, baseModel)
+ internalsignature.SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc, "contents")
+ helps.EnsureGeminiBoundaryUserContentDoc(doc, "contents")
baseURL := resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "streamGenerateContent")
@@ -317,9 +318,10 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
- body, _ = sjson.DeleteBytes(body, "session_id")
+ _ = doc.Delete("session_id")
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -394,12 +396,40 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}()
var param any
var sawFinishReason bool
+ var pendingFilterPayload []byte
+ var pendingFilterMsg string
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m)
+ emitTranslatedPayload := func(payload []byte) bool {
+ lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(payload), ¶m, claudeInputTokens)
+ helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
+ for i := range lines {
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ if helps.IsGeminiTerminalStreamChunk(lines[i]) {
+ terminalDelivered = true
+ }
+ case <-ctx.Done():
+ return false
+ }
+ }
+ return !helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ }
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
rawPayload := helps.JSONPayload(line)
+ if len(rawPayload) > 0 && len(pendingFilterPayload) > 0 {
+ flushed := pendingFilterPayload
+ pendingFilterPayload = nil
+ pendingFilterMsg = ""
+ if !emitTranslatedPayload(flushed) {
+ return
+ }
+ }
if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) {
sawFinishReason = true
}
@@ -416,29 +446,38 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
}
return
}
+ if filterMsg, blocked := clienterror.GeminiFilterBlockMessage(rawPayload); blocked {
+ pendingFilterPayload = bytes.Clone(rawPayload)
+ pendingFilterMsg = filterMsg
+ continue
+ }
filtered := helps.FilterSSEUsageMetadata(line)
payload := helps.JSONPayload(filtered)
if len(payload) == 0 {
continue
}
- lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(payload), ¶m, claudeInputTokens)
- helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
- helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
- return statusErr{code: http.StatusBadGateway, msg: msg}
- })
- for i := range lines {
- select {
- case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
- if helps.IsGeminiTerminalStreamChunk(lines[i]) {
- terminalDelivered = true
- }
- case <-ctx.Done():
- return
- }
+ if !emitTranslatedPayload(payload) {
+ return
}
- if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
+ }
+ if pendingFilterMsg != "" && scanner.Err() == nil && !sawFinishReason {
+ filterErr := statusErr{code: http.StatusBadRequest, msg: pendingFilterMsg}
+ helps.RecordAPIResponseError(ctx, e.cfg, filterErr)
+ if !streamUsage.PublishFailure(ctx, reporter, filterErr) {
+ reporter.PublishFailure(ctx, filterErr)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: filterErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
+ if len(pendingFilterPayload) > 0 {
+ if !emitTranslatedPayload(pendingFilterPayload) {
return
}
+ pendingFilterPayload = nil
+ pendingFilterMsg = ""
}
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
@@ -503,16 +542,16 @@ func geminiPromptBlockErrorMessage(payload []byte) (string, bool) {
return "", false
}
for _, cPath := range []string{"candidates", "response.candidates"} {
- if candidates := gjson.GetBytes(payload, cPath); candidates.IsArray() && len(candidates.Array()) > 0 {
+ if candidates := ojson.GetBytesNoCopy(payload, cPath); candidates.IsArray() && len(candidates.Array()) > 0 {
return "", false
}
}
for _, prefix := range []string{"promptFeedback", "response.promptFeedback"} {
- reason := strings.TrimSpace(gjson.GetBytes(payload, prefix+".blockReason").String())
+ reason := strings.TrimSpace(ojson.GetBytesNoCopy(payload, prefix+".blockReason").String())
if reason == "" {
continue
}
- if detail := strings.TrimSpace(gjson.GetBytes(payload, prefix+".blockReasonMessage").String()); detail != "" {
+ if detail := strings.TrimSpace(ojson.GetBytesNoCopy(payload, prefix+".blockReasonMessage").String()); detail != "" {
return fmt.Sprintf("upstream blocked the prompt: %s - %s", reason, detail), true
}
return fmt.Sprintf("upstream blocked the prompt: %s", reason), true
@@ -530,7 +569,7 @@ func geminiStreamHasFinishSignal(payload []byte) bool {
"promptFeedback.blockReason",
"response.promptFeedback.blockReason",
} {
- res := gjson.GetBytes(payload, path)
+ res := ojson.GetBytesNoCopy(payload, path)
if res.IsArray() {
for _, item := range res.Array() {
if strings.TrimSpace(item.String()) != "" {
@@ -555,22 +594,23 @@ func (e *GeminiExecutor) executeInteractions(ctx context.Context, auth *cliproxy
if err != nil {
return resp, err
}
- if gjson.GetBytes(body, "model").Exists() && targetName != "" {
- body = helps.SetStringIfDifferent(body, "model", targetName)
+ doc := ojson.NewDoc(body)
+ if doc.Get("model").Exists() && targetName != "" {
+ helps.SetStringIfDifferentDoc(doc, "model", targetName)
}
- body, err = applyGeminiInteractionsThinking(body, req, opts)
- if err != nil {
+ if err = applyGeminiInteractionsThinkingDoc(doc, req, opts); err != nil {
return resp, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
fromProtocol := opts.SourceFormat.String()
- body = sanitizeGeminiInteractionsUnsupportedInputIDs(body)
- continuation, body := helps.PrepareAntigravityInteractions(auth, req, opts, targetName, body)
+ sanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc)
+ continuation := helps.PrepareAntigravityInteractionsDoc(auth, req, opts, targetName, doc)
baseURL := resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, targetName, "interactions", fromProtocol, "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedRequestMetadata(body, "interactions")
httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errRequest != nil {
@@ -646,22 +686,23 @@ func (e *GeminiExecutor) executeInteractionsStream(ctx context.Context, auth *cl
if err != nil {
return nil, err
}
- if gjson.GetBytes(body, "model").Exists() && targetName != "" {
- body = helps.SetStringIfDifferent(body, "model", targetName)
+ doc := ojson.NewDoc(body)
+ if doc.Get("model").Exists() && targetName != "" {
+ helps.SetStringIfDifferentDoc(doc, "model", targetName)
}
- body, err = applyGeminiInteractionsThinking(body, req, opts)
- if err != nil {
+ if err = applyGeminiInteractionsThinkingDoc(doc, req, opts); err != nil {
return nil, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
fromProtocol := opts.SourceFormat.String()
- body = sanitizeGeminiInteractionsUnsupportedInputIDs(body)
- continuation, body := helps.PrepareAntigravityInteractions(auth, req, opts, targetName, body)
- body = helps.SetBoolIfDifferent(body, "stream", true)
+ sanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc)
+ continuation := helps.PrepareAntigravityInteractionsDoc(auth, req, opts, targetName, doc)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
baseURL := resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/interactions", baseURL, glAPIVersion)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, targetName, "interactions", fromProtocol, "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedRequestMetadata(body, "interactions")
httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errRequest != nil {
@@ -825,24 +866,26 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
originalTranslatedForPayload = helps.TranslateRequestWithAPIKeyModelCompatibility(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false, helps.APIKeyModelIsCompat(req))
}
- translatedReq, err = helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(translatedReq)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return cliproxyexecutor.Response{}, err
}
- translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
respCtx := context.WithValue(ctx, "alt", opts.Alt)
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
- translatedReq = helps.SetStringIfDifferent(translatedReq, "model", baseModel)
- translatedReq = internalsignature.SanitizeNonVertexGeminiRequestThoughtSignatures(translatedReq, "contents")
- translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents")
+ _ = doc.Delete("tools")
+ _ = doc.Delete("generationConfig")
+ _ = doc.Delete("safetySettings")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ internalsignature.SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc, "contents")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
baseURL := resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "countTokens")
- translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq)
+ ctx = helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts))
+ helps.FinalizePayloadDoc(ctx, doc)
+ translatedReq = doc.Bytes()
requestBody := bytes.NewReader(translatedReq)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, requestBody)
@@ -897,7 +940,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
return cliproxyexecutor.Response{}, statusErr{code: resp.StatusCode, msg: string(data)}
}
- count := gjson.GetBytes(data, "totalTokens").Int()
+ count := ojson.GetBytesNoCopy(data, "totalTokens").Int()
translated := sdktranslator.TranslateTokenCount(respCtx, to, responseFormat, count, data)
return cliproxyexecutor.Response{Payload: translated, Headers: resp.Header.Clone()}, nil
}
@@ -996,6 +1039,10 @@ func sanitizeGeminiInteractionsUnsupportedInputIDs(body []byte) []byte {
return helps.SanitizeGeminiInteractionsUnsupportedInputIDs(body, false)
}
+func sanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc *ojson.Doc) bool {
+ return helps.SanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc, false)
+}
+
func translateGeminiInteractionsRequestBody(ctx context.Context, cfg *config.Config, model string, payload []byte, opts cliproxyexecutor.Options, stream, isCompat bool) ([]byte, error) {
return helps.TranslateGeminiInteractionsRequestBody(ctx, cfg, model, payload, opts, stream, isCompat)
}
@@ -1045,6 +1092,10 @@ func applyGeminiInteractionsThinking(body []byte, req cliproxyexecutor.Request,
return helps.ApplyGeminiInteractionsThinking(body, req, opts)
}
+func applyGeminiInteractionsThinkingDoc(doc *ojson.Doc, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) error {
+ return helps.ApplyGeminiInteractionsThinkingDoc(doc, req, opts)
+}
+
func applyGeminiInteractionsRevisionHeader(req *http.Request) {
helps.ApplyGeminiInteractionsRevisionHeader(req)
}
@@ -1078,65 +1129,86 @@ func applyGeminiHeaders(req *http.Request, auth *cliproxyauth.Auth, clientHeader
}
func capGeminiMaxOutputTokens(body []byte, modelName string) []byte {
- maxOut := gjson.GetBytes(body, "generationConfig.maxOutputTokens")
- if !maxOut.Exists() || maxOut.Type != gjson.Number {
+ doc := ojson.NewDoc(body)
+ if !capGeminiMaxOutputTokensDoc(doc, modelName) {
return body
}
+ return doc.Bytes()
+}
+
+func capGeminiMaxOutputTokensDoc(doc *ojson.Doc, modelName string) bool {
+ if doc == nil {
+ return false
+ }
+ maxOut := doc.Get("generationConfig.maxOutputTokens")
+ if !maxOut.Exists() || maxOut.Type != ojson.Number {
+ return false
+ }
modelInfo := registry.LookupModelInfo(modelName, "gemini")
if modelInfo == nil {
- return body
+ return false
}
limit := modelInfo.OutputTokenLimit
if limit <= 0 {
limit = modelInfo.MaxCompletionTokens
}
if limit <= 0 || maxOut.Int() <= int64(limit) {
- return body
+ return false
}
- body, _ = sjson.SetBytes(body, "generationConfig.maxOutputTokens", limit)
- return body
+ return doc.Set("generationConfig.maxOutputTokens", limit) == nil
}
func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
- if modelName == "gemini-2.5-flash-image-preview" {
- aspectRatioResult := gjson.GetBytes(rawJSON, "generationConfig.imageConfig.aspectRatio")
- if aspectRatioResult.Exists() {
- contents := gjson.GetBytes(rawJSON, "contents")
- contentArray := contents.Array()
- if len(contentArray) > 0 {
- hasInlineData := false
- loopContent:
- for i := 0; i < len(contentArray); i++ {
- parts := contentArray[i].Get("parts").Array()
- for j := 0; j < len(parts); j++ {
- if parts[j].Get("inlineData").Exists() {
- hasInlineData = true
- break loopContent
- }
- }
- }
-
- if !hasInlineData {
- emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
- emptyImagePart := []byte(`{"inlineData":{"mime_type":"image/png","data":""}}`)
- emptyImagePart, _ = sjson.SetBytes(emptyImagePart, "inlineData.data", emptyImageBase64ed)
- newPartsJson := []byte(`[]`)
- newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", []byte(`{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`))
- newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", emptyImagePart)
-
- parts := contentArray[0].Get("parts").Array()
- for j := 0; j < len(parts); j++ {
- newPartsJson, _ = sjson.SetRawBytes(newPartsJson, "-1", []byte(parts[j].Raw))
- }
+ if modelName != "gemini-2.5-flash-image-preview" {
+ return rawJSON
+ }
+ doc := ojson.NewDoc(rawJSON)
+ if !fixGeminiImageAspectRatioDoc(modelName, doc) {
+ return rawJSON
+ }
+ return doc.Bytes()
+}
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "contents.0.parts", newPartsJson)
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`))
+func fixGeminiImageAspectRatioDoc(modelName string, doc *ojson.Doc) bool {
+ if doc == nil || modelName != "gemini-2.5-flash-image-preview" {
+ return false
+ }
+ aspectRatioResult := doc.Get("generationConfig.imageConfig.aspectRatio")
+ if !aspectRatioResult.Exists() {
+ return false
+ }
+ contents := doc.Get("contents")
+ contentArray := contents.Array()
+ if len(contentArray) > 0 {
+ hasInlineData := false
+ loopContent:
+ for i := 0; i < len(contentArray); i++ {
+ parts := contentArray[i].Get("parts").Array()
+ for j := 0; j < len(parts); j++ {
+ if parts[j].Get("inlineData").Exists() {
+ hasInlineData = true
+ break loopContent
}
}
- rawJSON, _ = sjson.DeleteBytes(rawJSON, "generationConfig.imageConfig")
+ }
+
+ if !hasInlineData {
+ emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
+ emptyImagePart, _ := ojson.SetBytes([]byte(`{"inlineData":{"mime_type":"image/png","data":""}}`), "inlineData.data", emptyImageBase64ed)
+ parts := contentArray[0].Get("parts").Array()
+ rawParts := make([][]byte, 0, len(parts)+2)
+ rawParts = append(rawParts, []byte(`{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`))
+ rawParts = append(rawParts, emptyImagePart)
+ for j := 0; j < len(parts); j++ {
+ rawParts = append(rawParts, []byte(parts[j].Raw))
+ }
+
+ _ = doc.SetRawBytes("contents.0.parts", helps.JoinRawJSONArray(rawParts))
+ _ = doc.SetRawBytes("generationConfig.responseModalities", []byte(`["IMAGE", "TEXT"]`))
}
}
- return rawJSON
+ _ = doc.Delete("generationConfig.imageConfig")
+ return true
}
// SupportsApplyPatch reports the actual executor contract, independent of its provider name.
diff --git a/internal/runtime/executor/gemini_executor_signature_test.go b/internal/runtime/executor/gemini_executor_signature_test.go
index 627164a423a..ce3d4aa51d7 100644
--- a/internal/runtime/executor/gemini_executor_signature_test.go
+++ b/internal/runtime/executor/gemini_executor_signature_test.go
@@ -3,12 +3,13 @@ package executor
import (
"bytes"
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
@@ -16,7 +17,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -93,7 +93,7 @@ func TestGeminiExecutorExecute_SanitizesClaudeCAISSignature(t *testing.T) {
t.Fatalf("upstream request leaked raw Claude CAIS signature: %s", upstreamBody)
}
- contents := gjson.GetBytes(upstreamBody, "contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "contents").Array()
for _, content := range contents {
if content.Get("role").String() == "model" {
for _, part := range content.Get("parts").Array() {
@@ -230,7 +230,7 @@ func TestGeminiExecutorExecute_FunctionCall_ReplacesClaudeSignatureWithBypass(t
t.Fatalf("Execute() error = %v", err)
}
- gotSig := gjson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String()
+ gotSig := ojson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String()
if gotSig != internalsignature.GeminiSkipThoughtSignatureValidator {
t.Fatalf("first functionCall thoughtSignature = %q, want bypass sentinel %q; upstreamBody=%s",
gotSig, internalsignature.GeminiSkipThoughtSignatureValidator, upstreamBody)
@@ -295,7 +295,7 @@ func TestGeminiExecutorExecute_PreservesNativeGeminiSignature(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- gotSig := gjson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String()
+ gotSig := ojson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String()
if gotSig != nativeSig {
t.Fatalf("thoughtSignature = %q, want preserved native signature %q; upstreamBody=%s",
gotSig, nativeSig, upstreamBody)
@@ -346,7 +346,7 @@ func TestGeminiExecutorExecute_UnsignedRequestNotCorrupted(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- text := gjson.GetBytes(upstreamBody, "contents.0.parts.0.text").String()
+ text := ojson.GetBytes(upstreamBody, "contents.0.parts.0.text").String()
if text != "Hello world" {
t.Fatalf("text = %q, want 'Hello world'; upstreamBody=%s", text, upstreamBody)
}
@@ -402,14 +402,14 @@ func TestGeminiExecutorExecute_ClaudeToolCallsSeparatedByTextKeepBypassSignature
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator {
t.Fatalf("first functionCall thoughtSignature = %q, want %q; upstreamBody=%s",
got, internalsignature.GeminiSkipThoughtSignatureValidator, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "contents.1.parts.1.text").String(); got != "more" {
+ if got := ojson.GetBytes(upstreamBody, "contents.1.parts.1.text").String(); got != "more" {
t.Fatalf("middle text part = %q, want 'more'; upstreamBody=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "contents.1.parts.2.thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(upstreamBody, "contents.1.parts.2.thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator {
t.Fatalf("functionCall after text thoughtSignature = %q, want %q; upstreamBody=%s",
got, internalsignature.GeminiSkipThoughtSignatureValidator, upstreamBody)
}
@@ -609,7 +609,7 @@ func TestGeminiVertexExecutorExecute_PreservesNativeGeminiSignature(t *testing.T
t.Fatalf("Execute() error = %v", err)
}
- gotSig := gjson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String()
+ gotSig := ojson.GetBytes(upstreamBody, "contents.1.parts.0.thoughtSignature").String()
if gotSig != nativeSig {
t.Fatalf("thoughtSignature = %q, want preserved native signature %q; upstreamBody=%s",
gotSig, nativeSig, upstreamBody)
@@ -661,7 +661,7 @@ func TestGeminiVertexExecutorExecute_UnsignedRequestNotCorrupted(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- text := gjson.GetBytes(upstreamBody, "contents.0.parts.0.text").String()
+ text := ojson.GetBytes(upstreamBody, "contents.0.parts.0.text").String()
if text != "Hello world" {
t.Fatalf("text = %q, want 'Hello world'; upstreamBody=%s", text, upstreamBody)
}
diff --git a/internal/runtime/executor/gemini_executor_test.go b/internal/runtime/executor/gemini_executor_test.go
index 8bc6343ccd4..7f71dbebfe4 100644
--- a/internal/runtime/executor/gemini_executor_test.go
+++ b/internal/runtime/executor/gemini_executor_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
@@ -17,7 +18,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCapGeminiMaxOutputTokensUsesOutputTokenLimit(t *testing.T) {
@@ -25,10 +25,10 @@ func TestCapGeminiMaxOutputTokensUsesOutputTokenLimit(t *testing.T) {
out := capGeminiMaxOutputTokens(body, "gemini-3.1-pro-preview")
- if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 65536 {
+ if got := ojson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 65536 {
t.Fatalf("maxOutputTokens = %d, want 65536", got)
}
- if got := gjson.GetBytes(out, "generationConfig.temperature").Float(); got != 0.2 {
+ if got := ojson.GetBytes(out, "generationConfig.temperature").Float(); got != 0.2 {
t.Fatalf("temperature = %v, want 0.2", got)
}
}
@@ -57,7 +57,7 @@ func TestCapGeminiMaxOutputTokensLeavesAllowedOrUnknown(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := capGeminiMaxOutputTokens(tt.body, tt.model)
- if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
+ if got := ojson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
t.Fatalf("maxOutputTokens = %d, want %d", got, tt.want)
}
})
@@ -71,7 +71,7 @@ func TestGeminiExecutorExecuteCapsMaxOutputTokensBeforeUpstream(t *testing.T) {
if err != nil {
t.Fatalf("read request body: %v", err)
}
- upstreamMaxOutputTokens = gjson.GetBytes(body, "generationConfig.maxOutputTokens").Int()
+ upstreamMaxOutputTokens = ojson.GetBytes(body, "generationConfig.maxOutputTokens").Int()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2}}`))
}))
@@ -124,7 +124,7 @@ func TestGeminiExecutorExecutePrependsLeadingUser(t *testing.T) {
if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- contents := gjson.GetBytes(upstreamBody, "contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "contents").Array()
if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" || contents[2].Get("role").String() != "user" {
t.Fatalf("upstream roles malformed: %s", upstreamBody)
}
@@ -157,7 +157,7 @@ func TestGeminiExecutorExecutePrependsLeadingUserForIssue4959ResponsesHistory(t
}, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- assertIssue4959LeadingUserContents(t, gjson.GetBytes(upstreamBody, "contents").Array())
+ assertIssue4959LeadingUserContents(t, ojson.GetBytes(upstreamBody, "contents").Array())
}
func TestGeminiExecutorExecuteAppendsTrailingUserForTrailingModelTurn(t *testing.T) {
@@ -189,7 +189,7 @@ func TestGeminiExecutorExecuteAppendsTrailingUserForTrailingModelTurn(t *testing
if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- contents := gjson.GetBytes(upstreamBody, "contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "contents").Array()
if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" || contents[2].Get("role").String() != "user" {
t.Fatalf("upstream roles malformed: %s", upstreamBody)
}
@@ -228,7 +228,7 @@ func TestGeminiExecutorCountTokensPrependsLeadingUser(t *testing.T) {
if !cliproxyexecutor.UpstreamAttempted(ctx) {
t.Fatal("CountTokens() did not mark the HTTP request as an upstream attempt")
}
- contents := gjson.GetBytes(upstreamBody, "contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" {
t.Fatalf("countTokens roles malformed: %s", upstreamBody)
}
@@ -243,7 +243,7 @@ func TestGeminiExecutorCountTokensPrependsLeadingUser(t *testing.T) {
if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil {
t.Fatalf("Execute(countTokens) error = %v", errExecute)
}
- contents = gjson.GetBytes(upstreamBody, "contents").Array()
+ contents = ojson.GetBytes(upstreamBody, "contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" {
t.Fatalf("Execute(countTokens) roles malformed: %s", upstreamBody)
}
@@ -281,7 +281,7 @@ func TestGeminiExecutorAppliesPayloadRulesAfterLeadingUserNormalization(t *testi
if _, errExecute := executor.Execute(context.Background(), auth, request, cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatGemini}); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- contents := gjson.GetBytes(upstreamBody, "contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "contents").Array()
if len(contents) != 3 || contents[0].Get("role").String() != "user" || contents[1].Get("role").String() != "model" {
t.Fatalf("upstream roles malformed: %s", upstreamBody)
}
@@ -336,10 +336,10 @@ func TestGeminiExecutorInteractionsWithGeminiAPIKeyUsesGeminiEndpoint(t *testing
if gotRevision != "" {
t.Fatalf("Api-Revision = %q, want empty for Gemini protocol request", gotRevision)
}
- if !gjson.GetBytes(upstreamBody, "contents.0.parts.0.text").Exists() {
+ if !ojson.GetBytes(upstreamBody, "contents.0.parts.0.text").Exists() {
t.Fatalf("contents text missing from translated Gemini body: %s", string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "input").Exists() {
+ if ojson.GetBytes(upstreamBody, "input").Exists() {
t.Fatalf("raw interactions input exists in translated Gemini body: %s", string(upstreamBody))
}
}
@@ -355,7 +355,7 @@ func TestGeminiExecutorNativeInteractionsUsesInteractionsEndpoint(t *testing.T)
if errRead != nil {
t.Fatalf("read request body: %v", errRead)
}
- gotModelExists = gjson.GetBytes(body, "model").Exists()
+ gotModelExists = ojson.GetBytes(body, "model").Exists()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`))
}))
@@ -390,7 +390,7 @@ func TestGeminiExecutorNativeInteractionsUsesInteractionsEndpoint(t *testing.T)
if gotModelExists {
t.Fatal("model field exists for agent-only request, want absent")
}
- if got := gjson.GetBytes(resp.Payload, "id").String(); got != "interaction_1" {
+ if got := ojson.GetBytes(resp.Payload, "id").String(); got != "interaction_1" {
t.Fatalf("response id = %q, want interaction_1", got)
}
}
@@ -438,13 +438,13 @@ func TestGeminiExecutorNativeInteractionsTranslatesOpenAIResponsesRequest(t *tes
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
- if got := gjson.GetBytes(upstreamBody, "input.0.type").String(); got != "user_input" {
+ if got := ojson.GetBytes(upstreamBody, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(resp.Payload, "output.0.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytes(resp.Payload, "output.0.content.0.text").String(); got != "ok" {
t.Fatalf("response text = %q, want ok. Payload: %s", got, string(resp.Payload))
}
}
@@ -506,7 +506,7 @@ func TestGeminiExecutorNativeInteractionsPayloadRulesUseResponsesFromProtocol(t
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "detailed" {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "detailed" {
t.Fatalf("thinking_summaries = %q, want detailed. Body: %s", got, string(upstreamBody))
}
}
@@ -561,29 +561,29 @@ func TestGeminiExecutorNativeInteractionsTranslatesOpenAIChatRequest(t *testing.
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "choices.0.delta.tool_calls.0.function.name").String() == "get_weather" {
+ if ojson.GetBytes(chunk.Payload, "choices.0.delta.tool_calls.0.function.name").String() == "get_weather" {
toolStart = chunk.Payload
}
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
- if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
+ if got := ojson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("translated request text = %q. Body: %s", got, string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "messages").Exists() {
+ if ojson.GetBytes(upstreamBody, "messages").Exists() {
t.Fatalf("raw OpenAI messages should not be sent upstream: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" {
t.Fatalf("translated tool type = %q, want function. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.tool_choice").String(); got != "auto" {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("translated tool choice = %q, want auto. Body: %s", got, string(upstreamBody))
}
if toolStart == nil {
t.Fatal("OpenAI tool call chunk not found")
}
- if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
+ if got := ojson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart))
}
}
@@ -639,10 +639,10 @@ func TestGeminiExecutorNativeInteractionsPayloadDefaultsUseTranslatedOpenAIChatS
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.temperature").Float(); got != 0.2 {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.temperature").Float(); got != 0.2 {
t.Fatalf("temperature = %v, want 0.2. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.top_p").Float(); got != 0.8 {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want default 0.8. Body: %s", got, string(upstreamBody))
}
}
@@ -698,23 +698,23 @@ func TestGeminiExecutorNativeInteractionsTranslatesGeminiStreamResponse(t *testi
if chunk.Err != nil {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "event_type").Exists() {
+ if ojson.GetBytes(chunk.Payload, "event_type").Exists() {
t.Fatalf("interactions payload leaked to Gemini response: %s", string(chunk.Payload))
}
- if gjson.GetBytes(chunk.Payload, "candidates.0.content.parts.0.functionCall").Exists() {
+ if ojson.GetBytes(chunk.Payload, "candidates.0.content.parts.0.functionCall").Exists() {
callChunk = chunk.Payload
}
- if gjson.GetBytes(chunk.Payload, "candidates.0.finishReason").Exists() {
+ if ojson.GetBytes(chunk.Payload, "candidates.0.finishReason").Exists() {
finishChunk = chunk.Payload
}
}
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
- if gjson.GetBytes(upstreamBody, "contents").Exists() {
+ if ojson.GetBytes(upstreamBody, "contents").Exists() {
t.Fatalf("raw Gemini contents should not be sent upstream: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
+ if got := ojson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("translated request text = %q. Body: %s", got, string(upstreamBody))
}
if chunkCount != 2 {
@@ -723,28 +723,28 @@ func TestGeminiExecutorNativeInteractionsTranslatesGeminiStreamResponse(t *testi
if callChunk == nil {
t.Fatal("Gemini functionCall chunk not found")
}
- if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
+ if got := ojson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(callChunk))
}
- if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
+ if got := ojson.GetBytes(callChunk, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(callChunk))
}
- if got := gjson.GetBytes(callChunk, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
+ if got := ojson.GetBytes(callChunk, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(callChunk))
}
if finishChunk == nil {
t.Fatal("Gemini finish chunk not found")
}
- if got := gjson.GetBytes(finishChunk, "candidates.0.finishReason").String(); got != "STOP" {
+ if got := ojson.GetBytes(finishChunk, "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(finishChunk))
}
- if got := gjson.GetBytes(finishChunk, "usageMetadata.promptTokenCount").Int(); got != 2 {
+ if got := ojson.GetBytes(finishChunk, "usageMetadata.promptTokenCount").Int(); got != 2 {
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(finishChunk))
}
- if got := gjson.GetBytes(finishChunk, "usageMetadata.candidatesTokenCount").Int(); got != 3 {
+ if got := ojson.GetBytes(finishChunk, "usageMetadata.candidatesTokenCount").Int(); got != 3 {
t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(finishChunk))
}
- if got := gjson.GetBytes(finishChunk, "usageMetadata.totalTokenCount").Int(); got != 5 {
+ if got := ojson.GetBytes(finishChunk, "usageMetadata.totalTokenCount").Int(); got != 5 {
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(finishChunk))
}
}
@@ -812,19 +812,19 @@ func TestGeminiExecutorNativeInteractionsTranslatesClaudeRequest(t *testing.T) {
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
- if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("translated request text = %q, want hi. Body: %s", got, string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "messages").Exists() {
+ if ojson.GetBytes(upstreamBody, "messages").Exists() {
t.Fatalf("raw Claude messages should not be sent upstream: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytes(upstreamBody, "tools.0.type").String(); got != "function" {
t.Fatalf("translated tool type = %q, want function. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(resp.Payload, "content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytes(resp.Payload, "content.0.text").String(); got != "ok" {
t.Fatalf("response text = %q, want ok. Payload: %s", got, string(resp.Payload))
}
- if got := gjson.GetBytes(resp.Payload, "usage.output_tokens").Int(); got != 1 {
+ if got := ojson.GetBytes(resp.Payload, "usage.output_tokens").Int(); got != 1 {
t.Fatalf("response output tokens = %d, want 1. Payload: %s", got, string(resp.Payload))
}
}
@@ -869,7 +869,7 @@ func TestGeminiExecutorNativeInteractionsStripsClaudeToolIDsBeforeUpstream(t *te
t.Fatalf("Execute() error = %v", errExecute)
}
- inputs := gjson.GetBytes(upstreamBody, "input").Array()
+ inputs := ojson.GetBytes(upstreamBody, "input").Array()
if len(inputs) == 0 {
t.Fatalf("upstream input missing: %s", upstreamBody)
}
@@ -968,7 +968,7 @@ func TestGeminiExecutorNativeInteractionsStripsPassthroughInputIDsBeforeUpstream
t.Fatalf("Execute() error = %v", errExecute)
}
- inputs := gjson.GetBytes(upstreamBody, "input").Array()
+ inputs := ojson.GetBytes(upstreamBody, "input").Array()
if len(inputs) != 3 {
t.Fatalf("upstream input count = %d, want 3. Body: %s", len(inputs), upstreamBody)
}
@@ -982,19 +982,19 @@ func TestGeminiExecutorNativeInteractionsStripsPassthroughInputIDsBeforeUpstream
}
}
}
- if got := gjson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytes(upstreamBody, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("user text = %q, want hi. Body: %s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "input.1.id").String(); got != "toolu_1" {
+ if got := ojson.GetBytes(upstreamBody, "input.1.id").String(); got != "toolu_1" {
t.Fatalf("function_call id = %q, want toolu_1. Body: %s", got, upstreamBody)
}
- if gjson.GetBytes(upstreamBody, "input.1.call_id").Exists() {
+ if ojson.GetBytes(upstreamBody, "input.1.call_id").Exists() {
t.Fatalf("function_call call_id must be omitted. Body: %s", upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "input.2.call_id").String(); got != "toolu_1" {
+ if got := ojson.GetBytes(upstreamBody, "input.2.call_id").String(); got != "toolu_1" {
t.Fatalf("function_result call_id = %q, want toolu_1. Body: %s", got, upstreamBody)
}
- if gjson.GetBytes(upstreamBody, "input.2.id").Exists() {
+ if ojson.GetBytes(upstreamBody, "input.2.id").Exists() {
t.Fatalf("function_result id must be omitted. Body: %s", upstreamBody)
}
})
@@ -1033,19 +1033,19 @@ func TestGeminiExecutorNativeInteractionsAppliesThinkingSuffix(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(upstreamBody, "model").String(); got != "gemini-3.1-flash-lite" {
+ if got := ojson.GetBytes(upstreamBody, "model").String(); got != "gemini-3.1-flash-lite" {
t.Fatalf("model = %q, want gemini-3.1-flash-lite. Body: %s", got, string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "generationConfig").Exists() {
+ if ojson.GetBytes(upstreamBody, "generationConfig").Exists() {
t.Fatalf("generationConfig exists, want Interactions snake_case only. Body: %s", string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() {
+ if ojson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() {
t.Fatalf("thinking_config exists, want native Interactions fields. Body: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").Exists() {
+ if ojson.GetBytes(upstreamBody, "generation_config.thinking_summaries").Exists() {
t.Fatalf("thinking_summaries should be absent without explicit summary intent. Body: %s", string(upstreamBody))
}
}
@@ -1082,16 +1082,16 @@ func TestGeminiExecutorNativeInteractionsPreservesThinkingProtocolFields(t *test
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if gjson.GetBytes(upstreamBody, "generationConfig").Exists() {
+ if ojson.GetBytes(upstreamBody, "generationConfig").Exists() {
t.Fatalf("generationConfig exists, want Interactions snake_case only. Body: %s", string(upstreamBody))
}
- if gjson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() {
+ if ojson.GetBytes(upstreamBody, "generation_config.thinking_config").Exists() {
t.Fatalf("thinking_config exists, want native Interactions fields. Body: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" {
+ if got := ojson.GetBytes(upstreamBody, "generation_config.thinking_summaries").String(); got != "auto" {
t.Fatalf("thinking_summaries = %q, want auto. Body: %s", got, string(upstreamBody))
}
}
@@ -1234,7 +1234,7 @@ func TestGeminiExecutorNativeInteractionsStreamParsesUsage(t *testing.T) {
t.Fatalf("chunk = %q, want complete SSE frame", string(chunk.Payload))
}
payload := geminiInteractionsSSEPayload(chunk.Payload)
- if gjson.GetBytes(payload, "event_type").String() == "interaction.completed" {
+ if ojson.GetBytes(payload, "event_type").String() == "interaction.completed" {
completed = payload
}
}
@@ -1244,13 +1244,13 @@ func TestGeminiExecutorNativeInteractionsStreamParsesUsage(t *testing.T) {
if completed == nil {
t.Fatal("interaction.completed chunk not found")
}
- if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 2 {
+ if got := ojson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 2 {
t.Fatalf("total_input_tokens = %d, want 2", got)
}
- if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 3 {
+ if got := ojson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 3 {
t.Fatalf("total_output_tokens = %d, want 3", got)
}
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 5 {
t.Fatalf("total_tokens = %d, want 5", got)
}
}
@@ -1296,13 +1296,13 @@ func TestGeminiExecutorNativeInteractionsClaudeStreamPreservesToolSignature(t *t
t.Fatalf("stream chunk error: %v", chunk.Err)
}
payload := geminiInteractionsSSEPayload(chunk.Payload)
- switch gjson.GetBytes(payload, "type").String() {
+ switch ojson.GetBytes(payload, "type").String() {
case "content_block_start":
- if gjson.GetBytes(payload, "content_block.type").String() == "tool_use" {
+ if ojson.GetBytes(payload, "content_block.type").String() == "tool_use" {
toolStart = payload
}
case "content_block_delta":
- if gjson.GetBytes(payload, "delta.type").String() == "input_json_delta" {
+ if ojson.GetBytes(payload, "delta.type").String() == "input_json_delta" {
toolDelta = payload
}
case "message_stop":
@@ -1312,10 +1312,10 @@ func TestGeminiExecutorNativeInteractionsClaudeStreamPreservesToolSignature(t *t
if toolStart == nil {
t.Fatal("tool content_block_start chunk not found")
}
- if got := gjson.GetBytes(toolStart, "content_block.signature").String(); got != "sig_1" {
+ if got := ojson.GetBytes(toolStart, "content_block.signature").String(); got != "sig_1" {
t.Fatalf("tool signature = %q, want sig_1. Payload: %s", got, string(toolStart))
}
- if got := gjson.GetBytes(toolDelta, "delta.partial_json").String(); got != `{"location":"北京"}` {
+ if got := ojson.GetBytes(toolDelta, "delta.partial_json").String(); got != `{"location":"北京"}` {
t.Fatalf("tool partial_json = %q, want location payload. Payload: %s", got, string(toolDelta))
}
if messageStop == nil {
@@ -1487,7 +1487,7 @@ func TestGeminiExecutorClaudeMalformedFunctionCallReturnsRetryableError(t *testi
if !okStatus || status.StatusCode() != http.StatusBadGateway {
t.Fatalf("Execute() error = %T %v, want status 502", errExec, errExec)
}
- if got := gjson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
+ if got := ojson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
t.Fatalf("Execute() error.type = %q, want api_error. Error: %s", got, errExec.Error())
}
assertMalformedFunctionCallFailedUsage(t, capture.records)
@@ -1776,6 +1776,95 @@ func TestGeminiExecutorPromptFeedbackBlockWithoutCandidatesReturnsError(t *testi
t.Fatalf("geminiPromptBlockErrorMessage(wrapped) = (%q, %v), wantSAFETY blocked true", msg, blocked)
}
})
+
+ t.Run("mid_stream_gemini_filter_block_returns_400_request_fault", func(t *testing.T) {
+ thoughtFrame := `{"response":{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"thinking"}]}}],"usageMetadata":{"promptTokenCount":100,"thoughtsTokenCount":10,"totalTokenCount":110},"modelVersion":"gemini-4-argon-b","responseId":"resp-1"}}`
+ filterFrame := `{"response":{"candidates":[{"content":{"parts":[{"text":"This request was blocked by Gemini's filters. They can occasionally trigger by mistake on safe coding, security, or biology-related queries. Please try rephrasing your prompt. You can [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting#file-bug) or read more about [our policies here](https://policies.google.com/terms/generative-ai/use-policy)."}]}}]}}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: " + thoughtFrame + "\n\ndata: " + filterFrame + "\n\n"))
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "gemini",
+ Attributes: map[string]string{
+ "api_key": "test-key",
+ "base_url": server.URL,
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon-high",
+ Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatGemini,
+ ResponseFormat: sdktranslator.FormatGemini,
+ }
+ streamRes, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ var streamErr error
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ streamErr = chunk.Err
+ }
+ }
+ if streamErr == nil {
+ t.Fatal("ExecuteStream() chunk.Err = nil, want 400 filter block error")
+ }
+ streamStatus, okStatus := streamErr.(interface{ StatusCode() int })
+ if !okStatus || streamStatus.StatusCode() != http.StatusBadRequest {
+ t.Fatalf("ExecuteStream() chunk.Err = %T %v, want status 400", streamErr, streamErr)
+ }
+ })
+
+ t.Run("mid_stream_quoted_filter_notice_followed_by_completion_succeeds", func(t *testing.T) {
+ filterFrame := `{"response":{"candidates":[{"content":{"parts":[{"text":"This request was blocked by Gemini's filters. They can occasionally trigger by mistake on safe coding, security, or biology-related queries. Please try rephrasing your prompt. You can [send feedback](https://ai.google.dev/gemini-api/docs/troubleshooting#file-bug) or read more about [our policies here](https://policies.google.com/terms/generative-ai/use-policy)."}]}}]}}`
+ finalFrame := `{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":" (quoted above)"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":12,"candidatesTokenCount":5,"totalTokenCount":17},"modelVersion":"gemini-4-argon-b","responseId":"resp-2"}}`
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: " + filterFrame + "\n\ndata: " + finalFrame + "\n\n"))
+ }))
+ defer server.Close()
+
+ exec := NewGeminiExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ ID: t.Name(),
+ Provider: "gemini",
+ Attributes: map[string]string{
+ "api_key": "test-key",
+ "base_url": server.URL,
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "gemini-4-argon-high",
+ Payload: []byte(`{"contents":[{"role":"user","parts":[{"text":"quote notice"}]}]}`),
+ }
+ opts := cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatGemini,
+ ResponseFormat: sdktranslator.FormatGemini,
+ }
+ streamRes, errStream := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if errStream != nil {
+ t.Fatalf("ExecuteStream() error = %v", errStream)
+ }
+ var payloadCount int
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("unexpected stream error: %v", chunk.Err)
+ }
+ if len(chunk.Payload) > 0 {
+ payloadCount++
+ }
+ }
+ if payloadCount < 2 {
+ t.Fatalf("expected both quoted chunk and completion chunk, got %d", payloadCount)
+ }
+ })
}
func TestGeminiAndVertexOpenAIStreamTruncationReturnsError(t *testing.T) {
diff --git a/internal/runtime/executor/gemini_interactions_continuation_test.go b/internal/runtime/executor/gemini_interactions_continuation_test.go
index ac75b0a2a8e..6271bbc8fe7 100644
--- a/internal/runtime/executor/gemini_interactions_continuation_test.go
+++ b/internal/runtime/executor/gemini_interactions_continuation_test.go
@@ -8,11 +8,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestIssue5190StatelessTwoToolRounds(t *testing.T) {
@@ -85,29 +85,29 @@ func testIssue5190ToolRounds(t *testing.T, rule string) {
}
}
body := <-requests
- if i > 0 && rule == "explicit" && gjson.GetBytes(body, "previous_interaction_id").String() != "explicit" {
+ if i > 0 && rule == "explicit" && ojson.GetBytes(body, "previous_interaction_id").String() != "explicit" {
t.Errorf("explicit continuation lost: %s", body)
}
if i > 0 && rule == "override" {
- if gjson.GetBytes(body, "previous_interaction_id").String() != "configured" || gjson.GetBytes(body, "environment_id").String() != "configured-env" || gjson.GetBytes(body, "input.0.content").String() != "configured-input" {
+ if ojson.GetBytes(body, "previous_interaction_id").String() != "configured" || ojson.GetBytes(body, "environment_id").String() != "configured-env" || ojson.GetBytes(body, "input.0.content").String() != "configured-input" {
t.Errorf("payload override lost: %s", body)
}
}
if i > 0 && rule == "filter" {
for _, path := range []string{"previous_interaction_id", "environment_id", "input"} {
- if gjson.GetBytes(body, path).Exists() {
+ if ojson.GetBytes(body, path).Exists() {
t.Errorf("filtered %s restored: %s", path, body)
}
}
}
if i > 0 && rule == "baseline" {
- if got := gjson.GetBytes(body, "previous_interaction_id").String(); got != fmt.Sprintf("interaction_%d", i) {
+ if got := ojson.GetBytes(body, "previous_interaction_id").String(); got != fmt.Sprintf("interaction_%d", i) {
t.Errorf("round %d continuation=%q body=%s", i, got, body)
}
- if got := gjson.GetBytes(body, "environment_id").String(); got != "env_1" {
+ if got := ojson.GetBytes(body, "environment_id").String(); got != "env_1" {
t.Errorf("round %d environment=%q", i, got)
}
- if got := gjson.GetBytes(body, "input.#").Int(); got != 1 {
+ if got := ojson.GetBytes(body, "input.#").Int(); got != 1 {
t.Errorf("round %d replayed history: %s", i, body)
}
}
diff --git a/internal/runtime/executor/gemini_interactions_tool_names_test.go b/internal/runtime/executor/gemini_interactions_tool_names_test.go
index 687c71ef58e..894c5739ae7 100644
--- a/internal/runtime/executor/gemini_interactions_tool_names_test.go
+++ b/internal/runtime/executor/gemini_interactions_tool_names_test.go
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestIssue5190ToolNamesAcrossChatAndResponses(t *testing.T) {
@@ -20,7 +20,7 @@ func TestIssue5190ToolNamesAcrossChatAndResponses(t *testing.T) {
}
request := sdktranslator.TranslateRequest(format, sdktranslator.FormatInteractions, model, original, false)
for i, upstream := range []string{"external_read_file", "external_external_read_file"} {
- if got := gjson.GetBytes(request, fmt.Sprintf("tools.%d.name", i)).String(); got != upstream {
+ if got := ojson.GetBytes(request, fmt.Sprintf("tools.%d.name", i)).String(); got != upstream {
t.Fatalf("upstream tool=%q want=%q: %s", got, upstream, request)
}
name := []string{"read_file", "external_read_file"}[i]
@@ -31,7 +31,7 @@ func TestIssue5190ToolNamesAcrossChatAndResponses(t *testing.T) {
if format == sdktranslator.FormatOpenAIResponse {
path = "output.0.name"
}
- if got := gjson.GetBytes(out, path).String(); got != name {
+ if got := ojson.GetBytes(out, path).String(); got != name {
t.Errorf("nonstream tool=%q want=%q: %s", got, name, out)
}
param = nil
@@ -50,7 +50,7 @@ func TestIssue5190ToolNamesAcrossChatAndResponses(t *testing.T) {
if format == sdktranslator.FormatOpenAIResponse {
path = "item.name"
}
- if got := gjson.Get(line, path); got.Exists() {
+ if got := ojson.Get(line, path); got.Exists() {
found = true
if got.String() != name {
t.Errorf("stream tool=%q want=%q: %s", got.String(), name, line)
diff --git a/internal/runtime/executor/gemini_interactions_translate_test.go b/internal/runtime/executor/gemini_interactions_translate_test.go
index 6d9af4ba2e3..5432b950570 100644
--- a/internal/runtime/executor/gemini_interactions_translate_test.go
+++ b/internal/runtime/executor/gemini_interactions_translate_test.go
@@ -5,12 +5,11 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type interactionsTranslateHooks struct {
@@ -19,7 +18,7 @@ type interactionsTranslateHooks struct {
func (h *interactionsTranslateHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte {
h.calls++
- updated, errSet := sjson.SetBytes(body, "plugin_call", h.calls)
+ updated, errSet := ojson.SetBytes(body, "plugin_call", h.calls)
if errSet != nil {
return body
}
@@ -170,7 +169,7 @@ func TestTranslateGeminiInteractionsRequestPairInvokesPluginOncePerInput(t *test
if hooks.calls != 1 {
t.Fatalf("stream=%v compat=%v: plugin calls = %d, want 1", stream, compat, hooks.calls)
}
- if got := gjson.GetBytes(work, "plugin_call").Int(); got != 1 {
+ if got := ojson.GetBytes(work, "plugin_call").Int(); got != 1 {
t.Fatalf("working plugin_call = %d, want 1", got)
}
if !bytes.Equal(base, work) {
@@ -187,10 +186,10 @@ func TestTranslateGeminiInteractionsRequestPairInvokesPluginOncePerInput(t *test
if hooks.calls != before+2 {
t.Fatalf("distinct plugin hook calls = %d, want %d", hooks.calls-before, 2)
}
- if got := gjson.GetBytes(work, "plugin_call").Int(); got != before+1 {
+ if got := ojson.GetBytes(work, "plugin_call").Int(); got != before+1 {
t.Fatalf("distinct working plugin_call = %d, want %d", got, before+1)
}
- if got := gjson.GetBytes(base, "plugin_call").Int(); got != before+2 {
+ if got := ojson.GetBytes(base, "plugin_call").Int(); got != before+2 {
t.Fatalf("distinct baseline plugin_call = %d, want %d", got, before+2)
}
}
diff --git a/internal/runtime/executor/gemini_vertex_bare_tink_signature_test.go b/internal/runtime/executor/gemini_vertex_bare_tink_signature_test.go
index c5fc8bb0d0b..98270afc129 100644
--- a/internal/runtime/executor/gemini_vertex_bare_tink_signature_test.go
+++ b/internal/runtime/executor/gemini_vertex_bare_tink_signature_test.go
@@ -3,18 +3,18 @@ package executor
import (
"bytes"
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// testVertexBareTinkSignature mirrors the Vertex AI serve-path shape: a TINK
@@ -59,7 +59,7 @@ func TestGeminiExecutorReplacesVertexBareTinkSignature(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- contents := gjson.GetBytes(upstreamBody, "contents").Array()
+ contents := ojson.GetBytes(upstreamBody, "contents").Array()
found := false
for _, content := range contents {
for _, part := range content.Get("parts").Array() {
@@ -114,7 +114,7 @@ func TestAntigravityExecutorReplacesVertexBareTinkSignature(t *testing.T) {
func TestGeminiVertexSanitizePreservesBareTinkSignature(t *testing.T) {
sig := testVertexBareTinkSignature()
out := internalsignature.SanitizeGeminiRequestThoughtSignatures(vertexBareTinkGeminiHistory(sig), "contents")
- if got := gjson.GetBytes(out, "contents.1.parts.0.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "contents.1.parts.0.thoughtSignature").String(); got != sig {
t.Fatalf("Vertex path signature = %q, want original bare-Tink value", got)
}
}
diff --git a/internal/runtime/executor/gemini_vertex_executor.go b/internal/runtime/executor/gemini_vertex_executor.go
index 467e2f6e740..bcc774cfcf5 100644
--- a/internal/runtime/executor/gemini_vertex_executor.go
+++ b/internal/runtime/executor/gemini_vertex_executor.go
@@ -15,7 +15,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
vertexauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/vertex"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
@@ -25,8 +27,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
@@ -59,7 +59,7 @@ func getVertexAction(model string, isStream bool) string {
// so it can be processed by the standard translation pipeline.
// This ensures Imagen models return responses in the same format as gemini-3-pro-image-preview.
func convertImagenToGeminiResponse(data []byte, model string) []byte {
- predictions := gjson.GetBytes(data, "predictions")
+ predictions := ojson.GetBytesNoCopy(data, "predictions")
if !predictions.Exists() || !predictions.IsArray() {
return data
}
@@ -117,14 +117,14 @@ func convertToImagenRequest(payload []byte) ([]byte, error) {
prompt := ""
// Try to get prompt from contents[0].parts[0].text
- contentsText := gjson.GetBytes(payload, "contents.0.parts.0.text")
+ contentsText := ojson.GetBytesNoCopy(payload, "contents.0.parts.0.text")
if contentsText.Exists() {
prompt = contentsText.String()
}
// If no contents, try messages format (OpenAI-compatible)
if prompt == "" {
- messagesText := gjson.GetBytes(payload, "messages.#.content")
+ messagesText := ojson.GetBytesNoCopy(payload, "messages.#.content")
if messagesText.Exists() && messagesText.IsArray() {
for _, msg := range messagesText.Array() {
if msg.String() != "" {
@@ -137,7 +137,7 @@ func convertToImagenRequest(payload []byte) ([]byte, error) {
// If still no prompt, try direct prompt field
if prompt == "" {
- directPrompt := gjson.GetBytes(payload, "prompt")
+ directPrompt := ojson.GetBytesNoCopy(payload, "prompt")
if directPrompt.Exists() {
prompt = directPrompt.String()
}
@@ -160,13 +160,13 @@ func convertToImagenRequest(payload []byte) ([]byte, error) {
}
// Extract optional parameters
- if aspectRatio := gjson.GetBytes(payload, "aspectRatio"); aspectRatio.Exists() {
+ if aspectRatio := ojson.GetBytesNoCopy(payload, "aspectRatio"); aspectRatio.Exists() {
imagenReq["parameters"].(map[string]any)["aspectRatio"] = aspectRatio.String()
}
- if sampleCount := gjson.GetBytes(payload, "sampleCount"); sampleCount.Exists() {
+ if sampleCount := ojson.GetBytesNoCopy(payload, "sampleCount"); sampleCount.Exists() {
imagenReq["parameters"].(map[string]any)["sampleCount"] = int(sampleCount.Int())
}
- if negativePrompt := gjson.GetBytes(payload, "negativePrompt"); negativePrompt.Exists() {
+ if negativePrompt := ojson.GetBytesNoCopy(payload, "negativePrompt"); negativePrompt.Exists() {
imagenReq["instances"].([]map[string]any)[0]["negativePrompt"] = negativePrompt.String()
}
@@ -416,6 +416,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
var body []byte
var originalTranslated []byte
+ var doc *ojson.Doc
// Handle Imagen models with special request format
if isImagenModel(baseModel) {
@@ -431,6 +432,7 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
return resp, errImagen
}
}
+ doc = ojson.NewDoc(body)
} else {
// Standard Gemini translation flow
from := opts.SourceFormat
@@ -447,15 +449,15 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
return resp, err
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc = ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return resp, err
}
- body = fixGeminiImageAspectRatio(baseModel, body)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
- body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents")
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.StripVertexOpenAIResponsesToolCallIDsDoc(doc, from.String())
+ internalsignature.SanitizeGeminiRequestThoughtSignaturesDoc(doc, "contents")
}
action := getVertexAction(baseModel, false)
@@ -464,19 +466,20 @@ func (e *GeminiVertexExecutor) executeWithServiceAccount(ctx context.Context, au
action = "countTokens"
}
}
- body = helps.EnsureGeminiLeadingUserContent(body, "contents")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
if action != "countTokens" {
- body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
- body = helps.EnsureGeminiTrailingUserContent(body, "contents")
+ helps.DropGeminiTrailingThoughtOnlyContentDoc(doc, "contents")
+ helps.EnsureGeminiTrailingUserContentDoc(doc, "contents")
}
baseURL := vertexBaseURL(location)
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action)
if opts.Alt != "" && action != "countTokens" {
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
- body, _ = sjson.DeleteBytes(body, "session_id")
+ _ = doc.Delete("session_id")
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "gemini", opts.SourceFormat.String(), "", body, originalTranslated, helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, "gemini", opts.SourceFormat.String(), "", doc, originalTranslated, helps.PayloadRequestedModel(opts, req.Model), helps.PayloadRequestPath(opts), opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, "gemini")
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -600,17 +603,17 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
return resp, err
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return resp, err
}
- body = fixGeminiImageAspectRatio(baseModel, body)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
- body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.StripVertexOpenAIResponsesToolCallIDsDoc(doc, from.String())
+ internalsignature.SanitizeGeminiRequestThoughtSignaturesDoc(doc, "contents")
action := getVertexAction(baseModel, false)
if req.Metadata != nil {
@@ -618,10 +621,10 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
action = "countTokens"
}
}
- body = helps.EnsureGeminiLeadingUserContent(body, "contents")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
if action != "countTokens" {
- body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
- body = helps.EnsureGeminiTrailingUserContent(body, "contents")
+ helps.DropGeminiTrailingThoughtOnlyContentDoc(doc, "contents")
+ helps.EnsureGeminiTrailingUserContentDoc(doc, "contents")
}
// For API key auth, use simpler URL format without project/location.
@@ -632,9 +635,10 @@ func (e *GeminiVertexExecutor) executeWithAPIKey(ctx context.Context, auth *clip
if opts.Alt != "" && action != "countTokens" {
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
- body, _ = sjson.DeleteBytes(body, "session_id")
+ _ = doc.Delete("session_id")
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -744,21 +748,21 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
return nil, err
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return nil, err
}
- body = fixGeminiImageAspectRatio(baseModel, body)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
- body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.StripVertexOpenAIResponsesToolCallIDsDoc(doc, from.String())
+ internalsignature.SanitizeGeminiRequestThoughtSignaturesDoc(doc, "contents")
action := getVertexAction(baseModel, true)
- body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
- body = helps.EnsureGeminiBoundaryUserContent(body, "contents")
+ helps.DropGeminiTrailingThoughtOnlyContentDoc(doc, "contents")
+ helps.EnsureGeminiBoundaryUserContentDoc(doc, "contents")
baseURL := vertexBaseURL(location)
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, action)
// Imagen models don't support streaming, skip SSE params
@@ -769,9 +773,10 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
}
- body, _ = sjson.DeleteBytes(body, "session_id")
+ _ = doc.Delete("session_id")
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -848,13 +853,41 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
}()
var terminalDelivered bool
var sawFinishReason bool
+ var pendingFilterLine []byte
+ var pendingFilterMsg string
var param any
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m)
+ emitTranslatedLine := func(line []byte) bool {
+ lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens)
+ helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
+ for i := range lines {
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ if helps.IsGeminiTerminalStreamChunk(lines[i]) {
+ terminalDelivered = true
+ }
+ case <-ctx.Done():
+ return false
+ }
+ }
+ return !helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ }
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
rawPayload := helps.JSONPayload(line)
+ if len(rawPayload) > 0 && len(pendingFilterLine) > 0 {
+ flushed := pendingFilterLine
+ pendingFilterLine = nil
+ pendingFilterMsg = ""
+ if !emitTranslatedLine(flushed) {
+ return
+ }
+ }
if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) {
sawFinishReason = true
}
@@ -871,24 +904,33 @@ func (e *GeminiVertexExecutor) executeStreamWithServiceAccount(ctx context.Conte
}
return
}
- lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens)
- helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
- helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
- return statusErr{code: http.StatusBadGateway, msg: msg}
- })
- for i := range lines {
- select {
- case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
- if helps.IsGeminiTerminalStreamChunk(lines[i]) {
- terminalDelivered = true
- }
- case <-ctx.Done():
- return
- }
+ if filterMsg, blocked := clienterror.GeminiFilterBlockMessage(rawPayload); blocked {
+ pendingFilterLine = bytes.Clone(line)
+ pendingFilterMsg = filterMsg
+ continue
}
- if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
+ if !emitTranslatedLine(line) {
+ return
+ }
+ }
+ if pendingFilterMsg != "" && scanner.Err() == nil && !sawFinishReason {
+ filterErr := statusErr{code: http.StatusBadRequest, msg: pendingFilterMsg}
+ helps.RecordAPIResponseError(ctx, e.cfg, filterErr)
+ if !streamUsage.PublishFailure(ctx, reporter, filterErr) {
+ reporter.PublishFailure(ctx, filterErr)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: filterErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
+ if len(pendingFilterLine) > 0 {
+ if !emitTranslatedLine(pendingFilterLine) {
return
}
+ pendingFilterLine = nil
+ pendingFilterMsg = ""
}
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
@@ -967,21 +1009,21 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
return nil, err
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return nil, err
}
- body = fixGeminiImageAspectRatio(baseModel, body)
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.StripVertexOpenAIResponsesToolCallIDs(body, from.String())
- body = internalsignature.SanitizeGeminiRequestThoughtSignatures(body, "contents")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.StripVertexOpenAIResponsesToolCallIDsDoc(doc, from.String())
+ internalsignature.SanitizeGeminiRequestThoughtSignaturesDoc(doc, "contents")
action := getVertexAction(baseModel, true)
- body = helps.DropGeminiTrailingThoughtOnlyContent(body, "contents")
- body = helps.EnsureGeminiBoundaryUserContent(body, "contents")
+ helps.DropGeminiTrailingThoughtOnlyContentDoc(doc, "contents")
+ helps.EnsureGeminiBoundaryUserContentDoc(doc, "contents")
// For API key auth, use simpler URL format without project/location
url := vertexAPIKeyModelsURL(baseURL, baseModel, action)
// Imagen models don't support streaming, skip SSE params
@@ -992,9 +1034,10 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
}
}
- body, _ = sjson.DeleteBytes(body, "session_id")
+ _ = doc.Delete("session_id")
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, to.String())
httpReq, errNewReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewReq != nil {
@@ -1068,13 +1111,41 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
}()
var terminalDelivered bool
var sawFinishReason bool
+ var pendingFilterLine []byte
+ var pendingFilterMsg string
var param any
helps.InitializeApplyPatchStream(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, ¶m)
+ emitTranslatedLine := func(line []byte) bool {
+ lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens)
+ helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
+ return statusErr{code: http.StatusBadGateway, msg: msg}
+ })
+ for i := range lines {
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
+ if helps.IsGeminiTerminalStreamChunk(lines[i]) {
+ terminalDelivered = true
+ }
+ case <-ctx.Done():
+ return false
+ }
+ }
+ return !helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
+ }
for scanner.Scan() {
line := scanner.Bytes()
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
reporter.ObserveResponseModel(line)
rawPayload := helps.JSONPayload(line)
+ if len(rawPayload) > 0 && len(pendingFilterLine) > 0 {
+ flushed := pendingFilterLine
+ pendingFilterLine = nil
+ pendingFilterMsg = ""
+ if !emitTranslatedLine(flushed) {
+ return
+ }
+ }
if !sawFinishReason && geminiStreamHasFinishSignal(rawPayload) {
sawFinishReason = true
}
@@ -1091,25 +1162,34 @@ func (e *GeminiVertexExecutor) executeStreamWithAPIKey(ctx context.Context, auth
}
return
}
- lines := helps.TranslateStreamWithClaudeInputTokens(ctx, to, responseFormat, req.Model, helps.ApplyPatchOriginalRequest(req, opts), body, bytes.Clone(line), ¶m, claudeInputTokens)
- helps.RecordApplyPatchStreamFailure(ctx, param, reporter, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage})
- helps.CaptureTranslatedClaudeStreamError(responseFormat, lines, &streamErr, func(msg string) error {
- return statusErr{code: http.StatusBadGateway, msg: msg}
- })
- for i := range lines {
- select {
- case out <- cliproxyexecutor.StreamChunk{Payload: lines[i]}:
- if helps.IsGeminiTerminalStreamChunk(lines[i]) {
- terminalDelivered = true
- }
- case <-ctx.Done():
- return
- }
+ if filterMsg, blocked := clienterror.GeminiFilterBlockMessage(rawPayload); blocked {
+ pendingFilterLine = bytes.Clone(line)
+ pendingFilterMsg = filterMsg
+ continue
}
- if helps.StopApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
+ if !emitTranslatedLine(line) {
return
}
}
+ if pendingFilterMsg != "" && scanner.Err() == nil && !sawFinishReason {
+ filterErr := statusErr{code: http.StatusBadRequest, msg: pendingFilterMsg}
+ helps.RecordAPIResponseError(ctx, e.cfg, filterErr)
+ if !streamUsage.PublishFailure(ctx, reporter, filterErr) {
+ reporter.PublishFailure(ctx, filterErr)
+ }
+ select {
+ case out <- cliproxyexecutor.StreamChunk{Err: filterErr}:
+ case <-ctx.Done():
+ }
+ return
+ }
+ if len(pendingFilterLine) > 0 {
+ if !emitTranslatedLine(pendingFilterLine) {
+ return
+ }
+ pendingFilterLine = nil
+ pendingFilterMsg = ""
+ }
if helps.EndApplyPatchStream(ctx, param, reporter, out, statusErr{code: http.StatusBadGateway, msg: helps.ApplyPatchUpstreamErrorMessage}) {
return
}
@@ -1182,25 +1262,27 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
originalTranslatedForPayload = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false)
}
- translatedReq, err = helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(translatedReq)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return cliproxyexecutor.Response{}, err
}
- translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
- translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
- translatedReq = helps.StripVertexOpenAIResponsesToolCallIDs(translatedReq, from.String())
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
+ _ = doc.Set("model", baseModel)
+ helps.StripVertexOpenAIResponsesToolCallIDsDoc(doc, from.String())
respCtx := context.WithValue(ctx, "alt", opts.Alt)
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
- translatedReq = internalsignature.SanitizeGeminiRequestThoughtSignatures(translatedReq, "contents")
- translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents")
+ _ = doc.Delete("tools")
+ _ = doc.Delete("generationConfig")
+ _ = doc.Delete("safetySettings")
+ internalsignature.SanitizeGeminiRequestThoughtSignaturesDoc(doc, "contents")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
baseURL := vertexBaseURL(location)
url := fmt.Sprintf("%s/%s/projects/%s/locations/%s/publishers/google/models/%s:%s", baseURL, vertexAPIVersion, projectID, location, baseModel, "countTokens")
- translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq)
+ ctxWithFinalizer := helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts))
+ helps.FinalizePayloadDoc(ctxWithFinalizer, doc)
+ translatedReq = doc.Bytes()
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
if errNewReq != nil {
return cliproxyexecutor.Response{}, errNewReq
@@ -1262,7 +1344,7 @@ func (e *GeminiVertexExecutor) countTokensWithServiceAccount(ctx context.Context
return cliproxyexecutor.Response{}, errRead
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
- count := gjson.GetBytes(data, "totalTokens").Int()
+ count := ojson.GetBytesNoCopy(data, "totalTokens").Int()
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, data)
return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil
}
@@ -1284,25 +1366,27 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
originalTranslatedForPayload = helps.TranslateRequestWithCodexMultiAgentV2(ctx, opts.Headers, e.cfg, from, to, baseModel, opts.OriginalRequest, false)
}
- translatedReq, err = helps.ApplyRequestThinking(translatedReq, req, opts, from.String(), to.String(), e.Identifier())
- if err != nil {
+ doc := ojson.NewDoc(translatedReq)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); err != nil {
return cliproxyexecutor.Response{}, err
}
- translatedReq = fixGeminiImageAspectRatio(baseModel, translatedReq)
- translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
- translatedReq = helps.StripVertexOpenAIResponsesToolCallIDs(translatedReq, from.String())
+ fixGeminiImageAspectRatioDoc(baseModel, doc)
+ _ = doc.Set("model", baseModel)
+ helps.StripVertexOpenAIResponsesToolCallIDsDoc(doc, from.String())
respCtx := context.WithValue(ctx, "alt", opts.Alt)
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
- translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
- translatedReq = internalsignature.SanitizeGeminiRequestThoughtSignatures(translatedReq, "contents")
- translatedReq = helps.EnsureGeminiLeadingUserContent(translatedReq, "contents")
+ _ = doc.Delete("tools")
+ _ = doc.Delete("generationConfig")
+ _ = doc.Delete("safetySettings")
+ internalsignature.SanitizeGeminiRequestThoughtSignaturesDoc(doc, "contents")
+ helps.EnsureGeminiLeadingUserContentDoc(doc, "contents")
// For API key auth, use simpler URL format without project/location
url := vertexAPIKeyModelsURL(baseURL, baseModel, "countTokens")
- translatedReq = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translatedReq)
+ ctxWithFinalizer := helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts))
+ helps.FinalizePayloadDoc(ctxWithFinalizer, doc)
+ translatedReq = doc.Bytes()
httpReq, errNewReq := http.NewRequestWithContext(respCtx, http.MethodPost, url, bytes.NewReader(translatedReq))
if errNewReq != nil {
return cliproxyexecutor.Response{}, errNewReq
@@ -1361,7 +1445,7 @@ func (e *GeminiVertexExecutor) countTokensWithAPIKey(ctx context.Context, auth *
return cliproxyexecutor.Response{}, errRead
}
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
- count := gjson.GetBytes(data, "totalTokens").Int()
+ count := ojson.GetBytesNoCopy(data, "totalTokens").Int()
out := sdktranslator.TranslateTokenCount(ctx, to, responseFormat, count, data)
return cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}, nil
}
@@ -1560,18 +1644,17 @@ func (e *GeminiVertexExecutor) executeInteractions(ctx context.Context, auth *cl
if errTranslate != nil {
return resp, errTranslate
}
- if gjson.GetBytes(body, "model").Exists() && targetName != "" {
- body = helps.SetStringIfDifferent(body, "model", targetName)
+ doc := ojson.NewDoc(body)
+ if doc.Get("model").Exists() && targetName != "" {
+ helps.SetStringIfDifferentDoc(doc, "model", targetName)
}
- var errThinking error
- body, errThinking = helps.ApplyGeminiInteractionsThinking(body, req, opts)
- if errThinking != nil {
+ if errThinking := helps.ApplyGeminiInteractionsThinkingDoc(doc, req, opts); errThinking != nil {
return resp, errThinking
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
fromProtocol := opts.SourceFormat.String()
- body = helps.SanitizeGeminiInteractionsUnsupportedInputIDs(body, true)
+ helps.SanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc, true)
apiKey, baseURL := vertexAPICreds(auth)
var projectID string
@@ -1593,7 +1676,8 @@ func (e *GeminiVertexExecutor) executeInteractions(ctx context.Context, auth *cl
url := vertexInteractionsURL(baseURL, projectID, false)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, targetName, "interactions", fromProtocol, "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedRequestMetadata(body, "interactions")
httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
@@ -1689,19 +1773,18 @@ func (e *GeminiVertexExecutor) executeInteractionsStream(ctx context.Context, au
if errTranslate != nil {
return nil, errTranslate
}
- if gjson.GetBytes(body, "model").Exists() && targetName != "" {
- body = helps.SetStringIfDifferent(body, "model", targetName)
+ doc := ojson.NewDoc(body)
+ if doc.Get("model").Exists() && targetName != "" {
+ helps.SetStringIfDifferentDoc(doc, "model", targetName)
}
- var errThinking error
- body, errThinking = helps.ApplyGeminiInteractionsThinking(body, req, opts)
- if errThinking != nil {
+ if errThinking := helps.ApplyGeminiInteractionsThinkingDoc(doc, req, opts); errThinking != nil {
return nil, errThinking
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
fromProtocol := opts.SourceFormat.String()
- body = helps.SanitizeGeminiInteractionsUnsupportedInputIDs(body, true)
- body = helps.SetBoolIfDifferent(body, "stream", true)
+ helps.SanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc, true)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
apiKey, baseURL := vertexAPICreds(auth)
var projectID string
@@ -1723,7 +1806,8 @@ func (e *GeminiVertexExecutor) executeInteractionsStream(ctx context.Context, au
url := vertexInteractionsURL(baseURL, projectID, true)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, targetName, "interactions", fromProtocol, "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, targetName, "interactions", fromProtocol, "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedRequestMetadata(body, "interactions")
httpReq, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
diff --git a/internal/runtime/executor/gemini_vertex_executor_interactions_test.go b/internal/runtime/executor/gemini_vertex_executor_interactions_test.go
index 1c49c49d754..441def88ea3 100644
--- a/internal/runtime/executor/gemini_vertex_executor_interactions_test.go
+++ b/internal/runtime/executor/gemini_vertex_executor_interactions_test.go
@@ -12,11 +12,11 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestGeminiVertexInteractions_ServiceAccount_Execute(t *testing.T) {
@@ -113,10 +113,10 @@ func TestGeminiVertexInteractions_ServiceAccount_Execute(t *testing.T) {
if gotRevision != "2026-05-20" {
t.Fatalf("got Api-Revision = %q, want 2026-05-20", gotRevision)
}
- if !gjson.GetBytes(upstreamBody, "input").Exists() {
+ if !ojson.GetBytes(upstreamBody, "input").Exists() {
t.Fatalf("expected input in upstream body, got: %s", string(upstreamBody))
}
- if gotID := gjson.GetBytes(resp.Payload, "id").String(); gotID != "interaction_vertex_1" {
+ if gotID := ojson.GetBytes(resp.Payload, "id").String(); gotID != "interaction_vertex_1" {
t.Fatalf("got response id = %q, want interaction_vertex_1", gotID)
}
}
@@ -424,7 +424,7 @@ func TestGeminiVertexInteractions_APIKey_Execute(t *testing.T) {
if gotRevision != "2026-05-20" {
t.Fatalf("got Api-Revision = %q, want 2026-05-20", gotRevision)
}
- if gotID := gjson.GetBytes(resp.Payload, "id").String(); gotID != "interaction_vertex_key" {
+ if gotID := ojson.GetBytes(resp.Payload, "id").String(); gotID != "interaction_vertex_key" {
t.Fatalf("got response id = %q, want interaction_vertex_key", gotID)
}
}
@@ -537,7 +537,7 @@ func TestGeminiVertexInteractions_PayloadRulesOrdering(t *testing.T) {
mu.Lock()
defer mu.Unlock()
- if got := gjson.GetBytes(upstreamBody, "custom_rule_param").String(); got != "enforced_after_all_transforms" {
+ if got := ojson.GetBytes(upstreamBody, "custom_rule_param").String(); got != "enforced_after_all_transforms" {
t.Fatalf("custom_rule_param = %q, want enforced_after_all_transforms. Body: %s", got, string(upstreamBody))
}
}
diff --git a/internal/runtime/executor/gemini_vertex_executor_openapi_test.go b/internal/runtime/executor/gemini_vertex_executor_openapi_test.go
index db1961292c3..9cbd6b5a2ac 100644
--- a/internal/runtime/executor/gemini_vertex_executor_openapi_test.go
+++ b/internal/runtime/executor/gemini_vertex_executor_openapi_test.go
@@ -11,12 +11,12 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestGeminiVertexOpenAPI_ServiceAccount_Execute(t *testing.T) {
@@ -114,13 +114,13 @@ func TestGeminiVertexOpenAPI_ServiceAccount_Execute(t *testing.T) {
if gotAuth != "Bearer mock-vertex-sa-token" {
t.Fatalf("got auth = %q, want Bearer mock-vertex-sa-token", gotAuth)
}
- if gotModel := gjson.GetBytes(upstreamBody, "model").String(); gotModel != "deepseek-ai/deepseek-v4.1-flash-maas" {
+ if gotModel := ojson.GetBytes(upstreamBody, "model").String(); gotModel != "deepseek-ai/deepseek-v4.1-flash-maas" {
t.Fatalf("got upstream model = %q, want deepseek-ai/deepseek-v4.1-flash-maas; body=%s", gotModel, string(upstreamBody))
}
- if gotMsg := gjson.GetBytes(upstreamBody, "messages.0.content").String(); gotMsg != "hello maas" {
+ if gotMsg := ojson.GetBytes(upstreamBody, "messages.0.content").String(); gotMsg != "hello maas" {
t.Fatalf("got upstream messages.0.content = %q, want hello maas; body=%s", gotMsg, string(upstreamBody))
}
- if gotText := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); gotText != "hello from vertex maas" {
+ if gotText := ojson.GetBytes(resp.Payload, "choices.0.message.content").String(); gotText != "hello from vertex maas" {
t.Fatalf("got response content = %q, want hello from vertex maas; payload=%s", gotText, string(resp.Payload))
}
@@ -152,7 +152,7 @@ func TestGeminiVertexOpenAPI_ModelNormalization_PublisherResourceAndExplicitOptI
}
body, _ := io.ReadAll(r.Body)
mu.Lock()
- upstreamModels = append(upstreamModels, gjson.GetBytes(body, "model").String())
+ upstreamModels = append(upstreamModels, ojson.GetBytes(body, "model").String())
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
@@ -325,7 +325,7 @@ func TestGeminiVertexOpenAPI_ServiceAccount_ExecuteStream_AndOpenAPILocationOver
if gotAuth != "Bearer mock-vertex-sa-token" {
t.Fatalf("got auth = %q, want Bearer mock-vertex-sa-token", gotAuth)
}
- if !gjson.GetBytes(upstreamBody, "stream_options.include_usage").Bool() {
+ if !ojson.GetBytes(upstreamBody, "stream_options.include_usage").Bool() {
t.Fatalf("expected stream_options.include_usage=true in upstream body: %s", string(upstreamBody))
}
if !strings.Contains(output.String(), "stream ok") {
@@ -442,17 +442,17 @@ func TestGeminiVertexOpenAPI_PayloadRulesOrdering(t *testing.T) {
mu.Lock()
defer mu.Unlock()
for name, body := range map[string][]byte{"Execute": nonStreamBody, "ExecuteStream": streamBody} {
- if got := gjson.GetBytes(body, "custom_openapi_param").String(); got != "final_barrier_applied" {
+ if got := ojson.GetBytes(body, "custom_openapi_param").String(); got != "final_barrier_applied" {
t.Fatalf("%s: custom_openapi_param = %q, want final_barrier_applied; body=%s", name, got, string(body))
}
- if got := gjson.GetBytes(body, "temperature").Float(); got != 0.25 {
+ if got := ojson.GetBytes(body, "temperature").Float(); got != 0.25 {
t.Fatalf("%s: temperature = %v, want 0.25; body=%s", name, got, string(body))
}
- if gjson.GetBytes(body, "top_p").Exists() {
+ if ojson.GetBytes(body, "top_p").Exists() {
t.Fatalf("%s: expected top_p to be filtered out, got body=%s", name, string(body))
}
}
- if gjson.GetBytes(streamBody, "stream_options.include_usage").Bool() {
+ if ojson.GetBytes(streamBody, "stream_options.include_usage").Bool() {
t.Fatalf("ExecuteStream: expected payload override stream_options.include_usage=false to win over built-in default, got body=%s", string(streamBody))
}
}
@@ -532,7 +532,7 @@ func TestGeminiVertexOpenAPI_APIKey_ValidationAndThirdPartyRelay(t *testing.T) {
t.Fatalf("got Authorization = %q, want Bearer test-vertex-key", gotBearer)
}
mu.Unlock()
- if gotText := gjson.GetBytes(resp.Payload, "choices.0.message.content").String(); gotText != "key openapi ok" {
+ if gotText := ojson.GetBytes(resp.Payload, "choices.0.message.content").String(); gotText != "key openapi ok" {
t.Fatalf("got response = %q, want key openapi ok", gotText)
}
@@ -601,7 +601,7 @@ func TestGeminiVertexOpenAPI_CountTokens(t *testing.T) {
if hitUpstream {
t.Fatal("expected CountTokens for OpenAPI model to use local tokenizer without hitting upstream")
}
- if gjson.GetBytes(resp.Payload, "totalTokens").Int() <= 0 {
+ if ojson.GetBytes(resp.Payload, "totalTokens").Int() <= 0 {
t.Fatalf("expected positive totalTokens, got: %s", string(resp.Payload))
}
}
@@ -698,7 +698,7 @@ func TestGeminiVertexOpenAPI_ExplicitOptIn_GeminiClientToolCallThoughtSignatureR
if errTurn1 != nil {
t.Fatalf("Turn 1 Execute() error = %v", errTurn1)
}
- gotSig := gjson.GetBytes(turn1Resp.Payload, "candidates.0.content.parts.0.thoughtSignature").String()
+ gotSig := ojson.GetBytes(turn1Resp.Payload, "candidates.0.content.parts.0.thoughtSignature").String()
if gotSig != "SIG_VERTEX_OPENAPI" {
t.Fatalf("Turn 1 response thoughtSignature = %q, want SIG_VERTEX_OPENAPI; payload=%s", gotSig, string(turn1Resp.Payload))
}
@@ -723,7 +723,7 @@ func TestGeminiVertexOpenAPI_ExplicitOptIn_GeminiClientToolCallThoughtSignatureR
if len(upstreamBodies) != 2 {
t.Fatalf("expected 2 upstream requests, got %d", len(upstreamBodies))
}
- if got := gjson.GetBytes(upstreamBodies[1], "messages.1.tool_calls.0.extra_content.google.thought_signature").String(); got != "SIG_VERTEX_OPENAPI" {
+ if got := ojson.GetBytes(upstreamBodies[1], "messages.1.tool_calls.0.extra_content.google.thought_signature").String(); got != "SIG_VERTEX_OPENAPI" {
t.Fatalf("Turn 2 upstream tool_calls.0.extra_content.google.thought_signature = %q, want SIG_VERTEX_OPENAPI; body=%s", got, string(upstreamBodies[1]))
}
}
@@ -815,7 +815,7 @@ func TestGeminiVertexOpenAPI_ExplicitOptIn_GeminiClientStreamToolCallThoughtSign
}
var gotStreamSig string
for _, c := range turn1Chunks {
- if sig := gjson.GetBytes(c, "candidates.0.content.parts.0.thoughtSignature").String(); sig != "" {
+ if sig := ojson.GetBytes(c, "candidates.0.content.parts.0.thoughtSignature").String(); sig != "" {
gotStreamSig = sig
}
}
@@ -847,7 +847,7 @@ func TestGeminiVertexOpenAPI_ExplicitOptIn_GeminiClientStreamToolCallThoughtSign
if len(upstreamBodies) != 2 {
t.Fatalf("expected 2 upstream stream requests, got %d", len(upstreamBodies))
}
- if got := gjson.GetBytes(upstreamBodies[1], "messages.1.tool_calls.0.extra_content.google.thought_signature").String(); got != "SIG_STREAM_OPENAPI" {
+ if got := ojson.GetBytes(upstreamBodies[1], "messages.1.tool_calls.0.extra_content.google.thought_signature").String(); got != "SIG_STREAM_OPENAPI" {
t.Fatalf("Turn 2 stream upstream tool_calls.0.extra_content.google.thought_signature = %q, want SIG_STREAM_OPENAPI; body=%s", got, string(upstreamBodies[1]))
}
}
@@ -866,7 +866,7 @@ func TestGeminiVertexOpenAPI_PerModelOptInAndOverride(t *testing.T) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
gotPaths = append(gotPaths, r.URL.Path)
- gotModels = append(gotModels, gjson.GetBytes(body, "model").String())
+ gotModels = append(gotModels, ojson.GetBytes(body, "model").String())
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
diff --git a/internal/runtime/executor/gemini_vertex_executor_test.go b/internal/runtime/executor/gemini_vertex_executor_test.go
index 1d89dce3a16..c0157afc705 100644
--- a/internal/runtime/executor/gemini_vertex_executor_test.go
+++ b/internal/runtime/executor/gemini_vertex_executor_test.go
@@ -10,13 +10,13 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const executorPatchRequest = `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}],"input":"patch a file"}`
@@ -25,14 +25,14 @@ const executorPatchInput = " *** Begin Patch\n*** End Patch\n "
func assertExecutorPatchDeclaration(t *testing.T, payload []byte) {
t.Helper()
- declaration := gjson.GetBytes(payload, "tools.0.functionDeclarations.0")
+ declaration := ojson.GetBytes(payload, "tools.0.functionDeclarations.0")
if declaration.Get("name").String() != "functions__apply_patch" || !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || !strings.Contains(declaration.Get("description").String(), "start: patch") || declaration.Get("parametersJsonSchema.properties.input.type").String() != "string" || !declaration.Get("parametersJsonSchema.additionalProperties").Exists() || declaration.Get("parametersJsonSchema.additionalProperties").Bool() {
t.Fatalf("missing standard patch declaration: %s", payload)
}
}
func assertExecutorPatchOutput(t *testing.T, payload []byte) {
t.Helper()
- item := gjson.GetBytes(payload, `output.#(type=="custom_tool_call")`)
+ item := ojson.GetBytes(payload, `output.#(type=="custom_tool_call")`)
if item.Get("type").String() != "custom_tool_call" || item.Get("input").String() != executorPatchInput || item.Get("name").String() != "apply_patch" || item.Get("namespace").String() != "functions" {
t.Fatalf("wrong patch output: %s", payload)
}
@@ -41,7 +41,7 @@ func assertExecutorPatchStream(t *testing.T, chunks <-chan cliproxyexecutor.Stre
t.Helper()
acknowledged := len(acknowledgeSnapshot) == 0
var delta strings.Builder
- var done, item, final gjson.Result
+ var done, item, final ojson.Result
for chunk := range chunks {
if chunk.Err != nil {
t.Fatal(chunk.Err)
@@ -50,7 +50,7 @@ func assertExecutorPatchStream(t *testing.T, chunks <-chan cliproxyexecutor.Stre
if !strings.HasPrefix(line, "data:") {
continue
}
- ev := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ ev := ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
switch ev.Get("type").String() {
case "response.output_text.delta":
if !acknowledged {
@@ -184,7 +184,7 @@ func TestGeminiVertexExecutorClaudeMalformedFunctionCallReturnsRetryableError(t
if !okStatus || status.StatusCode() != http.StatusBadGateway {
t.Fatalf("Execute() error = %T %v, want status 502", errExec, errExec)
}
- if got := gjson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
+ if got := ojson.Get(errExec.Error(), "error.type").String(); got != "api_error" {
t.Fatalf("Execute() error.type = %q, want api_error. Error: %s", got, errExec.Error())
}
assertMalformedFunctionCallFailedUsage(t, capture.records)
diff --git a/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go b/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go
index 38031ecaf73..020159398d2 100644
--- a/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go
+++ b/internal/runtime/executor/gemini_vertex_executor_trailing_reasoning_test.go
@@ -11,11 +11,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -92,7 +92,7 @@ func issue6630VertexContents(t *testing.T, cfg *config.Config, authMode string,
} else if _, errExecute := exec.Execute(ctx, auth, req, opts); errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- return gjson.GetBytes(<-captured, "contents").Raw
+ return ojson.GetBytes(<-captured, "contents").Raw
}
// issue6630AssertContents compares upstream contents with a hand-written JSON
diff --git a/internal/runtime/executor/gemini_vertex_interactions_sse_test.go b/internal/runtime/executor/gemini_vertex_interactions_sse_test.go
index a284a50866b..e9c364bed11 100644
--- a/internal/runtime/executor/gemini_vertex_interactions_sse_test.go
+++ b/internal/runtime/executor/gemini_vertex_interactions_sse_test.go
@@ -7,9 +7,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestGeminiVertexGenerateContentInteractionsSSE(t *testing.T) {
@@ -42,7 +42,7 @@ func TestGeminiVertexGenerateContentInteractionsSSE(t *testing.T) {
events = append(events, after)
}
if after, ok := strings.CutPrefix(line, "data: "); ok {
- payload := gjson.Parse(after)
+ payload := ojson.Parse(after)
if value := payload.Get("delta.text"); value.Exists() {
text += value.String()
}
diff --git a/internal/runtime/executor/helps/antigravity_compaction.go b/internal/runtime/executor/helps/antigravity_compaction.go
index c5c749a04c3..eecdf8a6858 100644
--- a/internal/runtime/executor/helps/antigravity_compaction.go
+++ b/internal/runtime/executor/helps/antigravity_compaction.go
@@ -6,17 +6,17 @@ import (
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
- "encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
"time"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -36,7 +36,7 @@ func RecognizedAntigravityCompactionCapsule(encryptedContent string) bool {
return strings.HasPrefix(strings.TrimSpace(encryptedContent), antigravityCompactionCapsulePrefix)
}
-func isRecognizedCPACompactionItem(item gjson.Result) bool {
+func isRecognizedCPACompactionItem(item ojson.Result) bool {
if item.Get("type").String() != "compaction" {
return false
}
@@ -48,7 +48,7 @@ func isRecognizedCPACompactionItem(item gjson.Result) bool {
// HasRecognizedResponsesCompactionItem checks whether input contains a CPA-minted compaction item.
func HasRecognizedResponsesCompactionItem(payload []byte) bool {
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if !input.IsArray() {
return false
}
@@ -62,7 +62,7 @@ func HasRecognizedResponsesCompactionItem(payload []byte) bool {
// HasResponsesCompactionTrigger checks whether input contains a compaction_trigger item.
func HasResponsesCompactionTrigger(payload []byte) bool {
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if !input.IsArray() {
return false
}
@@ -76,7 +76,7 @@ func HasResponsesCompactionTrigger(payload []byte) bool {
// HasResponsesCompactionItem checks whether input contains a compaction item.
func HasResponsesCompactionItem(payload []byte) bool {
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if !input.IsArray() {
return false
}
@@ -107,10 +107,25 @@ func ResponsesCompactionSourcePayload(req cliproxyexecutor.Request, opts cliprox
// DropForeignResponsesCompactionItems removes compaction items whose encrypted_content does not use the CPA capsule prefix.
func DropForeignResponsesCompactionItems(payload []byte, provider string) []byte {
- input := gjson.GetBytes(payload, "input")
- if !input.IsArray() {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !DropForeignResponsesCompactionItemsDoc(doc, provider) {
return payload
}
+ return doc.Bytes()
+}
+
+// DropForeignResponsesCompactionItemsDoc removes non-CPA compaction items in place on doc.
+func DropForeignResponsesCompactionItemsDoc(doc *ojson.Doc, provider string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
dropped := 0
items := make([]string, 0, len(input.Array()))
for _, item := range input.Array() {
@@ -121,24 +136,24 @@ func DropForeignResponsesCompactionItems(payload []byte, provider string) []byte
items = append(items, item.Raw)
}
if dropped == 0 {
- return payload
+ return false
}
log.Warnf("%s compaction: dropped %d non-CPA compaction item(s)", provider, dropped)
- out, _ := sjson.SetRawBytes(payload, "input", []byte("["+strings.Join(items, ",")+"]"))
- return out
+ return doc.SetRaw("input", "["+strings.Join(items, ",")+"]") == nil
}
// ExpandResponsesCompactionCapsules drops foreign compaction items and expands recognized CPA compaction capsules in req and opts.
func ExpandResponsesCompactionCapsules(req *cliproxyexecutor.Request, opts *cliproxyexecutor.Options, provider string) error {
sameOriginal := req != nil && opts != nil && len(opts.OriginalRequest) > 0 && bytes.Equal(req.Payload, opts.OriginalRequest)
- if req != nil {
- req.Payload = DropForeignResponsesCompactionItems(req.Payload, provider)
- if HasResponsesCompactionItem(req.Payload) {
- expanded, errExpand := ExpandAntigravityCompactionCapsules(req.Payload)
- if errExpand != nil {
- return errExpand
- }
- req.Payload = expanded
+ if req != nil && len(req.Payload) > 0 {
+ doc := ojson.NewDoc(req.Payload)
+ changed := DropForeignResponsesCompactionItemsDoc(doc, provider)
+ expanded, errExpand := ExpandAntigravityCompactionCapsulesDoc(doc)
+ if errExpand != nil {
+ return errExpand
+ }
+ if changed || expanded {
+ req.Payload = doc.Bytes()
}
}
if opts != nil && len(opts.OriginalRequest) > 0 {
@@ -146,13 +161,14 @@ func ExpandResponsesCompactionCapsules(req *cliproxyexecutor.Request, opts *clip
opts.OriginalRequest = req.Payload
return nil
}
- opts.OriginalRequest = DropForeignResponsesCompactionItems(opts.OriginalRequest, provider)
- if HasResponsesCompactionItem(opts.OriginalRequest) {
- expanded, errExpand := ExpandAntigravityCompactionCapsules(opts.OriginalRequest)
- if errExpand != nil {
- return errExpand
- }
- opts.OriginalRequest = expanded
+ doc := ojson.NewDoc(opts.OriginalRequest)
+ changed := DropForeignResponsesCompactionItemsDoc(doc, provider)
+ expanded, errExpand := ExpandAntigravityCompactionCapsulesDoc(doc)
+ if errExpand != nil {
+ return errExpand
+ }
+ if changed || expanded {
+ opts.OriginalRequest = doc.Bytes()
}
}
return nil
@@ -186,30 +202,42 @@ func ExpandRecognizedResponsesCompactionCapsules(req *cliproxyexecutor.Request,
// PrepareAntigravityCompactionSummaryPayload prepares a payload for non-stream Antigravity summary generation.
func PrepareAntigravityCompactionSummaryPayload(payload []byte, modelName string) []byte {
- out := payload
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ PrepareAntigravityCompactionSummaryPayloadDoc(doc, modelName)
+ return doc.Bytes()
+}
+
+// PrepareAntigravityCompactionSummaryPayloadDoc prepares doc in place for non-stream Antigravity summary generation.
+func PrepareAntigravityCompactionSummaryPayloadDoc(doc *ojson.Doc, modelName string) {
+ if doc == nil || doc.Len() == 0 {
+ return
+ }
+ _ = modelName
summaryPrompt := `{"type":"message","role":"user","content":[{"type":"input_text","text":"Please provide a concise and comprehensive summary of the preceding conversation and task progress so far, including user goals, key findings, actions taken, and current status, so that work can continue smoothly."}]}`
- input := gjson.GetBytes(out, "input")
+ input := doc.Get("input")
if input.IsArray() {
var filtered []string
for _, item := range input.Array() {
itemType := item.Get("type").String()
- if itemType == "compaction_trigger" {
+ if itemType == "compaction_trigger" || itemType == "additional_tools" {
continue
}
filtered = append(filtered, item.Raw)
}
filtered = append(filtered, summaryPrompt)
newInput := "[" + strings.Join(filtered, ",") + "]"
- out, _ = sjson.SetRawBytes(out, "input", []byte(newInput))
- } else if input.Type == gjson.String && input.String() != "" {
- userMsg := []byte(`{"type":"message","role":"user","content":[{"type":"input_text"}]}`)
- userMsg, _ = sjson.SetBytes(userMsg, "content.0.text", input.String())
- newInput := "[" + string(userMsg) + "," + summaryPrompt + "]"
- out, _ = sjson.SetRawBytes(out, "input", []byte(newInput))
+ _ = doc.SetRaw("input", newInput)
+ } else if input.Type == ojson.String && input.String() != "" {
+ text := input.String()
+ _ = doc.SetRaw("input", `[{"type":"message","role":"user","content":[{"type":"input_text"}]},`+summaryPrompt+`]`)
+ _ = doc.Set("input.0.content.0.text", text)
} else {
newInput := "[" + summaryPrompt + "]"
- out, _ = sjson.SetRawBytes(out, "input", []byte(newInput))
+ _ = doc.SetRaw("input", newInput)
}
// Remove fields not needed or incompatible with summary generation
@@ -223,11 +251,10 @@ func PrepareAntigravityCompactionSummaryPayload(payload []byte, modelName string
"truncation",
"metadata",
} {
- out, _ = sjson.DeleteBytes(out, field)
+ _ = util.DeleteJSONDocIfExists(doc, field)
}
- out, _ = sjson.SetBytes(out, "stream", false)
- return out
+ _ = doc.Set("stream", false)
}
// deriveAntigravityCompactionKey derives an AES-256 key from the fixed CLIProxyAPI secret.
@@ -312,74 +339,120 @@ func UnsealAntigravityCompaction(encryptedContent string) (string, error) {
// ExpandAntigravityCompactionCapsules expands compaction items in input into developer context.
func ExpandAntigravityCompactionCapsules(payload []byte) ([]byte, error) {
- input := gjson.GetBytes(payload, "input")
- if !input.IsArray() {
+ if len(payload) == 0 {
+ return payload, nil
+ }
+ doc := ojson.NewDoc(payload)
+ changed, errExpand := ExpandAntigravityCompactionCapsulesDoc(doc)
+ if errExpand != nil {
+ return nil, errExpand
+ }
+ if !changed {
return payload, nil
}
+ return doc.Bytes(), nil
+}
- var expandedItems []string
- changed := false
- for _, item := range input.Array() {
+// ExpandAntigravityCompactionCapsulesDoc expands compaction items in input into developer context in place on doc.
+func ExpandAntigravityCompactionCapsulesDoc(doc *ojson.Doc) (bool, error) {
+ if doc == nil || doc.Len() == 0 {
+ return false, nil
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false, nil
+ }
+
+ type capsuleExpansion struct {
+ index int
+ summary string
+ }
+ var expansions []capsuleExpansion
+ for idx, item := range input.Array() {
if item.Get("type").String() == "compaction" {
encryptedContent := item.Get("encrypted_content").String()
summary, errUnseal := UnsealAntigravityCompaction(encryptedContent)
if errUnseal != nil {
- return nil, fmt.Errorf("invalid compaction capsule: %w", errUnseal)
+ return false, fmt.Errorf("invalid compaction capsule: %w", errUnseal)
}
- devMsg := []byte(`{"type":"message","role":"developer","content":[{"type":"input_text"}]}`)
- devMsg, _ = sjson.SetBytes(devMsg, "content.0.text", "Context summary from previous turns:\n"+summary)
- expandedItems = append(expandedItems, string(devMsg))
- changed = true
- continue
+ expansions = append(expansions, capsuleExpansion{index: idx, summary: summary})
}
- expandedItems = append(expandedItems, item.Raw)
}
+ if len(expansions) == 0 {
+ return false, nil
+ }
+ for _, exp := range expansions {
+ itemPath := fmt.Sprintf("input.%d", exp.index)
+ if errSet := doc.SetRaw(itemPath, `{"type":"message","role":"developer","content":[{"type":"input_text"}]}`); errSet != nil {
+ return false, errSet
+ }
+ if errSet := doc.Set(itemPath+".content.0.text", "Context summary from previous turns:\n"+exp.summary); errSet != nil {
+ return false, errSet
+ }
+ }
+ return true, nil
+}
+// ExpandRecognizedAntigravityCompactionCapsules expands recognized CPA compaction items in input into developer context while leaving non-CPA compaction items untouched.
+func ExpandRecognizedAntigravityCompactionCapsules(payload []byte) ([]byte, error) {
+ if len(payload) == 0 {
+ return payload, nil
+ }
+ doc := ojson.NewDoc(payload)
+ changed, errExpand := ExpandRecognizedAntigravityCompactionCapsulesDoc(doc)
+ if errExpand != nil {
+ return nil, errExpand
+ }
if !changed {
return payload, nil
}
-
- newInput := "[" + strings.Join(expandedItems, ",") + "]"
- return sjson.SetRawBytes(payload, "input", []byte(newInput))
+ return doc.Bytes(), nil
}
-// ExpandRecognizedAntigravityCompactionCapsules expands recognized CPA compaction items in input into developer context while leaving non-CPA compaction items untouched.
-func ExpandRecognizedAntigravityCompactionCapsules(payload []byte) ([]byte, error) {
- input := gjson.GetBytes(payload, "input")
+// ExpandRecognizedAntigravityCompactionCapsulesDoc expands recognized CPA compaction items in input in place on doc.
+func ExpandRecognizedAntigravityCompactionCapsulesDoc(doc *ojson.Doc) (bool, error) {
+ if doc == nil || doc.Len() == 0 {
+ return false, nil
+ }
+ input := doc.Get("input")
if !input.IsArray() {
- return payload, nil
+ return false, nil
}
- var expandedItems []string
- changed := false
- for _, item := range input.Array() {
+ type capsuleExpansion struct {
+ index int
+ summary string
+ }
+ var expansions []capsuleExpansion
+ for idx, item := range input.Array() {
if isRecognizedCPACompactionItem(item) {
encryptedContent := item.Get("encrypted_content").String()
summary, errUnseal := UnsealAntigravityCompaction(encryptedContent)
if errUnseal != nil {
- return nil, fmt.Errorf("invalid compaction capsule: %w", errUnseal)
+ return false, fmt.Errorf("invalid compaction capsule: %w", errUnseal)
}
- devMsg := []byte(`{"type":"message","role":"developer","content":[{"type":"input_text"}]}`)
- devMsg, _ = sjson.SetBytes(devMsg, "content.0.text", "Context summary from previous turns:\n"+summary)
- expandedItems = append(expandedItems, string(devMsg))
- changed = true
- continue
+ expansions = append(expansions, capsuleExpansion{index: idx, summary: summary})
}
- expandedItems = append(expandedItems, item.Raw)
}
-
- if !changed {
- return payload, nil
+ if len(expansions) == 0 {
+ return false, nil
}
-
- newInput := "[" + strings.Join(expandedItems, ",") + "]"
- return sjson.SetRawBytes(payload, "input", []byte(newInput))
+ for _, exp := range expansions {
+ itemPath := fmt.Sprintf("input.%d", exp.index)
+ if errSet := doc.SetRaw(itemPath, `{"type":"message","role":"developer","content":[{"type":"input_text"}]}`); errSet != nil {
+ return false, errSet
+ }
+ if errSet := doc.Set(itemPath+".content.0.text", "Context summary from previous turns:\n"+exp.summary); errSet != nil {
+ return false, errSet
+ }
+ }
+ return true, nil
}
// ExtractAntigravitySummaryText extracts summary text from Antigravity or Responses response.
func ExtractAntigravitySummaryText(respPayload []byte) (string, error) {
// 1. OpenAI Responses format: iterate through output array and skip reasoning
- output := gjson.GetBytes(respPayload, "output")
+ output := ojson.GetBytesNoCopy(respPayload, "output")
if output.IsArray() {
var textParts []string
for _, item := range output.Array() {
@@ -394,20 +467,20 @@ func ExtractAntigravitySummaryText(respPayload []byte) (string, error) {
}
}
}
- } else if content.Type == gjson.String && content.String() != "" {
+ } else if content.Type == ojson.String && content.String() != "" {
textParts = append(textParts, content.String())
}
}
}
if len(textParts) > 0 {
- return strings.Join(textParts, "\n"), nil
+ return strings.Clone(strings.Join(textParts, "\n")), nil
}
}
// 2. Antigravity Gemini format: response.candidates.0.content.parts or candidates.0.content.parts
- candidates := gjson.GetBytes(respPayload, "response.candidates")
+ candidates := ojson.GetBytesNoCopy(respPayload, "response.candidates")
if !candidates.Exists() {
- candidates = gjson.GetBytes(respPayload, "candidates")
+ candidates = ojson.GetBytesNoCopy(respPayload, "candidates")
}
if candidates.IsArray() && len(candidates.Array()) > 0 {
var textParts []string
@@ -423,12 +496,12 @@ func ExtractAntigravitySummaryText(respPayload []byte) (string, error) {
}
}
if len(textParts) > 0 {
- return strings.Join(textParts, "\n"), nil
+ return strings.Clone(strings.Join(textParts, "\n")), nil
}
}
// 3. Claude format: content array
- content := gjson.GetBytes(respPayload, "content")
+ content := ojson.GetBytesNoCopy(respPayload, "content")
if content.IsArray() {
var textParts []string
for _, block := range content.Array() {
@@ -439,13 +512,13 @@ func ExtractAntigravitySummaryText(respPayload []byte) (string, error) {
}
}
if len(textParts) > 0 {
- return strings.Join(textParts, "\n"), nil
+ return strings.Clone(strings.Join(textParts, "\n")), nil
}
}
// 4. OpenAI Chat format: choices.0.message.content
- if text := gjson.GetBytes(respPayload, "choices.0.message.content").String(); text != "" {
- return text, nil
+ if text := ojson.GetBytesNoCopy(respPayload, "choices.0.message.content").String(); text != "" {
+ return strings.Clone(text), nil
}
return "", fmt.Errorf("no summary text found in upstream response")
@@ -457,48 +530,58 @@ func BuildAntigravityCompactionStreamChunks(modelName, capsule string, inputToke
responseID := fmt.Sprintf("resp_ag_compact_%d", time.Now().UnixNano())
itemID := fmt.Sprintf("cmp_ag_compact_%d", time.Now().UnixNano())
- itemInProgress := []byte(`{"type":"compaction","status":"in_progress"}`)
- itemInProgress, _ = sjson.SetBytes(itemInProgress, "id", itemID)
- itemInProgress, _ = sjson.SetBytes(itemInProgress, "encrypted_content", capsule)
+ itemInProgressDoc := ojson.NewDocString(`{"type":"compaction","status":"in_progress"}`)
+ _ = itemInProgressDoc.Set("id", itemID)
+ _ = itemInProgressDoc.Set("encrypted_content", capsule)
+ itemInProgress := itemInProgressDoc.Bytes()
- itemCompleted := []byte(`{"type":"compaction","status":"completed"}`)
- itemCompleted, _ = sjson.SetBytes(itemCompleted, "id", itemID)
- itemCompleted, _ = sjson.SetBytes(itemCompleted, "encrypted_content", capsule)
+ itemCompletedDoc := ojson.NewDocString(`{"type":"compaction","status":"completed"}`)
+ _ = itemCompletedDoc.Set("id", itemID)
+ _ = itemCompletedDoc.Set("encrypted_content", capsule)
+ itemCompleted := itemCompletedDoc.Bytes()
- usageNode := []byte(`{"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}}`)
- usageNode, _ = sjson.SetBytes(usageNode, "input_tokens", inputTokens)
- usageNode, _ = sjson.SetBytes(usageNode, "output_tokens", outputTokens)
- usageNode, _ = sjson.SetBytes(usageNode, "total_tokens", totalTokens)
+ usageDoc := ojson.NewDocString(`{"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}}`)
+ _ = usageDoc.Set("input_tokens", inputTokens)
+ _ = usageDoc.Set("output_tokens", outputTokens)
+ _ = usageDoc.Set("total_tokens", totalTokens)
+ usageNode := usageDoc.Bytes()
- createdResponse := []byte(`{"object":"response","status":"in_progress","background":false,"error":null,"output":[]}`)
- createdResponse, _ = sjson.SetBytes(createdResponse, "id", responseID)
- createdResponse, _ = sjson.SetBytes(createdResponse, "created_at", now)
- createdResponse, _ = sjson.SetBytes(createdResponse, "model", modelName)
+ createdResponseDoc := ojson.NewDocString(`{"object":"response","status":"in_progress","background":false,"error":null,"output":[]}`)
+ _ = createdResponseDoc.Set("id", responseID)
+ _ = createdResponseDoc.Set("created_at", now)
+ _ = createdResponseDoc.Set("model", modelName)
+ createdResponse := createdResponseDoc.Bytes()
inProgressResponse := createdResponse
- completedResponse := []byte(`{"object":"response","status":"completed","background":false,"error":null}`)
- completedResponse, _ = sjson.SetBytes(completedResponse, "id", responseID)
- completedResponse, _ = sjson.SetBytes(completedResponse, "created_at", now)
- completedResponse, _ = sjson.SetBytes(completedResponse, "completed_at", now)
- completedResponse, _ = sjson.SetBytes(completedResponse, "model", modelName)
- completedResponse, _ = sjson.SetRawBytes(completedResponse, "output", []byte("["+string(itemCompleted)+"]"))
- completedResponse, _ = sjson.SetRawBytes(completedResponse, "usage", usageNode)
+ completedResponseDoc := ojson.NewDocString(`{"object":"response","status":"completed","background":false,"error":null}`)
+ _ = completedResponseDoc.Set("id", responseID)
+ _ = completedResponseDoc.Set("created_at", now)
+ _ = completedResponseDoc.Set("completed_at", now)
+ _ = completedResponseDoc.Set("model", modelName)
+ _ = completedResponseDoc.SetRaw("output", "["+string(itemCompleted)+"]")
+ _ = completedResponseDoc.SetRawBytes("usage", usageNode)
+ completedResponse := completedResponseDoc.Bytes()
- createdPayload := []byte(`{"type":"response.created","sequence_number":0}`)
- createdPayload, _ = sjson.SetRawBytes(createdPayload, "response", createdResponse)
+ createdPayloadDoc := ojson.NewDocString(`{"type":"response.created","sequence_number":0}`)
+ _ = createdPayloadDoc.SetRawBytes("response", createdResponse)
+ createdPayload := createdPayloadDoc.Bytes()
- inProgressPayload := []byte(`{"type":"response.in_progress","sequence_number":1}`)
- inProgressPayload, _ = sjson.SetRawBytes(inProgressPayload, "response", inProgressResponse)
+ inProgressPayloadDoc := ojson.NewDocString(`{"type":"response.in_progress","sequence_number":1}`)
+ _ = inProgressPayloadDoc.SetRawBytes("response", inProgressResponse)
+ inProgressPayload := inProgressPayloadDoc.Bytes()
- addedPayload := []byte(`{"type":"response.output_item.added","sequence_number":2,"output_index":0}`)
- addedPayload, _ = sjson.SetRawBytes(addedPayload, "item", itemInProgress)
+ addedPayloadDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":2,"output_index":0}`)
+ _ = addedPayloadDoc.SetRawBytes("item", itemInProgress)
+ addedPayload := addedPayloadDoc.Bytes()
- donePayload := []byte(`{"type":"response.output_item.done","sequence_number":3,"output_index":0}`)
- donePayload, _ = sjson.SetRawBytes(donePayload, "item", itemCompleted)
+ donePayloadDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":3,"output_index":0}`)
+ _ = donePayloadDoc.SetRawBytes("item", itemCompleted)
+ donePayload := donePayloadDoc.Bytes()
- completedPayload := []byte(`{"type":"response.completed","sequence_number":4}`)
- completedPayload, _ = sjson.SetRawBytes(completedPayload, "response", completedResponse)
+ completedPayloadDoc := ojson.NewDocString(`{"type":"response.completed","sequence_number":4}`)
+ _ = completedPayloadDoc.SetRawBytes("response", completedResponse)
+ completedPayload := completedPayloadDoc.Bytes()
frames := [][]byte{
buildSSEFrame("response.created", createdPayload),
@@ -516,23 +599,23 @@ func BuildAntigravityCompactionResponse(modelName, capsule string, inputTokens,
responseID := fmt.Sprintf("resp_ag_compact_%d", time.Now().UnixNano())
itemID := fmt.Sprintf("cmp_ag_compact_%d", time.Now().UnixNano())
- itemJSON := []byte(`{"type":"compaction","status":"completed"}`)
- itemJSON, _ = sjson.SetBytes(itemJSON, "id", itemID)
- itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", capsule)
+ itemDoc := ojson.NewDocString(`{"type":"compaction","status":"completed"}`)
+ _ = itemDoc.Set("id", itemID)
+ _ = itemDoc.Set("encrypted_content", capsule)
- usageJSON := []byte(`{"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}}`)
- usageJSON, _ = sjson.SetBytes(usageJSON, "input_tokens", inputTokens)
- usageJSON, _ = sjson.SetBytes(usageJSON, "output_tokens", outputTokens)
- usageJSON, _ = sjson.SetBytes(usageJSON, "total_tokens", totalTokens)
+ usageDoc := ojson.NewDocString(`{"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}}`)
+ _ = usageDoc.Set("input_tokens", inputTokens)
+ _ = usageDoc.Set("output_tokens", outputTokens)
+ _ = usageDoc.Set("total_tokens", totalTokens)
- respJSON := []byte(`{"object":"response.compaction","status":"completed"}`)
- respJSON, _ = sjson.SetBytes(respJSON, "id", responseID)
- respJSON, _ = sjson.SetBytes(respJSON, "created_at", now)
- respJSON, _ = sjson.SetBytes(respJSON, "model", modelName)
- respJSON, _ = sjson.SetRawBytes(respJSON, "output", []byte("["+string(itemJSON)+"]"))
- respJSON, _ = sjson.SetRawBytes(respJSON, "usage", usageJSON)
+ respDoc := ojson.NewDocString(`{"object":"response.compaction","status":"completed"}`)
+ _ = respDoc.Set("id", responseID)
+ _ = respDoc.Set("created_at", now)
+ _ = respDoc.Set("model", modelName)
+ _ = respDoc.SetRaw("output", "["+itemDoc.String()+"]")
+ _ = respDoc.SetRaw("usage", usageDoc.String())
- return respJSON
+ return respDoc.Bytes()
}
// ExtractResponsesCompactionUsage extracts usage counters from an OpenAI Responses summary payload.
@@ -552,33 +635,34 @@ func PatchResponsesCompactionStreamUsage(chunk []byte, inputTokens, outputTokens
return chunk
}
data := bytes.TrimSpace(chunk[idx+len(dataPrefix):])
+ doc := ojson.NewDoc(data)
path := ""
switch {
- case gjson.GetBytes(data, "response.usage").Exists():
+ case doc.Get("response.usage").Exists():
path = "response.usage"
- case gjson.GetBytes(data, "usage").Exists():
+ case doc.Get("usage").Exists():
path = "usage"
default:
return chunk
}
- data, _ = sjson.SetBytes(data, path+".input_tokens", inputTokens)
- data, _ = sjson.SetBytes(data, path+".output_tokens", outputTokens)
- data, _ = sjson.SetBytes(data, path+".total_tokens", totalTokens)
- data, _ = sjson.SetBytes(data, path+".input_tokens_details.cached_tokens", cachedTokens)
- data, _ = sjson.SetBytes(data, path+".output_tokens_details.reasoning_tokens", reasoningTokens)
+ _ = doc.Set(path+".input_tokens", inputTokens)
+ _ = doc.Set(path+".output_tokens", outputTokens)
+ _ = doc.Set(path+".total_tokens", totalTokens)
+ _ = doc.Set(path+".input_tokens_details.cached_tokens", cachedTokens)
+ _ = doc.Set(path+".output_tokens_details.reasoning_tokens", reasoningTokens)
var buf bytes.Buffer
buf.Write(chunk[:idx])
buf.WriteString(dataPrefix)
- buf.Write(data)
+ buf.Write(doc.Bytes())
buf.WriteString("\n\n")
return buf.Bytes()
}
// ValidateResponsesCompactionSummaryStatus ensures the upstream summary response finished with status "completed".
func ValidateResponsesCompactionSummaryStatus(summaryPayload []byte) error {
- status := strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "status").String()))
+ status := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(summaryPayload, "status").String()))
if status == "" {
- status = strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "response.status").String()))
+ status = strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(summaryPayload, "response.status").String()))
}
if status != "completed" {
if status == "" {
@@ -586,15 +670,15 @@ func ValidateResponsesCompactionSummaryStatus(summaryPayload []byte) error {
}
return fmt.Errorf("compaction summary response status is %q, want \"completed\"", status)
}
- if eventType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(summaryPayload, "type").String())); eventType == "response.incomplete" || eventType == "response.failed" {
+ if eventType := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(summaryPayload, "type").String())); eventType == "response.incomplete" || eventType == "response.failed" {
return fmt.Errorf("compaction summary response terminal event is %q", eventType)
}
for _, path := range []string{"incomplete_details", "response.incomplete_details", "error", "response.error"} {
- if node := gjson.GetBytes(summaryPayload, path); node.Exists() && node.Type != gjson.Null {
+ if node := ojson.GetBytesNoCopy(summaryPayload, path); node.Exists() && node.Type != ojson.Null {
return fmt.Errorf("compaction summary response contains %s", path)
}
}
- if output := gjson.GetBytes(summaryPayload, "output"); output.IsArray() {
+ if output := ojson.GetBytesNoCopy(summaryPayload, "output"); output.IsArray() {
for _, item := range output.Array() {
itemStatus := strings.ToLower(strings.TrimSpace(item.Get("status").String()))
if itemStatus != "" && itemStatus != "completed" {
@@ -620,23 +704,24 @@ func BuildResponsesCompactionResponseFromSummary(modelName string, summaryPayloa
}
inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens := ExtractResponsesCompactionUsage(summaryPayload)
respPayload := BuildAntigravityCompactionResponse(modelName, capsule, inputTokens, outputTokens, totalTokens)
- respPayload, _ = sjson.SetBytes(respPayload, "usage.input_tokens_details.cached_tokens", cachedTokens)
- respPayload, _ = sjson.SetBytes(respPayload, "usage.output_tokens_details.reasoning_tokens", reasoningTokens)
- return respPayload, nil
+ respDoc := ojson.NewDoc(respPayload)
+ _ = respDoc.Set("usage.input_tokens_details.cached_tokens", cachedTokens)
+ _ = respDoc.Set("usage.output_tokens_details.reasoning_tokens", reasoningTokens)
+ return respDoc.Bytes(), nil
}
// BuildResponsesCompactionStreamChunksFromResponse converts a sealed response.compaction payload into SSE stream chunks.
func BuildResponsesCompactionStreamChunksFromResponse(modelName string, compactionPayload []byte) ([][]byte, error) {
- item := gjson.GetBytes(compactionPayload, "output.0")
+ item := ojson.GetBytesNoCopy(compactionPayload, "output.0")
capsule := item.Get("encrypted_content").String()
if item.Get("type").String() != "compaction" || capsule == "" {
return nil, fmt.Errorf("extract summary: compaction item missing")
}
- inputTokens := int(gjson.GetBytes(compactionPayload, "usage.input_tokens").Int())
- outputTokens := int(gjson.GetBytes(compactionPayload, "usage.output_tokens").Int())
- totalTokens := int(gjson.GetBytes(compactionPayload, "usage.total_tokens").Int())
- cachedTokens := int(gjson.GetBytes(compactionPayload, "usage.input_tokens_details.cached_tokens").Int())
- reasoningTokens := int(gjson.GetBytes(compactionPayload, "usage.output_tokens_details.reasoning_tokens").Int())
+ inputTokens := int(ojson.GetBytesNoCopy(compactionPayload, "usage.input_tokens").Int())
+ outputTokens := int(ojson.GetBytesNoCopy(compactionPayload, "usage.output_tokens").Int())
+ totalTokens := int(ojson.GetBytesNoCopy(compactionPayload, "usage.total_tokens").Int())
+ cachedTokens := int(ojson.GetBytesNoCopy(compactionPayload, "usage.input_tokens_details.cached_tokens").Int())
+ reasoningTokens := int(ojson.GetBytesNoCopy(compactionPayload, "usage.output_tokens_details.reasoning_tokens").Int())
chunks := BuildAntigravityCompactionStreamChunks(modelName, capsule, inputTokens, outputTokens, totalTokens)
for i := range chunks {
chunks[i] = PatchResponsesCompactionStreamUsage(chunks[i], inputTokens, outputTokens, totalTokens, cachedTokens, reasoningTokens)
diff --git a/internal/runtime/executor/helps/antigravity_compaction_test.go b/internal/runtime/executor/helps/antigravity_compaction_test.go
index f958e277043..dc0db241b8b 100644
--- a/internal/runtime/executor/helps/antigravity_compaction_test.go
+++ b/internal/runtime/executor/helps/antigravity_compaction_test.go
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
func TestAntigravityCompactionSealAndUnseal(t *testing.T) {
@@ -70,6 +70,7 @@ func TestPrepareAntigravityCompactionSummaryPayload(t *testing.T) {
"tool_choice": "auto",
"previous_response_id": "resp-old",
"input": [
+ {"type": "additional_tools", "tools": [{"type": "function", "name": "exec_command"}]},
{"type": "message", "role": "user", "content": "hi"},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "hello"}]},
{"type": "compaction_trigger"}
@@ -81,20 +82,20 @@ func TestPrepareAntigravityCompactionSummaryPayload(t *testing.T) {
if !json.Valid(prepared) {
t.Fatalf("prepared payload is not valid JSON: %s", string(prepared))
}
- if gjson.GetBytes(prepared, "stream").Bool() {
+ if ojson.GetBytes(prepared, "stream").Bool() {
t.Fatal("expected stream to be false")
}
- if gjson.GetBytes(prepared, "tools").Exists() {
+ if ojson.GetBytes(prepared, "tools").Exists() {
t.Fatal("expected tools to be removed")
}
- if gjson.GetBytes(prepared, "tool_choice").Exists() {
+ if ojson.GetBytes(prepared, "tool_choice").Exists() {
t.Fatal("expected tool_choice to be removed")
}
- if gjson.GetBytes(prepared, "previous_response_id").Exists() {
+ if ojson.GetBytes(prepared, "previous_response_id").Exists() {
t.Fatal("expected previous_response_id to be removed")
}
- input := gjson.GetBytes(prepared, "input").Array()
+ input := ojson.GetBytes(prepared, "input").Array()
if len(input) != 3 {
t.Fatalf("expected 3 items in input, got %d", len(input))
}
@@ -103,6 +104,9 @@ func TestPrepareAntigravityCompactionSummaryPayload(t *testing.T) {
if item.Get("type").String() == "compaction_trigger" {
t.Fatal("compaction_trigger must be removed from summary payload")
}
+ if item.Get("type").String() == "additional_tools" {
+ t.Fatal("additional_tools must be removed from summary payload")
+ }
}
lastItem := input[len(input)-1]
@@ -122,7 +126,7 @@ func TestPrepareAntigravityCompactionSummaryPayload_StringInputWithControlChars(
t.Fatalf("prepared output with control chars must be valid JSON, got: %s", string(prepared))
}
- input := gjson.GetBytes(prepared, "input").Array()
+ input := ojson.GetBytes(prepared, "input").Array()
if len(input) != 2 {
t.Fatalf("expected 2 items in input for string input, got %d", len(input))
}
@@ -160,7 +164,7 @@ func TestExpandAntigravityCompactionCapsules(t *testing.T) {
t.Fatalf("expanded payload is not valid JSON: %s", string(expanded))
}
- input := gjson.GetBytes(expanded, "input").Array()
+ input := ojson.GetBytes(expanded, "input").Array()
if len(input) != 2 {
t.Fatalf("expected 2 items, got %d", len(input))
}
@@ -275,10 +279,10 @@ func TestExpandResponsesCompactionCapsulesAndBuildFromSummary(t *testing.T) {
if errBuild != nil {
t.Fatalf("BuildResponsesCompactionResponseFromSummary failed: %v", errBuild)
}
- if got := gjson.GetBytes(compactionResp, "usage.input_tokens_details.cached_tokens").Int(); got != 5 {
+ if got := ojson.GetBytes(compactionResp, "usage.input_tokens_details.cached_tokens").Int(); got != 5 {
t.Fatalf("cached_tokens = %d, want 5", got)
}
- if got := gjson.GetBytes(compactionResp, "usage.output_tokens_details.reasoning_tokens").Int(); got != 2 {
+ if got := ojson.GetBytes(compactionResp, "usage.output_tokens_details.reasoning_tokens").Int(); got != 2 {
t.Fatalf("reasoning_tokens = %d, want 2", got)
}
@@ -290,7 +294,7 @@ func TestExpandResponsesCompactionCapsulesAndBuildFromSummary(t *testing.T) {
t.Fatalf("len(chunks) = %d, want 5", len(chunks))
}
- capsule := gjson.GetBytes(compactionResp, "output.0.encrypted_content").String()
+ capsule := ojson.GetBytes(compactionResp, "output.0.encrypted_content").String()
rawReq := []byte(`{"input":[{"type":"compaction","encrypted_content":"foreign"},{"type":"compaction","encrypted_content":"` + capsule + `"},{"type":"compaction_trigger"}]}`)
req := cliproxyexecutor.Request{Payload: rawReq}
opts := cliproxyexecutor.Options{OriginalRequest: rawReq}
@@ -300,7 +304,7 @@ func TestExpandResponsesCompactionCapsulesAndBuildFromSummary(t *testing.T) {
if errExpand := ExpandResponsesCompactionCapsules(&req, &opts, "kimi"); errExpand != nil {
t.Fatalf("ExpandResponsesCompactionCapsules failed: %v", errExpand)
}
- items := gjson.GetBytes(req.Payload, "input").Array()
+ items := ojson.GetBytes(req.Payload, "input").Array()
if len(items) != 2 || items[0].Get("role").String() != "developer" || !strings.Contains(items[0].Get("content.0.text").String(), "Summary with text part.") {
t.Fatalf("unexpected expanded input: %s", req.Payload)
}
@@ -336,7 +340,7 @@ func TestExpandRecognizedResponsesCompactionCapsules_PreservesForeign(t *testing
t.Fatalf("ExpandRecognizedResponsesCompactionCapsules failed: %v", errExpand)
}
for _, target := range [][]byte{req.Payload, opts.OriginalRequest} {
- items := gjson.GetBytes(target, "input").Array()
+ items := ojson.GetBytes(target, "input").Array()
if len(items) != 3 {
t.Fatalf("input len = %d, want 3; payload=%s", len(items), target)
}
diff --git a/internal/runtime/executor/helps/antigravity_grounding_urls.go b/internal/runtime/executor/helps/antigravity_grounding_urls.go
index 841273ada94..f85a4e39b88 100644
--- a/internal/runtime/executor/helps/antigravity_grounding_urls.go
+++ b/internal/runtime/executor/helps/antigravity_grounding_urls.go
@@ -7,11 +7,10 @@ import (
"net/url"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func isAntigravityVertexSearchRedirect(rawURL string) bool {
@@ -67,18 +66,30 @@ func ResolveAntigravityGroundingURLs(ctx context.Context, cfg *config.Config, au
if len(payload) == 0 {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !ResolveAntigravityGroundingURLsDoc(ctx, cfg, auth, doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// ResolveAntigravityGroundingURLsDoc replaces Vertex Search redirect URLs in grounding chunks in place on doc.
+func ResolveAntigravityGroundingURLsDoc(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth, doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
basePath := "response.candidates.0.groundingMetadata.groundingChunks"
- chunks := gjson.GetBytes(payload, basePath)
+ chunks := doc.Get(basePath)
if !chunks.IsArray() {
basePath = "candidates.0.groundingMetadata.groundingChunks"
- chunks = gjson.GetBytes(payload, basePath)
+ chunks = doc.Get(basePath)
}
if !chunks.IsArray() {
- return payload
+ return false
}
- output := payload
+ modified := false
resolved := map[string]string{}
for i, chunk := range chunks.Array() {
uri := strings.TrimSpace(chunk.Get("web.uri").String())
@@ -93,12 +104,11 @@ func ResolveAntigravityGroundingURLs(ctx context.Context, cfg *config.Config, au
if resolvedURI == uri {
continue
}
- updated, errSet := sjson.SetBytes(output, fmt.Sprintf("%s.%d.web.uri", basePath, i), resolvedURI)
- if errSet != nil {
+ if errSet := doc.Set(fmt.Sprintf("%s.%d.web.uri", basePath, i), resolvedURI); errSet != nil {
log.WithError(errSet).Debug("antigravity grounding url: set resolved url failed")
continue
}
- output = updated
+ modified = true
}
- return output
+ return modified
}
diff --git a/internal/runtime/executor/helps/antigravity_grounding_urls_test.go b/internal/runtime/executor/helps/antigravity_grounding_urls_test.go
index d3086a51f71..05aaf76381c 100644
--- a/internal/runtime/executor/helps/antigravity_grounding_urls_test.go
+++ b/internal/runtime/executor/helps/antigravity_grounding_urls_test.go
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
type groundingURLRoundTripper func(*http.Request) (*http.Response, error)
@@ -57,10 +57,10 @@ func TestResolveAntigravityGroundingURLsResolvesVertexRedirects(t *testing.T) {
if !sawRedirectRequest {
t.Fatal("expected resolver to request the vertex redirect")
}
- if got := gjson.GetBytes(output, "response.candidates.0.groundingMetadata.groundingChunks.0.web.uri").String(); got != resolvedURL {
+ if got := ojson.GetBytes(output, "response.candidates.0.groundingMetadata.groundingChunks.0.web.uri").String(); got != resolvedURL {
t.Fatalf("resolved uri = %q, want %q; output=%s", got, resolvedURL, output)
}
- if got := gjson.GetBytes(output, "response.candidates.0.groundingMetadata.groundingChunks.1.web.uri").String(); got != "https://already.example/source" {
+ if got := ojson.GetBytes(output, "response.candidates.0.groundingMetadata.groundingChunks.1.web.uri").String(); got != "https://already.example/source" {
t.Fatalf("non-vertex uri = %q", got)
}
}
diff --git a/internal/runtime/executor/helps/antigravity_interactions_state.go b/internal/runtime/executor/helps/antigravity_interactions_state.go
index 1c370200740..8fd83f13fe4 100644
--- a/internal/runtime/executor/helps/antigravity_interactions_state.go
+++ b/internal/runtime/executor/helps/antigravity_interactions_state.go
@@ -7,12 +7,12 @@ import (
"encoding/json"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
var antigravityInteractionsSessions = cache.NewInteractionsSessionCache()
@@ -38,10 +38,25 @@ func interactionsDigest(value any) string {
// PrepareAntigravityInteractions restores only a matching tool-result suffix.
// Explicit continuation always wins, including an explicitly empty field.
func PrepareAntigravityInteractions(auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, model string, body []byte) (*AntigravityInteractionsState, []byte) {
- state := &AntigravityInteractionsState{}
- if !strings.HasPrefix(strings.ToLower(model), "antigravity") || auth == nil {
+ doc := ojson.NewDoc(body)
+ state, mutated := prepareAntigravityInteractionsDoc(auth, req, opts, model, doc)
+ if !mutated {
return state, body
}
+ return state, doc.Bytes()
+}
+
+// PrepareAntigravityInteractionsDoc restores only a matching tool-result suffix in place on doc.
+func PrepareAntigravityInteractionsDoc(auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, model string, doc *ojson.Doc) *AntigravityInteractionsState {
+ state, _ := prepareAntigravityInteractionsDoc(auth, req, opts, model, doc)
+ return state
+}
+
+func prepareAntigravityInteractionsDoc(auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, model string, doc *ojson.Doc) (*AntigravityInteractionsState, bool) {
+ state := &AntigravityInteractionsState{}
+ if doc == nil || doc.Len() == 0 || !strings.HasPrefix(strings.ToLower(model), "antigravity") || auth == nil {
+ return state, false
+ }
caller, _ := opts.Metadata[cliproxyexecutor.CallerScopeMetadataKey].(string)
if caller == "" {
caller, _ = req.Metadata[cliproxyexecutor.CallerScopeMetadataKey].(string)
@@ -57,7 +72,7 @@ func PrepareAntigravityInteractions(auth *cliproxyauth.Auth, req cliproxyexecuto
if info, ok := session.ExtractSessionInfo(opts.Headers, original, opts.Metadata); ok {
identity = info.SessionID
}
- input := gjson.GetBytes(body, "input").Array()
+ input := doc.Get("input").Array()
if identity == "" {
lastUser := -1
for i, step := range input {
@@ -66,7 +81,7 @@ func PrepareAntigravityInteractions(auth *cliproxyauth.Auth, req cliproxyexecuto
}
}
if lastUser < 0 {
- return state, body
+ return state, false
}
// Decode JSON objects before hashing, so whitespace and key order do not
// split a conversation. Tool rounds after the last user are excluded.
@@ -74,21 +89,21 @@ func PrepareAntigravityInteractions(auth *cliproxyauth.Auth, req cliproxyexecuto
for _, step := range input[:lastUser+1] {
prefix = append(prefix, json.RawMessage(step.Raw))
}
- raw, errMarshal := json.Marshal(map[string]any{"input": prefix, "system_instruction": gjson.GetBytes(body, "system_instruction").Value()})
+ raw, errMarshal := json.Marshal(map[string]any{"input": prefix, "system_instruction": doc.Get("system_instruction").Value()})
if errMarshal != nil {
- return state, body
+ return state, false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var canonical any
if errDecode := decoder.Decode(&canonical); errDecode != nil {
- return state, body
+ return state, false
}
identity = interactionsDigest(canonical)
}
state.key = interactionsDigest([]string{caller, auth.ID, auth.Provider, auth.Attributes["api_key"], auth.Attributes["base_url"], model, identity})
- if gjson.GetBytes(body, "previous_interaction_id").Exists() {
- return state, body
+ if doc.Get("previous_interaction_id").Exists() {
+ return state, false
}
start := len(input)
var calls []string
@@ -98,22 +113,22 @@ func PrepareAntigravityInteractions(auth *cliproxyauth.Auth, req cliproxyexecuto
}
callKey := cache.InteractionsCallKey(calls)
if callKey == "" {
- return state, body
+ return state, false
}
cached, ok := antigravityInteractionsSessions.Get(state.key + ":" + callKey)
if !ok {
- return state, body
+ return state, false
}
- body, _ = sjson.SetBytes(body, "previous_interaction_id", cached.ID)
- if !gjson.GetBytes(body, "environment_id").Exists() && cached.Environment != "" {
- body, _ = sjson.SetBytes(body, "environment_id", cached.Environment)
+ _ = doc.Set("previous_interaction_id", cached.ID)
+ if !doc.Get("environment_id").Exists() && cached.Environment != "" {
+ _ = doc.Set("environment_id", cached.Environment)
}
results := make([]json.RawMessage, 0, len(input)-start)
for _, step := range input[start:] {
results = append(results, json.RawMessage(step.Raw))
}
- body, _ = sjson.SetBytes(body, "input", results)
- return state, body
+ _ = doc.Set("input", results)
+ return state, true
}
// Observe records only completed requires_action interactions, never failed or
@@ -122,20 +137,20 @@ func (s *AntigravityInteractionsState) Observe(payload []byte) {
if s == nil || s.key == "" {
return
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
event := root.Get("event_type").String()
interaction := root
if event != "" {
interaction = root.Get("interaction")
}
if id := interaction.Get("id").String(); id != "" {
- s.id = id
+ s.id = strings.Clone(id)
}
if environment := interaction.Get("environment_id").String(); environment != "" {
- s.environment = environment
+ s.environment = strings.Clone(environment)
}
if event == "step.start" && root.Get("step.type").String() == "function_call" {
- s.calls = append(s.calls, root.Get("step.id").String())
+ s.calls = append(s.calls, strings.Clone(root.Get("step.id").String()))
}
if event != "" && event != "interaction.completed" {
return
@@ -147,7 +162,7 @@ func (s *AntigravityInteractionsState) Observe(payload []byte) {
s.calls = nil
for _, step := range steps.Array() {
if step.Get("type").String() == "function_call" {
- s.calls = append(s.calls, step.Get("id").String())
+ s.calls = append(s.calls, strings.Clone(step.Get("id").String()))
}
}
}
diff --git a/internal/runtime/executor/helps/antigravity_interactions_state_test.go b/internal/runtime/executor/helps/antigravity_interactions_state_test.go
index 213ca42b605..47f6f2a8ed5 100644
--- a/internal/runtime/executor/helps/antigravity_interactions_state_test.go
+++ b/internal/runtime/executor/helps/antigravity_interactions_state_test.go
@@ -3,10 +3,10 @@ package helps
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
func TestIssue5190ContinuationIsolation(t *testing.T) {
@@ -58,7 +58,7 @@ func TestIssue5190ContinuationIsolation(t *testing.T) {
want = "interaction_1"
}
_, rewritten := PrepareAntigravityInteractions(auth, req, opts, targetModel, body)
- if got := gjson.GetBytes(rewritten, "previous_interaction_id").String(); got != want {
+ if got := ojson.GetBytes(rewritten, "previous_interaction_id").String(); got != want {
t.Fatalf("continuation=%q want=%q body=%s", got, want, rewritten)
}
if want == "" || name == "explicit" {
@@ -66,7 +66,7 @@ func TestIssue5190ContinuationIsolation(t *testing.T) {
t.Fatalf("unexpected rewrite: %s", rewritten)
}
}
- if name == "environment" && gjson.GetBytes(rewritten, "environment_id").String() != "explicit-env" {
+ if name == "environment" && ojson.GetBytes(rewritten, "environment_id").String() != "explicit-env" {
t.Fatalf("explicit environment overwritten: %s", rewritten)
}
})
diff --git a/internal/runtime/executor/helps/antigravity_structured_output.go b/internal/runtime/executor/helps/antigravity_structured_output.go
index 412432ce215..94dd8e42732 100644
--- a/internal/runtime/executor/helps/antigravity_structured_output.go
+++ b/internal/runtime/executor/helps/antigravity_structured_output.go
@@ -4,10 +4,10 @@ import (
"regexp"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var claude55Model = regexp.MustCompile(`^claude-(opus|sonnet)-5-5(?:-(high|reasoning))?(?:-[0-9]{8})?(?:\[[^\[\]]+\])?$`)
@@ -17,17 +17,33 @@ var claude55Model = regexp.MustCompile(`^claude-(opus|sonnet)-5-5(?:-(high|reaso
// Call after sensitive-word obfuscation and before schema cleaning or payload rules.
// The boolean tells the request builder not to restore VALIDATED tool mode.
func PrepareAntigravityClaudeStructuredOutput(model string, payload []byte) ([]byte, bool) {
+ if len(payload) == 0 {
+ return payload, false
+ }
+ doc := ojson.NewDoc(payload)
+ if !PrepareAntigravityClaudeStructuredOutputDoc(model, doc) {
+ return payload, false
+ }
+ return doc.Bytes(), true
+}
+
+// PrepareAntigravityClaudeStructuredOutputDoc replaces native output schemas with
+// best-effort prompt instructions in place on doc for Claude 5.5.
+func PrepareAntigravityClaudeStructuredOutputDoc(model string, doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
model = strings.ToLower(thinking.ParseSuffix(strings.TrimSpace(model)).ModelName)
if !claude55Model.MatchString(model) {
- return payload, false
+ return false
}
containers := []string{"request.generationConfig", "request.generation_config"}
schemaKeys := []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"}
- var schema gjson.Result
+ var schema ojson.Result
for _, container := range containers {
for _, key := range schemaKeys {
- if candidate := gjson.GetBytes(payload, container+"."+key); candidate.IsObject() {
+ if candidate := doc.Get(container + "." + key); candidate.IsObject() {
schema = candidate
break
}
@@ -37,38 +53,36 @@ func PrepareAntigravityClaudeStructuredOutput(model string, payload []byte) ([]b
}
}
if !schema.Exists() {
- return payload, false
+ return false
}
// Wrap the untouched schema as a format, not as format settings themselves.
- format := gjson.Parse(`{"type":"json_schema","schema":` + schema.Raw + `}`)
+ format := ojson.Parse(`{"type":"json_schema","schema":` + schema.Raw + `}`)
instruction := common.BuildClaudeStructuredOutputInstruction(format)
systemPath := "request.systemInstruction"
- if !gjson.GetBytes(payload, systemPath).Exists() && gjson.GetBytes(payload, "request.system_instruction").Exists() {
+ if !doc.Get(systemPath).Exists() && doc.Get("request.system_instruction").Exists() {
systemPath = "request.system_instruction"
}
- updated, errSet := sjson.SetBytes(payload, systemPath+".parts.-1.text", instruction)
- if errSet != nil {
- return payload, false
+ if errSet := doc.Set(systemPath+".parts.-1.text", instruction); errSet != nil {
+ return false
}
- payload = updated
// Remove only native output leaves; other config fields and aliases stay put.
for _, container := range containers {
for _, key := range append(schemaKeys, "responseMimeType", "response_mime_type") {
- payload, _ = sjson.DeleteBytes(payload, container+"."+key)
+ _ = util.DeleteJSONDocIfExists(doc, container+"."+key)
}
}
for _, container := range []string{"request.toolConfig", "request.tool_config"} {
for _, key := range []string{"functionCallingConfig", "function_calling_config"} {
path := container + "." + key
- switch strings.ToUpper(gjson.GetBytes(payload, path+".mode").String()) {
+ switch strings.ToUpper(doc.Get(path + ".mode").String()) {
case "ANY", "VALIDATED":
- payload, _ = sjson.SetBytes(payload, path+".mode", "AUTO")
- payload, _ = sjson.DeleteBytes(payload, path+".allowedFunctionNames")
- payload, _ = sjson.DeleteBytes(payload, path+".allowed_function_names")
+ _ = doc.Set(path+".mode", "AUTO")
+ _ = util.DeleteJSONDocIfExists(doc, path+".allowedFunctionNames")
+ _ = util.DeleteJSONDocIfExists(doc, path+".allowed_function_names")
}
}
}
- return payload, true
+ return true
}
diff --git a/internal/runtime/executor/helps/apply_patch.go b/internal/runtime/executor/helps/apply_patch.go
index 621807851bc..49069413768 100644
--- a/internal/runtime/executor/helps/apply_patch.go
+++ b/internal/runtime/executor/helps/apply_patch.go
@@ -4,10 +4,10 @@ import (
"bytes"
"context"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// ApplyPatchUpstreamErrorMessage deliberately excludes upstream JSON and patch text.
@@ -19,7 +19,7 @@ func TranslatedClaudeErrorPayload(responseFormat sdktranslator.Format, out []byt
if responseFormat != sdktranslator.FormatClaude || len(out) == 0 {
return "", false
}
- if gjson.GetBytes(out, "type").String() != "error" {
+ if ojson.GetBytesNoCopy(out, "type").String() != "error" {
return "", false
}
return string(out), true
@@ -37,7 +37,7 @@ func TranslatedClaudeStreamErrorPayload(responseFormat sdktranslator.Format, lin
if len(payload) == 0 {
continue
}
- if gjson.GetBytes(payload, "type").String() == "error" {
+ if ojson.GetBytesNoCopy(payload, "type").String() == "error" {
return string(payload), true
}
}
diff --git a/internal/runtime/executor/helps/apply_patch_responses.go b/internal/runtime/executor/helps/apply_patch_responses.go
index 2b4a08c3c0e..0f074023c12 100644
--- a/internal/runtime/executor/helps/apply_patch_responses.go
+++ b/internal/runtime/executor/helps/apply_patch_responses.go
@@ -4,21 +4,150 @@ import (
"bytes"
"errors"
"fmt"
+ "strings"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// NormalizeApplyPatchResponsesRequest opts a non-Codex executor into the patch contract.
func NormalizeApplyPatchResponsesRequest(body []byte, original ...[]byte) ([]byte, error) {
+ if !ojson.ValidBytes(body) {
+ return body, errors.New("invalid Responses request JSON")
+ }
+ doc := ojson.NewDoc(body)
+ if errNorm := NormalizeApplyPatchResponsesRequestDoc(doc, original...); errNorm != nil {
+ return body, errNorm
+ }
+ return doc.Bytes(), nil
+}
+
+// NormalizeApplyPatchResponsesRequestDoc opts a non-Codex executor into the patch contract in place on doc.
+func NormalizeApplyPatchResponsesRequestDoc(doc *ojson.Doc, original ...[]byte) error {
+ if doc == nil || doc.Len() == 0 {
+ return errors.New("invalid Responses request JSON")
+ }
if len(original) > 0 {
- body = preferChatFunctionPatchTools(original[0], body)
+ preferChatFunctionPatchToolsDoc(original[0], doc)
}
- return translatorcommon.NormalizeApplyPatchResponsesRequest(body)
+ descriptors := util.CollectResponsesToolDescriptorsDoc(doc)
+ winners := util.CollectResponsesToolWinnersDoc(doc)
+ affected := map[string]bool{}
+ for _, d := range descriptors {
+ if applypatch.IsCustomTool(d.Tool) {
+ affected[d.Name] = true
+ }
+ }
+ shouldDropTool := func(tool ojson.Result, namespace string) bool {
+ if tool.Get("type").String() == "namespace" {
+ return false
+ }
+ name := tool.Get("name").String()
+ if name == "" {
+ name = tool.Get("function.name").String()
+ }
+ qualified := util.QualifyResponsesNamespaceToolName(namespace, name)
+ winner, ok := winners[qualified]
+ return ok && affected[qualified] && winner.Tool.Index != tool.Index
+ }
+ var normalizeToolsDoc func(string, ojson.Result, string)
+ normalizeToolsDoc = func(toolsPath string, tools ojson.Result, namespace string) {
+ arr := tools.Array()
+ dropAny := false
+ for _, tool := range arr {
+ if shouldDropTool(tool, namespace) {
+ dropAny = true
+ break
+ }
+ }
+ survivors := arr
+ if dropAny {
+ keptRaw := make([][]byte, 0, len(arr))
+ survivors = make([]ojson.Result, 0, len(arr))
+ for _, tool := range arr {
+ if shouldDropTool(tool, namespace) {
+ continue
+ }
+ keptRaw = append(keptRaw, []byte(tool.Raw))
+ survivors = append(survivors, tool)
+ }
+ _ = doc.SetRawBytes(toolsPath, translatorcommon.JoinRawArray(keptRaw))
+ }
+ for idx, tool := range survivors {
+ toolPath := fmt.Sprintf("%s.%d", toolsPath, idx)
+ if tool.Get("type").String() == "namespace" {
+ for _, key := range []string{"tools", "children"} {
+ if children := tool.Get(key); children.IsArray() {
+ normalizeToolsDoc(toolPath+"."+key, children, tool.Get("name").String())
+ break
+ }
+ }
+ continue
+ }
+ name := tool.Get("name").String()
+ if name == "" {
+ name = tool.Get("function.name").String()
+ }
+ qualified := util.QualifyResponsesNamespaceToolName(namespace, name)
+ if winner, ok := winners[qualified]; ok && affected[qualified] && winner.Tool.Index == tool.Index && applypatch.IsCustomTool(tool) {
+ _ = doc.Set(toolPath+".type", "function")
+ _ = doc.Set(toolPath+".description", applypatch.Description(tool))
+ _ = doc.SetRawBytes(toolPath+".parameters", applypatch.Parameters())
+ _ = util.DeleteJSONDocIfExists(doc, toolPath+".format")
+ }
+ }
+ }
+ inputItems := doc.Get("input").Array()
+ patchHistory := map[string]bool{}
+ for _, item := range inputItems {
+ if item.Get("type").String() == "custom_tool_call" && strings.TrimSpace(item.Get("name").String()) == "apply_patch" {
+ if item.Get("input").Type != ojson.String {
+ return errors.New("apply_patch history input must be a string")
+ }
+ patchHistory[item.Get("call_id").String()] = true
+ }
+ }
+ if tools := doc.Get("tools"); tools.IsArray() {
+ normalizeToolsDoc("tools", tools, "")
+ }
+ for i, item := range inputItems {
+ path := fmt.Sprintf("input.%d", i)
+ switch item.Get("type").String() {
+ case "additional_tools":
+ if tools := item.Get("tools"); tools.IsArray() {
+ normalizeToolsDoc(path+".tools", tools, "")
+ }
+ case "custom_tool_call":
+ if strings.TrimSpace(item.Get("name").String()) != "apply_patch" {
+ continue
+ }
+ _ = doc.Set(path+".type", "function_call")
+ _ = doc.Set(path+".arguments", applypatch.WrapInput(item.Get("input").String()))
+ _ = doc.Delete(path + ".input")
+ case "custom_tool_call_output":
+ if patchHistory[item.Get("call_id").String()] {
+ _ = doc.Set(path+".type", "function_call_output")
+ }
+ }
+ }
+ var normalizeChoiceDoc func(string, ojson.Result)
+ normalizeChoiceDoc = func(choicePath string, choice ojson.Result) {
+ name := choice.Get("name").String()
+ namespace := choice.Get("namespace").String()
+ if d, ok := winners[util.QualifyResponsesNamespaceToolName(namespace, name)]; ok && applypatch.IsCustomTool(d.Tool) && choice.Get("type").String() == "custom" {
+ _ = doc.Set(choicePath+".type", "function")
+ }
+ for i, child := range choice.Get("tools").Array() {
+ normalizeChoiceDoc(fmt.Sprintf("%s.tools.%d", choicePath, i), child)
+ }
+ }
+ if choice := doc.Get("tool_choice"); choice.IsObject() {
+ normalizeChoiceDoc("tool_choice", choice)
+ }
+ return nil
}
// ApplyPatchResponsesState is owned explicitly by a non-native executor, never inferred
@@ -50,38 +179,57 @@ type patchDispatcherCall struct {
// preferChatFunctionPatchTools preserves the Chat request converter's existing winner rule.
func preferChatFunctionPatchTools(original, declarations []byte) []byte {
+ if len(declarations) == 0 {
+ return declarations
+ }
+ doc := ojson.NewDoc(declarations)
+ if !preferChatFunctionPatchToolsDoc(original, doc) {
+ return declarations
+ }
+ return doc.Bytes()
+}
+
+func preferChatFunctionPatchToolsDoc(original []byte, doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
ordinary := map[string]bool{}
- for _, tool := range gjson.GetBytes(original, "tools").Array() {
+ for _, tool := range ojson.GetBytesNoCopy(original, "tools").Array() {
if tool.Get("type").String() == "function" {
ordinary[tool.Get("function.name").String()] = true
}
}
if len(ordinary) == 0 {
- return declarations
+ return false
}
+ declTools := doc.Get("tools").Array()
available := map[string]bool{}
- for _, tool := range gjson.GetBytes(declarations, "tools").Array() {
+ for _, tool := range declTools {
if tool.Get("type").String() == "function" {
available[tool.Get("name").String()] = true
}
}
var tools [][]byte
- for _, tool := range gjson.GetBytes(declarations, "tools").Array() {
+ removed := false
+ for _, tool := range declTools {
name := tool.Get("name").String()
if applypatch.IsCustomTool(tool) && ordinary[name] && available[name] {
+ removed = true
continue
}
tools = append(tools, []byte(tool.Raw))
}
- declarations, _ = sjson.SetRawBytes(declarations, "tools", translatorcommon.JoinRawArray(tools))
- return declarations
+ if !removed {
+ return false
+ }
+ return doc.SetRawBytes("tools", translatorcommon.JoinRawArray(tools)) == nil
}
func NewApplyPatchResponsesState(source sdktranslator.Format, original, declarations []byte) *ApplyPatchResponsesState {
if source == sdktranslator.FormatOpenAI {
declarations = preferChatFunctionPatchTools(original, declarations)
}
- s := &ApplyPatchResponsesState{Bridge: translatorcommon.NewApplyPatchResponsesBridge(declarations), tools: util.CollectResponsesToolWinners(gjson.ParseBytes(declarations)), dispatchers: map[string]string{}, byDispatcherKey: map[string]*patchDispatcherCall{}}
+ s := &ApplyPatchResponsesState{Bridge: translatorcommon.NewApplyPatchResponsesBridge(declarations), tools: util.CollectResponsesToolWinners(util.ParseGJSONBytesNoCopy(declarations)), dispatchers: map[string]string{}, byDispatcherKey: map[string]*patchDispatcherCall{}}
for _, d := range s.tools {
if applypatch.IsCustomTool(d.Tool) {
s.active = true
@@ -103,7 +251,7 @@ func (s *ApplyPatchResponsesState) AddDispatcher(name, namespace string) {
}
}
-func dispatcherKeys(root gjson.Result) []string {
+func dispatcherKeys(root ojson.Result) []string {
var keys []string
for _, path := range []string{"item.id", "item_id"} {
if id := root.Get(path).String(); id != "" {
@@ -120,7 +268,7 @@ func dispatcherKeys(root gjson.Result) []string {
}
return keys
}
-func (s *ApplyPatchResponsesState) dispatcher(root gjson.Result) *patchDispatcherCall {
+func (s *ApplyPatchResponsesState) dispatcher(root ojson.Result) *patchDispatcherCall {
matched := map[*patchDispatcherCall]bool{}
for _, key := range dispatcherKeys(root) {
if call := s.byDispatcherKey[key]; call != nil {
@@ -135,7 +283,7 @@ func (s *ApplyPatchResponsesState) dispatcher(root gjson.Result) *patchDispatche
return nil
}
-func (s *ApplyPatchResponsesState) newDispatcherCandidate(root gjson.Result) *patchDispatcherCall {
+func (s *ApplyPatchResponsesState) newDispatcherCandidate(root ojson.Result) *patchDispatcherCall {
call := &patchDispatcherCall{index: -1}
s.records = append(s.records, call)
for _, key := range dispatcherKeys(root) {
@@ -159,11 +307,11 @@ func (s *ApplyPatchResponsesState) RememberDispatcherEvent(event []byte) {
// RememberDispatcherArguments keeps full snapshots on every matched candidate, including
// unnamed calls. The common bridge retains all identity/type contradictions until acquisition.
func (s *ApplyPatchResponsesState) RememberDispatcherArguments(event []byte) {
- if s.failed || s.closed || s.transportDone || len(s.dispatchers) == 0 || gjson.GetBytes(event, "type").String() != "response.function_call_arguments.done" {
+ if s.failed || s.closed || s.transportDone || len(s.dispatchers) == 0 || ojson.GetBytesNoCopy(event, "type").String() != "response.function_call_arguments.done" {
return
}
s.upstream = bytes.Clone(event)
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
if s.dispatcher(root) == nil {
s.newDispatcherCandidate(root)
}
@@ -176,15 +324,15 @@ func (s *ApplyPatchResponsesState) RememberDispatcherArguments(event []byte) {
}
}
-func patchDispatcherArguments(wrapper gjson.Result) string {
+func patchDispatcherArguments(wrapper ojson.Result) string {
arguments := wrapper.Get("arguments")
- if arguments.Type == gjson.String {
+ if arguments.Type == ojson.String {
return arguments.String()
}
return arguments.Raw
}
-func dispatcherEventName(root gjson.Result) string {
+func dispatcherEventName(root ojson.Result) string {
if root.Get("item").Exists() {
return root.Get("item.name").String()
}
@@ -192,8 +340,8 @@ func dispatcherEventName(root gjson.Result) string {
}
func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][]byte, error) {
- root := gjson.ParseBytes(event)
- raw := gjson.ParseBytes(original)
+ root := util.ParseGJSONBytesNoCopy(event)
+ raw := util.ParseGJSONBytesNoCopy(original)
kind := root.Get("type").String()
call := s.dispatcher(root)
name := dispatcherEventName(raw)
@@ -266,47 +414,48 @@ func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][
wrappers = append(wrappers, call.source)
}
for _, snapshot := range call.snapshots {
- arguments := gjson.GetBytes(snapshot, "arguments").String()
- if gjson.Get(arguments, "name").Exists() {
+ arguments := ojson.GetBytesNoCopy(snapshot, "arguments").String()
+ if ojson.Get(arguments, "name").Exists() {
wrappers = append(wrappers, arguments)
}
}
for _, pending := range call.originals {
- p := gjson.ParseBytes(pending)
+ p := util.ParseGJSONBytesNoCopy(pending)
arguments := p.Get("item.arguments").String()
- if (dispatcherEventName(p) == "" || s.dispatchers[dispatcherEventName(p)] == call.namespace) && gjson.Get(arguments, "name").Exists() {
+ if (dispatcherEventName(p) == "" || s.dispatchers[dispatcherEventName(p)] == call.namespace) && ojson.Get(arguments, "name").Exists() {
wrappers = append(wrappers, arguments)
}
}
// For callers without a pre-restoration copy, retain the original full-source contract.
if len(wrappers) == 0 {
for _, pending := range call.events {
- if args := gjson.GetBytes(pending, "arguments"); gjson.Get(args.String(), "name").String() != "" {
+ if args := ojson.GetBytesNoCopy(pending, "arguments"); ojson.Get(args.String(), "name").String() != "" {
wrappers = append(wrappers, args.String())
}
}
}
- source := gjson.Result{}
+ source := ojson.Result{}
for _, wrapper := range wrappers {
- if gjson.Valid(wrapper) && gjson.Get(wrapper, "name").String() != "" {
- source = gjson.Parse(wrapper)
+ if ojson.Valid(wrapper) && ojson.Get(wrapper, "name").String() != "" {
+ source = ojson.Parse(wrapper)
}
}
+ eventDoc := ojson.NewDoc(event)
name = root.Get(path + "name").String()
if name == "" || s.dispatchers[name] == call.namespace {
name = source.Get("name").String()
if name == "" {
name = call.name
}
- event, _ = sjson.SetBytes(event, path+"name", name)
- event, _ = sjson.SetBytes(event, path+"namespace", call.namespace)
+ _ = eventDoc.Set(path+"name", name)
+ _ = eventDoc.Set(path+"namespace", call.namespace)
}
if root.Get(path+"namespace").String() == "" {
- event, _ = sjson.SetBytes(event, path+"namespace", call.namespace)
+ _ = eventDoc.Set(path+"namespace", call.namespace)
}
if declared || dispatcherEventName(raw) == "" || kind == "response.function_call_arguments.done" {
- if wrapper := gjson.Parse(raw.Get(path + "arguments").String()); wrapper.Get("name").String() != "" {
- event, _ = sjson.SetBytes(event, path+"arguments", patchDispatcherArguments(wrapper))
+ if wrapper := ojson.Parse(raw.Get(path + "arguments").String()); wrapper.Get("name").String() != "" {
+ _ = eventDoc.Set(path+"arguments", patchDispatcherArguments(wrapper))
}
}
if !root.Get(path + "arguments").Exists() {
@@ -315,10 +464,11 @@ func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][
encoded = call.arguments
}
if encoded != "" {
- event, _ = sjson.SetBytes(event, path+"arguments", encoded)
+ _ = eventDoc.Set(path+"arguments", encoded)
}
}
- root = gjson.ParseBytes(event)
+ event = eventDoc.Bytes()
+ root = util.ParseGJSONBytesNoCopy(event)
call.events[len(call.events)-1] = event
d := s.tools[util.QualifyResponsesNamespaceToolName(call.namespace, name)]
patch := applypatch.IsCustomTool(d.Tool)
@@ -328,20 +478,20 @@ func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][
}
if patch {
for _, snapshot := range call.snapshots {
- if gjson.GetBytes(snapshot, "arguments").Type != gjson.String {
+ if ojson.GetBytesNoCopy(snapshot, "arguments").Type != ojson.String {
return nil, errors.New("apply_patch dispatcher arguments snapshot must be a string")
}
}
}
for _, wrapperRaw := range wrappers {
- wrapper := gjson.Parse(wrapperRaw)
+ wrapper := ojson.Parse(wrapperRaw)
child := s.tools[util.QualifyResponsesNamespaceToolName(call.namespace, wrapper.Get("name").String())]
if !patch && !applypatch.IsCustomTool(child.Tool) {
continue
}
input, errUnwrap := applypatch.UnwrapInput(patchDispatcherArguments(wrapper))
final, errFinal := applypatch.UnwrapInput(finalArguments)
- if !gjson.Valid(wrapperRaw) || wrapper.Get("name").String() != name || errUnwrap != nil || errFinal != nil || input != final {
+ if !ojson.Valid(wrapperRaw) || wrapper.Get("name").String() != name || errUnwrap != nil || errFinal != nil || input != final {
return nil, errors.New("conflicting apply_patch dispatcher arguments")
}
}
@@ -355,8 +505,8 @@ func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][
var out [][]byte
for i := start; i < len(call.events); i++ {
pending := call.events[i]
- p := gjson.ParseBytes(pending)
- originalRoot := gjson.ParseBytes(call.originals[i])
+ p := util.ParseGJSONBytesNoCopy(pending)
+ originalRoot := util.ParseGJSONBytesNoCopy(call.originals[i])
if patch {
for _, namespacePath := range []string{"namespace", "item.namespace"} {
if supplied := originalRoot.Get(namespacePath).String(); supplied != "" && supplied != call.namespace {
@@ -385,28 +535,30 @@ func (s *ApplyPatchResponsesState) expandDispatcher(event, original []byte) ([][
continue
}
if p.Get("type").String() != "response.function_call_arguments.delta" {
+ pendingDoc := ojson.NewDoc(pending)
pendingName := p.Get(pendingPath + "name").String()
if pendingName == "" || s.dispatchers[pendingName] == call.namespace {
- pending, _ = sjson.SetBytes(pending, pendingPath+"name", name)
- pending, _ = sjson.SetBytes(pending, pendingPath+"namespace", call.namespace)
+ _ = pendingDoc.Set(pendingPath+"name", name)
+ _ = pendingDoc.Set(pendingPath+"namespace", call.namespace)
}
if p.Get(pendingPath+"namespace").String() == "" {
- pending, _ = sjson.SetBytes(pending, pendingPath+"namespace", call.namespace)
+ _ = pendingDoc.Set(pendingPath+"namespace", call.namespace)
}
// Only actual upstream wrappers are unwrapped, not restored child contents.
args := originalRoot.Get(pendingPath + "arguments")
- if patch && args.Exists() && args.Type != gjson.String {
+ if patch && args.Exists() && args.Type != ojson.String {
return nil, errors.New("apply_patch dispatcher arguments snapshot must be a string")
}
if args.String() != "" && (dispatcherEventName(originalRoot) == "" || s.dispatchers[dispatcherEventName(originalRoot)] == call.namespace || pendingPath == "") {
- wrapper := gjson.Parse(args.String())
+ wrapper := ojson.Parse(args.String())
if wrapper.Get("name").String() != "" {
if patch && wrapper.Get("name").String() != name {
return nil, errors.New("conflicting apply_patch dispatcher snapshot")
}
- pending, _ = sjson.SetBytes(pending, pendingPath+"arguments", patchDispatcherArguments(wrapper))
+ _ = pendingDoc.Set(pendingPath+"arguments", patchDispatcherArguments(wrapper))
}
}
+ pending = pendingDoc.Bytes()
}
out = append(out, pending)
}
@@ -453,36 +605,44 @@ func (s *ApplyPatchResponsesState) Transform(event []byte) ([][]byte, error) {
original = event
}
var preceding [][]byte
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
kind := root.Get("type").String()
if kind == "response.completed" || kind == "response.incomplete" || kind == "response.done" {
- originalItems := gjson.GetBytes(original, "response.output").Array()
+ originalItems := ojson.GetBytesNoCopy(original, "response.output").Array()
+ var eventDoc *ojson.Doc
for i, item := range root.Get("response.output").Array() {
- done := []byte(`{"type":"response.output_item.done"}`)
- done, _ = sjson.SetRawBytes(done, "item", []byte(item.Raw))
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done"}`)
+ _ = doneDoc.SetRaw("item", item.Raw)
+ done := doneDoc.Bytes()
// Explicit IDs take priority over array position in a sparse terminal snapshot.
- call := s.dispatcher(gjson.ParseBytes(done))
+ call := s.dispatcher(util.ParseGJSONBytesNoCopy(done))
if call == nil && item.Get("id").String() == "" && item.Get("call_id").String() == "" {
- done, _ = sjson.SetBytes(done, "output_index", i)
- call = s.dispatcher(gjson.ParseBytes(done))
+ _ = doneDoc.Set("output_index", i)
+ done = doneDoc.Bytes()
+ call = s.dispatcher(util.ParseGJSONBytesNoCopy(done))
}
if call != nil && !call.ordinary {
if call.index >= 0 {
- done, _ = sjson.SetBytes(done, "output_index", call.index)
+ _ = doneDoc.Set("output_index", call.index)
} else {
- done, _ = sjson.SetBytes(done, "output_index", i)
+ _ = doneDoc.Set("output_index", i)
}
+ done = doneDoc.Bytes()
originalDone := done
// Filtering may shift array positions, but restoration preserves both IDs.
- matches := func(candidate gjson.Result) bool {
+ matches := func(candidate ojson.Result) bool {
return candidate.Get("id").String() == item.Get("id").String() && candidate.Get("call_id").String() == item.Get("call_id").String()
}
if i < len(originalItems) && matches(originalItems[i]) {
- originalDone, _ = sjson.SetRawBytes(done, "item", []byte(originalItems[i].Raw))
+ origDoc := ojson.NewDoc(done)
+ _ = origDoc.SetRaw("item", originalItems[i].Raw)
+ originalDone = origDoc.Bytes()
} else if item.Get("id").String() != "" || item.Get("call_id").String() != "" {
for _, originalItem := range originalItems {
if matches(originalItem) {
- originalDone, _ = sjson.SetRawBytes(done, "item", []byte(originalItem.Raw))
+ origDoc := ojson.NewDoc(done)
+ _ = origDoc.SetRaw("item", originalItem.Raw)
+ originalDone = origDoc.Bytes()
break
}
}
@@ -493,10 +653,16 @@ func (s *ApplyPatchResponsesState) Transform(event []byte) ([][]byte, error) {
}
preceding = append(preceding, events...)
if len(events) > 0 {
- event, _ = sjson.SetRawBytes(event, fmt.Sprintf("response.output.%d", i), []byte(gjson.GetBytes(events[len(events)-1], "item").Raw))
+ if eventDoc == nil {
+ eventDoc = ojson.NewDoc(event)
+ }
+ _ = eventDoc.SetRaw(fmt.Sprintf("response.output.%d", i), ojson.GetBytesNoCopy(events[len(events)-1], "item").Raw)
}
}
}
+ if eventDoc != nil {
+ event = eventDoc.Bytes()
+ }
if s.unfinishedDispatcher() {
return s.fail(errors.New("incomplete apply_patch namespace dispatcher received from upstream"))
}
@@ -591,7 +757,7 @@ func (s *ApplyPatchResponsesState) Stream(line []byte) ([][]byte, error) {
if bytes.Equal(event, payload) {
out = append(out, s.eventLine)
} else {
- out = append(out, []byte("event: "+gjson.GetBytes(event, "type").String()))
+ out = append(out, []byte("event: "+ojson.GetBytesNoCopy(event, "type").String()))
}
}
data := append([]byte("data: "), event...)
diff --git a/internal/runtime/executor/helps/apply_patch_responses_test.go b/internal/runtime/executor/helps/apply_patch_responses_test.go
index 32df7a736e4..094846a5ea5 100644
--- a/internal/runtime/executor/helps/apply_patch_responses_test.go
+++ b/internal/runtime/executor/helps/apply_patch_responses_test.go
@@ -5,8 +5,8 @@ import (
"fmt"
"testing"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestApplyPatchResponsesHelperNativeSSEBytes(t *testing.T) {
@@ -60,10 +60,10 @@ func TestApplyPatchResponsesHelperDispatcherKeysAndFinal(t *testing.T) {
}
found := false
for _, e := range out {
- if gjson.GetBytes(e, "type").String() == "response.custom_tool_call_input.done" {
+ if ojson.GetBytes(e, "type").String() == "response.custom_tool_call_input.done" {
found = true
}
- if gjson.GetBytes(e, "type").String() == "response.custom_tool_call_input.delta" {
+ if ojson.GetBytes(e, "type").String() == "response.custom_tool_call_input.delta" {
t.Fatalf("fabricated dispatcher preview: %s", out)
}
}
@@ -97,7 +97,7 @@ func TestApplyPatchResponsesHelperRequestChatPreference(t *testing.T) {
original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","function":{"name":"apply_patch","parameters":{"type":"object","properties":{"x":{"type":"integer"}}}}}]}`)
body := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"apply_patch","parameters":{"type":"object","properties":{"x":{"type":"integer"}}}}]}`)
out, errNormalize := NormalizeApplyPatchResponsesRequest(body, original)
- if errNormalize != nil || !gjson.GetBytes(out, "tools.0.parameters.properties.x").Exists() || gjson.GetBytes(out, "tools.0.parameters.properties.input").Exists() {
+ if errNormalize != nil || !ojson.GetBytes(out, "tools.0.parameters.properties.x").Exists() || ojson.GetBytes(out, "tools.0.parameters.properties.input").Exists() {
t.Fatalf("request ordinary preference lost: %s %v", out, errNormalize)
}
}
@@ -117,7 +117,7 @@ func TestApplyPatchResponsesHelperDispatcherOmittedArguments(t *testing.T) {
}
}
out, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":%q,"namespace":"n"}}`, lateName)))
- if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
+ if errTransform != nil || len(out) == 0 || ojson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
t.Fatalf("sourced dispatcher completion lost: %s %v", out, errTransform)
}
})
@@ -239,17 +239,17 @@ func TestApplyPatchResponsesHelperRetainedDispatcherProvenance(t *testing.T) {
for _, wrapper := range tc.wrappers {
s.RememberDispatcherArguments([]byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, wrapper)))
// The real restorer removes the wrapper before this event reaches Transform.
- if _, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, gjson.Get(wrapper, "arguments").Raw))); errTransform != nil {
+ if _, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.function_call_arguments.done","item_id":"a","arguments":%q}`, ojson.Get(wrapper, "arguments").Raw))); errTransform != nil {
t.Fatal(errTransform)
}
}
final := fmt.Sprintf(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"late","name":%q,"namespace":"n"}}`, tc.finalName)
out, errTransform := s.Transform([]byte(final))
if tc.shouldFail {
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytes(out[0], "type").String() != "response.failed" {
t.Fatalf("dispatcher child evidence lost: %s %v", out, errTransform)
}
- } else if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.name").String() != "lookup" || gjson.GetBytes(out[len(out)-1], "item.namespace").String() != "n" || gjson.GetBytes(out[len(out)-1], "item.arguments").String() != gjson.Get(tc.wrappers[len(tc.wrappers)-1], "arguments").Raw || bytes.Contains(bytes.Join(out, nil), []byte(`custom_tool_call`)) {
+ } else if errTransform != nil || len(out) == 0 || ojson.GetBytes(out[len(out)-1], "item.name").String() != "lookup" || ojson.GetBytes(out[len(out)-1], "item.namespace").String() != "n" || ojson.GetBytes(out[len(out)-1], "item.arguments").String() != ojson.Get(tc.wrappers[len(tc.wrappers)-1], "arguments").Raw || bytes.Contains(bytes.Join(out, nil), []byte(`custom_tool_call`)) {
t.Fatalf("ordinary dispatcher child changed: %s %v", out, errTransform)
}
})
@@ -278,7 +278,7 @@ func TestApplyPatchResponsesHelperDispatcherSnapshotsAllMatched(t *testing.T) {
t.Fatal(errTransform)
}
out, errTransform := s.Transform([]byte(fmt.Sprintf(`{"type":"response.output_item.done","output_index":%d,"item":{"type":"function_call","id":"i%d","call_id":"c%d","name":"n"}}`, discover, discover, discover)))
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytes(out[0], "type").String() != "response.failed" {
t.Fatalf("conflicting all-key evidence lost on record %d: %s %v", discover, out, errTransform)
}
})
@@ -324,7 +324,7 @@ func TestApplyPatchResponsesHelperDispatcherLifecycle(t *testing.T) {
}
}
out, errTransform := s.Transform([]byte(`{"type":"response.output_item.done","output_index":2,"item":{"type":"function_call","id":"a","call_id":"c","name":"n"}}`))
- if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
+ if errTransform != nil || len(out) == 0 || ojson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
t.Fatalf("retained source not acquired: %s %v", out, errTransform)
}
call := s.byDispatcherKey["item:a"]
@@ -337,7 +337,7 @@ func TestApplyPatchResponsesHelperDispatcherLifecycle(t *testing.T) {
switch closeAt {
case "response":
out, errTransform = s.Transform([]byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n"}]}}`))
- if errTransform != nil || len(out) != 1 || gjson.GetBytes(out[0], "response.output.0.input").String() != "p" {
+ if errTransform != nil || len(out) != 1 || ojson.GetBytes(out[0], "response.output.0.input").String() != "p" {
t.Fatalf("sparse terminal lost completed child or replayed progress: %s %v", out, errTransform)
}
case "sentinel":
@@ -382,7 +382,7 @@ func TestApplyPatchResponsesHelperLateChildNamespace(t *testing.T) {
}
out = append(out, converted...)
}
- if len(out) == 0 || gjson.GetBytes(out[len(out)-1], "item.namespace").String() != "n" || gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
+ if len(out) == 0 || ojson.GetBytes(out[len(out)-1], "item.namespace").String() != "n" || ojson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
t.Fatalf("sourced late child lost its namespace/input: %s", out)
}
}
@@ -404,7 +404,7 @@ func TestApplyPatchResponsesHelperTerminalSourceIdentity(t *testing.T) {
// Provider filtering can remove an earlier item after the original snapshot is copied.
s.RememberDispatcherEvent([]byte(`{"type":"response.completed","response":{"output":[{"type":"message","id":"removed"},{"type":"function_call","id":"a","call_id":"c","name":"n","namespace":"other"}]}}`))
out, errTransform := s.Transform([]byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"a","call_id":"c","name":"n","namespace":"n"}]}}`))
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytes(out[0], "type").String() != "response.failed" {
t.Fatalf("terminal source matched by position, losing contradictory namespace: %s %v", out, errTransform)
}
}
@@ -427,7 +427,7 @@ func TestApplyPatchResponsesHelperIndexOnlyTerminal(t *testing.T) {
terminal := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"n"}]}}`)
s.RememberDispatcherEvent(terminal)
out, errTransform := s.Transform(terminal)
- if errTransform != nil || len(out) == 0 || gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" {
+ if errTransform != nil || len(out) == 0 || ojson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" {
t.Fatalf("index-only provenance bypassed: %s %v", out, errTransform)
}
}
@@ -495,3 +495,27 @@ func TestApplyPatchResponsesHelperInactiveEOFAndDONE(t *testing.T) {
}
}
}
+
+func TestNormalizeApplyPatchResponsesRequestDocMutatesInPlace(t *testing.T) {
+ raw := []byte(`{"tools":[{"type":"namespace","name":"ns","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"g"}}]}],"tool_choice":{"type":"custom","namespace":"ns","name":"apply_patch"},"input":[{"type":"custom_tool_call","call_id":"c1","name":"\u00a0apply_patch\u00a0","input":"*** Begin Patch\n*** End Patch"},{"type":"custom_tool_call_output","call_id":"c1","output":"ok"}]}`)
+ doc := ojson.NewDoc(raw)
+ if errNorm := NormalizeApplyPatchResponsesRequestDoc(doc); errNorm != nil {
+ t.Fatalf("NormalizeApplyPatchResponsesRequestDoc error: %v", errNorm)
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "tools.0.tools.0.type").String(); got != "function" {
+ t.Fatalf("tools.0.tools.0.type = %q, want function: %s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "tools.0.tools.0.format").Exists() {
+ t.Fatalf("expected tools.0.tools.0.format to be deleted: %s", out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
+ t.Fatalf("tool_choice.type = %q, want function: %s", got, out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "function_call" {
+ t.Fatalf("input.0.type = %q, want function_call: %s", got, out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.1.type").String(); got != "function_call_output" {
+ t.Fatalf("input.1.type = %q, want function_call_output: %s", got, out)
+ }
+}
diff --git a/internal/runtime/executor/helps/chat_ttft_helpers.go b/internal/runtime/executor/helps/chat_ttft_helpers.go
index a6f5932a6fb..5d53eca1347 100644
--- a/internal/runtime/executor/helps/chat_ttft_helpers.go
+++ b/internal/runtime/executor/helps/chat_ttft_helpers.go
@@ -3,7 +3,7 @@ package helps
import (
"bytes"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsChatTokenEvent reports whether the given OpenAI-compatible Chat Completions SSE or JSON chunk
@@ -36,11 +36,11 @@ func IsChatTokenEvent(payload []byte) bool {
}
// Terminal error envelope fallback
- if gjson.GetBytes(payload, "error.message").Exists() || gjson.GetBytes(payload, "error").Exists() {
+ if ojson.GetBytesNoCopy(payload, "error.message").Exists() || ojson.GetBytesNoCopy(payload, "error").Exists() {
return true
}
- choices := gjson.GetBytes(payload, "choices").Array()
+ choices := ojson.GetBytesNoCopy(payload, "choices").Array()
if len(choices) == 0 {
return false
}
diff --git a/internal/runtime/executor/helps/claude_builtin_tools.go b/internal/runtime/executor/helps/claude_builtin_tools.go
index 34a6657ad9b..585b79390b8 100644
--- a/internal/runtime/executor/helps/claude_builtin_tools.go
+++ b/internal/runtime/executor/helps/claude_builtin_tools.go
@@ -3,7 +3,7 @@ package helps
import (
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
var defaultClaudeBuiltinToolNames = []string{
@@ -49,16 +49,16 @@ func AugmentClaudeBuiltinToolRegistry(body []byte, registry map[string]bool) map
if registry == nil {
registry = newClaudeBuiltinToolRegistry()
}
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.Exists() || !tools.IsArray() {
return registry
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if !IsClaudeServerToolType(tool.Get("type").String()) {
return true
}
if name := tool.Get("name").String(); name != "" {
- registry[name] = true
+ registry[strings.Clone(name)] = true
}
return true
})
diff --git a/internal/runtime/executor/helps/claude_cli_identity_seed_test.go b/internal/runtime/executor/helps/claude_cli_identity_seed_test.go
index ae1d6bdf511..5be4c6ffb29 100644
--- a/internal/runtime/executor/helps/claude_cli_identity_seed_test.go
+++ b/internal/runtime/executor/helps/claude_cli_identity_seed_test.go
@@ -4,9 +4,9 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
func TestEnsureClaudeCLIFingerprintIdentitySynthesizesStableSources(t *testing.T) {
@@ -44,14 +44,14 @@ func TestEnsureClaudeCLIFingerprintIdentitySynthesizesStableSources(t *testing.T
if deviceID != deviceIDs[0] {
t.Fatalf("selected device = %q, want %q", deviceID, deviceIDs[0])
}
- userID := gjson.GetBytes(updated, "metadata.user_id").String()
+ userID := ojson.GetBytes(updated, "metadata.user_id").String()
if !IsValidUserID(userID) {
t.Fatalf("user_id = %q, want valid", userID)
}
- if got := gjson.Get(userID, "account_uuid").String(); got != account {
+ if got := ojson.Get(userID, "account_uuid").String(); got != account {
t.Fatalf("user_id account = %q, want %q", got, account)
}
- if got := gjson.Get(userID, "session_id").String(); got != sessionID {
+ if got := ojson.Get(userID, "session_id").String(); got != sessionID {
t.Fatalf("user_id session = %q, want %q", got, sessionID)
}
}
diff --git a/internal/runtime/executor/helps/claude_client_detection.go b/internal/runtime/executor/helps/claude_client_detection.go
index 7acdd393d96..14475c560f8 100644
--- a/internal/runtime/executor/helps/claude_client_detection.go
+++ b/internal/runtime/executor/helps/claude_client_detection.go
@@ -8,9 +8,9 @@ import (
"sort"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
const (
@@ -61,8 +61,9 @@ var claudeCodeSubclientByEntrypoint = map[string]string{
}
// Only product surfaces with verified 2.1.220+ wire behavior are eligible for
-// pass-through. Other first-party-looking entrypoints are cloaked until their
-// CPA-reachable request shape has been captured and reviewed.
+// pass-through by default. Other first-party-looking entrypoints are cloaked
+// until their CPA-reachable request shape has been captured and reviewed, or
+// until they are allowlisted in header-defaults.native-entrypoints.
var nativeClaudeEntrypoints = map[string]bool{
"cli": true,
"sdk-cli": true,
@@ -70,6 +71,36 @@ var nativeClaudeEntrypoints = map[string]bool{
"claude-vscode": true,
}
+func isNativeClaudeEntrypoint(entrypoint string, cfg *config.Config) bool {
+ entrypoint = strings.ToLower(strings.TrimSpace(entrypoint))
+ if entrypoint == "" {
+ return false
+ }
+ if nativeClaudeEntrypoints[entrypoint] {
+ return true
+ }
+ if cfg == nil {
+ return false
+ }
+ for _, candidate := range cfg.ClaudeHeaderDefaults.NativeEntrypoints {
+ if strings.EqualFold(strings.TrimSpace(candidate), entrypoint) {
+ return true
+ }
+ }
+ return false
+}
+
+func claudeSubclientForEntrypoint(entrypoint string, cfg *config.Config) string {
+ entrypoint = strings.ToLower(strings.TrimSpace(entrypoint))
+ if subclient := claudeCodeSubclientByEntrypoint[entrypoint]; subclient != "" {
+ return subclient
+ }
+ if isNativeClaudeEntrypoint(entrypoint, cfg) {
+ return "claude-code-" + entrypoint
+ }
+ return ""
+}
+
type claudeCodeHelperShape uint8
const (
@@ -136,7 +167,8 @@ type ClaudeCodeRequestDetection struct {
// four strong signals; count_tokens omits metadata.user_id. A separate narrow
// profile recognizes measured native Haiku helper requests that intentionally
// omit claude-code-20250219. Unverified entrypoints (such as generic sdk-py)
-// remain unconfirmed by default and receive CLI cloaking unless cloak_mode is never.
+// remain unconfirmed by default and receive CLI cloaking unless cloak_mode is
+// never or the entrypoint is allowlisted in header-defaults.native-entrypoints.
func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bool, configs ...*config.Config) ClaudeCodeRequestDetection {
var cfg *config.Config
if len(configs) > 0 {
@@ -144,25 +176,26 @@ func DetectClaudeCodeRequest(headers http.Header, payload []byte, countTokens bo
}
userAgent := headerValue(headers, "User-Agent")
entrypoint, agentSDKVersion := parseClaudeCodeUserAgentDetails(userAgent)
+ subclient := claudeSubclientForEntrypoint(entrypoint, cfg)
detection := ClaudeCodeRequestDetection{
XAppCLI: headerValue(headers, "X-App") == "cli",
UserAgent: plausibleClaudeCodeUserAgent(userAgent, cfg),
BetasPresent: headerContainsClaudeCodeBeta(headers),
Entrypoint: entrypoint,
- Subclient: claudeCodeSubclientByEntrypoint[entrypoint],
+ Subclient: subclient,
AgentSDKVersion: agentSDKVersion,
}
- metadataUserID := gjson.GetBytes(payload, "metadata.user_id")
- detection.MetadataUserID = metadataUserID.Exists() && metadataUserID.Type == gjson.String && isValidUserID(metadataUserID.String())
- detection.NativeClient = nativeClaudeEntrypoints[entrypoint]
+ metadataUserID := ojson.GetBytesNoCopy(payload, "metadata.user_id")
+ detection.MetadataUserID = metadataUserID.Exists() && metadataUserID.Type == ojson.String && isValidUserID(metadataUserID.String())
+ detection.NativeClient = isNativeClaudeEntrypoint(entrypoint, cfg)
standardSignals := detection.XAppCLI && detection.UserAgent && detection.BetasPresent && (countTokens || detection.MetadataUserID)
detection.HelperProfile = detection.NativeClient && matchesMeasuredClaudeCodeHelperProfile(headers, payload, countTokens, detection, cfg)
detection.StrongSignals = standardSignals || detection.HelperProfile
detection.Confirmed = detection.StrongSignals && detection.NativeClient
detection.ConfirmedShape = detection.StrongSignals ||
(!countTokens && detection.XAppCLI && detection.UserAgent && detection.MetadataUserID &&
- claudeCodeSubclientByEntrypoint[entrypoint] != "" &&
+ subclient != "" &&
measuredClaudeCodeHaiku55HelperSessionMatches(headers, payload) &&
measuredClaudeCodeBillingAndIdentityMatch(payload, entrypoint))
return detection
@@ -215,7 +248,7 @@ func matchesMeasuredClaudeCodeHaiku55HelperProfile(
detection ClaudeCodeRequestDetection,
cfg *config.Config,
) bool {
- if !detection.NativeClient || gjson.GetBytes(payload, "model").String() != "claude-haiku-5-5" {
+ if !detection.NativeClient || ojson.GetBytesNoCopy(payload, "model").String() != "claude-haiku-5-5" {
return false
}
if !measuredClaudeCodeHaiku55HelperHeadersMatch(headers, cfg) {
@@ -278,12 +311,12 @@ func measuredClaudeCodeHaiku55HelperHeadersMatch(headers http.Header, cfg *confi
}
func measuredClaudeCodeHaiku55HelperSessionMatches(headers http.Header, payload []byte) bool {
- metadata := gjson.GetBytes(payload, "metadata")
+ metadata := ojson.GetBytesNoCopy(payload, "metadata")
if !metadata.IsObject() || !claudeJSONObjectHasKeys([]byte(metadata.Raw), []string{"user_id"}) {
return false
}
userID := metadata.Get("user_id")
- if userID.Type != gjson.String || !isValidUserID(userID.String()) {
+ if userID.Type != ojson.String || !isValidUserID(userID.String()) {
return false
}
var identityMap map[string]json.RawMessage
@@ -300,33 +333,33 @@ func measuredClaudeCodeHaiku55HelperSessionMatches(headers http.Header, payload
if len(identityMap) < 3 {
return false
}
- return headerValue(headers, ClaudeCodeSessionHeader) == gjson.GetBytes([]byte(userID.String()), "session_id").String()
+ return headerValue(headers, ClaudeCodeSessionHeader) == ojson.GetBytesNoCopy([]byte(userID.String()), "session_id").String()
}
func measuredClaudeCodeHaiku55HelperBodyMatches(payload []byte, entrypoint string) bool {
- if gjson.GetBytes(payload, "max_tokens").Type != gjson.Number {
+ if ojson.GetBytesNoCopy(payload, "max_tokens").Type != ojson.Number {
return false
}
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() || len(messages.Array()) != 1 || messages.Get("0.role").String() != "user" {
return false
}
if !measuredClaudeCodeBillingAndIdentityMatch(payload, entrypoint) {
return false
}
- tools := gjson.GetBytes(payload, "tools")
+ tools := ojson.GetBytesNoCopy(payload, "tools")
if !tools.IsArray() {
return false
}
toolItems := tools.Array()
if len(toolItems) == 0 {
- format := gjson.GetBytes(payload, "output_config.format")
+ format := ojson.GetBytesNoCopy(payload, "output_config.format")
required := format.Get("schema.required")
return format.Get("type").String() == "json_schema" &&
format.Get("schema.type").String() == "object" &&
format.Get("schema.properties.title.type").String() == "string" &&
required.IsArray() && len(required.Array()) == 1 && required.Get("0").String() == "title" &&
- format.Get("schema.additionalProperties").Type == gjson.False
+ format.Get("schema.additionalProperties").Type == ojson.False
}
if len(toolItems) == 1 {
return toolItems[0].Get("name").String() == "web_search" &&
@@ -336,7 +369,7 @@ func measuredClaudeCodeHaiku55HelperBodyMatches(payload []byte, entrypoint strin
}
func measuredClaudeCodeBillingAndIdentityMatch(payload []byte, entrypoint string) bool {
- system := gjson.GetBytes(payload, "system")
+ system := ojson.GetBytesNoCopy(payload, "system")
if !system.IsArray() || len(system.Array()) < 2 {
return false
}
@@ -459,12 +492,12 @@ func measuredClaudeCodeHelperHeadersMatch(headers http.Header, cfg *config.Confi
}
func measuredClaudeCodeHelperSessionMatches(headers http.Header, payload []byte) bool {
- metadata := gjson.GetBytes(payload, "metadata")
+ metadata := ojson.GetBytesNoCopy(payload, "metadata")
if !metadata.IsObject() || !claudeJSONObjectHasKeys([]byte(metadata.Raw), []string{"user_id"}) {
return false
}
userID := metadata.Get("user_id")
- if userID.Type != gjson.String || !isValidUserID(userID.String()) {
+ if userID.Type != ojson.String || !isValidUserID(userID.String()) {
return false
}
// The native metadata builder is
@@ -477,7 +510,7 @@ func measuredClaudeCodeHelperSessionMatches(headers http.Header, payload []byte)
!claudeJSONObjectHasKeys(identityRaw, []string{"device_id", "account_uuid", "session_id", "parent_session_id"}) {
return false
}
- return headerValue(headers, ClaudeCodeSessionHeader) == gjson.GetBytes(identityRaw, "session_id").String()
+ return headerValue(headers, ClaudeCodeSessionHeader) == ojson.GetBytesNoCopy(identityRaw, "session_id").String()
}
func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape {
@@ -498,12 +531,12 @@ func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape {
return claudeCodeHelperShapeNone
}
- maxTokens := gjson.GetBytes(payload, "max_tokens")
- if gjson.GetBytes(payload, "model").String() != claudeCodeHelperModel ||
- maxTokens.Type != gjson.Number {
+ maxTokens := ojson.GetBytesNoCopy(payload, "max_tokens")
+ if ojson.GetBytesNoCopy(payload, "model").String() != claudeCodeHelperModel ||
+ maxTokens.Type != ojson.Number {
return claudeCodeHelperShapeNone
}
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() || len(messages.Array()) != 1 {
return claudeCodeHelperShapeNone
}
@@ -514,18 +547,18 @@ func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape {
}
if shape == claudeCodeHelperShapeMinimal {
- if maxTokens.Raw != "1" || message.Get("content").Type != gjson.String {
+ if maxTokens.Raw != "1" || message.Get("content").Type != ojson.String {
return claudeCodeHelperShapeNone
}
return shape
}
if isTitle280 {
- outputConfig := gjson.GetBytes(payload, "output_config")
+ outputConfig := ojson.GetBytesNoCopy(payload, "output_config")
format := outputConfig.Get("format")
schema := format.Get("schema")
if maxTokens.Raw != "80" ||
- message.Get("content").Type != gjson.String ||
+ message.Get("content").Type != ojson.String ||
!claudeJSONObjectHasKeys([]byte(outputConfig.Raw), []string{"format"}) ||
!claudeJSONObjectHasKeys([]byte(format.Raw), []string{"type", "schema"}) ||
format.Get("type").String() != "json_schema" ||
@@ -545,14 +578,14 @@ func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape {
contentBlock.Get("type").String() != "text" {
return claudeCodeHelperShapeNone
}
- if !measuredClaudeCodeHelperSystemMatches(gjson.GetBytes(payload, "system")) {
+ if !measuredClaudeCodeHelperSystemMatches(ojson.GetBytesNoCopy(payload, "system")) {
return claudeCodeHelperShapeNone
}
- if tools := gjson.GetBytes(payload, "tools"); !tools.IsArray() || len(tools.Array()) != 0 {
+ if tools := ojson.GetBytesNoCopy(payload, "tools"); !tools.IsArray() || len(tools.Array()) != 0 {
return claudeCodeHelperShapeNone
}
- thinking := gjson.GetBytes(payload, "thinking")
- outputConfig := gjson.GetBytes(payload, "output_config")
+ thinking := ojson.GetBytesNoCopy(payload, "thinking")
+ outputConfig := ojson.GetBytesNoCopy(payload, "output_config")
if !claudeJSONObjectHasKeys([]byte(thinking.Raw), []string{"type"}) ||
thinking.Get("type").String() != "disabled" {
return claudeCodeHelperShapeNone
@@ -572,19 +605,19 @@ func measuredClaudeCodeHelperBodyShape(payload []byte) claudeCodeHelperShape {
!claudeJSONObjectHasKeys([]byte(titleProperty.Raw), []string{"type"}) ||
titleProperty.Get("type").String() != "string" ||
!required.IsArray() || len(required.Array()) != 1 || required.Get("0").String() != "title" ||
- additionalProperties.Type != gjson.False {
+ additionalProperties.Type != ojson.False {
return claudeCodeHelperShapeNone
}
- temperature := gjson.GetBytes(payload, "temperature")
+ temperature := ojson.GetBytesNoCopy(payload, "temperature")
if maxTokens.Raw != "32000" ||
temperature.Raw != "1" ||
- gjson.GetBytes(payload, "stream").Type != gjson.True {
+ ojson.GetBytesNoCopy(payload, "stream").Type != ojson.True {
return claudeCodeHelperShapeNone
}
return shape
}
-func measuredClaudeCodeHelperSystemMatches(system gjson.Result) bool {
+func measuredClaudeCodeHelperSystemMatches(system ojson.Result) bool {
if !system.IsArray() || len(system.Array()) != 3 {
return false
}
diff --git a/internal/runtime/executor/helps/claude_client_detection_test.go b/internal/runtime/executor/helps/claude_client_detection_test.go
index 86dd7f5fac3..7e0c91566a1 100644
--- a/internal/runtime/executor/helps/claude_client_detection_test.go
+++ b/internal/runtime/executor/helps/claude_client_detection_test.go
@@ -599,26 +599,69 @@ func TestDetectClaudeCodeRequestRecognizesAgentSDKHaiku55Helpers(t *testing.T) {
webSearchPayload := []byte(`{"max_tokens":1024,"messages":[{"role":"user","content":[{"type":"text","text":"search query"}]}],"metadata":{"user_id":` + string(encodedUserID) + `},"model":"claude-haiku-5-5","output_config":{"effort":"medium"},"stream":true,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.304.2e0; cc_entrypoint=sdk-ts"},{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK."},{"type":"text","text":"Perform web search."}],"tool_choice":{"type":"auto"},"tools":[{"type":"web_search_20250305","name":"web_search"}]}`)
for _, tc := range []struct {
- name string
- betas string
- payload []byte
+ name string
+ userAgent string
+ entrypoint string
+ subclient string
+ betas string
+ payload []byte
+ cfg *config.Config
}{
- {name: "sdk-ts title helper", betas: titleBetas, payload: titlePayload},
- {name: "sdk-ts web_search helper", betas: webSearchBetas, payload: webSearchPayload},
+ {name: "sdk-ts title helper", userAgent: "claude-cli/2.1.304 (external, sdk-ts, agent-sdk/0.3.276)", entrypoint: "sdk-ts", subclient: "claude-code-sdk-ts", betas: titleBetas, payload: titlePayload},
+ {name: "sdk-ts web_search helper", userAgent: "claude-cli/2.1.304 (external, sdk-ts, agent-sdk/0.3.276)", entrypoint: "sdk-ts", subclient: "claude-code-sdk-ts", betas: webSearchBetas, payload: webSearchPayload},
+ {
+ name: "configured t3-code title helper",
+ userAgent: "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276)",
+ entrypoint: "t3-code",
+ subclient: "claude-code-t3-code",
+ betas: titleBetas,
+ payload: []byte(strings.Replace(string(titlePayload), "cc_entrypoint=sdk-ts", "cc_entrypoint=t3-code", 1)),
+ cfg: &config.Config{ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{NativeEntrypoints: []string{"t3-code"}}},
+ },
} {
t.Run(tc.name, func(t *testing.T) {
headers := measuredClaudeCodeHelperHeaders(tc.betas)
- headers.Set("User-Agent", "claude-cli/2.1.304 (external, sdk-ts, agent-sdk/0.3.276)")
+ headers.Set("User-Agent", tc.userAgent)
headers.Set("X-Stainless-Package-Version", "0.128.0")
headers.Del("X-Client-Request-Id")
- detection := DetectClaudeCodeRequest(headers, tc.payload, false)
+ detection := DetectClaudeCodeRequest(headers, tc.payload, false, tc.cfg)
if !detection.Confirmed || !detection.StrongSignals || !detection.NativeClient || !detection.HelperProfile {
- t.Fatalf("detection = %#v, want confirmed sdk-ts Haiku 5.5 helper", detection)
+ t.Fatalf("detection = %#v, want confirmed %s Haiku 5.5 helper", detection, tc.entrypoint)
}
- if detection.Entrypoint != "sdk-ts" || detection.Subclient != "claude-code-sdk-ts" || detection.AgentSDKVersion != "0.3.276" {
- t.Fatalf("detection identity = %#v, want sdk-ts / claude-code-sdk-ts / 0.3.276", detection)
+ if detection.Entrypoint != tc.entrypoint || detection.Subclient != tc.subclient || detection.AgentSDKVersion != "0.3.276" {
+ t.Fatalf("detection identity = %#v, want %s / %s / 0.3.276", detection, tc.entrypoint, tc.subclient)
}
})
}
}
+
+func TestDetectClaudeCodeRequestAcceptsConfiguredNativeEntrypoints(t *testing.T) {
+ payload := claudeCodeDetectionPayload(validClaudeCodeMetadataUserID)
+ headers := confirmedClaudeCodeHeaders()
+ headers.Set("User-Agent", "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276)")
+
+ if detection := DetectClaudeCodeRequest(headers, payload, false); detection.Confirmed || detection.NativeClient {
+ t.Fatalf("default detection = %#v, want unconfirmed without native-entrypoints config", detection)
+ }
+
+ cfg := &config.Config{
+ ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
+ NativeEntrypoints: []string{"t3-code"},
+ },
+ }
+ detection := DetectClaudeCodeRequest(headers, payload, false, cfg)
+ if !detection.Confirmed || !detection.StrongSignals || !detection.NativeClient {
+ t.Fatalf("configured detection = %#v, want confirmed native client", detection)
+ }
+ if detection.Entrypoint != "t3-code" || detection.Subclient != "claude-code-t3-code" || detection.AgentSDKVersion != "0.3.276" {
+ t.Fatalf("configured detection identity = %#v, want t3-code / claude-code-t3-code / 0.3.276", detection)
+ }
+
+ missingBetaHeaders := confirmedClaudeCodeHeaders()
+ missingBetaHeaders.Set("User-Agent", "claude-cli/2.1.316 (external, t3-code, agent-sdk/0.3.276)")
+ missingBetaHeaders.Del("Anthropic-Beta")
+ if unconfirmed := DetectClaudeCodeRequest(missingBetaHeaders, payload, false, cfg); unconfirmed.Confirmed || unconfirmed.StrongSignals {
+ t.Fatalf("configured entrypoint without strong signals = %#v, want unconfirmed", unconfirmed)
+ }
+}
diff --git a/internal/runtime/executor/helps/claude_code_session.go b/internal/runtime/executor/helps/claude_code_session.go
index ba07b213975..dc3ad896d15 100644
--- a/internal/runtime/executor/helps/claude_code_session.go
+++ b/internal/runtime/executor/helps/claude_code_session.go
@@ -6,9 +6,9 @@ import (
"regexp"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
- "github.com/tidwall/gjson"
)
const (
@@ -103,15 +103,15 @@ func extractClaudeCodeSessionIDFromPayload(payload []byte) string {
if len(payload) == 0 {
return ""
}
- userID := gjson.GetBytes(payload, "metadata.user_id").String()
+ userID := ojson.GetBytesNoCopy(payload, "metadata.user_id").String()
if userID == "" {
return ""
}
if matches := claudeCodeSessionSuffixPattern.FindStringSubmatch(userID); len(matches) >= 2 {
- return matches[1]
+ return strings.Clone(matches[1])
}
if len(userID) > 0 && userID[0] == '{' {
- return strings.TrimSpace(gjson.Get(userID, "session_id").String())
+ return strings.Clone(strings.TrimSpace(ojson.Get(userID, "session_id").String()))
}
return ""
}
diff --git a/internal/runtime/executor/helps/claude_credential_identity.go b/internal/runtime/executor/helps/claude_credential_identity.go
index 601dcd075be..62b9985b54d 100644
--- a/internal/runtime/executor/helps/claude_credential_identity.go
+++ b/internal/runtime/executor/helps/claude_credential_identity.go
@@ -8,12 +8,13 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/sjson"
)
// ClaudeAgentSessionUUID maps the downstream agent conversation to one stable UUID,
@@ -90,7 +91,7 @@ func withoutClaudeMetadataUserID(payload []byte) []byte {
if len(payload) == 0 {
return payload
}
- updated, errDelete := sjson.DeleteBytes(payload, "metadata.user_id")
+ updated, errDelete := util.DeleteJSONBytesIfExists(payload, "metadata.user_id")
if errDelete != nil {
return payload
}
@@ -262,22 +263,510 @@ func newClaudeCredentialMetadataRequestError(err error) error {
return &claudeCredentialMetadataRequestError{cause: err}
}
+type claudeValidatorPhase uint8
+
+const (
+ claudePhaseRoot claudeValidatorPhase = iota
+ claudePhaseObjStartOrEnd
+ claudePhaseObjKey
+ claudePhaseObjColon
+ claudePhaseValue
+ claudePhaseArrStartOrEnd
+ claudePhaseAfterValue
+ claudePhaseDone
+)
+
+type claudeValidatorToken uint8
+
+const (
+ claudeTokNone claudeValidatorToken = iota
+ claudeTokString
+ claudeTokLiteral
+ claudeTokNumMinus
+ claudeTokNumZero
+ claudeTokNumDigits
+ claudeTokNumFracFirst
+ claudeTokNumFracDigits
+ claudeTokNumExpSign
+ claudeTokNumExpFirst
+ claudeTokNumExpDigits
+)
+
+type claudeRootMetadataValidator struct {
+ stack []byte
+ stackBuf [64]byte
+ keyBuf [64]byte
+ keyLen int
+ keyOverflow bool
+ keyHasEsc bool
+ isObjKey bool
+ isTopKey bool
+ seenMetadata bool
+ phase claudeValidatorPhase
+ tok claudeValidatorToken
+ strEscape uint8
+ litRem string
+ err error
+}
+
+func isClaudeHexDigit(c byte) bool {
+ return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
+}
+
+func (v *claudeRootMetadataValidator) fail(err error) (int, error) {
+ v.err = err
+ return 0, err
+}
+
+func (v *claudeRootMetadataValidator) finishTopKey() error {
+ if v.keyOverflow {
+ return nil
+ }
+ var isMeta bool
+ if !v.keyHasEsc {
+ isMeta = string(v.keyBuf[:v.keyLen]) == "metadata"
+ } else {
+ var rawKey [66]byte
+ rawKey[0] = '"'
+ copy(rawKey[1:], v.keyBuf[:v.keyLen])
+ rawKey[1+v.keyLen] = '"'
+ var decoded string
+ if errUnmarshal := json.Unmarshal(rawKey[:2+v.keyLen], &decoded); errUnmarshal != nil {
+ return fmt.Errorf("decode JSON object key: %w", errUnmarshal)
+ }
+ isMeta = decoded == "metadata"
+ }
+ if isMeta {
+ if v.seenMetadata {
+ return fmt.Errorf("duplicate JSON object key %q", "metadata")
+ }
+ v.seenMetadata = true
+ }
+ return nil
+}
+
+func (v *claudeRootMetadataValidator) Write(p []byte) (int, error) {
+ if v.err != nil {
+ return 0, v.err
+ }
+ i := 0
+ for i < len(p) {
+ switch v.tok {
+ case claudeTokString:
+ if v.strEscape == 0 && !v.isTopKey {
+ for i < len(p) && p[i] != '"' && p[i] != '\\' && p[i] >= 0x20 {
+ i++
+ }
+ if i >= len(p) {
+ return len(p), nil
+ }
+ }
+ c := p[i]
+ switch v.strEscape {
+ case 0:
+ if c < 0x20 {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ if c == '\\' {
+ if v.isTopKey {
+ v.keyHasEsc = true
+ if v.keyLen < len(v.keyBuf) {
+ v.keyBuf[v.keyLen] = c
+ v.keyLen++
+ } else {
+ v.keyOverflow = true
+ }
+ }
+ v.strEscape = 1
+ i++
+ continue
+ }
+ if c == '"' {
+ v.tok = claudeTokNone
+ if v.isObjKey {
+ v.isObjKey = false
+ if v.isTopKey {
+ v.isTopKey = false
+ if errKey := v.finishTopKey(); errKey != nil {
+ return v.fail(errKey)
+ }
+ }
+ v.phase = claudePhaseObjColon
+ } else {
+ v.phase = claudePhaseAfterValue
+ }
+ i++
+ continue
+ }
+ if v.isTopKey {
+ if v.keyLen < len(v.keyBuf) {
+ v.keyBuf[v.keyLen] = c
+ v.keyLen++
+ } else {
+ v.keyOverflow = true
+ }
+ }
+ i++
+ case 1:
+ if v.isTopKey {
+ if v.keyLen < len(v.keyBuf) {
+ v.keyBuf[v.keyLen] = c
+ v.keyLen++
+ } else {
+ v.keyOverflow = true
+ }
+ }
+ switch c {
+ case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':
+ v.strEscape = 0
+ case 'u':
+ v.strEscape = 2
+ default:
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ i++
+ default:
+ if !isClaudeHexDigit(c) {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ if v.isTopKey {
+ if v.keyLen < len(v.keyBuf) {
+ v.keyBuf[v.keyLen] = c
+ v.keyLen++
+ } else {
+ v.keyOverflow = true
+ }
+ }
+ if v.strEscape == 5 {
+ v.strEscape = 0
+ } else {
+ v.strEscape++
+ }
+ i++
+ }
+ continue
+
+ case claudeTokLiteral:
+ if p[i] != v.litRem[0] {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ v.litRem = v.litRem[1:]
+ if len(v.litRem) == 0 {
+ v.tok = claudeTokNone
+ v.phase = claudePhaseAfterValue
+ }
+ i++
+ continue
+
+ case claudeTokNumMinus:
+ c := p[i]
+ if c == '0' {
+ v.tok = claudeTokNumZero
+ i++
+ continue
+ }
+ if c >= '1' && c <= '9' {
+ v.tok = claudeTokNumDigits
+ i++
+ continue
+ }
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+
+ case claudeTokNumZero:
+ c := p[i]
+ if c == '.' {
+ v.tok = claudeTokNumFracFirst
+ i++
+ continue
+ }
+ if c == 'e' || c == 'E' {
+ v.tok = claudeTokNumExpSign
+ i++
+ continue
+ }
+ v.tok = claudeTokNone
+ v.phase = claudePhaseAfterValue
+
+ case claudeTokNumDigits:
+ c := p[i]
+ if c >= '0' && c <= '9' {
+ i++
+ continue
+ }
+ if c == '.' {
+ v.tok = claudeTokNumFracFirst
+ i++
+ continue
+ }
+ if c == 'e' || c == 'E' {
+ v.tok = claudeTokNumExpSign
+ i++
+ continue
+ }
+ v.tok = claudeTokNone
+ v.phase = claudePhaseAfterValue
+
+ case claudeTokNumFracFirst:
+ c := p[i]
+ if c >= '0' && c <= '9' {
+ v.tok = claudeTokNumFracDigits
+ i++
+ continue
+ }
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+
+ case claudeTokNumFracDigits:
+ c := p[i]
+ if c >= '0' && c <= '9' {
+ i++
+ continue
+ }
+ if c == 'e' || c == 'E' {
+ v.tok = claudeTokNumExpSign
+ i++
+ continue
+ }
+ v.tok = claudeTokNone
+ v.phase = claudePhaseAfterValue
+
+ case claudeTokNumExpSign:
+ c := p[i]
+ if c == '+' || c == '-' {
+ v.tok = claudeTokNumExpFirst
+ i++
+ continue
+ }
+ if c >= '0' && c <= '9' {
+ v.tok = claudeTokNumExpDigits
+ i++
+ continue
+ }
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+
+ case claudeTokNumExpFirst:
+ c := p[i]
+ if c >= '0' && c <= '9' {
+ v.tok = claudeTokNumExpDigits
+ i++
+ continue
+ }
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+
+ case claudeTokNumExpDigits:
+ c := p[i]
+ if c >= '0' && c <= '9' {
+ i++
+ continue
+ }
+ v.tok = claudeTokNone
+ v.phase = claudePhaseAfterValue
+ }
+
+ c := p[i]
+ if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
+ i++
+ continue
+ }
+
+ switch v.phase {
+ case claudePhaseRoot:
+ if c != '{' {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ v.stack = append(v.stack, '{')
+ v.phase = claudePhaseObjStartOrEnd
+ i++
+
+ case claudePhaseObjStartOrEnd:
+ if c == '}' {
+ v.stack = v.stack[:len(v.stack)-1]
+ if len(v.stack) == 0 {
+ v.phase = claudePhaseDone
+ } else {
+ v.phase = claudePhaseAfterValue
+ }
+ i++
+ continue
+ }
+ if c != '"' {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ v.tok = claudeTokString
+ v.strEscape = 0
+ v.isObjKey = true
+ v.isTopKey = len(v.stack) == 1
+ v.keyLen = 0
+ v.keyOverflow = false
+ v.keyHasEsc = false
+ i++
+
+ case claudePhaseObjKey:
+ if c != '"' {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ v.tok = claudeTokString
+ v.strEscape = 0
+ v.isObjKey = true
+ v.isTopKey = len(v.stack) == 1
+ v.keyLen = 0
+ v.keyOverflow = false
+ v.keyHasEsc = false
+ i++
+
+ case claudePhaseObjColon:
+ if c != ':' {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ v.phase = claudePhaseValue
+ i++
+
+ case claudePhaseArrStartOrEnd:
+ if c == ']' {
+ v.stack = v.stack[:len(v.stack)-1]
+ if len(v.stack) == 0 {
+ v.phase = claudePhaseDone
+ } else {
+ v.phase = claudePhaseAfterValue
+ }
+ i++
+ continue
+ }
+ v.phase = claudePhaseValue
+ continue
+
+ case claudePhaseValue:
+ switch c {
+ case '{':
+ v.stack = append(v.stack, '{')
+ v.phase = claudePhaseObjStartOrEnd
+ i++
+ case '[':
+ v.stack = append(v.stack, '[')
+ v.phase = claudePhaseArrStartOrEnd
+ i++
+ case '"':
+ v.tok = claudeTokString
+ v.strEscape = 0
+ v.isObjKey = false
+ v.isTopKey = false
+ i++
+ case 't':
+ v.tok = claudeTokLiteral
+ v.litRem = "rue"
+ i++
+ case 'f':
+ v.tok = claudeTokLiteral
+ v.litRem = "alse"
+ i++
+ case 'n':
+ v.tok = claudeTokLiteral
+ v.litRem = "ull"
+ i++
+ case '-':
+ v.tok = claudeTokNumMinus
+ i++
+ case '0':
+ v.tok = claudeTokNumZero
+ i++
+ default:
+ if c >= '1' && c <= '9' {
+ v.tok = claudeTokNumDigits
+ i++
+ } else {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ }
+
+ case claudePhaseAfterValue:
+ if len(v.stack) == 0 {
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ top := v.stack[len(v.stack)-1]
+ if top == '{' {
+ if c == ',' {
+ v.phase = claudePhaseObjKey
+ i++
+ continue
+ }
+ if c == '}' {
+ v.stack = v.stack[:len(v.stack)-1]
+ if len(v.stack) == 0 {
+ v.phase = claudePhaseDone
+ }
+ i++
+ continue
+ }
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ if c == ',' {
+ v.phase = claudePhaseValue
+ i++
+ continue
+ }
+ if c == ']' {
+ v.stack = v.stack[:len(v.stack)-1]
+ if len(v.stack) == 0 {
+ v.phase = claudePhaseDone
+ }
+ i++
+ continue
+ }
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+
+ case claudePhaseDone:
+ return v.fail(fmt.Errorf("request must be a JSON object"))
+ }
+ }
+ return len(p), nil
+}
+
+func validateClaudeDocMetadataRoot(doc *ojson.Doc) error {
+ if doc == nil || doc.Len() == 0 {
+ return fmt.Errorf("request must be a JSON object")
+ }
+ v := claudeRootMetadataValidator{}
+ v.stack = v.stackBuf[:0]
+ if _, err := doc.WriteTo(&v); err != nil {
+ return err
+ }
+ if v.err != nil {
+ return v.err
+ }
+ if v.tok != claudeTokNone || v.phase != claudePhaseDone || len(v.stack) != 0 {
+ return fmt.Errorf("request must be a JSON object")
+ }
+ return nil
+}
+
// ApplyClaudeCredentialMetadata rewrites the identity exception shared by native and cloaked OAuth requests.
func ApplyClaudeCredentialMetadata(payload []byte, auth *cliproxyauth.Auth, sessionID string) ([]byte, string, error) {
+ doc := ojson.NewDoc(payload)
+ deviceID, errApply := ApplyClaudeCredentialMetadataDoc(doc, auth, sessionID)
+ if errApply != nil {
+ return nil, "", errApply
+ }
+ return doc.Bytes(), deviceID, nil
+}
+
+// ApplyClaudeCredentialMetadataDoc rewrites the identity exception in place on doc.
+func ApplyClaudeCredentialMetadataDoc(doc *ojson.Doc, auth *cliproxyauth.Auth, sessionID string) (string, error) {
+ if doc == nil {
+ return "", fmt.Errorf("apply Claude credential metadata: doc is nil")
+ }
if auth == nil {
- return nil, "", fmt.Errorf("apply Claude credential metadata: auth is nil")
+ return "", fmt.Errorf("apply Claude credential metadata: auth is nil")
}
- metadata, metadataPresent, errMetadata := uniqueClaudeJSONObjectMember(payload, "metadata")
- if errMetadata != nil {
- return nil, "", newClaudeCredentialMetadataRequestError(fmt.Errorf("apply Claude credential metadata: %w", errMetadata))
+ metadataNode := doc.Get("metadata")
+ if errMetadata := validateClaudeDocMetadataRoot(doc); errMetadata != nil {
+ return "", newClaudeCredentialMetadataRequestError(fmt.Errorf("apply Claude credential metadata: %w", errMetadata))
}
var existing string
- if metadataPresent {
- trimmedMetadata := bytes.TrimSpace(metadata)
+ if metadataNode.Exists() && metadataNode.IsObject() {
+ trimmedMetadata := bytes.TrimSpace([]byte(metadataNode.Raw))
if len(trimmedMetadata) >= 2 && trimmedMetadata[0] == '{' {
userID, userIDPresent, errUserID := uniqueClaudeJSONObjectMember(trimmedMetadata, "user_id")
if errUserID != nil {
- return nil, "", newClaudeCredentialMetadataRequestError(fmt.Errorf("apply Claude credential metadata: metadata: %w", errUserID))
+ return "", newClaudeCredentialMetadataRequestError(fmt.Errorf("apply Claude credential metadata: metadata: %w", errUserID))
}
if userIDPresent && json.Unmarshal(userID, &existing) != nil {
existing = ""
@@ -287,26 +776,25 @@ func ApplyClaudeCredentialMetadata(payload []byte, auth *cliproxyauth.Auth, sess
deviceIDs, _, errDeviceIDs := claudeauth.EnsureDeviceIDPoolFor(&auth.Metadata)
if errDeviceIDs != nil {
- return nil, "", errDeviceIDs
+ return "", errDeviceIDs
}
deviceID, errDeviceID := claudeauth.SelectDeviceID(deviceIDs, sessionID)
if errDeviceID != nil {
- return nil, "", errDeviceID
+ return "", errDeviceID
}
accountUUID := ClaudeCredentialAccountUUID(auth)
if accountUUID == "" {
- return nil, "", fmt.Errorf("apply Claude credential metadata: account UUID is empty")
+ return "", fmt.Errorf("apply Claude credential metadata: account UUID is empty")
}
encoded, errIdentity := rebuildClaudeMetadataUserID(existing, deviceID, accountUUID, sessionID)
if errIdentity != nil {
- return nil, "", newClaudeCredentialMetadataRequestError(fmt.Errorf("apply Claude credential metadata: %w", errIdentity))
+ return "", newClaudeCredentialMetadataRequestError(fmt.Errorf("apply Claude credential metadata: %w", errIdentity))
}
- updated, errSet := sjson.SetBytes(payload, "metadata.user_id", string(encoded))
- if errSet != nil {
- return nil, "", fmt.Errorf("set Claude credential metadata: %w", errSet)
+ if errSet := doc.Set("metadata.user_id", string(encoded)); errSet != nil {
+ return "", newClaudeCredentialMetadataRequestError(fmt.Errorf("set Claude credential metadata: %w", errSet))
}
- return updated, deviceID, nil
+ return deviceID, nil
}
type claudeJSONMember struct {
@@ -383,6 +871,9 @@ func skipClaudeJSONString(raw []byte, position int) int {
for position < len(raw) {
switch raw[position] {
case '\\':
+ if position+1 >= len(raw) {
+ return len(raw)
+ }
position += 2
case '"':
return position + 1
@@ -390,12 +881,12 @@ func skipClaudeJSONString(raw []byte, position int) int {
position++
}
}
- return position
+ return len(raw)
}
func skipClaudeJSONValue(raw []byte, position int) int {
if position >= len(raw) {
- return position
+ return len(raw)
}
switch raw[position] {
case '"':
@@ -415,6 +906,9 @@ func skipClaudeJSONValue(raw []byte, position int) int {
}
position++
}
+ if position > len(raw) {
+ return len(raw)
+ }
return position
default:
for position < len(raw) {
@@ -425,7 +919,7 @@ func skipClaudeJSONValue(raw []byte, position int) int {
position++
}
}
- return position
+ return len(raw)
}
}
diff --git a/internal/runtime/executor/helps/claude_credential_identity_test.go b/internal/runtime/executor/helps/claude_credential_identity_test.go
index 86e278ddb13..95b107aea60 100644
--- a/internal/runtime/executor/helps/claude_credential_identity_test.go
+++ b/internal/runtime/executor/helps/claude_credential_identity_test.go
@@ -9,11 +9,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
type fakeClaudeCredentialDevicePoolKV struct {
@@ -146,20 +146,20 @@ func TestApplyClaudeCredentialMetadataUsesCredentialDeviceAndPreservesExtras(t *
if errApply != nil {
t.Fatalf("ApplyClaudeCredentialMetadata() error = %v", errApply)
}
- userID := gjson.GetBytes(updated, "metadata.user_id").String()
- if got := gjson.Get(userID, "device_id").String(); got != selectedDevice {
+ userID := ojson.GetBytes(updated, "metadata.user_id").String()
+ if got := ojson.Get(userID, "device_id").String(); got != selectedDevice {
t.Fatalf("device_id = %q, want selected %q", got, selectedDevice)
}
- if got := gjson.Get(userID, "account_uuid").String(); got != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
+ if got := ojson.Get(userID, "account_uuid").String(); got != "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" {
t.Fatalf("account_uuid = %q, want credential account", got)
}
- if got := gjson.Get(userID, "session_id").String(); got != sessionID {
+ if got := ojson.Get(userID, "session_id").String(); got != sessionID {
t.Fatalf("session_id = %q, want %q", got, sessionID)
}
- if got := gjson.Get(userID, "parent_session_id").String(); got != "parent-1" {
+ if got := ojson.Get(userID, "parent_session_id").String(); got != "parent-1" {
t.Fatalf("parent_session_id = %q, want preserved", got)
}
- if !gjson.Get(userID, "extra").Bool() {
+ if !ojson.Get(userID, "extra").Bool() {
t.Fatal("extra metadata was not preserved")
}
wantPrefix := `{"device_id":"` + selectedDevice + `","account_uuid":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa","session_id":"` + sessionID + `"`
@@ -211,6 +211,18 @@ func TestApplyClaudeCredentialMetadataRejectsDuplicateIdentityContainers(t *test
if !errors.As(errApply, &statusErr) || statusErr.StatusCode() != http.StatusBadRequest {
t.Fatalf("ApplyClaudeCredentialMetadata() error = %T %v, want HTTP 400", errApply, errApply)
}
+
+ doc := ojson.NewDoc([]byte(test.body))
+ _, errApplyDoc := ApplyClaudeCredentialMetadataDoc(doc, auth, sessionID)
+ if errApplyDoc == nil {
+ t.Fatal("ApplyClaudeCredentialMetadataDoc() error = nil, want duplicate-key rejection")
+ }
+ if !errors.As(errApplyDoc, &requestErr) || requestErr == nil || !requestErr.IsRequestScoped() {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc() error = %T %v, want request-scoped", errApplyDoc, errApplyDoc)
+ }
+ if !errors.As(errApplyDoc, &statusErr) || statusErr.StatusCode() != http.StatusBadRequest {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc() error = %T %v, want HTTP 400", errApplyDoc, errApplyDoc)
+ }
})
}
}
@@ -234,3 +246,94 @@ func TestApplyClaudeCredentialMetadataRequiresAccountUUID(t *testing.T) {
t.Fatalf("missing credential identity error = %T %v, want credential-scoped", errApply, errApply)
}
}
+
+func TestApplyClaudeCredentialMetadataDocMutatesInPlace(t *testing.T) {
+ auth := &cliproxyauth.Auth{Metadata: map[string]any{
+ "account_uuid": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
+ claudeauth.ClaudeDeviceIDsMetadataKey: []string{
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ },
+ }}
+ doc := ojson.NewDoc([]byte(`{"model":"claude-sonnet-4-5","messages":[]}`))
+ deviceID, errApply := ApplyClaudeCredentialMetadataDoc(doc, auth, "11111111-2222-4333-8444-555555555555")
+ if errApply != nil {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc() error = %v", errApply)
+ }
+ if deviceID == "" {
+ t.Fatal("expected non-empty deviceID")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "model").String(); got != "claude-sonnet-4-5" {
+ t.Fatalf("model = %q, want claude-sonnet-4-5", got)
+ }
+ if !strings.Contains(ojson.GetBytesNoCopy(out, "metadata.user_id").String(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee") {
+ t.Fatalf("metadata.user_id missing account_uuid: %s", out)
+ }
+
+ t.Run("empty base doc after SetRaw succeeds", func(t *testing.T) {
+ emptyDoc := ojson.NewDoc(nil)
+ if errSet := emptyDoc.SetRaw("messages", "[]"); errSet != nil {
+ t.Fatalf("SetRaw(messages): %v", errSet)
+ }
+ if _, errApply := ApplyClaudeCredentialMetadataDoc(emptyDoc, auth, "11111111-2222-4333-8444-555555555555"); errApply != nil {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc() error = %v", errApply)
+ }
+ emptyOut := emptyDoc.Bytes()
+ if !strings.Contains(ojson.GetBytesNoCopy(emptyOut, "metadata.user_id").String(), "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee") {
+ t.Fatalf("metadata.user_id missing account_uuid: %s", emptyOut)
+ }
+ })
+
+ t.Run("deleted duplicate metadata on split tree succeeds", func(t *testing.T) {
+ dedupDoc := ojson.NewDoc([]byte(`{"metadata":{"user_id":"{\"extra\":\"a\"}"},"metadata":{"user_id":"{\"extra\":\"b\"}"}}`))
+ if errSet := dedupDoc.Set("model", "claude-3"); errSet != nil {
+ t.Fatalf("Set(model): %v", errSet)
+ }
+ if errDel := dedupDoc.Delete("metadata"); errDel != nil {
+ t.Fatalf("Delete(metadata): %v", errDel)
+ }
+ if _, errApply := ApplyClaudeCredentialMetadataDoc(dedupDoc, auth, "11111111-2222-4333-8444-555555555555"); errApply != nil {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc() error = %v", errApply)
+ }
+ dedupOut := dedupDoc.Bytes()
+ userID := ojson.GetBytesNoCopy(dedupOut, "metadata.user_id").String()
+ if got := ojson.Get(userID, "extra").String(); got != "b" {
+ t.Fatalf("metadata.user_id extra = %q, want b (from remaining metadata): %s", got, dedupOut)
+ }
+ })
+
+ t.Run("remaining duplicate metadata on split tree fails", func(t *testing.T) {
+ dupDoc := ojson.NewDoc([]byte(`{"metadata":{"user_id":"a"},"metadata":{"user_id":"b"}}`))
+ if errSet := dupDoc.Set("model", "claude-3"); errSet != nil {
+ t.Fatalf("Set(model): %v", errSet)
+ }
+ _, errApply := ApplyClaudeCredentialMetadataDoc(dupDoc, auth, "11111111-2222-4333-8444-555555555555")
+ if errApply == nil {
+ t.Fatal("ApplyClaudeCredentialMetadataDoc() error = nil, want duplicate metadata rejection")
+ }
+ var requestErr cliproxyexecutor.RequestScopedError
+ if !errors.As(errApply, &requestErr) || requestErr == nil || !requestErr.IsRequestScoped() {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc() error = %T %v, want request-scoped", errApply, errApply)
+ }
+ })
+
+ t.Run("truncated tail values on split tree return request error without panic", func(t *testing.T) {
+ truncatedBodies := []string{
+ "{\"model\":\"claude\",\"metadata\":{},\"bad\":\"\\",
+ "{\"model\":\"claude\",\"metadata\":{},\"bad\":{\"x\":\"\\",
+ "{\"model\":\"claude\",\"metadata\":{},\"bad\":[1,2",
+ }
+ for _, body := range truncatedBodies {
+ truncDoc := ojson.NewDoc([]byte(body))
+ _ = truncDoc.Set("model", "claude-3")
+ _, errApply := ApplyClaudeCredentialMetadataDoc(truncDoc, auth, "11111111-2222-4333-8444-555555555555")
+ if errApply == nil {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc(%q) error = nil, want invalid JSON rejection", body)
+ }
+ var requestErr cliproxyexecutor.RequestScopedError
+ if !errors.As(errApply, &requestErr) || requestErr == nil || !requestErr.IsRequestScoped() {
+ t.Fatalf("ApplyClaudeCredentialMetadataDoc(%q) error = %T %v, want request-scoped", body, errApply, errApply)
+ }
+ }
+ })
+}
diff --git a/internal/runtime/executor/helps/claude_device_profile.go b/internal/runtime/executor/helps/claude_device_profile.go
index 8c2a9e6f95e..b203836f121 100644
--- a/internal/runtime/executor/helps/claude_device_profile.go
+++ b/internal/runtime/executor/helps/claude_device_profile.go
@@ -355,36 +355,40 @@ func claudeDeviceProfileScopeKey(auth *cliproxyauth.Auth, apiKey string) string
// claudeDeviceProfileSubclientScope keeps first-party clients with distinct
// wire identities from replacing one another in a credential's stabilized
// profile. The CLI retains the legacy base scope for cache compatibility.
-func claudeDeviceProfileSubclientScope(profile ClaudeDeviceProfile) string {
+func claudeDeviceProfileSubclientScope(profile ClaudeDeviceProfile, configs ...*config.Config) string {
+ var cfg *config.Config
+ if len(configs) > 0 {
+ cfg = configs[0]
+ }
entrypoint, _ := parseClaudeCodeUserAgentDetails(profile.UserAgent)
if entrypoint == "" || entrypoint == "cli" {
return ""
}
- if nativeClaudeEntrypoints[entrypoint] {
+ if isNativeClaudeEntrypoint(entrypoint, cfg) {
return entrypoint
}
return "other"
}
-func claudeDeviceProfileScopedKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile) string {
+func claudeDeviceProfileScopedKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile, configs ...*config.Config) string {
key := claudeDeviceProfileScopeKey(auth, apiKey)
- if subclient := claudeDeviceProfileSubclientScope(profile); subclient != "" {
+ if subclient := claudeDeviceProfileSubclientScope(profile, configs...); subclient != "" {
key += "|subclient:" + subclient
}
return key
}
-func claudeDeviceProfileCacheKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile) string {
- sum := sha256.Sum256([]byte(claudeDeviceProfileScopedKey(auth, apiKey, profile)))
+func claudeDeviceProfileCacheKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile, configs ...*config.Config) string {
+ sum := sha256.Sum256([]byte(claudeDeviceProfileScopedKey(auth, apiKey, profile, configs...)))
return hex.EncodeToString(sum[:])
}
-func claudeDeviceProfileKVKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile) string {
- return "cpa:claude:device-profile:" + homekv.HashKeyPart(claudeDeviceProfileScopedKey(auth, apiKey, profile))
+func claudeDeviceProfileKVKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile, configs ...*config.Config) string {
+ return "cpa:claude:device-profile:" + homekv.HashKeyPart(claudeDeviceProfileScopedKey(auth, apiKey, profile, configs...))
}
-func claudeDeviceProfileLockKVKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile) string {
- return "cpa:claude:device-profile-lock:" + homekv.HashKeyPart(claudeDeviceProfileScopedKey(auth, apiKey, profile))
+func claudeDeviceProfileLockKVKey(auth *cliproxyauth.Auth, apiKey string, profile ClaudeDeviceProfile, configs ...*config.Config) string {
+ return "cpa:claude:device-profile-lock:" + homekv.HashKeyPart(claudeDeviceProfileScopedKey(auth, apiKey, profile, configs...))
}
func startClaudeDeviceProfileCacheCleanup() {
@@ -444,7 +448,7 @@ func resolveClaudeDeviceProfileLocal(auth *cliproxyauth.Auth, apiKey string, hea
if hasCandidate {
cacheProfile = candidate
}
- cacheKey := claudeDeviceProfileCacheKey(auth, apiKey, cacheProfile)
+ cacheKey := claudeDeviceProfileCacheKey(auth, apiKey, cacheProfile, cfg)
claudeDeviceProfileCacheMu.RLock()
entry, hasCached := claudeDeviceProfileCache[cacheKey]
@@ -507,12 +511,12 @@ func resolveClaudeDeviceProfileHome(ctx context.Context, client claudeDeviceProf
if hasCandidate {
cacheProfile = candidate
}
- valueKey := claudeDeviceProfileKVKey(auth, apiKey, cacheProfile)
+ valueKey := claudeDeviceProfileKVKey(auth, apiKey, cacheProfile, cfg)
if !hasCandidate {
return readClaudeDeviceProfileFromHome(ctx, client, valueKey, baseline)
}
- lockKey := claudeDeviceProfileLockKVKey(auth, apiKey, cacheProfile)
+ lockKey := claudeDeviceProfileLockKVKey(auth, apiKey, cacheProfile, cfg)
gotLock, errLock := client.KVSetNX(ctx, lockKey, []byte("1"), claudeDeviceProfileLockTTL)
if errLock != nil {
return ClaudeDeviceProfile{}, errLock
diff --git a/internal/runtime/executor/helps/claude_device_profile_test.go b/internal/runtime/executor/helps/claude_device_profile_test.go
index b9ef1b7ae43..d1b2777e717 100644
--- a/internal/runtime/executor/helps/claude_device_profile_test.go
+++ b/internal/runtime/executor/helps/claude_device_profile_test.go
@@ -410,6 +410,29 @@ func TestResolveClaudeDeviceProfileSeparatesVSCodeAgentSDKFromCLI(t *testing.T)
t.Fatalf("ResolveClaudeDeviceProfileRequired() sdk-ts error = %v", errSDKTS)
}
+ cfg := &config.Config{
+ ClaudeHeaderDefaults: config.ClaudeHeaderDefaults{
+ NativeEntrypoints: []string{"t3-code", "custom-ide"},
+ },
+ }
+ t3CodeUA := "claude-cli/2.1.280 (external, t3-code, agent-sdk/0.3.276)"
+ t3CodeHeaders := claudeDeviceHeaders(t3CodeUA)
+ t3CodeHeaders.Set("X-Stainless-Package-Version", "0.112.1")
+ t3CodeHeaders.Set("X-Stainless-Runtime-Version", "v26.3.0")
+ t3CodeProfile, errT3Code := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", t3CodeHeaders, cfg)
+ if errT3Code != nil {
+ t.Fatalf("ResolveClaudeDeviceProfileRequired() t3-code error = %v", errT3Code)
+ }
+
+ customIDEUA := "claude-cli/2.1.280 (external, custom-ide, agent-sdk/0.3.276)"
+ customIDEHeaders := claudeDeviceHeaders(customIDEUA)
+ customIDEHeaders.Set("X-Stainless-Package-Version", "0.112.1")
+ customIDEHeaders.Set("X-Stainless-Runtime-Version", "v26.3.0")
+ customIDEProfile, errCustomIDE := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", customIDEHeaders, cfg)
+ if errCustomIDE != nil {
+ t.Fatalf("ResolveClaudeDeviceProfileRequired() custom-ide error = %v", errCustomIDE)
+ }
+
if cliProfile.UserAgent != "claude-cli/2.1.280 (external, cli)" {
t.Fatalf("CLI UserAgent = %q, want CLI profile", cliProfile.UserAgent)
}
@@ -419,6 +442,12 @@ func TestResolveClaudeDeviceProfileSeparatesVSCodeAgentSDKFromCLI(t *testing.T)
if sdkTSProfile.UserAgent != sdkTSUA {
t.Fatalf("sdk-ts UserAgent = %q, want %q", sdkTSProfile.UserAgent, sdkTSUA)
}
+ if t3CodeProfile.UserAgent != t3CodeUA {
+ t.Fatalf("t3-code UserAgent = %q, want %q", t3CodeProfile.UserAgent, t3CodeUA)
+ }
+ if customIDEProfile.UserAgent != customIDEUA {
+ t.Fatalf("custom-ide UserAgent = %q, want %q", customIDEProfile.UserAgent, customIDEUA)
+ }
cliProfileAgain, errCLIAgain := ResolveClaudeDeviceProfileRequired(context.Background(), auth, "api-key", cliHeaders, nil)
if errCLIAgain != nil {
diff --git a/internal/runtime/executor/helps/claude_diagnostics.go b/internal/runtime/executor/helps/claude_diagnostics.go
index 8e93f1fbc7f..25ca3718894 100644
--- a/internal/runtime/executor/helps/claude_diagnostics.go
+++ b/internal/runtime/executor/helps/claude_diagnostics.go
@@ -12,9 +12,9 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
)
const (
@@ -22,8 +22,19 @@ const (
claudeDiagnosticsCleanupPeriod = 15 * time.Minute
claudeDiagnosticsMaxEntries = 4096
claudeDiagnosticsEvictBatchSize = 256
+ claudeSessionDatePinKVPrefix = "cpa:claude:session-date-pin:"
)
+type claudeSessionDatePinKVClient interface {
+ KVGet(ctx context.Context, key string) ([]byte, bool, error)
+ KVSetNX(ctx context.Context, key string, value []byte, ttl time.Duration) (bool, error)
+ KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error)
+}
+
+var currentClaudeSessionDatePinKVClient = func() (claudeSessionDatePinKVClient, bool, error) {
+ return homekv.CurrentKVClient()
+}
+
var (
claudeRequestIDPattern = regexp.MustCompile(`^req_[A-Za-z0-9_-]{1,36}$`)
claudePromptIDPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
@@ -146,24 +157,104 @@ func ClaudeSessionDatePinKey(sessionID string) string {
return hex.EncodeToString(digest[:])
}
-// LookupClaudeSessionDatePin returns the retained pinned date for a session
-// without creating or refreshing it. Non-positive retention returns "".
-func LookupClaudeSessionDatePin(sessionID string, retention time.Duration) string {
- if retention <= 0 {
+func claudeSessionDatePinKVKey(pinKey string) string {
+ if pinKey == "" {
return ""
}
- pinKey := ClaudeSessionDatePinKey(sessionID)
+ return claudeSessionDatePinKVPrefix + pinKey
+}
+
+func normalizeClaudePinnedDate(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ if len(trimmed) != len("2006-01-02") {
+ return ""
+ }
+ if _, errParse := time.Parse("2006-01-02", trimmed); errParse != nil {
+ return ""
+ }
+ return trimmed
+}
+
+func lookupClaudeSessionDatePinLocked(pinKey string, now time.Time) string {
if pinKey == "" {
return ""
}
+ pin, okPin := claudeDiagnosticsState.datePins[pinKey]
+ if !okPin {
+ return ""
+ }
+ if !pin.expiresAt.IsZero() && now.After(pin.expiresAt) {
+ return ""
+ }
+ return normalizeClaudePinnedDate(pin.date)
+}
+
+func storePermanentClaudeSessionDatePinLocked(pinKey, date string, now time.Time) {
+ if pinKey == "" || date == "" {
+ return
+ }
+ cleanupClaudeDiagnosticsLocked(now)
+ if _, okPin := claudeDiagnosticsState.datePins[pinKey]; !okPin {
+ evictClaudeSessionDatePinsLocked()
+ }
+ claudeDiagnosticsState.datePins[pinKey] = claudeSessionDatePin{
+ date: date,
+ expiresAt: time.Time{},
+ }
+}
+
+func storePermanentClaudeSessionDatePin(pinKey, date string) {
+ if pinKey == "" || date == "" {
+ return
+ }
now := time.Now()
claudeDiagnosticsState.Lock()
- defer claudeDiagnosticsState.Unlock()
- pin, okPin := claudeDiagnosticsState.datePins[pinKey]
- if !okPin || now.After(pin.expiresAt) {
+ storePermanentClaudeSessionDatePinLocked(pinKey, date, now)
+ claudeDiagnosticsState.Unlock()
+}
+
+// IsClaudeSessionDatePinKVActive reports whether a persistent KV store (local
+// bbolt or Home KV) is configured as the backing store for Claude session date pins.
+func IsClaudeSessionDatePinKVActive() bool {
+ _, kvMode, _ := currentClaudeSessionDatePinKVClient()
+ return kvMode
+}
+
+// LookupClaudeSessionDatePin returns the retained pinned date for a session
+// without creating or refreshing it. When a KV store is active, pins are stored
+// permanently and retention is ignored; otherwise non-positive retention returns "".
+func LookupClaudeSessionDatePin(sessionID string, retention time.Duration) string {
+ pinKey := ClaudeSessionDatePinKey(sessionID)
+ if pinKey == "" {
return ""
}
- return pin.date
+ client, kvMode, errClient := currentClaudeSessionDatePinKVClient()
+ if kvMode {
+ if errClient == nil && client != nil {
+ raw, found, errGet := client.KVGet(context.Background(), claudeSessionDatePinKVKey(pinKey))
+ if errGet == nil {
+ if !found {
+ return ""
+ }
+ pinned := normalizeClaudePinnedDate(string(raw))
+ if pinned != "" {
+ storePermanentClaudeSessionDatePin(pinKey, pinned)
+ }
+ return pinned
+ }
+ }
+ now := time.Now()
+ claudeDiagnosticsState.Lock()
+ defer claudeDiagnosticsState.Unlock()
+ return lookupClaudeSessionDatePinLocked(pinKey, now)
+ }
+ if retention <= 0 {
+ return ""
+ }
+ now := time.Now()
+ claudeDiagnosticsState.Lock()
+ defer claudeDiagnosticsState.Unlock()
+ return lookupClaudeSessionDatePinLocked(pinKey, now)
}
// IsValidClaudePromptID verifies whether an explicit prompt ID adheres to strict RFC 4122 UUIDv4 semantics.
@@ -238,11 +329,12 @@ func BeginClaudeContinuity(credentialIdentity, sessionID string, isNewPromptTurn
// PinClaudeSessionDate returns the calendar date pinned for a session,
// recording date on the first call and returning the pinned value on later
-// calls. A non-positive retention keeps the legacy continuity-scoped pin,
-// whose lifetime follows claudeDiagnosticsTTL. A positive retention stores
-// the pin separately under a session-only key, so continuity expiry or a
-// credential switch cannot re-anchor it before the retention expires. An
-// unknown continuity key returns date unchanged, matching per-request dates.
+// calls. When a KV store is active, the pin is persisted permanently under a
+// session-only KV key and retention is ignored. Otherwise, a non-positive
+// retention keeps the legacy continuity-scoped pin (whose lifetime follows
+// claudeDiagnosticsTTL) and a positive retention stores the pin in memory
+// under a session-only key for retention. An unknown continuity key returns
+// date unchanged, matching per-request dates.
func PinClaudeSessionDate(key, sessionID, date string, retention time.Duration) string {
key = strings.TrimSpace(key)
sessionID = strings.TrimSpace(sessionID)
@@ -250,13 +342,90 @@ func PinClaudeSessionDate(key, sessionID, date string, retention time.Duration)
if key == "" || date == "" {
return date
}
- now := time.Now()
+ client, kvMode, errClient := currentClaudeSessionDatePinKVClient()
claudeDiagnosticsState.Lock()
- defer claudeDiagnosticsState.Unlock()
entry, okEntry := claudeDiagnosticsState.entries[key]
if !okEntry {
+ claudeDiagnosticsState.Unlock()
+ return date
+ }
+ if sessionID != "" && kvMode {
+ pinKey := ClaudeSessionDatePinKey(sessionID)
+ now := time.Now()
+ targetDate := normalizeClaudePinnedDate(date)
+ if l1Date := lookupClaudeSessionDatePinLocked(pinKey, now); l1Date != "" {
+ targetDate = l1Date
+ }
+ if errClient == nil && client != nil {
+ claudeDiagnosticsState.Unlock()
+ ctx := context.Background()
+ kvKey := claudeSessionDatePinKVKey(pinKey)
+ raw, found, errGet := client.KVGet(ctx, kvKey)
+ if errGet == nil && found {
+ if pinned := normalizeClaudePinnedDate(string(raw)); pinned != "" {
+ storePermanentClaudeSessionDatePin(pinKey, pinned)
+ return pinned
+ }
+ if targetDate == "" {
+ return date
+ }
+ swapped, errCAS := client.KVCompareAndSwap(ctx, kvKey, raw, true, []byte(targetDate), 0)
+ if errCAS == nil && swapped {
+ storePermanentClaudeSessionDatePin(pinKey, targetDate)
+ return targetDate
+ }
+ if errCAS == nil && !swapped {
+ if rawRace, foundRace, errRace := client.KVGet(ctx, kvKey); errRace == nil && foundRace {
+ if pinned := normalizeClaudePinnedDate(string(rawRace)); pinned != "" {
+ storePermanentClaudeSessionDatePin(pinKey, pinned)
+ return pinned
+ }
+ }
+ }
+ } else if errGet == nil && !found {
+ if targetDate == "" {
+ return date
+ }
+ written, errSet := client.KVSetNX(ctx, kvKey, []byte(targetDate), 0)
+ if errSet == nil && written {
+ storePermanentClaudeSessionDatePin(pinKey, targetDate)
+ return targetDate
+ }
+ if errSet == nil && !written {
+ if rawRace, foundRace, errRace := client.KVGet(ctx, kvKey); errRace == nil && foundRace {
+ if pinned := normalizeClaudePinnedDate(string(rawRace)); pinned != "" {
+ storePermanentClaudeSessionDatePin(pinKey, pinned)
+ return pinned
+ }
+ }
+ }
+ }
+ claudeDiagnosticsState.Lock()
+ entry, okEntry = claudeDiagnosticsState.entries[key]
+ if !okEntry {
+ claudeDiagnosticsState.Unlock()
+ return date
+ }
+ now = time.Now()
+ if l1Date := lookupClaudeSessionDatePinLocked(pinKey, now); l1Date != "" {
+ targetDate = l1Date
+ }
+ }
+ if targetDate != "" {
+ storePermanentClaudeSessionDatePinLocked(pinKey, targetDate, now)
+ if entry.pinnedDate == "" {
+ entry.pinnedDate = targetDate
+ claudeDiagnosticsState.entries[key] = entry
+ }
+ claudeDiagnosticsState.Unlock()
+ return targetDate
+ }
+ claudeDiagnosticsState.Unlock()
return date
}
+ defer claudeDiagnosticsState.Unlock()
+
+ now := time.Now()
if retention <= 0 || sessionID == "" {
if entry.pinnedDate == "" {
entry.pinnedDate = date
@@ -271,7 +440,7 @@ func PinClaudeSessionDate(key, sessionID, date string, retention time.Duration)
if !okPin {
evictClaudeSessionDatePinsLocked()
}
- if !okPin || now.After(pin.expiresAt) {
+ if !okPin || (!pin.expiresAt.IsZero() && now.After(pin.expiresAt)) {
pin.date = date
}
pin.expiresAt = now.Add(retention)
@@ -342,7 +511,7 @@ func cleanupClaudeDiagnosticsLocked(now time.Time) {
}
}
for key, pin := range claudeDiagnosticsState.datePins {
- if now.After(pin.expiresAt) {
+ if !pin.expiresAt.IsZero() && now.After(pin.expiresAt) {
delete(claudeDiagnosticsState.datePins, key)
}
}
@@ -364,6 +533,11 @@ func evictClaudeSessionDatePinsLocked() {
candidates = append(candidates, candidate{key: key, expiresAt: pin.expiresAt})
}
sort.Slice(candidates, func(i, j int) bool {
+ iZero := candidates[i].expiresAt.IsZero()
+ jZero := candidates[j].expiresAt.IsZero()
+ if iZero != jZero {
+ return !iZero
+ }
return candidates[i].expiresAt.Before(candidates[j].expiresAt)
})
count := min(claudeDiagnosticsEvictBatchSize, len(candidates))
@@ -453,7 +627,7 @@ func IsClaudeNewPromptTurn(body []byte) bool {
if IsClaudeProbeOrHelperRequest(body) {
return false
}
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
if !messages.IsArray() {
return true
}
@@ -483,15 +657,15 @@ var (
)
func isClaudeProbeRequest(body []byte) bool {
- maxTokens := gjson.GetBytes(body, "max_tokens")
+ maxTokens := ojson.GetBytesNoCopy(body, "max_tokens")
if !maxTokens.Exists() || maxTokens.Int() != 1 {
return false
}
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if tools.Exists() && len(tools.Array()) > 0 {
return false
}
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
if !messages.Exists() || !messages.IsArray() || len(messages.Array()) == 0 {
return true // headless preflight probe without messages
}
@@ -504,7 +678,7 @@ func isClaudeProbeRequest(body []byte) bool {
return false
}
content := firstMsg.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
str := strings.TrimSpace(content.String())
return str == "quota" || str == "test" || str == "." || str == "probe"
}
@@ -533,7 +707,7 @@ func isClaudeTitleHelperInstruction(body []byte) bool {
strings.Contains(t, "Write the title in the predominant language") ||
strings.Contains(t, "")
}
- system := gjson.GetBytes(body, "system")
+ system := ojson.GetBytesNoCopy(body, "system")
if system.IsArray() {
for _, part := range system.Array() {
if matchesTitlePrompt(part.Get("text").String()) {
@@ -543,7 +717,7 @@ func isClaudeTitleHelperInstruction(body []byte) bool {
} else if matchesTitlePrompt(system.String()) {
return true
}
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
if messages.IsArray() {
for _, msg := range messages.Array() {
content := msg.Get("content")
@@ -568,7 +742,7 @@ func isClaudeTitleHelperRequest(body []byte) bool {
"Write the title in the predominant language") {
return false
}
- props := gjson.GetBytes(body, "output_config.format.schema.properties")
+ props := ojson.GetBytesNoCopy(body, "output_config.format.schema.properties")
if props.Exists() {
if props.Get("title").Exists() && len(props.Map()) == 1 {
return isClaudeTitleHelperInstruction(body)
@@ -584,7 +758,7 @@ func isClaudeTitleHelperRequest(body []byte) bool {
strings.Contains(t, "Return a short title") ||
strings.Contains(t, "Write the title in the predominant language")
}
- system := gjson.GetBytes(body, "system")
+ system := ojson.GetBytesNoCopy(body, "system")
if system.IsArray() {
for _, part := range system.Array() {
if matchesSystemTitleInstruction(part.Get("text").String()) {
@@ -595,7 +769,7 @@ func isClaudeTitleHelperRequest(body []byte) bool {
return true
}
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytesNoCopy(body, "messages")
if messages.IsArray() {
for _, msg := range messages.Array() {
if msg.Get("role").String() == "system" {
@@ -631,21 +805,21 @@ func IsClaudeSubagentRequest(headers http.Header, body []byte) bool {
if val := HeaderValueCaseInsensitive(headers, "X-Claude-Code-Parent-Agent-Id"); val != "" {
return true
}
- if gjson.GetBytes(body, "metadata.user_id.parent_session_id").Exists() {
+ if ojson.GetBytesNoCopy(body, "metadata.user_id.parent_session_id").Exists() {
return true
}
- if userID := gjson.GetBytes(body, "metadata.user_id").String(); userID != "" {
+ if userID := ojson.GetBytesNoCopy(body, "metadata.user_id").String(); userID != "" {
if strings.Contains(userID, `"parent_session_id"`) {
return true
}
}
// Only inspect system[0].text or billing header for cc_is_subagent, never raw user message content
- system := gjson.GetBytes(body, "system")
+ system := ojson.GetBytesNoCopy(body, "system")
if system.IsArray() && len(system.Array()) > 0 {
if strings.Contains(system.Array()[0].Get("text").String(), "cc_is_subagent=true") {
return true
}
- } else if system.Type == gjson.String && strings.Contains(system.String(), "cc_is_subagent=true") {
+ } else if system.Type == ojson.String && strings.Contains(system.String(), "cc_is_subagent=true") {
return true
}
return false
@@ -656,11 +830,11 @@ func IsClaudeSubagentRequest(headers http.Header, body []byte) bool {
func ClaudePayloadHas1hTTL(payload []byte) bool {
// A ttl of "1h" is the JSON string "1h"; its quotes are structural and never
// escaped, so a payload without that token cannot match.
- if len(payload) == 0 || !jsonMayContainASCII(payload, `"1h"`) || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !jsonMayContainASCII(payload, `"1h"`) || !ojson.ValidBytes(payload) {
return false
}
has1h := false
- checkBlock := func(item gjson.Result) bool {
+ checkBlock := func(item ojson.Result) bool {
cc := item.Get("cache_control")
if cc.IsObject() && cc.Get("ttl").String() == "1h" {
has1h = true
@@ -668,27 +842,27 @@ func ClaudePayloadHas1hTTL(payload []byte) bool {
}
return true
}
- if tools := gjson.GetBytes(payload, "tools"); tools.IsArray() {
- tools.ForEach(func(_, item gjson.Result) bool {
+ if tools := ojson.GetBytesNoCopy(payload, "tools"); tools.IsArray() {
+ tools.ForEach(func(_, item ojson.Result) bool {
return checkBlock(item)
})
if has1h {
return true
}
}
- if system := gjson.GetBytes(payload, "system"); system.IsArray() {
- system.ForEach(func(_, item gjson.Result) bool {
+ if system := ojson.GetBytesNoCopy(payload, "system"); system.IsArray() {
+ system.ForEach(func(_, item ojson.Result) bool {
return checkBlock(item)
})
if has1h {
return true
}
}
- if messages := gjson.GetBytes(payload, "messages"); messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ if messages := ojson.GetBytesNoCopy(payload, "messages"); messages.IsArray() {
+ messages.ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if content.IsArray() {
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
return checkBlock(item)
})
}
@@ -711,34 +885,61 @@ func ClaudeSubagentRequests1h(headers http.Header, body []byte) bool {
// StripClaudeBillingTags removes cc_prev_req and cc_prompt_id from the billing header in body.
func StripClaudeBillingTags(body []byte) []byte {
- system := gjson.GetBytes(body, "system")
- if !system.IsArray() || len(system.Array()) == 0 {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !StripClaudeBillingTagsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+// StripClaudeBillingTagsDoc removes cc_prev_req and cc_prompt_id from the billing header in doc in place.
+func StripClaudeBillingTagsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ system := doc.Get("system")
+ if !system.IsArray() || len(system.Array()) == 0 {
+ return false
+ }
billingText := system.Array()[0].Get("text").String()
if !strings.HasPrefix(billingText, "x-anthropic-billing-header:") {
- return body
+ return false
}
cleaned := claudePrevReqBillingPattern.ReplaceAllString(billingText, "")
cleaned = claudePromptIDBillingPattern.ReplaceAllString(cleaned, "")
if cleaned != billingText {
- updated, err := sjson.SetBytes(body, "system.0.text", cleaned)
- if err == nil {
- return updated
- }
+ return doc.Set("system.0.text", cleaned) == nil
}
- return body
+ return false
}
// InjectClaudeBillingTags appends cc_prev_req and cc_prompt_id to the billing header in body if present.
func InjectClaudeBillingTags(body []byte, prevReq, promptID string) []byte {
- system := gjson.GetBytes(body, "system")
- if !system.IsArray() || len(system.Array()) == 0 {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !InjectClaudeBillingTagsDoc(doc, prevReq, promptID) {
return body
}
+ return doc.Bytes()
+}
+
+// InjectClaudeBillingTagsDoc appends cc_prev_req and cc_prompt_id to the billing header in doc in place.
+func InjectClaudeBillingTagsDoc(doc *ojson.Doc, prevReq, promptID string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ system := doc.Get("system")
+ if !system.IsArray() || len(system.Array()) == 0 {
+ return false
+ }
billingText := system.Array()[0].Get("text").String()
if !strings.HasPrefix(billingText, "x-anthropic-billing-header:") {
- return body
+ return false
}
cleaned := claudePrevReqBillingPattern.ReplaceAllString(billingText, "")
cleaned = claudePromptIDBillingPattern.ReplaceAllString(cleaned, "")
@@ -752,21 +953,17 @@ func InjectClaudeBillingTags(body []byte, prevReq, promptID string) []byte {
if promptID != "" {
cleaned += " cc_prompt_id=" + promptID + ";"
}
- updated, err := sjson.SetBytes(body, "system.0.text", cleaned)
- if err == nil {
- return updated
- }
- return body
+ return doc.Set("system.0.text", cleaned) == nil
}
// ExtractClaudeBillingTags extracts existing cc_prev_req and cc_prompt_id values
// from a billing header in system text, if present.
func ExtractClaudeBillingTags(body []byte) (prevReq, promptID string) {
- system := gjson.GetBytes(body, "system")
+ system := ojson.GetBytesNoCopy(body, "system")
var billingText string
if system.IsArray() && len(system.Array()) > 0 {
billingText = system.Array()[0].Get("text").String()
- } else if system.Type == gjson.String {
+ } else if system.Type == ojson.String {
billingText = system.String()
}
if billingText == "" || !strings.HasPrefix(billingText, "x-anthropic-billing-header:") {
@@ -778,7 +975,7 @@ func ExtractClaudeBillingTags(body []byte) (prevReq, promptID string) {
val = val[:end]
}
if claudeRequestIDPattern.MatchString(val) {
- prevReq = val
+ prevReq = strings.Clone(val)
}
}
if idx := strings.Index(billingText, "cc_prompt_id="); idx >= 0 {
@@ -787,7 +984,7 @@ func ExtractClaudeBillingTags(body []byte) (prevReq, promptID string) {
val = val[:end]
}
if IsValidClaudePromptID(val) {
- promptID = strings.ToLower(val)
+ promptID = strings.Clone(strings.ToLower(val))
}
}
return prevReq, promptID
diff --git a/internal/runtime/executor/helps/claude_diagnostics_test.go b/internal/runtime/executor/helps/claude_diagnostics_test.go
index 221a75bfda3..c6e77bf8aa8 100644
--- a/internal/runtime/executor/helps/claude_diagnostics_test.go
+++ b/internal/runtime/executor/helps/claude_diagnostics_test.go
@@ -1,12 +1,17 @@
package helps
import (
+ "bytes"
+ "context"
+ "errors"
"fmt"
"net/http"
+ "sync"
"testing"
"time"
"github.com/google/uuid"
+ homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
)
func TestClaudeDiagnosticsTracksCompletedMessagePerCredentialSession(t *testing.T) {
@@ -515,3 +520,328 @@ func TestPinClaudeSessionDateRetentionSurvivesContinuityTTLAndCredentialSwitch(t
t.Fatalf("post-pin-TTL pin = %q, want re-anchored 2026-08-02", got)
}
}
+
+type fakeClaudeSessionDatePinKVClient struct {
+ mu sync.Mutex
+ values map[string][]byte
+ err error
+ casErr error
+ getCalls int
+ setNXCalls int
+ casCalls int
+ lastSetNXTTL time.Duration
+ lastCASTTL time.Duration
+ onKVGet func()
+}
+
+func (f *fakeClaudeSessionDatePinKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) {
+ f.mu.Lock()
+ f.getCalls++
+ if f.err != nil {
+ err := f.err
+ f.mu.Unlock()
+ return nil, false, err
+ }
+ raw, ok := f.values[key]
+ var cloned []byte
+ if ok {
+ cloned = append([]byte(nil), raw...)
+ }
+ hook := f.onKVGet
+ f.mu.Unlock()
+ if hook != nil {
+ hook()
+ }
+ if !ok {
+ return nil, false, nil
+ }
+ return cloned, true, nil
+}
+
+func (f *fakeClaudeSessionDatePinKVClient) KVSetNX(_ context.Context, key string, value []byte, ttl time.Duration) (bool, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.setNXCalls++
+ if f.err != nil {
+ return false, f.err
+ }
+ f.lastSetNXTTL = ttl
+ if _, exists := f.values[key]; exists {
+ return false, nil
+ }
+ f.values[key] = append([]byte(nil), value...)
+ return true, nil
+}
+
+func (f *fakeClaudeSessionDatePinKVClient) KVCompareAndSwap(_ context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.casCalls++
+ if f.err != nil {
+ return false, f.err
+ }
+ if f.casErr != nil {
+ return false, f.casErr
+ }
+ f.lastCASTTL = ttl
+ cur, exists := f.values[key]
+ if exists != expectedExists {
+ return false, nil
+ }
+ if expectedExists && !bytes.Equal(cur, expected) {
+ return false, nil
+ }
+ f.values[key] = append([]byte(nil), value...)
+ return true, nil
+}
+
+func TestPinClaudeSessionDateKVPersistsPermanentlyAndIgnoresRetention(t *testing.T) {
+ resetClaudeDiagnosticsForTest()
+ defer resetClaudeDiagnosticsForTest()
+
+ fakeKV := &fakeClaudeSessionDatePinKVClient{values: make(map[string][]byte)}
+ prevClientFunc := currentClaudeSessionDatePinKVClient
+ currentClaudeSessionDatePinKVClient = func() (claudeSessionDatePinKVClient, bool, error) {
+ return fakeKV, true, nil
+ }
+ defer func() {
+ currentClaudeSessionDatePinKVClient = prevClientFunc
+ }()
+
+ if !IsClaudeSessionDatePinKVActive() {
+ t.Fatal("IsClaudeSessionDatePinKVActive() = false, want true")
+ }
+
+ // First request with retention = 0 still persists permanently in KV.
+ keyA, _, _ := BeginClaudeDiagnostics("credential-a", "session-kv")
+ if got := PinClaudeSessionDate(keyA, "session-kv", "2026-08-01", 0); got != "2026-08-01" {
+ t.Fatalf("first KV pin = %q, want 2026-08-01", got)
+ }
+ if fakeKV.setNXCalls != 1 || fakeKV.casCalls != 0 {
+ t.Fatalf("KV writes after first pin: setNX=%d cas=%d, want 1/0", fakeKV.setNXCalls, fakeKV.casCalls)
+ }
+ if fakeKV.lastSetNXTTL != 0 {
+ t.Fatalf("KVSetNX TTL = %v, want 0 (permanent)", fakeKV.lastSetNXTTL)
+ }
+ wantKVKey := "cpa:claude:session-date-pin:" + ClaudeSessionDatePinKey("session-kv")
+ if string(fakeKV.values[wantKVKey]) != "2026-08-01" {
+ t.Fatalf("KV stored value at %q = %q, want 2026-08-01", wantKVKey, string(fakeKV.values[wantKVKey]))
+ }
+
+ // Lookup ignores retention <= 0 when KV is active.
+ if got := LookupClaudeSessionDatePin("session-kv", 0); got != "2026-08-01" {
+ t.Fatalf("LookupClaudeSessionDatePin(retention=0) = %q, want 2026-08-01", got)
+ }
+
+ // Simulate process restart (wiping all in-memory state) and credential switch.
+ resetClaudeDiagnosticsForTest()
+ keyB, _, _ := BeginClaudeDiagnostics("credential-b", "session-kv")
+ if got := PinClaudeSessionDate(keyB, "session-kv", "2026-09-15", 0); got != "2026-08-01" {
+ t.Fatalf("post-restart KV pin = %q, want anchored 2026-08-01", got)
+ }
+ if fakeKV.setNXCalls != 1 || fakeKV.casCalls != 0 {
+ t.Fatalf("subsequent request must perform zero KV writes: setNX=%d cas=%d, want 1/0", fakeKV.setNXCalls, fakeKV.casCalls)
+ }
+
+ // Invalid input date is never written to KV.
+ keyInvalid, _, _ := BeginClaudeDiagnostics("credential-a", "session-invalid-date")
+ if got := PinClaudeSessionDate(keyInvalid, "session-invalid-date", "not-a-valid-date", 0); got != "not-a-valid-date" {
+ t.Fatalf("invalid date pin = %q, want unchanged input", got)
+ }
+ if fakeKV.setNXCalls != 1 || fakeKV.casCalls != 0 {
+ t.Fatalf("invalid date must not write to KV: setNX=%d cas=%d, want 1/0", fakeKV.setNXCalls, fakeKV.casCalls)
+ }
+}
+
+func TestPinClaudeSessionDateKVConcurrentCorruptedRepairUsesCAS(t *testing.T) {
+ resetClaudeDiagnosticsForTest()
+ defer resetClaudeDiagnosticsForTest()
+
+ fakeKV := &fakeClaudeSessionDatePinKVClient{values: make(map[string][]byte)}
+ prevClientFunc := currentClaudeSessionDatePinKVClient
+ currentClaudeSessionDatePinKVClient = func() (claudeSessionDatePinKVClient, bool, error) {
+ return fakeKV, true, nil
+ }
+ defer func() {
+ currentClaudeSessionDatePinKVClient = prevClientFunc
+ }()
+
+ wantKVKey := "cpa:claude:session-date-pin:" + ClaudeSessionDatePinKey("session-cas")
+ fakeKV.values[wantKVKey] = []byte("corrupted-value")
+
+ key1, _, _ := BeginClaudeDiagnostics("cred-1", "session-cas")
+ key2, _, _ := BeginClaudeDiagnostics("cred-2", "session-cas")
+
+ // Synchronize the first two KVGet calls so both callers deterministically
+ // observe the same corrupted bytes before either executes KVCompareAndSwap.
+ var getBarrier sync.WaitGroup
+ getBarrier.Add(2)
+ var barrierOnce sync.Mutex
+ barrierCount := 0
+ fakeKV.onKVGet = func() {
+ barrierOnce.Lock()
+ barrierCount++
+ waitForBoth := barrierCount <= 2
+ barrierOnce.Unlock()
+ if waitForBoth {
+ getBarrier.Done()
+ getBarrier.Wait()
+ }
+ }
+
+ var (
+ wg sync.WaitGroup
+ got1 string
+ got2 string
+ )
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ got1 = PinClaudeSessionDate(key1, "session-cas", "2026-08-01", 0)
+ }()
+ go func() {
+ defer wg.Done()
+ got2 = PinClaudeSessionDate(key2, "session-cas", "2026-08-02", 0)
+ }()
+ wg.Wait()
+ fakeKV.onKVGet = nil
+
+ if got1 != got2 {
+ t.Fatalf("concurrent CAS repair returned divergent dates: %q vs %q", got1, got2)
+ }
+ if got1 != "2026-08-01" && got1 != "2026-08-02" {
+ t.Fatalf("concurrent CAS repair date = %q, want 2026-08-01 or 2026-08-02", got1)
+ }
+ if string(fakeKV.values[wantKVKey]) != got1 {
+ t.Fatalf("KV value after concurrent repair = %q, want %q", string(fakeKV.values[wantKVKey]), got1)
+ }
+ if fakeKV.casCalls != 2 {
+ t.Fatalf("CAS repair calls: cas=%d, want 2", fakeKV.casCalls)
+ }
+ if fakeKV.lastCASTTL != 0 {
+ t.Fatalf("KVCompareAndSwap TTL = %v, want 0 (permanent)", fakeKV.lastCASTTL)
+ }
+
+ // When KVCompareAndSwap returns ErrCompareAndSwapUnsupported, concurrent callers
+ // must still converge on a single deterministic date via the mutex-protected L1
+ // fallback without performing an unconditional KV overwrite race.
+ resetClaudeDiagnosticsForTest()
+ fakeKV.values[wantKVKey] = []byte("corrupted-again")
+ fakeKV.casErr = homekv.ErrCompareAndSwapUnsupported
+ key3, _, _ := BeginClaudeDiagnostics("cred-3", "session-cas")
+ key4, _, _ := BeginClaudeDiagnostics("cred-4", "session-cas")
+
+ var unsupportedBarrier sync.WaitGroup
+ unsupportedBarrier.Add(2)
+ unsupportedCount := 0
+ fakeKV.onKVGet = func() {
+ barrierOnce.Lock()
+ unsupportedCount++
+ waitForBoth := unsupportedCount <= 2
+ barrierOnce.Unlock()
+ if waitForBoth {
+ unsupportedBarrier.Done()
+ unsupportedBarrier.Wait()
+ }
+ }
+
+ var (
+ wgUnsupported sync.WaitGroup
+ got3 string
+ got4 string
+ )
+ wgUnsupported.Add(2)
+ go func() {
+ defer wgUnsupported.Done()
+ got3 = PinClaudeSessionDate(key3, "session-cas", "2026-08-05", 0)
+ }()
+ go func() {
+ defer wgUnsupported.Done()
+ got4 = PinClaudeSessionDate(key4, "session-cas", "2026-08-06", 0)
+ }()
+ wgUnsupported.Wait()
+ fakeKV.onKVGet = nil
+
+ if got3 != got4 {
+ t.Fatalf("concurrent CAS-unsupported fallback returned divergent dates: %q vs %q", got3, got4)
+ }
+ if got3 != "2026-08-05" && got3 != "2026-08-06" {
+ t.Fatalf("concurrent CAS-unsupported fallback date = %q, want 2026-08-05 or 2026-08-06", got3)
+ }
+ if got := PinClaudeSessionDate(key3, "session-cas", "2026-08-07", 0); got != got3 {
+ t.Fatalf("subsequent call after CAS-unsupported fallback = %q, want %q", got, got3)
+ }
+}
+
+func TestPinClaudeSessionDateKVTransientFailureAndRecovery(t *testing.T) {
+ resetClaudeDiagnosticsForTest()
+ defer resetClaudeDiagnosticsForTest()
+
+ fakeKV := &fakeClaudeSessionDatePinKVClient{values: make(map[string][]byte)}
+ var clientErr error
+ prevClientFunc := currentClaudeSessionDatePinKVClient
+ currentClaudeSessionDatePinKVClient = func() (claudeSessionDatePinKVClient, bool, error) {
+ if clientErr != nil {
+ return nil, true, clientErr
+ }
+ return fakeKV, true, nil
+ }
+ defer func() {
+ currentClaudeSessionDatePinKVClient = prevClientFunc
+ }()
+
+ // Part 1: KV is unavailable on initial anchor and across midnight, then recovers.
+ clientErr = errors.New("home kv transiently unavailable")
+ if !IsClaudeSessionDatePinKVActive() {
+ t.Fatal("IsClaudeSessionDatePinKVActive() during transient KV error = false, want true")
+ }
+
+ keyA, _, _ := BeginClaudeDiagnostics("cred-a", "session-transient-1")
+ if got := PinClaudeSessionDate(keyA, "session-transient-1", "2026-08-01", 0); got != "2026-08-01" {
+ t.Fatalf("initial pin during KV outage = %q, want 2026-08-01", got)
+ }
+ if got := LookupClaudeSessionDatePin("session-transient-1", 0); got != "2026-08-01" {
+ t.Fatalf("LookupClaudeSessionDatePin during KV outage (retention=0) = %q, want 2026-08-01", got)
+ }
+
+ // Across midnight with a credential switch while KV is still down (with operation-level error).
+ clientErr = nil
+ fakeKV.err = errors.New("kv network timeout")
+ keyB, _, _ := BeginClaudeDiagnostics("cred-b", "session-transient-1")
+ if got := LookupClaudeSessionDatePin("session-transient-1", 0); got != "2026-08-01" {
+ t.Fatalf("LookupClaudeSessionDatePin across midnight during KV timeout = %q, want 2026-08-01", got)
+ }
+ if got := PinClaudeSessionDate(keyB, "session-transient-1", "2026-08-02", 0); got != "2026-08-01" {
+ t.Fatalf("PinClaudeSessionDate across midnight during KV timeout = %q, want 2026-08-01", got)
+ }
+
+ // KV recovers on day 3: PinClaudeSessionDate backfills the original 2026-08-01 anchor into KV.
+ fakeKV.err = nil
+ if got := PinClaudeSessionDate(keyB, "session-transient-1", "2026-08-03", 0); got != "2026-08-01" {
+ t.Fatalf("PinClaudeSessionDate after KV recovery = %q, want original anchor 2026-08-01", got)
+ }
+ wantKVKey1 := "cpa:claude:session-date-pin:" + ClaudeSessionDatePinKey("session-transient-1")
+ if string(fakeKV.values[wantKVKey1]) != "2026-08-01" {
+ t.Fatalf("KV backfilled date = %q, want 2026-08-01", string(fakeKV.values[wantKVKey1]))
+ }
+
+ // Part 2: Mid-session transient KV outage after L1 mirror is populated from KV.
+ keyC, _, _ := BeginClaudeDiagnostics("cred-c", "session-transient-2")
+ if got := PinClaudeSessionDate(keyC, "session-transient-2", "2026-08-10", 0); got != "2026-08-10" {
+ t.Fatalf("initial healthy KV pin = %q, want 2026-08-10", got)
+ }
+ // Simulate restart, then populate L1 via Lookup while KV is healthy.
+ resetClaudeDiagnosticsForTest()
+ if got := LookupClaudeSessionDatePin("session-transient-2", 0); got != "2026-08-10" {
+ t.Fatalf("LookupClaudeSessionDatePin after restart = %q, want 2026-08-10", got)
+ }
+ // KV goes down mid-session across midnight with retention = 0.
+ fakeKV.err = errors.New("mid-session kv error")
+ keyD, _, _ := BeginClaudeDiagnostics("cred-d", "session-transient-2")
+ if got := LookupClaudeSessionDatePin("session-transient-2", 0); got != "2026-08-10" {
+ t.Fatalf("LookupClaudeSessionDatePin during mid-session outage = %q, want 2026-08-10", got)
+ }
+ if got := PinClaudeSessionDate(keyD, "session-transient-2", "2026-08-11", 0); got != "2026-08-10" {
+ t.Fatalf("PinClaudeSessionDate during mid-session outage = %q, want 2026-08-10", got)
+ }
+}
diff --git a/internal/runtime/executor/helps/claude_input_tokens.go b/internal/runtime/executor/helps/claude_input_tokens.go
index 360ee94d39e..5c1c00d6ccc 100644
--- a/internal/runtime/executor/helps/claude_input_tokens.go
+++ b/internal/runtime/executor/helps/claude_input_tokens.go
@@ -8,11 +8,11 @@ import (
"strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"github.com/tiktoken-go/tokenizer"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
)
@@ -119,11 +119,11 @@ func collectClaudeInputTokenSegments(payload []byte) ([]string, error) {
if len(bytes.TrimSpace(payload)) == 0 {
return nil, nil
}
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
return nil, fmt.Errorf("invalid Claude request JSON")
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
segments := make([]string, 0, 32)
collectClaudeSystemTokenSegments(root.Get("system"), &segments)
collectClaudeMessageTokenSegments(root.Get("messages"), &segments)
@@ -132,16 +132,16 @@ func collectClaudeInputTokenSegments(payload []byte) ([]string, error) {
return segments, nil
}
-func collectClaudeSystemTokenSegments(system gjson.Result, segments *[]string) {
- if system.Type == gjson.String {
+func collectClaudeSystemTokenSegments(system ojson.Result, segments *[]string) {
+ if system.Type == ojson.String {
appendClaudeTokenString(segments, system.String())
return
}
if !system.IsArray() {
return
}
- system.ForEach(func(_, part gjson.Result) bool {
- if part.Type == gjson.String {
+ system.ForEach(func(_, part ojson.Result) bool {
+ if part.Type == ojson.String {
appendClaudeTokenString(segments, part.String())
} else if part.Get("type").String() == "text" {
appendClaudeTokenString(segments, part.Get("text").String())
@@ -150,27 +150,27 @@ func collectClaudeSystemTokenSegments(system gjson.Result, segments *[]string) {
})
}
-func collectClaudeMessageTokenSegments(messages gjson.Result, segments *[]string) {
+func collectClaudeMessageTokenSegments(messages ojson.Result, segments *[]string) {
if !messages.IsArray() {
return
}
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
appendClaudeTokenString(segments, message.Get("role").String())
collectClaudeContentTokenSegments(message.Get("content"), segments)
return true
})
}
-func collectClaudeContentTokenSegments(content gjson.Result, segments *[]string) {
+func collectClaudeContentTokenSegments(content ojson.Result, segments *[]string) {
if !content.Exists() {
return
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
appendClaudeTokenString(segments, content.String())
return
}
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
collectClaudeContentTokenSegments(part, segments)
return true
})
@@ -196,7 +196,7 @@ func collectClaudeContentTokenSegments(content gjson.Result, segments *[]string)
appendClaudeTokenString(segments, content.Get("tool_call_id").String())
collectClaudeContentTokenSegments(content.Get("content"), segments)
case "web_search_result", "search_result":
- if source := content.Get("source"); source.Type == gjson.String {
+ if source := content.Get("source"); source.Type == ojson.String {
appendClaudeTokenString(segments, source.String())
}
appendClaudeTokenString(segments, content.Get("title").String())
@@ -224,7 +224,7 @@ func collectClaudeContentTokenSegments(content gjson.Result, segments *[]string)
}
}
-func collectClaudeDocumentTokenSegments(document gjson.Result, segments *[]string) {
+func collectClaudeDocumentTokenSegments(document ojson.Result, segments *[]string) {
source := document.Get("source")
if source.Get("type").String() != "text" {
return
@@ -235,11 +235,11 @@ func collectClaudeDocumentTokenSegments(document gjson.Result, segments *[]strin
appendClaudeTokenString(segments, source.Get("content").String())
}
-func collectClaudeToolTokenSegments(tools gjson.Result, segments *[]string) {
+func collectClaudeToolTokenSegments(tools ojson.Result, segments *[]string) {
if !tools.IsArray() {
return
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
appendClaudeTokenString(segments, tool.Get("type").String())
appendClaudeTokenString(segments, tool.Get("name").String())
appendClaudeTokenString(segments, tool.Get("description").String())
@@ -248,11 +248,11 @@ func collectClaudeToolTokenSegments(tools gjson.Result, segments *[]string) {
})
}
-func collectClaudeToolChoiceTokenSegments(toolChoice gjson.Result, segments *[]string) {
+func collectClaudeToolChoiceTokenSegments(toolChoice ojson.Result, segments *[]string) {
if !toolChoice.Exists() {
return
}
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
appendClaudeTokenString(segments, toolChoice.String())
return
}
@@ -269,11 +269,11 @@ func appendClaudeTokenString(segments *[]string, value string) {
}
}
-func appendClaudeTokenJSON(segments *[]string, value gjson.Result) {
+func appendClaudeTokenJSON(segments *[]string, value ojson.Result) {
if !value.Exists() {
return
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
appendClaudeTokenString(segments, value.String())
return
}
@@ -330,8 +330,8 @@ func (state *ClaudeInputTokenState) applyChunk(ctx context.Context, chunk []byte
payloadEnd--
}
payload := line[payloadOffset:payloadEnd]
- if gjson.GetBytes(payload, "type").String() == "message_start" {
- inputTokens := gjson.GetBytes(payload, "message.usage.input_tokens")
+ if ojson.GetBytesNoCopy(payload, "type").String() == "message_start" {
+ inputTokens := ojson.GetBytesNoCopy(payload, "message.usage.input_tokens")
if inputTokens.Exists() && inputTokens.Int() != 0 {
return chunk, true
}
@@ -343,7 +343,7 @@ func (state *ClaudeInputTokenState) applyChunk(ctx context.Context, chunk []byte
if count == 0 {
return chunk, true
}
- updatedPayload, errSet := sjson.SetBytes(payload, "message.usage.input_tokens", count)
+ updatedPayload, errSet := ojson.SetBytes(payload, "message.usage.input_tokens", count)
if errSet != nil {
state.logEstimateError(ctx, fmt.Errorf("set message_start usage: %w", errSet))
return chunk, true
diff --git a/internal/runtime/executor/helps/claude_input_tokens_test.go b/internal/runtime/executor/helps/claude_input_tokens_test.go
index b1d521eee4c..8602409ae18 100644
--- a/internal/runtime/executor/helps/claude_input_tokens_test.go
+++ b/internal/runtime/executor/helps/claude_input_tokens_test.go
@@ -10,8 +10,8 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
"github.com/tiktoken-go/tokenizer"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
@@ -426,8 +426,8 @@ func messageStartInputTokens(chunks [][]byte) int64 {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
- if gjson.Get(payload, "type").String() == "message_start" {
- return gjson.Get(payload, "message.usage.input_tokens").Int()
+ if ojson.Get(payload, "type").String() == "message_start" {
+ return ojson.Get(payload, "message.usage.input_tokens").Int()
}
}
}
diff --git a/internal/runtime/executor/helps/claude_json_prefilter_fuzz_test.go b/internal/runtime/executor/helps/claude_json_prefilter_fuzz_test.go
index 295e1e07da2..bb206afa3c5 100644
--- a/internal/runtime/executor/helps/claude_json_prefilter_fuzz_test.go
+++ b/internal/runtime/executor/helps/claude_json_prefilter_fuzz_test.go
@@ -4,13 +4,13 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// referenceIsClaudeTitleHelperRequest is isClaudeTitleHelperRequest without
// the jsonMayContainASCII pre-filter (the v8.0.11 implementation).
func referenceIsClaudeTitleHelperRequest(body []byte) bool {
- props := gjson.GetBytes(body, "output_config.format.schema.properties")
+ props := ojson.GetBytes(body, "output_config.format.schema.properties")
if props.Exists() {
if props.Get("title").Exists() && len(props.Map()) == 1 {
return isClaudeTitleHelperInstruction(body)
@@ -22,7 +22,7 @@ func referenceIsClaudeTitleHelperRequest(body []byte) bool {
strings.Contains(t, "Return a short title") ||
strings.Contains(t, "Write the title in the predominant language")
}
- system := gjson.GetBytes(body, "system")
+ system := ojson.GetBytes(body, "system")
if system.IsArray() {
for _, part := range system.Array() {
if matchesSystemTitleInstruction(part.Get("text").String()) {
@@ -32,7 +32,7 @@ func referenceIsClaudeTitleHelperRequest(body []byte) bool {
} else if matchesSystemTitleInstruction(system.String()) {
return true
}
- messages := gjson.GetBytes(body, "messages")
+ messages := ojson.GetBytes(body, "messages")
if messages.IsArray() {
for _, msg := range messages.Array() {
if msg.Get("role").String() == "system" {
@@ -55,11 +55,11 @@ func referenceIsClaudeTitleHelperRequest(body []byte) bool {
// referenceClaudePayloadHas1hTTL is ClaudePayloadHas1hTTL without the
// jsonMayContainASCII pre-filter (the v8.0.11 implementation).
func referenceClaudePayloadHas1hTTL(payload []byte) bool {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return false
}
has1h := false
- checkBlock := func(item gjson.Result) bool {
+ checkBlock := func(item ojson.Result) bool {
cc := item.Get("cache_control")
if cc.IsObject() && cc.Get("ttl").String() == "1h" {
has1h = true
@@ -68,8 +68,8 @@ func referenceClaudePayloadHas1hTTL(payload []byte) bool {
return true
}
for _, key := range []string{"tools", "system"} {
- if arr := gjson.GetBytes(payload, key); arr.IsArray() {
- arr.ForEach(func(_, item gjson.Result) bool {
+ if arr := ojson.GetBytes(payload, key); arr.IsArray() {
+ arr.ForEach(func(_, item ojson.Result) bool {
return checkBlock(item)
})
if has1h {
@@ -77,11 +77,11 @@ func referenceClaudePayloadHas1hTTL(payload []byte) bool {
}
}
}
- if messages := gjson.GetBytes(payload, "messages"); messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ if messages := ojson.GetBytes(payload, "messages"); messages.IsArray() {
+ messages.ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if content.IsArray() {
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
return checkBlock(item)
})
}
diff --git a/internal/runtime/executor/helps/claude_ratelimit.go b/internal/runtime/executor/helps/claude_ratelimit.go
index ff1c7f27eee..35dd0369397 100644
--- a/internal/runtime/executor/helps/claude_ratelimit.go
+++ b/internal/runtime/executor/helps/claude_ratelimit.go
@@ -76,13 +76,18 @@ func isOverageOrFableOnlyRejection(headers http.Header, unifiedStatus, status5h,
}
// When Anthropic evaluates an overage-only or 7d_oi claim, it often omits
- // the 5h status header while 5h utilization is 0.00 (or vice versa).
- // Require an explicit, valid non-negative utilization below 1.0 before
- // treating the unmentioned window as healthy.
+ // the 5h status header (and omits 5h utilization entirely when the 5h window
+ // has no usage or does not exist on the account tier). Treat an omitted 5h
+ // utilization on a rejected response, or an explicit healthy 5h utilization
+ // below 1.0, as unexhausted when 7d is allowed.
if shared7dAllowed && status5h == "" {
if isClaudeUtilizationHealthy(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Utilization")) {
return true
}
+ if unifiedStatus == "rejected" &&
+ !hasHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-5h-Utilization") {
+ return true
+ }
}
if shared5hAllowed && status7d == "" {
if isClaudeUtilizationHealthy(getHeaderCaseInsensitive(headers, "Anthropic-Ratelimit-Unified-7d-Utilization")) {
@@ -412,6 +417,18 @@ func getHeaderCaseInsensitive(h http.Header, target string) string {
return ""
}
+func hasHeaderCaseInsensitive(h http.Header, target string) bool {
+ if h == nil {
+ return false
+ }
+ for k, v := range h {
+ if strings.EqualFold(k, target) && len(v) > 0 {
+ return true
+ }
+ }
+ return false
+}
+
func parseUnixOrTimestamp(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
diff --git a/internal/runtime/executor/helps/claude_ratelimit_test.go b/internal/runtime/executor/helps/claude_ratelimit_test.go
index 9477d76e2cd..2755db8d365 100644
--- a/internal/runtime/executor/helps/claude_ratelimit_test.go
+++ b/internal/runtime/executor/helps/claude_ratelimit_test.go
@@ -339,30 +339,33 @@ func TestClaudeHeadersIndicateUnifiedRateLimitRejection_OverageRejection_Utiliza
tests := []struct {
name string
+ setHeader bool
utilization string
wantUnified bool
}{
- {name: "healthy zero utilization", utilization: "0.00", wantUnified: false},
- {name: "healthy partial utilization", utilization: "0.50", wantUnified: false},
- {name: "missing utilization", utilization: "", wantUnified: true},
- {name: "invalid text", utilization: "invalid", wantUnified: true},
- {name: "NaN", utilization: "NaN", wantUnified: true},
- {name: "+Inf", utilization: "+Inf", wantUnified: true},
- {name: "-Inf", utilization: "-Inf", wantUnified: true},
- {name: "negative utilization", utilization: "-0.1", wantUnified: true},
- {name: "exact 1.0 utilization", utilization: "1.0", wantUnified: true},
- {name: "exceeded utilization", utilization: "1.05", wantUnified: true},
+ {name: "healthy zero utilization", setHeader: true, utilization: "0.00", wantUnified: false},
+ {name: "healthy partial utilization", setHeader: true, utilization: "0.50", wantUnified: false},
+ {name: "missing utilization", setHeader: false, wantUnified: false},
+ {name: "empty utilization", setHeader: true, utilization: "", wantUnified: true},
+ {name: "whitespace utilization", setHeader: true, utilization: " \t ", wantUnified: true},
+ {name: "invalid text", setHeader: true, utilization: "invalid", wantUnified: true},
+ {name: "NaN", setHeader: true, utilization: "NaN", wantUnified: true},
+ {name: "+Inf", setHeader: true, utilization: "+Inf", wantUnified: true},
+ {name: "-Inf", setHeader: true, utilization: "-Inf", wantUnified: true},
+ {name: "negative utilization", setHeader: true, utilization: "-0.1", wantUnified: true},
+ {name: "exact 1.0 utilization", setHeader: true, utilization: "1.0", wantUnified: true},
+ {name: "exceeded utilization", setHeader: true, utilization: "1.05", wantUnified: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := baseHeaders()
- if tt.utilization != "" {
+ if tt.setHeader {
h.Set("Anthropic-Ratelimit-Unified-5h-Utilization", tt.utilization)
}
got := ClaudeHeadersIndicateUnifiedRateLimitRejection(h)
if got != tt.wantUnified {
- t.Fatalf("ClaudeHeadersIndicateUnifiedRateLimitRejection() with 5h-utilization %q = %v, want %v", tt.utilization, got, tt.wantUnified)
+ t.Fatalf("ClaudeHeadersIndicateUnifiedRateLimitRejection() with 5h-utilization %q (set=%v) = %v, want %v", tt.utilization, tt.setHeader, got, tt.wantUnified)
}
})
}
@@ -418,6 +421,7 @@ func TestUnifiedResetMatchingOverageResetDoesNotOverrideRetryAfter_Issue6465(t *
"Anthropic-Ratelimit-Unified-Representative-Claim": []string{"overage"},
"Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"},
"Anthropic-Ratelimit-Unified-7d-Utilization": []string{"0.69"},
+ "Anthropic-Ratelimit-Unified-5h-Utilization": []string{"1.0"},
"Anthropic-Ratelimit-Unified-Reset": []string{billingReset},
"Anthropic-Ratelimit-Unified-Overage-Status": []string{"rejected"},
"Anthropic-Ratelimit-Unified-Overage-Reset": []string{billingReset},
@@ -425,7 +429,7 @@ func TestUnifiedResetMatchingOverageResetDoesNotOverrideRetryAfter_Issue6465(t *
}
if !ClaudeHeadersIndicateUnifiedRateLimitRejection(headers) {
- t.Fatal("missing 5h utilization should stay a credential-wide rejection")
+ t.Fatal("exhausted 5h utilization should stay a credential-wide rejection")
}
got := parseClaudeRateLimitResetWithFuzz(headers, now, 0, 0)
if got == nil {
@@ -521,29 +525,63 @@ func TestRetryAfterBeyondSevenDaysFallsBack_Issue6465(t *testing.T) {
func TestAllowedUnifiedOverageClaimKeepsShortRetryAfter_Issue6465(t *testing.T) {
now := time.Date(2026, 10, 7, 20, 46, 4, 0, time.UTC)
for _, unifiedStatus := range []string{"allowed", ""} {
- t.Run(unifiedStatus, func(t *testing.T) {
- headers := http.Header{
- "Anthropic-Ratelimit-Unified-Representative-Claim": []string{"overage"},
- "Anthropic-Ratelimit-Unified-Overage-Status": []string{"allowed"},
- "Retry-After": []string{"30"},
- }
- if unifiedStatus != "" {
- headers.Set("Anthropic-Ratelimit-Unified-Status", unifiedStatus)
- }
+ for _, status7d := range []string{"", "allowed", "allowed_warning"} {
+ t.Run(unifiedStatus+"_7d_"+status7d, func(t *testing.T) {
+ headers := http.Header{
+ "Anthropic-Ratelimit-Unified-Representative-Claim": []string{"overage"},
+ "Anthropic-Ratelimit-Unified-Overage-Status": []string{"allowed"},
+ "Retry-After": []string{"30"},
+ }
+ if unifiedStatus != "" {
+ headers.Set("Anthropic-Ratelimit-Unified-Status", unifiedStatus)
+ }
+ if status7d != "" {
+ headers.Set("Anthropic-Ratelimit-Unified-7d-Status", status7d)
+ }
- if ClaudeHeadersIndicateUnifiedRateLimitRejection(headers) {
- t.Fatal("non-rejected unified status was treated as a credential rejection")
- }
- got := parseClaudeRateLimitResetWithFuzz(headers, now, 0, 0)
- if got == nil || *got != 30*time.Second {
- var gotDesc string
- if got == nil {
- gotDesc = "nil"
- } else {
- gotDesc = got.String()
+ if ClaudeHeadersIndicateUnifiedRateLimitRejection(headers) {
+ t.Fatal("non-rejected unified status was treated as a credential rejection")
}
- t.Fatalf("cooldown = %s, want 30s; a short Retry-After must survive when unified status is not rejected", gotDesc)
- }
- })
+ got := parseClaudeRateLimitResetWithFuzz(headers, now, 0, 0)
+ if got == nil || *got != 30*time.Second {
+ var gotDesc string
+ if got == nil {
+ gotDesc = "nil"
+ } else {
+ gotDesc = got.String()
+ }
+ t.Fatalf("cooldown = %s, want 30s; a short Retry-After must survive when unified status is not rejected", gotDesc)
+ }
+ })
+ }
+ }
+}
+
+func TestFableOnlyRejectionWithout5hHeadersStaysModelScoped_Issue7197(t *testing.T) {
+ now := time.Unix(1793603466, 0).UTC()
+ headers := http.Header{
+ "Retry-After": []string{"546534"},
+ "X-Should-Retry": []string{"true"},
+ "Anthropic-Ratelimit-Unified-Status": []string{"rejected"},
+ "Anthropic-Ratelimit-Unified-Representative-Claim": []string{"seven_day_overage_included"},
+ "Anthropic-Ratelimit-Unified-7d-Status": []string{"allowed"},
+ "Anthropic-Ratelimit-Unified-7d-Utilization": []string{"0.62"},
+ "Anthropic-Ratelimit-Unified-7d-Reset": []string{"1794150000"},
+ "Anthropic-Ratelimit-Unified-7d_oi-Status": []string{"rejected"},
+ "Anthropic-Ratelimit-Unified-7d_oi-Utilization": []string{"1.0"},
+ "Anthropic-Ratelimit-Unified-7d_oi-Surpassed-Threshold": []string{"1.0"},
+ "Anthropic-Ratelimit-Unified-7d_oi-Reset": []string{"1794150000"},
+ "Anthropic-Ratelimit-Unified-Overage-Status": []string{"rejected"},
+ "Anthropic-Ratelimit-Unified-Overage-Disabled-Reason": []string{"out_of_credits"},
+ "Anthropic-Ratelimit-Unified-Fallback-Percentage": []string{"0.5"},
+ "Anthropic-Ratelimit-Unified-Reset": []string{"1794150000"},
+ }
+
+ if ClaudeHeadersIndicateUnifiedRateLimitRejection(headers) {
+ t.Fatal("Fable-only rejection with 7d allowed and omitted 5h headers was treated as credential-scoped (Issue #7197)")
+ }
+ got := parseClaudeRateLimitResetWithFuzz(headers, now, 0, 0)
+ if got != nil {
+ t.Fatalf("cooldown = %v, want nil; Fable-only rejection must not adopt multi-day Retry-After or 7d_oi reset", *got)
}
}
diff --git a/internal/runtime/executor/helps/claude_tool_choice.go b/internal/runtime/executor/helps/claude_tool_choice.go
index dbd5dfc35ba..c6ef4b4750b 100644
--- a/internal/runtime/executor/helps/claude_tool_choice.go
+++ b/internal/runtime/executor/helps/claude_tool_choice.go
@@ -3,9 +3,9 @@ package helps
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// NormalizeClaudeToolChoice downgrades forced tool use on Claude 5.5 to auto.
@@ -14,17 +14,30 @@ import (
// Delegated providers keep their own tool-choice contract. User payload rules
// must run afterwards and remain authoritative.
func NormalizeClaudeToolChoice(provider, model string, payload []byte) []byte {
- if provider != "claude" {
+ if len(payload) == 0 || provider != "claude" {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !NormalizeClaudeToolChoiceDoc(provider, model, doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// NormalizeClaudeToolChoiceDoc downgrades forced tool use on Claude 5.5 to auto in place on doc.
+func NormalizeClaudeToolChoiceDoc(provider, model string, doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 || provider != "claude" {
+ return false
+ }
model = strings.ToLower(thinking.ParseSuffix(strings.TrimSpace(model)).ModelName)
if !claude55Model.MatchString(model) {
- return payload
+ return false
}
- switch gjson.GetBytes(payload, "tool_choice.type").String() {
+ switch doc.Get("tool_choice.type").String() {
case "any", "tool":
- payload, _ = sjson.SetBytes(payload, "tool_choice.type", "auto")
- payload, _ = sjson.DeleteBytes(payload, "tool_choice.name")
+ _ = doc.Set("tool_choice.type", "auto")
+ _ = util.DeleteJSONDocIfExists(doc, "tool_choice.name")
+ return true
}
- return payload
+ return false
}
diff --git a/internal/runtime/executor/helps/claude_ttft_helpers.go b/internal/runtime/executor/helps/claude_ttft_helpers.go
index d79d097b6bc..e99e0bede95 100644
--- a/internal/runtime/executor/helps/claude_ttft_helpers.go
+++ b/internal/runtime/executor/helps/claude_ttft_helpers.go
@@ -3,7 +3,7 @@ package helps
import (
"bytes"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsClaudeTokenEvent reports whether an Anthropic Messages SSE chunk carries
@@ -34,11 +34,11 @@ func IsClaudeTokenEvent(payload []byte) bool {
}
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
switch eventType {
// Substantive streaming block deltas
case "content_block_delta":
- delta := gjson.GetBytes(payload, "delta")
+ delta := ojson.GetBytesNoCopy(payload, "delta")
if len(delta.Get("text").String()) > 0 {
return true
}
@@ -55,7 +55,7 @@ func IsClaudeTokenEvent(payload []byte) bool {
// Initial block start when populated upfront
case "content_block_start":
- cb := gjson.GetBytes(payload, "content_block")
+ cb := ojson.GetBytesNoCopy(payload, "content_block")
if len(cb.Get("text").String()) > 0 || len(cb.Get("thinking").String()) > 0 {
return true
}
@@ -63,7 +63,7 @@ func IsClaudeTokenEvent(payload []byte) bool {
// Terminal completion events fallback
case "message_delta":
- return len(gjson.GetBytes(payload, "delta.stop_reason").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "delta.stop_reason").String()) > 0
case "message_stop", "error":
return true
@@ -73,8 +73,8 @@ func IsClaudeTokenEvent(payload []byte) bool {
default:
// Non-streaming / complete message payload fallback
- if gjson.GetBytes(payload, "content").IsArray() {
- for _, block := range gjson.GetBytes(payload, "content").Array() {
+ if ojson.GetBytesNoCopy(payload, "content").IsArray() {
+ for _, block := range ojson.GetBytesNoCopy(payload, "content").Array() {
if len(block.Get("text").String()) > 0 || len(block.Get("thinking").String()) > 0 {
return true
}
diff --git a/internal/runtime/executor/helps/claude_web_search_results.go b/internal/runtime/executor/helps/claude_web_search_results.go
index 97757dcdda2..34b88b69506 100644
--- a/internal/runtime/executor/helps/claude_web_search_results.go
+++ b/internal/runtime/executor/helps/claude_web_search_results.go
@@ -7,13 +7,34 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
+ responsesclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/claude/openai/responses"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
-var claudeWebSearchToolResultMarker = []byte(`"web_search_tool_result"`)
+var (
+ claudeWebSearchToolResultMarker = []byte(`"web_search_tool_result"`)
+ claudeWebSearchCitationMarker = []byte(`"web_search_result_location"`)
+)
+
+// WithClaudeWebSearchResultRestore attaches a request-scoped cache lookup so
+// Responses -> Claude request translation can restore web_search_tool_result
+// blocks before deciding whether assistant citations remain backed by results.
+func WithClaudeWebSearchResultRestore(ctx context.Context, sessionKey string, ttl ...time.Duration) context.Context {
+ if strings.TrimSpace(sessionKey) == "" {
+ return ctx
+ }
+ return responsesclaude.WithWebSearchResultLookup(ctx, func(toolUseID string) ([]byte, bool) {
+ cached, ok, errGet := cache.GetClaudeWebSearchResultRequired(ctx, sessionKey, toolUseID, ttl...)
+ if errGet != nil || !ok || len(cached) == 0 {
+ return nil, false
+ }
+ return cached, true
+ })
+}
// RecordClaudeWebSearchResults stores the web_search_tool_result blocks of a
// Claude response for sessionKey. data is either a complete Messages response
@@ -29,8 +50,8 @@ func RecordClaudeWebSearchResultsContext(ctx context.Context, sessionKey string,
if strings.TrimSpace(sessionKey) == "" || !bytes.Contains(data, claudeWebSearchToolResultMarker) {
return
}
- if content := gjson.GetBytes(data, "content"); content.IsArray() {
- content.ForEach(func(_, block gjson.Result) bool {
+ if content := ojson.GetBytesNoCopy(data, "content"); content.IsArray() {
+ content.ForEach(func(_, block ojson.Result) bool {
recordClaudeWebSearchResultBlock(ctx, sessionKey, block, ttl, limit)
return true
})
@@ -41,14 +62,14 @@ func RecordClaudeWebSearchResultsContext(ctx context.Context, sessionKey string,
if !bytes.HasPrefix(line, []byte("data:")) {
continue
}
- event := gjson.ParseBytes(bytes.TrimSpace(line[len("data:"):]))
+ event := util.ParseGJSONBytesNoCopy(bytes.TrimSpace(line[len("data:"):]))
if event.Get("type").String() == "content_block_start" {
recordClaudeWebSearchResultBlock(ctx, sessionKey, event.Get("content_block"), ttl, limit)
}
}
}
-func recordClaudeWebSearchResultBlock(ctx context.Context, sessionKey string, block gjson.Result, ttl time.Duration, limit int) {
+func recordClaudeWebSearchResultBlock(ctx context.Context, sessionKey string, block ojson.Result, ttl time.Duration, limit int) {
if block.Get("type").String() != "web_search_tool_result" {
return
}
@@ -56,7 +77,7 @@ func recordClaudeWebSearchResultBlock(ctx context.Context, sessionKey string, bl
if !content.Exists() || claudeWebSearchResultContentEmpty(content) {
return
}
- cache.CacheClaudeWebSearchResultBestEffort(ctx, sessionKey, block.Get("tool_use_id").String(), []byte(content.Raw), ttl, limit)
+ cache.CacheClaudeWebSearchResultBestEffort(ctx, sessionKey, strings.Clone(block.Get("tool_use_id").String()), []byte(content.Raw), ttl, limit)
}
// RestoreClaudeWebSearchResults fills assistant web_search_tool_result blocks
@@ -67,47 +88,136 @@ func RestoreClaudeWebSearchResults(body []byte, sessionKey string, ttl ...time.D
return RestoreClaudeWebSearchResultsContext(context.Background(), body, sessionKey, ttl...)
}
+// RestoreClaudeWebSearchResultsDoc fills assistant web_search_tool_result blocks
+// in doc whose content is missing or empty with the content stored for sessionKey.
+func RestoreClaudeWebSearchResultsDoc(doc *ojson.Doc, sessionKey string, ttl ...time.Duration) bool {
+ return RestoreClaudeWebSearchResultsContextDoc(context.Background(), doc, sessionKey, ttl...)
+}
+
// RestoreClaudeWebSearchResultsContext fills assistant web_search_tool_result
// blocks whose content is missing or empty with the content stored for
// sessionKey using ctx for Home KV operations.
func RestoreClaudeWebSearchResultsContext(ctx context.Context, body []byte, sessionKey string, ttl ...time.Duration) []byte {
- if strings.TrimSpace(sessionKey) == "" || !bytes.Contains(body, claudeWebSearchToolResultMarker) {
+ if !bytes.Contains(body, claudeWebSearchToolResultMarker) {
return body
}
- messages := gjson.GetBytes(body, "messages")
- if !messages.IsArray() {
+ if strings.TrimSpace(sessionKey) == "" && !bytes.Contains(body, claudeWebSearchCitationMarker) {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !RestoreClaudeWebSearchResultsContextDoc(ctx, doc, sessionKey, ttl...) {
return body
}
+ return doc.Bytes()
+}
+
+// RestoreClaudeWebSearchResultsContextDoc fills assistant web_search_tool_result
+// blocks in doc whose content is missing or empty with the content stored for
+// sessionKey using ctx for Home KV operations, and drops web_search_result_location
+// citations from assistant turns whose web_search_tool_result remains empty.
+func RestoreClaudeWebSearchResultsContextDoc(ctx context.Context, doc *ojson.Doc, sessionKey string, ttl ...time.Duration) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ messages := doc.Get("messages")
+ if !messages.IsArray() {
+ return false
+ }
+ trimmedSessionKey := strings.TrimSpace(sessionKey)
restored := 0
+ droppedCitations := 0
+ turnHasEmptyWebSearchResult := false
for i, message := range messages.Array() {
- if message.Get("role").String() != "assistant" {
+ role := message.Get("role").String()
+ if role != "assistant" {
+ if claudeUserMessageIsTurnBoundary(message) {
+ turnHasEmptyWebSearchResult = false
+ }
continue
}
- for j, block := range message.Get("content").Array() {
+ blocks := message.Get("content").Array()
+ for j, block := range blocks {
if block.Get("type").String() != "web_search_tool_result" || !claudeWebSearchResultContentEmpty(block.Get("content")) {
continue
}
- cached, ok, errGet := cache.GetClaudeWebSearchResultRequired(ctx, sessionKey, block.Get("tool_use_id").String(), ttl...)
- if errGet != nil || !ok {
- continue
+ if trimmedSessionKey != "" {
+ cached, ok, errGet := cache.GetClaudeWebSearchResultRequired(ctx, trimmedSessionKey, block.Get("tool_use_id").String(), ttl...)
+ if errGet == nil && ok && len(cached) > 0 {
+ if errSet := doc.SetRawBytes(fmt.Sprintf("messages.%d.content.%d.content", i, j), cached); errSet == nil {
+ restored++
+ continue
+ } else {
+ log.Debugf("claude web search results: restore messages.%d.content.%d failed: %v", i, j, errSet)
+ }
+ }
}
- updated, errSet := sjson.SetRawBytes(body, fmt.Sprintf("messages.%d.content.%d.content", i, j), cached)
- if errSet != nil {
- log.Debugf("claude web search results: restore messages.%d.content.%d failed: %v", i, j, errSet)
+ turnHasEmptyWebSearchResult = true
+ }
+ if !turnHasEmptyWebSearchResult {
+ continue
+ }
+ for j, block := range blocks {
+ if block.Get("type").String() != "text" {
continue
}
- body = updated
- restored++
+ dropped := stripClaudeWebSearchCitationsDoc(doc, fmt.Sprintf("messages.%d.content.%d", i, j), block.Get("citations"))
+ droppedCitations += dropped
}
}
if restored > 0 {
log.Debugf("claude web search results: restored %d result block(s) from cache", restored)
}
- return body
+ if droppedCitations > 0 {
+ log.Debugf("claude web search results: dropped %d web_search_result_location citation(s) from assistant turn without search results", droppedCitations)
+ }
+ return restored > 0 || droppedCitations > 0
+}
+
+func claudeUserMessageIsTurnBoundary(message ojson.Result) bool {
+ content := message.Get("content")
+ if !content.IsArray() {
+ return true
+ }
+ for _, block := range content.Array() {
+ if block.Get("type").String() != "tool_result" {
+ return true
+ }
+ }
+ return false
+}
+
+func stripClaudeWebSearchCitationsDoc(doc *ojson.Doc, blockPath string, citations ojson.Result) int {
+ if !citations.Exists() || !citations.IsArray() {
+ return 0
+ }
+ var kept [][]byte
+ dropped := 0
+ citations.ForEach(func(_, citation ojson.Result) bool {
+ if citation.Get("type").String() == "web_search_result_location" {
+ dropped++
+ return true
+ }
+ kept = append(kept, []byte(citation.Raw))
+ return true
+ })
+ if dropped == 0 {
+ return 0
+ }
+ citationsPath := blockPath + ".citations"
+ if len(kept) == 0 {
+ if errDelete := doc.Delete(citationsPath); errDelete != nil {
+ return 0
+ }
+ return dropped
+ }
+ if errSet := doc.SetRawBytes(citationsPath, translatorcommon.JoinRawArray(kept)); errSet != nil {
+ return 0
+ }
+ return dropped
}
-func claudeWebSearchResultContentEmpty(content gjson.Result) bool {
- if !content.Exists() || content.Type == gjson.Null {
+func claudeWebSearchResultContentEmpty(content ojson.Result) bool {
+ if !content.Exists() || content.Type == ojson.Null {
return true
}
return content.IsArray() && len(content.Array()) == 0
diff --git a/internal/runtime/executor/helps/claude_web_search_results_test.go b/internal/runtime/executor/helps/claude_web_search_results_test.go
index f100e5679c5..3f2ffb04837 100644
--- a/internal/runtime/executor/helps/claude_web_search_results_test.go
+++ b/internal/runtime/executor/helps/claude_web_search_results_test.go
@@ -4,8 +4,8 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
- "github.com/tidwall/gjson"
)
const claudeWebSearchTestResults = `[{"type":"web_search_result","url":"https://example.com/a","title":"A","encrypted_content":"enc-a","page_age":null},{"type":"web_search_result","url":"https://example.com/b","title":"B","encrypted_content":"enc-b","page_age":null}]`
@@ -32,10 +32,10 @@ func TestRecordClaudeWebSearchResultsFromStreamEventAndRestore(t *testing.T) {
RecordClaudeWebSearchResults("session-1", event, 30*time.Minute, 16)
got := RestoreClaudeWebSearchResults(claudeWebSearchRequestBody(`[]`), "session-1", 30*time.Minute)
- if raw := gjson.GetBytes(got, "messages.1.content.1.content").Raw; raw != claudeWebSearchTestResults {
+ if raw := ojson.GetBytes(got, "messages.1.content.1.content").Raw; raw != claudeWebSearchTestResults {
t.Fatalf("restored content = %s, want original results", raw)
}
- if gjson.GetBytes(got, "messages.1.content.2.text").String() != "answer" {
+ if ojson.GetBytes(got, "messages.1.content.2.text").String() != "answer" {
t.Fatalf("neighbouring blocks changed: %s", got)
}
}
@@ -48,7 +48,7 @@ func TestRecordClaudeWebSearchResultsFromMessageResponse(t *testing.T) {
RecordClaudeWebSearchResults("session-1", response, 30*time.Minute, 16)
got := RestoreClaudeWebSearchResults(claudeWebSearchRequestBody(`[]`), "session-1", 30*time.Minute)
- if raw := gjson.GetBytes(got, "messages.1.content.1.content").Raw; raw != claudeWebSearchTestResults {
+ if raw := ojson.GetBytes(got, "messages.1.content.1.content").Raw; raw != claudeWebSearchTestResults {
t.Fatalf("restored content = %s, want original results", raw)
}
}
@@ -83,3 +83,40 @@ func TestRecordClaudeWebSearchResultsIgnoresEmptyResults(t *testing.T) {
t.Fatal("empty result content was cached")
}
}
+
+func TestRestoreClaudeWebSearchResultsDropsCitationsWhenResultRemainsEmpty(t *testing.T) {
+ resetClaudeWebSearchResults(t)
+ event := []byte(`data: {"type":"content_block_start","index":1,"content_block":{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":` + claudeWebSearchTestResults + `}}`)
+ RecordClaudeWebSearchResults("session-1", event, 30*time.Minute, 16)
+
+ citedBody := []byte(`{"model":"claude-opus-5-5","messages":[` +
+ `{"role":"user","content":[{"type":"text","text":"search"}]},` +
+ `{"role":"assistant","content":[` +
+ `{"type":"server_tool_use","id":"srvtoolu_1","name":"web_search","input":{"query":"q"}},` +
+ `{"type":"web_search_tool_result","tool_use_id":"srvtoolu_1","content":[]},` +
+ `{"type":"text","text":"answer","citations":[{"type":"web_search_result_location","url":"https://example.com/a","title":"A","cited_text":"answer","encrypted_index":"IDX_A"}]}]},` +
+ `{"role":"user","content":[{"type":"text","text":"how many results?"}]}]}`)
+
+ restored := RestoreClaudeWebSearchResults(citedBody, "session-1", 30*time.Minute)
+ if got := ojson.GetBytes(restored, "messages.1.content.1.content").Raw; got != claudeWebSearchTestResults {
+ t.Fatalf("restored content = %s, want %s", got, claudeWebSearchTestResults)
+ }
+ if got := ojson.GetBytes(restored, "messages.1.content.2.citations.0.encrypted_index").String(); got != "IDX_A" {
+ t.Fatalf("restored turn citation encrypted_index = %q, want IDX_A: %s", got, restored)
+ }
+
+ for name, sessionKey := range map[string]string{
+ "cache miss": "session-2",
+ "empty scope": "",
+ } {
+ t.Run(name, func(t *testing.T) {
+ missed := RestoreClaudeWebSearchResults(citedBody, sessionKey, 30*time.Minute)
+ if got := ojson.GetBytes(missed, "messages.1.content.2.text").String(); got != "answer" {
+ t.Fatalf("text = %q, want answer", got)
+ }
+ if citations := ojson.GetBytes(missed, "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("expected citations to be dropped on %s, got: %s", name, citations.Raw)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/helps/cloak_obfuscate.go b/internal/runtime/executor/helps/cloak_obfuscate.go
index fc9d36abe0a..10c6b5388ae 100644
--- a/internal/runtime/executor/helps/cloak_obfuscate.go
+++ b/internal/runtime/executor/helps/cloak_obfuscate.go
@@ -6,8 +6,7 @@ import (
"strings"
"unicode/utf8"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// zeroWidthSpace is the Unicode zero-width space character used for obfuscation.
@@ -97,34 +96,59 @@ func (m *SensitiveWordMatcher) ObfuscateText(text string) string {
// ObfuscateSensitiveWords processes the payload and obfuscates sensitive words
// in system blocks and message content.
func ObfuscateSensitiveWords(payload []byte, matcher *SensitiveWordMatcher) []byte {
- if matcher == nil || matcher.regex == nil {
+ if matcher == nil || matcher.regex == nil || len(payload) == 0 {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !ObfuscateSensitiveWordsDoc(doc, matcher) {
+ return payload
+ }
+ return doc.Bytes()
+}
- // Obfuscate in system blocks
- payload = obfuscateSystemBlocks(payload, matcher)
-
- // Obfuscate in messages
- payload = obfuscateMessages(payload, matcher)
-
- return payload
+// ObfuscateSensitiveWordsDoc processes doc in place and obfuscates sensitive words
+// in system blocks and message content.
+func ObfuscateSensitiveWordsDoc(doc *ojson.Doc, matcher *SensitiveWordMatcher) bool {
+ if matcher == nil || matcher.regex == nil || doc == nil || doc.Len() == 0 {
+ return false
+ }
+ modified := obfuscateSystemBlocksDoc(doc, matcher)
+ if obfuscateMessagesDoc(doc, matcher) {
+ modified = true
+ }
+ return modified
}
// ObfuscateSensitiveWordsInSystemInstruction obfuscates sensitive words in an Antigravity system instruction.
func ObfuscateSensitiveWordsInSystemInstruction(payload []byte, matcher *SensitiveWordMatcher) []byte {
- if matcher == nil || matcher.regex == nil {
+ if matcher == nil || matcher.regex == nil || len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !ObfuscateSensitiveWordsInSystemInstructionDoc(doc, matcher) {
return payload
}
+ return doc.Bytes()
+}
+
+// ObfuscateSensitiveWordsInSystemInstructionDoc obfuscates sensitive words in an Antigravity system instruction in place on doc.
+func ObfuscateSensitiveWordsInSystemInstructionDoc(doc *ojson.Doc, matcher *SensitiveWordMatcher) bool {
+ if matcher == nil || matcher.regex == nil || doc == nil || doc.Len() == 0 {
+ return false
+ }
+ modified := false
for _, path := range []string{"request.systemInstruction", "request.system_instruction"} {
- instruction := gjson.GetBytes(payload, path)
+ instruction := doc.Get(path)
if !instruction.Exists() {
continue
}
- if instruction.Type == gjson.String {
+ if instruction.Type == ojson.String {
text := instruction.String()
if obfuscated := matcher.obfuscateText(text); obfuscated != text {
- payload, _ = sjson.SetBytes(payload, path, obfuscated)
+ if errSet := doc.Set(path, obfuscated); errSet == nil {
+ modified = true
+ }
}
continue
}
@@ -133,31 +157,32 @@ func ObfuscateSensitiveWordsInSystemInstruction(payload []byte, matcher *Sensiti
if !parts.IsArray() {
continue
}
- parts.ForEach(func(key, part gjson.Result) bool {
- if part.Get("text").Type != gjson.String {
+ parts.ForEach(func(key, part ojson.Result) bool {
+ if part.Get("text").Type != ojson.String {
return true
}
text := part.Get("text").String()
if obfuscated := matcher.obfuscateText(text); obfuscated != text {
- payload, _ = sjson.SetBytes(payload, path+".parts."+key.String()+".text", obfuscated)
+ if errSet := doc.Set(path+".parts."+key.String()+".text", obfuscated); errSet == nil {
+ modified = true
+ }
}
return true
})
}
- return payload
+ return modified
}
-// obfuscateSystemBlocks obfuscates sensitive words in system blocks.
-func obfuscateSystemBlocks(payload []byte, matcher *SensitiveWordMatcher) []byte {
- system := gjson.GetBytes(payload, "system")
+func obfuscateSystemBlocksDoc(doc *ojson.Doc, matcher *SensitiveWordMatcher) bool {
+ system := doc.Get("system")
if !system.Exists() {
- return payload
+ return false
}
+ modified := false
if system.IsArray() {
- modified := false
- system.ForEach(func(key, value gjson.Result) bool {
+ system.ForEach(func(key, value ojson.Result) bool {
if value.Get("type").String() == "text" {
text := value.Get("text").String()
if strings.HasPrefix(text, "x-anthropic-billing-header:") {
@@ -166,36 +191,36 @@ func obfuscateSystemBlocks(payload []byte, matcher *SensitiveWordMatcher) []byte
obfuscated := matcher.obfuscateText(text)
if obfuscated != text {
path := "system." + key.String() + ".text"
- payload, _ = sjson.SetBytes(payload, path, obfuscated)
- modified = true
+ if errSet := doc.Set(path, obfuscated); errSet == nil {
+ modified = true
+ }
}
}
return true
})
- if modified {
- return payload
- }
- } else if system.Type == gjson.String {
+ } else if system.Type == ojson.String {
text := system.String()
if !strings.HasPrefix(text, "x-anthropic-billing-header:") {
obfuscated := matcher.obfuscateText(text)
if obfuscated != text {
- payload, _ = sjson.SetBytes(payload, "system", obfuscated)
+ if errSet := doc.Set("system", obfuscated); errSet == nil {
+ modified = true
+ }
}
}
}
- return payload
+ return modified
}
-// obfuscateMessages obfuscates sensitive words in message content.
-func obfuscateMessages(payload []byte, matcher *SensitiveWordMatcher) []byte {
- messages := gjson.GetBytes(payload, "messages")
+func obfuscateMessagesDoc(doc *ojson.Doc, matcher *SensitiveWordMatcher) bool {
+ messages := doc.Get("messages")
if !messages.Exists() || !messages.IsArray() {
- return payload
+ return false
}
- messages.ForEach(func(msgKey, msg gjson.Result) bool {
+ modified := false
+ messages.ForEach(func(msgKey, msg ojson.Result) bool {
content := msg.Get("content")
if !content.Exists() {
return true
@@ -203,22 +228,24 @@ func obfuscateMessages(payload []byte, matcher *SensitiveWordMatcher) []byte {
msgPath := "messages." + msgKey.String()
- if content.Type == gjson.String {
- // Simple string content
+ if content.Type == ojson.String {
text := content.String()
obfuscated := matcher.obfuscateText(text)
if obfuscated != text {
- payload, _ = sjson.SetBytes(payload, msgPath+".content", obfuscated)
+ if errSet := doc.Set(msgPath+".content", obfuscated); errSet == nil {
+ modified = true
+ }
}
} else if content.IsArray() {
- // Array of content blocks
- content.ForEach(func(blockKey, block gjson.Result) bool {
+ content.ForEach(func(blockKey, block ojson.Result) bool {
if block.Get("type").String() == "text" {
text := block.Get("text").String()
obfuscated := matcher.obfuscateText(text)
if obfuscated != text {
path := msgPath + ".content." + blockKey.String() + ".text"
- payload, _ = sjson.SetBytes(payload, path, obfuscated)
+ if errSet := doc.Set(path, obfuscated); errSet == nil {
+ modified = true
+ }
}
}
return true
@@ -228,5 +255,5 @@ func obfuscateMessages(payload []byte, matcher *SensitiveWordMatcher) []byte {
return true
})
- return payload
+ return modified
}
diff --git a/internal/runtime/executor/helps/codex_commentary.go b/internal/runtime/executor/helps/codex_commentary.go
new file mode 100644
index 00000000000..cdb8d95608b
--- /dev/null
+++ b/internal/runtime/executor/helps/codex_commentary.go
@@ -0,0 +1,18 @@
+package helps
+
+import (
+ "context"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
+)
+
+// WithCodexCommentaryPolicy keeps an in-flight stream's policy stable across
+// config reloads. It changes protocol display/replay only, not the payload rules.
+func WithCodexCommentaryPolicy(ctx context.Context, cfg *config.Config) context.Context {
+ enabled := translatorcommon.VisibleCodexCommentary(ctx)
+ if cfg != nil && cfg.Client.Claude.ShowCodexCommentary {
+ enabled = true
+ }
+ return translatorcommon.WithVisibleCodexCommentary(ctx, enabled)
+}
diff --git a/internal/runtime/executor/helps/codex_commentary_test.go b/internal/runtime/executor/helps/codex_commentary_test.go
new file mode 100644
index 00000000000..6a58a593a90
--- /dev/null
+++ b/internal/runtime/executor/helps/codex_commentary_test.go
@@ -0,0 +1,33 @@
+package helps
+
+import (
+ "context"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
+)
+
+func TestCodexCommentaryPolicyDefaultsAndSnapshots(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "")
+ if translatorcommon.VisibleCodexCommentary(WithCodexCommentaryPolicy(nil, nil)) {
+ t.Fatal("commentary must remain hidden by default")
+ }
+ cfg := &config.Config{}
+ cfg.Client.Claude.ShowCodexCommentary = true
+ inFlight := WithCodexCommentaryPolicy(context.Background(), cfg)
+ cfg.Client.Claude.ShowCodexCommentary = false
+ if !translatorcommon.VisibleCodexCommentary(inFlight) {
+ t.Fatal("config reload changed an in-flight stream")
+ }
+ if translatorcommon.VisibleCodexCommentary(WithCodexCommentaryPolicy(context.Background(), cfg)) {
+ t.Fatal("new request did not use the changed policy")
+ }
+}
+
+func TestCodexCommentaryPolicyEnvironmentCompatibility(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "1")
+ if !translatorcommon.VisibleCodexCommentary(WithCodexCommentaryPolicy(context.Background(), &config.Config{})) {
+ t.Fatal("legacy opt-in environment variable was ignored")
+ }
+}
diff --git a/internal/runtime/executor/helps/codex_hosted_images.go b/internal/runtime/executor/helps/codex_hosted_images.go
new file mode 100644
index 00000000000..48318e7d2dd
--- /dev/null
+++ b/internal/runtime/executor/helps/codex_hosted_images.go
@@ -0,0 +1,110 @@
+package helps
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// CodexModelUsesResponsesLite identifies models whose native Codex route uses Responses Lite.
+func CodexModelUsesResponsesLite(model string) bool {
+ slug := strings.ToLower(strings.TrimSpace(thinking.ParseSuffix(model).ModelName))
+ if idx := strings.LastIndex(slug, "/"); idx >= 0 {
+ slug = strings.TrimSpace(slug[idx+1:])
+ }
+ return slug != "" && (strings.HasPrefix(slug, "gpt-6") || registry.CodexClientModelUsesResponsesLite(slug))
+}
+
+// CodexResponsesLiteRequestDoc reads the route hints without changing the payload.
+func CodexResponsesLiteRequestDoc(doc *ojson.Doc, headers http.Header) bool {
+ if strings.EqualFold(strings.TrimSpace(headers.Get("X-OpenAI-Internal-Codex-Responses-Lite")), "true") {
+ return true
+ }
+ if doc == nil {
+ return false
+ }
+ value := doc.Get("client_metadata.ws_request_header_x_openai_internal_codex_responses_lite")
+ return value.Type == ojson.True || (value.Type == ojson.String && strings.EqualFold(strings.TrimSpace(value.String()), "true"))
+}
+
+// ValidateCodexHostedImageGenerationDoc checks the final, configured Responses payload
+// after all outbound headers, including credential and model overrides, are resolved.
+// Native Codex Responses Lite cannot serve forced hosted images. API-key, SIWC,
+// explicit passthrough, and standalone Images API requests retain their own contracts.
+func ValidateCodexHostedImageGenerationDoc(doc *ojson.Doc, upstreamHeaders http.Header, cfg *config.Config, nativeCodex bool) error {
+ if !nativeCodex || doc == nil || (cfg != nil && cfg.DisableImageGeneration == config.DisableImageGenerationPassthrough) {
+ return nil
+ }
+ if !CodexModelUsesResponsesLite(doc.Get("model").String()) && !CodexResponsesLiteRequestDoc(doc, upstreamHeaders) {
+ return nil
+ }
+ if !codexRequestForcesImageGenerationDoc(doc) {
+ return nil
+ }
+ return cliproxyauth.NewRequestScopedError("Hosted image_generation cannot be forced on this Codex Responses-Lite route. Use /v1/images/generations or /v1/images/edits instead.", http.StatusBadRequest)
+}
+
+func codexCollectDeclaredToolsDoc(doc *ojson.Doc) []ojson.Result {
+ var tools []ojson.Result
+ if declared := doc.Get("tools"); declared.IsArray() {
+ tools = append(tools, declared.Array()...)
+ }
+ if input := doc.Get("input"); input.IsArray() {
+ for _, item := range input.Array() {
+ if strings.TrimSpace(item.Get("type").String()) == "additional_tools" {
+ if declared := item.Get("tools"); declared.IsArray() {
+ tools = append(tools, declared.Array()...)
+ }
+ }
+ }
+ }
+ return tools
+}
+
+func codexRequestForcesImageGenerationDoc(doc *ojson.Doc) bool {
+ tools := codexCollectDeclaredToolsDoc(doc)
+ hasImageGen := false
+ allImageGen := len(tools) > 0
+ for _, tool := range tools {
+ if strings.TrimSpace(tool.Get("type").String()) == "image_generation" {
+ hasImageGen = true
+ } else {
+ allImageGen = false
+ }
+ }
+ if !hasImageGen {
+ return false
+ }
+ choice := doc.Get("tool_choice")
+ if choice.Type == ojson.String {
+ return strings.EqualFold(strings.TrimSpace(choice.String()), "required") && allImageGen
+ }
+ if !choice.IsObject() {
+ return false
+ }
+ switch strings.TrimSpace(choice.Get("type").String()) {
+ case "image_generation":
+ return true
+ case "allowed_tools":
+ if !strings.EqualFold(strings.TrimSpace(choice.Get("mode").String()), "required") {
+ return false
+ }
+ allowed := choice.Get("tools")
+ if !allowed.IsArray() || len(allowed.Array()) == 0 {
+ return false
+ }
+ for _, tool := range allowed.Array() {
+ if strings.TrimSpace(tool.Get("type").String()) != "image_generation" {
+ return false
+ }
+ }
+ return true
+ default:
+ return false
+ }
+}
diff --git a/internal/runtime/executor/helps/codex_hosted_images_test.go b/internal/runtime/executor/helps/codex_hosted_images_test.go
new file mode 100644
index 00000000000..02c2a15b8cb
--- /dev/null
+++ b/internal/runtime/executor/helps/codex_hosted_images_test.go
@@ -0,0 +1,98 @@
+package helps
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+func TestValidateCodexHostedImageGenerationDoc(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ body string
+ reject bool
+ }{
+ {"explicit", `{"tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"}}`, true},
+ {"required", `{"tools":[{"type":"image_generation"}],"tool_choice":"required"}`, true},
+ {"explicit_with_other_tools", `{"tools":[{"type":"function","name":"lookup"},{"type":"image_generation"}],"tool_choice":{"type":"image_generation"}}`, true},
+ {"allowed_required", `{"tools":[{"type":"function","name":"lookup"},{"type":"image_generation"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"image_generation"}]}}`, true},
+ {"additional_explicit", `{"input":[{"type":"additional_tools","tools":[{"type":"image_generation"}]}],"tool_choice":{"type":"image_generation"}}`, true},
+ {"additional_required", `{"input":[{"type":"additional_tools","tools":[{"type":"image_generation"}]}],"tool_choice":"required"}`, true},
+ {"omitted", `{"tools":[{"type":"image_generation"}]}`, false},
+ {"auto", `{"tools":[{"type":"image_generation"}],"tool_choice":"auto"}`, false},
+ {"none", `{"tools":[{"type":"image_generation"}],"tool_choice":"none"}`, false},
+ {"mixed_required", `{"tools":[{"type":"image_generation"},{"type":"function","name":"lookup"}],"tool_choice":"required"}`, false},
+ {"additional_mixed_required", `{"tools":[{"type":"image_generation"}],"input":[{"type":"additional_tools","tools":[{"type":"function","name":"lookup"}]}],"tool_choice":"required"}`, false},
+ {"allowed_auto", `{"tools":[{"type":"image_generation"}],"tool_choice":{"type":"allowed_tools","mode":"auto","tools":[{"type":"image_generation"}]}}`, false},
+ {"allowed_mixed", `{"tools":[{"type":"image_generation"},{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"image_generation"},{"type":"function","name":"lookup"}]}}`, false},
+ {"allowed_excludes_images", `{"tools":[{"type":"image_generation"},{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`, false},
+ {"allowed_empty", `{"tools":[{"type":"image_generation"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[]}}`, false},
+ {"filtered_declaration", `{"tool_choice":{"type":"image_generation"}}`, false},
+ {"standalone_function", `{"tools":[{"type":"function","name":"image_gen.imagegen"}],"tool_choice":"required"}`, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, model := range []string{"gpt-6-sol", "gpt-6.1-sol", "gpt-5.6-sol", "gpt-reserve", "codex/gpt-6-astra(high)"} {
+ doc := ojson.NewDocString(tc.body)
+ _ = doc.Set("model", model)
+ before := doc.String()
+ errValidate := ValidateCodexHostedImageGenerationDoc(doc, nil, nil, true)
+ if (errValidate != nil) != tc.reject {
+ t.Fatalf("model=%s error=%v, reject=%t", model, errValidate, tc.reject)
+ }
+ if doc.String() != before {
+ t.Fatal("validation changed the configured payload")
+ }
+ if errValidate != nil {
+ var requestErr cliproxyexecutor.RequestScopedError
+ var statusErr interface{ StatusCode() int }
+ if !errors.As(errValidate, &requestErr) || !requestErr.IsRequestScoped() || !errors.As(errValidate, &statusErr) || statusErr.StatusCode() != http.StatusBadRequest {
+ t.Fatalf("expected request-scoped HTTP 400, got %v", errValidate)
+ }
+ if !strings.Contains(errValidate.Error(), "/v1/images/generations") || !strings.Contains(errValidate.Error(), "/v1/images/edits") {
+ t.Fatalf("missing Images API guidance: %v", errValidate)
+ }
+ }
+ }
+ })
+ }
+}
+
+func TestValidateCodexHostedImageGenerationRouteAndPassthrough(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ model string
+ header string
+ metadata any
+ mode config.DisableImageGenerationMode
+ nativeCodex bool
+ reject bool
+ }{
+ {name: "legacy_model", model: "gpt-5.4", nativeCodex: true},
+ {name: "lite_header", model: "gpt-5.4", header: "true", nativeCodex: true, reject: true},
+ {name: "lite_metadata_bool", model: "gpt-5.4", metadata: true, nativeCodex: true, reject: true},
+ {name: "lite_metadata_string", model: "gpt-5.4", metadata: "true", nativeCodex: true, reject: true},
+ {name: "false_hints", model: "gpt-5.4", header: "false", metadata: false, nativeCodex: true},
+ {name: "api_key_or_siwc", model: "gpt-6-sol"},
+ {name: "explicit_passthrough", model: "gpt-6-sol", mode: config.DisableImageGenerationPassthrough, nativeCodex: true},
+ {name: "configured_tool_restored_in_chat_mode", model: "gpt-6-sol", mode: config.DisableImageGenerationChat, nativeCodex: true, reject: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ doc := ojson.NewDocString(`{"tools":[{"type":"image_generation"}],"tool_choice":{"type":"image_generation"}}`)
+ _ = doc.Set("model", tc.model)
+ if tc.metadata != nil {
+ _ = doc.Set("client_metadata.ws_request_header_x_openai_internal_codex_responses_lite", tc.metadata)
+ }
+ headers := make(http.Header)
+ headers.Set("X-OpenAI-Internal-Codex-Responses-Lite", tc.header)
+ cfg := &config.Config{SDKConfig: config.SDKConfig{DisableImageGeneration: tc.mode}}
+ if errValidate := ValidateCodexHostedImageGenerationDoc(doc, headers, cfg, tc.nativeCodex); (errValidate != nil) != tc.reject {
+ t.Fatalf("error=%v, reject=%t", errValidate, tc.reject)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/helps/codex_input_ids.go b/internal/runtime/executor/helps/codex_input_ids.go
index 694b5201198..a538c70a2e7 100644
--- a/internal/runtime/executor/helps/codex_input_ids.go
+++ b/internal/runtime/executor/helps/codex_input_ids.go
@@ -3,12 +3,11 @@ package helps
import (
"crypto/sha256"
"encoding/hex"
+ "fmt"
"strconv"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
const (
@@ -30,19 +29,38 @@ const (
// reasoning items whose IDs exceed the Codex limit, and deterministically shortens
// other overlong input item IDs.
func SanitizeCodexInputItemIDs(body []byte) []byte {
- input := util.GetGJSONBytesNoCopy(body, "input")
- if !input.IsArray() {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !SanitizeCodexInputItemIDsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+// SanitizeCodexInputItemIDsDoc normalizes supported input item IDs for Codex in place on doc,
+// removes encrypted reasoning items whose IDs exceed the Codex limit, and deterministically
+// shortens other overlong input item IDs.
+func SanitizeCodexInputItemIDsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
items := input.Array()
idStates := make(map[string]uint8, len(items))
+ hasDropped := false
for _, item := range items {
if shouldDropCodexEncryptedReasoningItem(item) {
+ hasDropped = true
continue
}
itemID := item.Get("id")
- if itemID.Type != gjson.String {
+ if itemID.Type != ojson.String {
continue
}
originalID := itemID.String()
@@ -63,17 +81,20 @@ func SanitizeCodexInputItemIDs(body []byte) []byte {
var mapped map[string]string
var collisionMapped map[string]string
- rebuilt := make([]string, 0, len(items))
+ var dropped map[int]bool
+ if hasDropped {
+ dropped = make(map[int]bool)
+ }
changed := false
- for _, item := range items {
+ for idx, item := range items {
if shouldDropCodexEncryptedReasoningItem(item) {
+ dropped[idx] = true
changed = true
continue
}
- raw := item.Raw
itemID := item.Get("id")
- if itemID.Type == gjson.String {
+ if itemID.Type == ojson.String {
originalID := itemID.String()
id := normalizeCodexInputItemID(item, originalID)
if id != originalID && idStates[id]&(codexInputItemIDPreserved|codexInputItemIDDuplicate) != 0 {
@@ -87,9 +108,10 @@ func SanitizeCodexInputItemIDs(body []byte) []byte {
collisionMapped[collisionKey] = id
idStates[id] |= codexInputItemIDPreserved
} else {
+ rawNormalizedID := normalizeCodexInputItemIDPrefix(item, originalID)
var collisionID string
for attempt := 0; ; attempt++ {
- collisionID = codexInputItemIDWithHashSuffix(id, attempt)
+ collisionID = codexInputItemIDWithHashSuffix(rawNormalizedID, attempt)
if idStates[collisionID]&codexInputItemIDOccupied != 0 {
continue
}
@@ -123,27 +145,37 @@ func SanitizeCodexInputItemIDs(body []byte) []byte {
}
if id != originalID {
- next, errSet := sjson.SetBytes([]byte(raw), "id", id)
- if errSet == nil {
- raw = string(next)
+ if errSet := doc.Set(fmt.Sprintf("input.%d.id", idx), id); errSet == nil {
changed = true
}
}
}
- rebuilt = append(rebuilt, raw)
}
if !changed {
- return body
+ return false
+ }
+ if hasDropped {
+ updatedItems := doc.Get("input").Array()
+ rebuilt := make([][]byte, 0, len(updatedItems)-len(dropped))
+ for idx, item := range updatedItems {
+ if !dropped[idx] {
+ rebuilt = append(rebuilt, []byte(item.Raw))
+ }
+ }
+ return doc.SetRawBytes("input", JoinRawJSONArray(rebuilt)) == nil
}
+ return true
+}
- updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(rebuilt, ",")+"]"))
- if errSet != nil {
- return body
+func normalizeCodexInputItemID(item ojson.Result, id string) string {
+ normalized := normalizeCodexInputItemIDPrefix(item, id)
+ if len([]rune(normalized)) > codexInputItemIDLimit {
+ return normalized
}
- return updated
+ return sanitizeCodexInputItemIDChars(normalized)
}
-func normalizeCodexInputItemID(item gjson.Result, id string) string {
+func normalizeCodexInputItemIDPrefix(item ojson.Result, id string) string {
var prefix string
switch item.Get("type").String() {
case "message":
@@ -170,16 +202,32 @@ func normalizeCodexInputItemID(item gjson.Result, id string) string {
return prefix + "_" + id
}
-func shouldDropCodexEncryptedReasoningItem(item gjson.Result) bool {
+func sanitizeCodexInputItemIDChars(id string) string {
+ if id == "" || isPortableToolCallID(id) {
+ return id
+ }
+ var b strings.Builder
+ b.Grow(len(id))
+ for _, r := range id {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+ b.WriteRune(r)
+ } else {
+ b.WriteByte('_')
+ }
+ }
+ return b.String()
+}
+
+func shouldDropCodexEncryptedReasoningItem(item ojson.Result) bool {
if item.Get("type").String() != "reasoning" {
return false
}
itemID := item.Get("id")
- if itemID.Type != gjson.String || len([]rune(itemID.String())) <= codexInputItemIDLimit {
+ if itemID.Type != ojson.String || len([]rune(itemID.String())) <= codexInputItemIDLimit {
return false
}
encryptedContent := item.Get("encrypted_content")
- return encryptedContent.Type == gjson.String && encryptedContent.String() != ""
+ return encryptedContent.Type == ojson.String && encryptedContent.String() != ""
}
func shortenCodexInputItemID(id string) string {
@@ -209,5 +257,5 @@ func codexInputItemIDWithHashSuffixRunes(id string, runes []rune, attempt int) s
if len(runes) < prefixLength {
prefixLength = len(runes)
}
- return string(runes[:prefixLength]) + suffix
+ return sanitizeCodexInputItemIDChars(string(runes[:prefixLength])) + suffix
}
diff --git a/internal/runtime/executor/helps/codex_input_ids_test.go b/internal/runtime/executor/helps/codex_input_ids_test.go
index 8dd7bc7c77b..1df4d9b0979 100644
--- a/internal/runtime/executor/helps/codex_input_ids_test.go
+++ b/internal/runtime/executor/helps/codex_input_ids_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
var benchmarkSanitizeCodexInputItemIDsOutput []byte
@@ -18,14 +18,90 @@ func TestSanitizeCodexInputItemIDsBoundaries(t *testing.T) {
got := SanitizeCodexInputItemIDs(body)
- if actual := gjson.GetBytes(got, "input.0.id").String(); actual != id64 {
+ if actual := ojson.GetBytes(got, "input.0.id").String(); actual != id64 {
t.Fatalf("64-character ID changed: %q", actual)
}
for _, path := range []string{"input.1.id", "input.2.id"} {
- actual := gjson.GetBytes(got, path).String()
+ actual := ojson.GetBytes(got, path).String()
if len([]rune(actual)) != 64 {
t.Fatalf("%s length = %d, want 64: %q", path, len([]rune(actual)), actual)
}
+ if !isPortableToolCallID(actual) {
+ t.Fatalf("%s = %q, contains invalid Codex ID characters", path, actual)
+ }
+ }
+}
+
+func TestSanitizeCodexInputItemIDsSanitizesInvalidCharacters(t *testing.T) {
+ overlongPlusSlash := "msg_" + strings.Repeat("A", 30) + "+/" + strings.Repeat("A", 414)
+ overlongSlashPlus := "msg_" + strings.Repeat("A", 30) + "/+" + strings.Repeat("A", 414)
+ overlongUnprefixedPlusSlash := strings.Repeat("A", 30) + "+/" + strings.Repeat("A", 414)
+ overlongTailPlus := "msg_" + strings.Repeat("B", 100) + "+" + strings.Repeat("B", 100)
+ overlongTailSlash := "msg_" + strings.Repeat("B", 100) + "/" + strings.Repeat("B", 100)
+
+ body := []byte(`{"input":[` +
+ `{"type":"message","role":"assistant","id":"` + overlongPlusSlash + `","content":[{"type":"output_text","text":"Hello"}]},` +
+ `{"type":"message","role":"assistant","id":"` + overlongSlashPlus + `","content":[{"type":"output_text","text":"World"}]},` +
+ `{"type":"message","role":"assistant","id":"` + overlongUnprefixedPlusSlash + `","content":[{"type":"output_text","text":"Unprefixed"}]},` +
+ `{"type":"message","role":"assistant","id":"` + overlongTailPlus + `","content":[{"type":"output_text","text":"Tail plus"}]},` +
+ `{"type":"message","role":"assistant","id":"` + overlongTailSlash + `","content":[{"type":"output_text","text":"Tail slash"}]},` +
+ `{"type":"message","role":"assistant","id":"msg_short+id/with=chars","content":[{"type":"output_text","text":"Short"}]},` +
+ `{"type":"message","role":"assistant","id":"msg_short_id_with_chars","content":[{"type":"output_text","text":"Short existing"}]},` +
+ `{"type":"function_call","id":"call:0+1/2","call_id":"call-1","name":"lookup","arguments":"{}"},` +
+ `{"type":"function_call_output","id":"fco:0+1/2","call_id":"call-1","output":"ok"},` +
+ `{"type":"tool_search_call","id":"ts_call+1/2","call_id":"call-2","execution":"client"}` +
+ `]}`)
+
+ first := SanitizeCodexInputItemIDs(body)
+ second := SanitizeCodexInputItemIDs(body)
+ normalizedAgain := SanitizeCodexInputItemIDs(first)
+
+ if string(first) != string(second) {
+ t.Fatalf("invalid character normalization is not deterministic: first=%s second=%s", first, second)
+ }
+ if string(first) != string(normalizedAgain) {
+ t.Fatalf("invalid character normalization is not idempotent: first=%s normalized_again=%s", first, normalizedAgain)
+ }
+
+ items := ojson.GetBytes(first, "input").Array()
+ if len(items) != 10 {
+ t.Fatalf("input length = %d, want 10", len(items))
+ }
+
+ seen := make(map[string]int, len(items))
+ expectedPrefixes := []string{"msg_", "msg_", "msg_", "msg_", "msg_", "msg_", "msg_", "fc_", "fco_", "tsc_"}
+ for idx, item := range items {
+ actual := item.Get("id").String()
+ if !isPortableToolCallID(actual) {
+ t.Fatalf("input.%d.id = %q, contains invalid Codex ID characters", idx, actual)
+ }
+ if len([]rune(actual)) > codexInputItemIDLimit {
+ t.Fatalf("input.%d.id length = %d, want at most %d: %q", idx, len([]rune(actual)), codexInputItemIDLimit, actual)
+ }
+ if !strings.HasPrefix(actual, expectedPrefixes[idx]) {
+ t.Fatalf("input.%d.id = %q, want prefix %q", idx, actual, expectedPrefixes[idx])
+ }
+ if prevIdx, exists := seen[actual]; exists {
+ t.Fatalf("input.%d.id collided with input.%d.id: %q", idx, prevIdx, actual)
+ }
+ seen[actual] = idx
+ }
+ if got := items[6].Get("id").String(); got != "msg_short_id_with_chars" {
+ t.Fatalf("existing valid ID changed: got %q, want msg_short_id_with_chars", got)
+ }
+
+ repeatedBody := []byte(`{"input":[` +
+ `{"type":"message","role":"assistant","id":"` + overlongPlusSlash + `"},` +
+ `{"type":"message","role":"assistant","id":"` + overlongPlusSlash + `"},` +
+ `{"type":"message","role":"assistant","id":"msg_short+repeat/id"},` +
+ `{"type":"message","role":"assistant","id":"msg_short+repeat/id"}` +
+ `]}`)
+ repeatedGot := SanitizeCodexInputItemIDs(repeatedBody)
+ if id0, id1 := ojson.GetBytes(repeatedGot, "input.0.id").String(), ojson.GetBytes(repeatedGot, "input.1.id").String(); id0 != id1 || !isPortableToolCallID(id0) {
+ t.Fatalf("repeated overlong invalid IDs did not map stably: %q vs %q", id0, id1)
+ }
+ if id2, id3 := ojson.GetBytes(repeatedGot, "input.2.id").String(), ojson.GetBytes(repeatedGot, "input.3.id").String(); id2 != id3 || id2 != "msg_short_repeat_id" {
+ t.Fatalf("repeated short invalid IDs = (%q, %q), want msg_short_repeat_id", id2, id3)
}
}
@@ -40,13 +116,13 @@ func TestSanitizeCodexInputItemIDsNormalizesMessageIDs(t *testing.T) {
first := SanitizeCodexInputItemIDs(body)
second := SanitizeCodexInputItemIDs(body)
- if got := gjson.GetBytes(first, "input.0.id").String(); got != "msg_"+invalidID {
+ if got := ojson.GetBytes(first, "input.0.id").String(); got != "msg_"+invalidID {
t.Fatalf("message ID = %q, want msg-prefixed ID", got)
}
- if got := gjson.GetBytes(first, "input.1.id").String(); got != "msg-1" {
+ if got := ojson.GetBytes(first, "input.1.id").String(); got != "msg-1" {
t.Fatalf("valid message ID changed: %q", got)
}
- if got := gjson.GetBytes(first, "input.2.id").String(); got != "fc_item_call" {
+ if got := ojson.GetBytes(first, "input.2.id").String(); got != "fc_item_call" {
t.Fatalf("function_call ID was not normalized: %q", got)
}
if string(first) != string(second) {
@@ -92,7 +168,7 @@ func TestSanitizeCodexInputItemIDsNormalizesResponseItemIDs(t *testing.T) {
for index, expected := range want {
path := fmt.Sprintf("input.%d.id", index)
- if actual := gjson.GetBytes(got, path).String(); actual != expected {
+ if actual := ojson.GetBytes(got, path).String(); actual != expected {
t.Fatalf("%s = %q, want %q; payload=%s", path, actual, expected, got)
}
}
@@ -138,8 +214,8 @@ func TestSanitizeCodexInputItemIDsAvoidsNormalizationCollisions(t *testing.T) {
second := SanitizeCodexInputItemIDs(body)
normalizedAgain := SanitizeCodexInputItemIDs(first)
ids := [2]string{
- gjson.GetBytes(first, "input.0.id").String(),
- gjson.GetBytes(first, "input.1.id").String(),
+ ojson.GetBytes(first, "input.0.id").String(),
+ ojson.GetBytes(first, "input.1.id").String(),
}
if ids[0] == ids[1] {
@@ -181,19 +257,19 @@ func TestSanitizeCodexInputItemIDsNormalizesLegacyToolSearchCallIDs(t *testing.T
if string(original) != snapshot {
t.Fatalf("SanitizeCodexInputItemIDs mutated caller slice: got %s, want %s", original, snapshot)
}
- if actual := gjson.GetBytes(got, "input.0.id").String(); actual != "rs_signed" {
+ if actual := ojson.GetBytes(got, "input.0.id").String(); actual != "rs_signed" {
t.Fatalf("reasoning id = %q, want rs_signed", actual)
}
- if actual := gjson.GetBytes(got, "input.0.encrypted_content").String(); actual != "gAAAA-opaque-signature" {
+ if actual := ojson.GetBytes(got, "input.0.encrypted_content").String(); actual != "gAAAA-opaque-signature" {
t.Fatalf("encrypted_content = %q, want gAAAA-opaque-signature", actual)
}
- if actual := gjson.GetBytes(got, "input.1.id").String(); actual != "tsc_call_4000860" {
+ if actual := ojson.GetBytes(got, "input.1.id").String(); actual != "tsc_call_4000860" {
t.Fatalf("tool_search_call id = %q, want tsc_call_4000860", actual)
}
- if actual := gjson.GetBytes(got, "input.1.call_id").String(); actual != "call_4000860" {
+ if actual := ojson.GetBytes(got, "input.1.call_id").String(); actual != "call_4000860" {
t.Fatalf("tool_search_call call_id = %q, want call_4000860", actual)
}
- if actual := gjson.GetBytes(got, "input.2.call_id").String(); actual != "call_4000860" {
+ if actual := ojson.GetBytes(got, "input.2.call_id").String(); actual != "call_4000860" {
t.Fatalf("tool_search_output call_id = %q, want call_4000860", actual)
}
@@ -210,8 +286,8 @@ func TestSanitizeCodexInputItemIDsNormalizesLegacyToolSearchCallIDs(t *testing.T
first := SanitizeCodexInputItemIDs(collisionBody)
second := SanitizeCodexInputItemIDs(collisionBody)
again := SanitizeCodexInputItemIDs(first)
- id0 := gjson.GetBytes(first, "input.0.id").String()
- id1 := gjson.GetBytes(first, "input.1.id").String()
+ id0 := ojson.GetBytes(first, "input.0.id").String()
+ id1 := ojson.GetBytes(first, "input.1.id").String()
if id0 == id1 {
t.Fatalf("distinct tool_search_call IDs %v collided: %q; payload=%s", ids, id0, first)
}
@@ -229,7 +305,7 @@ func TestSanitizeCodexInputItemIDsNormalizesCustomToolCallIDs(t *testing.T) {
body := []byte(`{"input":[{"type":"custom_tool_call","id":"` + invalidID + `","call_id":"call-1","name":"lookup","input":"{}"}]}`)
got := SanitizeCodexInputItemIDs(body)
- if actual := gjson.GetBytes(got, "input.0.id").String(); actual != "ctc_"+invalidID {
+ if actual := ojson.GetBytes(got, "input.0.id").String(); actual != "ctc_"+invalidID {
t.Fatalf("custom_tool_call ID = %q, want ctc-prefixed ID", actual)
}
}
@@ -248,10 +324,10 @@ func TestSanitizeCodexInputItemIDsNormalizesCustomToolCallOutputIDs(t *testing.T
second := SanitizeCodexInputItemIDs(body)
normalizedAgain := SanitizeCodexInputItemIDs(first)
- if actual := gjson.GetBytes(first, "input.0.id").String(); actual != "ctco_"+invalidID {
+ if actual := ojson.GetBytes(first, "input.0.id").String(); actual != "ctco_"+invalidID {
t.Fatalf("custom_tool_call_output ID = %q, want ctco-prefixed ID", actual)
}
- if actual := gjson.GetBytes(first, "input.1.id").String(); actual != validID {
+ if actual := ojson.GetBytes(first, "input.1.id").String(); actual != validID {
t.Fatalf("valid custom_tool_call_output ID changed: %q", actual)
}
if string(first) != string(second) {
@@ -274,7 +350,7 @@ func TestSanitizeCodexInputItemIDsDropsOverlongEncryptedReasoningItem(t *testing
`]}`)
got := SanitizeCodexInputItemIDs(body)
- input := gjson.GetBytes(got, "input").Array()
+ input := ojson.GetBytes(got, "input").Array()
if len(input) != 3 {
t.Fatalf("input length = %d, want 3: %s", len(input), got)
@@ -304,7 +380,7 @@ func TestSanitizeCodexInputItemIDsShortensOverlongReasoningWithoutEncryptedConte
body := []byte(`{"input":[{"type":"reasoning","id":"` + longReasoningID + `"` + testCase.encryptedContent + `,"summary":[]}]}`)
got := SanitizeCodexInputItemIDs(body)
- input := gjson.GetBytes(got, "input").Array()
+ input := ojson.GetBytes(got, "input").Array()
if len(input) != 1 {
t.Fatalf("input length = %d, want 1: %s", len(input), got)
}
@@ -324,17 +400,17 @@ func TestSanitizeCodexInputItemIDsAvoidsExistingIDCollision(t *testing.T) {
first := SanitizeCodexInputItemIDs(body)
second := SanitizeCodexInputItemIDs(body)
- shortened := gjson.GetBytes(first, "input.0.id").String()
+ shortened := ojson.GetBytes(first, "input.0.id").String()
if shortened == collidingValidID {
t.Fatalf("shortened ID collided with an existing valid ID: %q", shortened)
}
if len([]rune(shortened)) > 64 {
t.Fatalf("shortened ID length = %d, want at most 64", len([]rune(shortened)))
}
- if actual := gjson.GetBytes(first, "input.1.id").String(); actual != collidingValidID {
+ if actual := ojson.GetBytes(first, "input.1.id").String(); actual != collidingValidID {
t.Fatalf("existing valid ID changed: %q", actual)
}
- if actual := gjson.GetBytes(second, "input.0.id").String(); actual != shortened {
+ if actual := ojson.GetBytes(second, "input.0.id").String(); actual != shortened {
t.Fatalf("collision resolution is not deterministic: first=%q second=%q", shortened, actual)
}
}
diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2.go b/internal/runtime/executor/helps/codex_multi_agent_v2.go
index 6794a42d278..2f932d8cc92 100644
--- a/internal/runtime/executor/helps/codex_multi_agent_v2.go
+++ b/internal/runtime/executor/helps/codex_multi_agent_v2.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
+ "github.com/MoeForge/moego/ojson"
multiagentv2 "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/optimize-multi-agent-v2"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
@@ -29,6 +30,12 @@ func RewriteCodexMultiAgentV2Input(ctx context.Context, headers http.Header, pay
return multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg)
}
+// RewriteCodexMultiAgentV2InputDoc converts official Codex multi-agent input into
+// standard Responses API messages in place on doc when multi-agent v2 optimization is enabled.
+func RewriteCodexMultiAgentV2InputDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config) bool {
+ return multiagentv2.RewriteCodexMultiAgentV2InputDoc(doc, ctx, headers, cfg)
+}
+
// RewriteCodexOrphanDelegationInput converts orphan Codex delegation outputs into
// standard user messages when orphan delegation compatibility is enabled and the
// request carries the X-Openai-Subagent: collab_spawn header.
@@ -36,6 +43,12 @@ func RewriteCodexOrphanDelegationInput(ctx context.Context, headers http.Header,
return multiagentv2.RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg)
}
+// RewriteCodexOrphanDelegationInputDoc converts orphan Codex delegation outputs into
+// standard user messages in place on doc when orphan delegation compatibility is enabled.
+func RewriteCodexOrphanDelegationInputDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config) bool {
+ return multiagentv2.RewriteCodexOrphanDelegationInputForConfigDoc(doc, ctx, headers, cfg)
+}
+
// TranslateRequestWithCodexMultiAgentV2 normalizes official Codex multi-agent
// input before translating it to a non-Codex target protocol.
func TranslateRequestWithCodexMultiAgentV2(ctx context.Context, headers http.Header, cfg *config.Config, from, to sdktranslator.Format, model string, payload []byte, stream bool) []byte {
@@ -175,24 +188,41 @@ func TranslateRequestReturningError(ctx context.Context, headers http.Header, cf
}
func translateRequestWithAPIKeyModelCompatibilityForExecutor(ctx context.Context, headers http.Header, cfg *config.Config, targetExecutor string, from, to sdktranslator.Format, model string, payload []byte, stream, isCompat bool) ([]byte, error) {
- if IsCodexUserAgent(headers) && !isCodexTargetExecutor(targetExecutor) {
- payload = NormalizeCodexToolIntegerTypes(payload, headers)
- }
+ needsIntNorm := IsCodexUserAgent(headers) && !isCodexTargetExecutor(targetExecutor)
if !isCompat {
+ if needsIntNorm {
+ payload = NormalizeCodexToolIntegerTypes(payload, headers)
+ }
return translateRequestWithCodexMultiAgentV2Checked(ctx, headers, cfg, from, to, model, payload, stream)
}
if from == sdktranslator.FormatOpenAIResponse {
- payload = RewriteCodexOrphanDelegationInput(ctx, headers, payload, cfg)
- if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
- payload = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, payload, cfg, isCompat)
+ if len(payload) > 0 {
+ doc := ojson.NewDoc(payload)
+ changed := false
+ if needsIntNorm && NormalizeCodexToolIntegerTypesDoc(doc, headers) {
+ changed = true
+ }
+ if RewriteCodexOrphanDelegationInputDoc(doc, ctx, headers, cfg) {
+ changed = true
+ }
+ if to != sdktranslator.FormatCodex && to != sdktranslator.FormatOpenAIResponse {
+ if multiagentv2.RewriteCodexMultiAgentV2InputDoc(doc, ctx, headers, cfg, isCompat) {
+ changed = true
+ }
+ }
+ if changed {
+ payload = doc.Bytes()
+ }
}
+ } else if needsIntNorm {
+ payload = NormalizeCodexToolIntegerTypes(payload, headers)
}
var translated []byte
var convertErr error
switch {
case from == sdktranslator.FormatClaude && to == sdktranslator.FormatCodex:
- translated, convertErr = codexclaude.ConvertClaudeRequestToCodexWithCompat(model, payload, stream)
+ translated, convertErr = codexclaude.ConvertClaudeRequestToCodexWithCompatContext(ctx, model, payload, stream)
case from == sdktranslator.FormatClaude && to == sdktranslator.FormatGemini:
translated, convertErr = geminiclaude.ConvertClaudeRequestToGeminiWithCompat(model, payload, stream)
case from == sdktranslator.FormatClaude && to == sdktranslator.FormatInteractions:
@@ -218,26 +248,53 @@ func HasCodexMultiAgentV2NamespaceConflict(payload []byte) bool {
return multiagentv2.HasCodexMultiAgentV2NamespaceConflict(payload)
}
+// HasCodexMultiAgentV2NamespaceConflictDoc reports whether doc defines
+// the reserved optimized namespace, which must remain untouched.
+func HasCodexMultiAgentV2NamespaceConflictDoc(doc *ojson.Doc) bool {
+ return multiagentv2.HasCodexMultiAgentV2NamespaceConflictDoc(doc)
+}
+
// OptimizeCodexMultiAgentV2Request rewrites an eligible spawn_agent request and
// reports whether the collaboration namespace was renamed for upstream use.
func OptimizeCodexMultiAgentV2Request(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config) ([]byte, bool) {
return multiagentv2.OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg)
}
+// OptimizeCodexMultiAgentV2RequestDoc rewrites an eligible spawn_agent request in place on doc
+// and reports whether the collaboration namespace was renamed for upstream use.
+func OptimizeCodexMultiAgentV2RequestDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config) bool {
+ return multiagentv2.OptimizeCodexMultiAgentV2RequestDoc(doc, ctx, headers, cfg)
+}
+
// OptimizeCodexMultiAgentV2RequestForAuth applies the standard Codex MultiAgentV2
// request optimization and uses the execution attempt's resolved compatibility
// flag to convert agent_message items into portable message/user input,
// proactively stripping author, recipient, and internal passthrough metadata.
func OptimizeCodexMultiAgentV2RequestForAuth(ctx context.Context, headers http.Header, payload []byte, cfg *config.Config, auth *cliproxyauth.Auth, isCompat bool) ([]byte, bool) {
+ if len(payload) == 0 {
+ return payload, false
+ }
+ doc := ojson.NewDoc(payload)
+ optimized := OptimizeCodexMultiAgentV2RequestForAuthDoc(doc, ctx, headers, cfg, auth, isCompat)
+ return doc.Bytes(), optimized
+}
+
+// OptimizeCodexMultiAgentV2RequestForAuthDoc applies the standard Codex MultiAgentV2
+// request optimization in place on doc and reports whether the collaboration namespace
+// was renamed for upstream use.
+func OptimizeCodexMultiAgentV2RequestForAuthDoc(doc *ojson.Doc, ctx context.Context, headers http.Header, cfg *config.Config, auth *cliproxyauth.Auth, isCompat bool) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
if auth != nil && auth.AuthKind() == cliproxyauth.AuthKindAPIKey {
cfg = cfg.ForAPIKey()
}
- payload = multiagentv2.RewriteCodexOrphanDelegationInputForConfig(ctx, headers, payload, cfg)
- updated, optimized := multiagentv2.OptimizeCodexMultiAgentV2Request(ctx, headers, payload, cfg)
+ multiagentv2.RewriteCodexOrphanDelegationInputForConfigDoc(doc, ctx, headers, cfg)
+ optimized := multiagentv2.OptimizeCodexMultiAgentV2RequestDoc(doc, ctx, headers, cfg)
if isCompat {
- updated = multiagentv2.RewriteCodexMultiAgentV2Input(ctx, headers, updated, cfg, true)
+ multiagentv2.RewriteCodexMultiAgentV2InputDoc(doc, ctx, headers, cfg, true)
}
- return updated, optimized
+ return optimized
}
// RestoreCodexMultiAgentV2Response restores optimized collaboration namespace
diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2_summary_test.go b/internal/runtime/executor/helps/codex_multi_agent_v2_summary_test.go
index 4570c539b16..429501b50ba 100644
--- a/internal/runtime/executor/helps/codex_multi_agent_v2_summary_test.go
+++ b/internal/runtime/executor/helps/codex_multi_agent_v2_summary_test.go
@@ -5,9 +5,9 @@ import (
"fmt"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCompatibilityTranslationPreservesExplicitClaudeVisibility(t *testing.T) {
@@ -29,7 +29,7 @@ func TestCompatibilityTranslationPreservesExplicitClaudeVisibility(t *testing.T)
} {
t.Run(tc.name, func(t *testing.T) {
out := TranslateRequestWithAPIKeyModelCompatibility(context.Background(), nil, &config.Config{}, tc.from, sdktranslator.FormatClaude, "claude-opus-5-5", []byte(tc.payload), stream, compat)
- display := gjson.GetBytes(out, "thinking.display")
+ display := ojson.GetBytes(out, "thinking.display")
if display.String() != tc.want || (tc.want == "" && display.Exists()) {
t.Fatalf("display=%s, want %q; body=%s", display.Raw, tc.want, out)
}
diff --git a/internal/runtime/executor/helps/codex_multi_agent_v2_test.go b/internal/runtime/executor/helps/codex_multi_agent_v2_test.go
index 6fe83990bce..49ff949b0c4 100644
--- a/internal/runtime/executor/helps/codex_multi_agent_v2_test.go
+++ b/internal/runtime/executor/helps/codex_multi_agent_v2_test.go
@@ -8,12 +8,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type pairRequestPluginHooks struct {
@@ -26,7 +25,7 @@ func (h *pairRequestPluginHooks) NormalizeRequest(_ context.Context, _, _ sdktra
if h.onNormalize != nil {
h.onNormalize(body)
}
- updated, _ := sjson.SetBytes(body, "plugin_call", h.calls)
+ updated, _ := ojson.SetBytes(body, "plugin_call", h.calls)
return updated
}
@@ -155,10 +154,10 @@ func TestTranslateRequestPairInvokesPluginOncePerInput(t *testing.T) {
if hooks.calls != wantCalls {
t.Fatalf("stream=%v distinct=%v: plugin calls = %d, want %d", stream, distinct, hooks.calls, wantCalls)
}
- if got := gjson.GetBytes(base, "plugin_call").Int(); got != 1 {
+ if got := ojson.GetBytes(base, "plugin_call").Int(); got != 1 {
t.Fatalf("baseline plugin_call = %d, want 1", got)
}
- if got := gjson.GetBytes(work, "plugin_call").Int(); got != wantCalls {
+ if got := ojson.GetBytes(work, "plugin_call").Int(); got != wantCalls {
t.Fatalf("working plugin_call = %d, want %d", got, wantCalls)
}
if !distinct && !bytes.Equal(base, work) {
@@ -219,10 +218,10 @@ func TestTranslateRequestEnvelopePairWithCodexMultiAgentV2UsesModelInfo(t *testi
if errPair != nil {
t.Fatalf("translate request pair: %v", errPair)
}
- if gjson.GetBytes(base, "requestType").String() != "web_search" {
+ if ojson.GetBytes(base, "requestType").String() != "web_search" {
t.Fatalf("expected baseline requestType web_search, got: %s", base)
}
- if gjson.GetBytes(work, "requestType").String() != "web_search" {
+ if ojson.GetBytes(work, "requestType").String() != "web_search" {
t.Fatalf("expected working requestType web_search, got: %s", work)
}
@@ -235,7 +234,7 @@ func TestTranslateRequestEnvelopePairWithCodexMultiAgentV2UsesModelInfo(t *testi
if errPair != nil {
t.Fatalf("translate request pair: %v", errPair)
}
- if gjson.GetBytes(workDisabled, "requestType").String() == "web_search" {
+ if ojson.GetBytes(workDisabled, "requestType").String() == "web_search" {
t.Fatalf("expected non-web_search when capability disabled, got: %s", workDisabled)
}
}
@@ -244,7 +243,7 @@ func TestTranslateRequestWithAPIKeyModelCompatibility_InvokesPluginNormalizers(t
var summaryDisplayInHook string
hooks := &pairRequestPluginHooks{
onNormalize: func(body []byte) {
- summaryDisplayInHook = gjson.GetBytes(body, "thinking.display").String()
+ summaryDisplayInHook = ojson.GetBytes(body, "thinking.display").String()
},
}
sdktranslator.SetPluginHooks(hooks)
@@ -268,7 +267,7 @@ func TestTranslateRequestWithAPIKeyModelCompatibility_InvokesPluginNormalizers(t
if hooks.calls != 1 {
t.Fatalf("plugin hook calls = %d, want 1", hooks.calls)
}
- if got := gjson.GetBytes(out, "plugin_call").Int(); got != 1 {
+ if got := ojson.GetBytes(out, "plugin_call").Int(); got != 1 {
t.Fatalf("plugin_call = %d, want 1; output was %s", got, out)
}
// Assert summary config was applied to the body before invoking the normalizer hook
@@ -292,7 +291,7 @@ func TestTranslateRequestWithAPIKeyModelCompatibility_InvokesPluginNormalizers(t
if hooks.calls != 1 {
t.Fatalf("non-compat plugin hook calls = %d, want 1", hooks.calls)
}
- if got := gjson.GetBytes(outNonCompat, "plugin_call").Int(); got != 1 {
+ if got := ojson.GetBytes(outNonCompat, "plugin_call").Int(); got != 1 {
t.Fatalf("non-compat plugin_call = %d, want 1; output was %s", got, outNonCompat)
}
@@ -312,7 +311,7 @@ func TestTranslateRequestWithAPIKeyModelCompatibility_InvokesPluginNormalizers(t
if hooks.calls != 1 {
t.Fatalf("stream compat plugin hook calls = %d, want 1", hooks.calls)
}
- if got := gjson.GetBytes(outStream, "plugin_call").Int(); got != 1 {
+ if got := ojson.GetBytes(outStream, "plugin_call").Int(); got != 1 {
t.Fatalf("stream compat plugin_call = %d, want 1; output was %s", got, outStream)
}
}
@@ -363,10 +362,10 @@ func TestTranslateRequestCompatibilityForExecutorToolIntegerTypes(t *testing.T)
wantType = "integer"
}
for entry, body := range outputs {
- if got := gjson.GetBytes(body, route.properties+".yield_time_ms.type").String(); got != wantType {
+ if got := ojson.GetBytes(body, route.properties+".yield_time_ms.type").String(); got != wantType {
t.Errorf("%s: yield_time_ms.type = %q, want %q; body=%s", entry, got, wantType, body)
}
- if got := gjson.GetBytes(body, route.properties+".unrelated.type").String(); got != "number" {
+ if got := ojson.GetBytes(body, route.properties+".unrelated.type").String(); got != "number" {
t.Errorf("%s: unrelated.type = %q, want number", entry, got)
}
}
@@ -418,10 +417,10 @@ func TestTranslateRequestWithCodexMultiAgentV2_NormalizesCodexToolTypes(t *testi
)
// In Claude format, tool is under tools[0].input_schema.properties
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.yield_time_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(out, "tools.0.input_schema.properties.yield_time_ms.type").String(); got != "integer" {
t.Errorf("translated Claude tool yield_time_ms type = %q, want integer; out=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.timeout_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(out, "tools.0.input_schema.properties.timeout_ms.type").String(); got != "integer" {
t.Errorf("translated Claude tool timeout_ms type = %q, want integer; out=%s", got, out)
}
@@ -437,9 +436,9 @@ func TestTranslateRequestWithCodexMultiAgentV2_NormalizesCodexToolTypes(t *testi
)
// In Gemini format, tool is under functionDeclarations with parameters or parametersJsonSchema
- geminiParam := gjson.GetBytes(outGemini, "tools.0.functionDeclarations.0.parametersJsonSchema.properties")
+ geminiParam := ojson.GetBytes(outGemini, "tools.0.functionDeclarations.0.parametersJsonSchema.properties")
if !geminiParam.Exists() {
- geminiParam = gjson.GetBytes(outGemini, "tools.0.function_declarations.0.parameters.properties")
+ geminiParam = ojson.GetBytes(outGemini, "tools.0.function_declarations.0.parameters.properties")
}
if got := geminiParam.Get("yield_time_ms.type").String(); got != "integer" {
t.Errorf("translated Gemini tool yield_time_ms type = %q, want integer; out=%s", got, outGemini)
diff --git a/internal/runtime/executor/helps/codex_quota.go b/internal/runtime/executor/helps/codex_quota.go
index cc0c1c2b4f4..966e717b52f 100644
--- a/internal/runtime/executor/helps/codex_quota.go
+++ b/internal/runtime/executor/helps/codex_quota.go
@@ -4,7 +4,8 @@ import (
"net/http"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const (
@@ -34,7 +35,7 @@ func ParseCodexQuotaEventHeaders(payload []byte) http.Header {
if eventKind == codexQuotaEventOther {
return nil
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if eventKind == codexQuotaEventError {
return parseCodexQuotaHeadersObject(root.Get("headers"))
}
@@ -52,7 +53,7 @@ func ParseCodexQuotaEventHeaders(payload []byte) http.Header {
additional := firstCodexQuotaResult(root, "additional_rate_limits", "additionalRateLimits")
additionalCount := 0
if additional.Exists() && (additional.IsObject() || additional.IsArray()) {
- additional.ForEach(func(key, value gjson.Result) bool {
+ additional.ForEach(func(key, value ojson.Result) bool {
if additionalCount >= maxCodexAdditionalRateLimits {
return false
}
@@ -77,7 +78,7 @@ func ParseCodexQuotaEventHeaders(payload []byte) http.Header {
// plain-text request log, so reject control characters here the
// same way the plan type is validated below.
if validCodexQuotaEventText(limitName) {
- headers.Set(prefix+"Limit-Name", limitName)
+ headers.Set(prefix+"Limit-Name", strings.Clone(limitName))
}
hasQuotaData = true
additionalCount++
@@ -165,7 +166,7 @@ func codexQuotaEventKind(payload []byte) int {
return codexQuotaEventOther
}
-func addCodexQuotaRateLimitHeaders(headers http.Header, prefix string, rateInfo gjson.Result) bool {
+func addCodexQuotaRateLimitHeaders(headers http.Header, prefix string, rateInfo ojson.Result) bool {
if !rateInfo.Exists() || !rateInfo.IsObject() {
return false
}
@@ -207,12 +208,12 @@ func addCodexQuotaRateLimitHeaders(headers http.Header, prefix string, rateInfo
return changed
}
-func parseCodexQuotaHeadersObject(headersNode gjson.Result) http.Header {
+func parseCodexQuotaHeadersObject(headersNode ojson.Result) http.Header {
if !headersNode.Exists() || !headersNode.IsObject() {
return nil
}
headers := make(http.Header)
- headersNode.ForEach(func(key, value gjson.Result) bool {
+ headersNode.ForEach(func(key, value ojson.Result) bool {
name := http.CanonicalHeaderKey(strings.TrimSpace(key.String()))
if !isCodexQuotaHeaderName(name) {
return true
@@ -257,11 +258,11 @@ func isCodexQuotaHeaderName(name string) bool {
return false
}
-func setCodexQuotaScalarHeader(headers http.Header, name string, object gjson.Result, paths ...string) bool {
+func setCodexQuotaScalarHeader(headers http.Header, name string, object ojson.Result, paths ...string) bool {
return setCodexQuotaScalarHeaderFromResult(headers, name, object, paths...)
}
-func setCodexQuotaScalarHeaderFromResult(headers http.Header, name string, object gjson.Result, paths ...string) bool {
+func setCodexQuotaScalarHeaderFromResult(headers http.Header, name string, object ojson.Result, paths ...string) bool {
value := firstCodexQuotaResult(object, paths...)
if !value.Exists() {
return false
@@ -274,28 +275,28 @@ func setCodexQuotaScalarHeaderFromResult(headers http.Header, name string, objec
return true
}
-func codexQuotaScalarValue(value gjson.Result) string {
+func codexQuotaScalarValue(value ojson.Result) string {
switch value.Type {
- case gjson.String:
- return strings.TrimSpace(value.String())
- case gjson.Number, gjson.True, gjson.False:
- return strings.TrimSpace(value.Raw)
+ case ojson.String:
+ return strings.Clone(strings.TrimSpace(value.String()))
+ case ojson.Number, ojson.True, ojson.False:
+ return strings.Clone(strings.TrimSpace(value.Raw))
default:
return ""
}
}
-func firstCodexQuotaResult(object gjson.Result, paths ...string) gjson.Result {
+func firstCodexQuotaResult(object ojson.Result, paths ...string) ojson.Result {
for _, path := range paths {
value := object.Get(path)
- if value.Exists() && value.Type != gjson.Null {
+ if value.Exists() && value.Type != ojson.Null {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func firstCodexQuotaResultString(object gjson.Result, paths ...string) string {
+func firstCodexQuotaResultString(object ojson.Result, paths ...string) string {
for _, path := range paths {
value := firstCodexQuotaResult(object, path)
if raw := codexQuotaScalarValue(value); raw != "" {
diff --git a/internal/runtime/executor/helps/codex_terminal_incomplete.go b/internal/runtime/executor/helps/codex_terminal_incomplete.go
index 893be06f513..2c16dcb2920 100644
--- a/internal/runtime/executor/helps/codex_terminal_incomplete.go
+++ b/internal/runtime/executor/helps/codex_terminal_incomplete.go
@@ -3,7 +3,7 @@ package helps
import (
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const (
@@ -15,13 +15,13 @@ const (
// HasMeaningfulCodexOutputDelta reports whether an event carries non-empty generated content
// (such as non-empty text, reasoning, or function call arguments delta).
func HasMeaningfulCodexOutputDelta(eventData []byte) bool {
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
switch eventType {
case "response.output_text.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta":
- delta := gjson.GetBytes(eventData, "delta")
+ delta := ojson.GetBytesNoCopy(eventData, "delta")
return delta.Exists() && len(strings.TrimSpace(delta.String())) > 0
case "response.function_call_arguments.delta":
- delta := gjson.GetBytes(eventData, "delta")
+ delta := ojson.GetBytesNoCopy(eventData, "delta")
return delta.Exists() && len(strings.TrimSpace(delta.String())) > 0
}
return false
@@ -31,7 +31,7 @@ func HasMeaningfulCodexOutputDelta(eventData []byte) bool {
// an upstream silent abort where explicitly zero output tokens were generated and zero
// output content (no output items and no non-empty output deltas) was produced.
func IsCodexTerminalEmptyIncomplete(eventData []byte, outputItemsCount int, sawOutputDelta bool) bool {
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
if eventType != "response.incomplete" {
return false
}
@@ -44,13 +44,13 @@ func IsCodexTerminalEmptyIncomplete(eventData []byte, outputItemsCount int, sawO
return false
}
// If response.output contains any items, output was produced.
- output := gjson.GetBytes(eventData, "response.output")
+ output := ojson.GetBytesNoCopy(eventData, "response.output")
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
return false
}
// Require explicit numeric zero for output tokens (reject floats like 0.5, non-numbers, missing, or null).
- outputTokens := gjson.GetBytes(eventData, "response.usage.output_tokens")
- if !outputTokens.Exists() || outputTokens.Type != gjson.Number {
+ outputTokens := ojson.GetBytesNoCopy(eventData, "response.usage.output_tokens")
+ if !outputTokens.Exists() || outputTokens.Type != ojson.Number {
return false
}
if outputTokens.Num != 0 || strings.TrimSpace(outputTokens.Raw) != "0" {
diff --git a/internal/runtime/executor/helps/codex_tool_schema.go b/internal/runtime/executor/helps/codex_tool_schema.go
index d97e5e51609..9b7315b8770 100644
--- a/internal/runtime/executor/helps/codex_tool_schema.go
+++ b/internal/runtime/executor/helps/codex_tool_schema.go
@@ -1,38 +1,12 @@
package helps
import (
- "bytes"
- "encoding/json"
- "errors"
- "io"
- "math/big"
"net/http"
- "strconv"
- "strings"
-
- log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
toolschema "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/tool-schema"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
-)
-
-const (
- // codexComplexUnionBranchThreshold is the minimum number of union branches (oneOf / anyOf)
- // required before considering a pure constant union eligible for semantic enum normalization.
- codexComplexUnionBranchThreshold = 8
)
-// codexNumericBoundKeywords lists the JSON Schema numeric bound keywords that
-// the Codex backend rejects with HTTP 500 when set to an integer outside int64.
-var codexNumericBoundKeywords = [...]string{
- "minimum",
- "maximum",
- "exclusiveMinimum",
- "exclusiveMaximum",
-}
-
// IsCodexUserAgent reports whether headers contain a User-Agent indicating a Codex client.
func IsCodexUserAgent(headers http.Header) bool {
return toolschema.IsCodexUserAgent(headers)
@@ -44,6 +18,12 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
return toolschema.NormalizeCodexToolIntegerTypes(body, headers)
}
+// NormalizeCodexToolIntegerTypesDoc normalizes specified tool parameter declarations
+// from number to integer for Codex clients directly on doc in place.
+func NormalizeCodexToolIntegerTypesDoc(doc *ojson.Doc, headers http.Header) bool {
+ return toolschema.NormalizeCodexToolIntegerTypesDoc(doc, headers)
+}
+
// NormalizeCodexToolSchemas inspects function tools in a Codex request payload
// and simplifies pure constant union combinations (e.g. large oneOf branch sets
// representing enums with descriptions, as emitted by MCP servers) into semantically
@@ -51,453 +31,11 @@ func NormalizeCodexToolIntegerTypes(body []byte, headers http.Header) []byte {
// It does not normalize tool integer types because upstream OpenAI Codex models
// strictly validate reserved tool schemas (e.g. collaboration.wait_agent).
func NormalizeCodexToolSchemas(body []byte) []byte {
- if len(body) == 0 {
- return body
- }
- updatedTools, changed := normalizeCodexToolList(gjson.GetBytes(body, "tools"))
- if !changed {
- return body
- }
- out, errSet := sjson.SetRawBytes(body, "tools", updatedTools)
- if errSet != nil {
- return body
- }
- log.Debugf("codex: normalized tool schemas to prevent upstream failure")
- return out
-}
-
-// normalizeCodexToolList batches changed elements so a long request (or a wide
-// namespace) is copied once rather than once per tool. Copy the gaps verbatim
-// to retain array formatting and unchanged declarations.
-func normalizeCodexToolList(tools gjson.Result) ([]byte, bool) {
- if !tools.IsArray() {
- return nil, false
- }
- var out []byte
- offset := 0
- tools.ForEach(func(_, tool gjson.Result) bool {
- updated, changed := normalizeCodexTool(tool)
- if !changed {
- return true
- }
- if out == nil {
- out = make([]byte, 0, len(tools.Raw))
- }
- // ForEach indexes share the source of the containing array's index.
- start := tool.Index - tools.Index
- out = append(out, tools.Raw[offset:start]...)
- out = append(out, updated...)
- offset = start + len(tool.Raw)
- return true
- })
- if out == nil {
- return nil, false
- }
- return append(out, tools.Raw[offset:]...), true
-}
-
-func normalizeCodexTool(tool gjson.Result) ([]byte, bool) {
- toolType := tool.Get("type").String()
- // Handle namespace tools (e.g. multi-agent nested tools)
- if toolType == "namespace" {
- updatedTools, changed := normalizeCodexToolList(tool.Get("tools"))
- if !changed {
- return nil, false
- }
- updated, errSet := sjson.SetRawBytes([]byte(tool.Raw), "tools", updatedTools)
- return updated, errSet == nil
- }
-
- if toolType != "function" && toolType != "custom" {
- return nil, false
- }
-
- params := tool.Get("parameters")
- if !params.Exists() || !params.IsObject() {
- return nil, false
- }
-
- rawTool := []byte(tool.Raw)
- updatedParams, paramsChanged := normalizeCodexParameters(params)
- if !paramsChanged {
- return nil, false
- }
-
- updatedTool, errSet := sjson.SetRawBytes(rawTool, "parameters", updatedParams)
- if errSet != nil {
- return nil, false
- }
-
- log.Debugf("codex: normalized schema for tool %s to avoid upstream abort", tool.Get("name").String())
- return updatedTool, true
-}
-
-func normalizeCodexParameters(params gjson.Result) ([]byte, bool) {
- rawParams := []byte(params.Raw)
- changed := false
-
- if sanitizedParams, patternChanged := stripIncompatiblePatternsFromJSON(rawParams); patternChanged {
- rawParams = sanitizedParams
- changed = true
- params = gjson.ParseBytes(rawParams)
- }
-
- properties := params.Get("properties")
- if properties.Exists() && properties.IsObject() {
- for propName, propVal := range properties.Map() {
- updatedProp, propChanged := normalizeCodexPropertySchema(propVal)
- if propChanged {
- escapedKey := escapeCodexSjsonKey(propName)
- var errSet error
- rawParams, errSet = sjson.SetRawBytes(rawParams, "properties."+escapedKey, updatedProp)
- if errSet == nil {
- changed = true
- }
- }
- }
- }
-
- return rawParams, changed
-}
-
-// stripIncompatiblePatternsFromJSON recursively removes pattern attributes that strict
-// upstream validators reject: unsupported Unicode property escapes (\p{...} / \P{...})
-// and the octal NUL escape (\0), as well as integer numeric bounds (minimum, maximum,
-// exclusiveMinimum, exclusiveMaximum) outside the signed 64-bit integer range (#6579).
-// See util.HasUnsupportedUnicodePropertyEscape for the regex predicate and upstream errors.
-// It is schema-aware: only subschemas under known JSON Schema keyword locations are visited,
-// preventing accidental deletion of keys inside user data (e.g. description, default, enum).
-func stripIncompatiblePatternsFromJSON(raw []byte) ([]byte, bool) {
- rawStr := string(raw)
- // The fast path avoids parsing when no candidate escape or numeric bound is present.
- // Patterns arrive JSON-escaped, so a literal backslash before '0' is `\\0` in the raw bytes.
- // "inimum" and "aximum" match minimum, maximum, exclusiveMinimum, and exclusiveMaximum.
- if !strings.Contains(rawStr, `\p{`) && !strings.Contains(rawStr, `\P{`) && !strings.Contains(rawStr, `\u`) &&
- !strings.Contains(rawStr, `\\0`) && !strings.Contains(rawStr, "inimum") && !strings.Contains(rawStr, "aximum") {
- return raw, false
- }
- var root any
- dec := json.NewDecoder(bytes.NewReader(raw))
- dec.UseNumber()
- if err := dec.Decode(&root); err != nil || root == nil {
- return raw, false
- }
- // Verify no trailing garbage
- var dummy any
- if err := dec.Decode(&dummy); err != io.EOF {
- return raw, false
- }
- if !stripIncompatiblePatterns(root) {
- return raw, false
- }
- var buf bytes.Buffer
- enc := json.NewEncoder(&buf)
- enc.SetEscapeHTML(false)
- if err := enc.Encode(root); err != nil {
- return raw, false
- }
- return bytes.TrimSpace(buf.Bytes()), true
-}
-
-func isNumericBoundOutOfInt64(number json.Number) bool {
- raw := strings.TrimSpace(number.String())
- if raw == "" {
- return false
- }
- if !strings.ContainsAny(raw, ".eE") {
- _, errParse := strconv.ParseInt(raw, 10, 64)
- return errors.Is(errParse, strconv.ErrRange)
- }
-
- s := raw
- negative := false
- if strings.HasPrefix(s, "-") {
- negative = true
- s = s[1:]
- } else if strings.HasPrefix(s, "+") {
- s = s[1:]
- }
-
- mantissa := s
- var exp int64
- if idx := strings.IndexAny(s, "eE"); idx >= 0 {
- mantissa = s[:idx]
- expStr := s[idx+1:]
- parsedExp, errExp := strconv.ParseInt(expStr, 10, 64)
- if errExp != nil {
- if errors.Is(errExp, strconv.ErrRange) {
- return !strings.HasPrefix(expStr, "-") && strings.Trim(mantissa, "0.") != ""
- }
- return false
- }
- exp = parsedExp
- }
-
- intPart := mantissa
- fracPart := ""
- if dotIdx := strings.IndexByte(mantissa, '.'); dotIdx >= 0 {
- intPart = mantissa[:dotIdx]
- fracPart = mantissa[dotIdx+1:]
- if strings.IndexByte(fracPart, '.') >= 0 {
- return false
- }
- }
- if intPart == "" && fracPart == "" {
- return false
- }
- for i := 0; i < len(intPart); i++ {
- if intPart[i] < '0' || intPart[i] > '9' {
- return false
- }
- }
- for i := 0; i < len(fracPart); i++ {
- if fracPart[i] < '0' || fracPart[i] > '9' {
- return false
- }
- }
-
- allDigits := intPart + fracPart
- sig := strings.TrimLeft(allDigits, "0")
- if sig == "" {
- return false
- }
- if exp < -int64(len(raw)) {
- return false
- }
- if exp > int64(len(raw))+20 {
- return true
- }
- trimmed := strings.TrimRight(sig, "0")
- trailingZeros := int64(len(sig) - len(trimmed))
- effectiveExp := exp - int64(len(fracPart)) + trailingZeros
- if effectiveExp < 0 {
- return false
- }
- if effectiveExp > 19 || int64(len(trimmed))+effectiveExp > 19 {
- return true
- }
-
- intStr := trimmed + strings.Repeat("0", int(effectiveExp))
- if negative {
- intStr = "-" + intStr
- }
- _, errParse := strconv.ParseInt(intStr, 10, 64)
- return errors.Is(errParse, strconv.ErrRange)
-}
-
-func stripIncompatiblePatterns(v any) bool {
- changed := false
- switch schema := v.(type) {
- case map[string]any:
- if patternVal, ok := schema["pattern"].(string); ok && util.HasUnsupportedUnicodePropertyEscape(patternVal) {
- delete(schema, "pattern")
- changed = true
- }
- for _, boundKey := range codexNumericBoundKeywords {
- if boundVal, ok := schema[boundKey].(json.Number); ok && isNumericBoundOutOfInt64(boundVal) {
- delete(schema, boundKey)
- changed = true
- }
- }
-
- // Inspect regex keys under patternProperties
- if patternProps, ok := schema["patternProperties"].(map[string]any); ok {
- for patternKey, subSchema := range patternProps {
- if util.HasUnsupportedUnicodePropertyEscape(patternKey) {
- delete(patternProps, patternKey)
- changed = true
- } else if stripIncompatiblePatterns(subSchema) {
- changed = true
- }
- }
- }
-
- for _, mapKey := range util.SchemaMapKeywords {
- if mapKey == "patternProperties" {
- continue
- }
- if subMap, ok := schema[mapKey].(map[string]any); ok {
- for _, subSchema := range subMap {
- if stripIncompatiblePatterns(subSchema) {
- changed = true
- }
- }
- }
- }
-
- for _, valKey := range util.SchemaValueKeywords {
- if val, exists := schema[valKey]; exists {
- switch sub := val.(type) {
- case map[string]any:
- if stripIncompatiblePatterns(sub) {
- changed = true
- }
- case []any:
- for _, item := range sub {
- if stripIncompatiblePatterns(item) {
- changed = true
- }
- }
- }
- }
- }
- case []any:
- for _, item := range schema {
- if stripIncompatiblePatterns(item) {
- changed = true
- }
- }
- }
- return changed
-}
-
-func normalizeCodexPropertySchema(prop gjson.Result) ([]byte, bool) {
- if !prop.IsObject() {
- return nil, false
- }
-
- hasOneOf := prop.Get("oneOf").Exists()
- hasAnyOf := prop.Get("anyOf").Exists()
- // If both oneOf and anyOf are present on the same property, leave untouched to preserve compound constraints
- if hasOneOf && hasAnyOf {
- return nil, false
- }
-
- unionName := ""
- if hasOneOf {
- unionName = "oneOf"
- } else if hasAnyOf {
- unionName = "anyOf"
- } else {
- return nil, false
- }
-
- union := prop.Get(unionName)
- if !union.IsArray() || len(union.Array()) < codexComplexUnionBranchThreshold {
- return nil, false
- }
-
- branches := union.Array()
- constRawValues := make([]string, 0, len(branches))
- constSemanticKeys := make([]string, 0, len(branches))
- seenSemanticKeys := make(map[string]struct{}, len(branches))
- pureConsts := true
-
- for _, branch := range branches {
- canonicalKey, rawJSON, ok := isPureConstBranch(branch)
- if !ok {
- pureConsts = false
- break
- }
- if _, seen := seenSemanticKeys[canonicalKey]; seen {
- // Duplicate semantic value in oneOf violates exclusivity; keep original schema
- pureConsts = false
- break
- }
- seenSemanticKeys[canonicalKey] = struct{}{}
- constSemanticKeys = append(constSemanticKeys, canonicalKey)
- constRawValues = append(constRawValues, rawJSON)
- }
-
- // Only transform if every branch is proven to be a pure, unique const definition
- if !pureConsts || len(constRawValues) == 0 {
- return nil, false
- }
-
- rawProp := []byte(prop.Raw)
- existingEnum := prop.Get("enum")
- if existingEnum.Exists() && existingEnum.IsArray() {
- existingEnumKeys := make([]string, 0, len(existingEnum.Array()))
- for _, v := range existingEnum.Array() {
- key, ok := canonicalJSONValueKey(v)
- if !ok {
- return nil, false
- }
- existingEnumKeys = append(existingEnumKeys, key)
- }
- // Only remove the redundant union if existing enum is proven semantically identical
- if equalCanonicalSets(existingEnumKeys, constSemanticKeys) {
- rawProp, _ = sjson.DeleteBytes(rawProp, unionName)
- return rawProp, true
- }
- return nil, false
- }
-
- // Migrate the pure const union to an enum using raw JSON tokens to avoid any numeric precision loss
- rawEnumJSON := []byte("[" + strings.Join(constRawValues, ",") + "]")
- rawProp, errEnum := sjson.SetRawBytes(rawProp, "enum", rawEnumJSON)
- if errEnum != nil {
- return nil, false
- }
- rawProp, _ = sjson.DeleteBytes(rawProp, unionName)
- return rawProp, true
-}
-
-func isPureConstBranch(branch gjson.Result) (canonicalKey string, rawJSON string, ok bool) {
- if !branch.IsObject() {
- return "", "", false
- }
- constVal := branch.Get("const")
- if !constVal.Exists() {
- return "", "", false
- }
- // Verify no other schema validation constraints exist in this branch
- for key := range branch.Map() {
- if key != "const" && key != "description" && key != "title" {
- return "", "", false
- }
- }
- key, ok := canonicalJSONValueKey(constVal)
- if !ok {
- return "", "", false
- }
- return key, constVal.Raw, true
-}
-
-func canonicalJSONValueKey(val gjson.Result) (string, bool) {
- switch val.Type {
- case gjson.String:
- return "s:" + val.String(), true
- case gjson.Number:
- raw := strings.TrimSpace(val.Raw)
- var r big.Rat
- if _, ok := r.SetString(raw); ok {
- return "n:" + r.RatString(), true
- }
- return "n:" + raw, true
- case gjson.True:
- return "b:true", true
- case gjson.False:
- return "b:false", true
- case gjson.Null:
- return "null", true
- default:
- return "", false
- }
-}
-
-func equalCanonicalSets(a []string, b []string) bool {
- if len(a) != len(b) {
- return false
- }
- setA := make(map[string]struct{}, len(a))
- for _, v := range a {
- setA[v] = struct{}{}
- }
- for _, v := range b {
- if _, ok := setA[v]; !ok {
- return false
- }
- }
- return len(setA) == len(a)
+ return toolschema.NormalizeCodexToolSchemas(body)
}
-// escapeCodexSjsonKey escapes dots, colons, and backslashes in property keys so that sjson treats
-// keys containing dots (e.g. "my.field") or colons (e.g. ":action") as a single literal key rather
-// than nested paths or control syntax.
-func escapeCodexSjsonKey(key string) string {
- key = strings.ReplaceAll(key, `\`, `\\`)
- key = strings.ReplaceAll(key, `.`, `\.`)
- key = strings.ReplaceAll(key, `:`, `\:`)
- return key
+// NormalizeCodexToolSchemasDoc inspects function tools in doc and simplifies
+// pure constant union combinations and sanitizes unsupported schema patterns in place.
+func NormalizeCodexToolSchemasDoc(doc *ojson.Doc) bool {
+ return toolschema.NormalizeCodexToolSchemasDoc(doc)
}
diff --git a/internal/runtime/executor/helps/codex_tool_schema_batch_test.go b/internal/runtime/executor/helps/codex_tool_schema_batch_test.go
index 64004fe3b7e..92b0c42ee85 100644
--- a/internal/runtime/executor/helps/codex_tool_schema_batch_test.go
+++ b/internal/runtime/executor/helps/codex_tool_schema_batch_test.go
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ toolschema "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/tool-schema"
)
const codexBatchUnionTool = `{"type":"function","name":"choose","parameters":{"type":"object","properties":{"action":{"oneOf":[{"const":"a"},{"const":"b"},{"const":"c"},{"const":"d"},{"const":"e"},{"const":"f"},{"const":"g"},{"const":"h"}]}}}}`
@@ -95,37 +95,44 @@ func BenchmarkNormalizeCodexToolSchemas(b *testing.B) {
// Frozen pre-batching traversal: compare exact bytes and benchmark the same
// input before/after without changing the schema normalization policy.
func codexSequentialToolSchemas(body []byte) []byte {
- tools := gjson.GetBytes(body, "tools")
+ tools := ojson.GetBytesNoCopy(body, "tools")
if !tools.IsArray() {
return body
}
+ doc := ojson.NewDoc(body)
+ changed := false
for index, tool := range tools.Array() {
- if updated, changed := codexSequentialTool(tool); changed {
- if out, errSet := sjson.SetRawBytes(body, fmt.Sprintf("tools.%d", index), updated); errSet == nil {
- body = out
+ if updated, toolChanged := codexSequentialTool(tool); toolChanged {
+ if errSet := doc.SetRawBytes(fmt.Sprintf("tools.%d", index), updated); errSet == nil {
+ changed = true
}
}
}
- return body
+ if !changed {
+ return body
+ }
+ return doc.Bytes()
}
-func codexSequentialTool(tool gjson.Result) ([]byte, bool) {
+func codexSequentialTool(tool ojson.Result) ([]byte, bool) {
if tool.Get("type").String() != "namespace" {
- return normalizeCodexTool(tool)
+ return toolschema.NormalizeSingleCodexTool(tool)
}
nested := tool.Get("tools")
if !nested.IsArray() || len(nested.Array()) == 0 {
return nil, false
}
- raw := []byte(tool.Raw)
+ doc := ojson.NewDocString(tool.Raw)
changed := false
for index, child := range nested.Array() {
if updated, childChanged := codexSequentialTool(child); childChanged {
- if out, errSet := sjson.SetRawBytes(raw, fmt.Sprintf("tools.%d", index), updated); errSet == nil {
- raw = out
+ if errSet := doc.SetRawBytes(fmt.Sprintf("tools.%d", index), updated); errSet == nil {
changed = true
}
}
}
- return raw, changed
+ if !changed {
+ return nil, false
+ }
+ return doc.Bytes(), true
}
diff --git a/internal/runtime/executor/helps/codex_tool_schema_test.go b/internal/runtime/executor/helps/codex_tool_schema_test.go
index cc6da6af257..2beb8be5139 100644
--- a/internal/runtime/executor/helps/codex_tool_schema_test.go
+++ b/internal/runtime/executor/helps/codex_tool_schema_test.go
@@ -4,7 +4,7 @@ import (
"net/http"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestNormalizeCodexToolSchemas_ComplexOneOfSimplified_PreservesProperties(t *testing.T) {
@@ -51,7 +51,7 @@ func TestNormalizeCodexToolSchemas_ComplexOneOfSimplified_PreservesProperties(t
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
// Complex oneOf must be removed from action property
if tool.Get("parameters.properties.action.oneOf").Exists() {
t.Fatalf("expected oneOf to be removed from action property, got: %s", tool.Get("parameters").Raw)
@@ -107,7 +107,7 @@ func TestNormalizeCodexToolSchemas_DottedPropertyName(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
// Ensure properties contains "my.action", NOT nested object "my": {"action": ...}
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
dottedProp := tool.Get(`parameters.properties.my\.action`)
if !dottedProp.Exists() {
t.Fatalf("expected my.action property to exist with escaped key, got parameters: %s", tool.Get("parameters").Raw)
@@ -149,7 +149,7 @@ func TestNormalizeCodexToolSchemas_ColonPropertyName(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
colonProp := tool.Get(`parameters.properties.\:action`)
if !colonProp.Exists() {
t.Fatalf("expected :action property to exist with escaped colon, got parameters: %s", tool.Get("parameters").Raw)
@@ -187,7 +187,7 @@ func TestNormalizeCodexToolSchemas_NumericDuplicateConstNotTouched(t *testing.T)
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if !tool.Get("parameters.properties.val.oneOf").Exists() {
t.Fatalf("oneOf with numeric duplicates (1 and 1.0) must NOT be transformed to enum")
}
@@ -219,7 +219,7 @@ func TestNormalizeCodexToolSchemas_LargeIntegerPrecisionPreserved(t *testing.T)
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if tool.Get("parameters.properties.id.oneOf").Exists() {
t.Fatalf("oneOf should be deleted")
}
@@ -255,7 +255,7 @@ func TestNormalizeCodexToolSchemas_UnicodeDuplicateConstNotTouched(t *testing.T)
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if !tool.Get("parameters.properties.val.oneOf").Exists() {
t.Fatalf("oneOf with duplicate semantic values must NOT be transformed to enum")
}
@@ -286,7 +286,7 @@ func TestNormalizeCodexToolSchemas_TypePreservingComparison(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if !tool.Get("parameters.properties.val.oneOf").Exists() {
t.Fatalf("oneOf must NOT be deleted when enum strings do not match const numbers")
}
@@ -319,7 +319,7 @@ func TestNormalizeCodexToolSchemas_BothOneOfAndAnyOfUntouched(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if !tool.Get("parameters.properties.val.oneOf").Exists() || !tool.Get("parameters.properties.val.anyOf").Exists() {
t.Fatalf("compound oneOf+anyOf property must not be modified")
}
@@ -350,7 +350,7 @@ func TestNormalizeCodexToolSchemas_MigratesConstBranchesToEnum(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if tool.Get("parameters.properties.mode.oneOf").Exists() {
t.Fatalf("oneOf should be deleted")
}
@@ -386,7 +386,7 @@ func TestNormalizeCodexToolSchemas_NonMatchingEnumNotTouched(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if !tool.Get("parameters.properties.status.oneOf").Exists() {
t.Fatalf("oneOf should NOT be removed when enum set does not match const values set")
}
@@ -422,7 +422,7 @@ func TestNormalizeCodexToolSchemas_NonConstUnionNotTouched(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if !tool.Get("parameters.properties.data.oneOf").Exists() {
t.Fatalf("non-const oneOf must remain untouched")
}
@@ -448,7 +448,7 @@ func TestNormalizeCodexToolSchemas_SimpleToolPreserved(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
if tool.Get("parameters.properties.query.type").String() != "string" {
t.Fatalf("simple tool should not be modified, got: %s", tool.Get("parameters").Raw)
}
@@ -484,7 +484,7 @@ func TestNormalizeCodexToolSchemas_NamespaceToolSimplified(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- nestedTool := gjson.GetBytes(out, "tools.0.tools.0")
+ nestedTool := ojson.GetBytes(out, "tools.0.tools.0")
if nestedTool.Get("parameters.properties.action.oneOf").Exists() {
t.Fatalf("nested tool oneOf should be simplified")
}
@@ -519,7 +519,7 @@ func TestNormalizeCodexToolSchemas_StripsUnsupportedUnicodePropertyEscapePattern
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
params := tool.Get("parameters")
// Unsupported pattern must be removed
@@ -581,7 +581,7 @@ func TestNormalizeCodexToolSchemas_StripsOctalNULPatternEscape(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- params := gjson.GetBytes(out, "tools.0.parameters")
+ params := ojson.GetBytes(out, "tools.0.parameters")
// The octal NUL pattern must be removed, while the rest of the item schema stays.
if params.Get("properties.file_paths.items.pattern").Exists() {
@@ -649,7 +649,7 @@ func TestNormalizeCodexToolSchemas_PreservesNonSchemaPatternKeys(t *testing.T) {
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
params := tool.Get("parameters")
// Real schema pattern must be removed
@@ -720,7 +720,7 @@ func TestNormalizeCodexToolSchemas_CoversAllSchemaKeywordLocations(t *testing.T)
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
params := tool.Get("parameters")
// All subschemas in schema-aware locations must have their incompatible patterns stripped
@@ -800,7 +800,7 @@ func TestNormalizeCodexToolSchemas_JSONUnicodeEscapeBypassPrevention(t *testing.
}`)
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
params := tool.Get("parameters")
if params.Get("properties.p1.pattern").Exists() {
@@ -838,7 +838,7 @@ func TestNormalizeCodexToolSchemas_PatternPropertiesKeySanitization(t *testing.T
}`)
out := NormalizeCodexToolSchemas(input)
- tool := gjson.GetBytes(out, "tools.0")
+ tool := ojson.GetBytes(out, "tools.0")
params := tool.Get("parameters")
// Key with \p{L}+ must be removed
@@ -967,7 +967,7 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
t.Run("non-codex user agent preserves numbers", func(t *testing.T) {
headers := http.Header{"User-Agent": []string{"curl/8.7.1"}}
out := NormalizeCodexToolIntegerTypes(input, headers)
- if got := gjson.GetBytes(out, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(out, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected number for non-codex, got: %s", got)
}
})
@@ -976,8 +976,8 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0 (Mac OS 26.5.2; arm64)"}}
out := NormalizeCodexToolIntegerTypes(input, headers)
- toolMap := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(out, "tools").Array() {
+ toolMap := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytes(out, "tools").Array() {
toolMap[tool.Get("name").String()] = tool
}
@@ -1014,7 +1014,7 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}
// Check additional_tools under input
- addToolType := gjson.GetBytes(out, "input.0.tools.0.parameters.properties.yield_time_ms.type").Array()
+ addToolType := ojson.GetBytes(out, "input.0.tools.0.parameters.properties.yield_time_ms.type").Array()
if len(addToolType) != 2 || addToolType[0].String() != "integer" || addToolType[1].String() != "null" {
t.Errorf("additional_tools yield_time_ms type = %v, want [integer null]", addToolType)
}
@@ -1037,7 +1037,7 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(dupInput, headers)
- arr := gjson.GetBytes(out, "tools.0.parameters.properties.duration_ms.type").Array()
+ arr := ojson.GetBytes(out, "tools.0.parameters.properties.duration_ms.type").Array()
if len(arr) != 2 || arr[0].String() != "integer" || arr[1].String() != "null" {
t.Errorf("deduplicated type = %v, want [integer null]", arr)
}
@@ -1060,10 +1060,10 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(claudeInput, headers)
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.yield_time_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(out, "tools.0.input_schema.properties.yield_time_ms.type").String(); got != "integer" {
t.Errorf("claude tool yield_time_ms type = %q, want integer", got)
}
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.timeout_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(out, "tools.0.input_schema.properties.timeout_ms.type").String(); got != "integer" {
t.Errorf("claude tool timeout_ms type = %q, want integer", got)
}
})
@@ -1088,7 +1088,7 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}`)
headers := http.Header{"User-Agent": []string{"codex-desktop/0.159.0"}}
out := NormalizeCodexToolIntegerTypes(geminiInput, headers)
- if got := gjson.GetBytes(out, "tools.0.function_declarations.0.parameters.properties.duration_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(out, "tools.0.function_declarations.0.parameters.properties.duration_ms.type").String(); got != "integer" {
t.Errorf("gemini tool duration_ms type = %q, want integer", got)
}
})
@@ -1116,21 +1116,21 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(schemaInput, headers)
- if !gjson.GetBytes(out, "tools.0.parameters.properties.regex_field.pattern").Exists() {
+ if !ojson.GetBytes(out, "tools.0.parameters.properties.regex_field.pattern").Exists() {
t.Errorf("NormalizeCodexToolIntegerTypes should not strip pattern")
}
- if !gjson.GetBytes(out, "tools.0.parameters.properties.choice_field.oneOf").Exists() {
+ if !ojson.GetBytes(out, "tools.0.parameters.properties.choice_field.oneOf").Exists() {
t.Errorf("NormalizeCodexToolIntegerTypes should not modify oneOf")
}
})
t.Run("nil or empty headers leaves payload untouched", func(t *testing.T) {
outNil := NormalizeCodexToolIntegerTypes(input, nil)
- if got := gjson.GetBytes(outNil, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(outNil, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected nil headers to preserve number, got: %s", got)
}
outEmpty := NormalizeCodexToolIntegerTypes(input, http.Header{})
- if got := gjson.GetBytes(outEmpty, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(outEmpty, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected empty headers to preserve number, got: %s", got)
}
})
@@ -1182,8 +1182,8 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}`)
headers := http.Header{"User-Agent": []string{"codex-tui/0.154.0"}}
out := NormalizeCodexToolIntegerTypes(mcpInput, headers)
- toolMap := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(out, "tools").Array() {
+ toolMap := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytes(out, "tools").Array() {
toolMap[tool.Get("name").String()] = tool
}
// mcp__server__sleep must remain number
@@ -1224,7 +1224,7 @@ func TestNormalizeCodexToolIntegerTypes_CodexClientToolFieldTypes(t *testing.T)
}`)
headers := http.Header{"User-Agent": []string{"codex-desktop/0.159.0"}}
out := NormalizeCodexToolIntegerTypes(geminiInput, headers)
- if got := gjson.GetBytes(out, "tools.0.functionDeclarations.0.parametersJsonSchema.properties.yield_time_ms.type").String(); got != "integer" {
+ if got := ojson.GetBytes(out, "tools.0.functionDeclarations.0.parametersJsonSchema.properties.yield_time_ms.type").String(); got != "integer" {
t.Errorf("gemini parametersJsonSchema yield_time_ms type = %q, want integer", got)
}
})
@@ -1246,7 +1246,7 @@ func TestNormalizeCodexToolSchemas_DoesNotNormalizeIntegerTypes(t *testing.T) {
]
}`)
out := NormalizeCodexToolSchemas(input)
- if got := gjson.GetBytes(out, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
+ if got := ojson.GetBytes(out, "tools.0.parameters.properties.yield_time_ms.type").String(); got != "number" {
t.Fatalf("expected number, got: %s", got)
}
}
@@ -1333,7 +1333,7 @@ func TestNormalizeCodexToolSchemas_StripsOutOfRangeInt64Bounds(t *testing.T) {
}`)
out := NormalizeCodexToolSchemas(input)
- params := gjson.GetBytes(out, "tools.0.parameters")
+ params := ojson.GetBytes(out, "tools.0.parameters")
optsProps := params.Get("properties.options.properties")
// Issue #6579 reproduction: u64::MAX bound dropped, minimum: 0 and type preserved.
diff --git a/internal/runtime/executor/helps/configuration_update_plugin_test.go b/internal/runtime/executor/helps/configuration_update_plugin_test.go
index 805425ef34f..eceefc43882 100644
--- a/internal/runtime/executor/helps/configuration_update_plugin_test.go
+++ b/internal/runtime/executor/helps/configuration_update_plugin_test.go
@@ -5,6 +5,7 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
helps "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -13,8 +14,6 @@ import (
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type editUpdatePluginHooks struct {
@@ -26,10 +25,10 @@ type editUpdatePluginHooks struct {
func (h *editUpdatePluginHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte {
h.calls++
if h.effort == "" {
- out, _ := sjson.DeleteBytes(body, "input.0")
+ out, _ := ojson.DeleteBytes(body, "input.0")
return out
}
- out, _ := sjson.SetBytes(body, "input.0.reasoning.effort", h.effort)
+ out, _ := ojson.SetBytes(body, "input.0.reasoning.effort", h.effort)
return out
}
@@ -51,10 +50,10 @@ func TestPluginConfigurationUpdateNormalizationIsAuthoritative(t *testing.T) {
if errApply != nil {
t.Fatal(errApply)
}
- if got := gjson.GetBytes(body, "reasoning.effort").String(); got != tc.want {
+ if got := ojson.GetBytes(body, "reasoning.effort").String(); got != tc.want {
t.Fatalf("effort = %q, want %q; body=%s", got, tc.want, body)
}
- if len(gjson.GetBytes(body, "input").Array()) != 1 {
+ if len(ojson.GetBytes(body, "input").Array()) != 1 {
t.Fatalf("unexpected input: %s", body)
}
})
@@ -107,7 +106,7 @@ func TestNativeCrossProtocolTranslationWithoutPluginRetainsSourceUpdate(t *testi
if errApply != nil {
t.Fatal(errApply)
}
- if got := gjson.GetBytes(body, "reasoning_effort").String(); got != "high" {
+ if got := ojson.GetBytes(body, "reasoning_effort").String(); got != "high" {
t.Fatalf("cross-protocol effort = %q, want high; body=%s", got, body)
}
}
diff --git a/internal/runtime/executor/helps/devin_models.go b/internal/runtime/executor/helps/devin_models.go
index b9701fc0b79..23861a83c01 100644
--- a/internal/runtime/executor/helps/devin_models.go
+++ b/internal/runtime/executor/helps/devin_models.go
@@ -126,7 +126,10 @@ func ResolveDevinChatModelUID(rawModel string, thinkingLevel string, budgetToken
thinkingLevel = strings.TrimSpace(cleanModel[colonIdx+1:])
}
- // 4. Normalize requested effort
+ // 4. Normalize requested effort. Compound "-fast" requests select upstream
+ // fast-mode variants (see step 10); NormalizeThinkingLevel cannot represent them,
+ // so keep the raw level alongside the normalized effort.
+ rawLevel := strings.ToLower(strings.TrimSpace(thinkingLevel))
effort := NormalizeThinkingLevel(thinkingLevel, budgetTokens)
lowerBase := strings.ToLower(baseModel)
canonicalBase := strings.ReplaceAll(lowerBase, ".", "-")
@@ -239,9 +242,20 @@ func ResolveDevinChatModelUID(rawModel string, thinkingLevel string, budgetToken
return canonicalBase
}
- // 10. Model has thinking levels: determine default effort and clamp
+ // 10. Fast-mode variants: a strict "-fast" request keeps its level
+ // clamped to the catalog, never to the plain default.
+ if base, isFast := strings.CutSuffix(rawLevel, "-fast"); isFast && devinLevelIndex(base) != -1 {
+ defaultEffort := selectDefaultDevinEffort(canonicalBase, allowedLevels)
+ clamped := clampEffort(base, allowedLevels, defaultEffort)
+ return canonicalBase + "-" + clamped + "-fast"
+ }
+
+ // 11. Model has thinking levels: determine default effort and clamp
defaultEffort := selectDefaultDevinEffort(canonicalBase, allowedLevels)
clamped := clampEffort(effort, allowedLevels, defaultEffort)
+ if effort == "fast" {
+ return canonicalBase + "-" + clamped + "-fast"
+ }
return canonicalBase + "-" + clamped
}
diff --git a/internal/runtime/executor/helps/devin_models_test.go b/internal/runtime/executor/helps/devin_models_test.go
index 21c8a874f6d..4d950c51f66 100644
--- a/internal/runtime/executor/helps/devin_models_test.go
+++ b/internal/runtime/executor/helps/devin_models_test.go
@@ -20,6 +20,35 @@ func TestResolveDevinChatModelUID(t *testing.T) {
rawModel: "claude-fable-5-1-max",
want: "claude-fable-5-1-max",
},
+ {
+ name: "direct swe-2-flash-high unchanged",
+ rawModel: "swe-2-flash-high",
+ want: "swe-2-flash-high",
+ },
+ {
+ name: "swe-2-flash default to high when no effort",
+ rawModel: "swe-2-flash",
+ thinkingLevel: "",
+ want: "swe-2-flash-high",
+ },
+ {
+ name: "swe-2-flash with medium effort",
+ rawModel: "swe-2-flash",
+ thinkingLevel: "medium",
+ want: "swe-2-flash-medium",
+ },
+ {
+ name: "swe-2-flash with max effort suffix",
+ rawModel: "swe-2-flash(max)",
+ thinkingLevel: "",
+ want: "swe-2-flash-max",
+ },
+ {
+ name: "devin/swe-2-flash with colon medium",
+ rawModel: "devin/swe-2-flash:medium",
+ thinkingLevel: "",
+ want: "swe-2-flash-medium",
+ },
{
name: "direct swe-2-high unchanged",
rawModel: "swe-2-high",
@@ -325,6 +354,54 @@ func TestResolveDevinChatModelUID(t *testing.T) {
thinkingLevel: "",
want: "claude-opus-4-8-medium",
},
+ {
+ name: "claude-opus-5-5 with high-fast suffix maps to high-fast",
+ rawModel: "devin/claude-opus-5-5(high-fast)",
+ thinkingLevel: "",
+ want: "claude-opus-5-5-high-fast",
+ },
+ {
+ name: "claude-opus-5-5 with max-fast suffix maps to max-fast",
+ rawModel: "devin/claude-opus-5-5(max-fast)",
+ thinkingLevel: "",
+ want: "claude-opus-5-5-max-fast",
+ },
+ {
+ name: "claude-opus-5-5 with body high-fast effort maps to high-fast",
+ rawModel: "devin/claude-opus-5-5",
+ thinkingLevel: "high-fast",
+ want: "claude-opus-5-5-high-fast",
+ },
+ {
+ name: "claude-opus-5-5 with fast suffix maps to default effort fast",
+ rawModel: "devin/claude-opus-5-5(fast)",
+ thinkingLevel: "",
+ want: "claude-opus-5-5-medium-fast",
+ },
+ {
+ name: "claude-opus-5-5 with body fast effort maps to default effort fast",
+ rawModel: "devin/claude-opus-5-5",
+ thinkingLevel: "fast",
+ want: "claude-opus-5-5-medium-fast",
+ },
+ {
+ name: "claude-opus-5-5 with high suffix still maps to high",
+ rawModel: "devin/claude-opus-5-5(high)",
+ thinkingLevel: "",
+ want: "claude-opus-5-5-high",
+ },
+ {
+ name: "claude-haiku-4-5 with high-fast suffix still maps to private alias",
+ rawModel: "devin/claude-haiku-4-5(high-fast)",
+ thinkingLevel: "",
+ want: "MODEL_PRIVATE_11",
+ },
+ {
+ name: "swe-2-lightning with high-fast suffix still maps to high-lightning",
+ rawModel: "devin/swe-2-lightning(high-fast)",
+ thinkingLevel: "",
+ want: "swe-2-high-lightning",
+ },
{
name: "claude-opus-4-7 default maps to medium",
rawModel: "devin/claude-opus-4-7",
diff --git a/internal/runtime/executor/helps/devin_payload.go b/internal/runtime/executor/helps/devin_payload.go
index 19d2b75f016..6ab117e2b5d 100644
--- a/internal/runtime/executor/helps/devin_payload.go
+++ b/internal/runtime/executor/helps/devin_payload.go
@@ -2,13 +2,13 @@ package helps
import (
"bytes"
- "encoding/base64"
"encoding/json"
"fmt"
+ "github.com/MoeForge/moego/base64"
"math"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -63,7 +63,7 @@ var devinPayloadFields = map[protowire.Number]devinPayloadField{
// DevinPayloadDefaultsSource maps caller field presence to the native business
// view so defaults can replace built-in values without replacing caller values.
func DevinPayloadDefaultsSource(native, interactions []byte) []byte {
- out := []byte(`{}`)
+ doc := ojson.NewDocString("{}")
for target, sources := range map[string][]string{
"model": {"model"},
"system_prompt": {"system_instruction", "systemInstruction"},
@@ -75,9 +75,9 @@ func DevinPayloadDefaultsSource(native, interactions []byte) []byte {
"completion_config.top_k": {"generation_config.top_k", "generationConfig.top_k"},
"completion_config.top_p": {"generation_config.top_p", "generationConfig.top_p"},
} {
- var value gjson.Result
+ var value ojson.Result
for _, source := range sources {
- value = gjson.GetBytes(interactions, source)
+ value = ojson.GetBytesNoCopy(interactions, source)
if value.Exists() {
break
}
@@ -86,13 +86,13 @@ func DevinPayloadDefaultsSource(native, interactions []byte) []byte {
continue
}
if target == "prompts" || target == "tools" {
- value = gjson.GetBytes(native, target)
+ value = ojson.GetBytesNoCopy(native, target)
}
if value.Exists() {
- out, _ = sjson.SetRawBytes(out, target, []byte(value.Raw))
+ _ = doc.SetRaw(target, value.Raw)
}
}
- return out
+ return doc.Bytes()
}
// FinalizeDevinPayload projects the already-normalized protobuf business fields
@@ -106,12 +106,14 @@ func FinalizeDevinPayload(wire []byte, finalize PayloadFinalizer) ([]byte, []byt
if errMarshal != nil {
return nil, nil, errMarshal
}
- configured := finalize(body)
+ doc := ojson.NewDoc(body)
+ finalize.FinalizeDoc(doc)
+ configured := doc.Bytes()
if bytes.Equal(configured, body) {
return wire, configured, nil
}
body = configured
- out, errEncode := encodeDevinPayload(wire, gjson.ParseBytes(body), devinPayloadFields)
+ out, errEncode := encodeDevinPayload(wire, util.ParseGJSONBytesNoCopy(body), devinPayloadFields)
return out, body, errEncode
}
@@ -165,7 +167,7 @@ func decodeDevinPayload(wire []byte, fields map[protowire.Number]devinPayloadFie
return out, nil
}
-func encodeDevinPayload(original []byte, body gjson.Result, fields map[protowire.Number]devinPayloadField) ([]byte, error) {
+func encodeDevinPayload(original []byte, body ojson.Result, fields map[protowire.Number]devinPayloadField) ([]byte, error) {
var out []byte
// Credentials, device metadata, thread ordinals, and protocol flags stay opaque.
for len(original) > 0 {
@@ -188,10 +190,10 @@ func encodeDevinPayload(original []byte, body gjson.Result, fields map[protowire
continue
}
value := body.Get(field.name)
- if !value.Exists() || value.Type == gjson.Null {
+ if !value.Exists() || value.Type == ojson.Null {
continue
}
- values := []gjson.Result{value}
+ values := []ojson.Result{value}
if field.repeated {
values = value.Array()
}
diff --git a/internal/runtime/executor/helps/devin_wire.go b/internal/runtime/executor/helps/devin_wire.go
index ee53ae4ad6c..ecebd412d22 100644
--- a/internal/runtime/executor/helps/devin_wire.go
+++ b/internal/runtime/executor/helps/devin_wire.go
@@ -5,11 +5,11 @@ import (
"compress/gzip"
"crypto/rand"
"crypto/sha256"
- "encoding/base64"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"math"
"net/http"
diff --git a/internal/runtime/executor/helps/devin_wire_test.go b/internal/runtime/executor/helps/devin_wire_test.go
index 5d06ad8bbc9..176f884bc34 100644
--- a/internal/runtime/executor/helps/devin_wire_test.go
+++ b/internal/runtime/executor/helps/devin_wire_test.go
@@ -990,3 +990,49 @@ func TestRegressionIssue5910_ToolCallDeltaFields(t *testing.T) {
t.Errorf("tc.IsCustomToolCall = %v, want true", tc.IsCustomToolCall)
}
}
+
+func TestBuildDevinGetChatMessageRequest_ObfuscatesEmbeddedExecDescriptions(t *testing.T) {
+ tools := []DevinTool{
+ {
+ Name: "functions__exec",
+ Description: "Run JavaScript code to orchestrate/compose tool calls\n" +
+ "### exec_command\nRuns a command in a PTY, returning output or a session ID for ongoing interaction.\n" +
+ "### write_stdin\nWrites characters to an existing unified exec session and returns recent output.\n",
+ },
+ }
+
+ req := BuildDevinGetChatMessageRequest(
+ "token-123",
+ "device-seed-1",
+ "swe-2",
+ "system prompt",
+ nil,
+ tools,
+ nil,
+ 1000,
+ "session-1",
+ "cascade-1",
+ nil,
+ )
+ logBody := BuildDevinUpstreamLogBody(
+ nil,
+ false,
+ "swe-2",
+ "system prompt",
+ nil,
+ tools,
+ nil,
+ 1000,
+ "session-1",
+ "cascade-1",
+ )
+
+ for label, body := range map[string]string{"wire": string(req), "log": string(logBody)} {
+ if strings.Contains(body, "a session ID") || strings.Contains(body, "to an existing unified") {
+ t.Fatalf("%s body should not contain the original exec phrases: %s", label, body)
+ }
+ if !strings.Contains(body, "an session ID") || !strings.Contains(body, "to a existing unified") {
+ t.Fatalf("%s body should contain the obfuscated exec phrases: %s", label, body)
+ }
+ }
+}
diff --git a/internal/runtime/executor/helps/gemini_content_turns.go b/internal/runtime/executor/helps/gemini_content_turns.go
index 1aa2bedc332..cc962ffa9e8 100644
--- a/internal/runtime/executor/helps/gemini_content_turns.go
+++ b/internal/runtime/executor/helps/gemini_content_turns.go
@@ -1,10 +1,8 @@
package helps
import (
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
var emptyGeminiUserTurnJSON = []byte(`{"role":"user","parts":[{"text":""}]}`)
@@ -12,17 +10,33 @@ var emptyGeminiUserTurnJSON = []byte(`{"role":"user","parts":[{"text":""}]}`)
// EnsureGeminiLeadingUserContent ensures that the contents array at the given path
// starts with a user turn when sending to Gemini/Antigravity upstreams.
func EnsureGeminiLeadingUserContent(payload []byte, path string) []byte {
- firstRole := gjson.GetBytes(payload, path+".0.role")
- if firstRole.String() != "model" {
+ if len(payload) == 0 {
return payload
}
- contents := util.GetGJSONBytesNoCopy(payload, path)
- if !contents.IsArray() {
+ doc := ojson.NewDoc(payload)
+ if !EnsureGeminiLeadingUserContentDoc(doc, path) {
return payload
}
+ return doc.Bytes()
+}
+
+// EnsureGeminiLeadingUserContentDoc ensures that the contents array at the given path
+// starts with a user turn in place on doc.
+func EnsureGeminiLeadingUserContentDoc(doc *ojson.Doc, path string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ firstRole := doc.Get(path + ".0.role")
+ if firstRole.String() != "model" {
+ return false
+ }
+ contents := doc.Get(path)
+ if !contents.IsArray() {
+ return false
+ }
contentArray := contents.Array()
if len(contentArray) == 0 {
- return payload
+ return false
}
contentItems := make([][]byte, 0, len(contentArray)+1)
@@ -31,14 +45,10 @@ func EnsureGeminiLeadingUserContent(payload []byte, path string) []byte {
contentItems = append(contentItems, []byte(content.Raw))
}
- out, errSet := sjson.SetRawBytes(payload, path, translatorcommon.JoinRawArray(contentItems))
- if errSet != nil {
- return payload
- }
- return out
+ return doc.SetRawBytes(path, translatorcommon.JoinRawArray(contentItems)) == nil
}
-func contentHasFunctionResponse(content gjson.Result) bool {
+func contentHasFunctionResponse(content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return false
@@ -53,7 +63,7 @@ func contentHasFunctionResponse(content gjson.Result) bool {
// contentHasFunctionResponseMedia reports whether any functionResponse in the
// content carries nested media parts (inlineData/fileData).
-func contentHasFunctionResponseMedia(content gjson.Result) bool {
+func contentHasFunctionResponseMedia(content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return false
@@ -76,21 +86,37 @@ func contentHasFunctionResponseMedia(content gjson.Result) bool {
// A model-role functionResponse turn carrying nested media is the exception:
// Antigravity rejects it as the final turn with 400 INVALID_ARGUMENT (#6524).
func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte {
- contents := util.GetGJSONBytesNoCopy(payload, path)
- if !contents.IsArray() {
+ if len(payload) == 0 {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !EnsureGeminiTrailingUserContentDoc(doc, path) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// EnsureGeminiTrailingUserContentDoc ensures that the contents array at the given path
+// ends with a user turn in place on doc.
+func EnsureGeminiTrailingUserContentDoc(doc *ojson.Doc, path string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ contents := doc.Get(path)
+ if !contents.IsArray() {
+ return false
+ }
contentArray := contents.Array()
if len(contentArray) == 0 {
- return payload
+ return false
}
lastContent := contentArray[len(contentArray)-1]
lastRole := lastContent.Get("role").String()
if lastRole != "model" && lastRole != "assistant" {
- return payload
+ return false
}
if contentHasFunctionResponse(lastContent) && !contentHasFunctionResponseMedia(lastContent) {
- return payload
+ return false
}
contentItems := make([][]byte, 0, len(contentArray)+1)
@@ -99,11 +125,7 @@ func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte {
}
contentItems = append(contentItems, emptyGeminiUserTurnJSON)
- out, errSet := sjson.SetRawBytes(payload, path, translatorcommon.JoinRawArray(contentItems))
- if errSet != nil {
- return payload
- }
- return out
+ return doc.SetRawBytes(path, translatorcommon.JoinRawArray(contentItems)) == nil
}
// DropGeminiTrailingThoughtOnlyContent removes trailing model turns made only of
@@ -112,31 +134,43 @@ func EnsureGeminiTrailingUserContent(payload []byte, path string) []byte {
// empty user turn, which did not prevent Antigravity (#6530) or Vertex (#6630)
// from rejecting the request as ending with a model turn.
func DropGeminiTrailingThoughtOnlyContent(payload []byte, path string) []byte {
- contents := util.GetGJSONBytesNoCopy(payload, path)
- if !contents.IsArray() {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !DropGeminiTrailingThoughtOnlyContentDoc(doc, path) {
return payload
}
+ return doc.Bytes()
+}
+
+// DropGeminiTrailingThoughtOnlyContentDoc removes trailing model turns made only of
+// thought parts in place on doc, keeping at least one turn.
+func DropGeminiTrailingThoughtOnlyContentDoc(doc *ojson.Doc, path string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ contents := doc.Get(path)
+ if !contents.IsArray() {
+ return false
+ }
contentArray := contents.Array()
keep := len(contentArray)
for keep > 1 && contentIsThoughtOnlyModelTurn(contentArray[keep-1]) {
keep--
}
if keep == len(contentArray) {
- return payload
+ return false
}
contentItems := make([][]byte, 0, keep)
for _, content := range contentArray[:keep] {
contentItems = append(contentItems, []byte(content.Raw))
}
- out, errSet := sjson.SetRawBytes(payload, path, translatorcommon.JoinRawArray(contentItems))
- if errSet != nil {
- return payload
- }
- return out
+ return doc.SetRawBytes(path, translatorcommon.JoinRawArray(contentItems)) == nil
}
-func contentIsThoughtOnlyModelTurn(content gjson.Result) bool {
+func contentIsThoughtOnlyModelTurn(content ojson.Result) bool {
role := content.Get("role").String()
if role != "model" && role != "assistant" {
return false
@@ -156,6 +190,20 @@ func contentIsThoughtOnlyModelTurn(content gjson.Result) bool {
// EnsureGeminiBoundaryUserContent ensures that the contents array at the given path
// both starts and ends with a user turn when sending to Gemini/Antigravity upstreams.
func EnsureGeminiBoundaryUserContent(payload []byte, path string) []byte {
- payload = EnsureGeminiLeadingUserContent(payload, path)
- return EnsureGeminiTrailingUserContent(payload, path)
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !EnsureGeminiBoundaryUserContentDoc(doc, path) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// EnsureGeminiBoundaryUserContentDoc ensures that the contents array at the given path
+// both starts and ends with a user turn in place on doc.
+func EnsureGeminiBoundaryUserContentDoc(doc *ojson.Doc, path string) bool {
+ leading := EnsureGeminiLeadingUserContentDoc(doc, path)
+ trailing := EnsureGeminiTrailingUserContentDoc(doc, path)
+ return leading || trailing
}
diff --git a/internal/runtime/executor/helps/gemini_content_turns_test.go b/internal/runtime/executor/helps/gemini_content_turns_test.go
index 8d01548eecc..f667d43d7c9 100644
--- a/internal/runtime/executor/helps/gemini_content_turns_test.go
+++ b/internal/runtime/executor/helps/gemini_content_turns_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
var (
@@ -82,7 +82,7 @@ func TestEnsureGeminiLeadingUserContent(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := EnsureGeminiLeadingUserContent([]byte(tt.inputJSON), tt.path)
- contents := gjson.GetBytes(out, tt.path).Array()
+ contents := ojson.GetBytes(out, tt.path).Array()
roles := make([]string, 0, len(contents))
for _, content := range contents {
roles = append(roles, content.Get("role").String())
@@ -92,7 +92,7 @@ func TestEnsureGeminiLeadingUserContent(t *testing.T) {
t.Fatalf("roles = %q, want %q; output=%s", got, expectedRoles, out)
}
if tt.wantLeadingEmpty {
- text := gjson.GetBytes(out, tt.path+".0.parts.0.text")
+ text := ojson.GetBytes(out, tt.path+".0.parts.0.text")
if !text.Exists() || text.String() != "" {
t.Fatalf("leading empty user part missing; output=%s", out)
}
@@ -197,7 +197,7 @@ func TestEnsureGeminiTrailingUserContent(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := EnsureGeminiTrailingUserContent([]byte(tt.inputJSON), tt.path)
- contents := gjson.GetBytes(out, tt.path).Array()
+ contents := ojson.GetBytes(out, tt.path).Array()
roles := make([]string, 0, len(contents))
for _, content := range contents {
roles = append(roles, content.Get("role").String())
@@ -221,7 +221,7 @@ func TestEnsureGeminiBoundaryUserContent(t *testing.T) {
// A single model turn should get both leading and trailing user turns
inputJSON := `{"contents":[{"role":"model","parts":[{"text":"single answer"}]}]}`
out := EnsureGeminiBoundaryUserContent([]byte(inputJSON), "contents")
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytes(out, "contents").Array()
if len(contents) != 3 {
t.Fatalf("contents len = %d, want 3; out=%s", len(contents), out)
}
@@ -271,7 +271,7 @@ func TestDropGeminiTrailingThoughtOnlyContent(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := DropGeminiTrailingThoughtOnlyContent([]byte(tt.input), "contents")
- if got := gjson.GetBytes(out, "contents").Raw; got != tt.want {
+ if got := ojson.GetBytes(out, "contents").Raw; got != tt.want {
t.Fatalf("contents = %s, want %s", got, tt.want)
}
})
diff --git a/internal/runtime/executor/helps/gemini_interactions.go b/internal/runtime/executor/helps/gemini_interactions.go
index ee20f5b1f17..e4b67c7a7be 100644
--- a/internal/runtime/executor/helps/gemini_interactions.go
+++ b/internal/runtime/executor/helps/gemini_interactions.go
@@ -7,12 +7,11 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -32,15 +31,31 @@ const (
// other endpoints reject Vertex bare-Tink thought signatures, so those thought
// steps are dropped like any other incompatible signature.
func SanitizeGeminiInteractionsUnsupportedInputIDs(body []byte, allowVertexBareTink bool) []byte {
- input := gjson.GetBytes(body, "input")
- if !input.IsArray() {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !SanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc, allowVertexBareTink) {
return body
}
+ return doc.Bytes()
+}
+
+// SanitizeGeminiInteractionsUnsupportedInputIDsDoc aligns input step IDs with the
+// official Gemini Interactions API schema in place on doc.
+func SanitizeGeminiInteractionsUnsupportedInputIDsDoc(doc *ojson.Doc, allowVertexBareTink bool) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
items := input.Array()
- kept := make([]string, 0, len(items))
+ dropped := make(map[int]bool)
changed := false
- for _, item := range items {
- rawItem := item.Raw
+ for idx, item := range items {
+ stepPath := fmt.Sprintf("input.%d", idx)
stepType := item.Get("type").String()
if stepType == "thought" {
sig := strings.TrimSpace(item.Get("signature").String())
@@ -62,63 +77,65 @@ func SanitizeGeminiInteractionsUnsupportedInputIDs(body []byte, allowVertexBareT
// Official Gemini Interactions API rejects thought steps without a
// valid Gemini signature (or with foreign/corrupt base64 signatures
// or "content" arrays).
+ dropped[idx] = true
changed = true
continue
}
if normalizedSig != item.Get("signature").String() {
- rawItem, _ = sjson.Set(rawItem, "signature", normalizedSig)
- changed = true
+ if errSet := doc.Set(stepPath+".signature", normalizedSig); errSet == nil {
+ changed = true
+ }
}
for _, unsupportedField := range []string{"id", "content", "text", "thought_signature", "thoughtSignature"} {
- if gjson.Get(rawItem, unsupportedField).Exists() {
- rawItem, _ = sjson.Delete(rawItem, unsupportedField)
- changed = true
+ if item.Get(unsupportedField).Exists() {
+ if errDel := doc.Delete(stepPath + "." + unsupportedField); errDel == nil {
+ changed = true
+ }
}
}
- kept = append(kept, rawItem)
continue
}
if stepType == "function_call" {
if !item.Get("id").Exists() && item.Get("call_id").Exists() {
- rawItem, _ = sjson.Set(rawItem, "id", item.Get("call_id").String())
- changed = true
+ if errSet := doc.Set(stepPath+".id", item.Get("call_id").String()); errSet == nil {
+ changed = true
+ }
}
if item.Get("call_id").Exists() {
- rawItem, _ = sjson.Delete(rawItem, "call_id")
- changed = true
+ if errDel := doc.Delete(stepPath + ".call_id"); errDel == nil {
+ changed = true
+ }
}
} else if item.Get("id").Exists() {
- rawItem, _ = sjson.Delete(rawItem, "id")
- changed = true
+ if errDel := doc.Delete(stepPath + ".id"); errDel == nil {
+ changed = true
+ }
}
- content := gjson.Get(rawItem, "content")
+ content := item.Get("content")
if content.IsArray() {
for j, part := range content.Array() {
if part.Get("id").Exists() {
- rawItem, _ = sjson.Delete(rawItem, fmt.Sprintf("content.%d.id", j))
- changed = true
+ if errDel := doc.Delete(fmt.Sprintf("%s.content.%d.id", stepPath, j)); errDel == nil {
+ changed = true
+ }
}
}
}
- kept = append(kept, rawItem)
}
if !changed {
- return body
+ return false
}
- var b strings.Builder
- b.WriteByte('[')
- for i, raw := range kept {
- if i > 0 {
- b.WriteByte(',')
+ if len(dropped) > 0 {
+ updatedItems := doc.Get("input").Array()
+ kept := make([][]byte, 0, len(updatedItems)-len(dropped))
+ for idx, item := range updatedItems {
+ if !dropped[idx] {
+ kept = append(kept, []byte(item.Raw))
+ }
}
- b.WriteString(raw)
+ return doc.SetRawBytes("input", JoinRawJSONArray(kept)) == nil
}
- b.WriteByte(']')
- updated, err := sjson.SetRawBytes(body, "input", []byte(b.String()))
- if err != nil {
- return body
- }
- return updated
+ return true
}
// TranslateGeminiInteractionsRequestBody translates a request payload to FormatInteractions.
@@ -174,6 +191,15 @@ func ApplyGeminiInteractionsThinking(body []byte, req cliproxyexecutor.Request,
return ApplyRequestThinking(body, req, opts, fromFormat, sdktranslator.FormatInteractions.String(), "gemini")
}
+// ApplyGeminiInteractionsThinkingDoc applies thinking/reasoning configuration to doc in place.
+func ApplyGeminiInteractionsThinkingDoc(doc *ojson.Doc, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) error {
+ fromFormat := opts.SourceFormat.String()
+ if strings.TrimSpace(fromFormat) == "" {
+ fromFormat = sdktranslator.FormatInteractions.String()
+ }
+ return ApplyRequestThinkingDoc(doc, req, opts, fromFormat, sdktranslator.FormatInteractions.String(), "gemini")
+}
+
// ApplyGeminiInteractionsRevisionHeader sets the default Api-Revision header on outgoing HTTP requests.
func ApplyGeminiInteractionsRevisionHeader(req *http.Request) {
if req == nil {
diff --git a/internal/runtime/executor/helps/gemini_interactions_test.go b/internal/runtime/executor/helps/gemini_interactions_test.go
index a51e9b87f98..f27b42353cb 100644
--- a/internal/runtime/executor/helps/gemini_interactions_test.go
+++ b/internal/runtime/executor/helps/gemini_interactions_test.go
@@ -1,10 +1,10 @@
package helps
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func testVertexBareTinkInteractionsSignature() string {
@@ -20,12 +20,12 @@ func TestSanitizeGeminiInteractionsUnsupportedInputIDs_ScopesVertexBareTink(t *t
body := []byte(`{"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","signature":"` + sig + `"}]}`)
public := SanitizeGeminiInteractionsUnsupportedInputIDs(body, false)
- if gjson.GetBytes(public, `input.#(type=="thought")`).Exists() {
+ if ojson.GetBytes(public, `input.#(type=="thought")`).Exists() {
t.Fatalf("public Interactions endpoint must drop the Vertex bare-Tink thought: %s", public)
}
vertex := SanitizeGeminiInteractionsUnsupportedInputIDs(body, true)
- if got := gjson.GetBytes(vertex, `input.#(type=="thought").signature`).String(); got != sig {
+ if got := ojson.GetBytes(vertex, `input.#(type=="thought").signature`).String(); got != sig {
t.Fatalf("Vertex Interactions endpoint must keep the bare-Tink signature, got %q", got)
}
}
diff --git a/internal/runtime/executor/helps/gemini_stream_terminal.go b/internal/runtime/executor/helps/gemini_stream_terminal.go
index b5e16b28a7e..161db1ff746 100644
--- a/internal/runtime/executor/helps/gemini_stream_terminal.go
+++ b/internal/runtime/executor/helps/gemini_stream_terminal.go
@@ -4,7 +4,7 @@ import (
"bytes"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsGeminiTerminalStreamChunk recognizes terminal output from Gemini stream translators.
@@ -18,7 +18,7 @@ func IsGeminiTerminalStreamChunk(chunk []byte) bool {
if len(payload) == 0 {
continue
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
if eventType == "response.completed" || eventType == "response.incomplete" || eventType == "response.done" || eventType == "message_stop" {
return true
}
@@ -28,7 +28,7 @@ func IsGeminiTerminalStreamChunk(chunk []byte) bool {
"candidates.0.finishReason",
"response.candidates.0.finishReason",
} {
- if finishReason := gjson.GetBytes(payload, path); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
+ if finishReason := ojson.GetBytesNoCopy(payload, path); finishReason.Exists() && strings.TrimSpace(finishReason.String()) != "" {
return true
}
}
diff --git a/internal/runtime/executor/helps/gemini_stream_tool_usage.go b/internal/runtime/executor/helps/gemini_stream_tool_usage.go
index 08e283880a4..be97daaf0d5 100644
--- a/internal/runtime/executor/helps/gemini_stream_tool_usage.go
+++ b/internal/runtime/executor/helps/gemini_stream_tool_usage.go
@@ -4,8 +4,8 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
)
type geminiFunctionCallCounter struct {
@@ -15,7 +15,7 @@ type geminiFunctionCallCounter struct {
// observePart counts logical Gemini/Vertex function calls, deduplicating streamed
// partialArgs continuations, repeated call IDs, and empty separator objects.
-func (c *geminiFunctionCallCounter) observePart(candidateIdx int64, call gjson.Result) int64 {
+func (c *geminiFunctionCallCounter) observePart(candidateIdx int64, call ojson.Result) int64 {
if c == nil || !call.Exists() || !call.IsObject() {
return 0
}
@@ -23,7 +23,7 @@ func (c *geminiFunctionCallCounter) observePart(candidateIdx int64, call gjson.R
upstreamID := strings.TrimSpace(call.Get("id").String())
if active := c.activeByCandidate[candidateIdx]; active != nil {
- ownedCall := gjson.Parse(strings.Clone(call.Raw))
+ ownedCall := ojson.Parse(strings.Clone(call.Raw))
if active.CanContinueWith(ownedCall) {
_, done := active.Assembler.PushFunctionCall(ownedCall)
if active.Assembler.Err() != nil || done {
@@ -39,7 +39,7 @@ func (c *geminiFunctionCallCounter) observePart(candidateIdx int64, call gjson.R
if rawName == "" {
return 0
}
- ownCall := gjson.Parse(strings.Clone(call.Raw))
+ ownCall := ojson.Parse(strings.Clone(call.Raw))
clonedName := strings.Clone(rawName)
clonedID := strings.Clone(upstreamID)
active := translatorcommon.NewVertexStreamingToolCall(clonedName, clonedName, clonedID, clonedID, 0)
diff --git a/internal/runtime/executor/helps/gemini_ttft_helpers.go b/internal/runtime/executor/helps/gemini_ttft_helpers.go
index 9930777e028..1f5f8b074b0 100644
--- a/internal/runtime/executor/helps/gemini_ttft_helpers.go
+++ b/internal/runtime/executor/helps/gemini_ttft_helpers.go
@@ -3,7 +3,7 @@ package helps
import (
"bytes"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsGeminiTokenEvent reports whether a Google Gemini / Antigravity streaming chunk carries
@@ -28,13 +28,13 @@ func IsGeminiTokenEvent(payload []byte) bool {
}
// Terminal error fallback
- if gjson.GetBytes(payload, "error.message").Exists() || gjson.GetBytes(payload, "error").Exists() || gjson.GetBytes(payload, "response.error").Exists() {
+ if ojson.GetBytesNoCopy(payload, "error.message").Exists() || ojson.GetBytesNoCopy(payload, "error").Exists() || ojson.GetBytesNoCopy(payload, "response.error").Exists() {
return true
}
- candidatesNode := gjson.GetBytes(payload, "candidates")
+ candidatesNode := ojson.GetBytesNoCopy(payload, "candidates")
if !candidatesNode.Exists() {
- candidatesNode = gjson.GetBytes(payload, "response.candidates")
+ candidatesNode = ojson.GetBytesNoCopy(payload, "response.candidates")
}
candidates := candidatesNode.Array()
if len(candidates) == 0 {
@@ -51,7 +51,7 @@ func IsGeminiTokenEvent(payload []byte) bool {
if len(part.Get("thoughtText").String()) > 0 {
return true
}
- if thought := part.Get("thought"); thought.Type == gjson.String && len(thought.String()) > 0 {
+ if thought := part.Get("thought"); thought.Type == ojson.String && len(thought.String()) > 0 {
return true
}
if len(part.Get("functionCall.name").String()) > 0 {
diff --git a/internal/runtime/executor/helps/json_retry_helpers.go b/internal/runtime/executor/helps/json_retry_helpers.go
index e2b1412301d..2a234c5d1d4 100644
--- a/internal/runtime/executor/helps/json_retry_helpers.go
+++ b/internal/runtime/executor/helps/json_retry_helpers.go
@@ -7,8 +7,8 @@ import (
"strings"
"time"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// DeleteJSONField removes a top-level or nested JSON field from a payload.
@@ -16,16 +16,24 @@ func DeleteJSONField(body []byte, key string) []byte {
if key == "" || len(body) == 0 {
return body
}
- updated, err := sjson.DeleteBytes(body, key)
+ updated, err := util.DeleteJSONBytesIfExists(body, key)
if err != nil {
return body
}
return updated
}
+// DeleteJSONFieldDoc removes a top-level or nested JSON field from doc in place.
+func DeleteJSONFieldDoc(doc *ojson.Doc, key string) bool {
+ if key == "" || doc == nil || doc.Len() == 0 {
+ return false
+ }
+ return util.DeleteJSONDocIfExists(doc, key) == nil
+}
+
// ParseRetryDelay extracts the retry delay from a Google API 429 error response.
func ParseRetryDelay(errorBody []byte) (*time.Duration, error) {
- details := gjson.GetBytes(errorBody, "error.details")
+ details := ojson.GetBytesNoCopy(errorBody, "error.details")
if details.Exists() && details.IsArray() {
for _, detail := range details.Array() {
if detail.Get("@type").String() != "type.googleapis.com/google.rpc.RetryInfo" {
@@ -57,7 +65,7 @@ func ParseRetryDelay(errorBody []byte) (*time.Duration, error) {
}
}
- message := gjson.GetBytes(errorBody, "error.message").String()
+ message := ojson.GetBytesNoCopy(errorBody, "error.message").String()
if message != "" {
re := regexp.MustCompile(`after\s+(\d+)s\.?`)
if matches := re.FindStringSubmatch(message); len(matches) > 1 {
diff --git a/internal/runtime/executor/helps/kimi_responses.go b/internal/runtime/executor/helps/kimi_responses.go
index c7524d5f569..687cc28d067 100644
--- a/internal/runtime/executor/helps/kimi_responses.go
+++ b/internal/runtime/executor/helps/kimi_responses.go
@@ -4,11 +4,10 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
kimiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ResolveKimiBaseURL resolves the upstream API base URL for Kimi API requests based on auth.
@@ -55,12 +54,12 @@ func ResolveKimiClaudeBaseURL(auth *cliproxyauth.Auth) string {
return strings.TrimSuffix(baseURL, "/v1")
}
-func isResponsesToolCall(item gjson.Result) bool {
+func isResponsesToolCall(item ojson.Result) bool {
t := strings.TrimSpace(item.Get("type").String())
return t == "function_call" || t == "custom_tool_call"
}
-func isResponsesToolOutput(item gjson.Result) bool {
+func isResponsesToolOutput(item ojson.Result) bool {
t := strings.TrimSpace(item.Get("type").String())
return t == "function_call_output" || t == "custom_tool_call_output"
}
@@ -71,22 +70,38 @@ func isResponsesToolOutput(item gjson.Result) bool {
// This function defers intervening non-tool items until all outputs for the current batch
// of function calls have been emitted, keeping the outputs contiguous.
func NormalizeKimiResponsesInput(body []byte) ([]byte, error) {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return body, nil
}
+ doc := ojson.NewDoc(body)
+ changed, errNorm := NormalizeKimiResponsesInputDoc(doc)
+ if errNorm != nil {
+ return body, errNorm
+ }
+ if !changed {
+ return body, nil
+ }
+ return doc.Bytes(), nil
+}
- input := gjson.GetBytes(body, "input")
+// NormalizeKimiResponsesInputDoc normalizes the input array for Kimi Responses API requests in place on doc.
+func NormalizeKimiResponsesInputDoc(doc *ojson.Doc) (bool, error) {
+ if doc == nil || doc.Len() == 0 {
+ return false, nil
+ }
+
+ input := doc.Get("input")
if !input.Exists() || !input.IsArray() {
- return body, nil
+ return false, nil
}
items := input.Array()
if len(items) == 0 {
- return body, nil
+ return false, nil
}
reordered := false
- result := make([]gjson.Result, 0, len(items))
+ result := make([]ojson.Result, 0, len(items))
for i := 0; i < len(items); {
if !isResponsesToolCall(items[i]) {
@@ -138,8 +153,8 @@ func NormalizeKimiResponsesInput(body []byte) ([]byte, error) {
}
if remainingNeeded == 0 && lastMatchingIdx >= endCalls {
- matchingOutputs := make([]gjson.Result, 0, callIDCount)
- interveningItems := make([]gjson.Result, 0)
+ matchingOutputs := make([]ojson.Result, 0, callIDCount)
+ interveningItems := make([]ojson.Result, 0)
consumed := make(map[string]int, len(callIDs))
for k, v := range callIDs {
consumed[k] = v
@@ -172,7 +187,7 @@ func NormalizeKimiResponsesInput(body []byte) ([]byte, error) {
}
if !reordered {
- return body, nil
+ return false, nil
}
rebuilt := make([]string, 0, len(result))
@@ -180,9 +195,8 @@ func NormalizeKimiResponsesInput(body []byte) ([]byte, error) {
rebuilt = append(rebuilt, it.Raw)
}
- updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(rebuilt, ",")+"]"))
- if errSet != nil {
- return body, fmt.Errorf("normalize kimi responses input: %w", errSet)
+ if errSet := doc.SetRaw("input", "["+strings.Join(rebuilt, ",")+"]"); errSet != nil {
+ return false, fmt.Errorf("normalize kimi responses input: %w", errSet)
}
- return updated, nil
+ return true, nil
}
diff --git a/internal/runtime/executor/helps/kimi_responses_test.go b/internal/runtime/executor/helps/kimi_responses_test.go
index 396565d10ea..52f850575de 100644
--- a/internal/runtime/executor/helps/kimi_responses_test.go
+++ b/internal/runtime/executor/helps/kimi_responses_test.go
@@ -4,9 +4,9 @@ import (
"bytes"
"testing"
+ "github.com/MoeForge/moego/ojson"
kimiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
func TestResolveKimiResponsesURL(t *testing.T) {
@@ -161,7 +161,7 @@ func TestNormalizeKimiResponsesInput(t *testing.T) {
t.Fatalf("NormalizeKimiResponsesInput error = %v", errNormalize)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytes(normalized, "input").Array()
if len(items) != 5 {
t.Fatalf("expected 5 items, got %d: %s", len(items), string(normalized))
}
@@ -200,7 +200,7 @@ func TestNormalizeKimiResponsesInput(t *testing.T) {
t.Fatalf("NormalizeKimiResponsesInput error = %v", errNormalize)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytes(normalized, "input").Array()
if len(items) != 5 {
t.Fatalf("expected 5 items, got %d: %s", len(items), string(normalized))
}
@@ -270,7 +270,7 @@ func TestNormalizeKimiResponsesInput(t *testing.T) {
t.Fatalf("NormalizeKimiResponsesInput error = %v", errNormalize)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytes(normalized, "input").Array()
if len(items) != 9 {
t.Fatalf("expected 9 items, got %d: %s", len(items), string(normalized))
}
@@ -348,7 +348,7 @@ func TestNormalizeKimiResponsesInput(t *testing.T) {
if errNormalize != nil {
t.Fatalf("NormalizeKimiResponsesInput error = %v", errNormalize)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytes(normalized, "input").Array()
if len(items) != 3 {
t.Fatalf("expected 3 items, got %d", len(items))
}
diff --git a/internal/runtime/executor/helps/logging_helpers.go b/internal/runtime/executor/helps/logging_helpers.go
index cdf1264d16d..298d4276bdb 100644
--- a/internal/runtime/executor/helps/logging_helpers.go
+++ b/internal/runtime/executor/helps/logging_helpers.go
@@ -11,12 +11,12 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
const (
@@ -747,9 +747,9 @@ func extractHTMLTitle(body []byte) string {
// extractJSONErrorMessage attempts to extract error.message from JSON error responses
func extractJSONErrorMessage(body []byte) string {
- result := gjson.GetBytes(body, "error.message")
+ result := ojson.GetBytesNoCopy(body, "error.message")
if result.Exists() && result.String() != "" {
- return result.String()
+ return strings.Clone(result.String())
}
return ""
}
diff --git a/internal/runtime/executor/helps/meta_muse_tools.go b/internal/runtime/executor/helps/meta_muse_tools.go
new file mode 100644
index 00000000000..f5c42397c76
--- /dev/null
+++ b/internal/runtime/executor/helps/meta_muse_tools.go
@@ -0,0 +1,58 @@
+package helps
+
+import (
+ "github.com/MoeForge/moego/ojson"
+)
+
+// Captured from the Muse Code 1.4.12 reminder agent. Subscription-key tool
+// requests can require a canonical Muse namespace, even with current client
+// headers. Keep caller tools separate so their names and schemas are preserved.
+const metaMuseNamespace = `{"type":"namespace","name":"muse","description":"Muse Code tool set.","tools":[{"type":"function","name":"submit_reminder_decision","description":"Submit this reminder child's explicit decision.","parameters":{"additionalProperties":false,"properties":{"advisory_text":{"description":"Short text to deliver to the main agent when reminding.","type":["null","string"]},"confidence":{"description":"Confidence in the decision.","enum":["low","medium","high"],"type":["null","string"]},"decision":{"enum":["remind","none"],"type":"string"},"priority":{"description":"Requested reminder priority.","enum":["low","normal","high"],"type":["null","string"]},"reason":{"description":"Brief reason for reminding or staying silent.","type":"string"},"skill_id":{"description":"The listed skill id to remind when needs_reminder is true.","type":["null","string"]},"visible_for_steps":{"description":"Requested ephemeral visibility length in main model steps.","maximum":8,"minimum":1,"type":["null","integer"]}},"required":["advisory_text","confidence","decision","priority","reason","skill_id","visible_for_steps"],"type":"object"},"strict":true}]}`
+
+// EnsureMetaMuseToolNamespace appends the canonical namespace to tool-bearing
+// Responses requests, preserving caller tool indexes. It leaves tool-less
+// requests and existing Muse namespaces untouched. Call before payload rules so
+// user overrides and filters stay final.
+func EnsureMetaMuseToolNamespace(body []byte) []byte {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !EnsureMetaMuseToolNamespaceDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+// EnsureMetaMuseToolNamespaceDoc appends the canonical namespace to tool-bearing
+// Responses requests in place on doc.
+func EnsureMetaMuseToolNamespaceDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ tools := doc.Get("tools")
+ if !tools.IsArray() {
+ return false
+ }
+ items := tools.Array()
+ if len(items) == 0 {
+ return false
+ }
+ for _, item := range items {
+ if item.Get("type").String() == "namespace" && item.Get("name").String() == "muse" {
+ return false
+ }
+ }
+ raw := make([]byte, 0, len(metaMuseNamespace)+len(tools.Raw)+1)
+ raw = append(raw, '[')
+ for i, item := range items {
+ if i > 0 {
+ raw = append(raw, ',')
+ }
+ raw = append(raw, item.Raw...)
+ }
+ raw = append(raw, ',')
+ raw = append(raw, metaMuseNamespace...)
+ raw = append(raw, ']')
+ return doc.SetRawBytes("tools", raw) == nil
+}
diff --git a/internal/runtime/executor/helps/meta_muse_tools_test.go b/internal/runtime/executor/helps/meta_muse_tools_test.go
new file mode 100644
index 00000000000..19140e92d3a
--- /dev/null
+++ b/internal/runtime/executor/helps/meta_muse_tools_test.go
@@ -0,0 +1,48 @@
+package helps
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+)
+
+func TestEnsureMetaMuseToolNamespacePreservesCallerTools(t *testing.T) {
+ input := []byte(`{"input":"hello","tool_choice":"auto","tools":[{"type":"function","name":"muse","parameters":{"type":"object","properties":{"value":{"type":"integer"}}}},{"type":"namespace","name":"client","tools":[{"type":"function","name":"lookup"}]},{"type":"web_search"}]}`)
+ original := bytes.Clone(input)
+ got := EnsureMetaMuseToolNamespace(input)
+ if !ojson.ValidBytes(got) {
+ t.Fatalf("invalid JSON: %s", got)
+ }
+ tools := ojson.GetBytes(got, "tools").Array()
+ before := ojson.GetBytes(input, "tools").Array()
+ if len(tools) != len(before)+1 {
+ t.Fatalf("tool count = %d, want %d", len(tools), len(before)+1)
+ }
+ namespace := tools[len(before)]
+ if namespace.Get("type").String() != "namespace" || namespace.Get("name").String() != "muse" || namespace.Get("tools.0.name").String() != "submit_reminder_decision" {
+ t.Fatalf("missing canonical namespace: %s", namespace.Raw)
+ }
+ for i, tool := range before {
+ if tools[i].Raw != tool.Raw {
+ t.Fatalf("caller tool %d changed: %s", i, tools[i].Raw)
+ }
+ }
+ if ojson.GetBytes(got, "input").String() != "hello" || ojson.GetBytes(got, "tool_choice").String() != "auto" || !bytes.Equal(input, original) {
+ t.Fatal("unrelated fields or caller buffer changed")
+ }
+ if again := EnsureMetaMuseToolNamespace(got); !bytes.Equal(again, got) {
+ t.Fatal("second pass duplicated the namespace")
+ }
+}
+
+func TestEnsureMetaMuseToolNamespaceLeavesUnrelatedBodiesUnchanged(t *testing.T) {
+ for _, input := range []string{
+ ``, `{"input":"hello"}`, `{"tools":null}`, `{"tools":{}}`, `{"tools":[]}`,
+ `{"tools":[{"type":"function","name":"lookup"},{"type":"namespace","name":"muse","description":"caller supplied","tools":[{"type":"function","name":"read_file"}]}]}`,
+ } {
+ if got := EnsureMetaMuseToolNamespace([]byte(input)); string(got) != input {
+ t.Fatalf("body changed: %s -> %s", input, got)
+ }
+ }
+}
diff --git a/internal/runtime/executor/helps/meta_tools.go b/internal/runtime/executor/helps/meta_tools.go
index 46e5f05e84f..d69382e80e9 100644
--- a/internal/runtime/executor/helps/meta_tools.go
+++ b/internal/runtime/executor/helps/meta_tools.go
@@ -3,27 +3,42 @@ package helps
import (
"fmt"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// SanitizeMetaWebSearchTools strips unsupported fields from web_search tool definitions
// for Meta Muse upstreams. Specifically, Meta rejects search_content_types when tool type
// is "web_search" (it is only accepted on "web_search_preview" tools).
func SanitizeMetaWebSearchTools(body []byte) []byte {
- tools := gjson.GetBytes(body, "tools")
- if !tools.Exists() || !tools.IsArray() {
+ if len(body) == 0 {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !SanitizeMetaWebSearchToolsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+// SanitizeMetaWebSearchToolsDoc strips unsupported fields from web_search tool definitions
+// in place on doc for Meta Muse upstreams.
+func SanitizeMetaWebSearchToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ tools := doc.Get("tools")
+ if !tools.Exists() || !tools.IsArray() {
+ return false
+ }
var pathsToDelete []string
- tools.ForEach(func(index, tool gjson.Result) bool {
+ tools.ForEach(func(index, tool ojson.Result) bool {
if tool.Get("type").String() == "web_search" && tool.Get("search_content_types").Exists() {
pathsToDelete = append(pathsToDelete, fmt.Sprintf("tools.%d.search_content_types", index.Int()))
}
if tool.Get("type").String() == "namespace" {
subtools := tool.Get("tools")
if subtools.Exists() && subtools.IsArray() {
- subtools.ForEach(func(subIndex, subtool gjson.Result) bool {
+ subtools.ForEach(func(subIndex, subtool ojson.Result) bool {
if subtool.Get("type").String() == "web_search" && subtool.Get("search_content_types").Exists() {
pathsToDelete = append(pathsToDelete, fmt.Sprintf("tools.%d.tools.%d.search_content_types", index.Int(), subIndex.Int()))
}
@@ -34,13 +49,13 @@ func SanitizeMetaWebSearchTools(body []byte) []byte {
return true
})
if len(pathsToDelete) == 0 {
- return body
+ return false
}
- out := body
+ modified := false
for _, p := range pathsToDelete {
- if updated, errDel := sjson.DeleteBytes(out, p); errDel == nil {
- out = updated
+ if errDel := doc.Delete(p); errDel == nil {
+ modified = true
}
}
- return out
+ return modified
}
diff --git a/internal/runtime/executor/helps/meta_tools_test.go b/internal/runtime/executor/helps/meta_tools_test.go
index 35f7dfac986..bc717945205 100644
--- a/internal/runtime/executor/helps/meta_tools_test.go
+++ b/internal/runtime/executor/helps/meta_tools_test.go
@@ -3,7 +3,7 @@ package helps
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestSanitizeMetaWebSearchTools_StripsSearchContentTypesFromWebSearch(t *testing.T) {
@@ -40,7 +40,7 @@ func TestSanitizeMetaWebSearchTools_StripsSearchContentTypesFromWebSearch(t *tes
got := SanitizeMetaWebSearchTools(input)
// web_search at index 1 must have search_content_types stripped
- tool1 := gjson.GetBytes(got, "tools.1")
+ tool1 := ojson.GetBytes(got, "tools.1")
if tool1.Get("type").String() != "web_search" {
t.Fatalf("tools.1 type = %q, want web_search", tool1.Get("type").String())
}
@@ -52,7 +52,7 @@ func TestSanitizeMetaWebSearchTools_StripsSearchContentTypesFromWebSearch(t *tes
}
// web_search_preview at index 2 must retain search_content_types
- tool2 := gjson.GetBytes(got, "tools.2")
+ tool2 := ojson.GetBytes(got, "tools.2")
if tool2.Get("type").String() != "web_search_preview" {
t.Fatalf("tools.2 type = %q, want web_search_preview", tool2.Get("type").String())
}
@@ -61,7 +61,7 @@ func TestSanitizeMetaWebSearchTools_StripsSearchContentTypesFromWebSearch(t *tes
}
// nested web_search in namespace at index 3 must have search_content_types stripped
- nestedTool := gjson.GetBytes(got, "tools.3.tools.0")
+ nestedTool := ojson.GetBytes(got, "tools.3.tools.0")
if nestedTool.Get("type").String() != "web_search" {
t.Fatalf("tools.3.tools.0 type = %q, want web_search", nestedTool.Get("type").String())
}
@@ -70,7 +70,7 @@ func TestSanitizeMetaWebSearchTools_StripsSearchContentTypesFromWebSearch(t *tes
}
// function tool at index 0 must be unchanged
- tool0 := gjson.GetBytes(got, "tools.0")
+ tool0 := ojson.GetBytes(got, "tools.0")
if tool0.Get("name").String() != "lookup" {
t.Fatalf("tools.0 name = %q, want lookup", tool0.Get("name").String())
}
diff --git a/internal/runtime/executor/helps/model_capabilities.go b/internal/runtime/executor/helps/model_capabilities.go
index d1785d6f8ec..24335be931f 100644
--- a/internal/runtime/executor/helps/model_capabilities.go
+++ b/internal/runtime/executor/helps/model_capabilities.go
@@ -1,6 +1,7 @@
package helps
import (
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -20,6 +21,13 @@ func ApplyRequestThinking(body []byte, req cliproxyexecutor.Request, opts clipro
return ApplyRequestThinkingWithModelInfo(body, req, opts, fromFormat, toFormat, provider, nil, normalizedUpdatesChanged...)
}
+// ApplyRequestThinkingDoc applies request thinking onto doc in place, preserving
+// the registry lookup path unless the auth manager bound authoritative model
+// capabilities to this execution attempt.
+func ApplyRequestThinkingDoc(doc *ojson.Doc, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, providerKey string, PreserveConfigurationUpdates ...bool) error {
+ return ApplyRequestThinkingWithModelInfoDoc(doc, req, opts, fromFormat, toFormat, providerKey, nil, PreserveConfigurationUpdates...)
+}
+
// ApplyRequestThinkingWithModelInfo applies request thinking and falls back to
// fallbackModelInfo when neither request metadata nor the global registry
// resolves a model definition.
@@ -38,3 +46,25 @@ func ApplyRequestThinkingWithModelInfo(body []byte, req cliproxyexecutor.Request
}
return thinking.ApplyThinkingWithFallbackModelInfoAndSummary(body, source, req.Model, fromFormat, toFormat, provider, fallbackModelInfo, summaryConfig, normalizedUpdatesChanged...)
}
+
+// ApplyRequestThinkingWithModelInfoDoc applies request thinking onto doc in place
+// and falls back to fallbackModelInfo when neither request metadata nor the global
+// registry resolves a model definition.
+func ApplyRequestThinkingWithModelInfoDoc(doc *ojson.Doc, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, fromFormat, toFormat, provider string, fallbackModelInfo *registry.ModelInfo, normalizedUpdatesChanged ...bool) error {
+ if doc == nil {
+ return nil
+ }
+ originalSource := opts.OriginalRequest
+ if len(originalSource) == 0 {
+ originalSource = req.Payload
+ }
+ source := req.Payload
+ if len(source) == 0 {
+ source = opts.OriginalRequest
+ }
+ summaryConfig := translatedRequestSummaryConfigDoc(doc, req.Payload, originalSource, req.Model, fromFormat, toFormat)
+ if modelInfo, ok := cliproxyauth.ResolvedModelInfo(req); ok {
+ return thinking.ApplyThinkingWithModelInfoAndSummaryDoc(doc, source, req.Model, fromFormat, toFormat, provider, modelInfo, summaryConfig, normalizedUpdatesChanged...)
+ }
+ return thinking.ApplyThinkingWithFallbackModelInfoAndSummaryDoc(doc, source, req.Model, fromFormat, toFormat, provider, fallbackModelInfo, summaryConfig, normalizedUpdatesChanged...)
+}
diff --git a/internal/runtime/executor/helps/model_capabilities_test.go b/internal/runtime/executor/helps/model_capabilities_test.go
index 7be02682504..ccc39f338b3 100644
--- a/internal/runtime/executor/helps/model_capabilities_test.go
+++ b/internal/runtime/executor/helps/model_capabilities_test.go
@@ -5,6 +5,7 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
helps "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -16,7 +17,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type configuredThinkingExecutor struct {
@@ -145,11 +145,11 @@ func TestApplyRequestThinkingConfigurationUpdateSelectedCodexModel(t *testing.T)
if executor.seenModel != wantModel {
t.Fatalf("executor model = %q, want %q", executor.seenModel, wantModel)
}
- if got := gjson.GetBytes(response.Payload, "reasoning.effort").String(); got != tc.wantTop {
+ if got := ojson.GetBytes(response.Payload, "reasoning.effort").String(); got != tc.wantTop {
t.Fatalf("reasoning.effort = %q, want %q; body=%s", got, tc.wantTop, response.Payload)
}
updates := 0
- for _, item := range gjson.GetBytes(response.Payload, "input").Array() {
+ for _, item := range ojson.GetBytes(response.Payload, "input").Array() {
if item.Get("type").String() == "configuration_update" {
updates++
}
@@ -190,7 +190,7 @@ func TestApplyRequestThinkingConfigurationUpdateCrossProtocol(t *testing.T) {
if err != nil {
t.Fatalf("ApplyRequestThinking() error = %v", err)
}
- if got := gjson.GetBytes(out, tc.path).String(); got != tc.want {
+ if got := ojson.GetBytes(out, tc.path).String(); got != tc.want {
t.Fatalf("%s = %q, want %q; body=%s", tc.path, got, tc.want, out)
}
})
@@ -257,19 +257,19 @@ func TestApplyRequestThinkingUsesExactClaudeModeForSummaryOnlyRequest(t *testing
if errExecute != nil {
t.Fatalf("Execute() error = %v", errExecute)
}
- if got := gjson.GetBytes(executor.translatedBody, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(executor.translatedBody, "thinking.type").String(); got != "adaptive" {
t.Fatalf("pre-executor thinking.type = %q, want global adaptive trigger; body=%s", got, executor.translatedBody)
}
- if got := gjson.GetBytes(response.Payload, "thinking.type").String(); got != "enabled" {
+ if got := ojson.GetBytes(response.Payload, "thinking.type").String(); got != "enabled" {
t.Fatalf("thinking.type = %q, want exact manual mode; body=%s", got, response.Payload)
}
- if got := gjson.GetBytes(response.Payload, "thinking.budget_tokens").Int(); got != 1024 {
+ if got := ojson.GetBytes(response.Payload, "thinking.budget_tokens").Int(); got != 1024 {
t.Fatalf("thinking.budget_tokens = %d, want exact minimum 1024; body=%s", got, response.Payload)
}
- if got := gjson.GetBytes(response.Payload, "thinking.display").String(); got != "summarized" {
+ if got := ojson.GetBytes(response.Payload, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, response.Payload)
}
- if gjson.GetBytes(response.Payload, "output_config.effort").Exists() {
+ if ojson.GetBytes(response.Payload, "output_config.effort").Exists() {
t.Fatalf("manual thinking retained adaptive effort: %s", response.Payload)
}
}
@@ -337,7 +337,7 @@ func TestApplyRequestThinkingUsesSelectedPrefixedAPIKeyModel(t *testing.T) {
if !executor.resolved {
t.Fatalf("%s request did not receive selected model capabilities", path)
}
- if got := gjson.GetBytes(payload, "output_config.effort").String(); got != "high" {
+ if got := ojson.GetBytes(payload, "output_config.effort").String(); got != "high" {
t.Fatalf("%s output effort = %q, want selected credential capability high; body=%s", path, got, payload)
}
}
diff --git a/internal/runtime/executor/helps/openai_compat_max_tokens.go b/internal/runtime/executor/helps/openai_compat_max_tokens.go
index 71915c5b31d..00c55a4509a 100644
--- a/internal/runtime/executor/helps/openai_compat_max_tokens.go
+++ b/internal/runtime/executor/helps/openai_compat_max_tokens.go
@@ -3,9 +3,8 @@ package helps
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ShouldUseMaxCompletionTokensForModel reports whether the resolved model under an
@@ -48,37 +47,62 @@ func NormalizeOpenAIMaxTokens(payload []byte, useMaxCompletionTokens bool) []byt
if len(payload) == 0 {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !NormalizeOpenAIMaxTokensDoc(doc, useMaxCompletionTokens) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// NormalizeOpenAIMaxTokensDoc normalizes max_tokens and max_completion_tokens in place on doc.
+func NormalizeOpenAIMaxTokensDoc(doc *ojson.Doc, useMaxCompletionTokens bool) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
- hasMaxTokens := gjson.GetBytes(payload, "max_tokens").Exists()
- hasMaxCompletionTokens := gjson.GetBytes(payload, "max_completion_tokens").Exists()
+ maxTokens := doc.Get("max_tokens")
+ maxCompletionTokens := doc.Get("max_completion_tokens")
+ hasMaxTokens := maxTokens.Exists()
+ hasMaxCompletionTokens := maxCompletionTokens.Exists()
if !hasMaxTokens && !hasMaxCompletionTokens {
- return payload
+ return false
}
+ modified := false
if useMaxCompletionTokens {
if hasMaxTokens && !hasMaxCompletionTokens {
- val := gjson.GetBytes(payload, "max_tokens")
- if val.Raw != "" {
- payload, _ = sjson.SetRawBytes(payload, "max_completion_tokens", []byte(val.Raw))
+ if maxTokens.Raw != "" {
+ if errSet := doc.SetRaw("max_completion_tokens", maxTokens.Raw); errSet == nil {
+ modified = true
+ }
} else {
- payload, _ = sjson.SetBytes(payload, "max_completion_tokens", val.Value())
+ if errSet := doc.Set("max_completion_tokens", maxTokens.Value()); errSet == nil {
+ modified = true
+ }
}
}
if hasMaxTokens {
- payload, _ = sjson.DeleteBytes(payload, "max_tokens")
+ if errDel := doc.Delete("max_tokens"); errDel == nil {
+ modified = true
+ }
}
} else {
if hasMaxCompletionTokens && !hasMaxTokens {
- val := gjson.GetBytes(payload, "max_completion_tokens")
- if val.Raw != "" {
- payload, _ = sjson.SetRawBytes(payload, "max_tokens", []byte(val.Raw))
+ if maxCompletionTokens.Raw != "" {
+ if errSet := doc.SetRaw("max_tokens", maxCompletionTokens.Raw); errSet == nil {
+ modified = true
+ }
} else {
- payload, _ = sjson.SetBytes(payload, "max_tokens", val.Value())
+ if errSet := doc.Set("max_tokens", maxCompletionTokens.Value()); errSet == nil {
+ modified = true
+ }
}
}
if hasMaxCompletionTokens {
- payload, _ = sjson.DeleteBytes(payload, "max_completion_tokens")
+ if errDel := doc.Delete("max_completion_tokens"); errDel == nil {
+ modified = true
+ }
}
}
- return payload
+ return modified
}
diff --git a/internal/runtime/executor/helps/openai_compat_max_tokens_test.go b/internal/runtime/executor/helps/openai_compat_max_tokens_test.go
index f1bd20aa218..642cb48bbde 100644
--- a/internal/runtime/executor/helps/openai_compat_max_tokens_test.go
+++ b/internal/runtime/executor/helps/openai_compat_max_tokens_test.go
@@ -3,8 +3,8 @@ package helps
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
func TestShouldUseMaxCompletionTokensForModel(t *testing.T) {
@@ -115,10 +115,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"o1","max_tokens":1024,"messages":[]}`)
got := NormalizeOpenAIMaxTokens(input, true)
- if v := gjson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Int() != 1024 {
+ if v := ojson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Int() != 1024 {
t.Fatalf("max_completion_tokens = %v, want 1024; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_tokens").Exists() {
+ if ojson.GetBytes(got, "max_tokens").Exists() {
t.Fatalf("max_tokens should be deleted; out=%s", string(got))
}
})
@@ -127,10 +127,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"o1","max_tokens":512,"max_completion_tokens":2048}`)
got := NormalizeOpenAIMaxTokens(input, true)
- if v := gjson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Int() != 2048 {
+ if v := ojson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Int() != 2048 {
t.Fatalf("max_completion_tokens = %v, want 2048; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_tokens").Exists() {
+ if ojson.GetBytes(got, "max_tokens").Exists() {
t.Fatalf("max_tokens should be deleted; out=%s", string(got))
}
})
@@ -139,10 +139,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"o1","max_completion_tokens":4096}`)
got := NormalizeOpenAIMaxTokens(input, true)
- if v := gjson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Int() != 4096 {
+ if v := ojson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Int() != 4096 {
t.Fatalf("max_completion_tokens = %v, want 4096; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_tokens").Exists() {
+ if ojson.GetBytes(got, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; out=%s", string(got))
}
})
@@ -151,10 +151,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"deepseek","max_completion_tokens":1024,"messages":[]}`)
got := NormalizeOpenAIMaxTokens(input, false)
- if v := gjson.GetBytes(got, "max_tokens"); !v.Exists() || v.Int() != 1024 {
+ if v := ojson.GetBytes(got, "max_tokens"); !v.Exists() || v.Int() != 1024 {
t.Fatalf("max_tokens = %v, want 1024; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(got, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be deleted; out=%s", string(got))
}
})
@@ -163,10 +163,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"deepseek","max_tokens":512,"max_completion_tokens":2048}`)
got := NormalizeOpenAIMaxTokens(input, false)
- if v := gjson.GetBytes(got, "max_tokens"); !v.Exists() || v.Int() != 512 {
+ if v := ojson.GetBytes(got, "max_tokens"); !v.Exists() || v.Int() != 512 {
t.Fatalf("max_tokens = %v, want 512; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(got, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be deleted; out=%s", string(got))
}
})
@@ -175,10 +175,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"deepseek","max_tokens":4096}`)
got := NormalizeOpenAIMaxTokens(input, false)
- if v := gjson.GetBytes(got, "max_tokens"); !v.Exists() || v.Int() != 4096 {
+ if v := ojson.GetBytes(got, "max_tokens"); !v.Exists() || v.Int() != 4096 {
t.Fatalf("max_tokens = %v, want 4096; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(got, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; out=%s", string(got))
}
})
@@ -187,10 +187,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"o1","max_tokens":null}`)
got := NormalizeOpenAIMaxTokens(input, true)
- if v := gjson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Type != gjson.Null {
+ if v := ojson.GetBytes(got, "max_completion_tokens"); !v.Exists() || v.Type != ojson.Null {
t.Fatalf("max_completion_tokens = %v, want null; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_tokens").Exists() {
+ if ojson.GetBytes(got, "max_tokens").Exists() {
t.Fatalf("max_tokens should be deleted; out=%s", string(got))
}
})
@@ -199,10 +199,10 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"deepseek","max_completion_tokens":null}`)
got := NormalizeOpenAIMaxTokens(input, false)
- if v := gjson.GetBytes(got, "max_tokens"); !v.Exists() || v.Type != gjson.Null {
+ if v := ojson.GetBytes(got, "max_tokens"); !v.Exists() || v.Type != ojson.Null {
t.Fatalf("max_tokens = %v, want null; out=%s", v, string(got))
}
- if gjson.GetBytes(got, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(got, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be deleted; out=%s", string(got))
}
})
@@ -211,12 +211,12 @@ func TestNormalizeOpenAIMaxTokens(t *testing.T) {
input := []byte(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)
gotTrue := NormalizeOpenAIMaxTokens(input, true)
- if gjson.GetBytes(gotTrue, "max_tokens").Exists() || gjson.GetBytes(gotTrue, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotTrue, "max_tokens").Exists() || ojson.GetBytes(gotTrue, "max_completion_tokens").Exists() {
t.Fatalf("unexpected token fields emitted for true; out=%s", string(gotTrue))
}
gotFalse := NormalizeOpenAIMaxTokens(input, false)
- if gjson.GetBytes(gotFalse, "max_tokens").Exists() || gjson.GetBytes(gotFalse, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotFalse, "max_tokens").Exists() || ojson.GetBytes(gotFalse, "max_completion_tokens").Exists() {
t.Fatalf("unexpected token fields emitted for false; out=%s", string(gotFalse))
}
})
diff --git a/internal/runtime/executor/helps/openai_compat_tool_results.go b/internal/runtime/executor/helps/openai_compat_tool_results.go
index 1b47abfd3ca..febf345568a 100644
--- a/internal/runtime/executor/helps/openai_compat_tool_results.go
+++ b/internal/runtime/executor/helps/openai_compat_tool_results.go
@@ -1,13 +1,12 @@
package helps
import (
- "bytes"
+ "fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -34,38 +33,53 @@ func ShouldNormalizeOpenAIToolResultsForModel(compat *config.OpenAICompatibility
// and strips relayed tool result images for text-only compatibility models.
// Text parts are preserved and image parts are replaced with a short marker.
func NormalizeOpenAIToolResultsTextOnly(payload []byte) []byte {
- messages := gjson.GetBytes(payload, "messages")
- if !messages.Exists() || !messages.IsArray() {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !NormalizeOpenAIToolResultsTextOnlyDoc(doc) {
return payload
}
+ return doc.Bytes()
+}
+
+// NormalizeOpenAIToolResultsTextOnlyDoc converts tool message content to strings
+// and strips relayed tool result images in place on doc for text-only compatibility models.
+func NormalizeOpenAIToolResultsTextOnlyDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ messages := doc.Get("messages")
+ if !messages.Exists() || !messages.IsArray() {
+ return false
+ }
rawList := messages.Array()
if len(rawList) == 0 {
- return payload
+ return false
}
- newMessages := make([][]byte, 0, len(rawList))
+ modified := false
replacedPlaceholderInCurrentTurn := false
+ omitted := make(map[int]bool)
for i := 0; i < len(rawList); i++ {
msg := rawList[i]
role := msg.Get("role").String()
- msgBytes := []byte(msg.Raw)
switch role {
case "tool":
content := msg.Get("content")
- if content.Exists() && content.Type != gjson.String {
- if updated, errSet := sjson.SetBytes(msgBytes, "content", flattenOpenAIToolResultContent(content)); errSet == nil {
- msgBytes = updated
+ if content.Exists() && content.Type != ojson.String {
+ if errSet := doc.Set(fmt.Sprintf("messages.%d.content", i), flattenOpenAIToolResultContent(content)); errSet == nil {
+ modified = true
}
- } else if content.Exists() && content.Type == gjson.String && content.String() == claudeToolResultImagePlaceholder {
- if updated, errSet := sjson.SetBytes(msgBytes, "content", openAIToolResultImageOmittedText); errSet == nil {
- msgBytes = updated
+ } else if content.Exists() && content.Type == ojson.String && content.String() == claudeToolResultImagePlaceholder {
+ if errSet := doc.Set(fmt.Sprintf("messages.%d.content", i), openAIToolResultImageOmittedText); errSet == nil {
+ modified = true
replacedPlaceholderInCurrentTurn = true
}
}
- newMessages = append(newMessages, msgBytes)
case "user":
content := msg.Get("content")
@@ -74,7 +88,7 @@ func NormalizeOpenAIToolResultsTextOnly(payload []byte) []byte {
hasRelayNotice := false
hasImages := false
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.IsObject() {
if part.Get("type").String() == "text" && part.Get("text").String() == claudeToolResultImageRelayNotice {
hasRelayNotice = true
@@ -91,19 +105,22 @@ func NormalizeOpenAIToolResultsTextOnly(payload []byte) []byte {
if hasRelayNotice && hasImages {
if !replacedPlaceholderInCurrentTurn {
- for j := len(newMessages) - 1; j >= 0; j-- {
- prevRole := gjson.GetBytes(newMessages[j], "role").String()
+ for j := i - 1; j >= 0; j-- {
+ if omitted[j] {
+ continue
+ }
+ prevRole := doc.Get(fmt.Sprintf("messages.%d.role", j)).String()
if prevRole != "tool" {
break
}
- prevContent := gjson.GetBytes(newMessages[j], "content").String()
+ prevContent := doc.Get(fmt.Sprintf("messages.%d.content", j)).String()
if !strings.Contains(prevContent, openAIToolResultImageOmittedText) {
newContent := prevContent + "\n\n" + openAIToolResultImageOmittedText
if prevContent == "" {
newContent = openAIToolResultImageOmittedText
}
- if updated, errSet := sjson.SetBytes(newMessages[j], "content", newContent); errSet == nil {
- newMessages[j] = updated
+ if errSet := doc.Set(fmt.Sprintf("messages.%d.content", j), newContent); errSet == nil {
+ modified = true
}
}
break
@@ -113,37 +130,36 @@ func NormalizeOpenAIToolResultsTextOnly(payload []byte) []byte {
if len(remainingParts) == 0 {
// Synthetic relay message contained only relayed images; omit entirely.
+ omitted[i] = true
+ modified = true
continue
}
rawArray := "[" + strings.Join(remainingParts, ",") + "]"
- if updated, errSetRaw := sjson.SetRawBytes(msgBytes, "content", []byte(rawArray)); errSetRaw == nil {
- msgBytes = updated
+ if errSetRaw := doc.SetRaw(fmt.Sprintf("messages.%d.content", i), rawArray); errSetRaw == nil {
+ modified = true
}
}
}
- newMessages = append(newMessages, msgBytes)
default:
replacedPlaceholderInCurrentTurn = false
- newMessages = append(newMessages, msgBytes)
}
}
- var buf bytes.Buffer
- buf.WriteByte('[')
- for i, m := range newMessages {
- if i > 0 {
- buf.WriteByte(',')
+ if len(omitted) > 0 {
+ updatedList := doc.Get("messages").Array()
+ kept := make([][]byte, 0, len(updatedList)-len(omitted))
+ for idx, item := range updatedList {
+ if !omitted[idx] {
+ kept = append(kept, []byte(item.Raw))
+ }
+ }
+ if errSetRaw := doc.SetRawBytes("messages", JoinRawJSONArray(kept)); errSetRaw != nil {
+ return false
}
- buf.Write(m)
- }
- buf.WriteByte(']')
-
- if updated, errSetRaw := sjson.SetRawBytes(payload, "messages", buf.Bytes()); errSetRaw == nil {
- return updated
}
- return payload
+ return modified
}
func openAICompatibilityModelExcludesImages(models []config.OpenAICompatibilityModel, model string) (bool, bool) {
@@ -197,14 +213,14 @@ func normalizeOpenAICompatibilityModelName(model string) string {
return strings.TrimSpace(thinking.ParseSuffix(model).ModelName)
}
-func flattenOpenAIToolResultContent(content gjson.Result) string {
- if content.Type == gjson.String {
+func flattenOpenAIToolResultContent(content ojson.Result) string {
+ if content.Type == ojson.String {
return content.String()
}
if content.IsArray() {
parts := make([]string, 0, 4)
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
if part, ok := openAIToolResultPartText(item); ok {
parts = append(parts, part)
}
@@ -217,7 +233,7 @@ func flattenOpenAIToolResultContent(content gjson.Result) string {
if isOpenAIImageToolResultPart(content) {
return openAIToolResultImageOmittedText
}
- if text := content.Get("text"); text.Type == gjson.String {
+ if text := content.Get("text"); text.Type == ojson.String {
return text.String()
}
}
@@ -225,15 +241,15 @@ func flattenOpenAIToolResultContent(content gjson.Result) string {
return content.Raw
}
-func openAIToolResultPartText(item gjson.Result) (string, bool) {
- if item.Type == gjson.String {
+func openAIToolResultPartText(item ojson.Result) (string, bool) {
+ if item.Type == ojson.String {
return item.String(), true
}
if item.IsObject() {
if isOpenAIImageToolResultPart(item) {
return openAIToolResultImageOmittedText, true
}
- if text := item.Get("text"); text.Type == gjson.String {
+ if text := item.Get("text"); text.Type == ojson.String {
return text.String(), true
}
}
@@ -243,7 +259,7 @@ func openAIToolResultPartText(item gjson.Result) (string, bool) {
return item.Raw, true
}
-func isOpenAIImageToolResultPart(item gjson.Result) bool {
+func isOpenAIImageToolResultPart(item ojson.Result) bool {
if !item.IsObject() {
return false
}
diff --git a/internal/runtime/executor/helps/openai_compat_tool_results_test.go b/internal/runtime/executor/helps/openai_compat_tool_results_test.go
index 97d857e08a0..45200d6afff 100644
--- a/internal/runtime/executor/helps/openai_compat_tool_results_test.go
+++ b/internal/runtime/executor/helps/openai_compat_tool_results_test.go
@@ -4,8 +4,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
func TestNormalizeOpenAIToolResultsTextOnlyRelayedImages(t *testing.T) {
@@ -23,7 +23,7 @@ func TestNormalizeOpenAIToolResultsTextOnlyRelayedImages(t *testing.T) {
if strings.Contains(string(got), "image_url") {
t.Fatalf("text-only normalization left image_url in payload: %s", string(got))
}
- messages := gjson.GetBytes(got, "messages").Array()
+ messages := ojson.GetBytes(got, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages after dropping synthetic relay user message, got %d: %s", len(messages), string(got))
}
@@ -46,7 +46,7 @@ func TestNormalizeOpenAIToolResultsTextOnlyRelayedImages(t *testing.T) {
if strings.Contains(string(got), "image_url") {
t.Fatalf("text-only normalization left image_url in payload: %s", string(got))
}
- messages := gjson.GetBytes(got, "messages").Array()
+ messages := ojson.GetBytes(got, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d: %s", len(messages), string(got))
}
@@ -70,7 +70,7 @@ func TestNormalizeOpenAIToolResultsTextOnlyRelayedImages(t *testing.T) {
if strings.Contains(string(got), "image_url") {
t.Fatalf("text-only normalization left image_url in payload: %s", string(got))
}
- messages := gjson.GetBytes(got, "messages").Array()
+ messages := ojson.GetBytes(got, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages when user text is present, got %d: %s", len(messages), string(got))
}
@@ -104,7 +104,7 @@ func TestNormalizeOpenAIToolResultsTextOnlyRelayedImages(t *testing.T) {
if strings.Contains(string(got), "image_url") {
t.Fatalf("text-only normalization left image_url in payload: %s", string(got))
}
- messages := gjson.GetBytes(got, "messages").Array()
+ messages := ojson.GetBytes(got, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages, got %d: %s", len(messages), string(got))
}
@@ -134,7 +134,7 @@ func TestNormalizeOpenAIToolResultsTextOnlyRelayedImages(t *testing.T) {
if strings.Contains(string(got), "image_url") {
t.Fatalf("text-only normalization left image_url in payload: %s", string(got))
}
- messages := gjson.GetBytes(got, "messages").Array()
+ messages := ojson.GetBytes(got, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages, got %d: %s", len(messages), string(got))
}
@@ -160,20 +160,20 @@ func TestNormalizeOpenAIToolResultsTextOnly(t *testing.T) {
got := NormalizeOpenAIToolResultsTextOnly(input)
- toolContent := gjson.GetBytes(got, "messages.1.content")
- if toolContent.Type != gjson.String {
+ toolContent := ojson.GetBytes(got, "messages.1.content")
+ if toolContent.Type != ojson.String {
t.Fatalf("tool content type = %s, want string", toolContent.Type)
}
if toolContent.String() != "image inspected\n\n"+openAIToolResultImageOmittedText {
t.Fatalf("tool content = %q", toolContent.String())
}
- if gotContent := gjson.GetBytes(got, "messages.2.content"); gotContent.String() != "already text" {
+ if gotContent := ojson.GetBytes(got, "messages.2.content"); gotContent.String() != "already text" {
t.Fatalf("existing string tool content = %q", gotContent.String())
}
- if !gjson.GetBytes(got, "messages.0.content").IsArray() {
+ if !ojson.GetBytes(got, "messages.0.content").IsArray() {
t.Fatal("assistant content array was unexpectedly changed")
}
- if !gjson.GetBytes(got, "messages.3.content").IsArray() {
+ if !ojson.GetBytes(got, "messages.3.content").IsArray() {
t.Fatal("non-tool content array was unexpectedly changed")
}
}
@@ -204,7 +204,7 @@ func TestNormalizeOpenAIToolResultsTextOnlyImageAndUnknownContent(t *testing.T)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := NormalizeOpenAIToolResultsTextOnly([]byte(tt.input))
- if content := gjson.GetBytes(got, "messages.0.content").String(); content != tt.want {
+ if content := ojson.GetBytes(got, "messages.0.content").String(); content != tt.want {
t.Fatalf("tool content = %q, want %q", content, tt.want)
}
})
diff --git a/internal/runtime/executor/helps/payload_finalizer.go b/internal/runtime/executor/helps/payload_finalizer.go
index c12a5830507..a18cd6fdce5 100644
--- a/internal/runtime/executor/helps/payload_finalizer.go
+++ b/internal/runtime/executor/helps/payload_finalizer.go
@@ -2,17 +2,50 @@ package helps
import (
"context"
+ "reflect"
"strings"
+ "sync"
+ "sync/atomic"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// PayloadFinalizer applies user rules to a fully prepared business payload.
// Each rebuilt attempt must start from the unconfigured body, not a previous result.
type PayloadFinalizer func([]byte) []byte
+var (
+ payloadDocProbes sync.Map
+ builtinFinalizerCodePtr atomic.Uintptr
+)
+
+func payloadFinalizerKeyPtr(f PayloadFinalizer) uintptr {
+ if f == nil {
+ return 0
+ }
+ return reflect.ValueOf(f).Pointer()
+}
+
+// FinalizeDoc applies the finalizer to doc in place without probing custom wrappers.
+func (f PayloadFinalizer) FinalizeDoc(doc *ojson.Doc) {
+ if f == nil || doc == nil {
+ return
+ }
+ if ptr := payloadFinalizerKeyPtr(f); ptr != 0 && ptr == builtinFinalizerCodePtr.Load() {
+ var probe [1]byte
+ key := &probe[0]
+ payloadDocProbes.Store(key, doc)
+ _ = f(probe[:0])
+ if _, remaining := payloadDocProbes.LoadAndDelete(key); !remaining {
+ return
+ }
+ }
+ updated := f(doc.Bytes())
+ *doc = *ojson.NewDoc(updated)
+}
+
// NewPayloadFinalizer snapshots matching context before built-in request mutations.
func NewPayloadFinalizer(cfg *config.Config, executor, model, protocol, root string, original []byte, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) PayloadFinalizer {
requestedModel := PayloadRequestedModel(opts, req.Model)
@@ -23,13 +56,27 @@ func NewPayloadFinalizer(cfg *config.Config, executor, model, protocol, root str
if opts.Metadata != nil {
formatCallback, _ = opts.Metadata[cliproxyexecutor.FinalizedResponseFormatCallbackMetadataKey].(func(string))
}
- return func(body []byte) []byte {
+ fn := PayloadFinalizer(func(body []byte) []byte {
+ if len(body) == 0 && cap(body) == 1 {
+ key := &body[:1][0]
+ if v, ok := payloadDocProbes.LoadAndDelete(key); ok {
+ if doc, okDoc := v.(*ojson.Doc); okDoc && doc != nil {
+ ApplyPayloadConfigWithRequestForExecutorDoc(cfg, executor, model, protocol, opts.SourceFormat.String(), root, doc, source, requestedModel, requestPath, headers)
+ if formatCallback != nil {
+ formatCallback(strings.Clone(strings.TrimSpace(doc.Get(buildPayloadPath(root, "response_format")).String())))
+ }
+ }
+ return nil
+ }
+ }
out := ApplyPayloadConfigWithRequestForExecutor(cfg, executor, model, protocol, opts.SourceFormat.String(), root, body, source, requestedModel, requestPath, headers)
if formatCallback != nil {
- formatCallback(strings.TrimSpace(gjson.GetBytes(out, buildPayloadPath(root, "response_format")).String()))
+ formatCallback(strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(out, buildPayloadPath(root, "response_format")).String())))
}
return out
- }
+ })
+ builtinFinalizerCodePtr.Store(payloadFinalizerKeyPtr(fn))
+ return fn
}
type payloadFinalizerKey struct{}
@@ -46,3 +93,14 @@ func FinalizePayload(ctx context.Context, body []byte) []byte {
}
return body
}
+
+// FinalizePayloadDoc runs immediately before serialization or transport framing,
+// mutating doc in place.
+func FinalizePayloadDoc(ctx context.Context, doc *ojson.Doc) {
+ if doc == nil {
+ return
+ }
+ if finalize, ok := ctx.Value(payloadFinalizerKey{}).(PayloadFinalizer); ok && finalize != nil {
+ finalize.FinalizeDoc(doc)
+ }
+}
diff --git a/internal/runtime/executor/helps/payload_finalizer_test.go b/internal/runtime/executor/helps/payload_finalizer_test.go
index dcbe961f012..a3575af278a 100644
--- a/internal/runtime/executor/helps/payload_finalizer_test.go
+++ b/internal/runtime/executor/helps/payload_finalizer_test.go
@@ -5,13 +5,13 @@ import (
"context"
"mime/multipart"
"net/http"
+ "sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -26,7 +26,7 @@ func TestPayloadFinalizerDefaultsUseOriginalAndNormalizeBeforeRules(t *testing.T
finalize := NewPayloadFinalizer(cfg, "openai", "upstream", "openai", "", []byte(`{"present":"caller"}`), req, opts)
body := []byte(`{"missing":"built-in","present":"caller","late":"injected","tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object","properties":{"count":{"type":"integer"}}}}}]}`)
out := FinalizePayload(WithPayloadFinalizer(context.Background(), finalize), body)
- if gjson.GetBytes(out, "missing").String() != "user default" || gjson.GetBytes(out, "present").String() != "caller" || gjson.GetBytes(out, "late").Exists() || gjson.GetBytes(out, "tools.0.function.parameters.properties.count.type").String() != "number" {
+ if ojson.GetBytes(out, "missing").String() != "user default" || ojson.GetBytes(out, "present").String() != "caller" || ojson.GetBytes(out, "late").Exists() || ojson.GetBytes(out, "tools.0.function.parameters.properties.count.type").String() != "number" {
t.Fatalf("unexpected final payload: %s", out)
}
}
@@ -38,16 +38,17 @@ func TestPayloadFinalizerDevinBusinessFieldsAndCredentials(t *testing.T) {
t.Fatalf("no-op changed protobuf: %v", errUnchanged)
}
final, view, errFinalize := FinalizeDevinPayload(wire, func(body []byte) []byte {
- body, _ = sjson.DeleteBytes(body, "system_prompt")
- body, _ = sjson.DeleteBytes(body, "tools")
- body, _ = sjson.SetBytes(body, "completion_config.max_tokens", 0)
- body, _ = sjson.SetBytes(body, "prompts.0.content", "configured")
- return body
+ doc := ojson.NewDoc(body)
+ _ = doc.Delete("system_prompt")
+ _ = doc.Delete("tools")
+ _ = doc.Set("completion_config.max_tokens", 0)
+ _ = doc.Set("prompts.0.content", "configured")
+ return doc.Bytes()
})
if errFinalize != nil {
t.Fatal(errFinalize)
}
- if gjson.GetBytes(view, "system_prompt").Exists() || gjson.GetBytes(view, "tools").Exists() {
+ if ojson.GetBytes(view, "system_prompt").Exists() || ojson.GetBytes(view, "tools").Exists() {
t.Fatalf("filter undone: %s", view)
}
decoded, errDecode := decodeDevinPayload(final, devinPayloadFields)
@@ -100,7 +101,7 @@ func TestPayloadFinalizerMultipartPreservesFiles(t *testing.T) {
if errView != nil {
t.Fatal(errView)
}
- if gjson.GetBytes(view, "model").String() != "configured" || gjson.GetBytes(view, "image.data").String() != "AP8NCg==" {
+ if ojson.GetBytes(view, "model").String() != "configured" || ojson.GetBytes(view, "image.data").String() != "AP8NCg==" {
t.Fatalf("unexpected multipart business payload: %s", view)
}
}
@@ -113,10 +114,297 @@ func TestPayloadRulesMatchFinalBodyAndTrackPaths(t *testing.T) {
}}
original := []byte(`{"max_tokens":100,"messages":[{},{}]}`)
body, touched := ApplyPayloadConfigWithTrackedPaths(cfg, "model", "claude", "claude", "", []byte(`{"max_tokens":300,"context_management":{"builtin":true},"messages":[{},{},{}]}`), original, "model", "", nil, "diagnostics", "context_management")
- if gjson.GetBytes(body, "unexpected").Exists() || !gjson.GetBytes(body, "diagnostics.user").Bool() || gjson.GetBytes(body, "context_management").Exists() || gjson.GetBytes(body, "messages.#").Int() != 2 {
+ if ojson.GetBytes(body, "unexpected").Exists() || !ojson.GetBytes(body, "diagnostics.user").Bool() || ojson.GetBytes(body, "context_management").Exists() || ojson.GetBytes(body, "messages.#").Int() != 2 {
t.Fatalf("rules did not match final body: %s", body)
}
if !touched["diagnostics"] || !touched["context_management"] {
t.Fatalf("final tracking lost: %v", touched)
}
}
+
+func TestFinalizePayloadDocCrossMethodPipeline(t *testing.T) {
+ original := []byte(`{"model":"gpt-4o","temperature":0.7}`)
+ cfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Default: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "gpt-4o"}},
+ Params: map[string]any{"temperature": 0.2, "top_p": 0.9},
+ }},
+ Override: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "gpt-4o"}},
+ Params: map[string]any{"max_tokens": 2048},
+ }},
+ Filter: []config.PayloadFilterRule{{
+ Models: []config.PayloadModelRule{{Name: "gpt-4o"}},
+ Params: []string{"extra"},
+ }},
+ },
+ }
+ req := cliproxyexecutor.Request{Model: "gpt-4o", Payload: original}
+ opts := cliproxyexecutor.Options{}
+ ctx := WithPayloadFinalizer(context.Background(), NewPayloadFinalizer(cfg, "openai", "gpt-4o", "openai", "", original, req, opts))
+
+ doc := ojson.NewDoc([]byte(`{"model":"gpt-4o","temperature":0.7,"extra":true}`))
+ SetStringIfDifferentDoc(doc, "model", "gpt-4o")
+ SetBoolIfDifferentDoc(doc, "stream", true)
+ FinalizePayloadDoc(ctx, doc)
+ out := doc.Bytes()
+
+ if got := ojson.GetBytesNoCopy(out, "temperature").Float(); got != 0.7 {
+ t.Fatalf("temperature = %v, want 0.7 (preserved from original)", got)
+ }
+ if got := ojson.GetBytesNoCopy(out, "top_p").Float(); got != 0.9 {
+ t.Fatalf("top_p = %v, want 0.9", got)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 2048 {
+ t.Fatalf("max_tokens = %d, want 2048", got)
+ }
+ if ojson.GetBytesNoCopy(out, "extra").Exists() {
+ t.Fatalf("expected extra to be filtered out: %s", string(out))
+ }
+}
+
+func TestExecutorHelpersDocCrossMethodPipelines(t *testing.T) {
+ t.Run("claude doc pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"claude-sonnet-5-5","tool_choice":{"type":"tool","name":"calc"},"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.280; cc_prev_req=old;"},{"type":"text","text":"secret word here"}],"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"bad id!","name":"calc","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"bad id!","content":"secret word here"}]}]}`)
+ doc := ojson.NewDoc(raw)
+ matcher := BuildSensitiveWordMatcher([]string{"secret"})
+ if !NormalizeClaudeToolChoiceDoc("claude", "claude-sonnet-5-5", doc) {
+ t.Fatal("expected NormalizeClaudeToolChoiceDoc to mutate doc")
+ }
+ if !NormalizeClaudeToolCallIDsDoc(doc) {
+ t.Fatal("expected NormalizeClaudeToolCallIDsDoc to mutate doc")
+ }
+ if !ObfuscateSensitiveWordsDoc(doc, matcher) {
+ t.Fatal("expected ObfuscateSensitiveWordsDoc to mutate doc")
+ }
+ if !StripClaudeBillingTagsDoc(doc) {
+ t.Fatal("expected StripClaudeBillingTagsDoc to mutate doc")
+ }
+ if !InjectClaudeBillingTagsDoc(doc, "prev1", "prompt1") {
+ t.Fatal("expected InjectClaudeBillingTagsDoc to mutate doc")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "auto" {
+ t.Fatalf("tool_choice.type = %q, want auto", got)
+ }
+ if ojson.GetBytesNoCopy(out, "tool_choice.name").Exists() {
+ t.Fatalf("expected tool_choice.name to be removed: %s", out)
+ }
+ useID := ojson.GetBytesNoCopy(out, "messages.0.content.0.id").String()
+ resID := ojson.GetBytesNoCopy(out, "messages.1.content.0.tool_use_id").String()
+ if useID == "bad id!" || useID != resID {
+ t.Fatalf("tool call IDs not normalized consistently: %q vs %q", useID, resID)
+ }
+ })
+
+ t.Run("openai compat doc pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"gpt-4o","max_tokens":512,"messages":[{"role":"tool","tool_call_id":"call_1","content":[{"type":"text","text":"ok"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}]}`)
+ doc := ojson.NewDoc(raw)
+ if !NormalizeOpenAIMaxTokensDoc(doc, true) {
+ t.Fatal("expected NormalizeOpenAIMaxTokensDoc to mutate doc")
+ }
+ if !NormalizeOpenAIToolResultsTextOnlyDoc(doc) {
+ t.Fatal("expected NormalizeOpenAIToolResultsTextOnlyDoc to mutate doc")
+ }
+ out := doc.Bytes()
+ if ojson.GetBytesNoCopy(out, "max_tokens").Exists() || ojson.GetBytesNoCopy(out, "max_completion_tokens").Int() != 512 {
+ t.Fatalf("unexpected max_completion_tokens normalization: %s", out)
+ }
+ if ojson.GetBytesNoCopy(out, "messages.0.content").Type != ojson.String {
+ t.Fatalf("expected tool content to be flattened to string: %s", out)
+ }
+ })
+
+ t.Run("codex and responses doc pipeline", func(t *testing.T) {
+ raw := []byte(`{"model":"gpt-5","tools":[{"type":"tool_search","execution":"client"},{"type":"web_search","search_content_types":["text"]},{"type":"function","name":"fn","parameters":{"type":"object"}}],"input":[{"type":"message","id":"bad_id","role":"user","content":"hi"},{"type":"tool_search_call","id":"ts_1","call_id":"c1","execution":"client"}]}`)
+ doc := ojson.NewDoc(raw)
+ if !StripResponsesToolSearchDoc(doc) {
+ t.Fatal("expected StripResponsesToolSearchDoc to mutate doc")
+ }
+ if !SanitizeCodexInputItemIDsDoc(doc) {
+ t.Fatal("expected SanitizeCodexInputItemIDsDoc to mutate doc")
+ }
+ if !SanitizeMetaWebSearchToolsDoc(doc) {
+ t.Fatal("expected SanitizeMetaWebSearchToolsDoc to mutate doc")
+ }
+ if !EnsureMetaMuseToolNamespaceDoc(doc) {
+ t.Fatal("expected EnsureMetaMuseToolNamespaceDoc to mutate doc")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "input.0.id").String(); got != "msg_bad_id" {
+ t.Fatalf("input.0.id = %q, want msg_bad_id", got)
+ }
+ if len(ojson.GetBytesNoCopy(out, "input").Array()) != 1 {
+ t.Fatalf("expected tool_search_call to be stripped: %s", out)
+ }
+ if ojson.GetBytesNoCopy(out, "tools.0.search_content_types").Exists() {
+ t.Fatalf("expected search_content_types to be stripped: %s", out)
+ }
+ })
+
+ t.Run("gemini and vertex doc pipeline", func(t *testing.T) {
+ raw := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call_1","name":"fn","args":{}}},{"thought":true,"text":"thinking"}]},{"role":"model","parts":[{"thought":true,"text":"trailing thought"}]}]}`)
+ doc := ojson.NewDoc(raw)
+ if !DropGeminiTrailingThoughtOnlyContentDoc(doc, "contents") {
+ t.Fatal("expected DropGeminiTrailingThoughtOnlyContentDoc to mutate doc")
+ }
+ if !StripVertexOpenAIResponsesToolCallIDsDoc(doc, "openai-response") {
+ t.Fatal("expected StripVertexOpenAIResponsesToolCallIDsDoc to mutate doc")
+ }
+ if !EnsureGeminiBoundaryUserContentDoc(doc, "contents") {
+ t.Fatal("expected EnsureGeminiBoundaryUserContentDoc to mutate doc")
+ }
+ out := doc.Bytes()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
+ if len(contents) != 3 {
+ t.Fatalf("len(contents) = %d, want 3: %s", len(contents), out)
+ }
+ if contents[0].Get("role").String() != "user" || contents[2].Get("role").String() != "user" {
+ t.Fatalf("expected leading and trailing user turns: %s", out)
+ }
+ if contents[1].Get("parts.0.functionCall.id").Exists() {
+ t.Fatalf("expected functionCall.id to be stripped: %s", out)
+ }
+ })
+}
+
+func TestFinalizeDocCustomWrapperCalledOnceAndPreservesOriginalFromBody(t *testing.T) {
+ cfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Default: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "devin-model"}},
+ Params: map[string]any{
+ "completion_config.max_tokens": 9999,
+ "system_prompt": "default-system",
+ },
+ }},
+ },
+ }
+ req := cliproxyexecutor.Request{
+ Model: "devin-model",
+ Payload: []byte(`{"max_tokens":2048}`),
+ }
+ opts := cliproxyexecutor.Options{}
+
+ callCount := 0
+ wrapper := PayloadFinalizer(func(body []byte) []byte {
+ callCount++
+ original := body
+ if !ojson.GetBytesNoCopy(req.Payload, "max_tokens").Exists() {
+ original, _ = ojson.DeleteBytes(original, "completion_config.max_tokens")
+ }
+ return NewPayloadFinalizer(cfg, "devin", "devin-model", "openai", "", original, req, opts)(body)
+ })
+
+ doc := ojson.NewDoc([]byte(`{"completion_config":{"max_tokens":2048}}`))
+ wrapper.FinalizeDoc(doc)
+ if callCount != 1 {
+ t.Fatalf("custom PayloadFinalizer callCount = %d, want 1", callCount)
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "completion_config.max_tokens").Int(); got != 2048 {
+ t.Fatalf("completion_config.max_tokens = %d, want 2048 (preserved from body-derived original): %s", got, out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "system_prompt").String(); got != "default-system" {
+ t.Fatalf("system_prompt = %q, want default-system: %s", got, out)
+ }
+}
+
+func TestApplyPayloadConfigDocSequentialDefaultObjectAndNestedField(t *testing.T) {
+ cfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Default: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-5"}},
+ Params: map[string]any{"metadata": map[string]any{"x": 1}},
+ },
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-5"}},
+ Params: map[string]any{"metadata.x": 2, "metadata.y": 3},
+ },
+ },
+ },
+ }
+ raw := []byte(`{"model":"gpt-5"}`)
+ want := ApplyPayloadConfigWithRoot(cfg, "gpt-5", "openai", "", raw, nil, "", "")
+
+ doc := ojson.NewDoc(raw)
+ ApplyPayloadConfigWithRootDoc(cfg, "gpt-5", "openai", "", doc, nil, "", "")
+ got := doc.Bytes()
+
+ if ojson.GetBytesNoCopy(got, "metadata.x").Int() != ojson.GetBytesNoCopy(want, "metadata.x").Int() {
+ t.Fatalf("doc metadata.x = %d, want %d (got=%s want=%s)",
+ ojson.GetBytesNoCopy(got, "metadata.x").Int(),
+ ojson.GetBytesNoCopy(want, "metadata.x").Int(),
+ got, want)
+ }
+ if ojson.GetBytesNoCopy(got, "metadata.y").Int() != 3 {
+ t.Fatalf("doc metadata.y = %d, want 3: %s", ojson.GetBytesNoCopy(got, "metadata.y").Int(), got)
+ }
+
+ arrCfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Default: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-5"}},
+ Params: map[string]any{"items.2": 2},
+ },
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-5"}},
+ Params: map[string]any{"items.0": 1},
+ },
+ },
+ },
+ }
+ arrRaw := []byte(`{"model":"gpt-5","items":[]}`)
+ arrWant := ApplyPayloadConfigWithRoot(arrCfg, "gpt-5", "openai", "", arrRaw, nil, "", "")
+ arrDoc := ojson.NewDoc(arrRaw)
+ ApplyPayloadConfigWithRootDoc(arrCfg, "gpt-5", "openai", "", arrDoc, nil, "", "")
+ arrGot := arrDoc.Bytes()
+ if ojson.GetBytesNoCopy(arrGot, "items.0").Int() != 1 || ojson.GetBytesNoCopy(arrGot, "items.2").Int() != 2 || !bytes.Equal(arrGot, arrWant) {
+ t.Fatalf("sparse array default mismatch:\ngot %s\nwant %s", arrGot, arrWant)
+ }
+
+ hugeArrCfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Default: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "gpt-5"}},
+ Params: map[string]any{"items.1000000000": 1},
+ },
+ },
+ },
+ }
+ hugeArrRaw := []byte(`{"model":"gpt-5","items":[]}`)
+ hugeArrDoc := ojson.NewDoc(hugeArrRaw)
+ ApplyPayloadConfigWithRootDoc(hugeArrCfg, "gpt-5", "openai", "", hugeArrDoc, nil, "", "")
+ if got := string(hugeArrDoc.Bytes()); got != string(hugeArrRaw) {
+ t.Fatalf("huge index array default should be ignored without OOM: got %s, want %s", got, hugeArrRaw)
+ }
+}
+
+func TestFinalizeDocConcurrentWithNewPayloadFinalizer(t *testing.T) {
+ builtinFinalizerCodePtr.Store(0)
+ cfg := &config.Config{}
+ req := cliproxyexecutor.Request{Model: "gpt-4o", Payload: []byte(`{"model":"gpt-4o"}`)}
+ opts := cliproxyexecutor.Options{}
+
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ custom := PayloadFinalizer(func(body []byte) []byte { return body })
+ doc := ojson.NewDoc([]byte(`{"model":"gpt-4o"}`))
+ custom.FinalizeDoc(doc)
+ }()
+ go func() {
+ defer wg.Done()
+ fn := NewPayloadFinalizer(cfg, "openai", "gpt-4o", "openai", "", req.Payload, req, opts)
+ doc := ojson.NewDoc([]byte(`{"model":"gpt-4o"}`))
+ fn.FinalizeDoc(doc)
+ }()
+ }
+ wg.Wait()
+}
diff --git a/internal/runtime/executor/helps/payload_helpers.go b/internal/runtime/executor/helps/payload_helpers.go
index c10487e892f..b53bafba4e8 100644
--- a/internal/runtime/executor/helps/payload_helpers.go
+++ b/internal/runtime/executor/helps/payload_helpers.go
@@ -7,11 +7,11 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ApplyPayloadConfigWithRoot behaves like applyPayloadConfig but treats all parameter
@@ -24,18 +24,33 @@ func ApplyPayloadConfigWithRoot(cfg *config.Config, model, protocol, root string
return ApplyPayloadConfigWithRequest(cfg, model, protocol, "", root, payload, original, requestedModel, requestPath, nil)
}
+// ApplyPayloadConfigWithRootDoc applies payload config in place on doc with parameter paths relative to root.
+func ApplyPayloadConfigWithRootDoc(cfg *config.Config, model, protocol, root string, doc *ojson.Doc, original []byte, requestedModel string, requestPath string) {
+ ApplyPayloadConfigWithRequestDoc(cfg, model, protocol, "", root, doc, original, requestedModel, requestPath, nil)
+}
+
// ApplyPayloadConfigWithRequest applies payload config using source protocol and request header gates.
func ApplyPayloadConfigWithRequest(cfg *config.Config, model, protocol, fromProtocol, root string, payload, original []byte, requestedModel string, requestPath string, headers http.Header) []byte {
out, _ := ApplyPayloadConfigWithRequestTracked(cfg, model, protocol, fromProtocol, root, payload, original, requestedModel, requestPath, headers, "")
return out
}
+// ApplyPayloadConfigWithRequestDoc applies payload config in place on doc using source protocol and request header gates.
+func ApplyPayloadConfigWithRequestDoc(cfg *config.Config, model, protocol, fromProtocol, root string, doc *ojson.Doc, original []byte, requestedModel string, requestPath string, headers http.Header) {
+ _ = ApplyPayloadConfigWithRequestTrackedDoc(cfg, model, protocol, fromProtocol, root, doc, original, requestedModel, requestPath, headers, "")
+}
+
// ApplyPayloadConfigWithRequestForExecutor applies payload config with explicit target executor context.
func ApplyPayloadConfigWithRequestForExecutor(cfg *config.Config, targetExecutor, model, protocol, fromProtocol, root string, payload, original []byte, requestedModel string, requestPath string, headers http.Header) []byte {
out, _ := ApplyPayloadConfigWithTrackedPathsForExecutor(cfg, targetExecutor, model, protocol, fromProtocol, root, payload, original, requestedModel, requestPath, headers)
return out
}
+// ApplyPayloadConfigWithRequestForExecutorDoc applies payload config in place on doc with explicit target executor context.
+func ApplyPayloadConfigWithRequestForExecutorDoc(cfg *config.Config, targetExecutor, model, protocol, fromProtocol, root string, doc *ojson.Doc, original []byte, requestedModel string, requestPath string, headers http.Header) {
+ _ = ApplyPayloadConfigWithTrackedPathsForExecutorDoc(cfg, targetExecutor, model, protocol, fromProtocol, root, doc, original, requestedModel, requestPath, headers)
+}
+
func isCodexTargetExecutor(targetExecutor string) bool {
te := strings.ToLower(strings.TrimSpace(targetExecutor))
return te == "codex" || te == "codex-websockets" || te == "codex_websockets"
@@ -47,22 +62,48 @@ func ApplyPayloadConfigWithTrackedPaths(cfg *config.Config, model, protocol, fro
return ApplyPayloadConfigWithTrackedPathsForExecutor(cfg, "", model, protocol, fromProtocol, root, payload, original, requestedModel, requestPath, headers, trackedPaths...)
}
+// ApplyPayloadConfigWithTrackedPathsDoc applies payload config in place on doc and reports which
+// tracked paths (or their descendants) were targeted by an applied rule.
+func ApplyPayloadConfigWithTrackedPathsDoc(cfg *config.Config, model, protocol, fromProtocol, root string, doc *ojson.Doc, original []byte, requestedModel string, requestPath string, headers http.Header, trackedPaths ...string) map[string]bool {
+ return ApplyPayloadConfigWithTrackedPathsForExecutorDoc(cfg, "", model, protocol, fromProtocol, root, doc, original, requestedModel, requestPath, headers, trackedPaths...)
+}
+
// ApplyPayloadConfigWithTrackedPathsForExecutor applies payload config with target executor awareness.
// When headers indicate a Codex client and the target executor is not Codex or Codex WebSocket,
// it normalizes tool parameter integer types to satisfy client-side integer deserialization (#6237).
// For Codex and Codex WebSocket targets, integer normalization is skipped (#6244).
func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExecutor, model, protocol, fromProtocol, root string, payload, original []byte, requestedModel string, requestPath string, headers http.Header, trackedPaths ...string) ([]byte, map[string]bool) {
- touched := make(map[string]bool)
if len(payload) == 0 {
+ return payload, make(map[string]bool)
+ }
+ doc := ojson.NewDoc(payload)
+ touched, mutated := applyPayloadConfigWithTrackedPathsForExecutorDoc(cfg, targetExecutor, model, protocol, fromProtocol, root, doc, original, requestedModel, requestPath, headers, trackedPaths...)
+ if !mutated {
return payload, touched
}
+ return doc.Bytes(), touched
+}
+
+// ApplyPayloadConfigWithTrackedPathsForExecutorDoc applies payload config in place on doc with target executor awareness.
+func ApplyPayloadConfigWithTrackedPathsForExecutorDoc(cfg *config.Config, targetExecutor, model, protocol, fromProtocol, root string, doc *ojson.Doc, original []byte, requestedModel, requestPath string, headers http.Header, trackedPaths ...string) map[string]bool {
+ touched, _ := applyPayloadConfigWithTrackedPathsForExecutorDoc(cfg, targetExecutor, model, protocol, fromProtocol, root, doc, original, requestedModel, requestPath, headers, trackedPaths...)
+ return touched
+}
+
+func applyPayloadConfigWithTrackedPathsForExecutorDoc(cfg *config.Config, targetExecutor, model, protocol, fromProtocol, root string, doc *ojson.Doc, original []byte, requestedModel, requestPath string, headers http.Header, trackedPaths ...string) (map[string]bool, bool) {
+ touched := make(map[string]bool)
+ if doc == nil || doc.Len() == 0 {
+ return touched, false
+ }
+ mutated := false
if IsCodexUserAgent(headers) && !isCodexTargetExecutor(targetExecutor) {
- payload = NormalizeCodexToolIntegerTypes(payload, headers)
+ if NormalizeCodexToolIntegerTypesDoc(doc, headers) {
+ mutated = true
+ }
}
if cfg == nil {
- return payload, touched
+ return touched, mutated
}
- out := payload
markTouched := func(resolvedPath string) {
for _, tp := range trackedPaths {
@@ -73,11 +114,17 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
}
}
+ var strippedToolChoicePath string
// Apply disable-image-generation filtering before payload rules so config payload
// overrides can explicitly re-enable image_generation when desired.
if shouldStripImageGeneration(cfg.DisableImageGeneration, requestPath) {
- out = removeToolTypeFromPayloadWithRoot(out, root, "image_generation")
- out = removeToolChoiceFromPayloadWithRoot(out, root, "image_generation")
+ if removeToolTypeFromPayloadWithRootDoc(doc, root, "image_generation") {
+ mutated = true
+ }
+ if removeToolChoiceFromPayloadWithRootDoc(doc, root, "image_generation") {
+ mutated = true
+ strippedToolChoicePath = buildPayloadPath(root, "tool_choice")
+ }
}
rules := cfg.Payload
@@ -87,15 +134,75 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
requestedModel = strings.TrimSpace(requestedModel)
if model != "" || requestedModel != "" {
candidates := payloadModelCandidates(model, requestedModel)
- source := original
- if len(source) == 0 {
- source = payload
- }
appliedDefaults := make(map[string]struct{})
+ var createdByDefault map[string]struct{}
+ var initialArrayLengths map[string]int
+ sourcePathExists := func(resolvedPath string) bool {
+ if len(original) > 0 {
+ return ojson.GetBytesNoCopy(original, resolvedPath).Exists()
+ }
+ if strippedToolChoicePath != "" && resolvedPath == strippedToolChoicePath {
+ return true
+ }
+ if createdByDefault != nil || initialArrayLengths != nil {
+ if _, created := createdByDefault[resolvedPath]; created {
+ return false
+ }
+ for idx := 0; idx < len(resolvedPath); idx++ {
+ if resolvedPath[idx] == '.' && (idx == 0 || resolvedPath[idx-1] != '\\') {
+ prefix := resolvedPath[:idx]
+ if _, created := createdByDefault[prefix]; created {
+ return false
+ }
+ if initLen, ok := initialArrayLengths[prefix]; ok {
+ nextEnd := len(resolvedPath)
+ for j := idx + 1; j < len(resolvedPath); j++ {
+ if resolvedPath[j] == '.' && resolvedPath[j-1] != '\\' {
+ nextEnd = j
+ break
+ }
+ }
+ if arrIdx, errIdx := strconv.Atoi(resolvedPath[idx+1 : nextEnd]); errIdx == nil && arrIdx >= initLen {
+ return false
+ }
+ }
+ }
+ }
+ }
+ return doc.Get(resolvedPath).Exists()
+ }
+ recordDefaultCreation := func(resolvedPath string) {
+ if len(original) > 0 {
+ return
+ }
+ if createdByDefault == nil {
+ createdByDefault = make(map[string]struct{})
+ }
+ for idx := 0; idx < len(resolvedPath); idx++ {
+ if resolvedPath[idx] == '.' && (idx == 0 || resolvedPath[idx-1] != '\\') {
+ prefix := resolvedPath[:idx]
+ prefixNode := doc.Get(prefix)
+ if !prefixNode.Exists() {
+ createdByDefault[prefix] = struct{}{}
+ } else if prefixNode.IsArray() {
+ if initialArrayLengths == nil {
+ initialArrayLengths = make(map[string]int)
+ }
+ if _, recorded := initialArrayLengths[prefix]; !recorded {
+ initialArrayLengths[prefix] = len(prefixNode.Array())
+ }
+ }
+ }
+ }
+ if !doc.Get(resolvedPath).Exists() {
+ createdByDefault[resolvedPath] = struct{}{}
+ }
+ }
+
// Apply default rules: first write wins per field across all matching rules.
for i := range rules.Default {
rule := &rules.Default[i]
- if !payloadModelRulesMatch(rule.Models, protocol, fromProtocol, headers, out, root, candidates) {
+ if !payloadModelRulesMatchDoc(rule.Models, protocol, fromProtocol, headers, doc, root, candidates) {
continue
}
for path, value := range rule.Params {
@@ -103,18 +210,18 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
if fullPath == "" {
continue
}
- for _, resolvedPath := range resolvePayloadRulePaths(out, fullPath) {
- if gjson.GetBytes(source, resolvedPath).Exists() {
+ for _, resolvedPath := range resolvePayloadRulePathsDoc(doc, fullPath) {
+ if sourcePathExists(resolvedPath) {
continue
}
if _, ok := appliedDefaults[resolvedPath]; ok {
continue
}
- updated, errSet := sjson.SetBytes(out, resolvedPath, value)
- if errSet != nil {
+ recordDefaultCreation(resolvedPath)
+ if errSet := doc.Set(resolvedPath, value); errSet != nil {
continue
}
- out = updated
+ mutated = true
appliedDefaults[resolvedPath] = struct{}{}
markTouched(resolvedPath)
}
@@ -123,7 +230,7 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
// Apply default raw rules: first write wins per field across all matching rules.
for i := range rules.DefaultRaw {
rule := &rules.DefaultRaw[i]
- if !payloadModelRulesMatch(rule.Models, protocol, fromProtocol, headers, out, root, candidates) {
+ if !payloadModelRulesMatchDoc(rule.Models, protocol, fromProtocol, headers, doc, root, candidates) {
continue
}
for path, value := range rule.Params {
@@ -131,8 +238,8 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
if fullPath == "" {
continue
}
- for _, resolvedPath := range resolvePayloadRulePaths(out, fullPath) {
- if gjson.GetBytes(source, resolvedPath).Exists() {
+ for _, resolvedPath := range resolvePayloadRulePathsDoc(doc, fullPath) {
+ if sourcePathExists(resolvedPath) {
continue
}
if _, ok := appliedDefaults[resolvedPath]; ok {
@@ -142,11 +249,11 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
if !ok {
continue
}
- updated, errSet := sjson.SetRawBytes(out, resolvedPath, rawValue)
- if errSet != nil {
+ recordDefaultCreation(resolvedPath)
+ if errSet := doc.SetRawBytes(resolvedPath, rawValue); errSet != nil {
continue
}
- out = updated
+ mutated = true
appliedDefaults[resolvedPath] = struct{}{}
markTouched(resolvedPath)
}
@@ -155,7 +262,7 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
// Apply override rules: last write wins per field across all matching rules.
for i := range rules.Override {
rule := &rules.Override[i]
- if !payloadModelRulesMatch(rule.Models, protocol, fromProtocol, headers, out, root, candidates) {
+ if !payloadModelRulesMatchDoc(rule.Models, protocol, fromProtocol, headers, doc, root, candidates) {
continue
}
for path, value := range rule.Params {
@@ -163,9 +270,11 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
if fullPath == "" {
continue
}
- for _, resolvedPath := range resolvePayloadRulePaths(out, fullPath) {
- var applied bool
- out, applied = setPayloadValueIfDifferentTracked(out, resolvedPath, value)
+ for _, resolvedPath := range resolvePayloadRulePathsDoc(doc, fullPath) {
+ changed, applied := setPayloadValueIfDifferentDocTracked(doc, resolvedPath, value)
+ if changed {
+ mutated = true
+ }
if applied {
markTouched(resolvedPath)
}
@@ -175,7 +284,7 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
// Apply override raw rules: last write wins per field across all matching rules.
for i := range rules.OverrideRaw {
rule := &rules.OverrideRaw[i]
- if !payloadModelRulesMatch(rule.Models, protocol, fromProtocol, headers, out, root, candidates) {
+ if !payloadModelRulesMatchDoc(rule.Models, protocol, fromProtocol, headers, doc, root, candidates) {
continue
}
for path, value := range rule.Params {
@@ -187,9 +296,11 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
if !ok {
continue
}
- for _, resolvedPath := range resolvePayloadRulePaths(out, fullPath) {
- var applied bool
- out, applied = setPayloadRawValueIfDifferentTracked(out, resolvedPath, rawValue)
+ for _, resolvedPath := range resolvePayloadRulePathsDoc(doc, fullPath) {
+ changed, applied := setPayloadRawValueIfDifferentDocTracked(doc, resolvedPath, rawValue)
+ if changed {
+ mutated = true
+ }
if applied {
markTouched(resolvedPath)
}
@@ -199,7 +310,7 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
// Apply filter rules: remove matching paths from payload.
for i := range rules.Filter {
rule := &rules.Filter[i]
- if !payloadModelRulesMatch(rule.Models, protocol, fromProtocol, headers, out, root, candidates) {
+ if !payloadModelRulesMatchDoc(rule.Models, protocol, fromProtocol, headers, doc, root, candidates) {
continue
}
for _, path := range rule.Params {
@@ -207,21 +318,23 @@ func ApplyPayloadConfigWithTrackedPathsForExecutor(cfg *config.Config, targetExe
if fullPath == "" {
continue
}
- resolvedPaths := resolvePayloadRulePaths(out, fullPath)
- for i := len(resolvedPaths) - 1; i >= 0; i-- {
- resolvedPath := resolvedPaths[i]
- updated, errDel := sjson.DeleteBytes(out, resolvedPath)
- if errDel != nil {
+ resolvedPaths := resolvePayloadRulePathsDoc(doc, fullPath)
+ for idx := len(resolvedPaths) - 1; idx >= 0; idx-- {
+ resolvedPath := resolvedPaths[idx]
+ existed := doc.Get(resolvedPath).Exists()
+ if errDel := doc.Delete(resolvedPath); errDel != nil {
continue
}
- out = updated
+ if existed {
+ mutated = true
+ }
markTouched(resolvedPath)
}
}
}
}
}
- return out, touched
+ return touched, mutated
}
// ApplyPayloadConfigWithRequestTracked applies payload config and reports whether
@@ -231,6 +344,13 @@ func ApplyPayloadConfigWithRequestTracked(cfg *config.Config, model, protocol, f
return out, touched[trackedPath]
}
+// ApplyPayloadConfigWithRequestTrackedDoc applies payload config in place on doc and reports whether
+// an applied rule targeted trackedPath or one of its descendants.
+func ApplyPayloadConfigWithRequestTrackedDoc(cfg *config.Config, model, protocol, fromProtocol, root string, doc *ojson.Doc, original []byte, requestedModel string, requestPath string, headers http.Header, trackedPath string) bool {
+ touched := ApplyPayloadConfigWithTrackedPathsDoc(cfg, model, protocol, fromProtocol, root, doc, original, requestedModel, requestPath, headers, trackedPath)
+ return touched[trackedPath]
+}
+
func isImagesEndpointRequestPath(path string) bool {
path = strings.TrimSpace(path)
if path == "" {
@@ -266,8 +386,8 @@ func shouldStripImageGeneration(mode config.DisableImageGenerationMode, requestP
}
}
-func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, fromProtocol string, headers http.Header, payload []byte, root string, models []string) bool {
- if len(rules) == 0 || len(models) == 0 {
+func payloadModelRulesMatchDoc(rules []config.PayloadModelRule, protocol string, fromProtocol string, headers http.Header, doc *ojson.Doc, root string, models []string) bool {
+ if len(rules) == 0 || len(models) == 0 || doc == nil {
return false
}
for _, model := range models {
@@ -288,7 +408,7 @@ func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, fr
if !matchModelPattern(name, model) {
continue
}
- if payloadModelRuleConditionsMatch(payload, root, entry) {
+ if payloadModelRuleConditionsMatchDoc(doc, root, entry) {
return true
}
}
@@ -296,29 +416,29 @@ func payloadModelRulesMatch(rules []config.PayloadModelRule, protocol string, fr
return false
}
-func payloadModelRuleConditionsMatch(payload []byte, root string, rule config.PayloadModelRule) bool {
- if !payloadMatchConditionsMatch(payload, root, rule.Match) {
+func payloadModelRuleConditionsMatchDoc(doc *ojson.Doc, root string, rule config.PayloadModelRule) bool {
+ if !payloadMatchConditionsMatchDoc(doc, root, rule.Match) {
return false
}
- if !payloadNotMatchConditionsMatch(payload, root, rule.NotMatch) {
+ if !payloadNotMatchConditionsMatchDoc(doc, root, rule.NotMatch) {
return false
}
- if !payloadExistConditionsMatch(payload, root, rule.Exist) {
+ if !payloadExistConditionsMatchDoc(doc, root, rule.Exist) {
return false
}
- if !payloadNotExistConditionsMatch(payload, root, rule.NotExist) {
+ if !payloadNotExistConditionsMatchDoc(doc, root, rule.NotExist) {
return false
}
return true
}
-func payloadMatchConditionsMatch(payload []byte, root string, conditions []map[string]any) bool {
+func payloadMatchConditionsMatchDoc(doc *ojson.Doc, root string, conditions []map[string]any) bool {
for _, condition := range conditions {
for path, value := range condition {
if strings.TrimSpace(path) == "" {
continue
}
- if !payloadPathMatchesValue(payload, buildPayloadPath(root, path), value) {
+ if !payloadPathMatchesValueDoc(doc, buildPayloadPath(root, path), value) {
return false
}
}
@@ -326,13 +446,13 @@ func payloadMatchConditionsMatch(payload []byte, root string, conditions []map[s
return true
}
-func payloadNotMatchConditionsMatch(payload []byte, root string, conditions []map[string]any) bool {
+func payloadNotMatchConditionsMatchDoc(doc *ojson.Doc, root string, conditions []map[string]any) bool {
for _, condition := range conditions {
for path, value := range condition {
if strings.TrimSpace(path) == "" {
continue
}
- if payloadPathMatchesValue(payload, buildPayloadPath(root, path), value) {
+ if payloadPathMatchesValueDoc(doc, buildPayloadPath(root, path), value) {
return false
}
}
@@ -340,33 +460,56 @@ func payloadNotMatchConditionsMatch(payload []byte, root string, conditions []ma
return true
}
-func payloadExistConditionsMatch(payload []byte, root string, paths []string) bool {
+func payloadExistConditionsMatchDoc(doc *ojson.Doc, root string, paths []string) bool {
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
- if !payloadPathExists(payload, buildPayloadPath(root, path)) {
+ if !payloadPathExistsDoc(doc, buildPayloadPath(root, path)) {
return false
}
}
return true
}
-func payloadNotExistConditionsMatch(payload []byte, root string, paths []string) bool {
+func payloadNotExistConditionsMatchDoc(doc *ojson.Doc, root string, paths []string) bool {
for _, path := range paths {
if strings.TrimSpace(path) == "" {
continue
}
- if payloadPathExists(payload, buildPayloadPath(root, path)) {
+ if payloadPathExistsDoc(doc, buildPayloadPath(root, path)) {
return false
}
}
return true
}
+func payloadPathMatchesValueDoc(doc *ojson.Doc, path string, value any) bool {
+ for _, resolvedPath := range resolvePayloadRulePathsDoc(doc, path) {
+ result := doc.Get(resolvedPath)
+ if !result.Exists() {
+ continue
+ }
+ if payloadResultEquals(result, value) {
+ return true
+ }
+ }
+ return false
+}
+
+func payloadPathExistsDoc(doc *ojson.Doc, path string) bool {
+ for _, resolvedPath := range resolvePayloadRulePathsDoc(doc, path) {
+ result := doc.Get(resolvedPath)
+ if result.Exists() && result.Type != ojson.Null {
+ return true
+ }
+ }
+ return false
+}
+
func payloadPathMatchesValue(payload []byte, path string, value any) bool {
for _, resolvedPath := range resolvePayloadRulePaths(payload, path) {
- result := gjson.GetBytes(payload, resolvedPath)
+ result := ojson.GetBytesNoCopy(payload, resolvedPath)
if !result.Exists() {
continue
}
@@ -379,15 +522,15 @@ func payloadPathMatchesValue(payload []byte, path string, value any) bool {
func payloadPathExists(payload []byte, path string) bool {
for _, resolvedPath := range resolvePayloadRulePaths(payload, path) {
- result := gjson.GetBytes(payload, resolvedPath)
- if result.Exists() && result.Type != gjson.Null {
+ result := ojson.GetBytesNoCopy(payload, resolvedPath)
+ if result.Exists() && result.Type != ojson.Null {
return true
}
}
return false
}
-func payloadResultEquals(result gjson.Result, value any) bool {
+func payloadResultEquals(result ojson.Result, value any) bool {
actual, ok := normalizedPayloadResult(result)
if !ok {
return false
@@ -399,7 +542,7 @@ func payloadResultEquals(result gjson.Result, value any) bool {
return reflect.DeepEqual(actual, expected)
}
-func normalizedPayloadResult(result gjson.Result) (any, bool) {
+func normalizedPayloadResult(result ojson.Result) (any, bool) {
if !result.Exists() {
return nil, false
}
@@ -556,7 +699,20 @@ func payloadRuleTargetsPath(path, trackedPath string) bool {
return path == trackedPath || strings.HasPrefix(path, trackedPath+".") || strings.HasPrefix(trackedPath, path+".")
}
+func resolvePayloadRulePathsDoc(doc *ojson.Doc, path string) []string {
+ if doc == nil {
+ return nil
+ }
+ return resolvePayloadRulePathsFromGetter(doc.Get, path)
+}
+
func resolvePayloadRulePaths(payload []byte, path string) []string {
+ return resolvePayloadRulePathsFromGetter(func(p string) ojson.Result {
+ return payloadValueAtPath(payload, p)
+ }, path)
+}
+
+func resolvePayloadRulePathsFromGetter(get func(string) ojson.Result, path string) []string {
path = strings.TrimSpace(path)
if path == "" {
return nil
@@ -579,7 +735,7 @@ func resolvePayloadRulePaths(payload []byte, path string) []string {
}
nextPaths := make([]string, 0, len(paths))
for _, basePath := range paths {
- array := payloadValueAtPath(payload, basePath)
+ array := get(basePath)
if !array.Exists() || !array.IsArray() {
continue
}
@@ -709,14 +865,14 @@ func appendPayloadPathPart(path, part string) string {
return path + "." + part
}
-func payloadValueAtPath(payload []byte, path string) gjson.Result {
+func payloadValueAtPath(payload []byte, path string) ojson.Result {
if path == "" {
- return gjson.ParseBytes(payload)
+ return util.ParseGJSONBytesNoCopy(payload)
}
- return gjson.GetBytes(payload, path)
+ return ojson.GetBytesNoCopy(payload, path)
}
-func payloadQueryMatches(item gjson.Result, query string) bool {
+func payloadQueryMatches(item ojson.Result, query string) bool {
for _, orPart := range splitPayloadLogical(query, "||") {
if payloadQueryAndMatches(item, orPart) {
return true
@@ -725,7 +881,7 @@ func payloadQueryMatches(item gjson.Result, query string) bool {
return false
}
-func payloadQueryAndMatches(item gjson.Result, query string) bool {
+func payloadQueryAndMatches(item ojson.Result, query string) bool {
parts := splitPayloadLogical(query, "&&")
if len(parts) == 0 {
return false
@@ -773,7 +929,7 @@ func splitPayloadLogical(query, operator string) []string {
return parts
}
-func payloadQueryTermMatches(item gjson.Result, term string) bool {
+func payloadQueryTermMatches(item ojson.Result, term string) bool {
term = strings.TrimSpace(term)
if term == "" || item.Raw == "" {
return false
@@ -782,74 +938,64 @@ func payloadQueryTermMatches(item gjson.Result, term string) bool {
wrapped = append(wrapped, '[')
wrapped = append(wrapped, item.Raw...)
wrapped = append(wrapped, ']')
- return gjson.GetBytes(wrapped, "#("+term+")").Exists()
+ return ojson.GetBytesNoCopy(wrapped, "#("+term+")").Exists()
}
-func removeToolTypeFromPayloadWithRoot(payload []byte, root string, toolType string) []byte {
- if len(payload) == 0 {
- return payload
+func removeToolTypeFromPayloadWithRootDoc(doc *ojson.Doc, root string, toolType string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
}
toolType = strings.TrimSpace(toolType)
if toolType == "" {
- return payload
+ return false
}
toolsPath := buildPayloadPath(root, "tools")
- return removeToolTypeFromToolsArray(payload, toolsPath, toolType)
+ return removeToolTypeFromToolsArrayDoc(doc, toolsPath, toolType)
}
-func removeToolChoiceFromPayloadWithRoot(payload []byte, root string, toolType string) []byte {
- if len(payload) == 0 {
- return payload
+func removeToolChoiceFromPayloadWithRootDoc(doc *ojson.Doc, root string, toolType string) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
}
toolType = strings.TrimSpace(toolType)
if toolType == "" {
- return payload
+ return false
}
toolChoicePath := buildPayloadPath(root, "tool_choice")
- return removeToolChoiceFromPayload(payload, toolChoicePath, toolType)
+ return removeToolChoiceFromPayloadDoc(doc, toolChoicePath, toolType)
}
-func removeToolChoiceFromPayload(payload []byte, toolChoicePath string, toolType string) []byte {
- choice := gjson.GetBytes(payload, toolChoicePath)
+func removeToolChoiceFromPayloadDoc(doc *ojson.Doc, toolChoicePath string, toolType string) bool {
+ choice := doc.Get(toolChoicePath)
if !choice.Exists() {
- return payload
+ return false
}
- if choice.Type == gjson.String {
+ if choice.Type == ojson.String {
if strings.EqualFold(strings.TrimSpace(choice.String()), toolType) {
- updated, errDel := sjson.DeleteBytes(payload, toolChoicePath)
- if errDel == nil {
- return updated
- }
+ return doc.Delete(toolChoicePath) == nil
}
- return payload
+ return false
}
- if choice.Type != gjson.JSON {
- return payload
+ if choice.Type != ojson.JSON {
+ return false
}
choiceType := strings.TrimSpace(choice.Get("type").String())
if strings.EqualFold(choiceType, toolType) {
- updated, errDel := sjson.DeleteBytes(payload, toolChoicePath)
- if errDel == nil {
- return updated
- }
- return payload
+ return doc.Delete(toolChoicePath) == nil
}
if strings.EqualFold(choiceType, "tool") {
name := strings.TrimSpace(choice.Get("name").String())
if strings.EqualFold(name, toolType) {
- updated, errDel := sjson.DeleteBytes(payload, toolChoicePath)
- if errDel == nil {
- return updated
- }
+ return doc.Delete(toolChoicePath) == nil
}
}
- return payload
+ return false
}
-func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType string) []byte {
- tools := gjson.GetBytes(payload, toolsPath)
+func removeToolTypeFromToolsArrayDoc(doc *ojson.Doc, toolsPath string, toolType string) bool {
+ tools := doc.Get(toolsPath)
if !tools.Exists() || !tools.IsArray() {
- return payload
+ return false
}
toolItems := tools.Array()
removed := false
@@ -860,7 +1006,7 @@ func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType str
}
}
if !removed {
- return payload
+ return false
}
filtered := make([][]byte, 0, len(toolItems))
for _, tool := range toolItems {
@@ -868,11 +1014,18 @@ func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType str
filtered = append(filtered, []byte(tool.Raw))
}
}
- updated, errSet := sjson.SetRawBytes(payload, toolsPath, JoinRawJSONArray(filtered))
- if errSet != nil {
+ return doc.SetRawBytes(toolsPath, JoinRawJSONArray(filtered)) == nil
+}
+
+func removeToolTypeFromToolsArray(payload []byte, toolsPath string, toolType string) []byte {
+ if len(payload) == 0 {
return payload
}
- return updated
+ doc := ojson.NewDoc(payload)
+ if !removeToolTypeFromToolsArrayDoc(doc, toolsPath, toolType) {
+ return payload
+ }
+ return doc.Bytes()
}
func setPayloadValueIfDifferent(payload []byte, path string, value any) []byte {
@@ -881,14 +1034,14 @@ func setPayloadValueIfDifferent(payload []byte, path string, value any) []byte {
}
func setPayloadValueIfDifferentTracked(payload []byte, path string, value any) ([]byte, bool) {
- current := gjson.GetBytes(payload, path)
+ current := ojson.GetBytesNoCopy(payload, path)
switch typed := value.(type) {
case string:
- if current.Type == gjson.String && current.String() == typed {
+ if current.Type == ojson.String && current.String() == typed {
return payload, true
}
case bool:
- if (typed && current.Type == gjson.True) || (!typed && current.Type == gjson.False) {
+ if (typed && current.Type == ojson.True) || (!typed && current.Type == ojson.False) {
return payload, true
}
case nil:
@@ -896,40 +1049,83 @@ func setPayloadValueIfDifferentTracked(payload []byte, path string, value any) (
return payload, true
}
default:
- expectedJSON, errSet := sjson.SetBytes([]byte(`{}`), "value", value)
+ expectedJSON, errSet := ojson.SetBytes([]byte(`{}`), "value", value)
if errSet != nil {
return payload, false
}
- expected := gjson.GetBytes(expectedJSON, "value")
+ expected := ojson.GetBytesNoCopy(expectedJSON, "value")
if expected.Raw == "" {
return payload, false
}
if len(current.Indexes) == 0 && current.Raw == expected.Raw {
return payload, true
}
- updated, errSet := sjson.SetRawBytes(payload, path, []byte(expected.Raw))
+ updated, errSet := ojson.SetRawBytes(payload, path, []byte(expected.Raw))
if errSet != nil {
return payload, false
}
return updated, true
}
- updated, errSet := sjson.SetBytes(payload, path, value)
+ updated, errSet := ojson.SetBytes(payload, path, value)
if errSet != nil {
return payload, false
}
return updated, true
}
-func setPayloadRawValueIfDifferentTracked(payload []byte, path string, value []byte) ([]byte, bool) {
- current := gjson.GetBytes(payload, path)
+func setPayloadValueIfDifferentDocTracked(doc *ojson.Doc, path string, value any) (bool, bool) {
+ if doc == nil {
+ return false, false
+ }
+ current := doc.Get(path)
+ switch typed := value.(type) {
+ case string:
+ if len(current.Indexes) == 0 && current.Type == ojson.String && current.String() == typed {
+ return false, true
+ }
+ case bool:
+ if len(current.Indexes) == 0 && ((typed && current.Type == ojson.True) || (!typed && current.Type == ojson.False)) {
+ return false, true
+ }
+ case nil:
+ if len(current.Indexes) == 0 && current.Raw == "null" {
+ return false, true
+ }
+ default:
+ expectedJSON, errSet := ojson.SetBytes([]byte(`{}`), "value", value)
+ if errSet != nil {
+ return false, false
+ }
+ expected := ojson.GetBytesNoCopy(expectedJSON, "value")
+ if expected.Raw == "" {
+ return false, false
+ }
+ if len(current.Indexes) == 0 && current.Raw == expected.Raw {
+ return false, true
+ }
+ if errSet := doc.SetRaw(path, expected.Raw); errSet != nil {
+ return false, false
+ }
+ return true, true
+ }
+ if errSet := doc.Set(path, value); errSet != nil {
+ return false, false
+ }
+ return true, true
+}
+
+func setPayloadRawValueIfDifferentDocTracked(doc *ojson.Doc, path string, value []byte) (bool, bool) {
+ if doc == nil {
+ return false, false
+ }
+ current := doc.Get(path)
if current.Exists() && len(current.Indexes) == 0 && current.Raw == string(value) {
- return payload, true
+ return false, true
}
- updated, errSet := sjson.SetRawBytes(payload, path, value)
- if errSet != nil {
- return payload, false
+ if errSet := doc.SetRawBytes(path, value); errSet != nil {
+ return false, false
}
- return updated, true
+ return true, true
}
func payloadRawValue(value any) ([]byte, bool) {
diff --git a/internal/runtime/executor/helps/payload_helpers_codex_integer_test.go b/internal/runtime/executor/helps/payload_helpers_codex_integer_test.go
index 114672bf115..fedff8fb110 100644
--- a/internal/runtime/executor/helps/payload_helpers_codex_integer_test.go
+++ b/internal/runtime/executor/helps/payload_helpers_codex_integer_test.go
@@ -4,8 +4,8 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestApplyPayloadConfigWithTrackedPathsForExecutorCodexIntegerNormalizationUsesExecutor(t *testing.T) {
@@ -26,7 +26,7 @@ func TestApplyPayloadConfigWithTrackedPathsForExecutorCodexIntegerNormalizationU
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _ := ApplyPayloadConfigWithTrackedPathsForExecutor(nil, tt.executor, "model", tt.protocol, "", "", input, nil, "", "", headers)
- if typ := gjson.GetBytes(got, "tools.0.parameters.properties.yield_time_ms.type").String(); typ != tt.wantType {
+ if typ := ojson.GetBytes(got, "tools.0.parameters.properties.yield_time_ms.type").String(); typ != tt.wantType {
t.Fatalf("yield_time_ms.type = %q, want %q; payload=%s", typ, tt.wantType, got)
}
})
diff --git a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go
index 37f50b4db76..b85701d16a3 100644
--- a/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go
+++ b/internal/runtime/executor/helps/payload_helpers_disable_image_generation_test.go
@@ -4,8 +4,8 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntry(t *testing.T) {
@@ -16,7 +16,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntry(t *
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "")
- tools := gjson.GetBytes(out, "tools")
+ tools := ojson.GetBytes(out, "tools")
if !tools.Exists() || !tools.IsArray() {
t.Fatalf("expected tools array, got %v", tools.Type)
}
@@ -37,7 +37,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolsEntryWith
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "antigravity", "request", payload, nil, "", "")
- tools := gjson.GetBytes(out, "request.tools")
+ tools := ojson.GetBytes(out, "request.tools")
if !tools.Exists() || !tools.IsArray() {
t.Fatalf("expected request.tools array, got %v", tools.Type)
}
@@ -58,7 +58,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByTy
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "")
- if gjson.GetBytes(out, "tool_choice").Exists() {
+ if ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("expected tool_choice to be removed")
}
}
@@ -71,7 +71,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_RemovesToolChoiceByNa
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "antigravity", "request", payload, nil, "", "")
- if gjson.GetBytes(out, "request.tool_choice").Exists() {
+ if ojson.GetBytes(out, "request.tool_choice").Exists() {
t.Fatalf("expected request.tool_choice to be removed")
}
}
@@ -84,7 +84,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGenerationChat_KeepsImageGenerat
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "/v1/images/generations")
- tools := gjson.GetBytes(out, "tools")
+ tools := ojson.GetBytes(out, "tools")
if !tools.Exists() || !tools.IsArray() {
t.Fatalf("expected tools array, got %v", tools.Type)
}
@@ -92,7 +92,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGenerationChat_KeepsImageGenerat
if len(arr) != 2 {
t.Fatalf("expected 2 tools (no removal), got %d", len(arr))
}
- if !gjson.GetBytes(out, "tool_choice").Exists() {
+ if !ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("expected tool_choice to be kept on images endpoint")
}
}
@@ -108,7 +108,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGenerationPassthrough_KeepsPaylo
for _, requestPath := range []string{"", "/v1/responses", "/v1/images/generations"} {
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", requestPath)
- tools := gjson.GetBytes(out, "tools")
+ tools := ojson.GetBytes(out, "tools")
if !tools.Exists() || !tools.IsArray() {
t.Fatalf("path %q: expected tools array, got %v", requestPath, tools.Type)
}
@@ -118,7 +118,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGenerationPassthrough_KeepsPaylo
if got := tools.Array()[0].Get("type").String(); got != "image_generation" {
t.Fatalf("path %q: expected image_generation tool to be kept, got %q", requestPath, got)
}
- if !gjson.GetBytes(out, "tool_choice").Exists() {
+ if !ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("path %q: expected tool_choice to be kept", requestPath)
}
}
@@ -145,7 +145,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_PayloadOverrideCanRes
out := ApplyPayloadConfigWithRoot(cfg, "gpt-5.4", "openai-response", "", payload, nil, "", "")
- tools := gjson.GetBytes(out, "tools")
+ tools := ojson.GetBytes(out, "tools")
if !tools.Exists() || !tools.IsArray() {
t.Fatalf("expected tools array, got %v", tools.Type)
}
@@ -156,7 +156,7 @@ func TestApplyPayloadConfigWithRoot_DisableImageGeneration_PayloadOverrideCanRes
if got := arr[0].Get("type").String(); got != "image_generation" {
t.Fatalf("expected first tool type=image_generation, got %q", got)
}
- if !gjson.GetBytes(out, "tool_choice").Exists() {
+ if !ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("expected tool_choice to be restored by payload override")
}
}
@@ -187,13 +187,13 @@ func TestApplyPayloadConfigWithRequest_HeaderGateRequiresWildcardMatch(t *testin
headers.Set("X-Client-Tier", "tenant-alpha-region-us")
out := ApplyPayloadConfigWithRequest(cfg, "gpt-5.4", "openai", "responses", "", payload, nil, "", "", headers)
- if !gjson.GetBytes(out, "metadata.enabled").Bool() {
+ if !ojson.GetBytes(out, "metadata.enabled").Bool() {
t.Fatalf("expected header-matched payload rule to apply, payload=%s", string(out))
}
headers.Set("X-Client-Tier", "tenant-alpha")
out = ApplyPayloadConfigWithRequest(cfg, "gpt-5.4", "openai", "responses", "", payload, nil, "", "", headers)
- if gjson.GetBytes(out, "metadata.enabled").Exists() {
+ if ojson.GetBytes(out, "metadata.enabled").Exists() {
t.Fatalf("expected header-mismatched payload rule to be skipped, payload=%s", string(out))
}
}
@@ -224,12 +224,12 @@ func TestApplyPayloadConfigWithRequest_FromProtocolGateUsesSourceProtocol(t *tes
payload := []byte(`{"model":"gpt-5.4"}`)
out := ApplyPayloadConfigWithRequest(cfg, "gpt-5.4", "openai", "openai-response", "", payload, nil, "", "", nil)
- if got := gjson.GetBytes(out, "metadata.source").String(); got != "responses" {
+ if got := ojson.GetBytes(out, "metadata.source").String(); got != "responses" {
t.Fatalf("metadata.source = %q, want responses; payload=%s", got, string(out))
}
out = ApplyPayloadConfigWithRequest(cfg, "gpt-5.4", "openai", "openai", "", payload, nil, "", "", nil)
- if got := gjson.GetBytes(out, "metadata.source").String(); got != "openai" {
+ if got := ojson.GetBytes(out, "metadata.source").String(); got != "openai" {
t.Fatalf("metadata.source = %q, want openai; payload=%s", got, string(out))
}
}
@@ -268,7 +268,7 @@ func TestApplyPayloadConfigWithRequest_PayloadConditionsNarrowRule(t *testing.T)
payload := []byte(`{"model":"gpt-5.4","metadata":{"client":"codex","mode":"prod","null_value":null},"tools":[{"type":"function"},{"type":"web_search","enabled":true}]}`)
out := ApplyPayloadConfigWithRequest(cfg, "gpt-5.4", "openai", "responses", "", payload, nil, "", "", nil)
- if !gjson.GetBytes(out, "metadata.applied").Bool() {
+ if !ojson.GetBytes(out, "metadata.applied").Bool() {
t.Fatalf("expected payload condition-matched rule to apply, payload=%s", string(out))
}
}
@@ -332,7 +332,7 @@ func TestApplyPayloadConfigWithRequest_PayloadConditionsSkipRule(t *testing.T) {
}
out := ApplyPayloadConfigWithRequest(cfg, "gpt-5.4", "openai", "responses", "", payload, nil, "", "", nil)
- if gjson.GetBytes(out, "metadata.applied").Exists() {
+ if ojson.GetBytes(out, "metadata.applied").Exists() {
t.Fatalf("expected payload condition-mismatched rule to be skipped, payload=%s", string(out))
}
})
diff --git a/internal/runtime/executor/helps/payload_media.go b/internal/runtime/executor/helps/payload_media.go
index 47bbd95cff4..cdfd2420ff3 100644
--- a/internal/runtime/executor/helps/payload_media.go
+++ b/internal/runtime/executor/helps/payload_media.go
@@ -2,17 +2,18 @@ package helps
import (
"bytes"
- "encoding/base64"
"encoding/json"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"mime"
"mime/multipart"
"net/textproto"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// ApplyMediaPayloadConfig exposes multipart fields as JSON business values. File
@@ -30,14 +31,16 @@ func ApplyMediaPayloadConfig(cfg *config.Config, executor, model, protocol strin
if errOriginal != nil {
return nil, "", errOriginal
}
- out := NewPayloadFinalizer(cfg, executor, model, protocol, "", originalView, req, opts)(view)
+ doc := ojson.NewDoc(view)
+ NewPayloadFinalizer(cfg, executor, model, protocol, "", originalView, req, opts).FinalizeDoc(doc)
+ out := doc.Bytes()
if !multipartBody || bytes.Equal(out, view) {
return outOrBody(out, body, multipartBody), contentType, nil
}
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
- for key, value := range gjson.ParseBytes(out).Map() {
- values := []gjson.Result{value}
+ for key, value := range util.ParseGJSONBytesNoCopy(out).Map() {
+ values := []ojson.Result{value}
if value.IsArray() {
values = value.Array()
}
diff --git a/internal/runtime/executor/helps/payload_mutations.go b/internal/runtime/executor/helps/payload_mutations.go
index 7896d9b2f4b..75479c9b5a1 100644
--- a/internal/runtime/executor/helps/payload_mutations.go
+++ b/internal/runtime/executor/helps/payload_mutations.go
@@ -1,51 +1,88 @@
package helps
import (
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// SetStringIfDifferent updates path only when its value is not already the
// canonical JSON string. Values with another JSON type are still normalized.
func SetStringIfDifferent(payload []byte, path, value string) []byte {
- current := gjson.GetBytes(payload, path)
- if current.Type == gjson.String && current.String() == value {
+ current := ojson.GetBytesNoCopy(payload, path)
+ if current.Type == ojson.String && current.String() == value {
return payload
}
- updated, errSet := sjson.SetBytes(payload, path, value)
+ updated, errSet := ojson.SetBytes(payload, path, value)
if errSet != nil {
return payload
}
return updated
}
+// SetStringIfDifferentDoc updates path on doc in place only when its value is not
+// already the canonical JSON string. Values with another JSON type are still normalized.
+func SetStringIfDifferentDoc(doc *ojson.Doc, path, value string) bool {
+ if doc == nil {
+ return false
+ }
+ current := doc.Get(path)
+ if current.Type == ojson.String && current.String() == value {
+ return false
+ }
+ return doc.Set(path, value) == nil
+}
+
// SetBoolIfDifferent updates path only when its value is not already the
// canonical JSON boolean. Values with another JSON type are still normalized.
func SetBoolIfDifferent(payload []byte, path string, value bool) []byte {
- current := gjson.GetBytes(payload, path)
- if (value && current.Type == gjson.True) || (!value && current.Type == gjson.False) {
+ current := ojson.GetBytesNoCopy(payload, path)
+ if (value && current.Type == ojson.True) || (!value && current.Type == ojson.False) {
return payload
}
- updated, errSet := sjson.SetBytes(payload, path, value)
+ updated, errSet := ojson.SetBytes(payload, path, value)
if errSet != nil {
return payload
}
return updated
}
+// SetBoolIfDifferentDoc updates path on doc in place only when its value is not
+// already the canonical JSON boolean. Values with another JSON type are still normalized.
+func SetBoolIfDifferentDoc(doc *ojson.Doc, path string, value bool) bool {
+ if doc == nil {
+ return false
+ }
+ current := doc.Get(path)
+ if (value && current.Type == ojson.True) || (!value && current.Type == ojson.False) {
+ return false
+ }
+ return doc.Set(path, value) == nil
+}
+
// SetRawIfDifferent updates path only when the existing raw JSON is identical.
func SetRawIfDifferent(payload []byte, path string, value []byte) []byte {
- current := gjson.GetBytes(payload, path)
+ current := ojson.GetBytesNoCopy(payload, path)
if current.Exists() && len(current.Indexes) == 0 && current.Raw == string(value) {
return payload
}
- updated, errSet := sjson.SetRawBytes(payload, path, value)
+ updated, errSet := ojson.SetRawBytes(payload, path, value)
if errSet != nil {
return payload
}
return updated
}
+// SetRawIfDifferentDoc updates path on doc in place only when the existing raw JSON differs.
+func SetRawIfDifferentDoc(doc *ojson.Doc, path string, value []byte) bool {
+ if doc == nil {
+ return false
+ }
+ current := doc.Get(path)
+ if current.Exists() && len(current.Indexes) == 0 && current.Raw == string(value) {
+ return false
+ }
+ return doc.SetRawBytes(path, value) == nil
+}
+
// JoinRawJSONArray joins validated raw JSON array items without re-encoding them.
func JoinRawJSONArray(items [][]byte) []byte {
size := len(items) + 1
diff --git a/internal/runtime/executor/helps/payload_mutations_test.go b/internal/runtime/executor/helps/payload_mutations_test.go
index 6e1f882ff6a..cb5c5d8afe8 100644
--- a/internal/runtime/executor/helps/payload_mutations_test.go
+++ b/internal/runtime/executor/helps/payload_mutations_test.go
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
- "github.com/tidwall/gjson"
)
type countingPayloadMarshaler struct {
@@ -33,8 +33,8 @@ func TestSetStringIfDifferentNormalizesWrongType(t *testing.T) {
input := []byte(`{"model":123}`)
original := bytes.Clone(input)
output := SetStringIfDifferent(input, "model", "123")
- model := gjson.GetBytes(output, "model")
- if model.Type != gjson.String || model.String() != "123" {
+ model := ojson.GetBytes(output, "model")
+ if model.Type != ojson.String || model.String() != "123" {
t.Fatalf("model = %s, want string 123", model.Raw)
}
if !bytes.Equal(input, original) {
@@ -53,7 +53,7 @@ func TestSetBoolIfDifferentReusesCanonicalValue(t *testing.T) {
func TestSetBoolIfDifferentNormalizesWrongType(t *testing.T) {
input := []byte(`{"stream":"true"}`)
output := SetBoolIfDifferent(input, "stream", true)
- if stream := gjson.GetBytes(output, "stream"); stream.Type != gjson.True {
+ if stream := ojson.GetBytes(output, "stream"); stream.Type != ojson.True {
t.Fatalf("stream = %s, want boolean true", stream.Raw)
}
}
@@ -69,12 +69,44 @@ func TestSetRawIfDifferentReusesIdenticalRawValue(t *testing.T) {
func TestSetRawIfDifferentUpdatesDifferentRawValue(t *testing.T) {
input := []byte(`{"metadata":"executor"}`)
output := SetRawIfDifferent(input, "metadata", []byte(`{"source":"executor"}`))
- metadata := gjson.GetBytes(output, "metadata")
+ metadata := ojson.GetBytes(output, "metadata")
if !metadata.IsObject() || metadata.Get("source").String() != "executor" {
t.Fatalf("metadata = %s, want object", metadata.Raw)
}
}
+func TestSetIfDifferentDocMutations(t *testing.T) {
+ doc := ojson.NewDoc([]byte(`{"model":"gpt-test","stream":true,"metadata":{"source":"executor"}}`))
+ if SetStringIfDifferentDoc(doc, "model", "gpt-test") {
+ t.Fatal("SetStringIfDifferentDoc reported change for identical string")
+ }
+ if !SetStringIfDifferentDoc(doc, "model", "gpt-4o") {
+ t.Fatal("SetStringIfDifferentDoc did not report change for updated string")
+ }
+ if SetBoolIfDifferentDoc(doc, "stream", true) {
+ t.Fatal("SetBoolIfDifferentDoc reported change for identical bool")
+ }
+ if !SetBoolIfDifferentDoc(doc, "stream", false) {
+ t.Fatal("SetBoolIfDifferentDoc did not report change for updated bool")
+ }
+ if SetRawIfDifferentDoc(doc, "metadata", []byte(`{"source":"executor"}`)) {
+ t.Fatal("SetRawIfDifferentDoc reported change for identical raw value")
+ }
+ if !SetRawIfDifferentDoc(doc, "metadata", []byte(`{"source":"updated"}`)) {
+ t.Fatal("SetRawIfDifferentDoc did not report change for updated raw value")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "model").String(); got != "gpt-4o" {
+ t.Fatalf("model = %q, want gpt-4o", got)
+ }
+ if ojson.GetBytesNoCopy(out, "stream").Bool() {
+ t.Fatalf("stream = true, want false")
+ }
+ if got := ojson.GetBytesNoCopy(out, "metadata.source").String(); got != "updated" {
+ t.Fatalf("metadata.source = %q, want updated", got)
+ }
+}
+
func TestApplyPayloadConfigReusesCanonicalOverrides(t *testing.T) {
cfg := &config.Config{Payload: config.PayloadConfig{
Override: []config.PayloadRule{{
@@ -198,7 +230,7 @@ func TestApplyPayloadConfigProjectionOverrideWritesEveryMatch(t *testing.T) {
input := []byte(`{"items":[{"value":1},{"value":2}]}`)
output := ApplyPayloadConfigWithRoot(cfg, "gpt-test", "openai", "", input, nil, "", "")
for _, path := range []string{"items.0.value", "items.1.value"} {
- if got := gjson.GetBytes(output, path).Raw; got != `[1,2]` {
+ if got := ojson.GetBytes(output, path).Raw; got != `[1,2]` {
t.Fatalf("%s = %s, want [1,2]", path, got)
}
}
@@ -214,7 +246,7 @@ func TestApplyPayloadConfigProjectionOverrideRawWritesEveryMatch(t *testing.T) {
input := []byte(`{"items":[{"value":1},{"value":2}]}`)
output := ApplyPayloadConfigWithRoot(cfg, "gpt-test", "openai", "", input, nil, "", "")
for _, path := range []string{"items.0.value", "items.1.value"} {
- if got := gjson.GetBytes(output, path).Raw; got != `[1,2]` {
+ if got := ojson.GetBytes(output, path).Raw; got != `[1,2]` {
t.Fatalf("%s = %s, want [1,2]", path, got)
}
}
@@ -229,8 +261,8 @@ func TestApplyPayloadConfigNormalizesByteSliceOverride(t *testing.T) {
}}
input := []byte(`{"value":"YWJj"}`)
output := ApplyPayloadConfigWithRoot(cfg, "gpt-test", "openai", "", input, nil, "", "")
- value := gjson.GetBytes(output, "value")
- if value.Type != gjson.String || value.String() != "abc" {
+ value := ojson.GetBytes(output, "value")
+ if value.Type != ojson.String || value.String() != "abc" {
t.Fatalf("value = %s, want string abc", value.Raw)
}
}
@@ -238,7 +270,7 @@ func TestApplyPayloadConfigNormalizesByteSliceOverride(t *testing.T) {
func TestSetPayloadValueIfDifferentUsesSJSONNumberEncoding(t *testing.T) {
input := []byte(`{"value":1.2}`)
output := setPayloadValueIfDifferent(input, "value", float32(1.2))
- if got := gjson.GetBytes(output, "value").Raw; got != "1.2000000476837158" {
+ if got := ojson.GetBytes(output, "value").Raw; got != "1.2000000476837158" {
t.Fatalf("value = %s, want sjson float32 encoding", got)
}
canonical := []byte(`{"value":1.2000000476837158}`)
@@ -256,7 +288,7 @@ func TestSetPayloadValueIfDifferentCallsMarshalerOnce(t *testing.T) {
if calls != 1 {
t.Fatalf("MarshalJSON calls = %d, want 1", calls)
}
- if got := gjson.GetBytes(output, "value").String(); got != "new" {
+ if got := ojson.GetBytes(output, "value").String(); got != "new" {
t.Fatalf("value = %q, want new", got)
}
}
diff --git a/internal/runtime/executor/helps/plugin_executor_usage.go b/internal/runtime/executor/helps/plugin_executor_usage.go
index 89a6680fb35..697248a2bfc 100644
--- a/internal/runtime/executor/helps/plugin_executor_usage.go
+++ b/internal/runtime/executor/helps/plugin_executor_usage.go
@@ -4,8 +4,9 @@ import (
"bytes"
"strings"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
- "github.com/tidwall/gjson"
)
// ParsePluginExecutorResponseUsage extracts token usage from a non-streaming plugin executor response.
@@ -115,10 +116,10 @@ func ObservePluginExecutorStreamTTFT(protocol string, reporter *UsageReporter, p
}
func parseClaudePayloadUsage(payload []byte) usage.Detail {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return usage.Detail{}
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
usageNode := root.Get("usage")
if !usageNode.Exists() {
usageNode = root.Get("message.usage")
@@ -133,12 +134,12 @@ func parseClaudePayloadUsage(payload []byte) usage.Detail {
func parseClaudeStreamLine(line []byte) (usage.Detail, bool) {
payload := ExtractStreamJSONPayload(line)
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return usage.Detail{}, false
}
- usageNode := gjson.GetBytes(payload, "usage")
+ usageNode := ojson.GetBytesNoCopy(payload, "usage")
if !usageNode.Exists() {
- usageNode = gjson.GetBytes(payload, "message.usage")
+ usageNode = ojson.GetBytesNoCopy(payload, "message.usage")
}
if !usageNode.Exists() {
return usage.Detail{}, false
@@ -295,6 +296,8 @@ func mergeStreamStructuredFields(merged *usage.Detail, existing, update usage.De
merged.VideoInputTokens = mergeInt64Ptr(existing.VideoInputTokens, update.VideoInputTokens)
merged.FileInputTokens = mergeInt64Ptr(existing.FileInputTokens, update.FileInputTokens)
merged.CachedTextTokens = mergeInt64Ptr(existing.CachedTextTokens, update.CachedTextTokens)
+ merged.CachedImageTokens = mergeInt64Ptr(existing.CachedImageTokens, update.CachedImageTokens)
+ merged.CachedAudioTokens = mergeInt64Ptr(existing.CachedAudioTokens, update.CachedAudioTokens)
merged.TextOutputTokens = mergeInt64Ptr(existing.TextOutputTokens, update.TextOutputTokens)
merged.ImageOutputTokens = mergeInt64Ptr(existing.ImageOutputTokens, update.ImageOutputTokens)
merged.AudioOutputTokens = mergeInt64Ptr(existing.AudioOutputTokens, update.AudioOutputTokens)
diff --git a/internal/runtime/executor/helps/proxy_helpers.go b/internal/runtime/executor/helps/proxy_helpers.go
index d98f7d4907b..b0464c633eb 100644
--- a/internal/runtime/executor/helps/proxy_helpers.go
+++ b/internal/runtime/executor/helps/proxy_helpers.go
@@ -2,9 +2,13 @@ package helps
import (
"context"
+ "crypto/sha256"
+ "encoding/hex"
"fmt"
+ "net"
"net/http"
"strings"
+ "sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -39,10 +43,9 @@ func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip
// Priority: request override, then auth.ProxyURL, then cfg.ProxyURL.
proxyURL := effectiveProxyURL(ctx, cfg, auth)
- // If we have a proxy URL configured, set up the transport
+ // Priority 1 & 2: Explicit proxy URL takes precedence over context RoundTripper.
if proxyURL != "" {
- transport := buildProxyTransport(proxyURL)
- if transport != nil {
+ if transport := pooledProxyTransport(proxyURL, cfg, auth); transport != nil {
httpClient.Transport = transport
return httpClient
}
@@ -51,13 +54,462 @@ func NewProxyAwareHTTPClient(ctx context.Context, cfg *config.Config, auth *clip
}
// Priority 3: Use RoundTripper from context (typically from RoundTripperFor)
- if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
- httpClient.Transport = rt
+ if ctx != nil {
+ if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
+ httpClient.Transport = rt
+ return httpClient
+ }
+ }
+
+ // Priority 4: A credential may opt into pooling without an explicit proxy-url.
+ // With an empty proxy-url the pooled transport keeps the inherited
+ // environment-proxy behavior while isolating the credential's pool.
+ if transport := pooledProxyTransport("", cfg, auth); transport != nil {
+ httpClient.Transport = transport
}
return httpClient
}
+// proxyPoolKey identifies one cached pool. publisher isolates the runtime that
+// published the config, kind separates transports with different TLS fingerprints,
+// scope isolates credentials, proxy is the normalized proxy setting, and the
+// remaining fields fold in every setting that changes the built transport.
+type proxyPoolKey struct {
+ publisher uint64
+ kind proxyPoolKind
+ scope string
+ proxy string
+ idle time.Duration
+ max int
+ invalidIdle string
+}
+
+// proxyPoolKind separates transports that cannot share a pool. The standard
+// transport and the Claude Code uTLS transport differ in TLS fingerprint, HTTP
+// version support, and header framing, so a transport built for one must never be
+// handed to the other even when the proxy and pool settings match.
+type proxyPoolKind uint8
+
+const (
+ proxyPoolKindStandard proxyPoolKind = iota
+ proxyPoolKindClaudeCode
+)
+
+const (
+ // proxyPoolDefaultIdleConnTimeout bounds how long an idle pooled connection
+ // may survive before net/http closes it.
+ //
+ // Measured on 2026-10-29 against ollama.com through the local transparent
+ // proxy, which is the upstream this pool was introduced for: a pooled
+ // HTTP/2 connection was still reused after 120s, 180s, 300s and 600s of
+ // idleness, and a bare HTTP/1.1 connection survived an idle 600s without a
+ // close. 600s is therefore a lower bound on the upstream window, not a
+ // limit. The 90s that used to bound this path was net/http's own
+ // DefaultTransport IdleConnTimeout, a client-side timer rather than an
+ // upstream cutoff. 300s stays well inside the observed window while still
+ // recycling sockets, and it keeps the 31-300s request gaps that a 30s window
+ // throws away.
+ proxyPoolDefaultIdleConnTimeout = 300 * time.Second
+
+ // proxyPoolDefaultMaxIdleConnsPerHost mirrors net/http's
+ // DefaultMaxIdleConnsPerHost, so enabling the pool never retains more idle
+ // sockets per upstream host than the standard library default.
+ proxyPoolDefaultMaxIdleConnsPerHost = 2
+
+ // proxyPoolKeepAlivePeriod is the TCP keepalive interval stamped on every
+ // dialed upstream connection. Keepalive is what turns a silently dead peer
+ // (NAT timeout, killed proxy, dropped mobile link) into an error in tens of
+ // seconds instead of a request that blocks on TCP retransmits. It matches
+ // net/http's DefaultTransport dialer and only probes idle sockets, so it
+ // never truncates a long stream that is still flowing.
+ proxyPoolKeepAlivePeriod = 30 * time.Second
+
+ // proxyPoolCapacity bounds how many credential-scoped pools stay alive.
+ //
+ // The pool scales by eviction, not by sizing for the largest deployment:
+ // - 1 key: one entry, warm forever.
+ // - 100 keys: one entry each, nothing is evicted.
+ // - 100000 keys: the 512 most recently used credentials keep their pools
+ // and the rest fall back to the historical one-transport-per-request
+ // behavior, so memory stays at 512 transports and at most
+ // capacity*max idle sockets. Eviction only closes idle connections;
+ // in-flight requests keep their transport to completion.
+ proxyPoolCapacity = 512
+)
+
+var proxyTransportCache = NewTransportCache[proxyPoolKey](proxyPoolCapacity)
+
+// proxyPoolFence tracks the current snapshot generation of every runtime that
+// publishes configs. Fencing is per publisher: a reload in one service must never
+// retire the snapshots another service is still serving, because retiring a live
+// snapshot silently turns that service's pooling into a fresh transport per request.
+// One small entry per runtime is retained for the process lifetime.
+var proxyPoolFence struct {
+ sync.RWMutex
+ nextPublisher uint64
+ nextGeneration uint64
+ current map[uint64]uint64
+}
+
+// RegisterProxyPoolPublisher reserves one publisher identity per runtime. Pass the
+// result to AdvanceProxyPoolGeneration on every publication.
+func RegisterProxyPoolPublisher() uint64 {
+ proxyPoolFence.Lock()
+ defer proxyPoolFence.Unlock()
+ proxyPoolFence.nextPublisher++
+ return proxyPoolFence.nextPublisher
+}
+
+// AdvanceProxyPoolGeneration publishes cfg as the current snapshot of publisher and
+// retires only that publisher's older snapshots. cfg must be a private snapshot not
+// yet visible to request handlers. Pass 0 when the caller owns no publisher (direct
+// SDK embedding, helper probes, tests); those snapshots fence each other.
+func AdvanceProxyPoolGeneration(publisher uint64, cfg *config.Config) {
+ if cfg == nil {
+ return
+ }
+ proxyPoolFence.Lock()
+ proxyPoolFence.nextGeneration++
+ generation := proxyPoolFence.nextGeneration
+ if proxyPoolFence.current == nil {
+ proxyPoolFence.current = make(map[uint64]uint64)
+ }
+ proxyPoolFence.current[publisher] = generation
+ cfg.ProxyPoolPublisher = publisher
+ cfg.ProxyPoolGeneration = generation
+ // Retire this publisher's pools only, while the fence excludes its snapshots: a
+ // purged-then-refilled cache would resurrect transports built from retired
+ // settings. Purging the whole cache would close the idle connections of every
+ // other runtime on each reload of this one. Detach under the fence lock and
+ // close idle sockets after unlocking so socket teardown never blocks RLockers.
+ toClose := proxyTransportCache.detachMatching(func(key proxyPoolKey) bool { return key.publisher == publisher })
+ proxyPoolFence.Unlock()
+ for _, t := range toClose {
+ t.CloseIdleConnections()
+ }
+}
+
+// RetireProxyPoolPublisher closes all pooled transports owned by publisher and
+// removes its fence state when the owning service shuts down. Any snapshot carrying
+// a non-zero generation for this publisher will see current[publisher]==0 and remain
+// fenced out of the shared cache.
+func RetireProxyPoolPublisher(publisher uint64) {
+ if publisher == 0 {
+ return
+ }
+ proxyPoolFence.Lock()
+ delete(proxyPoolFence.current, publisher)
+ toClose := proxyTransportCache.detachMatching(func(key proxyPoolKey) bool { return key.publisher == publisher })
+ proxyPoolFence.Unlock()
+ for _, t := range toClose {
+ t.CloseIdleConnections()
+ }
+}
+
+// ResetProxyTransports closes idle connections in cached explicit proxy transports.
+func ResetProxyTransports() { proxyTransportCache.Purge() }
+
+func pooledProxyTransport(proxyURL string, cfg *config.Config, auth *cliproxyauth.Auth) *http.Transport {
+ tr, pooled := pooledTransport(proxyPoolKindStandard, proxyURL, cfg, auth, func() (*http.Transport, error) {
+ return buildPooledBaseTransport(proxyURL)
+ })
+ if pooled {
+ return tr
+ }
+ // Off unless this credential carries a connection-pool block. There is
+ // deliberately no global switch: pooling one api key must never change the
+ // transport lifecycle of another provider or OAuth identity. Without the
+ // block every request keeps the historical behavior of a fresh transport.
+ return buildProxyTransport(proxyURL)
+}
+
+// pooledTransport resolves one credential's pool for a transport kind. pooled is
+// false when the credential did not opt in or its proxy setting cannot be pooled, so
+// the caller keeps its historical transport.
+func pooledTransport(kind proxyPoolKind, proxyURL string, cfg *config.Config, auth *cliproxyauth.Auth, build func() (*http.Transport, error)) (*http.Transport, bool) {
+ settings, enabled := resolveProxyPoolSettings(auth)
+ if !enabled {
+ return nil, false
+ }
+ setting, errParse := proxyutil.Parse(proxyURL)
+ if errParse != nil {
+ return nil, false
+ }
+ // When pooling against an empty proxy-url (net/http's inherited environment
+ // proxy), cloning http.DefaultTransport requires it to be a *http.Transport.
+ // If an SDK embedder installed a non-*http.Transport (e.g. mTLS, tracing, custom
+ // routing), decline pooling so the request keeps using the installed default
+ // instead of being replaced by an empty transport.
+ if kind == proxyPoolKindStandard && strings.TrimSpace(proxyURL) == "" {
+ if base, ok := http.DefaultTransport.(*http.Transport); !ok || base == nil {
+ return nil, false
+ }
+ }
+ idle := proxyPoolDefaultIdleConnTimeout
+ invalidIdle := ""
+ if raw := strings.TrimSpace(settings.IdleConnTimeout); raw != "" {
+ if d, err := time.ParseDuration(raw); err == nil {
+ if d <= 0 {
+ return unpooledTransport(build), true
+ }
+ idle = d
+ } else {
+ invalidIdle = raw
+ }
+ }
+ max := proxyPoolDefaultMaxIdleConnsPerHost
+ if settings.MaxIdleConnsPerHost != nil {
+ if *settings.MaxIdleConnsPerHost <= 0 {
+ return unpooledTransport(build), true
+ }
+ max = *settings.MaxIdleConnsPerHost
+ }
+ var publisher uint64
+ if cfg != nil {
+ publisher = cfg.ProxyPoolPublisher
+ }
+ cacheProxy, poolLabel := proxyPoolCacheTarget(setting)
+ key := proxyPoolKey{
+ publisher: publisher,
+ kind: kind,
+ scope: proxyPoolScope(auth),
+ proxy: cacheProxy,
+ idle: idle,
+ max: max,
+ invalidIdle: invalidIdle,
+ }
+ // Only a snapshot that carries a retired generation is fenced. Generation 0 means
+ // the config was never published through the SDK generation fence (direct api
+ // callers, helper probes, tests), so it cannot be stale and must still pool;
+ // treating it as stale would silently disable pooling for every non-SDK caller
+ // that builds its own config.Config. The read lock is held across the cache lookup
+ // so a concurrent publication cannot purge the cache and watch this snapshot refill
+ // it with a transport built from retired settings.
+ if cfg != nil && cfg.ProxyPoolGeneration != 0 {
+ proxyPoolFence.RLock()
+ defer proxyPoolFence.RUnlock()
+ if cfg.ProxyPoolGeneration != proxyPoolFence.current[cfg.ProxyPoolPublisher] {
+ return unpooledTransport(build), true
+ }
+ }
+ tr, err := proxyTransportCache.Get(key, func() (*http.Transport, error) {
+ tr, errBuild := build()
+ if errBuild != nil {
+ return nil, errBuild
+ }
+ if tr == nil {
+ return nil, fmt.Errorf("build returned no transport")
+ }
+ if invalidIdle != "" {
+ log.Warnf("proxy connection pool: invalid idle-conn-timeout %q, using %s", invalidIdle, proxyPoolDefaultIdleConnTimeout)
+ }
+ tr.IdleConnTimeout = idle
+ tr.MaxIdleConnsPerHost = max
+ // A cloned default transport keeps MaxIdleConns=100, which across 512
+ // credential pools would allow 51200 idle sockets. Bound the whole pool
+ // of one credential instead: at most max idle sockets in total.
+ tr.MaxIdleConns = max
+ if dial := tr.DialContext; dial != nil {
+ tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+ conn, errDial := dial(ctx, network, addr)
+ if errDial != nil {
+ return nil, errDial
+ }
+ return stampProxyKeepAlive(conn), nil
+ }
+ }
+ log.Debugf("proxy connection pool: created transport kind=%d %s scope=%s idle=%s max=%d", kind, poolLabel, proxyPoolSecretScope("scope:", key.scope), idle, max)
+ return tr, nil
+ })
+ if err != nil {
+ log.Errorf("proxy connection pool: %v", err)
+ return nil, true
+ }
+ return tr, true
+}
+
+// resolveProxyPoolSettings resolves the pooling settings carried by one api-key
+// credential. Writing the connection-pool block is the opt-in, so an omitted
+// `enabled` means enabled and only an explicit `enabled: false` turns pooling off
+// (a key opting out of its group). Omitted fields fall back to the pool defaults.
+func resolveProxyPoolSettings(auth *cliproxyauth.Auth) (config.ProxyConnectionPoolConfig, bool) {
+ override := proxyPoolOverride(auth)
+ if override == nil {
+ return config.ProxyConnectionPoolConfig{}, false
+ }
+ if override.Enabled != nil && !*override.Enabled {
+ return config.ProxyConnectionPoolConfig{}, false
+ }
+ return config.ProxyConnectionPoolConfig{
+ Enabled: override.Enabled,
+ IdleConnTimeout: strings.TrimSpace(override.IdleConnTimeout),
+ MaxIdleConnsPerHost: override.MaxIdleConnsPerHost,
+ }, true
+}
+
+func proxyPoolOverride(auth *cliproxyauth.Auth) *cliproxyauth.ProxyConnectionPoolOverride {
+ if auth == nil {
+ return nil
+ }
+ return auth.ProxyConnectionPool
+}
+
+// proxyPoolCacheTarget returns the cache key and redacted log label for a parsed
+// proxy setting. Authenticated proxies fold a short SHA-256 digest of user:pass
+// into the cache key instead of raw credentials: each proxy identity (including
+// sticky-session residential proxy usernames) keeps its own pool, rotating proxy
+// credentials immediately switches pools, and plaintext secrets never sit in the
+// cache key or logs.
+func proxyPoolCacheTarget(setting proxyutil.Setting) (cacheProxy, poolLabel string) {
+ switch setting.Mode {
+ case proxyutil.ModeDirect:
+ // "direct" and "none" are the same setting and must not consume two slots.
+ return "direct", "direct"
+ case proxyutil.ModeInherit:
+ return "inherit", "inherit"
+ default:
+ redacted := proxyutil.Redact(setting.Raw)
+ if setting.URL != nil && setting.URL.User != nil {
+ return redacted + "#" + proxyPoolSecretScope("proxyauth:", setting.URL.User.String()), redacted
+ }
+ return setting.Raw, redacted
+ }
+}
+
+// proxyPoolAnonymousScope is the pool scope for requests that carry no credential.
+const proxyPoolAnonymousScope = "anonymous"
+
+// proxyPoolScope identifies the credential that owns a pool.
+//
+// Pooling must never mix accounts on one TCP/TLS connection: a shared connection
+// correlates distinct identities upstream, and a rotated key would keep reusing a
+// socket that was opened with the old one. Every credential therefore gets its own
+// pool, mirroring the Antigravity executor's credential-scoped pools. The scope is
+// only ever compared, never logged, so it cannot leak credential material.
+func proxyPoolScope(auth *cliproxyauth.Auth) string {
+ if auth == nil {
+ return proxyPoolAnonymousScope
+ }
+ var identity string
+ if id := strings.TrimSpace(auth.ID); id != "" {
+ identity = "id:" + id
+ } else if auth.Attributes != nil {
+ if path := strings.TrimSpace(auth.Attributes[cliproxyauth.AttributePath]); path != "" {
+ identity = "path:" + path
+ } else if source := strings.TrimSpace(auth.Attributes[cliproxyauth.AttributeSource]); source != "" {
+ identity = "source:" + source
+ }
+ }
+ // Auth.Label is deliberately not used: it is an optional human readable label
+ // with no uniqueness guarantee, so two identities sharing a label would share a
+ // pool. Fold in digested credential material even when an ID/path/source is
+ // present so rotating an API key or token in-place under a stable Auth.ID
+ // switches pools instead of reusing a socket opened with the old secret.
+ secret := proxyPoolCredentialSecretScope(auth)
+ if identity != "" && secret != "" {
+ return identity + "#" + secret
+ }
+ if identity != "" {
+ return identity
+ }
+ if secret != "" {
+ return secret
+ }
+ return proxyPoolAnonymousScope
+}
+
+func proxyPoolCredentialSecretScope(auth *cliproxyauth.Auth) string {
+ if auth == nil {
+ return ""
+ }
+ if refresh := authMetadataString(auth.Metadata, "refresh_token"); refresh != "" {
+ return proxyPoolSecretScope("refresh:", refresh)
+ }
+ if access := authMetadataString(auth.Metadata, "access_token"); access != "" {
+ return proxyPoolSecretScope("token:", access)
+ }
+ if auth.Attributes != nil {
+ if apiKey := strings.TrimSpace(auth.Attributes[cliproxyauth.AttributeAPIKey]); apiKey != "" {
+ return proxyPoolSecretScope("apikey:", apiKey)
+ }
+ }
+ return ""
+}
+
+// proxyPoolSecretScope derives a scope from secret material. Only a short digest is
+// retained, so a pool key cannot be turned back into the credential it came from.
+func proxyPoolSecretScope(prefix, secret string) string {
+ digest := sha256.Sum256([]byte(secret))
+ return prefix + hex.EncodeToString(digest[:8])
+}
+
+func authMetadataString(metadata map[string]any, key string) string {
+ if metadata == nil {
+ return ""
+ }
+ value, ok := metadata[key]
+ if !ok {
+ return ""
+ }
+ text, _ := value.(string)
+ return strings.TrimSpace(text)
+}
+
+// unpooledTransport constructs a one-off transport for an opted-in credential that
+// must bypass the shared cache (zero/negative pool bounds or a fenced config
+// generation). It uses the kind's own builder so an unpooled uTLS transport keeps
+// its TLS fingerprint. DisableKeepAlives is always set: no cache owns this transport,
+// so any connection left in its idle list would survive until an idle timer fires (or
+// forever on a transport built with IdleConnTimeout=0, such as the Claude Code one).
+func unpooledTransport(build func() (*http.Transport, error)) *http.Transport {
+ tr, errBuild := build()
+ if errBuild != nil {
+ log.Errorf("%v", errBuild)
+ return nil
+ }
+ if tr != nil {
+ tr.DisableKeepAlives = true
+ }
+ return tr
+}
+
+// buildPooledBaseTransport builds the transport a pool entry starts from. An empty
+// proxy setting keeps net/http's inherited environment-proxy behavior and only takes
+// ownership of the pool; explicit settings go through proxyutil as before.
+func buildPooledBaseTransport(proxyURL string) (*http.Transport, error) {
+ if strings.TrimSpace(proxyURL) == "" {
+ if base, ok := http.DefaultTransport.(*http.Transport); ok && base != nil {
+ return base.Clone(), nil
+ }
+ return nil, fmt.Errorf("http.DefaultTransport is not *http.Transport")
+ }
+ tr, _, errBuild := proxyutil.BuildHTTPTransport(proxyURL)
+ if errBuild != nil {
+ return nil, errBuild
+ }
+ return tr, nil
+}
+
+// stampProxyKeepAlive enables TCP keepalive on a freshly dialed upstream
+// connection. Direct and HTTP-proxy transports inherit net/http's keepalive dialer,
+// but the SOCKS5 path dials through proxy.Direct, which leaves keepalive at the OS
+// default of hours; a peer that died silently would then pin a pooled connection for
+// that long. Keepalive only probes idle sockets, so a live stream is never cut.
+func stampProxyKeepAlive(conn net.Conn) net.Conn {
+ tcp, ok := conn.(*net.TCPConn)
+ if !ok {
+ return conn
+ }
+ if err := tcp.SetKeepAlive(true); err != nil {
+ return conn
+ }
+ if err := tcp.SetKeepAlivePeriod(proxyPoolKeepAlivePeriod); err != nil {
+ return conn
+ }
+ return conn
+}
+
var devinTransportCache = NewTransportCache[string](DefaultTransportCacheCapacity)
// NewDevinHTTPClient creates an HTTP client customized for Devin Connect-RPC upstream.
diff --git a/internal/runtime/executor/helps/proxy_helpers_test.go b/internal/runtime/executor/helps/proxy_helpers_test.go
index cbda1b7664b..3cd9bce19ea 100644
--- a/internal/runtime/executor/helps/proxy_helpers_test.go
+++ b/internal/runtime/executor/helps/proxy_helpers_test.go
@@ -2,13 +2,20 @@ package helps
import (
"context"
+ "io"
+ "net"
"net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
"testing"
+ "time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
)
func TestNewProxyAwareHTTPClientRequestProxyOverridesAuthAndGlobal(t *testing.T) {
@@ -118,3 +125,127 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+
+func TestProxyConnectionPoolReuse(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ enabled bool
+ wantConnections int
+ }{{"disabled", false, 2}, {"enabled", true, 1}} {
+ t.Run(tc.name, func(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ var connections atomic.Int32
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }))
+ server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
+ if state == http.StateNew {
+ connections.Add(1)
+ }
+ }
+ server.Start()
+ defer server.Close()
+ cfg := testConfig()
+ auth := &cliproxyauth.Auth{ProxyURL: "direct"}
+ if tc.enabled {
+ auth.ProxyConnectionPool = poolEnabled()
+ }
+ for i := 0; i < 2; i++ {
+ client := NewProxyAwareHTTPClient(context.Background(), cfg, auth, 0)
+ resp, err := client.Get(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = io.Copy(io.Discard, resp.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = resp.Body.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if !tc.enabled {
+ client.CloseIdleConnections()
+ }
+ }
+ if got := connections.Load(); got != int32(tc.wantConnections) {
+ t.Fatalf("connections = %d, want %d", got, tc.wantConnections)
+ }
+ })
+ }
+}
+
+func TestProxyConnectionPoolSettingsIsolation(t *testing.T) {
+ enabled := true
+ maxIdle := 4
+ cfg := testConfig()
+ auth := poolAuth("settings", &cliproxyauth.ProxyConnectionPoolOverride{Enabled: &enabled, IdleConnTimeout: "15s", MaxIdleConnsPerHost: &maxIdle})
+ defer ResetProxyTransports()
+ first := pooledProxyTransport("direct", cfg, auth)
+ if first == nil || first.IdleConnTimeout != 15*time.Second || first.MaxIdleConnsPerHost != 4 {
+ t.Fatalf("unexpected transport: %+v", first)
+ }
+ if pooledProxyTransport("direct", cfg, auth) != first {
+ t.Fatal("transport not reused")
+ }
+ auth.ProxyConnectionPool.IdleConnTimeout = "20s"
+ if pooledProxyTransport("direct", cfg, auth) == first {
+ t.Fatal("different settings reused transport")
+ }
+ if pooledProxyTransport("http://localhost:1234", cfg, auth) == first {
+ t.Fatal("different proxies reused transport")
+ }
+ ResetProxyTransports()
+ if pooledProxyTransport("direct", cfg, auth) == first {
+ t.Fatal("reset did not remove transport")
+ }
+}
+
+func TestProxyConnectionPoolAuthenticatedProxyIsolation(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ cfg := testConfig()
+ auth := poolOptInAuth("credential-url")
+
+ first := pooledProxyTransport("http://user:old-secret@localhost:1234", cfg, auth)
+ second := pooledProxyTransport("http://user:old-secret@localhost:1234", cfg, auth)
+ if first == nil || second != first {
+ t.Fatal("identical authenticated proxy must reuse its pool")
+ }
+ rotated := pooledProxyTransport("http://user:new-secret@localhost:1234", cfg, auth)
+ if rotated == nil || rotated == first {
+ t.Fatal("different proxy credentials on the same host must not share a pool")
+ }
+ socks := pooledProxyTransport("socks5://user:old-secret@localhost:1234", cfg, auth)
+ if socks == nil || socks == first || pooledProxyTransport("socks5://user:old-secret@localhost:1234", cfg, auth) != socks {
+ t.Fatal("authenticated socks5 proxy must pool per scheme and credential")
+ }
+ setting, err := proxyutil.Parse("http://user:old-secret@localhost:1234")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cacheKey, label := proxyPoolCacheTarget(setting)
+ if strings.Contains(cacheKey, "old-secret") || strings.Contains(label, "old-secret") {
+ t.Fatalf("cache target leaked proxy secret: key=%q label=%q", cacheKey, label)
+ }
+}
+func TestProxyConnectionPoolCustomDefaultTransportDeclinesPooling(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ oldDefault := http.DefaultTransport
+ defer func() { http.DefaultTransport = oldDefault }()
+
+ customRT := roundTripperFunc(func(req *http.Request) (*http.Response, error) {
+ return &http.Response{StatusCode: 200}, nil
+ })
+ http.DefaultTransport = customRT
+
+ cfg := testConfig()
+ auth := poolOptInAuth("custom-default")
+
+ // With empty proxy-url, pooling must be declined because http.DefaultTransport
+ // cannot be cloned into *http.Transport.
+ client := NewProxyAwareHTTPClient(context.Background(), cfg, auth, 0)
+ if client.Transport != nil {
+ t.Fatalf("client.Transport = %T, want nil so client uses http.DefaultTransport", client.Transport)
+ }
+}
diff --git a/internal/runtime/executor/helps/proxy_pool_generation_test.go b/internal/runtime/executor/helps/proxy_pool_generation_test.go
new file mode 100644
index 00000000000..190ae471bf7
--- /dev/null
+++ b/internal/runtime/executor/helps/proxy_pool_generation_test.go
@@ -0,0 +1,156 @@
+package helps
+
+import (
+ "net/http"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+// This test changes the process-wide generation and must not run in parallel.
+func TestProxyPoolGenerationRejectsOldSnapshotAfterReload(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ publisher := RegisterProxyPoolPublisher()
+ old := &config.Config{}
+ AdvanceProxyPoolGeneration(publisher, old)
+ oldClone := old.CloneForRuntime()
+ if oldClone.ProxyPoolGeneration != old.ProxyPoolGeneration || oldClone.ProxyPoolPublisher != publisher {
+ t.Fatal("runtime clone lost the publisher fence")
+ }
+ first := pooledProxyTransport("direct", oldClone, poolOptInAuth("generation"))
+ if first == nil || proxyTransportCache.Len() != 1 {
+ t.Fatal("initial pool missing")
+ }
+ next := old.CloneForRuntime()
+ AdvanceProxyPoolGeneration(publisher, next)
+ if proxyTransportCache.Len() != 0 {
+ t.Fatal("reload did not retire the publisher's pools")
+ }
+ // A request captured the old snapshot before reload and resumes only after it.
+ resume := make(chan struct{})
+ result := make(chan *http.Transport, 1)
+ go func() {
+ <-resume
+ result <- pooledProxyTransport("direct", oldClone, poolOptInAuth("generation"))
+ }()
+ close(resume)
+ stale := <-result
+ if stale == nil || proxyTransportCache.Len() != 0 {
+ t.Fatal("stale snapshot repopulated pool")
+ }
+ if !stale.DisableKeepAlives {
+ t.Fatal("fenced one-off transport must disable keep-alives so it cannot leak idle sockets")
+ }
+ if staleClaude := pooledClaudeCodeTransport("direct", oldClone, poolOptInAuth("generation")); staleClaude == nil || !staleClaude.DisableKeepAlives {
+ t.Fatalf("fenced Anthropic transport must disable keep-alives, got %+v", staleClaude)
+ }
+ current := pooledProxyTransport("direct", next, poolOptInAuth("generation"))
+ if current == nil || proxyTransportCache.Len() != 1 {
+ t.Fatal("current snapshot did not populate pool")
+ }
+ if pooledProxyTransport("direct", next, poolOptInAuth("generation")) != current {
+ t.Fatal("current transport not reused")
+ }
+ if first == current || stale == current {
+ t.Fatal("old and new generations share transports")
+ }
+}
+
+// TestProxyPoolFenceIsPerPublisher pins the isolation requirement: a reload or a new
+// runtime in one publisher must never retire the snapshots another publisher is still
+// serving. A shared counter used to fence every runtime in the process, so building a
+// second SDK service silently turned the first service's pooling into a fresh
+// transport per request.
+func TestProxyPoolFenceIsPerPublisher(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ firstPublisher := RegisterProxyPoolPublisher()
+ secondPublisher := RegisterProxyPoolPublisher()
+ if firstPublisher == 0 || firstPublisher == secondPublisher {
+ t.Fatalf("publishers not unique: %d, %d", firstPublisher, secondPublisher)
+ }
+ auth := poolOptInAuth("shared-scope")
+ firstCfg := &config.Config{}
+ AdvanceProxyPoolGeneration(firstPublisher, firstCfg)
+ firstTransport := pooledProxyTransport("direct", firstCfg, auth)
+ if firstTransport == nil {
+ t.Fatal("first publisher did not pool")
+ }
+
+ // Publishing in another runtime must not retire or purge the first runtime's pool.
+ secondCfg := &config.Config{}
+ AdvanceProxyPoolGeneration(secondPublisher, secondCfg)
+ if got := pooledProxyTransport("direct", firstCfg, auth); got != firstTransport {
+ t.Fatal("another publisher's publication retired a live snapshot")
+ }
+ if got := proxyTransportCache.Len(); got != 1 {
+ t.Fatalf("cache holds %d pools, want the first publisher's pool only", got)
+ }
+
+ // Publishers keep separate pools even for the same credential and proxy.
+ secondTransport := pooledProxyTransport("direct", secondCfg, auth)
+ if secondTransport == nil || secondTransport == firstTransport {
+ t.Fatal("publishers share one pool")
+ }
+
+ // A reload inside the first publisher still retires the first publisher's pool.
+ nextFirst := firstCfg.CloneForRuntime()
+ AdvanceProxyPoolGeneration(firstPublisher, nextFirst)
+ if pooledProxyTransport("direct", firstCfg, auth) == firstTransport {
+ t.Fatal("reload of the same publisher did not retire its stale snapshot")
+ }
+ if got := pooledProxyTransport("direct", nextFirst, auth); got == firstTransport {
+ t.Fatal("reload of the same publisher reused the retired transport")
+ }
+ // ... while the second publisher's pool survives it.
+ if got := pooledProxyTransport("direct", secondCfg, auth); got != secondTransport {
+ t.Fatal("reload of one publisher retired another publisher's pool")
+ }
+}
+
+// TestProxyPoolUnfencedSnapshotStillPools covers configs that never passed through the
+// SDK generation fence: generation 0 cannot be stale, so pooling must stay on even after
+// the process-wide generation has advanced. Regression for callers that build their own
+// config.Config (direct api usage, helper probes) and would otherwise lose pooling
+// silently once any SDK service published a config.
+func TestProxyPoolUnfencedSnapshotStillPools(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ // Publish a config so the process-wide generation moves past zero.
+ AdvanceProxyPoolGeneration(RegisterProxyPoolPublisher(), &config.Config{})
+
+ plain := &config.Config{}
+ if plain.ProxyPoolGeneration != 0 {
+ t.Fatalf("expected an unfenced snapshot, generation = %d", plain.ProxyPoolGeneration)
+ }
+ auth := poolOptInAuth("unfenced")
+ pooled := pooledProxyTransport("direct", plain, auth)
+ if pooled == nil {
+ t.Fatal("unfenced snapshot must still pool")
+ }
+ if pooledProxyTransport("direct", plain, auth) != pooled {
+ t.Fatal("unfenced snapshot did not reuse its pool")
+ }
+
+ // A snapshot carrying a retired generation is still fenced out.
+ retired := &config.Config{ProxyPoolPublisher: plain.ProxyPoolPublisher}
+ proxyPoolFence.RLock()
+ retired.ProxyPoolGeneration = proxyPoolFence.current[retired.ProxyPoolPublisher] + 1
+ proxyPoolFence.RUnlock()
+ before := proxyTransportCache.Len()
+ first := pooledProxyTransport("direct", retired, auth)
+ second := pooledProxyTransport("direct", retired, auth)
+ if first == nil || second == nil {
+ t.Fatal("fenced snapshot must still get a usable transport")
+ }
+ if first == second {
+ t.Fatal("fenced snapshot must not be cached")
+ }
+ if got := proxyTransportCache.Len(); got != before {
+ t.Fatalf("fenced snapshot repopulated the cache: %d -> %d", before, got)
+ }
+}
diff --git a/internal/runtime/executor/helps/proxy_pool_isolation_test.go b/internal/runtime/executor/helps/proxy_pool_isolation_test.go
new file mode 100644
index 00000000000..da81ff837d7
--- /dev/null
+++ b/internal/runtime/executor/helps/proxy_pool_isolation_test.go
@@ -0,0 +1,249 @@
+package helps
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// These tests share the process-wide pool and must not run in parallel.
+
+func enabledPoolConfig() *config.Config { return testConfig() }
+
+// TestProxyPoolIsolatesCredentials pins requirement 2: two keys must never share a
+// pool, because a shared TCP/TLS connection would correlate distinct identities
+// upstream and would let a rotated key keep using an old socket.
+func TestProxyPoolIsolatesCredentials(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ cfg := enabledPoolConfig()
+
+ first := poolOptInAuth("key-a")
+ second := poolOptInAuth("key-b")
+ for _, proxyURL := range []string{
+ "",
+ "direct",
+ "http://127.0.0.1:8080",
+ "socks5://127.0.0.1:1080",
+ "http://user:pass@127.0.0.1:8080",
+ "socks5://user:pass@127.0.0.1:1080",
+ } {
+ firstTransport := pooledProxyTransport(proxyURL, cfg, first)
+ secondTransport := pooledProxyTransport(proxyURL, cfg, second)
+ if firstTransport == nil || secondTransport == nil {
+ t.Fatalf("proxy %q: pooled transports missing", proxyURL)
+ }
+ if firstTransport == secondTransport {
+ t.Fatalf("proxy %q: different credentials shared one pool", proxyURL)
+ }
+ if pooledProxyTransport(proxyURL, cfg, first) != firstTransport {
+ t.Fatalf("proxy %q: the same credential must reuse its pool", proxyURL)
+ }
+ }
+ // Two unauthenticated proxies on the same key must also keep separate pools.
+ if pooledProxyTransport("http://127.0.0.1:8080", cfg, first) == pooledProxyTransport("http://127.0.0.1:8081", cfg, first) {
+ t.Fatal("different unauthenticated proxies shared one pool")
+ }
+
+ // API-key credentials without an ID fall back to a digested key scope.
+ keyOne := poolAuth("", poolEnabled())
+ keyOne.Attributes = map[string]string{cliproxyauth.AttributeAPIKey: "sk-one"}
+ keyTwo := poolAuth("", poolEnabled())
+ keyTwo.Attributes = map[string]string{cliproxyauth.AttributeAPIKey: "sk-two"}
+ if pooledProxyTransport("direct", cfg, keyOne) == pooledProxyTransport("direct", cfg, keyTwo) {
+ t.Fatal("different API keys share one pool")
+ }
+ if scope := proxyPoolScope(keyOne); strings.Contains(scope, "sk-one") {
+ t.Fatalf("pool scope leaks credential material: %q", scope)
+ }
+ if proxyPoolScope(keyOne) != proxyPoolScope(&cliproxyauth.Auth{Attributes: map[string]string{cliproxyauth.AttributeAPIKey: "sk-one"}}) {
+ t.Fatal("identical credentials must map to one scope")
+ }
+ // A human readable label is not an identity.
+ if proxyPoolScope(&cliproxyauth.Auth{Label: "same"}) != proxyPoolScope(&cliproxyauth.Auth{Label: "same"}) {
+ t.Fatal("label-only auths must share the anonymous scope")
+ }
+ if got := proxyPoolScope(&cliproxyauth.Auth{Label: "shared"}); got != proxyPoolAnonymousScope {
+ t.Fatalf("label must not become a scope, got %q", got)
+ }
+
+ // Rotating an API key in-place under a stable Auth.ID must switch pools.
+ rotatedBefore := poolAuth("stable-id", poolEnabled())
+ rotatedBefore.Attributes = map[string]string{cliproxyauth.AttributeAPIKey: "sk-old"}
+ rotatedAfter := poolAuth("stable-id", poolEnabled())
+ rotatedAfter.Attributes = map[string]string{cliproxyauth.AttributeAPIKey: "sk-new"}
+ if pooledProxyTransport("direct", cfg, rotatedBefore) == pooledProxyTransport("direct", cfg, rotatedAfter) {
+ t.Fatal("rotating API key under the same Auth.ID must not reuse the old key's pool")
+ }
+}
+
+// TestProxyPoolBoundsIdleSocketsPerCredential covers requirement 4: one credential
+// must not be able to hoard idle sockets, no matter how many hosts it talks to.
+func TestProxyPoolBoundsIdleSocketsPerCredential(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ tr := pooledProxyTransport("direct", enabledPoolConfig(), poolOptInAuth("bounded"))
+ if tr == nil {
+ t.Fatal("pooled transport missing")
+ }
+ if tr.MaxIdleConns != proxyPoolDefaultMaxIdleConnsPerHost {
+ t.Fatalf("MaxIdleConns = %d, want %d", tr.MaxIdleConns, proxyPoolDefaultMaxIdleConnsPerHost)
+ }
+ if tr.MaxIdleConnsPerHost != proxyPoolDefaultMaxIdleConnsPerHost {
+ t.Fatalf("MaxIdleConnsPerHost = %d, want %d", tr.MaxIdleConnsPerHost, proxyPoolDefaultMaxIdleConnsPerHost)
+ }
+ if tr.IdleConnTimeout != proxyPoolDefaultIdleConnTimeout {
+ t.Fatalf("IdleConnTimeout = %s, want %s", tr.IdleConnTimeout, proxyPoolDefaultIdleConnTimeout)
+ }
+}
+
+// TestProxyPoolCapacityBoundsManyCredentials covers requirement 4 for the large
+// deployment: 100000 keys must not mean 100000 pools. The cache stays at its
+// capacity and every credential still gets a usable transport.
+func TestProxyPoolCapacityBoundsManyCredentials(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ cfg := enabledPoolConfig()
+ total := proxyPoolCapacity + 120
+ for i := 0; i < total; i++ {
+ auth := poolOptInAuth("key-" + strconv.Itoa(i))
+ if tr := pooledProxyTransport("direct", cfg, auth); tr == nil {
+ t.Fatalf("credential %d got no transport", i)
+ }
+ }
+ if got := proxyTransportCache.Len(); got != proxyPoolCapacity {
+ t.Fatalf("cache holds %d pools, want %d", got, proxyPoolCapacity)
+ }
+ // A hot credential keeps its pool while cold ones churn through it.
+ hot := poolOptInAuth("hot")
+ hotTransport := pooledProxyTransport("direct", cfg, hot)
+ for i := 0; i < 16; i++ {
+ pooledProxyTransport("direct", cfg, poolOptInAuth("cold-"+strconv.Itoa(i)))
+ }
+ if pooledProxyTransport("direct", cfg, hot) != hotTransport {
+ t.Fatal("recently used credential lost its pool")
+ }
+ if got := proxyTransportCache.Len(); got > proxyPoolCapacity {
+ t.Fatalf("cache grew past capacity: %d", got)
+ }
+}
+
+// TestEvictedTransportDoesNotRebuildIdlePool verifies that if a caller obtains a
+// pooled transport and is descheduled until after that entry is evicted from the
+// LRU cache, a subsequent RoundTrip on the stale transport still completes but
+// closes the socket immediately instead of parking an untracked idle connection.
+func TestEvictedTransportDoesNotRebuildIdlePool(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ cfg := enabledPoolConfig()
+
+ closedCh := make(chan struct{}, 1)
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ }))
+ server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
+ if state == http.StateClosed {
+ select {
+ case closedCh <- struct{}{}:
+ default:
+ }
+ }
+ }
+ server.Start()
+ defer server.Close()
+
+ staleAuth := poolOptInAuth("evicted-victim")
+ staleAuth.ProxyURL = "direct"
+ staleClient := NewProxyAwareHTTPClient(context.Background(), cfg, staleAuth, 0)
+
+ // Evict staleAuth's transport before staleClient performs its first RoundTrip.
+ for i := 0; i < proxyPoolCapacity+8; i++ {
+ other := poolOptInAuth("evict-filler-" + strconv.Itoa(i))
+ if tr := pooledProxyTransport("direct", cfg, other); tr == nil {
+ t.Fatalf("filler %d got no transport", i)
+ }
+ }
+
+ resp, err := staleClient.Get(server.URL)
+ if err != nil {
+ t.Fatalf("request on evicted transport failed: %v", err)
+ }
+ if _, errCopy := io.Copy(io.Discard, resp.Body); errCopy != nil {
+ t.Fatalf("read body: %v", errCopy)
+ }
+ if errClose := resp.Body.Close(); errClose != nil {
+ t.Fatalf("close body: %v", errClose)
+ }
+
+ // Because the transport was already evicted, tryPutIdleConn must reject parking
+ // the socket and close it immediately when the response finishes.
+ <-closedCh
+}
+
+// TestProxyPoolDropsUnexpectedlyClosedConnection covers requirement 3: when the
+// peer disappears mid-life, the pooled connection must be discarded instead of
+// being reused, and the following request must succeed on a fresh connection.
+func TestProxyPoolDropsUnexpectedlyClosedConnection(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ var opened atomic.Int32
+ var mu sync.Mutex
+ var live []net.Conn
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ }))
+ server.Config.ConnState = func(conn net.Conn, state http.ConnState) {
+ if state != http.StateNew {
+ return
+ }
+ opened.Add(1)
+ mu.Lock()
+ live = append(live, conn)
+ mu.Unlock()
+ }
+ server.Start()
+ defer server.Close()
+
+ cfg := enabledPoolConfig()
+ auth := poolAuth("dead-peer", poolEnabled())
+ auth.ProxyURL = "direct"
+ request := func() {
+ client := NewProxyAwareHTTPClient(context.Background(), cfg, auth, 0)
+ resp, err := client.Get(server.URL)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ if _, errCopy := io.Copy(io.Discard, resp.Body); errCopy != nil {
+ t.Fatalf("read body: %v", errCopy)
+ }
+ if errClose := resp.Body.Close(); errClose != nil {
+ t.Fatalf("close body: %v", errClose)
+ }
+ }
+
+ request()
+ if got := opened.Load(); got != 1 {
+ t.Fatalf("connections after first request = %d, want 1", got)
+ }
+ // Kill the pooled socket from the server side, the way a crashed upstream or a
+ // NAT timeout would surface.
+ mu.Lock()
+ for _, conn := range live {
+ _ = conn.Close()
+ }
+ mu.Unlock()
+ request()
+ if got := opened.Load(); got < 2 {
+ t.Fatalf("connections = %d, want a fresh dial after the peer closed", got)
+ }
+}
diff --git a/internal/runtime/executor/helps/proxy_pool_override_test.go b/internal/runtime/executor/helps/proxy_pool_override_test.go
new file mode 100644
index 00000000000..f0d98032e40
--- /dev/null
+++ b/internal/runtime/executor/helps/proxy_pool_override_test.go
@@ -0,0 +1,145 @@
+package helps
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// These tests share the process-wide pool and must not run in parallel.
+
+func boolPtr(v bool) *bool { return &v }
+
+func intPtr(v int) *int { return &v }
+
+// TestProxyPoolRequiresCredentialOptIn pins the per-api-key switch: there is no global
+// switch, so only a credential carrying a connection-pool block is pooled.
+func TestProxyPoolRequiresCredentialOptIn(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ cfg := testConfig()
+ optedIn := poolOptInAuth("ollama-key")
+ plain := &cliproxyauth.Auth{ID: "other-key"}
+
+ pooled := pooledProxyTransport("direct", cfg, optedIn)
+ if pooled == nil {
+ t.Fatal("opted-in credential got no transport")
+ }
+ if pooled.IdleConnTimeout != proxyPoolDefaultIdleConnTimeout {
+ t.Fatalf("IdleConnTimeout = %s, want default %s", pooled.IdleConnTimeout, proxyPoolDefaultIdleConnTimeout)
+ }
+ if pooledProxyTransport("direct", cfg, optedIn) != pooled {
+ t.Fatal("opted-in credential does not reuse its pool")
+ }
+ if pooledProxyTransport("direct", cfg, plain) == pooled {
+ t.Fatal("credential without the block shared the pool")
+ }
+ first := pooledProxyTransport("direct", cfg, plain)
+ second := pooledProxyTransport("direct", cfg, plain)
+ if first == second {
+ t.Fatal("credential without the block must not be pooled")
+ }
+ // A nil config still honours the credential block: pooling no longer needs a config.
+ if tr := pooledProxyTransport("direct", nil, optedIn); tr == nil || tr != pooled {
+ t.Fatal("nil config must still honour the credential block")
+ }
+}
+
+// TestProxyPoolExplicitDisable covers a key opting out of its group block.
+func TestProxyPoolExplicitDisable(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ optedOut := poolAuth("no-pool", &cliproxyauth.ProxyConnectionPoolOverride{Enabled: boolPtr(false)})
+ first := pooledProxyTransport("direct", testConfig(), optedOut)
+ second := pooledProxyTransport("direct", testConfig(), optedOut)
+ if first == nil || second == nil {
+ t.Fatal("transport missing")
+ }
+ if first == second {
+ t.Fatal("credential that disabled pooling was still pooled")
+ }
+ if proxyTransportCache.Len() != 0 {
+ t.Fatalf("opted-out credential polluted the cache: %d entries", proxyTransportCache.Len())
+ }
+}
+
+// TestProxyPoolPartialSettings covers partial blocks: omitted fields use the defaults.
+func TestProxyPoolPartialSettings(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ partial := poolAuth("partial", &cliproxyauth.ProxyConnectionPoolOverride{IdleConnTimeout: "45s"})
+ tr := pooledProxyTransport("direct", testConfig(), partial)
+ if tr == nil {
+ t.Fatal("pooled transport missing")
+ }
+ if tr.IdleConnTimeout != 45*time.Second {
+ t.Fatalf("IdleConnTimeout = %s, want 45s from the block", tr.IdleConnTimeout)
+ }
+ if tr.MaxIdleConnsPerHost != proxyPoolDefaultMaxIdleConnsPerHost {
+ t.Fatalf("MaxIdleConnsPerHost = %d, want the default %d", tr.MaxIdleConnsPerHost, proxyPoolDefaultMaxIdleConnsPerHost)
+ }
+
+ other := poolAuth("partial-2", &cliproxyauth.ProxyConnectionPoolOverride{IdleConnTimeout: "60s"})
+ if pooledProxyTransport("direct", testConfig(), other) == tr {
+ t.Fatal("different settings shared one pool")
+ }
+}
+
+// TestProxyPoolCredentialOptInWithoutProxyURL pins that pooling does not require a
+// proxy-url: the inherited environment-proxy behavior is preserved.
+func TestProxyPoolCredentialOptInWithoutProxyURL(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ cfg := testConfig()
+ auth := poolOptInAuth("no-proxy-url")
+ pooled := pooledProxyTransport("", cfg, auth)
+ if pooled == nil {
+ t.Fatal("opted-in credential without proxy-url got no transport")
+ }
+ if pooled.Proxy == nil {
+ t.Fatal("inherited environment proxy behavior must be preserved")
+ }
+ if pooledProxyTransport("", cfg, auth) != pooled {
+ t.Fatal("inherited pool not reused")
+ }
+ // A credential without the block keeps the shared default transport.
+ if tr := pooledProxyTransport("", cfg, &cliproxyauth.Auth{ID: "plain"}); tr != nil {
+ t.Fatal("unopted credential must keep using http.DefaultTransport")
+ }
+ // The generic client must wire the pooled transport in.
+ client := NewProxyAwareHTTPClient(context.Background(), cfg, auth, 0)
+ if client.Transport != pooled {
+ t.Fatalf("client transport = %T, want the pooled transport", client.Transport)
+ }
+ // When proxy-url is empty, an explicitly injected context RoundTripper must keep
+ // priority over the implicit credential pool, matching NewUtlsHTTPClient.
+ injected := roundTripperFunc(func(*http.Request) (*http.Response, error) { return nil, nil })
+ ctxWithRT := context.WithValue(context.Background(), "cliproxy.roundtripper", injected)
+ injectedClient := NewProxyAwareHTTPClient(ctxWithRT, cfg, auth, 0)
+ if injectedClient.Transport == pooled {
+ t.Fatal("context RoundTripper must take priority when proxy-url is empty")
+ }
+ // Zero or negative pool bounds with an empty proxy-url must still return a
+ // transport with DisableKeepAlives=true rather than nil (which would fall back
+ // to http.DefaultTransport and keep pooling).
+ for _, raw := range []string{"0s", "-1s"} {
+ zeroIdle := poolAuth("zero-no-proxy", &cliproxyauth.ProxyConnectionPoolOverride{IdleConnTimeout: raw})
+ if got := pooledProxyTransport("", cfg, zeroIdle); got == nil || !got.DisableKeepAlives {
+ t.Fatalf("empty proxy-url with idle %s must disable keep-alives, got %+v", raw, got)
+ }
+ }
+ for _, max := range []int{0, -1} {
+ val := max
+ zeroMax := poolAuth("max-no-proxy", &cliproxyauth.ProxyConnectionPoolOverride{MaxIdleConnsPerHost: &val})
+ if got := pooledProxyTransport("", cfg, zeroMax); got == nil || !got.DisableKeepAlives {
+ t.Fatalf("empty proxy-url with max %d must disable keep-alives, got %+v", max, got)
+ }
+ }
+}
diff --git a/internal/runtime/executor/helps/proxy_pool_settings_test.go b/internal/runtime/executor/helps/proxy_pool_settings_test.go
new file mode 100644
index 00000000000..de2d90742aa
--- /dev/null
+++ b/internal/runtime/executor/helps/proxy_pool_settings_test.go
@@ -0,0 +1,56 @@
+package helps
+
+import (
+ "sync"
+ "testing"
+
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// These tests use the process-wide pool and must not run in parallel.
+func TestProxyPoolSettingsAndConcurrentBuild(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ // Writing the block without any field is the opt-in and must fall back to defaults.
+ auth := poolAuth("defaults", poolBlock())
+ cfg := testConfig()
+ tr := pooledProxyTransport("direct", cfg, auth)
+ if tr == nil || tr.IdleConnTimeout != proxyPoolDefaultIdleConnTimeout || tr.MaxIdleConnsPerHost != proxyPoolDefaultMaxIdleConnsPerHost {
+ t.Fatalf("unexpected defaults: %+v", tr)
+ }
+ if pooledProxyTransport("none", cfg, auth) != tr {
+ t.Fatal("direct and none should share transport")
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 32; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if pooledProxyTransport("direct", cfg, auth) != tr {
+ t.Error("transport not shared")
+ }
+ }()
+ }
+ wg.Wait()
+ for _, raw := range []string{"0s", "-1s"} {
+ zero := poolAuth("zero", &cliproxyauth.ProxyConnectionPoolOverride{IdleConnTimeout: raw})
+ if got := pooledProxyTransport("direct", cfg, zero); got == nil || !got.DisableKeepAlives {
+ t.Fatalf("%s must disable keep-alives", raw)
+ }
+ }
+ for _, max := range []int{0, -1} {
+ val := max
+ capped := poolAuth("max", &cliproxyauth.ProxyConnectionPoolOverride{MaxIdleConnsPerHost: &val})
+ if got := pooledProxyTransport("direct", cfg, capped); got == nil || !got.DisableKeepAlives {
+ t.Fatalf("max %d must disable keep-alives", max)
+ }
+ }
+ invalid := poolAuth("invalid", &cliproxyauth.ProxyConnectionPoolOverride{IdleConnTimeout: "invalid"})
+ bad := pooledProxyTransport("direct", cfg, invalid)
+ if bad == nil || bad.IdleConnTimeout != proxyPoolDefaultIdleConnTimeout || pooledProxyTransport("direct", cfg, invalid) != bad {
+ t.Fatal("invalid duration must use cached default")
+ }
+ if pooledProxyTransport("direct", nil, nil) == pooledProxyTransport("direct", nil, nil) {
+ t.Fatal("a credential without the block must retain unpooled behavior")
+ }
+}
diff --git a/internal/runtime/executor/helps/proxy_pool_test_helpers_test.go b/internal/runtime/executor/helps/proxy_pool_test_helpers_test.go
new file mode 100644
index 00000000000..1b63b1cd437
--- /dev/null
+++ b/internal/runtime/executor/helps/proxy_pool_test_helpers_test.go
@@ -0,0 +1,28 @@
+package helps
+
+import (
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// Pooling is configured per api-key credential, so tests opt in through the auth
+// override instead of a global config block.
+func poolEnabled() *cliproxyauth.ProxyConnectionPoolOverride {
+ enabled := true
+ return &cliproxyauth.ProxyConnectionPoolOverride{Enabled: &enabled}
+}
+
+func poolOptInAuth(id string) *cliproxyauth.Auth {
+ return &cliproxyauth.Auth{ID: id, ProxyConnectionPool: poolEnabled()}
+}
+
+func poolAuth(id string, override *cliproxyauth.ProxyConnectionPoolOverride) *cliproxyauth.Auth {
+ return &cliproxyauth.Auth{ID: id, ProxyConnectionPool: override}
+}
+
+// poolBlock is the block with no fields: the opt-in that uses the defaults.
+func poolBlock() *cliproxyauth.ProxyConnectionPoolOverride {
+ return &cliproxyauth.ProxyConnectionPoolOverride{}
+}
+
+func testConfig() *config.Config { return &config.Config{} }
diff --git a/internal/runtime/executor/helps/request_pair_error_test.go b/internal/runtime/executor/helps/request_pair_error_test.go
index 143070013b1..6d8d7ee1ceb 100644
--- a/internal/runtime/executor/helps/request_pair_error_test.go
+++ b/internal/runtime/executor/helps/request_pair_error_test.go
@@ -6,11 +6,11 @@ import (
"net/http"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -60,7 +60,7 @@ func TestPairTranslationReportsOnlyTheWorkingError(t *testing.T) {
for name, pair := range routes {
t.Run(name, func(t *testing.T) {
- if _, working, err := pair(file, text); err != nil || !gjson.ValidBytes(working) {
+ if _, working, err := pair(file, text); err != nil || !ojson.ValidBytes(working) {
t.Fatalf("failing baseline rejected a working payload that translated: err = %v, working = %s", err, working)
}
if _, _, err := pair(text, file); err == nil {
@@ -89,7 +89,7 @@ func TestEnvelopePairKeepsTheWorkingPayloadWhenTheBaselineIsRefused(t *testing.T
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(working, "request.contents.0.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytes(working, "request.contents.0.parts.0.text").String(); got != "keep me" {
t.Fatalf("working = %s", working)
}
}
@@ -118,7 +118,7 @@ func TestResponsesAudioAndTextBesideFileIDOnCompatPath(t *testing.T) {
if err != nil {
t.Fatalf("text beside a file id was refused: %v", err)
}
- if got := gjson.GetBytes(body, "messages.0.content").String(); got != "keep me" {
+ if got := ojson.GetBytes(body, "messages.0.content").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
diff --git a/internal/runtime/executor/helps/request_pair_plugin_host_test.go b/internal/runtime/executor/helps/request_pair_plugin_host_test.go
index 9e403fe9978..726592580a6 100644
--- a/internal/runtime/executor/helps/request_pair_plugin_host_test.go
+++ b/internal/runtime/executor/helps/request_pair_plugin_host_test.go
@@ -6,12 +6,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func TestRequestPairWithEmptyPluginHostSanitizesSignaturesOnce(t *testing.T) {
@@ -60,7 +60,7 @@ func TestRequestPairWithEmptyPluginHostSanitizesSignaturesOnce(t *testing.T) {
if !bytes.Equal(base, work) {
t.Fatal("same input produced different translations")
}
- parts := gjson.GetBytes(work, "request.contents.0.parts").Array()
+ parts := ojson.GetBytes(work, "request.contents.0.parts").Array()
if len(parts) != 10 || parts[0].Get("thoughtSignature").String() != "skip_thought_signature_validator" {
t.Fatalf("unexpected parallel tool call layout: %s", work)
}
diff --git a/internal/runtime/executor/helps/request_unsupported_user_turn_test.go b/internal/runtime/executor/helps/request_unsupported_user_turn_test.go
index feb3c8819d3..72b687e65bc 100644
--- a/internal/runtime/executor/helps/request_unsupported_user_turn_test.go
+++ b/internal/runtime/executor/helps/request_unsupported_user_turn_test.go
@@ -4,9 +4,9 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -83,7 +83,7 @@ func TestTranslateRequestReturningErrorRefusesEmptiedAttachmentTurns(t *testing.
t.Run(name, func(t *testing.T) {
body, err := TranslateRequestReturningError(ctx, nil, &config.Config{}, route.from, route.to, "m", []byte(route.refused), false, compat)
requireUnsupportedPart(t, "single", err, route.wantType)
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("single: body is not JSON: %q", body)
}
diff --git a/internal/runtime/executor/helps/response_model.go b/internal/runtime/executor/helps/response_model.go
index ec2753d0ceb..20b5881d9c3 100644
--- a/internal/runtime/executor/helps/response_model.go
+++ b/internal/runtime/executor/helps/response_model.go
@@ -3,8 +3,8 @@ package helps
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
)
const (
@@ -35,41 +35,41 @@ func extractResponseModelEvent(payload []byte, provider string) (model string, t
// extractClaudeResponseModelEvent extracts the response model from an Anthropic Claude response.
func extractClaudeResponseModelEvent(data []byte) (model string, terminal bool) {
- eventType := gjson.GetBytes(data, "type").String()
+ eventType := ojson.GetBytesNoCopy(data, "type").String()
switch eventType {
case "message_start":
- m := gjson.GetBytes(data, "message.model")
- if m.Type == gjson.String {
+ m := ojson.GetBytesNoCopy(data, "message.model")
+ if m.Type == ojson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
- return s, false
+ return strings.Clone(s), false
}
}
return "", false
case "message_stop":
return "", true
case "message":
- m := gjson.GetBytes(data, "model")
- if m.Type == gjson.String {
+ m := ojson.GetBytesNoCopy(data, "model")
+ if m.Type == ojson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
- return s, true
+ return strings.Clone(s), true
}
}
return "", true
default:
- if !gjson.ValidBytes(data) {
+ if !ojson.ValidBytes(data) {
return "", false
}
- if m := gjson.GetBytes(data, "message.model"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "message.model"); m.Type == ojson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
- return s, false
+ return strings.Clone(s), false
}
- } else if m := gjson.GetBytes(data, "model"); m.Type == gjson.String {
+ } else if m := ojson.GetBytesNoCopy(data, "model"); m.Type == ojson.String {
s := strings.TrimSpace(m.String())
if len(s) <= maxResponseModelLength {
- return s, false
+ return strings.Clone(s), false
}
}
return "", false
@@ -78,42 +78,44 @@ func extractClaudeResponseModelEvent(data []byte) (model string, terminal bool)
// extractGeminiResponseModelEvent extracts the response model from a Google Gemini / Vertex / AIStudio response.
func extractGeminiResponseModelEvent(data []byte) (model string, terminal bool) {
- if !gjson.ValidBytes(data) {
+ if !ojson.ValidBytes(data) {
return "", false
}
- m := gjson.GetBytes(data, "response.modelVersion")
- if !m.Exists() || m.Type != gjson.String {
- m = gjson.GetBytes(data, "modelVersion")
+ m := ojson.GetBytesNoCopy(data, "response.modelVersion")
+ if !m.Exists() || m.Type != ojson.String {
+ m = ojson.GetBytesNoCopy(data, "modelVersion")
}
- if !m.Exists() || m.Type != gjson.String {
- m = gjson.GetBytes(data, "interaction.model")
+ if !m.Exists() || m.Type != ojson.String {
+ m = ojson.GetBytesNoCopy(data, "interaction.model")
}
- if !m.Exists() || m.Type != gjson.String {
- m = gjson.GetBytes(data, "model")
+ if !m.Exists() || m.Type != ojson.String {
+ m = ojson.GetBytesNoCopy(data, "model")
}
var served string
- if m.Type == gjson.String {
+ if m.Type == ojson.String {
served = strings.TrimSpace(m.String())
if len(served) > maxResponseModelLength {
served = ""
+ } else {
+ served = strings.Clone(served)
}
}
- cand := gjson.GetBytes(data, "candidates.0.finishReason")
+ cand := ojson.GetBytesNoCopy(data, "candidates.0.finishReason")
if !cand.Exists() {
- cand = gjson.GetBytes(data, "response.candidates.0.finishReason")
+ cand = ojson.GetBytesNoCopy(data, "response.candidates.0.finishReason")
}
terminal = cand.Exists() && cand.String() != ""
if !terminal {
- objectType := gjson.GetBytes(data, "object").String()
- finishReason := gjson.GetBytes(data, "choices.0.finish_reason").String()
+ objectType := ojson.GetBytesNoCopy(data, "object").String()
+ finishReason := ojson.GetBytesNoCopy(data, "choices.0.finish_reason").String()
terminal = objectType == "chat.completion" || finishReason != ""
}
if !terminal {
- eventType := gjson.GetBytes(data, "event_type").String()
+ eventType := ojson.GetBytesNoCopy(data, "event_type").String()
if eventType == "" {
- eventType = gjson.GetBytes(data, "type").String()
+ eventType = ojson.GetBytesNoCopy(data, "type").String()
}
- status := gjson.GetBytes(data, "interaction.status").String()
+ status := ojson.GetBytesNoCopy(data, "interaction.status").String()
terminal = isInteractionsTerminal(eventType, status)
}
return served, terminal
@@ -121,64 +123,64 @@ func extractGeminiResponseModelEvent(data []byte) (model string, terminal bool)
// extractGenericResponseModelEvent extracts the response model from standard JSON / SSE responses.
func extractGenericResponseModelEvent(data []byte) (model string, terminal bool) {
- if !gjson.ValidBytes(data) {
+ if !ojson.ValidBytes(data) {
return "", false
}
- if m := gjson.GetBytes(data, "response.model"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "response.model"); m.Type == ojson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
- eventType := gjson.GetBytes(data, "type").String()
+ eventType := ojson.GetBytesNoCopy(data, "type").String()
terminal := eventType == "response.completed" || eventType == "response.done" || eventType == "response.incomplete"
- return served, terminal
+ return strings.Clone(served), terminal
}
}
- if m := gjson.GetBytes(data, "interaction.model"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "interaction.model"); m.Type == ojson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
- eventType := gjson.GetBytes(data, "event_type").String()
+ eventType := ojson.GetBytesNoCopy(data, "event_type").String()
if eventType == "" {
- eventType = gjson.GetBytes(data, "type").String()
+ eventType = ojson.GetBytesNoCopy(data, "type").String()
}
- status := gjson.GetBytes(data, "interaction.status").String()
+ status := ojson.GetBytesNoCopy(data, "interaction.status").String()
terminal := isInteractionsTerminal(eventType, status)
- return served, terminal
+ return strings.Clone(served), terminal
}
}
- if m := gjson.GetBytes(data, "modelVersion"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "modelVersion"); m.Type == ojson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
- cand := gjson.GetBytes(data, "candidates.0.finishReason")
- return served, cand.Exists() && cand.String() != ""
+ cand := ojson.GetBytesNoCopy(data, "candidates.0.finishReason")
+ return strings.Clone(served), cand.Exists() && cand.String() != ""
}
}
- if m := gjson.GetBytes(data, "response.modelVersion"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "response.modelVersion"); m.Type == ojson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
- cand := gjson.GetBytes(data, "response.candidates.0.finishReason")
- return served, cand.Exists() && cand.String() != ""
+ cand := ojson.GetBytesNoCopy(data, "response.candidates.0.finishReason")
+ return strings.Clone(served), cand.Exists() && cand.String() != ""
}
}
- if m := gjson.GetBytes(data, "message.model"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "message.model"); m.Type == ojson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
- return served, false
+ return strings.Clone(served), false
}
}
- if m := gjson.GetBytes(data, "model"); m.Type == gjson.String {
+ if m := ojson.GetBytesNoCopy(data, "model"); m.Type == ojson.String {
served := strings.TrimSpace(m.String())
if len(served) <= maxResponseModelLength {
- objectType := gjson.GetBytes(data, "object").String()
- finishReason := gjson.GetBytes(data, "choices.0.finish_reason").String()
- status := gjson.GetBytes(data, "status").String()
+ objectType := ojson.GetBytesNoCopy(data, "object").String()
+ finishReason := ojson.GetBytesNoCopy(data, "choices.0.finish_reason").String()
+ status := ojson.GetBytesNoCopy(data, "status").String()
terminal := objectType == "chat.completion" || finishReason != "" || status == "completed" || status == "incomplete"
- return served, terminal
+ return strings.Clone(served), terminal
}
}
- eventType := gjson.GetBytes(data, "event_type").String()
+ eventType := ojson.GetBytesNoCopy(data, "event_type").String()
if eventType == "" {
- eventType = gjson.GetBytes(data, "type").String()
+ eventType = ojson.GetBytesNoCopy(data, "type").String()
}
- status := gjson.GetBytes(data, "interaction.status").String()
+ status := ojson.GetBytesNoCopy(data, "interaction.status").String()
if isInteractionsTerminal(eventType, status) || eventType == "message_stop" {
return "", true
}
@@ -206,24 +208,24 @@ func extractCodexResponseModelEvent(payload []byte) (model string, terminal bool
}
// The event type is checked before the payload is validated, so the hot path
// (output deltas) stays a single cheap lookup.
- carriesModel, terminal := codexResponseModelEventKind(gjson.GetBytes(data, "type").String())
+ carriesModel, terminal := codexResponseModelEventKind(ojson.GetBytesNoCopy(data, "type").String())
if !carriesModel {
return "", false
}
- if !gjson.ValidBytes(data) {
+ if !ojson.ValidBytes(data) {
return "", false
}
// The value is upstream-controlled: reject non-string and oversized names
// rather than propagating them into logs and usage records.
- modelResult := gjson.GetBytes(data, "response.model")
- if modelResult.Type != gjson.String {
+ modelResult := ojson.GetBytesNoCopy(data, "response.model")
+ if modelResult.Type != ojson.String {
return "", terminal
}
model = strings.TrimSpace(modelResult.String())
if len(model) > maxResponseModelLength {
return "", terminal
}
- return model, terminal
+ return strings.Clone(model), terminal
}
// codexResponseModelEventKind reports whether a codex event embeds the authoritative
diff --git a/internal/runtime/executor/helps/response_model_test.go b/internal/runtime/executor/helps/response_model_test.go
index e32a0c413d8..d17184add36 100644
--- a/internal/runtime/executor/helps/response_model_test.go
+++ b/internal/runtime/executor/helps/response_model_test.go
@@ -427,3 +427,54 @@ func TestUsageReporterAdditionalModelRecordOmitsResponseModel(t *testing.T) {
t.Fatalf("additional record response model = %q, want empty", additionalRecord.ResponseModel)
}
}
+
+func TestExtractResponseModelClonesFromReusableBuffer(t *testing.T) {
+ cases := []struct {
+ name string
+ provider string
+ payload string
+ want string
+ }{
+ {
+ name: "codex",
+ provider: "codex",
+ payload: `data: {"type":"response.created","response":{"model":"gpt-5.6-luna"}}`,
+ want: "gpt-5.6-luna",
+ },
+ {
+ name: "claude message_start",
+ provider: "claude",
+ payload: `data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929"}}`,
+ want: "claude-sonnet-4-5-20250929",
+ },
+ {
+ name: "gemini modelVersion",
+ provider: "gemini",
+ payload: `data: {"modelVersion":"gemini-2.5-pro-001","candidates":[{"finishReason":"STOP"}]}`,
+ want: "gemini-2.5-pro-001",
+ },
+ {
+ name: "generic chat completion",
+ provider: "openai",
+ payload: `data: {"model":"gpt-4.1-mini","choices":[{"finish_reason":"stop"}]}`,
+ want: "gpt-4.1-mini",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ buf := []byte(tc.payload)
+ got, _ := extractResponseModelEvent(buf, tc.provider)
+ reporter := NewUsageReporter(context.Background(), tc.provider, "requested-model", nil)
+ reporter.ObserveResponseModel(buf)
+ for i := range buf {
+ buf[i] = 'X'
+ }
+ if got != tc.want {
+ t.Fatalf("extractResponseModelEvent after buffer overwrite = %q, want %q", got, tc.want)
+ }
+ if repGot := reporter.ResponseModel(); repGot != tc.want {
+ t.Fatalf("reporter.ResponseModel() after buffer overwrite = %q, want %q", repGot, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/helps/responses_tool_search.go b/internal/runtime/executor/helps/responses_tool_search.go
index 064ef193134..c1b171746bc 100644
--- a/internal/runtime/executor/helps/responses_tool_search.go
+++ b/internal/runtime/executor/helps/responses_tool_search.go
@@ -1,10 +1,10 @@
package helps
import (
+ "fmt"
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
const (
@@ -16,11 +16,11 @@ const (
// ResponsesHasToolSearch reports whether a Responses payload carries a client
// tool_search declaration or tool_search history items.
func ResponsesHasToolSearch(payload []byte) bool {
- if hasClientToolSearchDeclaration(gjson.GetBytes(payload, "tools")) {
+ if hasClientToolSearchDeclaration(ojson.GetBytesNoCopy(payload, "tools")) {
return true
}
found := false
- gjson.GetBytes(payload, "input").ForEach(func(_, item gjson.Result) bool {
+ ojson.GetBytesNoCopy(payload, "input").ForEach(func(_, item ojson.Result) bool {
switch strings.TrimSpace(item.Get("type").String()) {
case responsesToolSearchCallType, responsesToolSearchOutputType:
found = true
@@ -38,51 +38,69 @@ func ResponsesHasToolSearch(payload []byte) bool {
// declarations are left for the translator. An unchanged payload is returned
// as is.
func StripResponsesToolSearch(payload []byte) []byte {
- out := payload
- if tools := gjson.GetBytes(out, "tools"); tools.IsArray() {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !StripResponsesToolSearchDoc(doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// StripResponsesToolSearchDoc removes client tool_search declarations and every
+// tool_search_call/tool_search_output input item in place on doc.
+func StripResponsesToolSearchDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ modified := false
+ if tools := doc.Get("tools"); tools.IsArray() {
if kept, changed := withoutClientToolSearch(tools); changed {
- out, _ = sjson.SetRawBytes(out, "tools", kept)
+ if errSet := doc.SetRawBytes("tools", kept); errSet == nil {
+ modified = true
+ }
}
}
- input := gjson.GetBytes(out, "input")
+ input := doc.Get("input")
if !input.IsArray() {
- return out
+ return modified
}
- changed := false
- items := make([]string, 0, len(input.Array()))
- input.ForEach(func(_, item gjson.Result) bool {
+ dropped := make(map[int]bool)
+ for idx, item := range input.Array() {
switch strings.TrimSpace(item.Get("type").String()) {
case responsesToolSearchCallType, responsesToolSearchOutputType:
- changed = true
- return true
+ dropped[idx] = true
+ modified = true
case "additional_tools":
if kept, keptChanged := withoutClientToolSearch(item.Get("tools")); keptChanged {
- updated, errSet := sjson.SetRawBytes([]byte(item.Raw), "tools", kept)
- if errSet == nil {
- items = append(items, string(updated))
- changed = true
- return true
+ if errSet := doc.SetRawBytes(fmt.Sprintf("input.%d.tools", idx), kept); errSet == nil {
+ modified = true
}
}
}
- items = append(items, item.Raw)
- return true
- })
- if !changed {
- return out
}
- out, _ = sjson.SetRawBytes(out, "input", []byte("["+strings.Join(items, ",")+"]"))
- return out
+ if len(dropped) > 0 {
+ updatedItems := doc.Get("input").Array()
+ items := make([]string, 0, len(updatedItems)-len(dropped))
+ for idx, item := range updatedItems {
+ if !dropped[idx] {
+ items = append(items, item.Raw)
+ }
+ }
+ _ = doc.SetRaw("input", "["+strings.Join(items, ",")+"]")
+ }
+ return modified
}
-func isClientToolSearchDeclaration(tool gjson.Result) bool {
+func isClientToolSearchDeclaration(tool ojson.Result) bool {
return strings.TrimSpace(tool.Get("type").String()) == responsesToolSearchType &&
strings.TrimSpace(tool.Get("execution").String()) == "client"
}
-func hasClientToolSearchDeclaration(tools gjson.Result) bool {
+func hasClientToolSearchDeclaration(tools ojson.Result) bool {
found := false
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
found = isClientToolSearchDeclaration(tool)
return !found
})
@@ -91,13 +109,13 @@ func hasClientToolSearchDeclaration(tools gjson.Result) bool {
// withoutClientToolSearch rebuilds a tools array without client tool_search
// declarations and reports whether anything was removed.
-func withoutClientToolSearch(tools gjson.Result) ([]byte, bool) {
+func withoutClientToolSearch(tools ojson.Result) ([]byte, bool) {
if !tools.IsArray() {
return nil, false
}
changed := false
kept := make([]string, 0, len(tools.Array()))
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if isClientToolSearchDeclaration(tool) {
changed = true
return true
diff --git a/internal/runtime/executor/helps/responses_tool_search_test.go b/internal/runtime/executor/helps/responses_tool_search_test.go
index f580d88b57f..f8b7a5b433e 100644
--- a/internal/runtime/executor/helps/responses_tool_search_test.go
+++ b/internal/runtime/executor/helps/responses_tool_search_test.go
@@ -3,7 +3,7 @@ package helps
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestResponsesHasToolSearch(t *testing.T) {
@@ -90,11 +90,11 @@ func TestStripResponsesToolSearch(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
got := StripResponsesToolSearch([]byte(tc.payload))
if tc.wantTools != "" {
- if raw := gjson.GetBytes(got, "tools").Raw; raw != tc.wantTools {
+ if raw := ojson.GetBytes(got, "tools").Raw; raw != tc.wantTools {
t.Fatalf("tools = %s, want %s", raw, tc.wantTools)
}
}
- if raw := gjson.GetBytes(got, "input").Raw; raw != tc.wantInput {
+ if raw := ojson.GetBytes(got, "input").Raw; raw != tc.wantInput {
t.Fatalf("input = %s, want %s", raw, tc.wantInput)
}
})
diff --git a/internal/runtime/executor/helps/responses_ttft_helpers.go b/internal/runtime/executor/helps/responses_ttft_helpers.go
index f7dc1a63cba..81454857182 100644
--- a/internal/runtime/executor/helps/responses_ttft_helpers.go
+++ b/internal/runtime/executor/helps/responses_ttft_helpers.go
@@ -3,7 +3,7 @@ package helps
import (
"bytes"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsResponsesTokenEvent reports whether the given OpenAI/xAI Responses API WebSocket or SSE event
@@ -27,7 +27,7 @@ func IsResponsesTokenEvent(payload []byte) bool {
}
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
switch eventType {
// Substantive text, reasoning, and tool argument streaming deltas
case "response.reasoning_summary_text.delta",
@@ -42,56 +42,56 @@ func IsResponsesTokenEvent(payload []byte) bool {
"response.shell_call_command.delta",
"response.refusal.delta",
"response.audio.transcript.delta":
- return len(gjson.GetBytes(payload, "delta").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "delta").String()) > 0
// Multimodal streaming deltas (audio and image generation preview)
case "response.audio.delta":
- return len(gjson.GetBytes(payload, "delta").String()) > 0 || len(gjson.GetBytes(payload, "data").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "delta").String()) > 0 || len(ojson.GetBytesNoCopy(payload, "data").String()) > 0
case "response.image_generation_call.partial_image":
- return len(gjson.GetBytes(payload, "partial_image_b64").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "partial_image_b64").String()) > 0
// Shell call command added event when command is populated upfront
case "response.shell_call_command.added":
- return len(gjson.GetBytes(payload, "command").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "command").String()) > 0
// Single-chunk / non-stream completed items
case "response.reasoning_summary_text.done",
"response.reasoning_text.done",
"response.output_text.done":
- return len(gjson.GetBytes(payload, "text").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "text").String()) > 0
case "response.refusal.done":
- return len(gjson.GetBytes(payload, "refusal").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "refusal").String()) > 0
case "response.function_call_arguments.done",
"response.mcp_call_arguments.done":
- return len(gjson.GetBytes(payload, "arguments").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "arguments").String()) > 0
case "response.custom_tool_call_input.done":
- return len(gjson.GetBytes(payload, "input").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "input").String()) > 0
case "response.code_interpreter_call_code.done":
- return len(gjson.GetBytes(payload, "code").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "code").String()) > 0
case "response.shell_call_command.done":
- return len(gjson.GetBytes(payload, "command").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "command").String()) > 0
case "response.reasoning_summary_part.done":
- return len(gjson.GetBytes(payload, "part.text").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "part.text").String()) > 0
case "response.content_part.done":
- return len(gjson.GetBytes(payload, "part.text").String()) > 0 || len(gjson.GetBytes(payload, "part.refusal").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "part.text").String()) > 0 || len(ojson.GetBytesNoCopy(payload, "part.refusal").String()) > 0
case "response.output_item.done":
- itemType := gjson.GetBytes(payload, "item.type").String()
+ itemType := ojson.GetBytesNoCopy(payload, "item.type").String()
switch itemType {
case "function_call":
- return len(gjson.GetBytes(payload, "item.arguments").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "item.arguments").String()) > 0
case "custom_tool_call":
- return len(gjson.GetBytes(payload, "item.input").String()) > 0
+ return len(ojson.GetBytesNoCopy(payload, "item.input").String()) > 0
case "reasoning":
for _, field := range []string{"item.summary", "item.content"} {
- for _, part := range gjson.GetBytes(payload, field).Array() {
+ for _, part := range ojson.GetBytesNoCopy(payload, field).Array() {
text := part.Get("text")
- if text.Type == gjson.String && text.String() != "" {
+ if text.Type == ojson.String && text.String() != "" {
return true
}
}
}
case "message":
- for _, content := range gjson.GetBytes(payload, "item.content").Array() {
+ for _, content := range ojson.GetBytesNoCopy(payload, "item.content").Array() {
if len(content.Get("text").String()) > 0 || len(content.Get("refusal").String()) > 0 {
return true
}
diff --git a/internal/runtime/executor/helps/responses_usage_helpers.go b/internal/runtime/executor/helps/responses_usage_helpers.go
index 645a289f92f..1dc52da6aa5 100644
--- a/internal/runtime/executor/helps/responses_usage_helpers.go
+++ b/internal/runtime/executor/helps/responses_usage_helpers.go
@@ -3,8 +3,7 @@ package helps
import (
"bytes"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// EnsureResponsesUsageDetails ensures that Responses usage objects contain output_tokens_details
@@ -22,16 +21,14 @@ func EnsureResponsesUsageDetails(payload []byte) []byte {
// 1. JSON-first: If trimmed payload starts with '{', process as a plain JSON object.
if trimmed[0] == '{' {
- if gjson.GetBytes(trimmed, "object").String() == "response.compaction" {
+ if ojson.GetBytesNoCopy(trimmed, "object").String() == "response.compaction" {
return payload
}
- updated := trimmed
- updated = ensureUsageDetailsAt(updated, "response.usage")
- updated = ensureUsageDetailsAt(updated, "usage")
- if bytes.Equal(updated, trimmed) {
+ doc := ojson.NewDoc(trimmed)
+ if !EnsureResponsesUsageDetailsDoc(doc) {
return payload
}
- return updated
+ return doc.Bytes()
}
// 2. SSE frames: Scan lines for data: prefixed lines and patch their JSON payloads.
@@ -53,15 +50,13 @@ func EnsureResponsesUsageDetails(payload []byte) []byte {
if len(dataPayload) == 0 || dataPayload[0] != '{' {
continue
}
- if gjson.GetBytes(dataPayload, "object").String() == "response.compaction" {
+ if ojson.GetBytesNoCopy(dataPayload, "object").String() == "response.compaction" {
continue
}
- updated := dataPayload
- updated = ensureUsageDetailsAt(updated, "response.usage")
- updated = ensureUsageDetailsAt(updated, "usage")
- if !bytes.Equal(updated, dataPayload) {
+ doc := ojson.NewDoc(dataPayload)
+ if EnsureResponsesUsageDetailsDoc(doc) {
newPrefix := bytes.Clone(line[:prefixLen])
- lines[i] = append(newPrefix, updated...)
+ lines[i] = append(newPrefix, doc.Bytes()...)
modified = true
}
}
@@ -74,35 +69,62 @@ func EnsureResponsesUsageDetails(payload []byte) []byte {
return payload
}
-func ensureUsageDetailsAt(jsonBody []byte, path string) []byte {
- usageNode := gjson.GetBytes(jsonBody, path)
+// EnsureResponsesUsageDetailsDoc ensures that Responses usage objects in doc contain
+// output_tokens_details and input_tokens_details in place.
+func EnsureResponsesUsageDetailsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ if doc.Get("object").String() == "response.compaction" {
+ return false
+ }
+ changed1 := ensureUsageDetailsAtDoc(doc, "response.usage")
+ changed2 := ensureUsageDetailsAtDoc(doc, "usage")
+ return changed1 || changed2
+}
+
+func ensureUsageDetailsAtDoc(doc *ojson.Doc, path string) bool {
+ usageNode := doc.Get(path)
if !usageNode.Exists() || !usageNode.IsObject() {
- return jsonBody
+ return false
}
+ changed := false
outputDetails := usageNode.Get("output_tokens_details")
if !outputDetails.Exists() {
- jsonBody, _ = sjson.SetBytes(jsonBody, path+".output_tokens_details.reasoning_tokens", 0)
- } else if outputDetails.Type == gjson.Null || !outputDetails.IsObject() {
- jsonBody, _ = sjson.SetRawBytes(jsonBody, path+".output_tokens_details", []byte(`{"reasoning_tokens":0}`))
+ if errSet := doc.Set(path+".output_tokens_details.reasoning_tokens", 0); errSet == nil {
+ changed = true
+ }
+ } else if outputDetails.Type == ojson.Null || !outputDetails.IsObject() {
+ if errSet := doc.SetRaw(path+".output_tokens_details", `{"reasoning_tokens":0}`); errSet == nil {
+ changed = true
+ }
} else {
reasoning := outputDetails.Get("reasoning_tokens")
- if !reasoning.Exists() || reasoning.Type == gjson.Null {
- jsonBody, _ = sjson.SetBytes(jsonBody, path+".output_tokens_details.reasoning_tokens", 0)
+ if !reasoning.Exists() || reasoning.Type == ojson.Null {
+ if errSet := doc.Set(path+".output_tokens_details.reasoning_tokens", 0); errSet == nil {
+ changed = true
+ }
}
}
inputDetails := usageNode.Get("input_tokens_details")
if !inputDetails.Exists() {
- jsonBody, _ = sjson.SetBytes(jsonBody, path+".input_tokens_details.cached_tokens", 0)
- } else if inputDetails.Type == gjson.Null || !inputDetails.IsObject() {
- jsonBody, _ = sjson.SetRawBytes(jsonBody, path+".input_tokens_details", []byte(`{"cached_tokens":0}`))
+ if errSet := doc.Set(path+".input_tokens_details.cached_tokens", 0); errSet == nil {
+ changed = true
+ }
+ } else if inputDetails.Type == ojson.Null || !inputDetails.IsObject() {
+ if errSet := doc.SetRaw(path+".input_tokens_details", `{"cached_tokens":0}`); errSet == nil {
+ changed = true
+ }
} else {
cached := inputDetails.Get("cached_tokens")
- if !cached.Exists() || cached.Type == gjson.Null {
- jsonBody, _ = sjson.SetBytes(jsonBody, path+".input_tokens_details.cached_tokens", 0)
+ if !cached.Exists() || cached.Type == ojson.Null {
+ if errSet := doc.Set(path+".input_tokens_details.cached_tokens", 0); errSet == nil {
+ changed = true
+ }
}
}
- return jsonBody
+ return changed
}
diff --git a/internal/runtime/executor/helps/responses_usage_helpers_test.go b/internal/runtime/executor/helps/responses_usage_helpers_test.go
index d650461a534..d522a41f43c 100644
--- a/internal/runtime/executor/helps/responses_usage_helpers_test.go
+++ b/internal/runtime/executor/helps/responses_usage_helpers_test.go
@@ -5,26 +5,26 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestEnsureResponsesUsageDetails_NonStreamJSON(t *testing.T) {
raw := []byte(`{"id":"resp_1","object":"response","status":"completed","usage":{"input_tokens":84,"output_tokens":16,"total_tokens":100}}`)
got := EnsureResponsesUsageDetails(raw)
- if !gjson.GetBytes(got, "usage.output_tokens_details").Exists() {
+ if !ojson.GetBytes(got, "usage.output_tokens_details").Exists() {
t.Fatalf("expected usage.output_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected usage.output_tokens_details.reasoning_tokens == 0, got %d", gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected usage.output_tokens_details.reasoning_tokens == 0, got %d", ojson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
}
- if !gjson.GetBytes(got, "usage.input_tokens_details").Exists() {
+ if !ojson.GetBytes(got, "usage.input_tokens_details").Exists() {
t.Fatalf("expected usage.input_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected usage.input_tokens_details.cached_tokens == 0, got %d", gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected usage.input_tokens_details.cached_tokens == 0, got %d", ojson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
}
}
@@ -32,17 +32,17 @@ func TestEnsureResponsesUsageDetails_NonStreamJSONWithDataSubstring(t *testing.T
raw := []byte(`{"id":"resp_1","object":"response","status":"completed","output":[{"type":"message","content":[{"type":"text","text":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUg"}]}],"usage":{"input_tokens":84,"output_tokens":16,"total_tokens":100}}`)
got := EnsureResponsesUsageDetails(raw)
- if !gjson.GetBytes(got, "usage.output_tokens_details").Exists() {
+ if !ojson.GetBytes(got, "usage.output_tokens_details").Exists() {
t.Fatalf("expected usage.output_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected usage.output_tokens_details.reasoning_tokens == 0, got %d", gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected usage.output_tokens_details.reasoning_tokens == 0, got %d", ojson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
}
- if !gjson.GetBytes(got, "usage.input_tokens_details").Exists() {
+ if !ojson.GetBytes(got, "usage.input_tokens_details").Exists() {
t.Fatalf("expected usage.input_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected usage.input_tokens_details.cached_tokens == 0, got %d", gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected usage.input_tokens_details.cached_tokens == 0, got %d", ojson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
}
}
@@ -54,17 +54,17 @@ func TestEnsureResponsesUsageDetails_SSEData(t *testing.T) {
t.Fatalf("expected data: prefix preserved, got %s", string(got))
}
jsonBody := bytes.TrimPrefix(got, []byte("data: "))
- if !gjson.GetBytes(jsonBody, "response.usage.output_tokens_details").Exists() {
+ if !ojson.GetBytes(jsonBody, "response.usage.output_tokens_details").Exists() {
t.Fatalf("expected response.usage.output_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected reasoning_tokens == 0, got %d", ojson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
}
- if !gjson.GetBytes(jsonBody, "response.usage.input_tokens_details").Exists() {
+ if !ojson.GetBytes(jsonBody, "response.usage.input_tokens_details").Exists() {
t.Fatalf("expected response.usage.input_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected cached_tokens == 0, got %d", ojson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
}
}
@@ -79,17 +79,17 @@ func TestEnsureResponsesUsageDetails_SSEEventDataMultiLine(t *testing.T) {
for _, line := range bytes.Split(got, []byte("\n")) {
if bytes.HasPrefix(line, []byte("data: ")) {
jsonBody := bytes.TrimPrefix(line, []byte("data: "))
- if !gjson.GetBytes(jsonBody, "response.usage.output_tokens_details").Exists() {
+ if !ojson.GetBytes(jsonBody, "response.usage.output_tokens_details").Exists() {
t.Fatalf("expected response.usage.output_tokens_details to exist in multi-line frame, got %s", string(got))
}
- if gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected reasoning_tokens == 0, got %d", ojson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
}
- if !gjson.GetBytes(jsonBody, "response.usage.input_tokens_details").Exists() {
+ if !ojson.GetBytes(jsonBody, "response.usage.input_tokens_details").Exists() {
t.Fatalf("expected response.usage.input_tokens_details to exist in multi-line frame, got %s", string(got))
}
- if gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected cached_tokens == 0, got %d", ojson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
}
}
}
@@ -100,11 +100,11 @@ func TestEnsureResponsesUsageDetails_PreservesExistingDetails(t *testing.T) {
got := EnsureResponsesUsageDetails(raw)
jsonBody := bytes.TrimPrefix(got, []byte("data: "))
- if gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 2 {
- t.Fatalf("expected reasoning_tokens == 2, got %d", gjson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int() != 2 {
+ t.Fatalf("expected reasoning_tokens == 2, got %d", ojson.GetBytes(jsonBody, "response.usage.output_tokens_details.reasoning_tokens").Int())
}
- if gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 3 {
- t.Fatalf("expected cached_tokens == 3, got %d", gjson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int() != 3 {
+ t.Fatalf("expected cached_tokens == 3, got %d", ojson.GetBytes(jsonBody, "response.usage.input_tokens_details.cached_tokens").Int())
}
}
@@ -112,11 +112,11 @@ func TestEnsureResponsesUsageDetails_HandlesNullOrEmptyDetails(t *testing.T) {
raw := []byte(`{"id":"resp_1","usage":{"input_tokens":10,"input_tokens_details":null,"output_tokens":4,"output_tokens_details":{},"total_tokens":14}}`)
got := EnsureResponsesUsageDetails(raw)
- if gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected reasoning_tokens == 0, got %d", ojson.GetBytes(got, "usage.output_tokens_details.reasoning_tokens").Int())
}
- if gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected cached_tokens == 0, got %d", ojson.GetBytes(got, "usage.input_tokens_details.cached_tokens").Int())
}
}
@@ -186,19 +186,19 @@ func TestTranslateStreamWithClaudeInputTokens_OpenAICompatTranslation_PatchesRes
for _, line := range bytes.Split(ch, []byte("\n")) {
if bytes.HasPrefix(line, []byte("data: ")) {
payload := bytes.TrimPrefix(line, []byte("data: "))
- if gjson.GetBytes(payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(payload, "type").String() == "response.completed" {
foundCompleted = true
- if !gjson.GetBytes(payload, "response.usage.output_tokens_details").Exists() {
+ if !ojson.GetBytes(payload, "response.usage.output_tokens_details").Exists() {
t.Fatalf("expected output_tokens_details to exist on translated response.completed: %s", string(ch))
}
- if gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected reasoning_tokens == 0, got %d", ojson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int())
}
- if !gjson.GetBytes(payload, "response.usage.input_tokens_details").Exists() {
+ if !ojson.GetBytes(payload, "response.usage.input_tokens_details").Exists() {
t.Fatalf("expected input_tokens_details to exist on translated response.completed: %s", string(ch))
}
- if gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected cached_tokens == 0, got %d", ojson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int())
}
}
}
diff --git a/internal/runtime/executor/helps/thinking.go b/internal/runtime/executor/helps/thinking.go
index e251a8527de..28594548c5c 100644
--- a/internal/runtime/executor/helps/thinking.go
+++ b/internal/runtime/executor/helps/thinking.go
@@ -3,6 +3,7 @@ package helps
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
)
@@ -16,6 +17,16 @@ func ApplyThinkingWithSourcePayload(body, currentSourcePayload, originalSourcePa
return thinking.ApplyThinkingWithSummary(body, model, fromFormat, toFormat, providerKey, summary)
}
+// ApplyThinkingWithSourcePayloadDoc preserves summary visibility from the original
+// client payload while applying thinking configuration onto doc in place.
+func ApplyThinkingWithSourcePayloadDoc(doc *ojson.Doc, currentSourcePayload, originalSourcePayload []byte, model, fromFormat, toFormat, providerKey string) error {
+ if doc == nil {
+ return nil
+ }
+ summary := translatedRequestSummaryConfigDoc(doc, currentSourcePayload, originalSourcePayload, model, fromFormat, toFormat)
+ return thinking.ApplyThinkingWithSummaryDoc(doc, model, fromFormat, toFormat, providerKey, summary)
+}
+
// translatedRequestSummaryConfig gives the translated target payload precedence
// so a plugin request normalizer can remove or rewrite a canonical summary field.
// The original source is consulted only when the payload that was translated no
@@ -62,3 +73,39 @@ func translatedRequestSummaryConfig(body, currentSourcePayload, originalSourcePa
// subsequently activate adaptive thinking. Preserve the source in that case.
return currentSummary
}
+
+func translatedRequestSummaryConfigDoc(doc *ojson.Doc, currentSourcePayload, originalSourcePayload []byte, model, fromFormat, toFormat string) thinking.SummaryConfig {
+ if doc == nil || doc.Len() == 0 {
+ return thinking.SummaryConfig{}
+ }
+ fromFormat = strings.ToLower(strings.TrimSpace(fromFormat))
+ toFormat = strings.ToLower(strings.TrimSpace(toFormat))
+
+ var targetSummary thinking.SummaryConfig
+ if fromFormat == toFormat {
+ targetSummary = thinking.ExtractSummaryConfigDoc(doc, toFormat)
+ } else {
+ targetSummary = thinking.ExtractExplicitSummaryConfigDoc(doc, toFormat)
+ }
+ if targetSummary.Mode != thinking.SummaryUnspecified {
+ return targetSummary
+ }
+
+ currentSummary := thinking.ExtractTranslatedSummaryConfig(currentSourcePayload, fromFormat, toFormat)
+ originalSummary := thinking.ExtractTranslatedSummaryConfig(originalSourcePayload, fromFormat, toFormat)
+ if currentSummary.Mode == thinking.SummaryUnspecified {
+ return originalSummary
+ }
+
+ from := sdktranslator.FromString(fromFormat)
+ to := sdktranslator.FromString(toFormat)
+ if !sdktranslator.HasRequestTransformer(from, to) {
+ return thinking.SummaryConfig{}
+ }
+
+ if thinking.CanRepresentSummaryConfigForModelDoc(doc, toFormat, model, currentSummary) {
+ return thinking.SummaryConfig{}
+ }
+
+ return currentSummary
+}
diff --git a/internal/runtime/executor/helps/thinking_test.go b/internal/runtime/executor/helps/thinking_test.go
index ba80af2b7da..8e1293f188d 100644
--- a/internal/runtime/executor/helps/thinking_test.go
+++ b/internal/runtime/executor/helps/thinking_test.go
@@ -4,12 +4,11 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
helps "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/gemini"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type summaryRemovingPluginHooks struct {
@@ -19,10 +18,10 @@ type summaryRemovingPluginHooks struct {
func (h *summaryRemovingPluginHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte {
h.t.Helper()
const path = "generationConfig.thinkingConfig.includeThoughts"
- if !gjson.GetBytes(body, path).Bool() {
+ if !ojson.GetBytes(body, path).Bool() {
h.t.Fatalf("request normalizer did not receive enabled summary: %s", body)
}
- out, _ := sjson.DeleteBytes(body, path)
+ out, _ := ojson.DeleteBytes(body, path)
return out
}
@@ -56,7 +55,7 @@ func TestApplyThinkingWithSourcePayloadPreservesNormalizerSummaryRemoval(t *test
false,
)
const summaryPath = "generationConfig.thinkingConfig.includeThoughts"
- if gjson.GetBytes(translated, summaryPath).Exists() {
+ if ojson.GetBytes(translated, summaryPath).Exists() {
t.Fatalf("request normalizer did not remove summary: %s", translated)
}
@@ -72,7 +71,7 @@ func TestApplyThinkingWithSourcePayloadPreservesNormalizerSummaryRemoval(t *test
if err != nil {
t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err)
}
- if gjson.GetBytes(out, summaryPath).Exists() {
+ if ojson.GetBytes(out, summaryPath).Exists() {
t.Fatalf("executor restored summary removed by request normalizer: %s", out)
}
}
@@ -94,7 +93,7 @@ func TestApplyThinkingWithSourcePayloadPreservesOriginalOnlySummary(t *testing.T
if err != nil {
t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err)
}
- if include := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts"); !include.Exists() || include.Bool() {
+ if include := ojson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts"); !include.Exists() || include.Bool() {
t.Fatalf("original disabled summary was not preserved: %s", out)
}
}
@@ -121,12 +120,51 @@ func TestApplyThinkingWithSourcePayload_AntigravityResponsesReasoningSummaryAuto
if err != nil {
t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err)
}
- include := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts")
+ include := ojson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts")
if !include.Exists() || !include.Bool() {
t.Fatalf("includeThoughts not enabled: %s", out)
}
- level := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String()
+ level := ojson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String()
if level != "low" {
t.Fatalf("thinkingLevel = %q, want low: %s", level, out)
}
}
+
+func TestApplyRequestThinking_AntigravityClaude55PreservesThinkingLevel(t *testing.T) {
+ for _, model := range []string{"claude-sonnet-5-5-high", "claude-opus-5-5-high"} {
+ for _, level := range []string{"low", "medium", "high"} {
+ t.Run(model+"/"+level, func(t *testing.T) {
+ source := []byte(`{"contents":[{"role":"user","parts":[{"text":"Reply OK"}]}],"generationConfig":{"maxOutputTokens":128000,"thinkingConfig":{"includeThoughts":true,"thinkingLevel":"` + level + `"}}}`)
+ translated := sdktranslator.TranslateRequest(
+ sdktranslator.FormatGemini,
+ sdktranslator.FormatAntigravity,
+ model,
+ source,
+ true,
+ )
+
+ out, err := helps.ApplyThinkingWithSourcePayload(
+ translated,
+ source,
+ source,
+ model,
+ sdktranslator.FormatGemini.String(),
+ sdktranslator.FormatAntigravity.String(),
+ "antigravity",
+ )
+ if err != nil {
+ t.Fatalf("ApplyThinkingWithSourcePayload() error = %v", err)
+ }
+ if got := ojson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != level {
+ t.Fatalf("thinkingLevel = %q, want %q: %s", got, level, out)
+ }
+ if budget := ojson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget"); budget.Exists() {
+ t.Fatalf("thinkingBudget should be absent, got %s: %s", budget.Raw, out)
+ }
+ if include := ojson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts"); !include.Exists() || !include.Bool() {
+ t.Fatalf("includeThoughts = %s, want true: %s", include.Raw, out)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/runtime/executor/helps/token_helpers.go b/internal/runtime/executor/helps/token_helpers.go
index 92b8ba8dfb4..1864b4b51a3 100644
--- a/internal/runtime/executor/helps/token_helpers.go
+++ b/internal/runtime/executor/helps/token_helpers.go
@@ -4,7 +4,8 @@ import (
"fmt"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/tiktoken-go/tokenizer"
)
@@ -46,7 +47,7 @@ func CountOpenAIChatTokens(enc tokenizer.Codec, payload []byte) (int64, error) {
return 0, nil
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
segments := make([]string, 0, 32)
collectOpenAIMessages(root.Get("messages"), &segments)
@@ -74,11 +75,11 @@ func BuildOpenAIUsageJSON(count int64) []byte {
return []byte(fmt.Sprintf(`{"usage":{"prompt_tokens":%d,"completion_tokens":0,"total_tokens":%d}}`, count, count))
}
-func collectOpenAIMessages(messages gjson.Result, segments *[]string) {
+func collectOpenAIMessages(messages ojson.Result, segments *[]string) {
if !messages.Exists() || !messages.IsArray() {
return
}
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
addIfNotEmpty(segments, message.Get("role").String())
addIfNotEmpty(segments, message.Get("name").String())
collectOpenAIContent(message.Get("content"), segments)
@@ -88,16 +89,16 @@ func collectOpenAIMessages(messages gjson.Result, segments *[]string) {
})
}
-func collectOpenAIContent(content gjson.Result, segments *[]string) {
+func collectOpenAIContent(content ojson.Result, segments *[]string) {
if !content.Exists() {
return
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
addIfNotEmpty(segments, content.String())
return
}
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
partType := part.Get("type").String()
switch partType {
case "text", "input_text", "output_text":
@@ -114,7 +115,7 @@ func collectOpenAIContent(content gjson.Result, segments *[]string) {
collectOpenAIContent(part, segments)
return true
}
- if part.Type == gjson.JSON {
+ if part.Type == ojson.JSON {
addIfNotEmpty(segments, part.Raw)
return true
}
@@ -124,16 +125,16 @@ func collectOpenAIContent(content gjson.Result, segments *[]string) {
})
return
}
- if content.Type == gjson.JSON {
+ if content.Type == ojson.JSON {
addIfNotEmpty(segments, content.Raw)
}
}
-func collectOpenAIToolCalls(calls gjson.Result, segments *[]string) {
+func collectOpenAIToolCalls(calls ojson.Result, segments *[]string) {
if !calls.Exists() || !calls.IsArray() {
return
}
- calls.ForEach(func(_, call gjson.Result) bool {
+ calls.ForEach(func(_, call ojson.Result) bool {
addIfNotEmpty(segments, call.Get("id").String())
addIfNotEmpty(segments, call.Get("type").String())
function := call.Get("function")
@@ -149,7 +150,7 @@ func collectOpenAIToolCalls(calls gjson.Result, segments *[]string) {
})
}
-func collectOpenAIFunctionCall(call gjson.Result, segments *[]string) {
+func collectOpenAIFunctionCall(call ojson.Result, segments *[]string) {
if !call.Exists() {
return
}
@@ -157,12 +158,12 @@ func collectOpenAIFunctionCall(call gjson.Result, segments *[]string) {
addIfNotEmpty(segments, call.Get("arguments").String())
}
-func collectOpenAITools(tools gjson.Result, segments *[]string) {
+func collectOpenAITools(tools ojson.Result, segments *[]string) {
if !tools.Exists() {
return
}
if tools.IsArray() {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
appendToolPayload(tool, segments)
return true
})
@@ -171,11 +172,11 @@ func collectOpenAITools(tools gjson.Result, segments *[]string) {
appendToolPayload(tools, segments)
}
-func collectOpenAIFunctions(functions gjson.Result, segments *[]string) {
+func collectOpenAIFunctions(functions ojson.Result, segments *[]string) {
if !functions.Exists() || !functions.IsArray() {
return
}
- functions.ForEach(func(_, function gjson.Result) bool {
+ functions.ForEach(func(_, function ojson.Result) bool {
addIfNotEmpty(segments, function.Get("name").String())
addIfNotEmpty(segments, function.Get("description").String())
if params := function.Get("parameters"); params.Exists() {
@@ -185,18 +186,18 @@ func collectOpenAIFunctions(functions gjson.Result, segments *[]string) {
})
}
-func collectOpenAIToolChoice(choice gjson.Result, segments *[]string) {
+func collectOpenAIToolChoice(choice ojson.Result, segments *[]string) {
if !choice.Exists() {
return
}
- if choice.Type == gjson.String {
+ if choice.Type == ojson.String {
addIfNotEmpty(segments, choice.String())
return
}
addIfNotEmpty(segments, choice.Raw)
}
-func collectOpenAIResponseFormat(format gjson.Result, segments *[]string) {
+func collectOpenAIResponseFormat(format ojson.Result, segments *[]string) {
if !format.Exists() {
return
}
@@ -210,7 +211,7 @@ func collectOpenAIResponseFormat(format gjson.Result, segments *[]string) {
}
}
-func appendToolPayload(tool gjson.Result, segments *[]string) {
+func appendToolPayload(tool ojson.Result, segments *[]string) {
if !tool.Exists() {
return
}
diff --git a/internal/runtime/executor/helps/tool_call_ids.go b/internal/runtime/executor/helps/tool_call_ids.go
index 6a63b38f304..76bda259685 100644
--- a/internal/runtime/executor/helps/tool_call_ids.go
+++ b/internal/runtime/executor/helps/tool_call_ids.go
@@ -1,12 +1,13 @@
package helps
import (
- "encoding/base64"
+ "fmt"
"strconv"
"strings"
"unicode/utf8"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
)
const devinToolIDPrefix = "cpa_tid_v1_"
@@ -74,7 +75,7 @@ func NormalizeClaudeToolCallIDs(body []byte) []byte {
}
var fields []idField
used := make(map[string]bool)
- for _, message := range gjson.GetBytes(body, "messages").Array() {
+ for _, message := range ojson.GetBytesNoCopy(body, "messages").Array() {
for _, block := range message.Get("content").Array() {
field := ""
switch block.Get("type").String() {
@@ -86,7 +87,7 @@ func NormalizeClaudeToolCallIDs(body []byte) []byte {
continue
}
value := block.Get(field)
- if value.Type != gjson.String || value.String() == "" {
+ if value.Type != ojson.String || value.String() == "" {
continue
}
id := value.String()
@@ -121,3 +122,62 @@ func NormalizeClaudeToolCallIDs(body []byte) []byte {
}
return append(out, body[offset:]...)
}
+
+// NormalizeClaudeToolCallIDsDoc repairs client tool ID fields in place on doc.
+func NormalizeClaudeToolCallIDsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ type idTarget struct {
+ path string
+ id string
+ }
+ var targets []idTarget
+ used := make(map[string]bool)
+ for msgIdx, message := range doc.Get("messages").Array() {
+ for blockIdx, block := range message.Get("content").Array() {
+ field := ""
+ switch block.Get("type").String() {
+ case "tool_use":
+ field = "id"
+ case "tool_result":
+ field = "tool_use_id"
+ default:
+ continue
+ }
+ value := block.Get(field)
+ if value.Type != ojson.String || value.String() == "" {
+ continue
+ }
+ id := value.String()
+ if isPortableToolCallID(id) {
+ used[id] = true
+ } else {
+ targets = append(targets, idTarget{
+ path: fmt.Sprintf("messages.%d.content.%d.%s", msgIdx, blockIdx, field),
+ id: id,
+ })
+ }
+ }
+ }
+ if len(targets) == 0 {
+ return false
+ }
+ mapped := make(map[string]string, len(targets))
+ modified := false
+ for _, target := range targets {
+ id := mapped[target.id]
+ if id == "" {
+ id = EncodeDevinToolCallID(target.id)
+ for used[id] {
+ id = "_" + id
+ }
+ mapped[target.id] = id
+ used[id] = true
+ }
+ if errSet := doc.Set(target.path, id); errSet == nil {
+ modified = true
+ }
+ }
+ return modified
+}
diff --git a/internal/runtime/executor/helps/tool_call_ids_test.go b/internal/runtime/executor/helps/tool_call_ids_test.go
index 12c437e7b90..7c1fc4b9870 100644
--- a/internal/runtime/executor/helps/tool_call_ids_test.go
+++ b/internal/runtime/executor/helps/tool_call_ids_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func legacyToolIDHistory(callCount int) []byte {
@@ -45,8 +45,8 @@ func TestIssue6478_NormalizeLargeHistoryAllocation(t *testing.T) {
}
normalized := NormalizeClaudeToolCallIDs(body)
- uses := gjson.GetBytes(normalized, "messages.0.content").Array()
- results := gjson.GetBytes(normalized, "messages.1.content").Array()
+ uses := ojson.GetBytes(normalized, "messages.0.content").Array()
+ results := ojson.GetBytes(normalized, "messages.1.content").Array()
if len(uses) != callCount || len(results) != callCount {
t.Fatalf("history lost calls/results: %d/%d, want %d", len(uses), len(results), callCount)
}
diff --git a/internal/runtime/executor/helps/transport_cache.go b/internal/runtime/executor/helps/transport_cache.go
index 943c609ddc6..b8225746e9d 100644
--- a/internal/runtime/executor/helps/transport_cache.go
+++ b/internal/runtime/executor/helps/transport_cache.go
@@ -2,9 +2,12 @@ package helps
import (
"container/list"
+ "context"
"errors"
+ "net"
"net/http"
"sync"
+ "sync/atomic"
)
// DefaultTransportCacheCapacity bounds how many transports a TransportCache keeps
@@ -33,6 +36,49 @@ type TransportCache[K comparable] struct {
type transportCacheEntry[K comparable] struct {
key K
transport *http.Transport
+ retired atomic.Bool
+}
+
+// attachTransportRetirementGuard ensures a transport that has been evicted or
+// closed from the cache cannot rebuild an idle pool if a descheduled caller
+// performs a RoundTrip on it after eviction. In net/http, queueForIdleConn
+// clears t.closeIdle before dialConn invokes DialContext/DialTLSContext;
+// re-arming CloseIdleConnections inside the dial callback while retired is true
+// leaves the in-flight connection open for the current request and causes
+// tryPutIdleConn to close it immediately once the response finishes.
+func attachTransportRetirementGuard(transport *http.Transport, retired *atomic.Bool) {
+ if transport == nil || retired == nil {
+ return
+ }
+ dial := transport.DialContext
+ if dial == nil && transport.DialTLSContext == nil && transport.Dial == nil && transport.DialTLS == nil {
+ var d net.Dialer
+ dial = d.DialContext
+ }
+ if dial != nil {
+ transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+ conn, err := dial(ctx, network, addr)
+ if err != nil {
+ return nil, err
+ }
+ if retired.Load() {
+ transport.CloseIdleConnections()
+ }
+ return conn, nil
+ }
+ }
+ if dialTLS := transport.DialTLSContext; dialTLS != nil {
+ transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+ conn, err := dialTLS(ctx, network, addr)
+ if err != nil {
+ return nil, err
+ }
+ if retired.Load() {
+ transport.CloseIdleConnections()
+ }
+ return conn, nil
+ }
+ }
}
// NewTransportCache returns a cache holding at most capacity transports. A
@@ -63,20 +109,23 @@ func (c *TransportCache[K]) Get(key K, build func() (*http.Transport, error)) (*
}
c.mu.Lock()
+ locked := true
+ defer func() {
+ if locked {
+ c.mu.Unlock()
+ }
+ }()
if element, ok := c.items[key]; ok {
c.order.MoveToFront(element)
transport := element.Value.(*transportCacheEntry[K]).transport
- c.mu.Unlock()
return transport, nil
}
transport, errBuild := build()
if errBuild != nil {
- c.mu.Unlock()
return nil, errBuild
}
if transport == nil {
- c.mu.Unlock()
return nil, errors.New("transport cache: build returned no transport")
}
@@ -84,13 +133,17 @@ func (c *TransportCache[K]) Get(key K, build func() (*http.Transport, error)) (*
if element, ok := c.items[key]; ok {
c.order.MoveToFront(element)
existing := element.Value.(*transportCacheEntry[K]).transport
+ locked = false
c.mu.Unlock()
transport.CloseIdleConnections()
return existing, nil
}
- c.items[key] = c.order.PushFront(&transportCacheEntry[K]{key: key, transport: transport})
+ entry := &transportCacheEntry[K]{key: key, transport: transport}
+ attachTransportRetirementGuard(transport, &entry.retired)
+ c.items[key] = c.order.PushFront(entry)
evicted := c.evictLocked()
+ locked = false
c.mu.Unlock()
for _, t := range evicted {
@@ -113,6 +166,7 @@ func (c *TransportCache[K]) evictLocked() []*http.Transport {
}
c.order.Remove(oldest)
entry := oldest.Value.(*transportCacheEntry[K])
+ entry.retired.Store(true)
delete(c.items, entry.key)
evicted = append(evicted, entry.transport)
}
@@ -154,20 +208,23 @@ func (c *TransportCache[K]) CloseKey(key K) bool {
}
c.order.Remove(element)
delete(c.items, key)
- transport := element.Value.(*transportCacheEntry[K]).transport
+ entry := element.Value.(*transportCacheEntry[K])
+ entry.retired.Store(true)
+ transport := entry.transport
c.mu.Unlock()
transport.CloseIdleConnections()
return true
}
-// CloseMatching removes and closes idle connections for every cached transport whose key
-// satisfies predicate. Returns the number of closed transports.
-func (c *TransportCache[K]) CloseMatching(predicate func(key K) bool) int {
+// detachMatching removes every cached transport whose key satisfies predicate and returns
+// the detached transports so callers can close idle connections outside outer locks.
+func (c *TransportCache[K]) detachMatching(predicate func(key K) bool) []*http.Transport {
if c == nil || predicate == nil {
- return 0
+ return nil
}
c.mu.Lock()
+ defer c.mu.Unlock()
var toClose []*http.Transport
var next *list.Element
for element := c.order.Front(); element != nil; element = next {
@@ -175,12 +232,18 @@ func (c *TransportCache[K]) CloseMatching(predicate func(key K) bool) int {
entry := element.Value.(*transportCacheEntry[K])
if predicate(entry.key) {
c.order.Remove(element)
+ entry.retired.Store(true)
delete(c.items, entry.key)
toClose = append(toClose, entry.transport)
}
}
- c.mu.Unlock()
+ return toClose
+}
+// CloseMatching removes and closes idle connections for every cached transport whose key
+// satisfies predicate. Returns the number of closed transports.
+func (c *TransportCache[K]) CloseMatching(predicate func(key K) bool) int {
+ toClose := c.detachMatching(predicate)
for _, t := range toClose {
t.CloseIdleConnections()
}
@@ -195,7 +258,9 @@ func (c *TransportCache[K]) Purge() {
c.mu.Lock()
var toClose []*http.Transport
for element := c.order.Front(); element != nil; element = element.Next() {
- toClose = append(toClose, element.Value.(*transportCacheEntry[K]).transport)
+ entry := element.Value.(*transportCacheEntry[K])
+ entry.retired.Store(true)
+ toClose = append(toClose, entry.transport)
}
c.order.Init()
c.items = make(map[K]*list.Element, c.capacity)
diff --git a/internal/runtime/executor/helps/transport_cache_test.go b/internal/runtime/executor/helps/transport_cache_test.go
index 2bc0c39a949..0cb3ecc0aff 100644
--- a/internal/runtime/executor/helps/transport_cache_test.go
+++ b/internal/runtime/executor/helps/transport_cache_test.go
@@ -260,3 +260,48 @@ func TestTransportCacheEvictionClosesIdleConnections(t *testing.T) {
t.Fatal("expected auth-2 and auth-3 to remain in cache")
}
}
+
+func TestTransportCacheBuilderPanicReleasesMutex(t *testing.T) {
+ cache := NewTransportCache[cacheKey](4)
+ key := cacheKey{"auth-panic", ""}
+
+ func() {
+ defer func() {
+ if r := recover(); r == nil {
+ t.Fatal("expected panic from builder to propagate")
+ }
+ }()
+ _, _ = cache.Get(key, func() (*http.Transport, error) {
+ panic("builder boom")
+ })
+ }()
+
+ if got := cache.Len(); got != 0 {
+ t.Fatalf("Len() after panicked build = %d, want 0", got)
+ }
+
+ transport, err := cache.Get(key, func() (*http.Transport, error) {
+ return &http.Transport{}, nil
+ })
+ if err != nil || transport == nil {
+ t.Fatalf("Get() after recovered builder panic = (%p, %v), want usable cache", transport, err)
+ }
+}
+
+func TestTransportCachePurgeRetiresEntries(t *testing.T) {
+ cache := NewTransportCache[cacheKey](4)
+ key := cacheKey{"auth-purge", ""}
+
+ _, err := cache.Get(key, func() (*http.Transport, error) {
+ return &http.Transport{}, nil
+ })
+ if err != nil {
+ t.Fatalf("Get() error = %v", err)
+ }
+
+ entry := cache.items[key].Value.(*transportCacheEntry[cacheKey])
+ cache.Purge()
+ if !entry.retired.Load() {
+ t.Fatal("expected Purge() to mark removed entry as retired")
+ }
+}
diff --git a/internal/runtime/executor/helps/unsupported_part_test.go b/internal/runtime/executor/helps/unsupported_part_test.go
index e592e8c38f4..1a5f08a1932 100644
--- a/internal/runtime/executor/helps/unsupported_part_test.go
+++ b/internal/runtime/executor/helps/unsupported_part_test.go
@@ -4,9 +4,9 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// A turn that only holds a file the target cannot receive is refused with a named
@@ -34,7 +34,7 @@ func TestByteWrappersKeepAJSONBodyForAnUnsendableFile(t *testing.T) {
for _, compat := range []bool{false, true} {
original, working := TranslateRequestPairWithAPIKeyModelCompatibility(context.Background(), nil, nil, sdktranslator.FormatClaude, to, "m", payload, payload, false, compat)
for _, body := range [][]byte{original, working} {
- if !gjson.ValidBytes(body) || len(body) < 2 {
+ if !ojson.ValidBytes(body) || len(body) < 2 {
t.Fatalf("to=%s compat=%v: body = %q", to, compat, body)
}
}
diff --git a/internal/runtime/executor/helps/usage_helpers.go b/internal/runtime/executor/helps/usage_helpers.go
index a037cba6137..fbff997aae2 100644
--- a/internal/runtime/executor/helps/usage_helpers.go
+++ b/internal/runtime/executor/helps/usage_helpers.go
@@ -14,19 +14,18 @@ import (
"sync"
"sync/atomic"
"time"
- "unsafe"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
internallogging "github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type UsageReporter struct {
@@ -367,13 +366,13 @@ func (r *UsageReporter) SetTranslatedReasoningEffort(payload []byte, format stri
}
func extractEffectiveServiceTier(payload []byte) string {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return ""
}
for _, path := range []string{"service_tier", "request.service_tier", "interaction.service_tier"} {
- if node := gjson.GetBytes(payload, path); node.Type == gjson.String {
+ if node := ojson.GetBytesNoCopy(payload, path); node.Type == ojson.String {
if tier := strings.TrimSpace(node.String()); tier != "" {
- return tier
+ return strings.Clone(tier)
}
}
}
@@ -733,6 +732,8 @@ func failFromErrors(errs ...error) usage.Failure {
}
if truncatedErr, ok := errors.AsType[cliproxyexecutor.StreamTruncatedError](err); ok && truncatedErr != nil && truncatedErr.IsStreamTruncated() {
fail.Code = usage.FailureStreamTruncatedCode
+ } else if requestErr, ok := errors.AsType[cliproxyexecutor.RequestScopedError](err); ok && requestErr != nil && requestErr.IsRequestScoped() {
+ fail.Code = usage.FailureRequestScopedCode
}
return fail
}
@@ -970,16 +971,6 @@ var (
geminiPredictionsMarker = []byte(`"predictions"`)
)
-// unsafeBytesToString converts a byte slice to a string without heap allocation.
-// The returned string must only be used for read-only gjson traversal within the
-// call frame, and any string values stored into long-lived structs are cloned.
-func unsafeBytesToString(b []byte) string {
- if len(b) == 0 {
- return ""
- }
- return unsafe.String(unsafe.SliceData(b), len(b))
-}
-
func cloneTrimmedString(s string) string {
s = strings.TrimSpace(s)
if s == "" {
@@ -1110,16 +1101,16 @@ func (b *StreamUsageBuffer) observeGeminiFamilyStream(line []byte, allowResponse
return
}
payload := jsonPayload(line)
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return
}
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
var (
- node gjson.Result
- candidatesNode gjson.Result
- predsNode gjson.Result
+ node ojson.Result
+ candidatesNode ojson.Result
+ predsNode ojson.Result
)
- root.ForEach(func(key, val gjson.Result) bool {
+ root.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "usageMetadata", "usage_metadata":
if !node.Exists() {
@@ -1131,7 +1122,7 @@ func (b *StreamUsageBuffer) observeGeminiFamilyStream(line []byte, allowResponse
predsNode = val
case "response":
if allowResponseWrapper && val.IsObject() {
- val.ForEach(func(rk, rv gjson.Result) bool {
+ val.ForEach(func(rk, rv ojson.Result) bool {
switch rk.String() {
case "usageMetadata", "usage_metadata":
if !node.Exists() {
@@ -1179,6 +1170,8 @@ func (b *StreamUsageBuffer) observeGeminiFamilyStream(line []byte, allowResponse
{&detail.VideoInputTokens, update.VideoInputTokens},
{&detail.FileInputTokens, update.FileInputTokens},
{&detail.CachedTextTokens, update.CachedTextTokens},
+ {&detail.CachedImageTokens, update.CachedImageTokens},
+ {&detail.CachedAudioTokens, update.CachedAudioTokens},
{&detail.TextOutputTokens, update.TextOutputTokens},
{&detail.ImageOutputTokens, update.ImageOutputTokens},
{&detail.AudioOutputTokens, update.AudioOutputTokens},
@@ -1216,7 +1209,7 @@ func (b *StreamUsageBuffer) ObserveOpenAIStream(line []byte) {
if !hasUsageCandidate && !hasCacheCandidate && !hasTierCandidate && !hasToolCallsCandidate && !hasImageCompletedCandidate {
return
}
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
return
}
@@ -1230,7 +1223,7 @@ func (b *StreamUsageBuffer) ObserveOpenAIStream(line []byte) {
detail := usage.Detail{}
usageOK := false
if hasUsageCandidate || hasCacheCandidate {
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
usageNode := root.Get("usage")
detail, usageOK = parseOpenAIStyleUsageWithCache(root, usageNode)
if detail.ToolUsageDetails != nil && detail.ToolUsageDetails.ToolCallsRequested != nil {
@@ -1265,9 +1258,9 @@ func (b *StreamUsageBuffer) ObserveOpenAIStream(line []byte) {
}
func (b *StreamUsageBuffer) observeOpenAIStreamToolCalls(payload []byte) {
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
for choiceIdx, choice := range root.Get("choices").Array() {
- if idx := choice.Get("index"); idx.Type == gjson.Number {
+ if idx := choice.Get("index"); idx.Type == ojson.Number {
choiceIdx = int(idx.Int())
}
tcNode := choice.Get("delta.tool_calls")
@@ -1279,7 +1272,7 @@ func (b *StreamUsageBuffer) observeOpenAIStreamToolCalls(payload []byte) {
}
for itemIdx, tc := range tcNode.Array() {
key := ""
- if idxNode := tc.Get("index"); idxNode.Type == gjson.Number {
+ if idxNode := tc.Get("index"); idxNode.Type == ojson.Number {
key = fmt.Sprintf("%d:%d", choiceIdx, idxNode.Int())
} else if id := strings.TrimSpace(tc.Get("id").String()); id != "" {
key = fmt.Sprintf("%d:id:%s", choiceIdx, id)
@@ -1299,18 +1292,18 @@ func (b *StreamUsageBuffer) observeOpenAIStreamToolCalls(payload []byte) {
}
func (b *StreamUsageBuffer) observeOpenAIImageStreamCompleted(payload []byte) {
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
evType := strings.TrimSpace(root.Get("type").String())
if evType != "image_generation.completed" && evType != "image_edit.completed" {
return
}
b.openAIStreamImages++
details := &usage.ImageDetails{
- Size: strings.TrimSpace(root.Get("size").String()),
- Quality: strings.TrimSpace(root.Get("quality").String()),
- Background: strings.TrimSpace(root.Get("background").String()),
- OutputFormat: strings.TrimSpace(root.Get("output_format").String()),
- MimeType: strings.TrimSpace(root.Get("mime_type").String()),
+ Size: cloneTrimmedString(root.Get("size").String()),
+ Quality: cloneTrimmedString(root.Get("quality").String()),
+ Background: cloneTrimmedString(root.Get("background").String()),
+ OutputFormat: cloneTrimmedString(root.Get("output_format").String()),
+ MimeType: cloneTrimmedString(root.Get("mime_type").String()),
}
if !isEmptyImageDetails(details) {
b.openAIStreamImageDetails = details
@@ -1388,17 +1381,17 @@ func (b *StreamUsageBuffer) ObserveClaudeSSEEvent(rawEvent []byte) {
return
}
joined := bytes.TrimSpace(bytes.Join(dataLines, []byte("\n")))
- if len(joined) == 0 || !gjson.ValidBytes(joined) {
+ if len(joined) == 0 || !ojson.ValidBytes(joined) {
return
}
b.ObserveClaudeStream(joined)
}
func (b *StreamUsageBuffer) observeClaudeStreamToolBlocks(payload []byte) {
- if b == nil || !bytes.Contains(payload, claudeStreamBlockStartMarker) || !gjson.ValidBytes(payload) {
+ if b == nil || !bytes.Contains(payload, claudeStreamBlockStartMarker) || !ojson.ValidBytes(payload) {
return
}
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
if root.Get("type").String() != "content_block_start" {
return
}
@@ -1522,7 +1515,7 @@ func (b *StreamUsageBuffer) ObserveResponsesStream(line []byte) bool {
return false
}
payload := jsonPayload(line)
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return false
}
if b.responseModel == "" {
@@ -1531,11 +1524,11 @@ func (b *StreamUsageBuffer) ObserveResponsesStream(line []byte) bool {
}
}
if bytes.Contains(payload, responsesOutputItemDoneMarker) {
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
if strings.TrimSpace(root.Get("type").String()) == "response.output_item.done" {
if item := root.Get("item"); item.Exists() && item.IsObject() {
rawItem := bytes.Clone([]byte(item.Raw))
- if idxNode := root.Get("output_index"); idxNode.Type == gjson.Number && idxNode.Int() >= 0 {
+ if idxNode := root.Get("output_index"); idxNode.Type == ojson.Number && idxNode.Int() >= 0 {
if b.responsesOutputByIndex == nil {
b.responsesOutputByIndex = make(map[int64][]byte)
}
@@ -1567,7 +1560,7 @@ func ParseCodexUsageWithOutputItems(data []byte, itemsByIndex map[int64][]byte,
if bytes.Contains(data, openAIStreamServiceTierMarker) {
responseServiceTier = extractResponseServiceTier(data)
}
- root := gjson.Parse(unsafeBytesToString(data))
+ root := util.ParseGJSONBytesNoCopy(data)
usageNode := root.Get("response.usage")
detail, ok := parseOpenAIStyleUsageWithCache(root, usageNode)
if !ok {
@@ -1583,7 +1576,7 @@ func ParseCodexUsageWithOutputItems(data []byte, itemsByIndex map[int64][]byte,
if detail.UpstreamServiceTier == "" {
detail.UpstreamServiceTier = responseServiceTier
}
- if wsReq := root.Get("response.tool_usage.web_search.num_requests"); wsReq.Type == gjson.Number {
+ if wsReq := root.Get("response.tool_usage.web_search.num_requests"); wsReq.Type == ojson.Number {
v := wsReq.Int()
if detail.ToolUsageDetails == nil {
detail.ToolUsageDetails = &usage.ToolUsageDetails{}
@@ -1611,7 +1604,7 @@ func ParseCodexUsageWithOutputItems(data []byte, itemsByIndex map[int64][]byte,
return detail, true
}
-func applyResponsesCacheRetention(detail *usage.Detail, root, usageNode gjson.Result) {
+func applyResponsesCacheRetention(detail *usage.Detail, root, usageNode ojson.Result) {
if detail == nil {
return
}
@@ -1633,7 +1626,7 @@ func applyResponsesCacheRetention(detail *usage.Detail, root, usageNode gjson.Re
buckets := firstExistingUsageNode(usageNode, "input_tokens_details.cache_write_tokens_details", "prompt_tokens_details.cache_write_tokens_details")
short := buckets.Get("ttl_30m_tokens")
long := buckets.Get("ttl_12h_tokens")
- if retention == "" && mode == "" && ttl == "" && short.Type != gjson.Number && long.Type != gjson.Number {
+ if retention == "" && mode == "" && ttl == "" && short.Type != ojson.Number && long.Type != ojson.Number {
return
}
if detail.CacheDetails == nil {
@@ -1642,11 +1635,11 @@ func applyResponsesCacheRetention(detail *usage.Detail, root, usageNode gjson.Re
detail.CacheDetails.Retention = strings.Clone(retention)
detail.CacheDetails.Mode = mode
detail.CacheDetails.TTL = ttl
- if short.Type == gjson.Number {
+ if short.Type == ojson.Number {
detail.CacheCreation30mTokens = usage.Int64Ptr(short.Int())
detail.CacheDetails.Ephemeral30mInputTokens = usage.Int64Ptr(short.Int())
}
- if long.Type == gjson.Number {
+ if long.Type == ojson.Number {
detail.CacheCreation12hTokens = usage.Int64Ptr(long.Int())
detail.CacheDetails.Ephemeral12hInputTokens = usage.Int64Ptr(long.Int())
}
@@ -1660,7 +1653,7 @@ func ParseCodexImageToolUsageWithOutputItems(requestBody, data []byte, itemsByIn
if len(data) == 0 {
return usage.Detail{}, false
}
- root := gjson.Parse(unsafeBytesToString(data))
+ root := util.ParseGJSONBytesNoCopy(data)
usageNode := root.Get("response.tool_usage.image_gen")
if !hasOpenAIStyleUsageTokenFields(usageNode) {
return usage.Detail{}, false
@@ -1670,11 +1663,11 @@ func ParseCodexImageToolUsageWithOutputItems(requestBody, data []byte, itemsByIn
return detail, true
}
-func applyCodexImageToolOutputMetadata(detail *usage.Detail, requestBody []byte, outputNode gjson.Result, itemsByIndex map[int64][]byte, fallback [][]byte) {
+func applyCodexImageToolOutputMetadata(detail *usage.Detail, requestBody []byte, outputNode ojson.Result, itemsByIndex map[int64][]byte, fallback [][]byte) {
if detail == nil {
return
}
- var items []gjson.Result
+ var items []ojson.Result
if outputNode.Exists() && outputNode.IsArray() && len(outputNode.Array()) > 0 {
items = outputNode.Array()
} else {
@@ -1721,7 +1714,7 @@ func ParseOpenAIUsage(data []byte) usage.Detail {
if bytes.Contains(data, openAIStreamServiceTierMarker) {
responseServiceTier = extractResponseServiceTier(data)
}
- root := gjson.Parse(unsafeBytesToString(data))
+ root := util.ParseGJSONBytesNoCopy(data)
usageNode := root.Get("usage")
detail, _ := parseOpenAIStyleUsageWithCache(root, usageNode)
detail.ResponseServiceTier = responseServiceTier
@@ -1735,7 +1728,7 @@ func ParseOpenAIUsage(data []byte) usage.Detail {
return detail
}
-func applyOpenAIChatToolCallsUsage(detail *usage.Detail, root gjson.Result) {
+func applyOpenAIChatToolCallsUsage(detail *usage.Detail, root ojson.Result) {
if detail == nil || !root.Exists() || !root.IsObject() {
return
}
@@ -1759,7 +1752,7 @@ func applyOpenAIChatToolCallsUsage(detail *usage.Detail, root gjson.Result) {
// parseOpenAIStyleUsageWithCache keeps structured-only usage and cache metadata
// consistent across Chat, Responses, and streaming entry points.
-func parseOpenAIStyleUsageWithCache(root, usageNode gjson.Result) (usage.Detail, bool) {
+func parseOpenAIStyleUsageWithCache(root, usageNode ojson.Result) (usage.Detail, bool) {
var detail usage.Detail
hasTokens := hasOpenAIStyleUsageTokenFields(usageNode)
if hasTokens {
@@ -1779,19 +1772,20 @@ func hasStructuredUsageDetail(d usage.Detail) bool {
d.CacheCreation1hTokens != nil || d.CacheCreation12hTokens != nil ||
d.TextInputTokens != nil || d.ImageInputTokens != nil || d.AudioInputTokens != nil ||
d.VideoInputTokens != nil || d.FileInputTokens != nil || d.CachedTextTokens != nil ||
+ d.CachedImageTokens != nil || d.CachedAudioTokens != nil ||
d.TextOutputTokens != nil || d.ImageOutputTokens != nil || d.AudioOutputTokens != nil ||
d.AcceptedPredictionTokens != nil || d.RejectedPredictionTokens != nil ||
d.Speed != "" || d.InferenceGeo != ""
}
-func hasOpenAIStyleUsageTokenFields(usageNode gjson.Result) bool {
+func hasOpenAIStyleUsageTokenFields(usageNode ojson.Result) bool {
if !usageNode.Exists() || !usageNode.IsObject() {
return false
}
return usageNode.Get("total_tokens").Exists() || hasOpenAIStyleUsageBucketFields(usageNode)
}
-func hasOpenAIStyleUsageBucketFields(usageNode gjson.Result) bool {
+func hasOpenAIStyleUsageBucketFields(usageNode ojson.Result) bool {
return usageNode.Get("prompt_tokens").Exists() ||
usageNode.Get("input_tokens").Exists() ||
usageNode.Get("completion_tokens").Exists() ||
@@ -1807,7 +1801,7 @@ func hasOpenAIStyleUsageBucketFields(usageNode gjson.Result) bool {
usageNode.Get("reasoning_tokens").Exists()
}
-func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
+func parseOpenAIStyleUsageNode(usageNode ojson.Result) usage.Detail {
if !usageNode.Exists() || !usageNode.IsObject() {
return usage.Detail{}
}
@@ -1816,13 +1810,13 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
hasInput bool
hasOutput bool
hasBucketFields bool
- inputDetails gjson.Result
- promptDetails gjson.Result
- outputDetails gjson.Result
- compDetails gjson.Result
- flatReasoning gjson.Result
+ inputDetails ojson.Result
+ promptDetails ojson.Result
+ outputDetails ojson.Result
+ compDetails ojson.Result
+ flatReasoning ojson.Result
)
- usageNode.ForEach(func(key, val gjson.Result) bool {
+ usageNode.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "prompt_tokens":
detail.InputTokens = val.Int()
@@ -1862,11 +1856,11 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
})
var hasCached, hasCacheCreation, hasReasoning bool
- scanInDetails := func(node gjson.Result) {
+ scanInDetails := func(node ojson.Result) {
if !node.Exists() || !node.IsObject() {
return
}
- node.ForEach(func(k, v gjson.Result) bool {
+ node.ForEach(func(k, v ojson.Result) bool {
switch k.String() {
case "cached_tokens":
if !hasCached {
@@ -1882,33 +1876,53 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
}
hasBucketFields = true
case "text_tokens":
- if v.Type == gjson.Number && detail.TextInputTokens == nil {
+ if v.Type == ojson.Number && detail.TextInputTokens == nil {
detail.TextInputTokens = usage.Int64Ptr(v.Int())
}
case "image_tokens", "prompt_image_tokens":
- if v.Type == gjson.Number && detail.ImageInputTokens == nil {
+ if v.Type == ojson.Number && detail.ImageInputTokens == nil {
detail.ImageInputTokens = usage.Int64Ptr(v.Int())
}
case "audio_tokens":
- if v.Type == gjson.Number && detail.AudioInputTokens == nil {
+ if v.Type == ojson.Number && detail.AudioInputTokens == nil {
detail.AudioInputTokens = usage.Int64Ptr(v.Int())
}
case "video_tokens":
- if v.Type == gjson.Number && detail.VideoInputTokens == nil {
+ if v.Type == ojson.Number && detail.VideoInputTokens == nil {
detail.VideoInputTokens = usage.Int64Ptr(v.Int())
}
case "file_tokens":
- if v.Type == gjson.Number && detail.FileInputTokens == nil {
+ if v.Type == ojson.Number && detail.FileInputTokens == nil {
detail.FileInputTokens = usage.Int64Ptr(v.Int())
}
- case "cached_prompt_text_tokens":
- if v.Type == gjson.Number && detail.CachedTextTokens == nil {
+ case "cached_prompt_text_tokens", "cached_text_tokens":
+ if v.Type == ojson.Number && detail.CachedTextTokens == nil {
detail.CachedTextTokens = usage.Int64Ptr(v.Int())
}
+ case "cached_image_tokens":
+ if v.Type == ojson.Number && detail.CachedImageTokens == nil {
+ detail.CachedImageTokens = usage.Int64Ptr(v.Int())
+ }
+ case "cached_audio_tokens":
+ if v.Type == ojson.Number && detail.CachedAudioTokens == nil {
+ detail.CachedAudioTokens = usage.Int64Ptr(v.Int())
+ }
case "cached_tokens_details":
- if detail.CachedTextTokens == nil && v.IsObject() {
- if tv := v.Get("text_tokens"); tv.Type == gjson.Number {
- detail.CachedTextTokens = usage.Int64Ptr(tv.Int())
+ if v.IsObject() {
+ if detail.CachedTextTokens == nil {
+ if tv := v.Get("text_tokens"); tv.Type == ojson.Number {
+ detail.CachedTextTokens = usage.Int64Ptr(tv.Int())
+ }
+ }
+ if detail.CachedImageTokens == nil {
+ if iv := v.Get("image_tokens"); iv.Type == ojson.Number {
+ detail.CachedImageTokens = usage.Int64Ptr(iv.Int())
+ }
+ }
+ if detail.CachedAudioTokens == nil {
+ if av := v.Get("audio_tokens"); av.Type == ojson.Number {
+ detail.CachedAudioTokens = usage.Int64Ptr(av.Int())
+ }
}
}
}
@@ -1927,11 +1941,11 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
scanInDetails(inputDetails)
scanInDetails(promptDetails)
- scanOutDetails := func(node gjson.Result) {
+ scanOutDetails := func(node ojson.Result) {
if !node.Exists() || !node.IsObject() {
return
}
- node.ForEach(func(k, v gjson.Result) bool {
+ node.ForEach(func(k, v ojson.Result) bool {
switch k.String() {
case "reasoning_tokens":
if !hasReasoning {
@@ -1940,23 +1954,23 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
}
hasBucketFields = true
case "text_tokens":
- if v.Type == gjson.Number && detail.TextOutputTokens == nil {
+ if v.Type == ojson.Number && detail.TextOutputTokens == nil {
detail.TextOutputTokens = usage.Int64Ptr(v.Int())
}
case "image_tokens":
- if v.Type == gjson.Number && detail.ImageOutputTokens == nil {
+ if v.Type == ojson.Number && detail.ImageOutputTokens == nil {
detail.ImageOutputTokens = usage.Int64Ptr(v.Int())
}
case "audio_tokens":
- if v.Type == gjson.Number && detail.AudioOutputTokens == nil {
+ if v.Type == ojson.Number && detail.AudioOutputTokens == nil {
detail.AudioOutputTokens = usage.Int64Ptr(v.Int())
}
case "accepted_prediction_tokens":
- if v.Type == gjson.Number && detail.AcceptedPredictionTokens == nil {
+ if v.Type == ojson.Number && detail.AcceptedPredictionTokens == nil {
detail.AcceptedPredictionTokens = usage.Int64Ptr(v.Int())
}
case "rejected_prediction_tokens":
- if v.Type == gjson.Number && detail.RejectedPredictionTokens == nil {
+ if v.Type == ojson.Number && detail.RejectedPredictionTokens == nil {
detail.RejectedPredictionTokens = usage.Int64Ptr(v.Int())
}
}
@@ -2017,27 +2031,27 @@ func parseOpenAIStyleUsageNode(usageNode gjson.Result) usage.Detail {
return detail
}
-func populateOpenAIStyleStructuredFields(detail *usage.Detail, usageNode gjson.Result) {
+func populateOpenAIStyleStructuredFields(detail *usage.Detail, usageNode ojson.Result) {
if detail == nil || !usageNode.Exists() || !usageNode.IsObject() {
return
}
var (
toolDetails *usage.ToolUsageDetails
- costNode gjson.Result
- ticksNode gjson.Result
- byokNode gjson.Result
- costDetails gjson.Result
+ costNode ojson.Result
+ ticksNode ojson.Result
+ byokNode ojson.Result
+ costDetails ojson.Result
hasCost bool
)
- usageNode.ForEach(func(key, val gjson.Result) bool {
+ usageNode.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "service_tier":
- if detail.UpstreamServiceTier == "" && val.Type == gjson.String {
+ if detail.UpstreamServiceTier == "" && val.Type == ojson.String {
detail.UpstreamServiceTier = cloneTrimmedString(val.String())
}
case "attribution":
if detail.UpstreamServiceTier == "" && val.IsObject() {
- if t := val.Get("tier"); t.Type == gjson.String {
+ if t := val.Get("tier"); t.Type == ojson.String {
detail.UpstreamServiceTier = cloneTrimmedString(t.String())
}
}
@@ -2046,14 +2060,14 @@ func populateOpenAIStyleStructuredFields(detail *usage.Detail, usageNode gjson.R
toolDetails = mergeToolUsageDetails(toolDetails, parsed)
}
case "num_sources_used":
- if val.Type == gjson.Number {
+ if val.Type == ojson.Number {
if toolDetails == nil {
toolDetails = &usage.ToolUsageDetails{}
}
toolDetails.NumSourcesUsed = usage.Int64Ptr(val.Int())
}
case "num_server_side_tools_used":
- if val.Type == gjson.Number {
+ if val.Type == ojson.Number {
if toolDetails == nil {
toolDetails = &usage.ToolUsageDetails{}
}
@@ -2092,11 +2106,11 @@ func populateOpenAIStyleStructuredFields(detail *usage.Detail, usageNode gjson.R
}
}
-func parseCollectedOutputItems(itemsByIndex map[int64][]byte, fallback [][]byte) []gjson.Result {
+func parseCollectedOutputItems(itemsByIndex map[int64][]byte, fallback [][]byte) []ojson.Result {
if len(itemsByIndex) == 0 && len(fallback) == 0 {
return nil
}
- items := make([]gjson.Result, 0, len(itemsByIndex)+len(fallback))
+ items := make([]ojson.Result, 0, len(itemsByIndex)+len(fallback))
if len(itemsByIndex) > 0 {
indexes := make([]int64, 0, len(itemsByIndex))
for idx := range itemsByIndex {
@@ -2110,14 +2124,14 @@ func parseCollectedOutputItems(itemsByIndex map[int64][]byte, fallback [][]byte)
}
}
for _, idx := range indexes {
- if raw := itemsByIndex[idx]; len(raw) > 0 && gjson.ValidBytes(raw) {
- items = append(items, gjson.ParseBytes(raw))
+ if raw := itemsByIndex[idx]; len(raw) > 0 && ojson.ValidBytes(raw) {
+ items = append(items, util.ParseGJSONBytesNoCopy(raw))
}
}
}
for _, raw := range fallback {
- if len(raw) > 0 && gjson.ValidBytes(raw) {
- items = append(items, gjson.ParseBytes(raw))
+ if len(raw) > 0 && ojson.ValidBytes(raw) {
+ items = append(items, util.ParseGJSONBytesNoCopy(raw))
}
}
return items
@@ -2134,14 +2148,14 @@ func applyResponsesCollectedOutputItemsUsage(detail *usage.Detail, itemsByIndex
applyResponsesOutputItemSliceUsage(detail, items)
}
-func applyResponsesOutputItemsUsage(detail *usage.Detail, outputNode gjson.Result) {
+func applyResponsesOutputItemsUsage(detail *usage.Detail, outputNode ojson.Result) {
if detail == nil || !outputNode.Exists() || !outputNode.IsArray() {
return
}
applyResponsesOutputItemSliceUsage(detail, outputNode.Array())
}
-func applyResponsesOutputItemSliceUsage(detail *usage.Detail, items []gjson.Result) {
+func applyResponsesOutputItemSliceUsage(detail *usage.Detail, items []ojson.Result) {
if detail == nil || len(items) == 0 {
return
}
@@ -2301,14 +2315,14 @@ func ParseRequestImageDetails(requestPayload []byte, contentType ...string) *usa
return parseMultipartImageDetails(requestPayload, params["boundary"])
}
}
- if len(requestPayload) == 0 || !gjson.ValidBytes(requestPayload) {
+ if len(requestPayload) == 0 || !ojson.ValidBytes(requestPayload) {
return nil
}
- root := gjson.ParseBytes(requestPayload)
+ root := util.ParseGJSONBytesNoCopy(requestPayload)
pick := func(paths ...string) string {
for _, p := range paths {
- if v := root.Get(p); v.Type == gjson.String {
- if s := strings.TrimSpace(v.String()); s != "" {
+ if v := root.Get(p); v.Type == ojson.String {
+ if s := cloneTrimmedString(v.String()); s != "" {
return s
}
}
@@ -2333,15 +2347,15 @@ func ParseRequestImageDetails(requestPayload []byte, contentType ...string) *usa
continue
}
out = &usage.ImageDetails{
- Size: strings.TrimSpace(tool.Get("size").String()),
- Resolution: strings.TrimSpace(tool.Get("resolution").String()),
- ImageSize: strings.TrimSpace(firstExistingUsageNode(tool, "image_size", "imageSize").String()),
- AspectRatio: strings.TrimSpace(firstExistingUsageNode(tool, "aspect_ratio", "aspectRatio").String()),
- Quality: strings.TrimSpace(tool.Get("quality").String()),
- Background: strings.TrimSpace(tool.Get("background").String()),
- OutputFormat: strings.TrimSpace(tool.Get("output_format").String()),
- MimeType: strings.TrimSpace(firstExistingUsageNode(tool, "mime_type", "mimeType").String()),
- InputFidelity: strings.TrimSpace(tool.Get("input_fidelity").String()),
+ Size: cloneTrimmedString(tool.Get("size").String()),
+ Resolution: cloneTrimmedString(tool.Get("resolution").String()),
+ ImageSize: cloneTrimmedString(firstExistingUsageNode(tool, "image_size", "imageSize").String()),
+ AspectRatio: cloneTrimmedString(firstExistingUsageNode(tool, "aspect_ratio", "aspectRatio").String()),
+ Quality: cloneTrimmedString(tool.Get("quality").String()),
+ Background: cloneTrimmedString(tool.Get("background").String()),
+ OutputFormat: cloneTrimmedString(tool.Get("output_format").String()),
+ MimeType: cloneTrimmedString(firstExistingUsageNode(tool, "mime_type", "mimeType").String()),
+ InputFidelity: cloneTrimmedString(tool.Get("input_fidelity").String()),
}
break
}
@@ -2403,23 +2417,23 @@ func parseMultipartImageDetails(payload []byte, boundary string) *usage.ImageDet
func ParseOpenAIImageUsage(requestPayload, responsePayload []byte, contentType ...string) usage.Detail {
var detail usage.Detail
detail.RequestedImageDetails = ParseRequestImageDetails(requestPayload, contentType...)
- if len(responsePayload) == 0 || !gjson.ValidBytes(responsePayload) {
+ if len(responsePayload) == 0 || !ojson.ValidBytes(responsePayload) {
return detail
}
- root := gjson.Parse(unsafeBytesToString(responsePayload))
+ root := util.ParseGJSONBytesNoCopy(responsePayload)
var (
- usageNode gjson.Result
+ usageNode ojson.Result
serviceTier string
imageCount int64
imgDetails usage.ImageDetails
firstItemMeta usage.ImageDetails
)
- root.ForEach(func(key, val gjson.Result) bool {
+ root.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "usage":
usageNode = val
case "service_tier":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
serviceTier = cloneTrimmedString(val.String())
}
case "data":
@@ -2427,8 +2441,8 @@ func ParseOpenAIImageUsage(requestPayload, responsePayload []byte, contentType .
arr := val.Array()
imageCount = int64(len(arr))
if len(arr) > 0 && arr[0].IsObject() {
- arr[0].ForEach(func(dk, dv gjson.Result) bool {
- if dv.Type != gjson.String {
+ arr[0].ForEach(func(dk, dv ojson.Result) bool {
+ if dv.Type != ojson.String {
return true
}
switch dk.String() {
@@ -2464,39 +2478,39 @@ func ParseOpenAIImageUsage(requestPayload, responsePayload []byte, contentType .
}
}
case "size":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
imgDetails.Size = cloneTrimmedString(val.String())
}
case "resolution":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
imgDetails.Resolution = cloneTrimmedString(val.String())
}
case "image_size", "imageSize":
- if val.Type == gjson.String && imgDetails.ImageSize == "" {
+ if val.Type == ojson.String && imgDetails.ImageSize == "" {
imgDetails.ImageSize = cloneTrimmedString(val.String())
}
case "aspect_ratio", "aspectRatio":
- if val.Type == gjson.String && imgDetails.AspectRatio == "" {
+ if val.Type == ojson.String && imgDetails.AspectRatio == "" {
imgDetails.AspectRatio = cloneTrimmedString(val.String())
}
case "quality":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
imgDetails.Quality = cloneTrimmedString(val.String())
}
case "background":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
imgDetails.Background = cloneTrimmedString(val.String())
}
case "output_format":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
imgDetails.OutputFormat = cloneTrimmedString(val.String())
}
case "mime_type", "mimeType":
- if val.Type == gjson.String && imgDetails.MimeType == "" {
+ if val.Type == ojson.String && imgDetails.MimeType == "" {
imgDetails.MimeType = cloneTrimmedString(val.String())
}
case "input_fidelity":
- if val.Type == gjson.String {
+ if val.Type == ojson.String {
imgDetails.InputFidelity = cloneTrimmedString(val.String())
}
}
@@ -2561,8 +2575,8 @@ func ParseXAIImageUsage(requestPayload, responsePayload []byte) usage.Detail {
// ParseXAIVideoUsage extracts token usage and video parameters from an xAI video response.
func ParseXAIVideoUsage(requestPayload, responsePayload []byte) usage.Detail {
detail := ParseOpenAIUsage(responsePayload)
- root := gjson.ParseBytes(responsePayload)
- reqRoot := gjson.ParseBytes(requestPayload)
+ root := util.ParseGJSONBytesNoCopy(responsePayload)
+ reqRoot := util.ParseGJSONBytesNoCopy(requestPayload)
// Only attach video media attributes when a completed video output or usage is present,
// not on an initial async job creation response that only returns {"request_id":"..."}.
hasCompletedVideo := root.Get("usage").Exists() || root.Get("video").Exists() || root.Get("data").Exists() || root.Get("url").Exists() || strings.EqualFold(strings.TrimSpace(root.Get("status").String()), "completed")
@@ -2571,8 +2585,8 @@ func ParseXAIVideoUsage(requestPayload, responsePayload []byte) usage.Detail {
}
pickRespString := func(paths ...string) string {
for _, p := range paths {
- if v := root.Get(p); v.Type == gjson.String {
- if s := strings.TrimSpace(v.String()); s != "" {
+ if v := root.Get(p); v.Type == ojson.String {
+ if s := cloneTrimmedString(v.String()); s != "" {
return s
}
}
@@ -2587,12 +2601,12 @@ func ParseXAIVideoUsage(requestPayload, responsePayload []byte) usage.Detail {
detail.ImageDetails = imgDetails
}
detail.RequestedImageDetails = ParseRequestImageDetails(requestPayload)
- if dur := firstExistingUsageNode(root, "duration", "video.duration"); dur.Type == gjson.Number && dur.Float() > 0 {
+ if dur := firstExistingUsageNode(root, "duration", "video.duration"); dur.Type == ojson.Number && dur.Float() > 0 {
detail.MediaCounts = &usage.MediaCounts{
CompletionMedia: usage.Int64Ptr(1),
AudioSeconds: usage.Float64Ptr(dur.Float()),
}
- } else if dur := reqRoot.Get("duration"); dur.Type == gjson.Number && dur.Float() > 0 {
+ } else if dur := reqRoot.Get("duration"); dur.Type == ojson.Number && dur.Float() > 0 {
detail.MediaCounts = &usage.MediaCounts{
CompletionMedia: usage.Int64Ptr(1),
AudioSeconds: usage.Float64Ptr(dur.Float()),
@@ -2601,14 +2615,14 @@ func ParseXAIVideoUsage(requestPayload, responsePayload []byte) usage.Detail {
return detail
}
-func parseOpenAIToolUsageDetailsNode(node gjson.Result) *usage.ToolUsageDetails {
+func parseOpenAIToolUsageDetailsNode(node ojson.Result) *usage.ToolUsageDetails {
if !node.Exists() || !node.IsObject() {
return nil
}
out := &usage.ToolUsageDetails{}
hasAny := false
- node.ForEach(func(key, value gjson.Result) bool {
- if value.Type != gjson.Number {
+ node.ForEach(func(key, value ojson.Result) bool {
+ if value.Type != ojson.Number {
return true
}
k := strings.TrimSpace(key.String())
@@ -2666,10 +2680,10 @@ func parseOpenAIToolUsageDetailsNode(node gjson.Result) *usage.ToolUsageDetails
func ParseOpenAIStreamUsage(line []byte) (usage.Detail, bool) {
payload := jsonPayload(line)
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return usage.Detail{}, false
}
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
detail, ok := parseOpenAIStyleUsageWithCache(root, root.Get("usage"))
detail.ResponseServiceTier = extractResponseServiceTierFromValidJSON(payload)
if detail.UpstreamServiceTier == "" {
@@ -2679,7 +2693,7 @@ func ParseOpenAIStreamUsage(line []byte) (usage.Detail, bool) {
}
func ParseClaudeUsage(data []byte) usage.Detail {
- root := gjson.ParseBytes(data)
+ root := util.ParseGJSONBytesNoCopy(data)
usageNode := root.Get("usage")
if !usageNode.Exists() {
return usage.Detail{}
@@ -2691,10 +2705,10 @@ func ParseClaudeUsage(data []byte) usage.Detail {
func ParseClaudeStreamUsage(line []byte) (usage.Detail, bool) {
payload := jsonPayload(line)
- if len(payload) == 0 || !bytes.Contains(payload, openAIStreamUsageMarker) || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !bytes.Contains(payload, openAIStreamUsageMarker) || !ojson.ValidBytes(payload) {
return usage.Detail{}, false
}
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
usageNode := root.Get("usage")
if !usageNode.Exists() {
usageNode = root.Get("message.usage")
@@ -2705,7 +2719,7 @@ func ParseClaudeStreamUsage(line []byte) (usage.Detail, bool) {
return parseClaudeUsageNode(usageNode), true
}
-func parseClaudeUsageNode(usageNode gjson.Result) usage.Detail {
+func parseClaudeUsageNode(usageNode ojson.Result) usage.Detail {
cacheReadTokens := usageNode.Get("cache_read_input_tokens").Int()
cacheCreationTokens := usageNode.Get("cache_creation_input_tokens").Int()
rawOutputTokens := usageNode.Get("output_tokens").Int()
@@ -2763,24 +2777,24 @@ func parseClaudeUsageNode(usageNode gjson.Result) usage.Detail {
return detail
}
-func populateClaudeUsageStructuredFields(detail *usage.Detail, usageNode gjson.Result) {
+func populateClaudeUsageStructuredFields(detail *usage.Detail, usageNode ojson.Result) {
if detail == nil || !usageNode.Exists() || !usageNode.IsObject() {
return
}
- if v := usageNode.Get("service_tier"); v.Type == gjson.String {
+ if v := usageNode.Get("service_tier"); v.Type == ojson.String {
detail.UpstreamServiceTier = cloneTrimmedString(v.String())
}
- if v := usageNode.Get("speed"); v.Type == gjson.String {
+ if v := usageNode.Get("speed"); v.Type == ojson.String {
detail.Speed = cloneTrimmedString(v.String())
}
- if v := usageNode.Get("inference_geo"); v.Type == gjson.String {
+ if v := usageNode.Get("inference_geo"); v.Type == ojson.String {
detail.InferenceGeo = cloneTrimmedString(v.String())
}
iterations := parseClaudeIterationsNode(usageNode.Get("iterations"))
detail.Iterations = usage.IterationsPtr(iterations)
cacheDetails := parseClaudeCacheCreationNode(usageNode.Get("cache_creation"))
if cacheDetails == nil && len(iterations) > 0 &&
- (usageNode.Get("cache_creation_input_tokens").Type == gjson.Number || detail.CacheCreationTokens > 0) {
+ (usageNode.Get("cache_creation_input_tokens").Type == ojson.Number || detail.CacheCreationTokens > 0) {
cacheDetails = deriveClaudeCacheCreationFromIterations(iterations, detail.CacheCreationTokens)
}
applyCacheDetailsToUsageDetail(detail, cacheDetails)
@@ -2791,7 +2805,7 @@ func populateClaudeUsageStructuredFields(detail *usage.Detail, usageNode gjson.R
detail.CostDetails = parseClaudeCostNode(usageNode)
}
-func applyClaudeContentBlocksUsage(detail *usage.Detail, contentNode gjson.Result) {
+func applyClaudeContentBlocksUsage(detail *usage.Detail, contentNode ojson.Result) {
if detail == nil || !contentNode.Exists() || !contentNode.IsArray() {
return
}
@@ -2877,35 +2891,35 @@ func applyCacheDetailsToUsageDetail(detail *usage.Detail, cacheDetails *usage.Ca
detail.CacheCreation12hTokens = usage.CloneInt64Ptr(cacheDetails.Ephemeral12hInputTokens)
}
-func parseClaudeCacheCreationNode(node gjson.Result) *usage.CacheDetails {
+func parseClaudeCacheCreationNode(node ojson.Result) *usage.CacheDetails {
if !node.Exists() || !node.IsObject() {
return nil
}
out := &usage.CacheDetails{}
hasAny := false
- node.ForEach(func(key, value gjson.Result) bool {
+ node.ForEach(func(key, value ojson.Result) bool {
k := strings.TrimSpace(key.String())
switch k {
case "ttl":
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
out.TTL = cloneTrimmedString(value.String())
hasAny = out.TTL != "" || hasAny
}
return true
case "mode":
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
out.Mode = cloneTrimmedString(value.String())
hasAny = out.Mode != "" || hasAny
}
return true
case "retention":
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
out.Retention = cloneTrimmedString(value.String())
hasAny = out.Retention != "" || hasAny
}
return true
}
- if value.Type != gjson.Number {
+ if value.Type != ojson.Number {
return true
}
v := value.Int()
@@ -2944,14 +2958,14 @@ func parseClaudeCacheCreationNode(node gjson.Result) *usage.CacheDetails {
return out
}
-func parseClaudeServerToolUseNode(node gjson.Result) *usage.ToolUsageDetails {
+func parseClaudeServerToolUseNode(node ojson.Result) *usage.ToolUsageDetails {
if !node.Exists() || !node.IsObject() {
return nil
}
out := &usage.ToolUsageDetails{}
hasAny := false
- node.ForEach(func(key, value gjson.Result) bool {
- if value.Type != gjson.Number {
+ node.ForEach(func(key, value ojson.Result) bool {
+ if value.Type != ojson.Number {
return true
}
k := strings.TrimSpace(key.String())
@@ -2989,7 +3003,7 @@ func parseClaudeServerToolUseNode(node gjson.Result) *usage.ToolUsageDetails {
return out
}
-func parseClaudeIterationsNode(node gjson.Result) []usage.IterationUsage {
+func parseClaudeIterationsNode(node ojson.Result) []usage.IterationUsage {
if !node.Exists() || !node.IsArray() {
return nil
}
@@ -3003,36 +3017,36 @@ func parseClaudeIterationsNode(node gjson.Result) []usage.IterationUsage {
continue
}
it := usage.IterationUsage{}
- if v := item.Get("type"); v.Type == gjson.String {
+ if v := item.Get("type"); v.Type == ojson.String {
it.Type = cloneTrimmedString(v.String())
}
- if v := item.Get("model"); v.Type == gjson.String {
+ if v := item.Get("model"); v.Type == ojson.String {
it.Model = cloneTrimmedString(v.String())
}
- if v := item.Get("input_tokens"); v.Type == gjson.Number {
+ if v := item.Get("input_tokens"); v.Type == ojson.Number {
it.InputTokens = usage.Int64Ptr(v.Int())
}
- if v := item.Get("output_tokens"); v.Type == gjson.Number {
+ if v := item.Get("output_tokens"); v.Type == ojson.Number {
it.OutputTokens = usage.Int64Ptr(v.Int())
}
- if v := firstExistingUsageNode(item, "output_tokens_details.thinking_tokens", "thinking_tokens"); v.Type == gjson.Number {
+ if v := firstExistingUsageNode(item, "output_tokens_details.thinking_tokens", "thinking_tokens"); v.Type == ojson.Number {
val := v.Int()
if val < 0 {
val = 0
}
it.ThinkingTokens = usage.Int64Ptr(val)
it.ReasoningTokens = usage.Int64Ptr(val)
- } else if v := item.Get("output_tokens_details.reasoning_tokens"); v.Type == gjson.Number {
+ } else if v := item.Get("output_tokens_details.reasoning_tokens"); v.Type == ojson.Number {
val := v.Int()
if val < 0 {
val = 0
}
it.ReasoningTokens = usage.Int64Ptr(val)
}
- if v := item.Get("cache_read_input_tokens"); v.Type == gjson.Number {
+ if v := item.Get("cache_read_input_tokens"); v.Type == ojson.Number {
it.CacheReadInputTokens = usage.Int64Ptr(v.Int())
}
- if v := item.Get("cache_creation_input_tokens"); v.Type == gjson.Number {
+ if v := item.Get("cache_creation_input_tokens"); v.Type == ojson.Number {
it.CacheCreationInputTokens = usage.Int64Ptr(v.Int())
}
it.CacheCreation = parseClaudeCacheCreationNode(item.Get("cache_creation"))
@@ -3111,34 +3125,34 @@ func deriveClaudeCacheCreationFromIterations(iterations []usage.IterationUsage,
return out
}
-func parseClaudeCostNode(usageNode gjson.Result) *usage.CostDetails {
+func parseClaudeCostNode(usageNode ojson.Result) *usage.CostDetails {
costNode := usageNode.Get("cost")
byokNode := usageNode.Get("is_byok")
detailsNode := usageNode.Get("cost_details")
out := &usage.CostDetails{}
hasAny := false
- if costNode.Type == gjson.Number {
+ if costNode.Type == ojson.Number {
out.Cost = usage.Float64Ptr(costNode.Float())
hasAny = true
}
- if ticksNode := usageNode.Get("cost_in_usd_ticks"); ticksNode.Type == gjson.Number {
+ if ticksNode := usageNode.Get("cost_in_usd_ticks"); ticksNode.Type == ojson.Number {
out.CostInUSDTicks = usage.Int64Ptr(ticksNode.Int())
hasAny = true
}
- if byokNode.Type == gjson.True || byokNode.Type == gjson.False {
+ if byokNode.Type == ojson.True || byokNode.Type == ojson.False {
out.IsBYOK = usage.BoolPtr(byokNode.Bool())
hasAny = true
}
if detailsNode.Exists() && detailsNode.IsObject() {
- if v := detailsNode.Get("upstream_inference_cost"); v.Type == gjson.Number {
+ if v := detailsNode.Get("upstream_inference_cost"); v.Type == ojson.Number {
out.UpstreamInferenceCost = usage.Float64Ptr(v.Float())
hasAny = true
}
- if v := detailsNode.Get("upstream_inference_prompt_cost"); v.Type == gjson.Number {
+ if v := detailsNode.Get("upstream_inference_prompt_cost"); v.Type == ojson.Number {
out.UpstreamInferencePromptCost = usage.Float64Ptr(v.Float())
hasAny = true
}
- if v := detailsNode.Get("upstream_inference_completions_cost"); v.Type == gjson.Number {
+ if v := detailsNode.Get("upstream_inference_completions_cost"); v.Type == ojson.Number {
out.UpstreamInferenceCompletionsCost = usage.Float64Ptr(v.Float())
hasAny = true
}
@@ -3149,7 +3163,7 @@ func parseClaudeCostNode(usageNode gjson.Result) *usage.CostDetails {
return out
}
-func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
+func parseGeminiFamilyUsageDetail(node ojson.Result) usage.Detail {
if !node.Exists() || !node.IsObject() {
return usage.Detail{}
}
@@ -3162,12 +3176,12 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
cachedTokens int64
serviceTier string
trafficType string
- promptDetails gjson.Result
- toolUseDetails gjson.Result
- cacheDetails gjson.Result
- candDetails gjson.Result
+ promptDetails ojson.Result
+ toolUseDetails ojson.Result
+ cacheDetails ojson.Result
+ candDetails ojson.Result
)
- node.ForEach(func(key, val gjson.Result) bool {
+ node.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "promptTokenCount", "prompt_token_count":
if promptTokens == 0 {
@@ -3194,11 +3208,11 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
cachedTokens = val.Int()
}
case "serviceTier", "service_tier":
- if serviceTier == "" && val.Type == gjson.String {
+ if serviceTier == "" && val.Type == ojson.String {
serviceTier = cloneTrimmedString(val.String())
}
case "trafficType", "traffic_type":
- if trafficType == "" && val.Type == gjson.String {
+ if trafficType == "" && val.Type == ojson.String {
trafficType = cloneTrimmedString(val.String())
}
case "promptTokensDetails", "prompt_tokens_details":
@@ -3267,11 +3281,11 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
Extra: map[string]int64{"tool_use_prompt_tokens": toolUseTokens},
}
}
- parseModalityList := func(arr gjson.Result, target func(modality string, count int64)) {
+ parseModalityList := func(arr ojson.Result, target func(modality string, count int64)) {
if !arr.Exists() || !arr.IsArray() {
return
}
- arr.ForEach(func(_, entry gjson.Result) bool {
+ arr.ForEach(func(_, entry ojson.Result) bool {
mod := strings.ToUpper(strings.TrimSpace(entry.Get("modality").String()))
if mod == "" {
return true
@@ -3280,7 +3294,7 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
if !cntNode.Exists() {
cntNode = entry.Get("token_count")
}
- if cntNode.Type == gjson.Number {
+ if cntNode.Type == ojson.Number {
target(mod, cntNode.Int())
}
return true
@@ -3327,17 +3341,30 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
}
}
if cacheDetails.Exists() {
- var cachedText int64
- var hasCachedText bool
+ var cachedText, cachedImg, cachedAudio int64
+ var hasCachedText, hasCachedImg, hasCachedAudio bool
parseModalityList(cacheDetails, func(mod string, cnt int64) {
- if mod == "TEXT" {
+ switch mod {
+ case "TEXT":
cachedText += cnt
hasCachedText = true
+ case "IMAGE":
+ cachedImg += cnt
+ hasCachedImg = true
+ case "AUDIO":
+ cachedAudio += cnt
+ hasCachedAudio = true
}
})
if hasCachedText {
detail.CachedTextTokens = usage.Int64Ptr(cachedText)
}
+ if hasCachedImg {
+ detail.CachedImageTokens = usage.Int64Ptr(cachedImg)
+ }
+ if hasCachedAudio {
+ detail.CachedAudioTokens = usage.Int64Ptr(cachedAudio)
+ }
}
if candDetails.Exists() {
var textOut, imgOut, audioOut int64
@@ -3368,7 +3395,7 @@ func parseGeminiFamilyUsageDetail(node gjson.Result) usage.Detail {
return detail
}
-func applyGeminiPayloadArtifacts(detail *usage.Detail, root gjson.Result) {
+func applyGeminiPayloadArtifacts(detail *usage.Detail, root ojson.Result) {
if detail == nil || !root.Exists() || !root.IsObject() {
return
}
@@ -3376,19 +3403,19 @@ func applyGeminiPayloadArtifacts(detail *usage.Detail, root gjson.Result) {
if !candidates.Exists() {
candidates = root.Get("response.candidates")
}
- var preds gjson.Result
+ var preds ojson.Result
if strings.Contains(root.Raw, `"predictions"`) {
preds = root.Get("predictions")
}
applyGeminiNodesArtifacts(detail, candidates, preds)
}
-func applyGeminiNodesArtifacts(detail *usage.Detail, candidates, preds gjson.Result) {
+func applyGeminiNodesArtifacts(detail *usage.Detail, candidates, preds ojson.Result) {
var callCounter geminiFunctionCallCounter
applyGeminiNodesArtifactsWithCounter(detail, candidates, preds, &callCounter)
}
-func applyGeminiNodesArtifactsWithCounter(detail *usage.Detail, candidates, preds gjson.Result, callCounter *geminiFunctionCallCounter) {
+func applyGeminiNodesArtifactsWithCounter(detail *usage.Detail, candidates, preds ojson.Result, callCounter *geminiFunctionCallCounter) {
if detail == nil {
return
}
@@ -3408,25 +3435,25 @@ func applyGeminiNodesArtifactsWithCounter(detail *usage.Detail, candidates, pred
imageOutCount int64
firstImageMime string
)
- candidates.ForEach(func(candPos, cand gjson.Result) bool {
+ candidates.ForEach(func(candPos, cand ojson.Result) bool {
if !cand.IsObject() {
return true
}
candIdx := candPos.Int()
- if idxNode := cand.Get("index"); idxNode.Type == gjson.Number {
+ if idxNode := cand.Get("index"); idxNode.Type == ojson.Number {
candIdx = idxNode.Int()
}
- cand.ForEach(func(ck, cv gjson.Result) bool {
+ cand.ForEach(func(ck, cv ojson.Result) bool {
switch ck.String() {
case "groundingMetadata", "grounding_metadata":
if !cv.IsObject() {
return true
}
- cv.ForEach(func(gk, gv gjson.Result) bool {
+ cv.ForEach(func(gk, gv ojson.Result) bool {
switch gk.String() {
case "webSearchQueries", "web_search_queries":
if gv.IsArray() {
- gv.ForEach(func(_, qItem gjson.Result) bool {
+ gv.ForEach(func(_, qItem ojson.Result) bool {
if strings.TrimSpace(qItem.String()) != "" {
webSearchQueries++
webSearchGrounded = true
@@ -3438,7 +3465,7 @@ func applyGeminiNodesArtifactsWithCounter(detail *usage.Detail, candidates, pred
webSearchGrounded = true
case "groundingChunks", "grounding_chunks":
if gv.IsArray() {
- gv.ForEach(func(_, ch gjson.Result) bool {
+ gv.ForEach(func(_, ch ojson.Result) bool {
switch {
case ch.Get("web").Exists():
webChunks++
@@ -3462,11 +3489,11 @@ func applyGeminiNodesArtifactsWithCounter(detail *usage.Detail, candidates, pred
}
case "content":
if parts := cv.Get("parts"); parts.IsArray() {
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if !part.IsObject() {
return true
}
- part.ForEach(func(pk, pv gjson.Result) bool {
+ part.ForEach(func(pk, pv ojson.Result) bool {
switch pk.String() {
case "executableCode", "executable_code":
codeExecCount++
@@ -3548,20 +3575,20 @@ func applyGeminiNodesArtifactsWithCounter(detail *usage.Detail, candidates, pred
if preds.Exists() && preds.IsArray() {
var count int64
var firstMime string
- preds.ForEach(func(_, pred gjson.Result) bool {
+ preds.ForEach(func(_, pred ojson.Result) bool {
if !pred.IsObject() {
return true
}
var hasImg bool
var mime string
- pred.ForEach(func(pk, pv gjson.Result) bool {
+ pred.ForEach(func(pk, pv ojson.Result) bool {
switch pk.String() {
case "bytesBase64Encoded", "gcsUri":
- if pv.Type == gjson.String && len(pv.Raw) > 2 {
+ if pv.Type == ojson.String && len(pv.Raw) > 2 {
hasImg = true
}
case "mimeType":
- if pv.Type == gjson.String {
+ if pv.Type == ojson.String {
mime = cloneTrimmedString(pv.String())
}
}
@@ -3596,7 +3623,7 @@ func ApplyGeminiRequestImageConfig(detail *usage.Detail, requestPayload []byte)
detail.RequestedImageDetails = ParseRequestImageDetails(requestPayload)
}
-func parseInteractionsUsageDetail(node gjson.Result) usage.Detail {
+func parseInteractionsUsageDetail(node ojson.Result) usage.Detail {
cacheRead := firstExistingUsageNode(node, "cache_read_tokens", "cacheReadTokens")
toolUseTokens := firstExistingUsageNode(node, "tool_use_tokens", "total_tool_use_tokens", "toolUseTokens", "totalToolUseTokens").Int()
inputTokens, okInput := safeUsageTokenSum(
@@ -3636,6 +3663,86 @@ func parseInteractionsUsageDetail(node gjson.Result) usage.Detail {
detail.ReasoningTokens,
detail.TotalTokens,
)
+ parseInteractionsModalities := func(arr ojson.Result, target func(mod string, cnt int64)) {
+ if !arr.Exists() || !arr.IsArray() {
+ return
+ }
+ arr.ForEach(func(_, entry ojson.Result) bool {
+ mod := strings.ToUpper(strings.TrimSpace(entry.Get("modality").String()))
+ if mod == "" {
+ return true
+ }
+ cntNode := firstExistingUsageNode(entry, "tokens", "tokenCount", "token_count")
+ if cntNode.Type == ojson.Number {
+ target(mod, cntNode.Int())
+ }
+ return true
+ })
+ }
+ if inMod := firstExistingUsageNode(node, "input_tokens_by_modality", "inputTokensByModality"); inMod.Exists() {
+ var textIn, imgIn, audioIn, videoIn, docIn int64
+ var hasTextIn, hasImgIn, hasAudioIn, hasVideoIn, hasDocIn bool
+ parseInteractionsModalities(inMod, func(mod string, cnt int64) {
+ switch mod {
+ case "TEXT":
+ textIn += cnt
+ hasTextIn = true
+ case "IMAGE":
+ imgIn += cnt
+ hasImgIn = true
+ case "AUDIO":
+ audioIn += cnt
+ hasAudioIn = true
+ case "VIDEO":
+ videoIn += cnt
+ hasVideoIn = true
+ case "DOCUMENT", "FILE":
+ docIn += cnt
+ hasDocIn = true
+ }
+ })
+ if hasTextIn {
+ detail.TextInputTokens = usage.Int64Ptr(textIn)
+ }
+ if hasImgIn {
+ detail.ImageInputTokens = usage.Int64Ptr(imgIn)
+ }
+ if hasAudioIn {
+ detail.AudioInputTokens = usage.Int64Ptr(audioIn)
+ }
+ if hasVideoIn {
+ detail.VideoInputTokens = usage.Int64Ptr(videoIn)
+ }
+ if hasDocIn {
+ detail.FileInputTokens = usage.Int64Ptr(docIn)
+ }
+ }
+ if cacheMod := firstExistingUsageNode(node, "cached_tokens_by_modality", "cachedTokensByModality"); cacheMod.Exists() {
+ var cachedText, cachedImg, cachedAudio int64
+ var hasCachedText, hasCachedImg, hasCachedAudio bool
+ parseInteractionsModalities(cacheMod, func(mod string, cnt int64) {
+ switch mod {
+ case "TEXT":
+ cachedText += cnt
+ hasCachedText = true
+ case "IMAGE":
+ cachedImg += cnt
+ hasCachedImg = true
+ case "AUDIO":
+ cachedAudio += cnt
+ hasCachedAudio = true
+ }
+ })
+ if hasCachedText {
+ detail.CachedTextTokens = usage.Int64Ptr(cachedText)
+ }
+ if hasCachedImg {
+ detail.CachedImageTokens = usage.Int64Ptr(cachedImg)
+ }
+ if hasCachedAudio {
+ detail.CachedAudioTokens = usage.Int64Ptr(cachedAudio)
+ }
+ }
return detail
}
@@ -3644,7 +3751,7 @@ func hasUsageDetail(detail usage.Detail) bool {
}
func ParseInteractionsUsage(data []byte) usage.Detail {
- root := gjson.ParseBytes(data)
+ root := util.ParseGJSONBytesNoCopy(data)
node := firstExistingUsageNode(root, "usage", "total_usage", "metadata.total_usage", "metadata.usage", "usageMetadata", "usage_metadata", "interaction.usage", "interaction.total_usage", "interaction.metadata.total_usage")
if !node.Exists() {
return usage.Detail{}
@@ -3660,7 +3767,7 @@ func ParseInteractionsUsage(data []byte) usage.Detail {
}
func extractResponseServiceTier(payload []byte) string {
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return ""
}
return extractResponseServiceTierFromValidJSON(payload)
@@ -3668,8 +3775,8 @@ func extractResponseServiceTier(payload []byte) string {
func extractResponseServiceTierFromValidJSON(payload []byte) string {
for _, path := range []string{"response.service_tier", "service_tier", "interaction.service_tier"} {
- if tier := strings.TrimSpace(gjson.GetBytes(payload, path).String()); tier != "" {
- return tier
+ if tier := strings.TrimSpace(ojson.GetBytesNoCopy(payload, path).String()); tier != "" {
+ return strings.Clone(tier)
}
}
return ""
@@ -3680,7 +3787,7 @@ func ParseInteractionsStreamUsage(line []byte) (usage.Detail, bool) {
if len(payload) == 0 {
payload = line
}
- if len(payload) == 0 || !gjson.ValidBytes(payload) {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
return usage.Detail{}, false
}
detail := ParseInteractionsUsage(payload)
@@ -3691,16 +3798,16 @@ func ParseInteractionsStreamUsage(line []byte) (usage.Detail, bool) {
}
func ParseGeminiUsage(data []byte) usage.Detail {
- if len(data) == 0 || !gjson.ValidBytes(data) {
+ if len(data) == 0 || !ojson.ValidBytes(data) {
return usage.Detail{}
}
- root := gjson.Parse(unsafeBytesToString(data))
+ root := util.ParseGJSONBytesNoCopy(data)
var (
- node gjson.Result
- candidatesNode gjson.Result
- predsNode gjson.Result
+ node ojson.Result
+ candidatesNode ojson.Result
+ predsNode ojson.Result
)
- root.ForEach(func(key, val gjson.Result) bool {
+ root.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "usageMetadata", "usage_metadata":
if !node.Exists() {
@@ -3712,7 +3819,7 @@ func ParseGeminiUsage(data []byte) usage.Detail {
predsNode = val
case "response":
if val.IsObject() {
- val.ForEach(func(rk, rv gjson.Result) bool {
+ val.ForEach(func(rk, rv ojson.Result) bool {
switch rk.String() {
case "usageMetadata", "usage_metadata":
if !node.Exists() {
@@ -3746,11 +3853,11 @@ func ParseGeminiStreamUsage(line []byte) (usage.Detail, bool) {
}
hasCamel := bytes.Contains(payload, geminiUsageMetaMarker)
hasSnake := !hasCamel && bytes.Contains(payload, geminiUsageMetaSnakeMarker)
- if (!hasCamel && !hasSnake) || !gjson.ValidBytes(payload) {
+ if (!hasCamel && !hasSnake) || !ojson.ValidBytes(payload) {
return usage.Detail{}, false
}
- root := gjson.Parse(unsafeBytesToString(payload))
- var node gjson.Result
+ root := util.ParseGJSONBytesNoCopy(payload)
+ var node ojson.Result
if hasCamel {
node = root.Get("usageMetadata")
} else {
@@ -3769,14 +3876,14 @@ func ParseGeminiStreamUsage(line []byte) (usage.Detail, bool) {
return detail, true
}
-func firstExistingUsageNode(root gjson.Result, paths ...string) gjson.Result {
+func firstExistingUsageNode(root ojson.Result, paths ...string) ojson.Result {
for _, path := range paths {
node := root.Get(path)
if node.Exists() {
return node
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
func safeUsageTokenSum(values ...int64) (int64, bool) {
@@ -3813,10 +3920,10 @@ func ParseAntigravityStreamUsage(line []byte) (usage.Detail, bool) {
}
hasCamel := bytes.Contains(payload, geminiUsageMetaMarker)
hasSnake := !hasCamel && bytes.Contains(payload, geminiUsageMetaSnakeMarker)
- if (!hasCamel && !hasSnake) || !gjson.ValidBytes(payload) {
+ if (!hasCamel && !hasSnake) || !ojson.ValidBytes(payload) {
return usage.Detail{}, false
}
- root := gjson.Parse(unsafeBytesToString(payload))
+ root := util.ParseGJSONBytesNoCopy(payload)
node := root.Get("response.usageMetadata")
if !node.Exists() && hasCamel {
node = root.Get("usageMetadata")
@@ -3863,7 +3970,7 @@ func FilterSSEUsageMetadata(payload []byte) []byte {
continue
}
rawJSON := bytes.TrimSpace(line[dataIdx+5:])
- traceID := gjson.GetBytes(rawJSON, "traceId").String()
+ traceID := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "traceId").String())
if isStopChunkWithoutUsage(rawJSON) && traceID != "" {
rememberStopWithoutUsage(traceID)
continue
@@ -3914,19 +4021,19 @@ func FilterSSEUsageMetadata(payload []byte) []byte {
// - Antigravity: response.candidates.0.finishReason
func StripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {
jsonBytes := bytes.TrimSpace(rawJSON)
- if len(jsonBytes) == 0 || !bytes.Contains(jsonBytes, geminiUsageMetaMarker) || !gjson.ValidBytes(jsonBytes) {
+ if len(jsonBytes) == 0 || !bytes.Contains(jsonBytes, geminiUsageMetaMarker) || !ojson.ValidBytes(jsonBytes) {
return rawJSON, false
}
- root := gjson.Parse(unsafeBytesToString(jsonBytes))
+ root := util.ParseGJSONBytesNoCopy(jsonBytes)
var (
finishReason string
- topUsage gjson.Result
- respUsage gjson.Result
- topUsageKey gjson.Result
- respUsageKey gjson.Result
+ topUsage ojson.Result
+ respUsage ojson.Result
+ topUsageKey ojson.Result
+ respUsageKey ojson.Result
)
- root.ForEach(func(key, val gjson.Result) bool {
+ root.ForEach(func(key, val ojson.Result) bool {
switch key.String() {
case "usageMetadata":
topUsage = val
@@ -3937,7 +4044,7 @@ func StripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {
}
case "response":
if val.IsObject() {
- val.ForEach(func(rk, rv gjson.Result) bool {
+ val.ForEach(func(rk, rv ojson.Result) bool {
switch rk.String() {
case "usageMetadata":
respUsage = rv
@@ -3976,29 +4083,32 @@ func StripUsageMetadataFromJSON(rawJSON []byte) ([]byte, bool) {
}
}
- cleaned := jsonBytes
+ doc := ojson.NewDoc(jsonBytes)
var changed bool
if topUsage.Exists() {
- cleaned, _ = sjson.SetRawBytes(cleaned, "cpaUsageMetadata", []byte(topUsage.Raw))
- cleaned, _ = sjson.DeleteBytes(cleaned, "usageMetadata")
+ _ = doc.SetRaw("cpaUsageMetadata", topUsage.Raw)
+ _ = doc.Delete("usageMetadata")
changed = true
}
if respUsage.Exists() {
- cleaned, _ = sjson.SetRawBytes(cleaned, "response.cpaUsageMetadata", []byte(respUsage.Raw))
- cleaned, _ = sjson.DeleteBytes(cleaned, "response.usageMetadata")
+ _ = doc.SetRaw("response.cpaUsageMetadata", respUsage.Raw)
+ _ = doc.Delete("response.usageMetadata")
changed = true
}
- return cleaned, changed
+ if !changed {
+ return rawJSON, false
+ }
+ return doc.Bytes(), true
}
func hasUsageMetadata(jsonBytes []byte) bool {
- if len(jsonBytes) == 0 || !gjson.ValidBytes(jsonBytes) {
+ if len(jsonBytes) == 0 || !ojson.ValidBytes(jsonBytes) {
return false
}
- if gjson.GetBytes(jsonBytes, "usageMetadata").Exists() {
+ if ojson.GetBytesNoCopy(jsonBytes, "usageMetadata").Exists() {
return true
}
- if gjson.GetBytes(jsonBytes, "response.usageMetadata").Exists() {
+ if ojson.GetBytesNoCopy(jsonBytes, "response.usageMetadata").Exists() {
return true
}
return false
@@ -4007,12 +4117,12 @@ func hasUsageMetadata(jsonBytes []byte) bool {
var finishReasonMarker = []byte(`"finishReason"`)
func isStopChunkWithoutUsage(jsonBytes []byte) bool {
- if len(jsonBytes) == 0 || !bytes.Contains(jsonBytes, finishReasonMarker) || bytes.Contains(jsonBytes, geminiUsageMetaMarker) || !gjson.ValidBytes(jsonBytes) {
+ if len(jsonBytes) == 0 || !bytes.Contains(jsonBytes, finishReasonMarker) || bytes.Contains(jsonBytes, geminiUsageMetaMarker) || !ojson.ValidBytes(jsonBytes) {
return false
}
- finishReason := gjson.GetBytes(jsonBytes, "candidates.0.finishReason")
+ finishReason := ojson.GetBytesNoCopy(jsonBytes, "candidates.0.finishReason")
if !finishReason.Exists() {
- finishReason = gjson.GetBytes(jsonBytes, "response.candidates.0.finishReason")
+ finishReason = ojson.GetBytesNoCopy(jsonBytes, "response.candidates.0.finishReason")
}
trimmed := strings.TrimSpace(finishReason.String())
if !finishReason.Exists() || trimmed == "" {
diff --git a/internal/runtime/executor/helps/usage_helpers_test.go b/internal/runtime/executor/helps/usage_helpers_test.go
index 48f9fb4cfb9..386cbe16b4e 100644
--- a/internal/runtime/executor/helps/usage_helpers_test.go
+++ b/internal/runtime/executor/helps/usage_helpers_test.go
@@ -14,6 +14,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
@@ -25,7 +26,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
- "github.com/tidwall/gjson"
)
func TestParseOpenAIUsageChatCompletions(t *testing.T) {
@@ -590,8 +590,8 @@ func TestParseInteractionsUsageNormalizesCacheWriteAlias(t *testing.T) {
}
func TestParseInteractionsUsageIncludesToolUseTokens(t *testing.T) {
- detail := ParseInteractionsUsage([]byte(`{"usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_tool_use_tokens":4,"total_tokens":15}}`))
- if detail.InputTokens != 6 || detail.OutputTokens != 6 || detail.ReasoningTokens != 3 || detail.TotalTokens != 15 {
+ detail := ParseInteractionsUsage([]byte(`{"usage":{"total_input_tokens":2,"input_tokens_by_modality":[{"modality":"text","tokens":2}],"total_output_tokens":6,"total_thought_tokens":3,"total_tool_use_tokens":4,"total_tokens":15}}`))
+ if detail.InputTokens != 6 || detail.OutputTokens != 6 || detail.ReasoningTokens != 3 || detail.TotalTokens != 15 || detail.TextInputTokens == nil || *detail.TextInputTokens != 2 {
t.Fatalf("detail = %+v", detail)
}
if !detail.TokenBreakdown.Valid() || detail.TokenBreakdown.Quality != usage.TokenAccountingQualityComplete ||
@@ -1505,6 +1505,20 @@ func TestFailFromErrorsCarriesStreamTruncatedCode(t *testing.T) {
}
}
+func TestFailFromErrorsCarriesRequestScopedCode(t *testing.T) {
+ errRequest := cliproxyauth.NewRequestScopedError("Use /v1/images/generations for forced image generation.", http.StatusBadRequest)
+ for _, err := range []error{errRequest, fmt.Errorf("validate payload: %w", errRequest)} {
+ fail := failFromErrors(err)
+ if fail.Code != usage.FailureRequestScopedCode || fail.StatusCode != http.StatusBadRequest {
+ t.Fatalf("request-scoped classification lost during usage reporting: %+v", fail)
+ }
+ }
+ plain := failFromErrors(&cliproxyauth.Error{HTTPStatus: http.StatusBadRequest, Message: "model_not_found"})
+ if plain.Code != "" {
+ t.Fatalf("upstream model rejection gained request-scoped classification: %+v", plain)
+ }
+}
+
func TestParseClaudeUsageExtractsCacheTTLServerToolsIterationsAndCost(t *testing.T) {
var buffer StreamUsageBuffer
lineStart := []byte(`data: {"type":"message_start","message":{"id":"msg_1","model":"claude-opus-5-5","usage":{"input_tokens":4,"cache_creation_input_tokens":3036,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":3036},"output_tokens":16,"service_tier":"standard","inference_geo":"global","speed":"standard"}}}`)
@@ -1552,13 +1566,13 @@ func TestParseClaudeUsageExtractsCacheTTLServerToolsIterationsAndCost(t *testing
}
func TestParseGeminiAndAntigravityUsageWithModalitiesAndTools(t *testing.T) {
- payload := []byte(`{"candidates":[{"content":{"parts":[{"executableCode":{"language":"PYTHON","code":"print(1)"}},{"functionCall":{"name":"lookup","args":{"q":"a"}}},{"inlineData":{"mimeType":"image/png","data":"AA=="}}]},"groundingMetadata":{"webSearchQueries":["gemini pricing","vertex grounding"],"groundingChunks":[{"web":{"uri":"https://a.example"}},{"web":{"uri":"https://b.example"}},{"retrievedContext":{"uri":"gs://b/doc"}}]},"urlContextMetadata":{"urlMetadata":[{"retrievedUrl":"https://c.example"}]}}],"usageMetadata":{"promptTokenCount":1000,"cachedContentTokenCount":400,"candidatesTokenCount":300,"thoughtsTokenCount":120,"toolUsePromptTokenCount":50,"totalTokenCount":1470,"trafficType":"ON_DEMAND_PRIORITY","promptTokensDetails":[{"modality":"TEXT","tokenCount":800},{"modality":"IMAGE","tokenCount":150},{"modality":"DOCUMENT","tokenCount":50}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":400}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":100},{"modality":"IMAGE","tokenCount":200}]}}`)
+ payload := []byte(`{"candidates":[{"content":{"parts":[{"executableCode":{"language":"PYTHON","code":"print(1)"}},{"functionCall":{"name":"lookup","args":{"q":"a"}}},{"inlineData":{"mimeType":"image/png","data":"AA=="}}]},"groundingMetadata":{"webSearchQueries":["gemini pricing","vertex grounding"],"groundingChunks":[{"web":{"uri":"https://a.example"}},{"web":{"uri":"https://b.example"}},{"retrievedContext":{"uri":"gs://b/doc"}}]},"urlContextMetadata":{"urlMetadata":[{"retrievedUrl":"https://c.example"}]}}],"usageMetadata":{"promptTokenCount":1000,"cachedContentTokenCount":400,"candidatesTokenCount":300,"thoughtsTokenCount":120,"toolUsePromptTokenCount":50,"totalTokenCount":1470,"trafficType":"ON_DEMAND_PRIORITY","promptTokensDetails":[{"modality":"TEXT","tokenCount":800},{"modality":"IMAGE","tokenCount":150},{"modality":"DOCUMENT","tokenCount":50}],"cacheTokensDetails":[{"modality":"TEXT","tokenCount":300},{"modality":"IMAGE","tokenCount":100}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":100},{"modality":"IMAGE","tokenCount":200}]}}`)
detail := ParseGeminiUsage(payload)
ApplyGeminiRequestImageConfig(&detail, []byte(`{"generationConfig":{"imageConfig":{"aspectRatio":"16:9","imageSize":"2K"}}}`))
if detail.InputTokens != 1050 || detail.OutputTokens != 300 || detail.ReasoningTokens != 120 || detail.TotalTokens != 1470 {
t.Fatalf("unexpected Gemini token totals: %+v", detail)
}
- if detail.TextInputTokens == nil || *detail.TextInputTokens != 800 || detail.ImageInputTokens == nil || *detail.ImageInputTokens != 150 || detail.FileInputTokens == nil || *detail.FileInputTokens != 50 || detail.CachedTextTokens == nil || *detail.CachedTextTokens != 400 {
+ if detail.TextInputTokens == nil || *detail.TextInputTokens != 800 || detail.ImageInputTokens == nil || *detail.ImageInputTokens != 150 || detail.FileInputTokens == nil || *detail.FileInputTokens != 50 || detail.CachedTextTokens == nil || *detail.CachedTextTokens != 300 || detail.CachedImageTokens == nil || *detail.CachedImageTokens != 100 {
t.Fatalf("unexpected Gemini input modalities: %+v", detail)
}
if detail.TextOutputTokens == nil || *detail.TextOutputTokens != 100 || detail.ImageOutputTokens == nil || *detail.ImageOutputTokens != 200 {
@@ -1852,9 +1866,9 @@ func TestStripUsageMetadataPreservesToolArguments(t *testing.T) {
prefix = "response."
}
out, changed := StripUsageMetadataFromJSON([]byte(raw))
- if !changed || !gjson.ValidBytes(out) || gjson.GetBytes(out, prefix+"usageMetadata").Exists() ||
- gjson.GetBytes(out, prefix+"cpaUsageMetadata.promptTokenCount").Int() != 5 ||
- gjson.GetBytes(out, prefix+"candidates.0.content.parts.0.functionCall.args.usageMetadata.value").Int() != 1 {
+ if !changed || !ojson.ValidBytes(out) || ojson.GetBytes(out, prefix+"usageMetadata").Exists() ||
+ ojson.GetBytes(out, prefix+"cpaUsageMetadata.promptTokenCount").Int() != 5 ||
+ ojson.GetBytes(out, prefix+"candidates.0.content.parts.0.functionCall.args.usageMetadata.value").Int() != 1 {
t.Fatalf("metadata rename changed the wrong field: %s", out)
}
})
@@ -2078,3 +2092,34 @@ func TestObserveResponsesStreamPreservesOutputItemDoneToolUsageWhenCompletedOutp
t.Fatalf("ObservePluginExecutorStreamUsage lost output_item.done tool usage: %+v", pd.ToolUsageDetails)
}
}
+
+func TestImageDetailsCloneStringsFromReusableBuffer(t *testing.T) {
+ reqBuf := []byte(`{"size":"1024x1024","quality":"high","background":"transparent","output_format":"png","response_format":"b64_json","mime_type":"image/png","input_fidelity":"high"}`)
+ reqDetails := ParseRequestImageDetails(reqBuf)
+ for i := range reqBuf {
+ reqBuf[i] = 'X'
+ }
+ if reqDetails == nil || reqDetails.Size != "1024x1024" || reqDetails.Quality != "high" || reqDetails.Background != "transparent" || reqDetails.OutputFormat != "png" || reqDetails.ResponseFormat != "b64_json" || reqDetails.MimeType != "image/png" || reqDetails.InputFidelity != "high" {
+ t.Fatalf("ParseRequestImageDetails corrupted after buffer overwrite: %+v", reqDetails)
+ }
+
+ toolReqBuf := []byte(`{"tools":[{"type":"image_generation","size":"1536x1024","quality":"medium","background":"opaque","output_format":"webp"}]}`)
+ toolDetails := ParseRequestImageDetails(toolReqBuf)
+ for i := range toolReqBuf {
+ toolReqBuf[i] = 'X'
+ }
+ if toolDetails == nil || toolDetails.Size != "1536x1024" || toolDetails.Quality != "medium" || toolDetails.Background != "opaque" || toolDetails.OutputFormat != "webp" {
+ t.Fatalf("ParseRequestImageDetails (tool) corrupted after buffer overwrite: %+v", toolDetails)
+ }
+
+ streamBuf := []byte(`data: {"type":"image_generation.completed","size":"1024x1536","quality":"high","background":"transparent","output_format":"png","usage":{"input_tokens":5,"output_tokens":10,"total_tokens":15}}`)
+ var b StreamUsageBuffer
+ b.ObserveOpenAIStream(streamBuf)
+ for i := range streamBuf {
+ streamBuf[i] = 'X'
+ }
+ d, ok := b.Detail()
+ if !ok || d.ImageDetails == nil || d.ImageDetails.Size != "1024x1536" || d.ImageDetails.Quality != "high" || d.ImageDetails.Background != "transparent" || d.ImageDetails.OutputFormat != "png" {
+ t.Fatalf("ObserveOpenAIStream ImageDetails corrupted after buffer overwrite: %+v", d.ImageDetails)
+ }
+}
diff --git a/internal/runtime/executor/helps/utls_client.go b/internal/runtime/executor/helps/utls_client.go
index 1b497861a9b..cdf1a63706a 100644
--- a/internal/runtime/executor/helps/utls_client.go
+++ b/internal/runtime/executor/helps/utls_client.go
@@ -328,6 +328,13 @@ func cachedClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
}
func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
+ return newClaudeCodeRoundTripperForDialer(claudeCodeDialer(proxyURL))
+}
+
+// claudeCodeDialer resolves the dialer the Claude Code transport uses for one proxy
+// setting. It is shared by the cached transport and the per-credential pooled one so
+// both keep the same proxy semantics.
+func claudeCodeDialer(proxyURL string) proxy.Dialer {
var dialer proxy.Dialer = proxy.Direct
if proxyURL != "" {
proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL)
@@ -337,7 +344,22 @@ func newClaudeCodeRoundTripper(proxyURL string) http.RoundTripper {
dialer = proxyDialer
}
}
- return newClaudeCodeRoundTripperForDialer(dialer)
+ return dialer
+}
+
+// pooledClaudeCodeTransport returns the credential-scoped Claude Code uTLS transport
+// when the credential opted into pooling, or nil so the caller keeps the historical
+// shared per-proxy transport. Per credential pooling matters here because this
+// transport is otherwise shared by every key that uses the same proxy, which mixes
+// distinct identities on one connection and ignores the key's pool settings.
+func pooledClaudeCodeTransport(proxyURL string, cfg *config.Config, auth *cliproxyauth.Auth) *http.Transport {
+ tr, pooled := pooledTransport(proxyPoolKindClaudeCode, proxyURL, cfg, auth, func() (*http.Transport, error) {
+ return newClaudeCodeRoundTripperForDialer(claudeCodeDialer(proxyURL)), nil
+ })
+ if !pooled {
+ return nil
+ }
+ return tr
}
// newClaudeCodeRoundTripperForDialer builds the Claude Code transport on top
@@ -367,6 +389,7 @@ func newClaudeCodeRoundTripperForDialerAndRoots(dialer proxy.Dialer, roots *x509
if err != nil {
return nil, fmt.Errorf("claude tls: dial upstream: %w", err)
}
+ conn = stampProxyKeepAlive(conn)
host, _, errSplit := net.SplitHostPort(addr)
if errSplit != nil {
@@ -399,20 +422,51 @@ func newClaudeCodeRoundTripperForDialerAndRoots(dialer proxy.Dialer, roots *x509
// fallbackRoundTripper uses provider-specific TLS fingerprints for protected
// HTTPS hosts and falls back to the standard transport for all other requests.
+//
+// Each pooled branch may be resolved lazily on first use: a single request only
+// ever takes one branch, and building both pools up front would spend two cache
+// slots per key (evicting live standard pools with unused Anthropic pools).
type fallbackRoundTripper struct {
- anthropic http.RoundTripper
- chrome http.RoundTripper
- fallback http.RoundTripper
+ mu sync.Mutex
+ anthropic http.RoundTripper
+ anthropicFn func() http.RoundTripper
+ chrome http.RoundTripper
+ fallback http.RoundTripper
+ fallbackFn func() http.RoundTripper
+}
+
+func (f *fallbackRoundTripper) anthropicRoundTripper() http.RoundTripper {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.anthropicFn != nil {
+ if rt := f.anthropicFn(); rt != nil {
+ f.anthropic = rt
+ }
+ f.anthropicFn = nil
+ }
+ return f.anthropic
+}
+
+func (f *fallbackRoundTripper) fallbackRoundTripper() http.RoundTripper {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.fallbackFn != nil {
+ if rt := f.fallbackFn(); rt != nil {
+ f.fallback = rt
+ }
+ f.fallbackFn = nil
+ }
+ return f.fallback
}
func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if IsAnthropicUpstreamURL(req.URL) {
- return f.anthropic.RoundTrip(req)
+ return f.anthropicRoundTripper().RoundTrip(req)
}
if req.URL.Scheme == "https" && strings.EqualFold(req.URL.Hostname(), "chatgpt.com") {
return f.chrome.RoundTrip(req)
}
- return f.fallback.RoundTrip(req)
+ return f.fallbackRoundTripper().RoundTrip(req)
}
// NewUtlsHTTPClient creates an HTTP client using provider-specific TLS
@@ -447,21 +501,48 @@ func NewUtlsHTTPClient(ctx context.Context, cfg *config.Config, auth *cliproxyau
var chromeRT http.RoundTripper = newUtlsRoundTripper(proxyURL)
var anthropicRT http.RoundTripper = cachedClaudeCodeRoundTripper(proxyURL)
var standardTransport http.RoundTripper = http.DefaultTransport
+ var anthropicFn, fallbackFn func() http.RoundTripper
if proxyURL != "" {
- if transport := buildProxyTransport(proxyURL); transport != nil {
- standardTransport = transport
+ fallbackFn = func() http.RoundTripper {
+ if transport := pooledProxyTransport(proxyURL, cfg, auth); transport != nil {
+ return transport
+ }
+ return standardTransport
+ }
+ // The Anthropic path has its own TLS fingerprint, so it needs its own pool kind.
+ anthropicFn = func() http.RoundTripper {
+ if transport := pooledClaudeCodeTransport(proxyURL, cfg, auth); transport != nil {
+ return transport
+ }
+ return anthropicRT
}
} else if ctxRoundTripper != nil {
chromeRT = ctxRoundTripper
anthropicRT = ctxRoundTripper
standardTransport = ctxRoundTripper
+ } else {
+ // A credential opt-in applies here too; an injected RoundTripper keeps priority.
+ fallbackFn = func() http.RoundTripper {
+ if transport := pooledProxyTransport("", cfg, auth); transport != nil {
+ return transport
+ }
+ return standardTransport
+ }
+ anthropicFn = func() http.RoundTripper {
+ if transport := pooledClaudeCodeTransport("", cfg, auth); transport != nil {
+ return transport
+ }
+ return anthropicRT
+ }
}
client := &http.Client{
Transport: &fallbackRoundTripper{
- anthropic: anthropicRT,
- chrome: chromeRT,
- fallback: standardTransport,
+ anthropic: anthropicRT,
+ anthropicFn: anthropicFn,
+ chrome: chromeRT,
+ fallback: standardTransport,
+ fallbackFn: fallbackFn,
},
}
if timeout > 0 {
diff --git a/internal/runtime/executor/helps/utls_proxy_pool_test.go b/internal/runtime/executor/helps/utls_proxy_pool_test.go
new file mode 100644
index 00000000000..e5f3ec3fe7a
--- /dev/null
+++ b/internal/runtime/executor/helps/utls_proxy_pool_test.go
@@ -0,0 +1,155 @@
+package helps
+
+import (
+ "context"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "sync/atomic"
+ "testing"
+
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+func TestUtlsFallbackProxyConnectionPool(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ t.Run(map[bool]string{false: "disabled", true: "enabled"}[enabled], func(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ var connections atomic.Int32
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }))
+ server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
+ if state == http.StateNew {
+ connections.Add(1)
+ }
+ }
+ server.Start()
+ defer server.Close()
+ cfg := testConfig()
+ auth := &cliproxyauth.Auth{ProxyURL: "direct"}
+ if enabled {
+ auth.ProxyConnectionPool = poolEnabled()
+ }
+ for i := 0; i < 2; i++ {
+ client := NewUtlsHTTPClient(context.Background(), cfg, auth, 0)
+ resp, err := client.Get(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = io.Copy(io.Discard, resp.Body); err != nil {
+ t.Fatal(err)
+ }
+ if err = resp.Body.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ want := int32(2)
+ if enabled {
+ want = 1
+ }
+ if got := connections.Load(); got != want {
+ t.Fatalf("connections = %d, want %d", got, want)
+ }
+ })
+ }
+}
+
+// TestUtlsAnthropicTransportPoolsPerCredential pins the Anthropic fingerprint path:
+// it is built by its own pool kind, so the credential's settings apply and two keys
+// never share one connection. Previously this transport was always the shared
+// per-proxy cache entry, which mixed distinct identities on one TLS connection and
+// ignored max-idle-conns-per-host.
+func TestUtlsAnthropicTransportPoolsPerCredential(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+ cfg := testConfig()
+ anthropicTransport := func(id string, override *cliproxyauth.ProxyConnectionPoolOverride) *http.Transport {
+ t.Helper()
+ client := NewUtlsHTTPClient(context.Background(), cfg, poolAuth(id, override), 0)
+ fallback, ok := client.Transport.(*fallbackRoundTripper)
+ if !ok {
+ t.Fatalf("client transport = %T, want *fallbackRoundTripper", client.Transport)
+ }
+ transport, ok := fallback.anthropicRoundTripper().(*http.Transport)
+ if !ok {
+ t.Fatalf("anthropic transport = %T, want *http.Transport", fallback.anthropicRoundTripper())
+ }
+ return transport
+ }
+
+ first := anthropicTransport("key-a", poolBlock())
+ if first.IdleConnTimeout != proxyPoolDefaultIdleConnTimeout || first.MaxIdleConnsPerHost != proxyPoolDefaultMaxIdleConnsPerHost {
+ t.Fatalf("pool defaults not applied to the Anthropic transport: %+v", first)
+ }
+ if got := anthropicTransport("key-a", poolBlock()); got != first {
+ t.Fatal("credential did not reuse its own Anthropic pool")
+ }
+ if second := anthropicTransport("key-b", poolBlock()); second == first {
+ t.Fatal("two credentials share one Anthropic transport")
+ }
+
+ // Zero bounds disable keep-alives on the fingerprint transport too.
+ zero := 0
+ if transport := anthropicTransport("key-a", &cliproxyauth.ProxyConnectionPoolOverride{MaxIdleConnsPerHost: &zero}); !transport.DisableKeepAlives {
+ t.Fatal("max-idle-conns-per-host: 0 must disable keep-alives on the Anthropic transport")
+ }
+
+ // Credentials that did not opt in keep the historical shared transport.
+ plain := func(id string) http.RoundTripper {
+ t.Helper()
+ client := NewUtlsHTTPClient(context.Background(), cfg, &cliproxyauth.Auth{ID: id}, 0)
+ return client.Transport.(*fallbackRoundTripper).anthropicRoundTripper()
+ }
+ if plain("key-a") != plain("key-b") {
+ t.Fatal("unpooled credentials must keep sharing the cached Claude Code transport")
+ }
+}
+
+// TestUtlsLazyPoolsDoNotEvictUsedPool pins that a request only populates the pool
+// kind it actually uses. Eagerly populating both the standard and Anthropic pools
+// consumed two cache slots per key, so 300 non-Anthropic keys exceeded the 512-slot
+// cache and evicted their own live pools on every second pass.
+func TestUtlsLazyPoolsDoNotEvictUsedPool(t *testing.T) {
+ ResetProxyTransports()
+ t.Cleanup(ResetProxyTransports)
+
+ var opened atomic.Int32
+ server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte("ok"))
+ }))
+ server.Config.ConnState = func(_ net.Conn, state http.ConnState) {
+ if state == http.StateNew {
+ opened.Add(1)
+ }
+ }
+ server.Start()
+ defer server.Close()
+
+ cfg := testConfig()
+ const keys = 300
+ for round := 0; round < 2; round++ {
+ for i := 0; i < keys; i++ {
+ auth := poolOptInAuth("key-" + strconv.Itoa(i))
+ auth.ProxyURL = "direct"
+ client := NewUtlsHTTPClient(context.Background(), cfg, auth, 0)
+ resp, err := client.Get(server.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = io.Copy(io.Discard, resp.Body); err != nil {
+ t.Fatal(err)
+ }
+ if err = resp.Body.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if got := proxyTransportCache.Len(); got != keys {
+ t.Fatalf("cache holds %d entries, want %d (one per key, no unused Anthropic pools)", got, keys)
+ }
+ if got := opened.Load(); got != keys {
+ t.Fatalf("connections = %d, want %d (second round must reuse every key's pool)", got, keys)
+ }
+}
diff --git a/internal/runtime/executor/helps/vertex_openapi.go b/internal/runtime/executor/helps/vertex_openapi.go
index 465025d6023..77a58fa50be 100644
--- a/internal/runtime/executor/helps/vertex_openapi.go
+++ b/internal/runtime/executor/helps/vertex_openapi.go
@@ -7,11 +7,12 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// IsVertexOpenAPIModel reports whether the given model identifier targets a
@@ -116,11 +117,11 @@ func ShouldExecuteVertexOpenAPIForRequest(cfg *config.Config, auth *cliproxyauth
}
requestedModel := PayloadRequestedModel(opts, "")
if requestedModel == "" {
- requestedModel = strings.TrimSpace(gjson.GetBytes(opts.OriginalRequest, "model").String())
+ requestedModel = strings.TrimSpace(ojson.GetBytesNoCopy(opts.OriginalRequest, "model").String())
}
if requestedModel == "" && len(opts.Metadata) > 0 {
if data, errMarshal := json.Marshal(opts.Metadata); errMarshal == nil {
- requestedModel = strings.TrimSpace(gjson.GetBytes(data, "model").String())
+ requestedModel = strings.TrimSpace(ojson.GetBytesNoCopy(data, "model").String())
}
}
requestedModel = stripVertexAuthPrefix(requestedModel, auth)
@@ -204,7 +205,7 @@ func isVertexOAuthCredential(auth *cliproxyauth.Auth) bool {
func vertexModelOpenAPIRules(data []byte) []config.OAuthModelAlias {
var rules []config.OAuthModelAlias
- for _, item := range gjson.ParseBytes(data).Array() {
+ for _, item := range util.ParseGJSONBytesNoCopy(data).Array() {
var rule config.OAuthModelAlias
if errUnmarshal := json.Unmarshal([]byte(item.Raw), &rule); errUnmarshal == nil && (strings.TrimSpace(rule.Name) != "" || strings.TrimSpace(rule.Alias) != "") {
rules = append(rules, rule)
diff --git a/internal/runtime/executor/helps/vertex_payload_helpers.go b/internal/runtime/executor/helps/vertex_payload_helpers.go
index e289b038963..355b4b5b900 100644
--- a/internal/runtime/executor/helps/vertex_payload_helpers.go
+++ b/internal/runtime/executor/helps/vertex_payload_helpers.go
@@ -1,86 +1,53 @@
package helps
import (
+ "fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// StripVertexOpenAIResponsesToolCallIDs removes OpenAI Responses call IDs that
// Vertex rejects in Gemini functionCall/functionResponse payloads.
func StripVertexOpenAIResponsesToolCallIDs(payload []byte, sourceFormat string) []byte {
- if !strings.EqualFold(strings.TrimSpace(sourceFormat), "openai-response") {
+ if len(payload) == 0 || !strings.EqualFold(strings.TrimSpace(sourceFormat), "openai-response") {
return payload
}
-
- contents := util.GetGJSONBytesNoCopy(payload, "contents")
- if !contents.IsArray() || !vertexContentsHaveToolCallIDs(contents) {
+ doc := ojson.NewDoc(payload)
+ if !StripVertexOpenAIResponsesToolCallIDsDoc(doc, sourceFormat) {
return payload
}
+ return doc.Bytes()
+}
- contentsChanged := false
- contentItems := make([][]byte, 0, int(contents.Get("#").Int()))
- contents.ForEach(func(_, content gjson.Result) bool {
+// StripVertexOpenAIResponsesToolCallIDsDoc removes OpenAI Responses call IDs that
+// Vertex rejects in Gemini functionCall/functionResponse payloads in place on doc.
+func StripVertexOpenAIResponsesToolCallIDsDoc(doc *ojson.Doc, sourceFormat string) bool {
+ if doc == nil || doc.Len() == 0 || !strings.EqualFold(strings.TrimSpace(sourceFormat), "openai-response") {
+ return false
+ }
+
+ contents := doc.Get("contents")
+ if !contents.IsArray() {
+ return false
+ }
+
+ changed := false
+ for cIdx, content := range contents.Array() {
parts := content.Get("parts")
if !parts.IsArray() {
- contentItems = append(contentItems, []byte(content.Raw))
- return true
+ continue
}
-
- partsChanged := false
- partItems := make([][]byte, 0, int(parts.Get("#").Int()))
- parts.ForEach(func(_, part gjson.Result) bool {
- partJSON := []byte(part.Raw)
- for _, path := range []string{"functionCall.id", "functionResponse.id"} {
- if !part.Get(path).Exists() {
+ for pIdx, part := range parts.Array() {
+ for _, field := range []string{"functionCall.id", "functionResponse.id"} {
+ if !part.Get(field).Exists() {
continue
}
- updated, errDelete := sjson.DeleteBytes(partJSON, path)
- if errDelete == nil {
- partJSON = updated
- partsChanged = true
+ if errDel := doc.Delete(fmt.Sprintf("contents.%d.parts.%d.%s", cIdx, pIdx, field)); errDel == nil {
+ changed = true
}
}
- partItems = append(partItems, partJSON)
- return true
- })
-
- contentJSON := []byte(content.Raw)
- if partsChanged {
- updated, errSet := sjson.SetRawBytes(contentJSON, "parts", JoinRawJSONArray(partItems))
- if errSet == nil {
- contentJSON = updated
- contentsChanged = true
- }
}
- contentItems = append(contentItems, contentJSON)
- return true
- })
- if !contentsChanged {
- return payload
}
-
- updated, errSet := sjson.SetRawBytes(payload, "contents", JoinRawJSONArray(contentItems))
- if errSet != nil {
- return payload
- }
- return updated
-}
-
-func vertexContentsHaveToolCallIDs(contents gjson.Result) bool {
- hasIDs := false
- contents.ForEach(func(_, content gjson.Result) bool {
- parts := content.Get("parts")
- if !parts.IsArray() {
- return true
- }
- parts.ForEach(func(_, part gjson.Result) bool {
- hasIDs = part.Get("functionCall.id").Exists() || part.Get("functionResponse.id").Exists()
- return !hasIDs
- })
- return !hasIDs
- })
- return hasIDs
+ return changed
}
diff --git a/internal/runtime/executor/helps/vertex_payload_helpers_test.go b/internal/runtime/executor/helps/vertex_payload_helpers_test.go
index f21217d3613..171dccc8441 100644
--- a/internal/runtime/executor/helps/vertex_payload_helpers_test.go
+++ b/internal/runtime/executor/helps/vertex_payload_helpers_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestStripVertexToolCallIDsReusesPayloadWithoutIDs(t *testing.T) {
@@ -18,16 +18,16 @@ func TestStripVertexToolCallIDsReusesPayloadWithoutIDs(t *testing.T) {
func TestStripVertexToolCallIDsRebuildsContentsOnce(t *testing.T) {
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call_1","name":"lookup","args":{"id":9007199254740993}}}]},{"role":"user","parts":[{"functionResponse":{"id":"call_1","name":"lookup","response":{"id":"keep"}}}]}]}`)
output := StripVertexOpenAIResponsesToolCallIDs(input, "openai-response")
- if gjson.GetBytes(output, "contents.0.parts.0.functionCall.id").Exists() {
+ if ojson.GetBytes(output, "contents.0.parts.0.functionCall.id").Exists() {
t.Fatal("functionCall.id was not removed")
}
- if gjson.GetBytes(output, "contents.1.parts.0.functionResponse.id").Exists() {
+ if ojson.GetBytes(output, "contents.1.parts.0.functionResponse.id").Exists() {
t.Fatal("functionResponse.id was not removed")
}
- if got := gjson.GetBytes(output, "contents.1.parts.0.functionResponse.response.id").String(); got != "keep" {
+ if got := ojson.GetBytes(output, "contents.1.parts.0.functionResponse.response.id").String(); got != "keep" {
t.Fatalf("nested response id = %q, want keep", got)
}
- if got := gjson.GetBytes(output, "contents.0.parts.0.functionCall.args.id").Raw; got != "9007199254740993" {
+ if got := ojson.GetBytes(output, "contents.0.parts.0.functionCall.args.id").Raw; got != "9007199254740993" {
t.Fatalf("large integer = %s, want exact original value", got)
}
}
diff --git a/internal/runtime/executor/helps/websocket_observer_helpers.go b/internal/runtime/executor/helps/websocket_observer_helpers.go
index afb25c368c0..2d1b3b3a088 100644
--- a/internal/runtime/executor/helps/websocket_observer_helpers.go
+++ b/internal/runtime/executor/helps/websocket_observer_helpers.go
@@ -2,10 +2,11 @@ package helps
import (
"context"
+ "strings"
+ "github.com/MoeForge/moego/ojson"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// EmitWebSocketResponseEvent delivers an upstream WebSocket response frame to any configured observer.
@@ -19,7 +20,7 @@ func EmitWebSocketResponseEvent(ctx context.Context, opts cliproxyexecutor.Optio
authLabel = auth.Label
authType, _ = auth.AccountInfo()
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := strings.Clone(ojson.GetBytesNoCopy(payload, "type").String())
var requestID string
var traceID string
if opts.Metadata != nil {
diff --git a/internal/runtime/executor/helps/xai_version.go b/internal/runtime/executor/helps/xai_version.go
index f6d1e83fb03..3b909c51136 100644
--- a/internal/runtime/executor/helps/xai_version.go
+++ b/internal/runtime/executor/helps/xai_version.go
@@ -11,9 +11,9 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
const (
@@ -153,7 +153,7 @@ func FetchXAINPMLatestVersion(ctx context.Context, client *http.Client) (string,
return "", fmt.Errorf("read npm registry response: %w", errRead)
}
- version := strings.TrimSpace(gjson.GetBytes(body, "version").String())
+ version := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(body, "version").String()))
if version == "" {
return "", errors.New("version not found in npm response")
}
diff --git a/internal/runtime/executor/issue6258_terminal_test.go b/internal/runtime/executor/issue6258_terminal_test.go
index dedc38bbbeb..b4447bfdb55 100644
--- a/internal/runtime/executor/issue6258_terminal_test.go
+++ b/internal/runtime/executor/issue6258_terminal_test.go
@@ -13,11 +13,11 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const issue6258ExecutorContent = `{"responseId":"executor-6258","candidates":[{"content":{"parts":[{"text":"answer"}]}}]}`
@@ -92,11 +92,11 @@ func issue6258StartStream(t *testing.T, ctx context.Context, executor issue6258S
return stream.Chunks
}
-func issue6258Events(payload []byte) []gjson.Result {
- var events []gjson.Result
+func issue6258Events(payload []byte) []ojson.Result {
+ var events []ojson.Result
for _, line := range strings.Split(string(payload), "\n") {
if strings.HasPrefix(line, "data:") {
- event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ event := ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
if event.Get("type").Exists() {
events = append(events, event)
}
@@ -105,9 +105,9 @@ func issue6258Events(payload []byte) []gjson.Result {
return events
}
-func issue6258ExecutorTerminal(t *testing.T, chunks <-chan cliproxyexecutor.StreamChunk) gjson.Result {
+func issue6258ExecutorTerminal(t *testing.T, chunks <-chan cliproxyexecutor.StreamChunk) ojson.Result {
t.Helper()
- var response gjson.Result
+ var response ojson.Result
terminals := 0
done := map[string]int{}
for chunk := range chunks {
diff --git a/internal/runtime/executor/issue6478_tool_ids_edges_test.go b/internal/runtime/executor/issue6478_tool_ids_edges_test.go
index 77df58445c5..eeb95e98299 100644
--- a/internal/runtime/executor/issue6478_tool_ids_edges_test.go
+++ b/internal/runtime/executor/issue6478_tool_ids_edges_test.go
@@ -10,12 +10,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestIssue6478_DevinReservedPrefixStatelessFragmentedRoundTrip(t *testing.T) {
@@ -63,7 +63,7 @@ func TestIssue6478_DevinReservedPrefixStatelessFragmentedRoundTrip(t *testing.T)
t.Fatal(chunk.Err)
}
for _, line := range strings.Split(string(chunk.Payload), "\n") {
- event := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ event := ojson.Parse(strings.TrimPrefix(line, "data: "))
if event.Get("content_block.type").String() == "tool_use" {
ids = append(ids, event.Get("content_block.id").String())
arguments = append(arguments, "")
@@ -78,7 +78,7 @@ func TestIssue6478_DevinReservedPrefixStatelessFragmentedRoundTrip(t *testing.T)
if errExecute != nil {
t.Fatal(errExecute)
}
- for _, block := range gjson.GetBytes(result.Payload, "content").Array() {
+ for _, block := range ojson.GetBytes(result.Payload, "content").Array() {
if block.Get("type").String() == "tool_use" {
ids = append(ids, block.Get("id").String())
arguments = append(arguments, block.Get("input").Raw)
@@ -97,7 +97,7 @@ func TestIssue6478_DevinReservedPrefixStatelessFragmentedRoundTrip(t *testing.T)
if i < 3 && id == nativeIDs[i] {
t.Errorf("illegal or reserved native ID[%d] was not escaped: %q", i, id)
}
- if gjson.Get(arguments[i], "literal_id").String() != "Write:1#abc" {
+ if ojson.Get(arguments[i], "literal_id").String() != "Write:1#abc" {
t.Errorf("fragmented arguments corrupted: %s", arguments[i])
}
}
@@ -128,10 +128,10 @@ func TestIssue6478_DevinReservedPrefixStatelessFragmentedRoundTrip(t *testing.T)
t.Fatal(errDecode)
}
for i, id := range nativeIDs {
- if got := gjson.GetBytes(body, "prompts.1.tool_calls").Array()[i].Get("id").String(); got != id {
+ if got := ojson.GetBytes(body, "prompts.1.tool_calls").Array()[i].Get("id").String(); got != id {
t.Errorf("stateless call[%d] = %q, want original %q", i, got, id)
}
- if got := gjson.GetBytes(body, "prompts").Array()[i+2].Get("tool_call_id").String(); got != id {
+ if got := ojson.GetBytes(body, "prompts").Array()[i+2].Get("tool_call_id").String(); got != id {
t.Errorf("stateless result[%d] = %q, want original %q", i, got, id)
}
}
@@ -146,8 +146,8 @@ func TestIssue6478_ClaudeLegacyEncodingCollision(t *testing.T) {
ids := []string{issue6478Calls[0].id, issue6478Calls[1].id, "cpa_tid_v1_V3JpdGU6MSNlMWYwZGE4MTE0MzY0OWYzYTM3OGQyNGFmNmZjM2VjOQ", "call_8jAZYBK5veMdr1DD2DqObARK"}
for _, stream := range []bool{false, true} {
body := executeClaudeContextManagementRequest(t, &config.Config{}, issue6478History(ids), stream)
- uses := gjson.GetBytes(body, "messages.1.content").Array()
- results := gjson.GetBytes(body, "messages.2.content").Array()
+ uses := ojson.GetBytes(body, "messages.1.content").Array()
+ results := ojson.GetBytes(body, "messages.2.content").Array()
if len(uses) != 4 || len(results) != 4 {
t.Fatalf("history lost calls/results: %s", body)
}
@@ -167,7 +167,7 @@ func TestIssue6478_ClaudeLegacyEncodingCollision(t *testing.T) {
}
// Repairing a repaired transcript must be byte-stable at schema ID fields.
again := executeClaudeContextManagementRequest(t, &config.Config{}, body, stream)
- if gjson.GetBytes(again, "messages.1.content").Raw != gjson.GetBytes(body, "messages.1.content").Raw {
+ if ojson.GetBytes(again, "messages.1.content").Raw != ojson.GetBytes(body, "messages.1.content").Raw {
t.Errorf("history ID repair is not idempotent")
}
}
@@ -202,12 +202,12 @@ func TestIssue6478_ClaudeUpstreamCountTokensRepairBeforePayload(t *testing.T) {
t.Fatal(errCount)
}
if configured {
- if gjson.GetBytes(body, "tools.0.description").String() != "observed repaired ID" || gjson.GetBytes(body, "messages.1.content.0.id").String() != "configured:id#override" || gjson.GetBytes(body, "messages.2.content.0.tool_use_id").String() != "configured:id#override" {
+ if ojson.GetBytes(body, "tools.0.description").String() != "observed repaired ID" || ojson.GetBytes(body, "messages.1.content.0.id").String() != "configured:id#override" || ojson.GetBytes(body, "messages.2.content.0.tool_use_id").String() != "configured:id#override" {
t.Errorf("count_tokens repair must precede final user rules: %s", body)
}
} else {
- useIDs := gjson.GetBytes(body, "messages.1.content.#.id").Array()
- resultIDs := gjson.GetBytes(body, "messages.2.content.#.tool_use_id").Array()
+ useIDs := ojson.GetBytes(body, "messages.1.content.#.id").Array()
+ resultIDs := ojson.GetBytes(body, "messages.2.content.#.tool_use_id").Array()
if len(useIDs) != 4 || len(resultIDs) != 4 {
t.Fatalf("count_tokens lost history calls/results: %s", body)
}
@@ -238,7 +238,7 @@ func TestIssue6478_DevinDoesNotDecodeOrdinaryNativeIDs(t *testing.T) {
}
}()
for i, id := range ids {
- got := gjson.GetBytes(body, "prompts.1.tool_calls").Array()[i].Get("id").String()
+ got := ojson.GetBytes(body, "prompts.1.tool_calls").Array()[i].Get("id").String()
if got != id {
t.Errorf("ordinary native ID was misdecoded: %q, want %q", got, id)
}
diff --git a/internal/runtime/executor/issue6478_tool_ids_test.go b/internal/runtime/executor/issue6478_tool_ids_test.go
index 0ba31654ab5..93ea8771a59 100644
--- a/internal/runtime/executor/issue6478_tool_ids_test.go
+++ b/internal/runtime/executor/issue6478_tool_ids_test.go
@@ -9,12 +9,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
var issue6478Calls = []struct {
@@ -113,7 +113,7 @@ func TestIssue6478_DevinClaudeOutputAndToolResultRoundTrip(t *testing.T) {
t.Fatalf("stream chunk: %v", chunk.Err)
}
for _, line := range strings.Split(string(chunk.Payload), "\n") {
- event := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ event := ojson.Parse(strings.TrimPrefix(line, "data: "))
if event.Get("type").String() == "content_block_start" && event.Get("content_block.type").String() == "tool_use" {
ids = append(ids, event.Get("content_block.id").String())
}
@@ -124,7 +124,7 @@ func TestIssue6478_DevinClaudeOutputAndToolResultRoundTrip(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute: %v", errExecute)
}
- for _, block := range gjson.GetBytes(result.Payload, "content").Array() {
+ for _, block := range ojson.GetBytes(result.Payload, "content").Array() {
if block.Get("type").String() == "tool_use" {
ids = append(ids, block.Get("id").String())
}
@@ -151,11 +151,11 @@ func TestIssue6478_DevinClaudeOutputAndToolResultRoundTrip(t *testing.T) {
if errDecode != nil {
t.Fatalf("decode sent Devin payload: %v", errDecode)
}
- if gjson.GetBytes(native, "system_prompt").String() != "observed original Devin IDs" {
+ if ojson.GetBytes(native, "system_prompt").String() != "observed original Devin IDs" {
t.Errorf("Devin payload condition did not observe original call/result IDs before final barrier")
}
var sentCalls, sentResults []string
- for _, prompt := range gjson.GetBytes(native, "prompts").Array() {
+ for _, prompt := range ojson.GetBytes(native, "prompts").Array() {
for _, call := range prompt.Get("tool_calls").Array() {
sentCalls = append(sentCalls, call.Get("id").String())
}
@@ -189,7 +189,7 @@ func TestIssue6478_ClaudeReplaysLegacyToolIDs(t *testing.T) {
t.Run(name, func(t *testing.T) {
body := executeClaudeContextManagementRequest(t, &config.Config{}, issue6478History(legacyIDs), stream)
var useIDs, resultIDs []string
- for _, message := range gjson.GetBytes(body, "messages").Array() {
+ for _, message := range ojson.GetBytes(body, "messages").Array() {
for _, block := range message.Get("content").Array() {
switch block.Get("type").String() {
case "tool_use":
@@ -245,16 +245,16 @@ func TestIssue6478_ClaudeToolIDRepairPrecedesPayloadRules(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
body := executeClaudeContextManagementRequest(t, cfg, issue6478History(legacyIDs), stream)
- if gjson.GetBytes(body, "temperature").Float() != 0.25 {
+ if ojson.GetBytes(body, "temperature").Float() != 0.25 {
t.Errorf("payload condition did not observe repaired tool ID before final barrier")
}
for _, path := range []string{"messages.1.content.0.id", "messages.2.content.0.tool_use_id"} {
- if got := gjson.GetBytes(body, path).String(); got != "configured:id#override" {
+ if got := ojson.GetBytes(body, path).String(); got != "configured:id#override" {
t.Errorf("final user override at %s was changed: got %q", path, got)
}
}
for _, path := range []string{"messages.1.content.1.id", "messages.2.content.1.tool_use_id"} {
- if gjson.GetBytes(body, path).Exists() {
+ if ojson.GetBytes(body, path).Exists() {
t.Errorf("final user filter at %s was undone", path)
}
}
diff --git a/internal/runtime/executor/kimi_executor.go b/internal/runtime/executor/kimi_executor.go
index 56ed89c3ab6..d0b0cd468b3 100644
--- a/internal/runtime/executor/kimi_executor.go
+++ b/internal/runtime/executor/kimi_executor.go
@@ -13,6 +13,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
kimiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v8/internal/buildinfo"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -23,8 +24,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const kimiReasoningUnavailable = "[reasoning unavailable]"
@@ -148,27 +147,26 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
// Strip kimi- prefix and any [1m] suffix for upstream API
upstreamModel := normalizeKimiUpstreamModel(baseModel)
reporter.SetUpstreamModel(upstreamModel)
- body, err = sjson.SetBytes(body, "model", upstreamModel)
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = doc.Set("model", upstreamModel); err != nil {
return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), "kimi", e.Identifier())
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), "kimi", e.Identifier()); err != nil {
return resp, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body, err = normalizeKimiToolMessageLinks(body)
- if err != nil {
+ if err = normalizeKimiToolMessageLinksDoc(doc); err != nil {
return resp, err
}
- body = normalizeKimiTools(body)
- body = normalizeKimiTemperature(body)
+ normalizeKimiToolsDoc(doc)
+ normalizeKimiTemperatureDoc(doc)
url := helps.ResolveKimiChatURL(auth)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, e.Identifier())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -286,31 +284,29 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
// Strip kimi- prefix and any [1m] suffix for upstream API
upstreamModel := normalizeKimiUpstreamModel(baseModel)
reporter.SetUpstreamModel(upstreamModel)
- body, err = sjson.SetBytes(body, "model", upstreamModel)
- if err != nil {
+ doc := ojson.NewDoc(body)
+ if err = doc.Set("model", upstreamModel); err != nil {
return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", err)
}
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), "kimi", e.Identifier())
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), "kimi", e.Identifier()); err != nil {
return nil, err
}
- body, err = sjson.SetBytes(body, "stream_options.include_usage", true)
- if err != nil {
+ if err = doc.Set("stream_options.include_usage", true); err != nil {
return nil, fmt.Errorf("kimi executor: failed to set stream_options in payload: %w", err)
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body, err = normalizeKimiToolMessageLinks(body)
- if err != nil {
+ if err = normalizeKimiToolMessageLinksDoc(doc); err != nil {
return nil, err
}
- body = normalizeKimiTools(body)
- body = normalizeKimiTemperature(body)
+ normalizeKimiToolsDoc(doc)
+ normalizeKimiTemperatureDoc(doc)
url := helps.ResolveKimiChatURL(auth)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, e.Identifier())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
@@ -439,40 +435,33 @@ func (e *KimiExecutor) executeResponses(ctx context.Context, auth *cliproxyauth.
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
- body := bytes.Clone(req.Payload)
+ doc := ojson.NewDoc(req.Payload)
upstreamModel := normalizeKimiUpstreamModel(baseModel)
reporter.SetUpstreamModel(upstreamModel)
- var errSet error
- body, errSet = sjson.SetBytes(body, "model", upstreamModel)
- if errSet != nil {
+ if errSet := doc.Set("model", upstreamModel); errSet != nil {
return resp, fmt.Errorf("kimi executor: failed to set model in payload: %w", errSet)
}
- body = helps.SetBoolIfDifferent(body, "stream", false)
+ helps.SetBoolIfDifferentDoc(doc, "stream", false)
- var errThinking error
- body, errThinking = helps.ApplyRequestThinking(body, req, opts, opts.SourceFormat.String(), sdktranslator.FormatCodex.String(), e.Identifier())
- if errThinking != nil {
+ if errThinking := helps.ApplyRequestThinkingDoc(doc, req, opts, opts.SourceFormat.String(), sdktranslator.FormatCodex.String(), e.Identifier()); errThinking != nil {
return resp, errThinking
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- var errNormalizePatch error
- body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body)
- if errNormalizePatch != nil {
+ if errNormalizePatch := helps.NormalizeApplyPatchResponsesRequestDoc(doc); errNormalizePatch != nil {
return resp, errNormalizePatch
}
- var errNormalizeInput error
- body, errNormalizeInput = helps.NormalizeKimiResponsesInput(body)
- if errNormalizeInput != nil {
+ if _, errNormalizeInput := helps.NormalizeKimiResponsesInputDoc(doc); errNormalizeInput != nil {
return resp, fmt.Errorf("kimi executor: failed to normalize responses input: %w", errNormalizeInput)
}
- body = normalizeKimiTools(body)
- body = normalizeKimiTemperature(body)
+ normalizeKimiToolsDoc(doc)
+ normalizeKimiTemperatureDoc(doc)
url := helps.ResolveKimiResponsesURL(auth)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", doc, req.Payload, requestedModel, requestPath, opts.Headers)
+ body := doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, e.Identifier())
httpReq, errNewRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewRequest != nil {
@@ -577,40 +566,33 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox
reporter := helps.NewExecutorUsageReporter(ctx, e, baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
- body := bytes.Clone(req.Payload)
+ doc := ojson.NewDoc(req.Payload)
upstreamModel := normalizeKimiUpstreamModel(baseModel)
reporter.SetUpstreamModel(upstreamModel)
- var errSet error
- body, errSet = sjson.SetBytes(body, "model", upstreamModel)
- if errSet != nil {
+ if errSet := doc.Set("model", upstreamModel); errSet != nil {
return nil, fmt.Errorf("kimi executor: failed to set model in payload: %w", errSet)
}
- body = helps.SetBoolIfDifferent(body, "stream", true)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
- var errThinking error
- body, errThinking = helps.ApplyRequestThinking(body, req, opts, opts.SourceFormat.String(), sdktranslator.FormatCodex.String(), e.Identifier())
- if errThinking != nil {
+ if errThinking := helps.ApplyRequestThinkingDoc(doc, req, opts, opts.SourceFormat.String(), sdktranslator.FormatCodex.String(), e.Identifier()); errThinking != nil {
return nil, errThinking
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- var errNormalizePatch error
- body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body)
- if errNormalizePatch != nil {
+ if errNormalizePatch := helps.NormalizeApplyPatchResponsesRequestDoc(doc); errNormalizePatch != nil {
return nil, errNormalizePatch
}
- var errNormalizeInputStream error
- body, errNormalizeInputStream = helps.NormalizeKimiResponsesInput(body)
- if errNormalizeInputStream != nil {
+ if _, errNormalizeInputStream := helps.NormalizeKimiResponsesInputDoc(doc); errNormalizeInputStream != nil {
return nil, fmt.Errorf("kimi executor: failed to normalize responses input: %w", errNormalizeInputStream)
}
- body = normalizeKimiTools(body)
- body = normalizeKimiTemperature(body)
+ normalizeKimiToolsDoc(doc)
+ normalizeKimiTemperatureDoc(doc)
url := helps.ResolveKimiResponsesURL(auth)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", body, req.Payload, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, "openai-response", opts.SourceFormat.String(), "", doc, req.Payload, requestedModel, requestPath, opts.Headers)
+ body := doc.Bytes()
reporter.SetTranslatedReasoningEffort(body, e.Identifier())
httpReq, errNewRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if errNewRequest != nil {
@@ -724,7 +706,7 @@ func (e *KimiExecutor) executeResponsesStream(ctx context.Context, auth *cliprox
if bytes.HasPrefix(line, dataTag) {
dataBytes := bytes.TrimSpace(line[len(dataTag):])
- eventType := gjson.GetBytes(dataBytes, "type").String()
+ eventType := ojson.GetBytesNoCopy(dataBytes, "type").String()
if eventType == "response.completed" || eventType == "response.incomplete" || eventType == "response.done" {
if usage, ok := helps.ParseCodexUsage(dataBytes); ok && (usage.TotalTokens > 0 || usage.InputTokens > 0) {
streamUsage.Observe(usage, true)
@@ -852,13 +834,30 @@ func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth,
}
func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return body, nil
}
+ doc := ojson.NewDoc(body)
+ changed, err := normalizeKimiToolMessageLinksChangedDoc(doc)
+ if err != nil || !changed {
+ return body, err
+ }
+ return doc.Bytes(), nil
+}
+
+func normalizeKimiToolMessageLinksDoc(doc *ojson.Doc) error {
+ _, err := normalizeKimiToolMessageLinksChangedDoc(doc)
+ return err
+}
- messages := util.GetGJSONBytesNoCopy(body, "messages")
+func normalizeKimiToolMessageLinksChangedDoc(doc *ojson.Doc) (bool, error) {
+ if doc == nil || doc.Len() == 0 {
+ return false, nil
+ }
+
+ messages := doc.Get("messages")
if !messages.Exists() || !messages.IsArray() {
- return body, nil
+ return false, nil
}
type messagePatch struct {
@@ -963,18 +962,15 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
"pending_tool_calls": len(pending),
}).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates")
}
- return body, nil
+ return false, nil
}
- var out []byte
if dropped == 0 && len(patches) == 1 {
patch := patches[0]
path := fmt.Sprintf("messages.%d.%s", patch.index, patch.path)
- updated, errSet := sjson.SetBytes(body, path, patch.value)
- if errSet != nil {
- return body, fmt.Errorf("kimi executor: %s: %w", patch.errorContext, errSet)
+ if errSet := doc.Set(path, patch.value); errSet != nil {
+ return false, fmt.Errorf("kimi executor: %s: %w", patch.errorContext, errSet)
}
- out = updated
} else {
messageItems := make([]string, 0, len(msgs)-dropped)
patchIndex := 0
@@ -983,25 +979,25 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
continue
}
messageJSON := msg.Raw
- for patchIndex < len(patches) && patches[patchIndex].index == msgIndex {
- patch := patches[patchIndex]
- next, errSet := sjson.SetBytes([]byte(messageJSON), patch.path, patch.value)
- if errSet != nil {
- return body, fmt.Errorf("kimi executor: %s: %w", patch.errorContext, errSet)
+ if patchIndex < len(patches) && patches[patchIndex].index == msgIndex {
+ msgDoc := ojson.NewDocString(messageJSON)
+ for patchIndex < len(patches) && patches[patchIndex].index == msgIndex {
+ patch := patches[patchIndex]
+ if errSet := msgDoc.Set(patch.path, patch.value); errSet != nil {
+ return false, fmt.Errorf("kimi executor: %s: %w", patch.errorContext, errSet)
+ }
+ patchIndex++
}
- messageJSON = string(next)
- patchIndex++
+ messageJSON = msgDoc.String()
}
messageItems = append(messageItems, messageJSON)
}
- updated, errSet := sjson.SetRawBytes(body, "messages", helps.JoinRawJSONStrings(messageItems))
- if errSet != nil {
+ if errSet := doc.SetRawBytes("messages", helps.JoinRawJSONStrings(messageItems)); errSet != nil {
if dropped > 0 {
- return body, fmt.Errorf("kimi executor: failed to drop empty assistant messages: %w", errSet)
+ return false, fmt.Errorf("kimi executor: failed to drop empty assistant messages: %w", errSet)
}
- return body, fmt.Errorf("kimi executor: %s: %w", patches[0].errorContext, errSet)
+ return false, fmt.Errorf("kimi executor: %s: %w", patches[0].errorContext, errSet)
}
- out = updated
}
if patched > 0 || patchedReasoning > 0 {
@@ -1016,10 +1012,10 @@ func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
"pending_tool_calls": len(pending),
}).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates")
}
- return out, nil
+ return true, nil
}
-func shouldDropKimiAssistantMessage(msg gjson.Result) bool {
+func shouldDropKimiAssistantMessage(msg ojson.Result) bool {
if strings.TrimSpace(msg.Get("role").String()) != "assistant" {
return false
}
@@ -1029,14 +1025,14 @@ func shouldDropKimiAssistantMessage(msg gjson.Result) bool {
return isKimiAssistantContentEmpty(msg.Get("content"))
}
-func hasKimiToolCalls(msg gjson.Result) bool {
+func hasKimiToolCalls(msg ojson.Result) bool {
toolCalls := msg.Get("tool_calls")
return toolCalls.Exists() && toolCalls.IsArray() && len(toolCalls.Array()) > 0
}
-func hasKimiLegacyFunctionCall(msg gjson.Result) bool {
+func hasKimiLegacyFunctionCall(msg ojson.Result) bool {
functionCall := msg.Get("function_call")
- if !functionCall.Exists() || functionCall.Type == gjson.Null {
+ if !functionCall.Exists() || functionCall.Type == ojson.Null {
return false
}
if functionCall.IsObject() && strings.TrimSpace(functionCall.Raw) == "{}" {
@@ -1045,16 +1041,16 @@ func hasKimiLegacyFunctionCall(msg gjson.Result) bool {
return strings.TrimSpace(functionCall.Raw) != ""
}
-func hasKimiAssistantReasoning(msg gjson.Result) bool {
+func hasKimiAssistantReasoning(msg ojson.Result) bool {
reasoning := msg.Get("reasoning_content")
return reasoning.Exists() && strings.TrimSpace(reasoning.String()) != ""
}
-func isKimiAssistantContentEmpty(content gjson.Result) bool {
- if !content.Exists() || content.Type == gjson.Null {
+func isKimiAssistantContentEmpty(content ojson.Result) bool {
+ if !content.Exists() || content.Type == ojson.Null {
return true
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return strings.TrimSpace(content.String()) == ""
}
if !content.IsArray() {
@@ -1068,11 +1064,11 @@ func isKimiAssistantContentEmpty(content gjson.Result) bool {
return true
}
-func isKimiAssistantContentPartEmpty(part gjson.Result) bool {
- if !part.Exists() || part.Type == gjson.Null {
+func isKimiAssistantContentPartEmpty(part ojson.Result) bool {
+ if !part.Exists() || part.Type == ojson.Null {
return true
}
- if part.Type == gjson.String {
+ if part.Type == ojson.String {
return strings.TrimSpace(part.String()) == ""
}
if !part.IsObject() {
@@ -1092,13 +1088,13 @@ func isUsableKimiReasoning(reasoning string) bool {
return trimmed != "" && trimmed != kimiReasoningUnavailable
}
-func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {
+func fallbackAssistantReasoning(msg ojson.Result, hasLatest bool, latest string) string {
if hasLatest && isUsableKimiReasoning(latest) {
return latest
}
content := msg.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
if text := strings.TrimSpace(content.String()); text != "" {
return text
}
@@ -1370,34 +1366,55 @@ func normalizeKimiTools(body []byte) []byte {
if len(body) == 0 {
return body
}
- body = normalizeKimiToolList(body, "tools", true)
- body = normalizeKimiToolList(body, "functions", false)
- if input := gjson.GetBytes(body, "input"); input.Exists() && input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !normalizeKimiToolsDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func normalizeKimiToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ changed := normalizeKimiToolListDoc(doc, "tools", true)
+ if normalizeKimiToolListDoc(doc, "functions", false) {
+ changed = true
+ }
+ if input := doc.Get("input"); input.Exists() && input.IsArray() {
for idx, item := range input.Array() {
switch strings.TrimSpace(item.Get("type").String()) {
case "additional_tools", "tool_search_output":
- body = normalizeKimiToolList(body, fmt.Sprintf("input.%d.tools", idx), true)
+ if normalizeKimiToolListDoc(doc, fmt.Sprintf("input.%d.tools", idx), true) {
+ changed = true
+ }
}
}
}
- return body
+ return changed
}
func normalizeKimiToolList(body []byte, arrayKey string, isTools bool) []byte {
- items := gjson.GetBytes(body, arrayKey)
- normalized, changed := normalizeKimiToolArray(items, isTools)
- if !changed {
+ doc := ojson.NewDoc(body)
+ if !normalizeKimiToolListDoc(doc, arrayKey, isTools) {
return body
}
+ return doc.Bytes()
+}
- out, errSetRaw := sjson.SetRawBytes(body, arrayKey, normalized)
- if errSetRaw != nil {
- return body
+func normalizeKimiToolListDoc(doc *ojson.Doc, arrayKey string, isTools bool) bool {
+ if doc == nil {
+ return false
}
- return out
+ items := doc.Get(arrayKey)
+ normalized, changed := normalizeKimiToolArray(items, isTools)
+ if !changed {
+ return false
+ }
+ return doc.SetRawBytes(arrayKey, normalized) == nil
}
-func normalizeKimiToolArray(items gjson.Result, isTools bool) ([]byte, bool) {
+func normalizeKimiToolArray(items ojson.Result, isTools bool) ([]byte, bool) {
if !items.Exists() || !items.IsArray() {
return nil, false
}
@@ -1410,12 +1427,18 @@ func normalizeKimiToolArray(items gjson.Result, isTools bool) ([]byte, bool) {
updatedItems := make([]string, 0, len(arr))
for _, item := range arr {
itemRaw := item.Raw
+ var itemDoc *ojson.Doc
+ ensureItemDoc := func() *ojson.Doc {
+ if itemDoc == nil {
+ itemDoc = ojson.NewDocString(itemRaw)
+ }
+ return itemDoc
+ }
if isTools && strings.TrimSpace(item.Get("type").String()) == "namespace" {
for _, nestedKey := range []string{"tools", "children"} {
- if nested := gjson.Get(itemRaw, nestedKey); nested.Exists() && nested.IsArray() {
+ if nested := item.Get(nestedKey); nested.Exists() && nested.IsArray() {
if normalizedNested, nestedChanged := normalizeKimiToolArray(nested, true); nestedChanged {
- if updated, errSet := sjson.SetRawBytes([]byte(itemRaw), nestedKey, normalizedNested); errSet == nil {
- itemRaw = string(updated)
+ if errSet := ensureItemDoc().SetRawBytes(nestedKey, normalizedNested); errSet == nil {
changed = true
}
}
@@ -1435,13 +1458,15 @@ func normalizeKimiToolArray(items gjson.Result, isTools bool) ([]byte, bool) {
if rawParams.IsObject() {
normalizedParams := normalizeKimiParametersSchema(rawParams.Raw)
if normalizedParams != rawParams.Raw {
- if updated, errSet := sjson.SetRawBytes([]byte(itemRaw), paramPath, []byte(normalizedParams)); errSet == nil {
- itemRaw = string(updated)
+ if errSet := ensureItemDoc().SetRawBytes(paramPath, []byte(normalizedParams)); errSet == nil {
changed = true
}
}
}
}
+ if itemDoc != nil {
+ itemRaw = itemDoc.String()
+ }
updatedItems = append(updatedItems, itemRaw)
}
@@ -1457,20 +1482,28 @@ func normalizeKimiParametersSchema(paramsRaw string) string {
}
inlined := util.InlineLocalRefs(paramsRaw)
- paramBytes := []byte(inlined)
+ doc := ojson.NewDocString(inlined)
+ changed := inlined != paramsRaw
- if inlinedDefs := gjson.GetBytes(paramBytes, "$defs"); inlinedDefs.Exists() {
- paramBytes, _ = sjson.DeleteBytes(paramBytes, "$defs")
+ if doc.Get("$defs").Exists() {
+ if doc.Delete("$defs") == nil {
+ changed = true
+ }
}
- if inlinedDefinitions := gjson.GetBytes(paramBytes, "definitions"); inlinedDefinitions.Exists() {
- paramBytes, _ = sjson.DeleteBytes(paramBytes, "definitions")
+ if doc.Get("definitions").Exists() {
+ if doc.Delete("definitions") == nil {
+ changed = true
+ }
}
-
- if rootType := gjson.GetBytes(paramBytes, "type"); !rootType.Exists() {
- paramBytes, _ = sjson.SetBytes(paramBytes, "type", "object")
+ if !doc.Get("type").Exists() {
+ if doc.Set("type", "object") == nil {
+ changed = true
+ }
}
-
- return string(paramBytes)
+ if !changed {
+ return paramsRaw
+ }
+ return doc.String()
}
// normalizeKimiTemperature normalizes or strips the temperature parameter for Kimi upstream.
@@ -1480,22 +1513,33 @@ func normalizeKimiParametersSchema(paramsRaw string) string {
// If a client specifies an invalid temperature, stripping it allows the upstream
// to apply its safe model default and avoids a 400 Bad Request error.
func normalizeKimiTemperature(body []byte) []byte {
- tempRes := gjson.GetBytes(body, "temperature")
- if !tempRes.Exists() {
+ doc := ojson.NewDoc(body)
+ if !normalizeKimiTemperatureDoc(doc) {
return body
}
- thinkingType := gjson.GetBytes(body, "thinking.type").String()
+ return doc.Bytes()
+}
+
+func normalizeKimiTemperatureDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ tempRes := doc.Get("temperature")
+ if !tempRes.Exists() {
+ return false
+ }
+ thinkingType := doc.Get("thinking.type").String()
if strings.EqualFold(thinkingType, "disabled") {
if tempRes.Float() != 0.6 {
- body, _ = sjson.DeleteBytes(body, "temperature")
+ return doc.Delete("temperature") == nil
}
- return body
+ return false
}
// Default / enabled thinking requires temperature 1.0.
if tempRes.Float() != 1.0 {
- body, _ = sjson.DeleteBytes(body, "temperature")
+ return doc.Delete("temperature") == nil
}
- return body
+ return false
}
// SupportsApplyPatch reports the actual executor contract, independent of its provider name.
diff --git a/internal/runtime/executor/kimi_executor_test.go b/internal/runtime/executor/kimi_executor_test.go
index bdcbbd4a28e..dd0b1c679c3 100644
--- a/internal/runtime/executor/kimi_executor_test.go
+++ b/internal/runtime/executor/kimi_executor_test.go
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
kimiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/kimi"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -20,7 +21,6 @@ import (
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
)
func TestNewKimiExecutorInitializesDelegatedClaudeConfig(t *testing.T) {
@@ -121,10 +121,10 @@ func TestKimiExecutorResponsesPassthrough(t *testing.T) {
if authHeader != "Bearer test-kimi-key" {
t.Fatalf("Authorization = %q, want Bearer test-kimi-key", authHeader)
}
- if gotModel := gjson.GetBytes(upstreamBody, "model").String(); gotModel != "k3" {
+ if gotModel := ojson.GetBytes(upstreamBody, "model").String(); gotModel != "k3" {
t.Fatalf("upstreamBody model = %q, want k3", gotModel)
}
- if gotText := gjson.GetBytes(resp.Payload, "output.0.content.0.text").String(); gotText != "hello world" {
+ if gotText := ojson.GetBytes(resp.Payload, "output.0.content.0.text").String(); gotText != "hello world" {
t.Fatalf("response output text = %q, want hello world", gotText)
}
}
@@ -182,7 +182,7 @@ func TestKimiExecutorResponsesStreamPassthrough(t *testing.T) {
if streamHeader != "text/event-stream" {
t.Fatalf("Accept header = %q, want text/event-stream", streamHeader)
}
- if gotModel := gjson.GetBytes(upstreamBody, "model").String(); gotModel != "k3" {
+ if gotModel := ojson.GetBytes(upstreamBody, "model").String(); gotModel != "k3" {
t.Fatalf("upstreamBody model = %q, want k3", gotModel)
}
@@ -277,10 +277,10 @@ func TestKimiExecutorResponsesAppliesSuffixThinking(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if gotModel := gjson.GetBytes(upstreamBody, "model").String(); gotModel != "k3" {
+ if gotModel := ojson.GetBytes(upstreamBody, "model").String(); gotModel != "k3" {
t.Fatalf("upstream model = %q, want k3", gotModel)
}
- if gotEffort := gjson.GetBytes(upstreamBody, "reasoning.effort").String(); gotEffort != "high" {
+ if gotEffort := ojson.GetBytes(upstreamBody, "reasoning.effort").String(); gotEffort != "high" {
t.Fatalf("upstream reasoning.effort = %q, want high; body=%s", gotEffort, upstreamBody)
}
}
@@ -319,7 +319,7 @@ func TestKimiExecutorResponsesSuffixOverridesBodyThinking(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if gotEffort := gjson.GetBytes(upstreamBody, "reasoning.effort").String(); gotEffort != "max" {
+ if gotEffort := ojson.GetBytes(upstreamBody, "reasoning.effort").String(); gotEffort != "max" {
t.Fatalf("upstream reasoning.effort = %q, want max (suffix overrides body); body=%s", gotEffort, upstreamBody)
}
}
@@ -361,7 +361,7 @@ func TestKimiExecutorResponsesStreamAppliesSuffixThinking(t *testing.T) {
for range result.Chunks {
}
- if gotEffort := gjson.GetBytes(upstreamBody, "reasoning.effort").String(); gotEffort != "low" {
+ if gotEffort := ojson.GetBytes(upstreamBody, "reasoning.effort").String(); gotEffort != "low" {
t.Fatalf("upstream reasoning.effort = %q, want low; body=%s", gotEffort, upstreamBody)
}
}
@@ -400,13 +400,13 @@ func TestKimiExecutorClaudeRequestPreservesInternalModelSemantics(t *testing.T)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(upstreamBody, "model").String(); got != "k2.5" {
+ if got := ojson.GetBytes(upstreamBody, "model").String(); got != "k2.5" {
t.Fatalf("upstream model = %q, want k2.5", got)
}
- if got := gjson.GetBytes(upstreamBody, "output_config.effort").String(); got != "high" {
+ if got := ojson.GetBytes(upstreamBody, "output_config.effort").String(); got != "high" {
t.Fatalf("upstream output_config.effort = %q, want high", got)
}
- if got := gjson.GetBytes(response.Payload, "model").String(); got != model {
+ if got := ojson.GetBytes(response.Payload, "model").String(); got != model {
t.Fatalf("response model = %q, want %q", got, model)
}
}
@@ -454,7 +454,7 @@ func TestKimiExecutorPreservesAssistantContentAndToolCallsFromResponsesHistory(t
t.Fatalf("Execute() error = %v", err)
}
- input := gjson.GetBytes(upstreamBody, "input").Array()
+ input := ojson.GetBytes(upstreamBody, "input").Array()
if got := len(input); got != 4 {
t.Fatalf("upstream input count = %d, want 4; body=%s", got, upstreamBody)
}
@@ -509,7 +509,7 @@ func TestKimiExecutorCountTokensUsesCanonicalUpstreamModel(t *testing.T) {
if got := upstreamRequest.URL.String(); got != "https://api.kimi.com/coding/v1/messages/count_tokens?beta=true" {
t.Fatalf("upstream URL = %q, want Kimi count tokens endpoint", got)
}
- if got := gjson.GetBytes(upstreamBody, "model").String(); got != "k3" {
+ if got := ojson.GetBytes(upstreamBody, "model").String(); got != "k3" {
t.Fatalf("upstream model = %q, want k3", got)
}
}
@@ -658,7 +658,7 @@ func TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
+ got := ojson.GetBytes(out, "messages.1.tool_call_id").String()
if got != "list_directory:1" {
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "list_directory:1")
}
@@ -677,7 +677,7 @@ func TestNormalizeKimiToolMessageLinks_InferSinglePendingID(t *testing.T) {
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
+ got := ojson.GetBytes(out, "messages.1.tool_call_id").String()
if got != "call_123" {
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_123")
}
@@ -699,8 +699,8 @@ func TestNormalizeKimiToolMessageLinks_AmbiguousMissingIDIsNotInferred(t *testin
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- if gjson.GetBytes(out, "messages.1.tool_call_id").Exists() {
- t.Fatalf("messages.1.tool_call_id should be absent for ambiguous case, got %q", gjson.GetBytes(out, "messages.1.tool_call_id").String())
+ if ojson.GetBytes(out, "messages.1.tool_call_id").Exists() {
+ t.Fatalf("messages.1.tool_call_id should be absent for ambiguous case, got %q", ojson.GetBytes(out, "messages.1.tool_call_id").String())
}
}
@@ -717,7 +717,7 @@ func TestNormalizeKimiToolMessageLinks_PreservesExistingToolCallID(t *testing.T)
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
+ got := ojson.GetBytes(out, "messages.1.tool_call_id").String()
if got != "call_1" {
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
}
@@ -736,7 +736,7 @@ func TestNormalizeKimiToolMessageLinks_InheritsPreviousReasoningForAssistantTool
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.1.reasoning_content").String()
if got != "previous reasoning" {
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "previous reasoning")
}
@@ -754,7 +754,7 @@ func TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *te
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- reasoning := gjson.GetBytes(out, "messages.0.reasoning_content")
+ reasoning := ojson.GetBytes(out, "messages.0.reasoning_content")
if !reasoning.Exists() {
t.Fatalf("messages.0.reasoning_content should exist")
}
@@ -776,7 +776,7 @@ func TestNormalizeKimiToolMessageLinks_DoesNotReuseUnavailableReasoning(t *testi
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.1.reasoning_content").String()
if got != "current summary" {
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "current summary")
}
@@ -796,7 +796,7 @@ func TestNormalizeKimiToolMessageLinks_UnavailableReasoningDoesNotOverridePrevio
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.2.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.2.reasoning_content").String()
if got != "real reasoning" {
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "real reasoning")
}
@@ -814,7 +814,7 @@ func TestNormalizeKimiToolMessageLinks_ReplacesUnavailableReasoningContent(t *te
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.0.reasoning_content").String()
if got != "assistant summary" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
}
@@ -832,7 +832,7 @@ func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.0.reasoning_content").String()
if got != "first line\nsecond line" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "first line\nsecond line")
}
@@ -850,7 +850,7 @@ func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.0.reasoning_content").String()
if got != "assistant summary" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
}
@@ -868,7 +868,7 @@ func TestNormalizeKimiToolMessageLinks_PreservesExistingAssistantReasoning(t *te
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
+ got := ojson.GetBytes(out, "messages.0.reasoning_content").String()
if got != "keep me" {
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "keep me")
}
@@ -889,13 +889,13 @@ func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_1" {
+ if got := ojson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_1" {
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
}
- if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != "call_2" {
+ if got := ojson.GetBytes(out, "messages.3.tool_call_id").String(); got != "call_2" {
t.Fatalf("messages.3.tool_call_id = %q, want %q", got, "call_2")
}
- if got := gjson.GetBytes(out, "messages.2.reasoning_content").String(); got != "r1" {
+ if got := ojson.GetBytes(out, "messages.2.reasoning_content").String(); got != "r1" {
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1")
}
}
@@ -919,9 +919,9 @@ func TestNormalizeKimiToolMessageLinks_DropsEmptyAssistantWithoutToolLink(t *tes
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytes(out, "messages").Array()
if len(messages) != 3 {
- t.Fatalf("messages length = %d, want 3, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw)
+ t.Fatalf("messages length = %d, want 3, raw = %s", len(messages), ojson.GetBytes(out, "messages").Raw)
}
if got := messages[0].Get("content").String(); got != "start" {
t.Fatalf("messages.0.content = %q, want %q", got, "start")
@@ -949,9 +949,9 @@ func TestNormalizeKimiToolMessageLinks_PreservesAssistantWithToolLinkOrReasoning
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
}
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytes(out, "messages").Array()
if len(messages) != 4 {
- t.Fatalf("messages length = %d, want 4, raw = %s", len(messages), gjson.GetBytes(out, "messages").Raw)
+ t.Fatalf("messages length = %d, want 4, raw = %s", len(messages), ojson.GetBytes(out, "messages").Raw)
}
if !messages[0].Get("tool_calls").Exists() {
t.Fatalf("messages.0.tool_calls should exist")
@@ -1081,7 +1081,7 @@ func TestKimiExecutorNormalizesToolSchemasForMoonshot(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- tool := gjson.GetBytes(upstreamBody, "tools.0.function")
+ tool := ojson.GetBytes(upstreamBody, "tools.0.function")
if !tool.Exists() {
t.Fatalf("upstream tool function not found: %s", upstreamBody)
}
@@ -1171,7 +1171,7 @@ func TestKimiExecutorStreamNormalizesToolSchemasFromResponses(t *testing.T) {
}
}
- tool := gjson.GetBytes(upstreamBody, "tools.0")
+ tool := ojson.GetBytes(upstreamBody, "tools.0")
if !tool.Exists() {
t.Fatalf("upstream tool not found in stream body: %s", upstreamBody)
}
@@ -1219,7 +1219,7 @@ func TestNormalizeKimiToolsDirect(t *testing.T) {
normalized := normalizeKimiTools(input)
- toolParams := gjson.GetBytes(normalized, "tools.0.function.parameters")
+ toolParams := ojson.GetBytes(normalized, "tools.0.function.parameters")
if toolParams.Get("definitions").Exists() {
t.Errorf("definitions was not stripped: %s", toolParams.Raw)
}
@@ -1236,7 +1236,7 @@ func TestNormalizeKimiToolsDirect(t *testing.T) {
t.Errorf("tools.0.function.parameters.type = %q, want object", got)
}
- fnParams := gjson.GetBytes(normalized, "functions.0.parameters")
+ fnParams := ojson.GetBytes(normalized, "functions.0.parameters")
if got := fnParams.Get("type").String(); got != "object" {
t.Errorf("functions.0.parameters.type = %q, want object", got)
}
@@ -1393,7 +1393,7 @@ func TestKimiExecutorStreamNormalizesNamespacedToolSchemasFromResponses(t *testi
}
}
- nestedTool := gjson.GetBytes(upstreamBody, "tools.0.tools.0")
+ nestedTool := ojson.GetBytes(upstreamBody, "tools.0.tools.0")
if !nestedTool.Exists() {
t.Fatalf("upstream namespaced tool not found: %s", upstreamBody)
}
@@ -1410,7 +1410,7 @@ func TestKimiExecutorStreamNormalizesNamespacedToolSchemasFromResponses(t *testi
t.Fatalf("steps.items.oneOf.0.properties.path.items.type = %q, want integer", got)
}
- extraTool := gjson.GetBytes(upstreamBody, "input.1.tools.0.tools.0")
+ extraTool := ojson.GetBytes(upstreamBody, "input.1.tools.0.tools.0")
if !extraTool.Exists() {
t.Fatalf("upstream additional_tools namespaced tool not found: %s", upstreamBody)
}
@@ -1479,7 +1479,7 @@ func TestNormalizeKimiTemperature(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeKimiTemperature([]byte(tt.body))
- res := gjson.GetBytes(got, "temperature")
+ res := ojson.GetBytes(got, "temperature")
if res.Exists() != tt.wantExist {
t.Fatalf("temperature.Exists() = %v, want %v; body=%s", res.Exists(), tt.wantExist, string(got))
}
@@ -1493,8 +1493,8 @@ func TestNormalizeKimiTemperature(t *testing.T) {
func TestKimiExecutor_MappedModelDoesNotWarnWhenUpstreamServesMappedModel(t *testing.T) {
ctx := context.WithValue(context.Background(), "cliproxy.roundtripper", kimiRoundTripperFunc(func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
- if gjson.GetBytes(body, "model").String() != "kimi-for-coding" {
- t.Fatalf("upstream request model = %q, want kimi-for-coding", gjson.GetBytes(body, "model").String())
+ if ojson.GetBytes(body, "model").String() != "kimi-for-coding" {
+ t.Fatalf("upstream request model = %q, want kimi-for-coding", ojson.GetBytes(body, "model").String())
}
return &http.Response{
StatusCode: http.StatusOK,
@@ -1779,7 +1779,7 @@ func TestKimiExecutorExecuteResponses_InterleavedToolOutputsReordered(t *testing
t.Fatalf("Execute() error = %v", errExecute)
}
- input := gjson.GetBytes(upstreamBody, "input").Array()
+ input := ojson.GetBytes(upstreamBody, "input").Array()
if got := len(input); got != 5 {
t.Fatalf("upstream input count = %d, want 5; body=%s", got, upstreamBody)
}
@@ -1846,7 +1846,7 @@ func TestKimiExecutorExecuteResponsesStream_InterleavedToolOutputsReordered(t *t
}
}
- input := gjson.GetBytes(upstreamBody, "input").Array()
+ input := ojson.GetBytes(upstreamBody, "input").Array()
if got := len(input); got != 5 {
t.Fatalf("upstream input count = %d, want 5; body=%s", got, upstreamBody)
}
@@ -1942,7 +1942,7 @@ func TestKimiExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
return
}
seenBodies = append(seenBodies, body)
- for idx, item := range gjson.GetBytes(body, "input").Array() {
+ for idx, item := range ojson.GetBytes(body, "input").Array() {
switch item.Get("type").String() {
case "compaction_trigger", "compaction":
w.Header().Set("Content-Type", "application/json")
@@ -1958,7 +1958,7 @@ func TestKimiExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
return
}
- if gjson.GetBytes(body, "stream").Bool() {
+ if ojson.GetBytes(body, "stream").Bool() {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_cont\",\"status\":\"in_progress\",\"model\":\"k3\"}}\n\n"+
"event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"CPA_COMPACTION_MARKER_42\"}]}}\n\n"+
@@ -2049,7 +2049,7 @@ func TestKimiExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
if errExec != nil {
t.Fatalf("Execute(compaction_trigger) error = %v", errExec)
}
- nonStreamItems := gjson.GetBytes(nonStreamResp.Payload, "output").Array()
+ nonStreamItems := ojson.GetBytes(nonStreamResp.Payload, "output").Array()
if len(nonStreamItems) != 1 || nonStreamItems[0].Get("type").String() != "compaction" {
t.Fatalf("expected single compaction item in non-stream response, got: %s", nonStreamResp.Payload)
}
@@ -2117,7 +2117,7 @@ func TestKimiExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
if errOrigOnly != nil {
t.Fatalf("Execute(OriginalRequest-only trigger) error = %v", errOrigOnly)
}
- if got := gjson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
+ if got := ojson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
t.Fatalf("OriginalRequest-only trigger output[0].type = %q, want compaction", got)
}
@@ -2162,14 +2162,14 @@ func TestKimiExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
if errSecond != nil {
t.Fatalf("Execute(second compaction_trigger) error = %v", errSecond)
}
- if got := len(gjson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
+ if got := len(ojson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
t.Fatalf("second compaction output len = %d, want 1", got)
}
secondSeen := seenBodies[len(seenBodies)-1]
if !bytes.Contains(secondSeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) {
t.Fatalf("expected first capsule summary in second compaction request, got: %s", secondSeen)
}
- if got := gjson.GetBytes(secondSeen, "max_output_tokens").Int(); got != 2048 {
+ if got := ojson.GetBytes(secondSeen, "max_output_tokens").Int(); got != 2048 {
t.Fatalf("max_output_tokens = %d, want payload rule 2048; body=%s", got, secondSeen)
}
diff --git a/internal/runtime/executor/kimi_home_model_capabilities_test.go b/internal/runtime/executor/kimi_home_model_capabilities_test.go
index b631070f622..6caded723b4 100644
--- a/internal/runtime/executor/kimi_home_model_capabilities_test.go
+++ b/internal/runtime/executor/kimi_home_model_capabilities_test.go
@@ -9,12 +9,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type kimiHomeModelCapabilityDispatcher struct{}
@@ -117,13 +117,13 @@ func TestKimiHomeModelThinkingCapabilitiesOverrideLocalRegistry(t *testing.T) {
}
}
- if got := gjson.GetBytes(upstreamBody, "thinking.type").String(); got != "enabled" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.type").String(); got != "enabled" {
t.Fatalf("thinking.type = %q, want enabled; body=%s", got, upstreamBody)
}
- if got := gjson.GetBytes(upstreamBody, "thinking.effort").String(); got != "high" {
+ if got := ojson.GetBytes(upstreamBody, "thinking.effort").String(); got != "high" {
t.Fatalf("thinking.effort = %q, want high from Home model capabilities; body=%s", got, upstreamBody)
}
- if effort := gjson.GetBytes(upstreamBody, "reasoning_effort"); effort.Exists() {
+ if effort := ojson.GetBytes(upstreamBody, "reasoning_effort"); effort.Exists() {
t.Fatalf("reasoning_effort should be absent, got %s; body=%s", effort.Raw, upstreamBody)
}
})
diff --git a/internal/runtime/executor/kimi_thinking_replay.go b/internal/runtime/executor/kimi_thinking_replay.go
index c67084c2eb8..a95b8d3be0e 100644
--- a/internal/runtime/executor/kimi_thinking_replay.go
+++ b/internal/runtime/executor/kimi_thinking_replay.go
@@ -9,14 +9,14 @@ import (
"sort"
"strings"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type kimiThinkingReplayScope struct {
@@ -76,11 +76,32 @@ func prepareKimiThinkingReplayRequest(ctx context.Context, req cliproxyexecutor.
return req, scope
}
+func prepareKimiThinkingReplayRequestDoc(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) kimiThinkingReplayScope {
+ scope := kimiThinkingReplayScopeFromRequest(ctx, req, opts)
+ if !scope.valid() || doc == nil {
+ return scope
+ }
+ content, snapshot, found, errGet := internalcache.GetKimiThinkingReplayWithSnapshotRequired(ctx, scope.modelFamily, scope.sessionKey)
+ scope.snapshot = snapshot
+ scope.cacheReady = errGet == nil
+ if errGet != nil {
+ log.Warnf("kimi thinking replay cache read failed: %v", errGet)
+ return scope
+ }
+ if !found {
+ return scope
+ }
+ if restoreKimiThinkingReplayContentDoc(doc, content) {
+ scope.replayApplied = true
+ }
+ return scope
+}
+
func cacheKimiThinkingReplayResponse(ctx context.Context, scope kimiThinkingReplayScope, response []byte) {
if !scope.valid() || !scope.cacheReady {
return
}
- content := gjson.GetBytes(response, "content")
+ content := ojson.GetBytesNoCopy(response, "content")
if !content.IsArray() {
return
}
@@ -122,7 +143,7 @@ func clearKimiThinkingReplayContent(ctx context.Context, scope kimiThinkingRepla
}
func kimiThinkingReplayContentIsReplayable(content []byte) bool {
- root := gjson.ParseBytes(content)
+ root := util.ParseGJSONBytesNoCopy(content)
if !root.IsArray() {
return false
}
@@ -144,13 +165,24 @@ func kimiThinkingReplayContentIsReplayable(content []byte) bool {
}
func restoreKimiThinkingReplayContent(body, cachedContent []byte) ([]byte, bool) {
- cachedParts, cachedOK := kimiNonThinkingContentParts(gjson.ParseBytes(cachedContent))
- if !cachedOK {
+ doc := ojson.NewDoc(body)
+ if !restoreKimiThinkingReplayContentDoc(doc, cachedContent) {
return body, false
}
- messages := gjson.GetBytes(body, "messages")
+ return doc.Bytes(), true
+}
+
+func restoreKimiThinkingReplayContentDoc(doc *ojson.Doc, cachedContent []byte) bool {
+ if doc == nil {
+ return false
+ }
+ cachedParts, cachedOK := kimiNonThinkingContentParts(util.ParseGJSONBytesNoCopy(cachedContent))
+ if !cachedOK {
+ return false
+ }
+ messages := doc.Get("messages")
if !messages.IsArray() {
- return body, false
+ return false
}
messageItems := messages.Array()
for index := len(messageItems) - 1; index >= 0; index-- {
@@ -160,7 +192,7 @@ func restoreKimiThinkingReplayContent(body, cachedContent []byte) ([]byte, bool)
}
currentContent := message.Get("content")
if kimiJSONEqual([]byte(currentContent.Raw), cachedContent) {
- return body, false
+ return false
}
if kimiContentHasThinking(currentContent) {
continue
@@ -169,16 +201,12 @@ func restoreKimiThinkingReplayContent(body, cachedContent []byte) ([]byte, bool)
if !currentOK || !kimiCanonicalPartsEqual(currentParts, cachedParts) {
continue
}
- updated, errSet := sjson.SetRawBytes(body, fmt.Sprintf("messages.%d.content", index), cachedContent)
- if errSet != nil {
- return body, false
- }
- return updated, true
+ return doc.SetRawBytes(fmt.Sprintf("messages.%d.content", index), cachedContent) == nil
}
- return body, false
+ return false
}
-func kimiContentHasThinking(content gjson.Result) bool {
+func kimiContentHasThinking(content ojson.Result) bool {
if !content.IsArray() {
return false
}
@@ -191,7 +219,7 @@ func kimiContentHasThinking(content gjson.Result) bool {
return false
}
-func kimiNonThinkingContentParts(content gjson.Result) ([][]byte, bool) {
+func kimiNonThinkingContentParts(content ojson.Result) ([][]byte, bool) {
if !content.IsArray() {
return nil, false
}
@@ -284,11 +312,11 @@ func (a *kimiThinkingReplayStreamAccumulator) observe(chunk []byte) {
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
continue
}
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
a.abandon()
continue
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "message_start":
a.observed = true
@@ -313,7 +341,7 @@ func (a *kimiThinkingReplayStreamAccumulator) observe(chunk []byte) {
}
}
-func (a *kimiThinkingReplayStreamAccumulator) observeBlockStart(root gjson.Result) {
+func (a *kimiThinkingReplayStreamAccumulator) observeBlockStart(root ojson.Result) {
index := int(root.Get("index").Int())
block := root.Get("content_block")
if !block.IsObject() || len(a.blocks) >= internalcache.KimiThinkingReplayCacheMaxBlocksPerEntry {
@@ -331,7 +359,7 @@ func (a *kimiThinkingReplayStreamAccumulator) observeBlockStart(root gjson.Resul
a.blocks[index] = &kimiThinkingReplayStreamBlock{raw: append([]byte(nil), raw...)}
}
-func (a *kimiThinkingReplayStreamAccumulator) observeBlockDelta(root gjson.Result) {
+func (a *kimiThinkingReplayStreamAccumulator) observeBlockDelta(root ojson.Result) {
index := int(root.Get("index").Int())
block, ok := a.blocks[index]
if !ok {
@@ -359,7 +387,7 @@ func (a *kimiThinkingReplayStreamAccumulator) observeBlockDelta(root gjson.Resul
func (a *kimiThinkingReplayStreamAccumulator) appendBlockText(block *kimiThinkingReplayStreamBlock, builder *strings.Builder, initialized *bool, path, suffix string) {
if !*initialized {
- initial := gjson.GetBytes(block.raw, path).String()
+ initial := ojson.GetBytesNoCopy(block.raw, path).String()
if !a.reserveBytes(len(initial)) {
return
}
@@ -377,7 +405,7 @@ func (a *kimiThinkingReplayStreamAccumulator) finishBlock(index int) {
a.abandon()
return
}
- if block.hasInputDelta && !gjson.Valid(block.input.String()) {
+ if block.hasInputDelta && !ojson.Valid(block.input.String()) {
a.abandon()
return
}
@@ -415,25 +443,29 @@ func (a *kimiThinkingReplayStreamAccumulator) content() ([]byte, bool) {
a.abandon()
return nil, false
}
- raw := append([]byte(nil), block.raw...)
+ if !block.textInitialized && !block.thinkingInitialized && !block.signatureInitialized && !block.hasInputDelta {
+ parts = append(parts, append([]byte(nil), block.raw...))
+ continue
+ }
+ blockDoc := ojson.NewDoc(block.raw)
var errSet error
if block.textInitialized {
- raw, errSet = sjson.SetBytes(raw, "text", block.text.String())
+ errSet = blockDoc.Set("text", block.text.String())
}
if errSet == nil && block.thinkingInitialized {
- raw, errSet = sjson.SetBytes(raw, "thinking", block.thinking.String())
+ errSet = blockDoc.Set("thinking", block.thinking.String())
}
if errSet == nil && block.signatureInitialized {
- raw, errSet = sjson.SetBytes(raw, "signature", block.signature.String())
+ errSet = blockDoc.Set("signature", block.signature.String())
}
if errSet == nil && block.hasInputDelta {
- raw, errSet = sjson.SetRawBytes(raw, "input", []byte(block.input.String()))
+ errSet = blockDoc.SetRawBytes("input", []byte(block.input.String()))
}
if errSet != nil {
a.abandon()
return nil, false
}
- parts = append(parts, raw)
+ parts = append(parts, blockDoc.Bytes())
}
content := helps.JoinRawJSONArray(parts)
if len(content) > internalcache.KimiThinkingReplayCacheMaxBytesPerEntry {
diff --git a/internal/runtime/executor/kimi_thinking_replay_test.go b/internal/runtime/executor/kimi_thinking_replay_test.go
index 94914438b34..ad227faceb6 100644
--- a/internal/runtime/executor/kimi_thinking_replay_test.go
+++ b/internal/runtime/executor/kimi_thinking_replay_test.go
@@ -8,12 +8,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
type kimiLocalBadRequestError struct{}
@@ -72,7 +72,7 @@ func TestRestoreKimiThinkingReplayContentPreservesCompleteAssistantContent(t *te
if !restored {
t.Fatal("expected cached thinking content to be restored")
}
- got := gjson.GetBytes(updated, "messages.1.content")
+ got := ojson.GetBytes(updated, "messages.1.content")
if !kimiJSONEqual([]byte(got.Raw), cached) {
t.Fatalf("restored content = %s, want complete cached content %s", got.Raw, cached)
}
@@ -115,7 +115,7 @@ func TestPrepareKimiThinkingReplayRequestSharesOnlyK3Variants(t *testing.T) {
if scope.modelFamily != "k3" {
t.Fatalf("K3 replay family = %q, want k3", scope.modelFamily)
}
- if !gjson.GetBytes(prepared.Payload, "messages.0.content.0.signature").Exists() {
+ if !ojson.GetBytes(prepared.Payload, "messages.0.content.0.signature").Exists() {
t.Fatalf("K3 variant switch did not restore cached thinking: %s", prepared.Payload)
}
@@ -124,7 +124,7 @@ func TestPrepareKimiThinkingReplayRequestSharesOnlyK3Variants(t *testing.T) {
if scopeK27.modelFamily != "kimi-for-coding-highspeed" {
t.Fatalf("K2.7 replay family = %q, want kimi-for-coding-highspeed", scopeK27.modelFamily)
}
- if gjson.GetBytes(preparedK27.Payload, "messages.0.content.0.signature").Exists() {
+ if ojson.GetBytes(preparedK27.Payload, "messages.0.content.0.signature").Exists() {
t.Fatalf("K2.7 variants must remain isolated: %s", preparedK27.Payload)
}
}
@@ -154,7 +154,7 @@ func TestPrepareKimiThinkingReplayRequestSharesK31Variants(t *testing.T) {
if scope.modelFamily != "k3.1-pro" || !scope.replayApplied {
t.Fatalf("variant switch scope = %+v, want shared K3.1 replay", scope)
}
- if !kimiJSONEqual([]byte(gjson.GetBytes(prepared.Payload, "messages.0.content").Raw), []byte(cached)) {
+ if !kimiJSONEqual([]byte(ojson.GetBytes(prepared.Payload, "messages.0.content").Raw), []byte(cached)) {
t.Fatalf("variant switch did not preserve complete cached content: %s", prepared.Payload)
}
for _, isolatedModel := range []string{"kimi-k3", "kimi-k3-256k", "kimi-k2.8"} {
@@ -185,14 +185,14 @@ func TestKimiThinkingReplayScopeIsolatesClaudeCodeCallers(t *testing.T) {
}
preparedA, _ := prepareKimiThinkingReplayRequest(callerAContext, req, opts)
- if !gjson.GetBytes(preparedA.Payload, "messages.0.content.0.signature").Exists() {
+ if !ojson.GetBytes(preparedA.Payload, "messages.0.content.0.signature").Exists() {
t.Fatalf("caller A did not receive its replay: %s", preparedA.Payload)
}
preparedB, callerBScope := prepareKimiThinkingReplayRequest(testContextWithAPIKey("caller-b"), req, opts)
if callerBScope.sessionKey == callerAScope.sessionKey {
t.Fatal("different downstream API keys shared one replay scope")
}
- if gjson.GetBytes(preparedB.Payload, "messages.0.content.0.signature").Exists() {
+ if ojson.GetBytes(preparedB.Payload, "messages.0.content.0.signature").Exists() {
t.Fatalf("caller B received caller A replay: %s", preparedB.Payload)
}
_, unauthenticatedScope := prepareKimiThinkingReplayRequest(context.Background(), req, opts)
@@ -248,7 +248,7 @@ func TestKimiExecutorClaudeNonStreamReplaysThinkingAcrossK3VariantSwitch(t *test
if len(upstreamBodies) != 2 {
t.Fatalf("upstream request count = %d, want 2", len(upstreamBodies))
}
- gotContent := gjson.GetBytes(upstreamBodies[1], "messages.1.content")
+ gotContent := ojson.GetBytes(upstreamBodies[1], "messages.1.content")
if !kimiJSONEqual([]byte(gotContent.Raw), []byte(cachedContent)) {
t.Fatalf("second upstream assistant content = %s, want %s", gotContent.Raw, cachedContent)
}
@@ -393,7 +393,7 @@ func TestKimiExecutorClaudeStreamReplaysThinkingAcrossK3VariantSwitch(t *testing
if len(upstreamBodies) != 2 {
t.Fatalf("upstream request count = %d, want 2", len(upstreamBodies))
}
- content := gjson.GetBytes(upstreamBodies[1], "messages.1.content")
+ content := ojson.GetBytes(upstreamBodies[1], "messages.1.content")
if got := content.Get("0.thinking").String(); got != "stream reasoning" {
t.Fatalf("replayed stream thinking = %q, want stream reasoning; content=%s", got, content.Raw)
}
diff --git a/internal/runtime/executor/meta_executor.go b/internal/runtime/executor/meta_executor.go
index 34d428d9c55..683881f8834 100644
--- a/internal/runtime/executor/meta_executor.go
+++ b/internal/runtime/executor/meta_executor.go
@@ -4,16 +4,17 @@ import (
"context"
"fmt"
"net/http"
+ "runtime"
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
metaauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/meta"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
"golang.org/x/sync/singleflight"
)
@@ -24,7 +25,29 @@ var (
var metaRefreshGroup singleflight.Group
-const metaUserAgent = "muse-build/1.3.0 (interactive; macos-aarch64; build ac7280f2aca67769d1455a8847bb502b617d50f6)"
+// Captured from Muse Code 1.4.14-R7309.1 (build dec7d7497fecdad135ec5835a8161dbc92e55b58)
+// on linux-x86_64. Header parity alone does not satisfy subscription-key
+// attestation for tool-bearing requests; see helps.EnsureMetaMuseToolNamespace.
+const (
+ metaClientVersion = "1.4.14"
+ metaClientBuild = "dec7d7497fecdad135ec5835a8161dbc92e55b58"
+ metaClientSurface = "tbh:exec"
+)
+
+func metaUserAgent() string {
+ goos := runtime.GOOS
+ if goos == "darwin" {
+ goos = "macos"
+ }
+ arch := runtime.GOARCH
+ switch arch {
+ case "amd64":
+ arch = "x86_64"
+ case "arm64":
+ arch = "aarch64"
+ }
+ return fmt.Sprintf("muse-build/%s (non-interactive; %s-%s; build %s)", metaClientVersion, goos, arch, metaClientBuild)
+}
// MetaExecutor implements the cliproxyauth.ProviderExecutor for Meta Muse models (api.meta.ai).
type MetaExecutor struct {
@@ -52,8 +75,7 @@ func (e *MetaExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth
} else {
req.Header.Del("Authorization")
}
- req.Header.Set("User-Agent", metaUserAgent)
- req.Header.Set("X-Client-Id", "tbh:tui")
+ applyMetaClientIdentity(req)
var attrs map[string]string
if auth != nil {
@@ -257,7 +279,7 @@ func (e *MetaExecutor) enrichAuth(auth *cliproxyauth.Auth) *cliproxyauth.Auth {
cloned.Attributes["api_key"] = token
}
if _, hasHeader := cloned.Attributes["header:User-Agent"]; !hasHeader {
- cloned.Attributes["header:User-Agent"] = metaUserAgent
+ cloned.Attributes["header:User-Agent"] = metaUserAgent()
}
return cloned
}
@@ -346,7 +368,7 @@ func parseMetaRetryAfter(statusCode int, errorBody []byte, now time.Time) *time.
if (statusCode != http.StatusTooManyRequests && statusCode != http.StatusNotFound) || len(errorBody) == 0 {
return nil
}
- if resetsAt := gjson.GetBytes(errorBody, "error.resets_at").Int(); resetsAt > 0 {
+ if resetsAt := ojson.GetBytesNoCopy(errorBody, "error.resets_at").Int(); resetsAt > 0 {
resetAtTime := time.Unix(resetsAt, 0)
if resetAtTime.After(now) {
retryAfter := resetAtTime.Sub(now)
@@ -373,12 +395,12 @@ func isMetaSubscriptionQuota(statusCode int, body []byte) bool {
if statusCode != http.StatusTooManyRequests || len(body) == 0 {
return false
}
- msg := strings.ToLower(gjson.GetBytes(body, "error.message").String())
- code := strings.ToLower(gjson.GetBytes(body, "error.code").String())
+ msg := strings.ToLower(ojson.GetBytesNoCopy(body, "error.message").String())
+ code := strings.ToLower(ojson.GetBytesNoCopy(body, "error.code").String())
if strings.Contains(msg, "subscription quota") || strings.Contains(msg, "quota exhausted") {
return true
}
- if (code == "rate_limit_exceeded" || strings.Contains(code, "quota")) && gjson.GetBytes(body, "error.resets_at").Exists() {
+ if (code == "rate_limit_exceeded" || strings.Contains(code, "quota")) && ojson.GetBytesNoCopy(body, "error.resets_at").Exists() {
return true
}
return false
diff --git a/internal/runtime/executor/meta_executor_execute.go b/internal/runtime/executor/meta_executor_execute.go
index d3b1ed75e74..ed7e98095be 100644
--- a/internal/runtime/executor/meta_executor_execute.go
+++ b/internal/runtime/executor/meta_executor_execute.go
@@ -9,6 +9,8 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
@@ -16,14 +18,13 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"github.com/tiktoken-go/tokenizer"
)
type metaPreparedRequest struct {
applyPatch *helps.ApplyPatchResponsesState
baseModel string
+ effectiveModel string
from sdktranslator.Format
responseFormat sdktranslator.Format
to sdktranslator.Format
@@ -51,37 +52,41 @@ func (e *MetaExecutor) prepareResponsesRequest(ctx context.Context, auth *clipro
return nil, err
}
- var errThinking error
- body, errThinking = helps.ApplyRequestThinking(body, req, opts, from.String(), to.String(), e.Identifier())
- if errThinking != nil {
+ doc := ojson.NewDoc(body)
+ if errThinking := helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier()); errThinking != nil {
return nil, errThinking
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.SetBoolIfDifferent(body, "stream", stream)
- body, _ = sjson.DeleteBytes(body, "generate")
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "safety_identifier")
- body, _ = sjson.DeleteBytes(body, "stream_options")
- body, _ = sjson.DeleteBytes(body, "client_metadata")
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.SetBoolIfDifferentDoc(doc, "stream", stream)
+ _ = doc.Delete("generate")
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ _ = doc.Delete("stream_options")
+ _ = doc.Delete("client_metadata")
applyPatch := helps.NewApplyPatchResponsesState(from, originalPayload, originalTranslated)
- var errNormalizePatch error
- body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body, originalPayload)
- if errNormalizePatch != nil {
+ if errNormalizePatch := helps.NormalizeApplyPatchResponsesRequestDoc(doc, originalPayload); errNormalizePatch != nil {
return nil, errNormalizePatch
}
- body = normalizeCodexInstructions(body)
+ normalizeCodexInstructionsDoc(doc)
baseURL, _ := metaCreds(auth)
- body = sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(ctx, "meta executor", body, baseURL)
- body = helps.SanitizeMetaWebSearchTools(body)
- body = helps.NormalizeCodexToolIntegerTypes(body, opts.Headers)
+ sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURLDoc(ctx, "meta executor", doc, baseURL)
+ helps.SanitizeMetaWebSearchToolsDoc(doc)
+ helps.NormalizeCodexToolIntegerTypesDoc(doc, opts.Headers)
+ helps.EnsureMetaMuseToolNamespaceDoc(doc)
- body = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, e.Identifier(), from.String(), "", body, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, e.Identifier(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ effectiveModel := strings.TrimSpace(doc.Get("model").String())
+ if effectiveModel == "" {
+ effectiveModel = baseModel
+ }
+ body = doc.Bytes()
return &metaPreparedRequest{
applyPatch: applyPatch,
baseModel: baseModel,
+ effectiveModel: effectiveModel,
from: from,
responseFormat: responseFormat,
to: to,
@@ -126,7 +131,7 @@ func (e *MetaExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
if errRequest != nil {
return resp, errRequest
}
- applyMetaAPIHeaders(httpReq, enriched, token, true, opts.Headers)
+ applyMetaAPIHeaders(httpReq, enriched, token, true, opts.Headers, prepared.effectiveModel)
e.recordMetaRequest(ctx, enriched, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, enriched, 0)
@@ -200,14 +205,14 @@ func (e *MetaExecutor) translateMetaCompleted(ctx context.Context, req cliproxye
continue
}
eventData := bytes.TrimSpace(trimmedLine[len(dataTag):])
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
if isMetaSubscriptionUsageEvent(eventType, eventData) {
filteringSubscriptionUsage = false
continue
}
if filteringSubscriptionUsage {
if eventType == "" {
- if gjson.ValidBytes(eventData) {
+ if ojson.ValidBytes(eventData) {
filteringSubscriptionUsage = false
}
continue
@@ -224,7 +229,7 @@ func (e *MetaExecutor) translateMetaCompleted(ctx context.Context, req cliproxye
return metaCompletedTranslation{}, errBridge
}
for _, eventData := range events {
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
switch eventType {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
@@ -365,15 +370,46 @@ func (e *MetaExecutor) recordMetaRequest(ctx context.Context, auth *cliproxyauth
})
}
-func applyMetaAPIHeaders(req *http.Request, auth *cliproxyauth.Auth, token string, stream bool, clientHeaders http.Header) {
+// applyMetaClientIdentity stamps the headers Muse Code 1.4.14 sends on
+// /v1/responses. Tool-bearing subscription requests also need the canonical
+// tool namespace. Session ids are a fresh UUID-7 per request so
+// proxy traffic does not reuse one Muse session across unrelated clients.
+func applyMetaClientIdentity(req *http.Request) {
+ if req == nil {
+ return
+ }
+ req.Header.Set("User-Agent", metaUserAgent())
+ req.Header.Set("X-Client-Id", metaClientSurface)
+ if strings.TrimSpace(req.Header.Get("X-Tbh-Session-Id")) != "" {
+ return
+ }
+ sessionID := uuid.Must(uuid.NewV7()).String()
+ req.Header.Set("X-Tbh-Session-Id", sessionID)
+ req.Header.Set("X-Meta-Ai-Gateway-Session-Id", sessionID)
+ req.Header.Set("X-Tbh-Thread-Id", sessionID)
+ req.Header.Set("X-Tbh-Root-Session-Id", sessionID)
+ req.Header.Set("X-Tbh-Agent-Role", "main")
+}
+
+func isMetaContributorModel(model string) bool {
+ base := strings.TrimSpace(thinking.ParseSuffix(strings.TrimSpace(model)).ModelName)
+ return strings.HasSuffix(strings.ToLower(base), "-contributor")
+}
+
+func applyMetaAPIHeaders(req *http.Request, auth *cliproxyauth.Auth, token string, stream bool, clientHeaders http.Header, model ...string) {
req.Header.Set("Content-Type", "application/json")
if strings.TrimSpace(token) != "" {
req.Header.Set("Authorization", "Bearer "+token)
} else {
req.Header.Del("Authorization")
}
- req.Header.Set("User-Agent", metaUserAgent)
- req.Header.Set("X-Client-Id", "tbh:tui")
+ applyMetaClientIdentity(req)
+ for _, m := range model {
+ if isMetaContributorModel(m) {
+ req.Header.Set("X-Tbh-Contributor-Mode", "1")
+ break
+ }
+ }
if stream {
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
@@ -398,7 +434,7 @@ func isMetaSubscriptionUsageEvent(eventType string, eventData []byte) bool {
if eventType == metaSubscriptionUsageEventType {
return true
}
- if eventType == "" && len(eventData) > 0 && gjson.GetBytes(eventData, "subscription").IsObject() {
+ if eventType == "" && len(eventData) > 0 && ojson.GetBytesNoCopy(eventData, "subscription").IsObject() {
return true
}
return false
@@ -426,12 +462,12 @@ func wrapMetaUpstreamError(statusCode int, body []byte) error {
}
func metaStreamEventError(eventData []byte) error {
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
if eventType != "error" && eventType != "response.failed" {
return nil
}
statusCode := http.StatusBadGateway
- if code := int(gjson.GetBytes(eventData, "error.code").Int()); code >= 400 && code <= 599 {
+ if code := int(ojson.GetBytesNoCopy(eventData, "error.code").Int()); code >= 400 && code <= 599 {
statusCode = code
}
return wrapMetaUpstreamError(statusCode, eventData)
@@ -439,16 +475,16 @@ func metaStreamEventError(eventData []byte) error {
func metaAsCompletedEvent(data []byte) ([]byte, bool) {
trimmed := bytes.TrimSpace(data)
- if !gjson.ValidBytes(trimmed) {
+ if !ojson.ValidBytes(trimmed) {
return nil, false
}
- root := gjson.ParseBytes(trimmed)
+ root := util.ParseGJSONBytesNoCopy(trimmed)
switch root.Get("type").String() {
case "response.completed", "response.incomplete":
return trimmed, true
}
if root.Get("object").String() == "response" || root.Get("output").Exists() {
- wrapped, errSet := sjson.SetRawBytes([]byte(`{"type":"response.completed"}`), "response", trimmed)
+ wrapped, errSet := ojson.SetRawBytes([]byte(`{"type":"response.completed"}`), "response", trimmed)
if errSet != nil {
return nil, false
}
diff --git a/internal/runtime/executor/meta_executor_muse_tools_test.go b/internal/runtime/executor/meta_executor_muse_tools_test.go
new file mode 100644
index 00000000000..39374874d59
--- /dev/null
+++ b/internal/runtime/executor/meta_executor_muse_tools_test.go
@@ -0,0 +1,114 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+// Model the tool-bearing rejection observed on the live Meta endpoint. Testing
+// both public formats catches Hermes chat translation as well as Responses.
+func TestMetaExecutor_MuseToolsReachUpstream(t *testing.T) {
+ for _, format := range []string{"openai", "codex"} {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/stream=%t", format, stream), func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Error(err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ tools := ojson.GetBytes(body, "tools").Array()
+ if len(tools) != 2 || tools[1].Get("type").String() != "namespace" || tools[1].Get("name").String() != "muse" || tools[1].Get("tools.0.name").String() != "submit_reminder_decision" {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = io.WriteString(w, `{"error":{"code":"client_attestation_denied"}}`)
+ return
+ }
+ if r.URL.Path != "/responses" || tools[0].Get("name").String() != "client_lookup" || tools[0].Get("parameters.properties.query.type").String() != "string" {
+ t.Errorf("client tool or upstream path changed: %s %s", r.URL.Path, body)
+ }
+ writeMetaResponsesOK(w, "ok")
+ }))
+ defer server.Close()
+ payload := []byte(`{"model":"muse-wonder-full-1.0-contributor","input":"hello","tools":[{"type":"function","name":"client_lookup","parameters":{"type":"object","properties":{"query":{"type":"string"}}}}]}`)
+ if format == "openai" {
+ payload = []byte(`{"model":"muse-wonder-full-1.0-contributor","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"client_lookup","parameters":{"type":"object","properties":{"query":{"type":"string"}}}}}]}`)
+ }
+ exec := NewMetaExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{Provider: "meta", Attributes: map[string]string{"api_key": "test-key", "base_url": server.URL}}
+ req := cliproxyexecutor.Request{Model: "muse-wonder-full-1.0-contributor", Payload: payload}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString(format), Stream: stream}
+ if stream {
+ result, err := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatal(chunk.Err)
+ }
+ }
+ } else if _, err := exec.Execute(context.Background(), auth, req, opts); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ }
+}
+
+func TestMetaExecutor_MuseToolsPayloadRulesRemainFinal(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ for _, remove := range []bool{false, true} {
+ t.Run(fmt.Sprintf("stream=%t/remove=%t", stream, remove), func(t *testing.T) {
+ cfg := &config.Config{Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{{Models: []config.PayloadModelRule{{Name: "muse-*"}}, Params: map[string]any{"tools.0.description": "configured caller", "tools.1.description": "configured namespace"}}},
+ }}
+ if remove {
+ cfg.Payload.Filter = []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "muse-*"}}, Params: []string{"tools"}}}
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Error(err)
+ }
+ if remove {
+ if ojson.GetBytes(body, "tools").Exists() {
+ t.Errorf("filtered tools were restored: %s", body)
+ }
+ } else if ojson.GetBytes(body, "tools.1.name").String() != "muse" || ojson.GetBytes(body, "tools.1.description").String() != "configured namespace" || ojson.GetBytes(body, "tools.0.description").String() != "configured caller" {
+ t.Errorf("namespace did not honor final payload override: %s", body)
+ }
+ writeMetaResponsesOK(w, "ok")
+ }))
+ defer server.Close()
+ exec := NewMetaExecutor(cfg)
+ auth := &cliproxyauth.Auth{Provider: "meta", Attributes: map[string]string{"api_key": "test-key", "base_url": server.URL}}
+ req := cliproxyexecutor.Request{Model: "muse-wonder-full-1.0", Payload: []byte(`{"input":"hello","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}`)}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("codex"), Stream: stream}
+ if stream {
+ result, err := exec.ExecuteStream(context.Background(), auth, req, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatal(chunk.Err)
+ }
+ }
+ } else if _, err := exec.Execute(context.Background(), auth, req, opts); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/runtime/executor/meta_executor_stream.go b/internal/runtime/executor/meta_executor_stream.go
index e5f1255aa86..af94e144ccd 100644
--- a/internal/runtime/executor/meta_executor_stream.go
+++ b/internal/runtime/executor/meta_executor_stream.go
@@ -9,11 +9,11 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
@@ -52,7 +52,7 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
if errRequest != nil {
return nil, errRequest
}
- applyMetaAPIHeaders(httpReq, enriched, token, true, opts.Headers)
+ applyMetaAPIHeaders(httpReq, enriched, token, true, opts.Headers, prepared.effectiveModel)
e.recordMetaRequest(ctx, enriched, url, httpReq.Header.Clone(), prepared.body)
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, enriched, 0)
@@ -179,7 +179,7 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
continue
}
eventData := bytes.TrimSpace(trimmedLine[len(dataTag):])
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
if isMetaSubscriptionUsageEvent(eventType, eventData) {
pendingEventLine = nil
filteringSubscriptionUsage = false
@@ -189,7 +189,7 @@ func (e *MetaExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
if filteringSubscriptionUsage {
if eventType == "" {
pendingEventLine = nil
- if gjson.ValidBytes(eventData) {
+ if ojson.ValidBytes(eventData) {
filteringSubscriptionUsage = false
}
skipFilteredBlankLine = true
diff --git a/internal/runtime/executor/meta_executor_test.go b/internal/runtime/executor/meta_executor_test.go
index 1e33cc6dd4c..55d9f03361d 100644
--- a/internal/runtime/executor/meta_executor_test.go
+++ b/internal/runtime/executor/meta_executor_test.go
@@ -17,6 +17,8 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/google/uuid"
metaauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/meta"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -28,7 +30,6 @@ import (
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
)
func TestMetaExecutor_Identifier(t *testing.T) {
@@ -140,8 +141,8 @@ func TestMetaExecutor_ExecuteSuccessAndRateLimit(t *testing.T) {
if r.URL.Path == "/responses" {
// Check user agent
- if ua := r.Header.Get("User-Agent"); ua != metaUserAgent {
- t.Errorf("expected User-Agent %s, got %s", metaUserAgent, ua)
+ if ua := r.Header.Get("User-Agent"); ua != metaUserAgent() {
+ t.Errorf("expected User-Agent %s, got %s", metaUserAgent(), ua)
}
// Simulate 429 rate limit
w.WriteHeader(http.StatusTooManyRequests)
@@ -859,20 +860,20 @@ func TestMetaExecutor_ExecuteShapesResponsesRequest(t *testing.T) {
if gotAuth != "Bearer meta-token" {
t.Fatalf("Authorization = %q, want Bearer meta-token", gotAuth)
}
- if gotUA != metaUserAgent {
- t.Fatalf("User-Agent = %q, want %q", gotUA, metaUserAgent)
+ if gotUA != metaUserAgent() {
+ t.Fatalf("User-Agent = %q, want %q", gotUA, metaUserAgent())
}
- if gjson.GetBytes(gotBody, "messages").Exists() {
+ if ojson.GetBytes(gotBody, "messages").Exists() {
t.Fatalf("request still uses chat completions messages: %s", gotBody)
}
- if !gjson.GetBytes(gotBody, "input").Exists() {
+ if !ojson.GetBytes(gotBody, "input").Exists() {
t.Fatalf("request missing responses input: %s", gotBody)
}
- if !gjson.GetBytes(gotBody, "stream").Bool() {
+ if !ojson.GetBytes(gotBody, "stream").Bool() {
t.Fatalf("stream = false, want true")
}
- if gjson.GetBytes(gotBody, "model").String() != "muse-spark-1.3" {
- t.Fatalf("model = %q, want muse-spark-1.3", gjson.GetBytes(gotBody, "model").String())
+ if ojson.GetBytes(gotBody, "model").String() != "muse-spark-1.3" {
+ t.Fatalf("model = %q, want muse-spark-1.3", ojson.GetBytes(gotBody, "model").String())
}
if len(resp.Payload) == 0 {
t.Fatal("expected non-empty translated payload")
@@ -943,7 +944,7 @@ func TestMetaExecutor_PreservesPreviousResponseID(t *testing.T) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(gotBody, "previous_response_id").String(); got != "resp_prev" {
+ if got := ojson.GetBytes(gotBody, "previous_response_id").String(); got != "resp_prev" {
t.Fatalf("previous_response_id = %q, want resp_prev", got)
}
}
@@ -1057,12 +1058,18 @@ func TestMetaExecutor_PrepareRequest_ClientIdHeader_Issue6117(t *testing.T) {
if errPrepare := exec.PrepareRequest(req, auth); errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := req.Header.Get("X-Client-Id"); got != "tbh:tui" {
- t.Errorf("X-Client-Id = %q, want %q", got, "tbh:tui")
+ if got := req.Header.Get("X-Client-Id"); got != metaClientSurface {
+ t.Errorf("X-Client-Id = %q, want %q", got, metaClientSurface)
+ }
+ if got := req.Header.Get("User-Agent"); !strings.Contains(got, "muse-build/"+metaClientVersion) {
+ t.Errorf("User-Agent = %q, want muse-build/%s", got, metaClientVersion)
}
if _, exists := req.Header["X-Client-Id:"]; exists {
t.Errorf("X-Client-Id: with trailing colon must not exist in headers")
}
+ if got := req.Header.Get("X-Tbh-Contributor-Mode"); got != "" {
+ t.Errorf("X-Tbh-Contributor-Mode = %q, want empty", got)
+ }
}
func TestMetaExecutor_ApplyMetaAPIHeaders_ClientIdHeader_Issue6117(t *testing.T) {
@@ -1077,9 +1084,201 @@ func TestMetaExecutor_ApplyMetaAPIHeaders_ClientIdHeader_Issue6117(t *testing.T)
},
}
applyMetaAPIHeaders(req, auth, "test-key", true, nil)
- if got := req.Header.Get("X-Client-Id"); got != "tbh:tui" {
- t.Errorf("applyMetaAPIHeaders: X-Client-Id = %q, want %q", got, "tbh:tui")
+ if got := req.Header.Get("X-Client-Id"); got != metaClientSurface {
+ t.Errorf("applyMetaAPIHeaders: X-Client-Id = %q, want %q", got, metaClientSurface)
+ }
+ sessionID := req.Header.Get("X-Tbh-Session-Id")
+ if _, errParse := uuid.Parse(sessionID); errParse != nil {
+ t.Errorf("X-Tbh-Session-Id = %q, want a UUID", sessionID)
+ }
+ for _, name := range []string{"X-Meta-Ai-Gateway-Session-Id", "X-Tbh-Thread-Id", "X-Tbh-Root-Session-Id"} {
+ if got := req.Header.Get(name); got != sessionID {
+ t.Errorf("%s = %q, want %q", name, got, sessionID)
+ }
+ }
+ if got := req.Header.Get("X-Tbh-Agent-Role"); got != "main" {
+ t.Errorf("X-Tbh-Agent-Role = %q, want main", got)
+ }
+ if got := req.Header.Get("X-Tbh-Contributor-Mode"); got != "" {
+ t.Errorf("X-Tbh-Contributor-Mode = %q, want empty", got)
+ }
+
+ contributorReq, errContributorReq := http.NewRequest(http.MethodPost, "https://api.meta.ai/responses", nil)
+ if errContributorReq != nil {
+ t.Fatal(errContributorReq)
+ }
+ contributorReq.Header.Set("X-Tbh-Session-Id", sessionID)
+ applyMetaAPIHeaders(contributorReq, auth, "test-key", true, nil, "muse-wonder-1.0-contributor")
+ if got := contributorReq.Header.Get("X-Tbh-Contributor-Mode"); got != "1" {
+ t.Errorf("contributor model X-Tbh-Contributor-Mode = %q, want 1", got)
+ }
+}
+
+func TestMetaExecutor_ContributorModeHeader_Issue7272(t *testing.T) {
+ var capturedHeaders []http.Header
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ capturedHeaders = append(capturedHeaders, r.Header.Clone())
+ writeMetaResponsesOK(w, "ok")
+ }))
+ defer server.Close()
+
+ exec := NewMetaExecutor(&config.Config{})
+ auth := &cliproxyauth.Auth{
+ Provider: "meta",
+ Attributes: map[string]string{
+ "api_key": "meta-token",
+ "base_url": server.URL,
+ },
+ }
+
+ cases := []struct {
+ name string
+ model string
+ wantContributor string
+ }{
+ {
+ name: "plain muse-wonder-1.0 omits contributor header",
+ model: "muse-wonder-1.0",
+ wantContributor: "",
+ },
+ {
+ name: "plain muse-wonder-full-1.0 omits contributor header",
+ model: "muse-wonder-full-1.0",
+ wantContributor: "",
+ },
+ {
+ name: "plain muse-spark-1.3 omits contributor header",
+ model: "muse-spark-1.3",
+ wantContributor: "",
+ },
+ {
+ name: "contributor muse-wonder-1.0-contributor sets header",
+ model: "muse-wonder-1.0-contributor",
+ wantContributor: "1",
+ },
+ {
+ name: "contributor muse-wonder-full-1.0-contributor sets header",
+ model: "muse-wonder-full-1.0-contributor",
+ wantContributor: "1",
+ },
+ {
+ name: "contributor muse-spark-1.3-contributor sets header",
+ model: "muse-spark-1.3-contributor",
+ wantContributor: "1",
+ },
+ {
+ name: "contributor model with thinking suffix sets header",
+ model: "muse-wonder-1.0-contributor(high)",
+ wantContributor: "1",
+ },
}
+
+ for _, tc := range cases {
+ t.Run("Execute/"+tc.name, func(t *testing.T) {
+ before := len(capturedHeaders)
+ payload := []byte(fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hello"}]}`, tc.model))
+ _, errExec := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: tc.model,
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ })
+ if errExec != nil {
+ t.Fatalf("Execute(%q) error = %v", tc.model, errExec)
+ }
+ if len(capturedHeaders) != before+1 {
+ t.Fatalf("captured %d requests, want %d", len(capturedHeaders), before+1)
+ }
+ hdr := capturedHeaders[len(capturedHeaders)-1]
+ if got := hdr.Get("X-Tbh-Contributor-Mode"); got != tc.wantContributor {
+ t.Errorf("Execute(%q): X-Tbh-Contributor-Mode = %q, want %q", tc.model, got, tc.wantContributor)
+ }
+ if tc.wantContributor == "" && len(hdr.Values("X-Tbh-Contributor-Mode")) != 0 {
+ t.Errorf("Execute(%q): expected X-Tbh-Contributor-Mode header to be absent, got %v", tc.model, hdr.Values("X-Tbh-Contributor-Mode"))
+ }
+ })
+
+ t.Run("ExecuteStream/"+tc.name, func(t *testing.T) {
+ before := len(capturedHeaders)
+ payload := []byte(fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":"hello"}]}`, tc.model))
+ streamRes, errStream := exec.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: tc.model,
+ Payload: payload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ Stream: true,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(%q) error = %v", tc.model, errStream)
+ }
+ for chunk := range streamRes.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("ExecuteStream(%q) chunk error = %v", tc.model, chunk.Err)
+ }
+ }
+ if len(capturedHeaders) != before+1 {
+ t.Fatalf("captured %d requests, want %d", len(capturedHeaders), before+1)
+ }
+ hdr := capturedHeaders[len(capturedHeaders)-1]
+ if got := hdr.Get("X-Tbh-Contributor-Mode"); got != tc.wantContributor {
+ t.Errorf("ExecuteStream(%q): X-Tbh-Contributor-Mode = %q, want %q", tc.model, got, tc.wantContributor)
+ }
+ if tc.wantContributor == "" && len(hdr.Values("X-Tbh-Contributor-Mode")) != 0 {
+ t.Errorf("ExecuteStream(%q): expected X-Tbh-Contributor-Mode header to be absent, got %v", tc.model, hdr.Values("X-Tbh-Contributor-Mode"))
+ }
+ })
+ }
+
+ t.Run("payload override model determines contributor header", func(t *testing.T) {
+ overrideExec := NewMetaExecutor(&config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{{Name: "muse-wonder-1.0", Protocol: "meta"}},
+ Params: map[string]any{"model": "muse-wonder-1.0-contributor"},
+ },
+ {
+ Models: []config.PayloadModelRule{{Name: "muse-wonder-1.0-contributor", Protocol: "meta"}},
+ Params: map[string]any{"model": "muse-wonder-1.0"},
+ },
+ },
+ },
+ })
+
+ before := len(capturedHeaders)
+ _, errToContrib := overrideExec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0",
+ Payload: []byte(`{"model":"muse-wonder-1.0","messages":[{"role":"user","content":"hello"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ })
+ if errToContrib != nil {
+ t.Fatalf("Execute(override to contributor) error = %v", errToContrib)
+ }
+ if len(capturedHeaders) != before+1 {
+ t.Fatalf("captured %d requests, want %d", len(capturedHeaders), before+1)
+ }
+ if got := capturedHeaders[len(capturedHeaders)-1].Get("X-Tbh-Contributor-Mode"); got != "1" {
+ t.Errorf("override to contributor: X-Tbh-Contributor-Mode = %q, want 1", got)
+ }
+
+ before = len(capturedHeaders)
+ _, errToPlain := overrideExec.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "muse-wonder-1.0-contributor",
+ Payload: []byte(`{"model":"muse-wonder-1.0-contributor","messages":[{"role":"user","content":"hello"}]}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai"),
+ })
+ if errToPlain != nil {
+ t.Fatalf("Execute(override to plain) error = %v", errToPlain)
+ }
+ if len(capturedHeaders) != before+1 {
+ t.Fatalf("captured %d requests, want %d", len(capturedHeaders), before+1)
+ }
+ if vals := capturedHeaders[len(capturedHeaders)-1].Values("X-Tbh-Contributor-Mode"); len(vals) != 0 {
+ t.Errorf("override to plain: expected X-Tbh-Contributor-Mode absent, got %v", vals)
+ }
+ })
}
func TestMetaExecutor_Execute_SendsClientIdHeader_Issue6117(t *testing.T) {
@@ -1107,8 +1306,8 @@ func TestMetaExecutor_Execute_SendsClientIdHeader_Issue6117(t *testing.T) {
if errExec != nil {
t.Fatalf("Execute() error = %v", errExec)
}
- if capturedClientId != "tbh:tui" {
- t.Errorf("Execute: captured X-Client-Id = %q, want %q", capturedClientId, "tbh:tui")
+ if capturedClientId != metaClientSurface {
+ t.Errorf("Execute: captured X-Client-Id = %q, want %q", capturedClientId, metaClientSurface)
}
}
@@ -1288,7 +1487,7 @@ func TestMetaExecutor_Execute_StripsSearchContentTypesFromWebSearch(t *testing.T
t.Fatalf("Execute() error = %v", err)
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
var foundWebSearch bool
for _, tool := range tools {
if tool.Get("type").String() == "web_search" {
@@ -1347,7 +1546,7 @@ func TestMetaExecutor_ExecuteStream_StripsSearchContentTypesFromWebSearch(t *tes
for range streamResult.Chunks {
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
var foundWebSearch bool
for _, tool := range tools {
if tool.Get("type").String() == "web_search" {
@@ -1499,7 +1698,7 @@ func TestMetaExecutor_NormalizesToolFieldsForCodexUserAgent(t *testing.T) {
if errExecNonCodex != nil {
t.Fatalf("Execute(non-codex) error = %v", errExecNonCodex)
}
- if gotType := gjson.GetBytes(gotBody, "tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("expected non-Codex UA to preserve number, got %q", gotType)
}
@@ -1514,7 +1713,7 @@ func TestMetaExecutor_NormalizesToolFieldsForCodexUserAgent(t *testing.T) {
if errExecNilHeaders != nil {
t.Fatalf("Execute(nil headers) error = %v", errExecNilHeaders)
}
- if gotType := gjson.GetBytes(gotBody, "tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
+ if gotType := ojson.GetBytes(gotBody, "tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "number" {
t.Fatalf("expected nil headers to preserve number, got %q", gotType)
}
@@ -1531,8 +1730,8 @@ func TestMetaExecutor_NormalizesToolFieldsForCodexUserAgent(t *testing.T) {
t.Fatalf("Execute() error = %v", errExec)
}
- toolMap := make(map[string]gjson.Result)
- for _, tool := range gjson.GetBytes(gotBody, "tools").Array() {
+ toolMap := make(map[string]ojson.Result)
+ for _, tool := range ojson.GetBytes(gotBody, "tools").Array() {
toolMap[tool.Get("name").String()] = tool
}
@@ -1559,7 +1758,7 @@ func TestMetaExecutor_NormalizesToolFieldsForCodexUserAgent(t *testing.T) {
}
// Verify input[].additional_tools is also normalized to integer
- if gotType := gjson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "integer" {
+ if gotType := ojson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "integer" {
t.Fatalf("Meta Execute additional_tools yield_time_ms type = %q, want integer", gotType)
}
@@ -1577,10 +1776,10 @@ func TestMetaExecutor_NormalizesToolFieldsForCodexUserAgent(t *testing.T) {
}
for range streamResult.Chunks {
}
- if gotType := gjson.GetBytes(gotBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); gotType != "integer" {
+ if gotType := ojson.GetBytes(gotBody, "tools.#(name==\"exec_command\").parameters.properties.yield_time_ms.type").String(); gotType != "integer" {
t.Fatalf("Meta stream tools exec_command yield_time_ms type = %q, want integer", gotType)
}
- if gotType := gjson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "integer" {
+ if gotType := ojson.GetBytes(gotBody, "input.#(type==\"additional_tools\").tools.0.parameters.properties.yield_time_ms.type").String(); gotType != "integer" {
t.Fatalf("Meta stream additional_tools yield_time_ms type = %q, want integer", gotType)
}
}
@@ -1634,21 +1833,21 @@ func TestMetaExecutor_Execute_PreservesMuseReasoningEncryptedContent_Issue6450(t
t.Fatalf("Execute() error = %v", err)
}
- if gotEC := gjson.GetBytes(gotBody, "input.0.encrypted_content").String(); gotEC != museEncryptedContent {
+ if gotEC := ojson.GetBytes(gotBody, "input.0.encrypted_content").String(); gotEC != museEncryptedContent {
t.Fatalf("encrypted_content = %q, want %q; body=%s", gotEC, museEncryptedContent, gotBody)
}
- if gotID := gjson.GetBytes(gotBody, "input.0.id").String(); gotID != "rs_1" {
+ if gotID := ojson.GetBytes(gotBody, "input.0.id").String(); gotID != "rs_1" {
t.Fatalf("reasoning id = %q, want rs_1; body=%s", gotID, gotBody)
}
assertEmptyReasoningContent(t, gotBody, "input.0.content")
- if gotSummary := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); gotSummary != "thinking step" {
+ if gotSummary := ojson.GetBytes(gotBody, "input.0.summary.0.text").String(); gotSummary != "thinking step" {
t.Fatalf("summary = %q, want 'thinking step'; body=%s", gotSummary, gotBody)
}
// Verify Claude signature stripped and id dropped when store=false
- if gjson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
t.Fatalf("Claude signature should be stripped: %s", gotBody)
}
- if gjson.GetBytes(gotBody, "input.1.id").Exists() {
+ if ojson.GetBytes(gotBody, "input.1.id").Exists() {
t.Fatalf("Claude reasoning id should be dropped: %s", gotBody)
}
@@ -1664,20 +1863,20 @@ func TestMetaExecutor_Execute_PreservesMuseReasoningEncryptedContent_Issue6450(t
}
for range streamResult.Chunks {
}
- if gotEC := gjson.GetBytes(gotBody, "input.0.encrypted_content").String(); gotEC != museEncryptedContent {
+ if gotEC := ojson.GetBytes(gotBody, "input.0.encrypted_content").String(); gotEC != museEncryptedContent {
t.Fatalf("stream encrypted_content = %q, want %q; body=%s", gotEC, museEncryptedContent, gotBody)
}
- if gotID := gjson.GetBytes(gotBody, "input.0.id").String(); gotID != "rs_1" {
+ if gotID := ojson.GetBytes(gotBody, "input.0.id").String(); gotID != "rs_1" {
t.Fatalf("stream reasoning id = %q, want rs_1; body=%s", gotID, gotBody)
}
assertEmptyReasoningContent(t, gotBody, "input.0.content")
- if gotSummary := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); gotSummary != "thinking step" {
+ if gotSummary := ojson.GetBytes(gotBody, "input.0.summary.0.text").String(); gotSummary != "thinking step" {
t.Fatalf("stream summary = %q, want 'thinking step'; body=%s", gotSummary, gotBody)
}
- if gjson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.1.encrypted_content").Exists() {
t.Fatalf("stream Claude signature should be stripped: %s", gotBody)
}
- if gjson.GetBytes(gotBody, "input.1.id").Exists() {
+ if ojson.GetBytes(gotBody, "input.1.id").Exists() {
t.Fatalf("stream Claude reasoning id should be dropped: %s", gotBody)
}
}
@@ -1799,7 +1998,7 @@ func TestMetaExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
return
}
seenBodies = append(seenBodies, body)
- for idx, item := range gjson.GetBytes(body, "input").Array() {
+ for idx, item := range ojson.GetBytes(body, "input").Array() {
switch item.Get("type").String() {
case "compaction_trigger", "compaction":
w.Header().Set("Content-Type", "application/json")
@@ -1896,7 +2095,7 @@ func TestMetaExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
if errExec != nil {
t.Fatalf("Execute(compaction_trigger) error = %v", errExec)
}
- nonStreamItems := gjson.GetBytes(nonStreamResp.Payload, "output").Array()
+ nonStreamItems := ojson.GetBytes(nonStreamResp.Payload, "output").Array()
if len(nonStreamItems) != 1 || nonStreamItems[0].Get("type").String() != "compaction" {
t.Fatalf("expected single compaction item in non-stream response, got: %s", nonStreamResp.Payload)
}
@@ -1964,7 +2163,7 @@ func TestMetaExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
if errOrigOnly != nil {
t.Fatalf("Execute(OriginalRequest-only trigger) error = %v", errOrigOnly)
}
- if got := gjson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
+ if got := ojson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
t.Fatalf("OriginalRequest-only trigger output[0].type = %q, want compaction", got)
}
@@ -2009,14 +2208,14 @@ func TestMetaExecutor_ResponsesCompactionTriggerAndReplay_Issue6781(t *testing.T
if errSecond != nil {
t.Fatalf("Execute(second compaction_trigger) error = %v", errSecond)
}
- if got := len(gjson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
+ if got := len(ojson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
t.Fatalf("second compaction output len = %d, want 1", got)
}
secondSeen := seenBodies[len(seenBodies)-1]
if !bytes.Contains(secondSeen, []byte("Summary retaining CPA_COMPACTION_MARKER_42.")) {
t.Fatalf("expected first capsule summary in second compaction request, got: %s", secondSeen)
}
- if got := gjson.GetBytes(secondSeen, "max_output_tokens").Int(); got != 2048 {
+ if got := ojson.GetBytes(secondSeen, "max_output_tokens").Int(); got != 2048 {
t.Fatalf("max_output_tokens = %d, want payload rule 2048; body=%s", got, secondSeen)
}
diff --git a/internal/runtime/executor/oauth_scope_test.go b/internal/runtime/executor/oauth_scope_test.go
index f0bb15109f8..1f47871c708 100644
--- a/internal/runtime/executor/oauth_scope_test.go
+++ b/internal/runtime/executor/oauth_scope_test.go
@@ -8,12 +8,12 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestV8SharedUpstreamRequestSettingsAffectBothAuthKinds(t *testing.T) {
@@ -79,14 +79,14 @@ upstream:
default:
t.Fatalf("request did not reach upstream: %v", err)
}
- if got := gjson.GetBytes(body, "input.0.type").String(); got != "message" {
+ if got := ojson.GetBytes(body, "input.0.type").String(); got != "message" {
t.Fatalf("shared orphan delegation type = %q; body=%s", got, body)
}
if provider == "codex" {
- if got := gjson.GetBytes(body, "tools.0.name").String(); got != "collaboration-optimize" {
+ if got := ojson.GetBytes(body, "tools.0.name").String(); got != "collaboration-optimize" {
t.Fatalf("client collaboration namespace = %q, want collaboration-optimize", got)
}
- } else if !gjson.GetBytes(body, `tools.#(type=="x_search")`).Exists() {
+ } else if !ojson.GetBytes(body, `tools.#(type=="x_search")`).Exists() {
t.Fatal("shared x_search setting was not applied")
}
if !cfg.Client.Codex.OptimizeMultiAgentV2 || !cfg.Codex.OrphanDelegationCompatibility || !cfg.XAI.InjectXSearch {
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index b9caea42209..17a06945a8a 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -16,6 +16,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
@@ -26,8 +27,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -62,6 +61,11 @@ func NewOpenAICompatExecutor(provider string, cfg *config.Config) *OpenAICompatE
// Identifier implements cliproxyauth.ProviderExecutor.
func (e *OpenAICompatExecutor) Identifier() string { return e.provider }
+// UsesConfig reports whether the executor is bound to the provided config snapshot.
+func (e *OpenAICompatExecutor) UsesConfig(cfg *config.Config) bool {
+ return e != nil && e.cfg == cfg
+}
+
// PrepareRequest injects OpenAI-compatible credentials into the outgoing HTTP request.
func (e *OpenAICompatExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
if req == nil {
@@ -108,6 +112,20 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
func (e *OpenAICompatExecutor) executeWithCredentials(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (resp cliproxyexecutor.Response, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
+ if opts.SourceFormat == sdktranslator.FormatOpenAIResponse {
+ if opts.Alt == "responses/compact" {
+ if errExpand := helps.ExpandRecognizedResponsesCompactionCapsules(&req, &opts); errExpand != nil {
+ return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ } else {
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, e.Identifier()); errExpand != nil {
+ return resp, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeOpenAICompatResponsesCompaction(ctx, auth, req, opts, baseURL, apiKey, compat)
+ }
+ }
+ }
baseModel := thinking.ParseSuffix(req.Model).ModelName
@@ -139,34 +157,32 @@ func (e *OpenAICompatExecutor) executeWithCredentials(ctx context.Context, auth
return resp, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- translated = helps.SetStringIfDifferent(translated, "model", baseModel)
+ doc := ojson.NewDoc(translated)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
- translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return resp, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
if helps.ShouldNormalizeOpenAIToolResultsForModel(compat, baseModel, requestedModel) {
- translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
+ helps.NormalizeOpenAIToolResultsTextOnlyDoc(doc)
}
if opts.Alt != "responses/compact" {
useMCT := helps.ShouldUseMaxCompletionTokensForModel(compat, baseModel, requestedModel)
- translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT)
- translated, err = e.applyPromptCacheKeyWithCompat(ctx, auth, compat, from, baseModel, req, opts, translated)
- if err != nil {
+ helps.NormalizeOpenAIMaxTokensDoc(doc, useMCT)
+ if err = e.applyPromptCacheKeyWithCompatDoc(ctx, auth, compat, from, baseModel, req, opts, doc); err != nil {
return resp, err
}
}
if opts.Alt == "responses/compact" {
- if updated, errDelete := sjson.DeleteBytes(translated, "stream"); errDelete == nil {
- translated = updated
- }
- translated = sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, "openai compat executor", translated, true, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL)
+ _ = doc.Delete("stream")
+ sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, "openai compat executor", doc, true, resolveResponsesSignatureTargetModel(baseModel, req, opts), baseURL)
}
url := strings.TrimSuffix(baseURL, "/") + endpoint
- translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ translated = doc.Bytes()
reporter.SetTranslatedReasoningEffort(translated, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
@@ -348,6 +364,14 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
func (e *OpenAICompatExecutor) executeStreamWithCredentials(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (_ *cliproxyexecutor.StreamResult, err error) {
ctx = helps.EnsureSessionContext(ctx, opts, req.Payload)
+ if opts.SourceFormat == sdktranslator.FormatOpenAIResponse && opts.Alt != "responses/compact" {
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, e.Identifier()); errExpand != nil {
+ return nil, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ if helps.HasResponsesCompactionTriggerRequest(req, opts) {
+ return e.executeOpenAICompatResponsesCompactionStream(ctx, auth, req, opts, baseURL, apiKey, compat)
+ }
+ }
baseModel := thinking.ParseSuffix(req.Model).ModelName
@@ -374,33 +398,33 @@ func (e *OpenAICompatExecutor) executeStreamWithCredentials(ctx context.Context,
return nil, err
}
originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- translated = helps.SetStringIfDifferent(translated, "model", baseModel)
+ doc := ojson.NewDoc(translated)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
- translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return nil, err
}
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
requestPath := helps.PayloadRequestPath(opts)
if helps.ShouldNormalizeOpenAIToolResultsForModel(compat, baseModel, requestedModel) {
- translated = helps.NormalizeOpenAIToolResultsTextOnly(translated)
+ helps.NormalizeOpenAIToolResultsTextOnlyDoc(doc)
}
if opts.Alt != "responses/compact" {
useMCT := helps.ShouldUseMaxCompletionTokensForModel(compat, baseModel, requestedModel)
- translated = helps.NormalizeOpenAIMaxTokens(translated, useMCT)
- translated, err = e.applyPromptCacheKeyWithCompat(ctx, auth, compat, from, baseModel, req, opts, translated)
- if err != nil {
+ helps.NormalizeOpenAIMaxTokensDoc(doc, useMCT)
+ if err = e.applyPromptCacheKeyWithCompatDoc(ctx, auth, compat, from, baseModel, req, opts, doc); err != nil {
return nil, err
}
}
// Request usage data in the final streaming chunk so that token statistics
// are captured even when the upstream is an OpenAI-compatible provider.
- translated = helps.SetBoolIfDifferent(translated, "stream_options.include_usage", true)
+ helps.SetBoolIfDifferentDoc(doc, "stream_options.include_usage", true)
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
- translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, to.String(), from.String(), "", translated, originalTranslated, requestedModel, requestPath, opts.Headers)
+ helps.ApplyPayloadConfigWithRequestDoc(e.cfg, baseModel, to.String(), from.String(), "", doc, originalTranslated, requestedModel, requestPath, opts.Headers)
+ translated = doc.Bytes()
reporter.SetTranslatedReasoningEffort(translated, to.String())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated))
if err != nil {
@@ -794,7 +818,63 @@ func (e *OpenAICompatExecutor) executeImagesStream(ctx context.Context, auth *cl
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
}
+func (e *OpenAICompatExecutor) executeOpenAICompatResponsesCompaction(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (cliproxyexecutor.Response, error) {
+ var resp cliproxyexecutor.Response
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryPayload := helps.PrepareAntigravityCompactionSummaryPayload(helps.ResponsesCompactionSourcePayload(req, opts), baseModel)
+ summaryReq := req
+ summaryReq.Payload = summaryPayload
+ summaryOpts := opts
+ summaryOpts.Alt = ""
+ summaryOpts.Stream = false
+ summaryOpts.OriginalRequest = nil
+ summaryOpts.SourceFormat = sdktranslator.FormatOpenAIResponse
+ summaryOpts.ResponseFormat = sdktranslator.FormatOpenAIResponse
+
+ summaryResp, errSummary := e.executeWithCredentials(ctx, auth, summaryReq, summaryOpts, baseURL, apiKey, compat)
+ if errSummary != nil {
+ return resp, errSummary
+ }
+ respPayload, errBuild := helps.BuildResponsesCompactionResponseFromSummary(baseModel, summaryResp.Payload)
+ if errBuild != nil {
+ return resp, statusErr{code: http.StatusBadGateway, msg: errBuild.Error()}
+ }
+ return cliproxyexecutor.Response{
+ Payload: respPayload,
+ Headers: summaryResp.Headers,
+ }, nil
+}
+
+func (e *OpenAICompatExecutor) executeOpenAICompatResponsesCompactionStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, baseURL, apiKey string, compat *config.OpenAICompatibility) (*cliproxyexecutor.StreamResult, error) {
+ baseModel := thinking.ParseSuffix(req.Model).ModelName
+ summaryResp, errSummary := e.executeOpenAICompatResponsesCompaction(ctx, auth, req, opts, baseURL, apiKey, compat)
+ if errSummary != nil {
+ return nil, errSummary
+ }
+ chunks, errChunks := helps.BuildResponsesCompactionStreamChunksFromResponse(baseModel, summaryResp.Payload)
+ if errChunks != nil {
+ return nil, statusErr{code: http.StatusBadGateway, msg: errChunks.Error()}
+ }
+ out := make(chan cliproxyexecutor.StreamChunk, len(chunks))
+ for _, chunk := range chunks {
+ out <- cliproxyexecutor.StreamChunk{Payload: chunk}
+ }
+ close(out)
+
+ headers := summaryResp.Headers.Clone()
+ if headers == nil {
+ headers = make(http.Header)
+ }
+ headers.Set("Content-Type", "text/event-stream")
+ return &cliproxyexecutor.StreamResult{Headers: headers, Chunks: out}, nil
+}
+
func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ if opts.SourceFormat == sdktranslator.FormatOpenAIResponse && opts.Alt != "responses/compact" {
+ if errExpand := helps.ExpandResponsesCompactionCapsules(&req, &opts, e.Identifier()); errExpand != nil {
+ return cliproxyexecutor.Response{}, statusErr{code: http.StatusBadRequest, msg: errExpand.Error()}
+ }
+ }
baseModel := thinking.ParseSuffix(req.Model).ModelName
from := opts.SourceFormat
@@ -812,12 +892,14 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau
modelForCounting := baseModel
- translated, err = helps.ApplyRequestThinking(translated, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged)
- if err != nil {
+ doc := ojson.NewDoc(translated)
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), to.String(), e.Identifier(), updatesChanged); err != nil {
return cliproxyexecutor.Response{}, err
}
- translated = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts)(translated)
+ ctxWithFinalizer := helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslatedForPayload, req, opts))
+ helps.FinalizePayloadDoc(ctxWithFinalizer, doc)
+ translated = doc.Bytes()
enc, err := helps.TokenizerForModel(modelForCounting)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("openai compat executor: tokenizer init failed: %w", err)
@@ -885,15 +967,16 @@ func prepareOpenAICompatImagesPayload(payload []byte, model string, contentType
model = strings.TrimSpace(model)
contentType = strings.TrimSpace(contentType)
if json.Valid(payload) {
+ doc := ojson.NewDoc(payload)
if model != "" {
- payload = helps.SetStringIfDifferent(payload, "model", model)
+ helps.SetStringIfDifferentDoc(doc, "model", model)
}
if stream {
- payload = helps.SetBoolIfDifferent(payload, "stream", true)
+ helps.SetBoolIfDifferentDoc(doc, "stream", true)
} else {
- payload, _ = sjson.DeleteBytes(payload, "stream")
+ _ = doc.Delete("stream")
}
- return payload, "application/json", nil
+ return doc.Bytes(), "application/json", nil
}
mediaType, params, errParse := mime.ParseMediaType(contentType)
@@ -993,30 +1076,47 @@ func (e *OpenAICompatExecutor) applyPromptCacheKeyWithCompat(ctx context.Context
if compat == nil || !compat.SupportPromptCacheKey {
return translated, nil
}
+ doc := ojson.NewDoc(translated)
+ if errCacheKey := e.applyPromptCacheKeyWithCompatDoc(ctx, auth, compat, from, baseModel, req, opts, doc); errCacheKey != nil {
+ return translated, errCacheKey
+ }
+ return doc.Bytes(), nil
+}
- for _, payload := range [][]byte{req.Payload, opts.OriginalRequest, translated} {
- if promptCacheKey := strings.TrimSpace(gjson.GetBytes(payload, "prompt_cache_key").String()); promptCacheKey != "" {
- return helps.SetStringIfDifferent(translated, "prompt_cache_key", promptCacheKey), nil
+func (e *OpenAICompatExecutor) applyPromptCacheKeyWithCompatDoc(ctx context.Context, auth *cliproxyauth.Auth, compat *config.OpenAICompatibility, from sdktranslator.Format, baseModel string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) error {
+ if doc == nil || compat == nil || !compat.SupportPromptCacheKey {
+ return nil
+ }
+
+ for _, payload := range [][]byte{req.Payload, opts.OriginalRequest} {
+ if promptCacheKey := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "prompt_cache_key").String()); promptCacheKey != "" {
+ helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", promptCacheKey)
+ return nil
}
}
+ if promptCacheKey := strings.TrimSpace(doc.Get("prompt_cache_key").String()); promptCacheKey != "" {
+ helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", promptCacheKey)
+ return nil
+ }
- modelName := strings.TrimSpace(gjson.GetBytes(translated, "model").String())
+ modelName := strings.TrimSpace(doc.Get("model").String())
if modelName == "" {
modelName = baseModel
}
if sourceFormatEqual(from, sdktranslator.FormatClaude) {
cached, ok, errCache := helps.ClaudeCodePromptCache(ctx, modelName, req.Payload, opts.Headers)
if errCache != nil {
- return translated, errCache
+ return errCache
}
if ok {
- return helps.SetStringIfDifferent(translated, "prompt_cache_key", cached.ID), nil
+ helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", cached.ID)
+ return nil
}
}
sessionID := helps.ProviderSessionUUID(e.provider, opts.Metadata, req.Metadata)
if sessionID == "" {
- return translated, nil
+ return nil
}
provider := strings.TrimSpace(e.provider)
if provider == "" {
@@ -1030,7 +1130,8 @@ func (e *OpenAICompatExecutor) applyPromptCacheKeyWithCompat(ctx context.Context
sessionID,
}, "\x00")
promptCacheKey := uuid.NewSHA1(uuid.NameSpaceOID, []byte(identity)).String()
- return helps.SetStringIfDifferent(translated, "prompt_cache_key", promptCacheKey), nil
+ helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", promptCacheKey)
+ return nil
}
func (e *OpenAICompatExecutor) resolveCredentials(auth *cliproxyauth.Auth) (baseURL, apiKey string) {
@@ -1128,8 +1229,8 @@ func openAICompatErrorEvent(eventName string) bool {
}
func openAICompatChunkHasFinishReason(payload []byte) bool {
- for _, choice := range gjson.GetBytes(payload, "choices").Array() {
- if reason := choice.Get("finish_reason"); reason.Exists() && reason.Type != gjson.Null && strings.TrimSpace(reason.String()) != "" {
+ for _, choice := range ojson.GetBytesNoCopy(payload, "choices").Array() {
+ if reason := choice.Get("finish_reason"); reason.Exists() && reason.Type != ojson.Null && strings.TrimSpace(reason.String()) != "" {
return true
}
}
@@ -1140,16 +1241,16 @@ func openAICompatStreamDataError(payload []byte, eventName string) (statusErr, b
if len(payload) == 0 || !json.Valid(payload) {
return statusErr{}, false
}
- payloadType := gjson.GetBytes(payload, "type").String()
+ payloadType := ojson.GetBytesNoCopy(payload, "type").String()
hasError := false
for _, path := range []string{"error", "response.error"} {
- errorNode := gjson.GetBytes(payload, path)
+ errorNode := ojson.GetBytesNoCopy(payload, path)
if errorNode.Exists() && errorNode.Raw != "null" {
hasError = true
break
}
}
- hasTopLevelErrorFields := gjson.GetBytes(payload, "code").Exists() && gjson.GetBytes(payload, "message").Exists()
+ hasTopLevelErrorFields := ojson.GetBytesNoCopy(payload, "code").Exists() && ojson.GetBytesNoCopy(payload, "message").Exists()
if !hasError && !strings.EqualFold(payloadType, "error") && !strings.EqualFold(payloadType, "response.error") && !strings.EqualFold(payloadType, "response.failed") &&
!openAICompatErrorEvent(eventName) && !hasTopLevelErrorFields {
return statusErr{}, false
@@ -1157,7 +1258,7 @@ func openAICompatStreamDataError(payload []byte, eventName string) (statusErr, b
status := 0
for _, path := range []string{"status", "status_code", "error.status", "error.status_code", "response.error.status", "response.error.status_code"} {
- status = int(gjson.GetBytes(payload, path).Int())
+ status = int(ojson.GetBytesNoCopy(payload, path).Int())
if status >= http.StatusBadRequest && status <= 599 {
break
}
@@ -1255,8 +1356,8 @@ func openAICompatRetryAfter(status int, headers http.Header, body []byte, now ti
return retryAfter
}
- code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.code").String()))
- message := strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "error.message").String()))
+ code := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.code").String()))
+ message := strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "error.message").String()))
if strings.Contains(code, "tpmratelimitexceeded") ||
(strings.Contains(message, "tokens per minute") && strings.Contains(message, "limit") && strings.Contains(message, "exceeded")) {
delay := openAICompatTPMFallbackRetryAfter
diff --git a/internal/runtime/executor/openai_compat_executor_compact_test.go b/internal/runtime/executor/openai_compat_executor_compact_test.go
index 0f9ba8dd5a5..f5f71d4d537 100644
--- a/internal/runtime/executor/openai_compat_executor_compact_test.go
+++ b/internal/runtime/executor/openai_compat_executor_compact_test.go
@@ -15,13 +15,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestOpenAICompatExecutorCompactPassthrough(t *testing.T) {
@@ -66,13 +66,13 @@ func TestOpenAICompatExecutorCompactPassthrough(t *testing.T) {
if gotPath != "/v1/responses/compact" {
t.Fatalf("path = %q, want %q", gotPath, "/v1/responses/compact")
}
- if !gjson.GetBytes(gotBody, "input").Exists() {
+ if !ojson.GetBytes(gotBody, "input").Exists() {
t.Fatalf("expected input in body")
}
- if gjson.GetBytes(gotBody, "messages").Exists() {
+ if ojson.GetBytes(gotBody, "messages").Exists() {
t.Fatalf("unexpected messages in body")
}
- if gjson.GetBytes(gotBody, "prompt_cache_key").Exists() {
+ if ojson.GetBytes(gotBody, "prompt_cache_key").Exists() {
t.Fatalf("unexpected prompt_cache_key in responses compact body: %s", string(gotBody))
}
if string(resp.Payload) != `{"id":"resp_1","object":"response.compaction","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}` {
@@ -119,7 +119,7 @@ func TestOpenAICompatExecutorPayloadOverrideWinsOverThinkingSuffix(t *testing.T)
if err != nil {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "reasoning_effort").String(); got != "low" {
+ if got := ojson.GetBytes(gotBody, "reasoning_effort").String(); got != "low" {
t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, "low", string(gotBody))
}
}
@@ -239,7 +239,7 @@ func TestOpenAICompatExecutorApplyPromptCacheKey(t *testing.T) {
if errApply != nil {
t.Fatalf("applyPromptCacheKey error: %v", errApply)
}
- gotKey := gjson.GetBytes(translated, "prompt_cache_key").String()
+ gotKey := ojson.GetBytes(translated, "prompt_cache_key").String()
if test.wantKey != "" {
if gotKey != test.wantKey {
t.Fatalf("prompt_cache_key = %q, want %q", gotKey, test.wantKey)
@@ -297,7 +297,7 @@ func TestOpenAICompatExecutorPromptCacheKeyCallerValueWinsPayloadOverride(t *tes
if errApply != nil {
t.Fatalf("applyPromptCacheKey error: %v", errApply)
}
- if got := gjson.GetBytes(translated, "prompt_cache_key").String(); got != test.want {
+ if got := ojson.GetBytes(translated, "prompt_cache_key").String(); got != test.want {
t.Fatalf("prompt_cache_key = %q, want %q", got, test.want)
}
})
@@ -333,7 +333,7 @@ func TestOpenAICompatExecutorPromptCacheKeyIsModelAndProtocolScoped(t *testing.T
if errApply != nil {
t.Fatalf("applyPromptCacheKey error: %v", errApply)
}
- return gjson.GetBytes(translated, "prompt_cache_key").String()
+ return ojson.GetBytes(translated, "prompt_cache_key").String()
}
baseKey := derive(t, "gpt-5.6", "openai")
@@ -386,7 +386,7 @@ func TestOpenAICompatExecutorPromptCacheKeyUsesConfigIndex(t *testing.T) {
if errApply != nil {
t.Fatalf("applyPromptCacheKey error: %v", errApply)
}
- gotPresent := gjson.GetBytes(translated, "prompt_cache_key").String() != ""
+ gotPresent := ojson.GetBytes(translated, "prompt_cache_key").String() != ""
if gotPresent != test.wantPresent {
t.Fatalf("prompt_cache_key present = %t, want %t; body=%s", gotPresent, test.wantPresent, string(translated))
}
@@ -421,7 +421,7 @@ func TestOpenAICompatExecutorPromptCacheKeyIgnoresConfigIndexForNonConfigAuth(t
if errApply != nil {
t.Fatalf("applyPromptCacheKey error: %v", errApply)
}
- if gjson.GetBytes(translated, "prompt_cache_key").Exists() {
+ if ojson.GetBytes(translated, "prompt_cache_key").Exists() {
t.Fatalf("unexpected prompt_cache_key for non-config auth: %s", string(translated))
}
}
@@ -460,7 +460,7 @@ func TestOpenAICompatExecutorPromptCacheKeyExecute(t *testing.T) {
if errExecute != nil {
t.Fatalf("Execute error: %v", errExecute)
}
- if gotKey := gjson.GetBytes(gotBody, "prompt_cache_key").String(); gotKey == "" {
+ if gotKey := ojson.GetBytes(gotBody, "prompt_cache_key").String(); gotKey == "" {
t.Fatalf("prompt_cache_key is missing from upstream body: %s", string(gotBody))
}
}
@@ -506,7 +506,7 @@ func TestOpenAICompatExecutorPromptCacheKeyExecuteStream(t *testing.T) {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
}
- if gotKey := gjson.GetBytes(gotBody, "prompt_cache_key").String(); gotKey == "" {
+ if gotKey := ojson.GetBytes(gotBody, "prompt_cache_key").String(); gotKey == "" {
t.Fatalf("prompt_cache_key is missing from upstream stream body: %s", string(gotBody))
}
}
@@ -553,7 +553,7 @@ func TestOpenAICompatExecutorPromptCacheKeyStreamCompactSkipped(t *testing.T) {
t.Fatalf("stream chunk error: %v", chunk.Err)
}
}
- if gjson.GetBytes(gotBody, "prompt_cache_key").Exists() {
+ if ojson.GetBytes(gotBody, "prompt_cache_key").Exists() {
t.Fatalf("unexpected prompt_cache_key in streaming compact body: %s", string(gotBody))
}
}
@@ -609,13 +609,13 @@ func TestOpenAICompatExecutorImagesGenerationsPassthrough(t *testing.T) {
if gotContentType != "application/json" {
t.Fatalf("content type = %q, want application/json", gotContentType)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "upstream-image" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "upstream-image" {
t.Fatalf("model = %q, want upstream-image; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "prompt_cache_key").Exists() {
+ if ojson.GetBytes(gotBody, "prompt_cache_key").Exists() {
t.Fatalf("unexpected prompt_cache_key in image body: %s", string(gotBody))
}
- if got := gjson.GetBytes(resp.Payload, "data.0.b64_json").String(); got != "AA==" {
+ if got := ojson.GetBytes(resp.Payload, "data.0.b64_json").String(); got != "AA==" {
t.Fatalf("response payload = %s", string(resp.Payload))
}
}
@@ -672,10 +672,10 @@ func TestOpenAICompatExecutorImagesGenerationsStreamsUpstream(t *testing.T) {
if gotAccept != "text/event-stream" {
t.Fatalf("accept = %q, want text/event-stream", gotAccept)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "upstream-image" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "upstream-image" {
t.Fatalf("model = %q, want upstream-image; body=%s", got, string(gotBody))
}
- if !gjson.GetBytes(gotBody, "stream").Bool() {
+ if !ojson.GetBytes(gotBody, "stream").Bool() {
t.Fatalf("stream flag missing from upstream body: %s", string(gotBody))
}
if !strings.Contains(streamed.String(), "event: image_generation.partial") || !strings.Contains(streamed.String(), "data: [DONE]") {
@@ -903,7 +903,7 @@ func TestOpenAICompatExecutorStreamSkipsKeepAliveUntilDataLine(t *testing.T) {
}
got.Write(chunk.Payload)
}
- if gjson.Get(got.String(), "choices.0.delta.content").String() != "hello" {
+ if ojson.Get(got.String(), "choices.0.delta.content").String() != "hello" {
t.Fatalf("stream payload = %s", got.String())
}
}
@@ -1335,14 +1335,344 @@ func TestOpenAICompatExecutorStreamDropsChunksAfterDone(t *testing.T) {
if strings.Contains(p, `"cost"`) {
t.Fatalf("post-DONE cost chunk was forwarded: %s", p)
}
- if !gjson.Get(p, "id").Exists() {
+ if !ojson.Get(p, "id").Exists() {
t.Fatalf("chunk missing id: %s", p)
}
}
- if gjson.Get(payloads[0], "choices.0.delta.content").String() != "hi" {
+ if ojson.Get(payloads[0], "choices.0.delta.content").String() != "hi" {
t.Fatalf("first chunk = %s", payloads[0])
}
- if gjson.Get(payloads[1], "choices.0.finish_reason").String() != "stop" {
+ if ojson.Get(payloads[1], "choices.0.delta.content").String() != "" || ojson.Get(payloads[1], "choices.0.finish_reason").String() != "stop" {
t.Fatalf("second chunk = %s", payloads[1])
}
}
+
+func TestOpenAICompatExecutor_ResponsesCompactionTriggerAndReplay_Issue7248(t *testing.T) {
+ var seenPaths []string
+ var seenBodies [][]byte
+ var returnIncomplete bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body, errRead := io.ReadAll(r.Body)
+ if errRead != nil {
+ t.Errorf("read upstream body: %v", errRead)
+ http.Error(w, "read error", http.StatusBadRequest)
+ return
+ }
+ seenPaths = append(seenPaths, r.URL.Path)
+ seenBodies = append(seenBodies, body)
+
+ if r.URL.Path == "/v1/responses/compact" {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"id":"resp_cmp_native","object":"response.compaction","status":"completed","output":[{"id":"cmp_native","type":"compaction","status":"completed","encrypted_content":"gAAAA_native"}],"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}`)
+ return
+ }
+
+ if returnIncomplete {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"id":"chatcmpl_inc","object":"chat.completion","created":1773896263,"model":"cline-pass/deepseek-v4.1-flash","choices":[{"index":0,"message":{"role":"assistant","reasoning_content":"thinking","content":"Partial truncated summary."},"finish_reason":"length"}],"usage":{"prompt_tokens":12,"completion_tokens":6,"total_tokens":18}}`)
+ return
+ }
+
+ if ojson.GetBytes(body, "stream").Bool() {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(w,
+ `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1773896263,"model":"cline-pass/deepseek-v4.1-flash","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Checking context..."},"finish_reason":null}]}`+"\n\n"+
+ `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","created":1773896263,"model":"cline-pass/deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"COBALT-731"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}}`+"\n\n"+
+ "data: [DONE]\n\n",
+ )
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{"id":"chatcmpl_sum","object":"chat.completion","created":1773896263,"model":"cline-pass/deepseek-v4.1-flash","choices":[{"index":0,"message":{"role":"assistant","reasoning_content":"Summarizing session context.","content":"Summary retaining COBALT-731."},"finish_reason":"stop"}],"usage":{"prompt_tokens":12,"completion_tokens":6,"total_tokens":18,"prompt_tokens_details":{"cached_tokens":4},"completion_tokens_details":{"reasoning_tokens":2}}}`)
+ }))
+ defer server.Close()
+
+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{{
+ Models: []config.PayloadModelRule{{Name: "cline-pass/deepseek-v4.1-flash", Protocol: "openai"}},
+ Params: map[string]any{
+ "max_tokens": 2048,
+ },
+ }},
+ },
+ })
+ auth := &cliproxyauth.Auth{
+ Provider: "openai-compatibility",
+ Attributes: map[string]string{
+ "base_url": server.URL + "/v1",
+ "api_key": "test-cline-key",
+ },
+ }
+
+ triggerPayload := []byte(`{
+ "model": "cline-pass/deepseek-v4.1-flash",
+ "stream": true,
+ "input": [
+ {
+ "type": "additional_tools",
+ "tools": [
+ {
+ "type": "function",
+ "name": "exec_command",
+ "description": "Execute a shell command",
+ "parameters": {"type": "object", "properties": {"cmd": {"type": "string"}}}
+ }
+ ]
+ },
+ {
+ "type": "message",
+ "role": "user",
+ "content": [{"type": "input_text", "text": "Remember our synthetic project marker COBALT-731 and reply with ACK."}]
+ },
+ {"type": "compaction_trigger"}
+ ]
+ }`)
+
+ streamResult, errStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream(compaction_trigger) error = %v", errStream)
+ }
+
+ var streamBuf bytes.Buffer
+ for chunk := range streamResult.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("stream chunk error = %v", chunk.Err)
+ }
+ streamBuf.Write(chunk.Payload)
+ }
+
+ completed := claudeCompactionCompletedResponse(t, streamBuf.String())
+ outputItems := completed.Get("response.output").Array()
+ if len(outputItems) != 1 || outputItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in stream response, got: %s", completed.Raw)
+ }
+ capsule := outputItems[0].Get("encrypted_content").String()
+ if !strings.HasPrefix(capsule, "cpa-ag-compact-v1:") {
+ t.Fatalf("expected CPA compaction capsule prefix, got %q", capsule)
+ }
+ if gotCached := completed.Get("response.usage.input_tokens_details.cached_tokens").Int(); gotCached != 4 {
+ t.Fatalf("cached_tokens = %d, want 4", gotCached)
+ }
+ if gotReasoning := completed.Get("response.usage.output_tokens_details.reasoning_tokens").Int(); gotReasoning != 2 {
+ t.Fatalf("reasoning_tokens = %d, want 2", gotReasoning)
+ }
+ firstSeen := seenBodies[0]
+ if ojson.GetBytes(firstSeen, "stream").Bool() {
+ t.Fatalf("expected non-stream summary request upstream, got: %s", firstSeen)
+ }
+ if ojson.GetBytes(firstSeen, "tools").Exists() {
+ t.Fatalf("expected tools (including additional_tools) to be stripped from summary request, got: %s", firstSeen)
+ }
+
+ nonStreamResp, errExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errExec != nil {
+ t.Fatalf("Execute(compaction_trigger) error = %v", errExec)
+ }
+ nonStreamItems := ojson.GetBytes(nonStreamResp.Payload, "output").Array()
+ if len(nonStreamItems) != 1 || nonStreamItems[0].Get("type").String() != "compaction" {
+ t.Fatalf("expected single compaction item in non-stream response, got: %s", nonStreamResp.Payload)
+ }
+
+ replayPayload := []byte(fmt.Sprintf(`{
+ "model": "cline-pass/deepseek-v4.1-flash",
+ "stream": true,
+ "input": [
+ {"type": "compaction", "encrypted_content": "foreign-opaque-token"},
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "What is the project marker?"}]}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+
+ contStream, errCont := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: replayPayload,
+ })
+ if errCont != nil {
+ t.Fatalf("ExecuteStream(replay) error = %v", errCont)
+ }
+ for chunk := range contStream.Chunks {
+ if chunk.Err != nil {
+ t.Fatalf("continuation stream chunk error = %v", chunk.Err)
+ }
+ }
+
+ lastSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(lastSeen, []byte("Summary retaining COBALT-731.")) {
+ t.Fatalf("expected expanded summary in continuation upstream request, got: %s", lastSeen)
+ }
+ if bytes.Contains(lastSeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("expected foreign compaction item to be dropped, got: %s", lastSeen)
+ }
+
+ if _, errNonStreamReplay := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: replayPayload,
+ }); errNonStreamReplay != nil {
+ t.Fatalf("Execute(replay) error = %v", errNonStreamReplay)
+ }
+ nonStreamReplaySeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(nonStreamReplaySeen, []byte("Summary retaining COBALT-731.")) || bytes.Contains(nonStreamReplaySeen, []byte("foreign-opaque-token")) {
+ t.Fatalf("unexpected non-stream replay upstream body: %s", nonStreamReplaySeen)
+ }
+
+ if _, errCountTokens := executor.CountTokens(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "gpt-4o",
+ Payload: replayPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: replayPayload,
+ }); errCountTokens != nil {
+ t.Fatalf("CountTokens(replay) error = %v", errCountTokens)
+ }
+
+ strippedTriggerPayload := []byte(`{"model":"cline-pass/deepseek-v4.1-flash","stream":false,"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"Remember our synthetic project marker COBALT-731 and reply with ACK."}]}]}`)
+ origOnlyResp, errOrigOnly := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: strippedTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errOrigOnly != nil {
+ t.Fatalf("Execute(OriginalRequest-only trigger) error = %v", errOrigOnly)
+ }
+ if got := ojson.GetBytes(origOnlyResp.Payload, "output.0.type").String(); got != "compaction" {
+ t.Fatalf("OriginalRequest-only trigger output[0].type = %q, want compaction", got)
+ }
+
+ corruptedPayload := []byte(`{
+ "model": "cline-pass/deepseek-v4.1-flash",
+ "stream": false,
+ "input": [
+ {"type": "compaction", "encrypted_content": "cpa-ag-compact-v1:not-valid"},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "continue"}]}
+ ]
+ }`)
+ _, errCorrupt := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: corruptedPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: corruptedPayload,
+ })
+ if errCorrupt == nil {
+ t.Fatal("expected error for corrupted compaction capsule, got nil")
+ }
+ assertStatusErr(t, errCorrupt, http.StatusBadRequest)
+
+ secondTriggerPayload := []byte(fmt.Sprintf(`{
+ "model": "cline-pass/deepseek-v4.1-flash",
+ "stream": false,
+ "input": [
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Second phase work."}]},
+ {"type": "compaction_trigger"}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+ secondResp, errSecond := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: secondTriggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: secondTriggerPayload,
+ })
+ if errSecond != nil {
+ t.Fatalf("Execute(second compaction_trigger) error = %v", errSecond)
+ }
+ if got := len(ojson.GetBytes(secondResp.Payload, "output").Array()); got != 1 {
+ t.Fatalf("second compaction output len = %d, want 1", got)
+ }
+ secondSeen := seenBodies[len(seenBodies)-1]
+ if !bytes.Contains(secondSeen, []byte("Summary retaining COBALT-731.")) {
+ t.Fatalf("expected first capsule summary in second compaction request, got: %s", secondSeen)
+ }
+ if got := ojson.GetBytes(secondSeen, "max_tokens").Int(); got != 2048 {
+ t.Fatalf("max_tokens = %d, want payload rule 2048; body=%s", got, secondSeen)
+ }
+
+ compactPassthroughPayload := []byte(fmt.Sprintf(`{
+ "model": "cline-pass/deepseek-v4.1-flash",
+ "input": [
+ {"type": "compaction", "encrypted_content": "foreign-native-token"},
+ {"type": "compaction", "id": %q, "encrypted_content": %q},
+ {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Compact again."}]}
+ ]
+ }`, outputItems[0].Get("id").String(), capsule))
+ if _, errCompactEndpoint := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: compactPassthroughPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ Alt: "responses/compact",
+ }); errCompactEndpoint != nil {
+ t.Fatalf("Execute(responses/compact) error = %v", errCompactEndpoint)
+ }
+ compactSeen := seenBodies[len(seenBodies)-1]
+ if seenPaths[len(seenPaths)-1] != "/v1/responses/compact" {
+ t.Fatalf("responses/compact path = %q, want /v1/responses/compact", seenPaths[len(seenPaths)-1])
+ }
+ if !bytes.Contains(compactSeen, []byte("Summary retaining COBALT-731.")) {
+ t.Fatalf("expected CPA capsule expanded on /responses/compact passthrough, got: %s", compactSeen)
+ }
+ if !bytes.Contains(compactSeen, []byte("foreign-native-token")) {
+ t.Fatalf("expected foreign compaction item preserved on /responses/compact passthrough, got: %s", compactSeen)
+ }
+
+ returnIncomplete = true
+ _, errIncExec := executor.Execute(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ OriginalRequest: triggerPayload,
+ })
+ if errIncExec == nil {
+ t.Fatal("expected Execute(compaction_trigger) to fail on incomplete summary, got nil")
+ }
+ assertStatusErr(t, errIncExec, http.StatusBadGateway)
+
+ _, errIncStream := executor.ExecuteStream(context.Background(), auth, cliproxyexecutor.Request{
+ Model: "cline-pass/deepseek-v4.1-flash",
+ Payload: triggerPayload,
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FormatOpenAIResponse,
+ ResponseFormat: sdktranslator.FormatOpenAIResponse,
+ Stream: true,
+ OriginalRequest: triggerPayload,
+ })
+ if errIncStream == nil {
+ t.Fatal("expected ExecuteStream(compaction_trigger) to fail on incomplete summary, got nil")
+ }
+ assertStatusErr(t, errIncStream, http.StatusBadGateway)
+}
diff --git a/internal/runtime/executor/openai_compat_executor_max_tokens_test.go b/internal/runtime/executor/openai_compat_executor_max_tokens_test.go
index ff455092d79..19dafacab94 100644
--- a/internal/runtime/executor/openai_compat_executor_max_tokens_test.go
+++ b/internal/runtime/executor/openai_compat_executor_max_tokens_test.go
@@ -7,11 +7,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
@@ -67,10 +67,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens").Int(); got != 1024 {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens").Int(); got != 1024 {
t.Fatalf("max_completion_tokens = %d, want 1024; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -89,10 +89,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens").Int(); got != 512 {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens").Int(); got != 512 {
t.Fatalf("max_completion_tokens = %d, want 512; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -111,10 +111,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_tokens").Int(); got != 1024 {
+ if got := ojson.GetBytes(gotBody, "max_tokens").Int(); got != 1024 {
t.Fatalf("max_tokens = %d, want 1024; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -133,10 +133,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_tokens").Int(); got != 512 {
+ if got := ojson.GetBytes(gotBody, "max_tokens").Int(); got != 512 {
t.Fatalf("max_tokens = %d, want 512; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -155,10 +155,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_completion_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -177,10 +177,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -199,10 +199,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_completion_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -221,10 +221,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalization(t *testing.T) {
t.Fatalf("Execute error: %v", err)
}
- if got := gjson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -285,10 +285,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalizationStream(t *testing.T) {
for range res.Chunks {
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens").Int(); got != 256 {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens").Int(); got != 256 {
t.Fatalf("max_completion_tokens = %d, want 256; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -309,10 +309,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalizationStream(t *testing.T) {
for range res.Chunks {
}
- if got := gjson.GetBytes(gotBody, "max_tokens").Int(); got != 256 {
+ if got := ojson.GetBytes(gotBody, "max_tokens").Int(); got != 256 {
t.Fatalf("max_tokens = %d, want 256; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -333,10 +333,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalizationStream(t *testing.T) {
for range res.Chunks {
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_completion_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -357,10 +357,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalizationStream(t *testing.T) {
for range res.Chunks {
}
- if got := gjson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -381,10 +381,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalizationStream(t *testing.T) {
for range res.Chunks {
}
- if got := gjson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_completion_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_completion_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent; body=%s", string(gotBody))
}
})
@@ -405,10 +405,10 @@ func TestOpenAICompatExecutor_MaxTokensNormalizationStream(t *testing.T) {
for range res.Chunks {
}
- if got := gjson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytes(gotBody, "max_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_tokens = %v, want null; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "max_completion_tokens").Exists() {
+ if ojson.GetBytes(gotBody, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; body=%s", string(gotBody))
}
})
diff --git a/internal/runtime/executor/openai_compat_executor_reasoning_test.go b/internal/runtime/executor/openai_compat_executor_reasoning_test.go
index 7d11be40da5..da3fa8c86ed 100644
--- a/internal/runtime/executor/openai_compat_executor_reasoning_test.go
+++ b/internal/runtime/executor/openai_compat_executor_reasoning_test.go
@@ -7,12 +7,12 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestOpenAICompatExecutorUsesCompatibleClaudeTranslation(t *testing.T) {
@@ -48,7 +48,7 @@ func TestOpenAICompatExecutorUsesCompatibleClaudeTranslation(t *testing.T) {
t.Fatalf("Execute error: %v", errExecute)
}
- assistant := gjson.GetBytes(upstreamBody, "messages.0")
+ assistant := ojson.GetBytes(upstreamBody, "messages.0")
if got := assistant.Get("reasoning_content").String(); got != "prior reasoning" {
t.Fatalf("reasoning_content = %q, want %q; body=%s", got, "prior reasoning", upstreamBody)
}
diff --git a/internal/runtime/executor/openai_compat_executor_roles_test.go b/internal/runtime/executor/openai_compat_executor_roles_test.go
new file mode 100644
index 00000000000..4d8d3d0bf65
--- /dev/null
+++ b/internal/runtime/executor/openai_compat_executor_roles_test.go
@@ -0,0 +1,93 @@
+package executor
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+func TestOpenAICompatExecutorPreservesResponsesInstructionRoles(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
+ bodies := make(chan []byte, 1)
+ server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+ body, errRead := io.ReadAll(request.Body)
+ if errRead != nil {
+ http.Error(writer, "cannot read request", http.StatusBadRequest)
+ return
+ }
+ bodies <- body
+ if stream {
+ writer.Header().Set("Content-Type", "text/event-stream")
+ _, _ = io.WriteString(writer, "data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n")
+ return
+ }
+ writer.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(writer, `{"id":"chatcmpl-test","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
+ }))
+ defer server.Close()
+ executor := NewOpenAICompatExecutor("openai-compatibility", &config.Config{})
+ auth := &cliproxyauth.Auth{Provider: "openai-compatibility", Attributes: map[string]string{"base_url": server.URL + "/v1", "api_key": "test-key"}}
+ request := cliproxyexecutor.Request{
+ Model: "test-model",
+ Payload: []byte(`{"instructions":"base instructions","input":[
+ {"role":"developer","content":"mode: plan"},
+ {"role":"user","content":"mode: default is only user text"},
+ {"role":"developer","content":[{"type":"input_text","text":"mode: default"}]},
+ {"type":"function_call","call_id":"call_1","name":"inspect","arguments":"{}"},
+ {"type":"function_call_output","call_id":"call_1","output":"read-only result"},
+ {"role":"developer","content":"mode: plan"},
+ {"role":"user","content":"continue"}
+ ],"tools":[{"type":"function","name":"inspect","parameters":{"type":"object","properties":{}}}]}`),
+ }
+ options := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse, ResponseFormat: sdktranslator.FormatOpenAIResponse, Stream: stream}
+ if stream {
+ result, errStream := executor.ExecuteStream(context.Background(), auth, request, options)
+ if errStream != nil {
+ t.Fatal(errStream)
+ }
+ for chunk := range result.Chunks {
+ if chunk.Err != nil {
+ t.Fatal(chunk.Err)
+ }
+ }
+ } else if _, errExec := executor.Execute(context.Background(), auth, request, options); errExec != nil {
+ t.Fatal(errExec)
+ }
+ body := <-bodies
+ messages := ojson.GetBytes(body, "messages").Array()
+ wantRoles := []string{"system", "developer", "user", "developer", "assistant", "tool", "developer", "user"}
+ wantTexts := []string{"base instructions", "mode: plan", "mode: default is only user text", "mode: default", "", "read-only result", "mode: plan", "continue"}
+ if len(messages) != len(wantRoles) {
+ t.Fatalf("message count = %d, want %d; body=%s", len(messages), len(wantRoles), body)
+ }
+ for index, message := range messages {
+ if role := message.Get("role").String(); role != wantRoles[index] {
+ t.Errorf("message %d role = %q, want %q", index, role, wantRoles[index])
+ }
+ text := message.Get("content")
+ if text.IsArray() {
+ text = text.Get("0.text")
+ }
+ if text.String() != wantTexts[index] {
+ t.Errorf("message %d text = %q, want %q", index, text.String(), wantTexts[index])
+ }
+ }
+ if got := messages[4].Get("tool_calls.0.id").String(); got != "call_1" {
+ t.Errorf("tool call id = %q, want call_1", got)
+ }
+ if got := messages[5].Get("tool_call_id").String(); got != "call_1" {
+ t.Errorf("tool result id = %q, want call_1", got)
+ }
+ })
+ }
+}
diff --git a/internal/runtime/executor/openai_compat_executor_tool_results_test.go b/internal/runtime/executor/openai_compat_executor_tool_results_test.go
index 307e03488de..faa0bd2314c 100644
--- a/internal/runtime/executor/openai_compat_executor_tool_results_test.go
+++ b/internal/runtime/executor/openai_compat_executor_tool_results_test.go
@@ -8,11 +8,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestOpenAICompatExecutorToolResultContentByInputModalities(t *testing.T) {
@@ -84,9 +84,9 @@ func TestOpenAICompatExecutorToolResultContentByInputModalities(t *testing.T) {
t.Fatalf("Execute error: %v", errExecute)
}
- toolContent := gjson.GetBytes(gotBody, "messages.1.content")
+ toolContent := ojson.GetBytes(gotBody, "messages.1.content")
if tt.wantString {
- if toolContent.Type != gjson.String {
+ if toolContent.Type != ojson.String {
t.Fatalf("tool content type = %s, want string; body=%s", toolContent.Type, string(gotBody))
}
want := "image inspected\n\n[image omitted: unsupported by upstream]"
@@ -97,7 +97,7 @@ func TestOpenAICompatExecutorToolResultContentByInputModalities(t *testing.T) {
t.Fatalf("text-only model still received an image_url part:\n%s", string(gotBody))
}
} else {
- if toolContent.Type != gjson.String {
+ if toolContent.Type != ojson.String {
t.Fatalf("tool content type = %s, want string; body=%s", toolContent.Type, string(gotBody))
}
if toolContent.String() != "image inspected" {
diff --git a/internal/runtime/executor/openai_compat_executor_video_test.go b/internal/runtime/executor/openai_compat_executor_video_test.go
index 9c735a82b7d..f279fdf6b04 100644
--- a/internal/runtime/executor/openai_compat_executor_video_test.go
+++ b/internal/runtime/executor/openai_compat_executor_video_test.go
@@ -9,11 +9,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestOpenAICompatExecutor_VideoInput(t *testing.T) {
@@ -88,10 +88,10 @@ func TestOpenAICompatExecutor_VideoInput(t *testing.T) {
if captured.path != "/v1/chat/completions" {
t.Fatalf("upstream path = %q, want /v1/chat/completions", captured.path)
}
- if got := gjson.GetBytes(captured.body, "model").String(); got != "video-model" {
+ if got := ojson.GetBytes(captured.body, "model").String(); got != "video-model" {
t.Fatalf("upstream model = %q, want video-model", got)
}
- content := gjson.GetBytes(captured.body, "messages.0.content").Array()
+ content := ojson.GetBytes(captured.body, "messages.0.content").Array()
if len(content) != 3 {
t.Fatalf("upstream content has %d parts, want text and two videos: %s", len(content), captured.body)
}
diff --git a/internal/runtime/executor/openai_compat_home_options_test.go b/internal/runtime/executor/openai_compat_home_options_test.go
index bab1fd4db8d..80060892771 100644
--- a/internal/runtime/executor/openai_compat_home_options_test.go
+++ b/internal/runtime/executor/openai_compat_home_options_test.go
@@ -10,6 +10,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
cpaauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -17,7 +18,6 @@ import (
cpaexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestHomeV8PromptCacheKey(t *testing.T) {
@@ -33,7 +33,7 @@ func TestHomeV8PromptCacheKey(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- got := gjson.GetBytes(out, "prompt_cache_key").String()
+ got := ojson.GetBytes(out, "prompt_cache_key").String()
t.Logf("home=%v prompt_cache_key=%q", home, got)
if got != "fixture-cache-key" {
t.Errorf("home=%v: enabled prompt cache option is not consumed", home)
@@ -71,10 +71,10 @@ func TestHomeV8CompatOptionsReachUpstream(t *testing.T) {
if enabled {
field = "max_completion_tokens"
}
- if gjson.GetBytes(body, field).Int() != 512 {
+ if ojson.GetBytes(body, field).Int() != 512 {
t.Fatalf("enabled=%v missing %s in %s", enabled, field, body)
}
- if enabled && gjson.GetBytes(body, "prompt_cache_key").String() != "fixture-cache" {
+ if enabled && ojson.GetBytes(body, "prompt_cache_key").String() != "fixture-cache" {
t.Fatalf("prompt cache missing: %s", body)
}
}
@@ -106,7 +106,7 @@ func TestHomeV8PromptCacheOverrideAndFallback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(result, "prompt_cache_key").Exists(); got != tc.want {
+ if got := ojson.GetBytes(result, "prompt_cache_key").Exists(); got != tc.want {
t.Fatalf("cache key present=%v, want=%v", got, tc.want)
}
})
@@ -199,7 +199,7 @@ func TestHomeCompatOptionsUseSelectedRoute(t *testing.T) {
if enabled {
field, otherField = otherField, field
}
- if gjson.GetBytes(body, field).Int() != 64 || gjson.GetBytes(body, otherField).Exists() {
+ if ojson.GetBytes(body, field).Int() != 64 || ojson.GetBytes(body, otherField).Exists() {
t.Errorf("expected only %s=64 in %s", field, body)
}
if hasImage := strings.Contains(string(body), "image_url"); hasImage == enabled {
diff --git a/internal/runtime/executor/openai_responses_signature.go b/internal/runtime/executor/openai_responses_signature.go
index 59469dd32ce..2b5170caa48 100644
--- a/internal/runtime/executor/openai_responses_signature.go
+++ b/internal/runtime/executor/openai_responses_signature.go
@@ -6,23 +6,21 @@ import (
"net/url"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
-func openaiResponsesReasoningSummaryIsEmpty(summary gjson.Result) bool {
- if !summary.Exists() || summary.Type == gjson.Null {
+func openaiResponsesReasoningSummaryIsEmpty(summary ojson.Result) bool {
+ if !summary.Exists() || summary.Type == ojson.Null {
return true
}
return summary.IsArray() && len(summary.Array()) == 0
}
-func promoteOpenAIResponsesReasoningTextToSummary(itemRaw string, content gjson.Result) (string, error) {
+func promoteOpenAIResponsesReasoningTextToSummary(itemRaw string, content ojson.Result) (string, error) {
var b strings.Builder
b.WriteByte('[')
n := 0
@@ -34,7 +32,7 @@ func promoteOpenAIResponsesReasoningTextToSummary(itemRaw string, content gjson.
if text == "" {
continue
}
- partJSON, err := sjson.Set(`{"type":"summary_text"}`, "text", text)
+ partJSON, err := ojson.Set(`{"type":"summary_text"}`, "text", text)
if err != nil {
return itemRaw, err
}
@@ -48,7 +46,36 @@ func promoteOpenAIResponsesReasoningTextToSummary(itemRaw string, content gjson.
if n == 0 {
return itemRaw, nil
}
- return sjson.SetRaw(itemRaw, "summary", b.String())
+ return ojson.SetRaw(itemRaw, "summary", b.String())
+}
+
+func promoteOpenAIResponsesReasoningTextToSummaryDoc(itemDoc *ojson.Doc, content ojson.Result) error {
+ var b strings.Builder
+ b.WriteByte('[')
+ n := 0
+ for _, part := range content.Array() {
+ if strings.TrimSpace(part.Get("type").String()) != "reasoning_text" {
+ continue
+ }
+ text := part.Get("text").String()
+ if text == "" {
+ continue
+ }
+ partJSON, err := ojson.Set(`{"type":"summary_text"}`, "text", text)
+ if err != nil {
+ return err
+ }
+ if n > 0 {
+ b.WriteByte(',')
+ }
+ b.WriteString(partJSON)
+ n++
+ }
+ b.WriteByte(']')
+ if n == 0 {
+ return nil
+ }
+ return itemDoc.SetRawBytes("summary", []byte(b.String()))
}
type metaSignaturePolicy int
@@ -66,10 +93,18 @@ func sanitizeOpenAIResponsesReasoningEncryptedContent(ctx context.Context, provi
return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, false, metaSignatureForeign, signature.SignatureProviderUnknown, false)
}
+func sanitizeOpenAIResponsesReasoningEncryptedContentDoc(ctx context.Context, provider string, doc *ojson.Doc) bool {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx, provider, doc, false, false, metaSignatureForeign, signature.SignatureProviderUnknown, false)
+}
+
func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(ctx context.Context, provider string, body []byte, isCompat bool) []byte {
return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(ctx, provider, body, isCompat, "")
}
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatDoc(ctx context.Context, provider string, doc *ojson.Doc, isCompat bool) bool {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelDoc(ctx, provider, doc, isCompat, "")
+}
+
// sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel is the
// compat variant that knows its target model: a positively recognized Meta
// Muse envelope is preserved whenever the upstream actually serves a
@@ -107,7 +142,19 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(ctx context
return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx, provider, body, isCompat, targetModel, "")
}
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelDoc(ctx context.Context, provider string, doc *ojson.Doc, isCompat bool, targetModel string) bool {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, provider, doc, isCompat, targetModel, "")
+}
+
func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(ctx context.Context, provider string, body []byte, isCompat bool, targetModel string, baseURL string) []byte {
+ doc := ojson.NewDoc(body)
+ if !sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx, provider, doc, isCompat, targetModel, baseURL) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURLDoc(ctx context.Context, provider string, doc *ojson.Doc, isCompat bool, targetModel string, baseURL string) bool {
targetProvider := signature.SignatureProviderFromModelName(targetModel)
metaPolicy := metaSignatureForeign
if targetProvider == signature.SignatureProviderMeta {
@@ -117,13 +164,17 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(c
metaPolicy = metaSignatureAllowRouted
}
}
- return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, isCompat, isCompat, metaPolicy, targetProvider, isOpenRouterUpstreamURL(baseURL))
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx, provider, doc, isCompat, isCompat, metaPolicy, targetProvider, isOpenRouterUpstreamURL(baseURL))
}
func sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign(ctx context.Context, provider string, body []byte) []byte {
return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureForeign, signature.SignatureProviderUnknown, false)
}
+func sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeignDoc(ctx context.Context, provider string, doc *ojson.Doc) bool {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx, provider, doc, false, true, metaSignatureForeign, signature.SignatureProviderUnknown, false)
+}
+
// sanitizeOpenAIResponsesReasoningEncryptedContentForMeta is the Meta-target
// variant and is deliberately native-only: only positively recognized Meta
// Muse envelopes without a router suffix may replay to api.meta.ai, whose
@@ -136,6 +187,10 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx context.Context
return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureNativeOnly, signature.SignatureProviderMeta, false)
}
+func sanitizeOpenAIResponsesReasoningEncryptedContentForMetaDoc(ctx context.Context, provider string, doc *ojson.Doc) bool {
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx, provider, doc, false, true, metaSignatureNativeOnly, signature.SignatureProviderMeta, false)
+}
+
// sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL gates Meta
// reasoning signatures by whether baseURL is the native Meta upstream
// (api.meta.ai, which accepts only unsuffixed envelopes) or a third-party
@@ -143,10 +198,18 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx context.Context
// router-suffixed Meta Muse envelopes verbatim when targeting OpenRouter, or
// strip the OpenRouter suffix for other providers).
func sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(ctx context.Context, provider string, body []byte, baseURL string) []byte {
+ doc := ojson.NewDoc(body)
+ if !sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURLDoc(ctx, provider, doc, baseURL) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURLDoc(ctx context.Context, provider string, doc *ojson.Doc, baseURL string) bool {
if isNativeMetaUpstreamURL(baseURL) {
- return sanitizeOpenAIResponsesReasoningEncryptedContentForMeta(ctx, provider, body)
+ return sanitizeOpenAIResponsesReasoningEncryptedContentForMetaDoc(ctx, provider, doc)
}
- return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx, provider, body, false, true, metaSignatureAllowRouted, signature.SignatureProviderMeta, isOpenRouterUpstreamURL(baseURL))
+ return sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx, provider, doc, false, true, metaSignatureAllowRouted, signature.SignatureProviderMeta, isOpenRouterUpstreamURL(baseURL))
}
func isNativeMetaUpstreamURL(rawURL string) bool {
@@ -183,10 +246,21 @@ func stripRouterSuffix(rawSignature, suffix string) string {
}
func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Context, provider string, body []byte, isCompat bool, keepForeign bool, metaPolicy metaSignaturePolicy, targetProvider signature.SignatureProvider, allowRouterSuffix bool) []byte {
- inputResult := util.GetGJSONBytesNoCopy(body, "input")
- if !inputResult.Exists() || !inputResult.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx, provider, doc, isCompat, keepForeign, metaPolicy, targetProvider, allowRouterSuffix) {
return body
}
+ return doc.Bytes()
+}
+
+func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptionsDoc(ctx context.Context, provider string, doc *ojson.Doc, isCompat bool, keepForeign bool, metaPolicy metaSignaturePolicy, targetProvider signature.SignatureProvider, allowRouterSuffix bool) bool {
+ if doc == nil || doc.Len() == 0 {
+ return false
+ }
+ inputResult := doc.Get("input")
+ if !inputResult.Exists() || !inputResult.IsArray() {
+ return false
+ }
provider = strings.TrimSpace(provider)
if provider == "" {
provider = "openai responses upstream"
@@ -197,14 +271,14 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
// treated as a store lookup and returns:
// Item with id '...' not found. Items are not persisted when `store` is set to false.
// Strip those orphan ids unless the request explicitly opts into store=true.
- stripOrphanReasoningIDs := !gjson.GetBytes(body, "store").Bool()
+ stripOrphanReasoningIDs := !doc.Get("store").Bool()
items := inputResult.Array()
// rebuilt accumulates the edited "input" array as JSON array bytes. It
// stays nil while no item needs editing so the common case (nothing to
// sanitize) does no allocation or rebuilding. Edits are applied directly
- // to each item's own raw JSON rather than re-parsing the whole body,
+ // to each item's own Doc rather than re-parsing the whole body,
// keeping the cost proportional to the item being edited.
var rebuilt []byte
itemsWritten := 0
@@ -243,7 +317,13 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
itemID = fmt.Sprintf("input[%d]", index)
}
- nextItem := item.Raw
+ var itemDoc *ojson.Doc
+ ensureItemDoc := func() *ojson.Doc {
+ if itemDoc == nil {
+ itemDoc = ojson.NewDoc([]byte(item.Raw))
+ }
+ return itemDoc
+ }
changed := false
// Official Codex schema sets maxItems: 0 on reasoning.content. Third-party
@@ -254,18 +334,15 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
content := item.Get("content")
if !isCompat && content.IsArray() && len(content.Array()) > 0 {
if openaiResponsesReasoningSummaryIsEmpty(item.Get("summary")) {
- promoted, errPromote := promoteOpenAIResponsesReasoningTextToSummary(nextItem, content)
- if errPromote != nil {
+ if errPromote := promoteOpenAIResponsesReasoningTextToSummaryDoc(ensureItemDoc(), content); errPromote != nil {
helps.LogWithRequestID(ctx).Debugf("%s: failed to promote reasoning_text into summary at input[%d]: %v", provider, index, errPromote)
} else {
- nextItem = promoted
+ changed = true
}
}
- cleared, errClear := sjson.SetRaw(nextItem, "content", "[]")
- if errClear != nil {
+ if errClear := ensureItemDoc().SetRawBytes("content", []byte("[]")); errClear != nil {
helps.LogWithRequestID(ctx).Debugf("%s: failed to clear reasoning content at input[%d]: %v", provider, index, errClear)
} else {
- nextItem = cleared
changed = true
helps.LogWithRequestID(ctx).Debugf("%s: cleared reasoning content at input[%d] item_id=%q", provider, index, itemID)
}
@@ -273,37 +350,30 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
if !encryptedContent.Exists() {
if !isCompat && stripOrphanReasoningIDs && item.Get("id").Exists() {
- dropped, err := sjson.Delete(nextItem, "id")
- if err != nil {
- helps.LogWithRequestID(ctx).Debugf("%s: failed to drop orphan reasoning id at input[%d]: %v", provider, index, err)
+ if errDel := ensureItemDoc().Delete("id"); errDel != nil {
+ helps.LogWithRequestID(ctx).Debugf("%s: failed to drop orphan reasoning id at input[%d]: %v", provider, index, errDel)
} else {
- nextItem = dropped
changed = true
helps.LogWithRequestID(ctx).Debugf("%s: dropped orphan reasoning id at input[%d] item_id=%q reason=missing encrypted_content with store disabled", provider, index, itemID)
}
}
- if !changed {
+ if !changed || itemDoc == nil {
keep(item.Raw)
continue
}
startRebuild(index)
- keep(nextItem)
+ keep(itemDoc.String())
continue
}
reason := ""
rewrittenSignature := ""
switch encryptedContent.Type {
- case gjson.String:
+ case ojson.String:
rawSignature := encryptedContent.String()
if rawSignature != strings.TrimSpace(rawSignature) {
reason = "encrypted_content has leading or trailing whitespace"
} else if metaPolicy != metaSignatureForeign {
- // Meta targets never reach the GPT-first gate: a GPT,
- // foreign-provider, or unknown-format blob must not replay to
- // Meta. Only a positively recognized Meta envelope survives,
- // and the native Meta upstream additionally refuses the
- // router suffix a router such as OpenRouter appends.
if info, err := signature.InspectMetaReasoningSignature(rawSignature); err != nil {
reason = err.Error()
} else if info.RouterSuffix != "" {
@@ -330,94 +400,83 @@ func sanitizeOpenAIResponsesReasoningEncryptedContentWithOptions(ctx context.Con
reason = err.Error()
}
} else {
- // Target model belongs to a known non-GPT, non-Meta family (such as
- // Grok or Gemini served through a Responses router like OpenRouter).
- // Only preserve signatures that match that target family (or
- // unknown-format blobs when keepForeign is enabled); foreign GPT or
- // other known-provider envelopes are stripped.
- keep := false
+ keepSig := false
if rawSignature != "" {
switch targetProvider {
case signature.SignatureProviderGrok:
if grokInfo, errGrok := signature.InspectGrokEncryptedContent(rawSignature); errGrok == nil {
- keep = true
+ keepSig = true
if grokInfo.RouterSuffix != "" && !allowRouterSuffix {
rewrittenSignature = stripRouterSuffix(rawSignature, grokInfo.RouterSuffix)
}
}
case signature.SignatureProviderGemini, signature.SignatureProviderClaude, signature.SignatureProviderKimi, signature.SignatureProviderSWE:
var normalized string
- normalized, keep = signature.CompatibleSignatureForProvider(targetProvider, rawSignature)
- if keep && normalized != "" && normalized != rawSignature {
+ normalized, keepSig = signature.CompatibleSignatureForProvider(targetProvider, rawSignature)
+ if keepSig && normalized != "" && normalized != rawSignature {
rewrittenSignature = normalized
}
}
- if !keep && keepForeign {
- keep = signature.DetectSignatureProvider(rawSignature) == signature.SignatureProviderUnknown
+ if !keepSig && keepForeign {
+ keepSig = signature.DetectSignatureProvider(rawSignature) == signature.SignatureProviderUnknown
}
}
- if !keep {
+ if !keepSig {
reason = fmt.Sprintf("reasoning encrypted_content is not compatible with target provider %s", targetProvider)
}
}
- case gjson.Null:
+ case ojson.Null:
reason = "encrypted_content is null"
default:
reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String())
}
if reason == "" {
if rewrittenSignature != "" {
- if updatedItem, errSet := sjson.Set(nextItem, "encrypted_content", rewrittenSignature); errSet == nil {
- nextItem = updatedItem
+ if errSet := ensureItemDoc().Set("encrypted_content", rewrittenSignature); errSet == nil {
changed = true
helps.LogWithRequestID(ctx).Debugf("%s: stripped router suffix from reasoning encrypted_content at input[%d] item_id=%q", provider, index, itemID)
}
}
- if !changed {
+ if !changed || itemDoc == nil {
keep(item.Raw)
continue
}
startRebuild(index)
- keep(nextItem)
+ keep(itemDoc.String())
continue
}
- dropped, err := sjson.Delete(nextItem, "encrypted_content")
- if err != nil {
- helps.LogWithRequestID(ctx).Debugf("%s: failed to drop invalid reasoning encrypted_content at input[%d]: %v", provider, index, err)
- if !changed {
+ if errDel := ensureItemDoc().Delete("encrypted_content"); errDel != nil {
+ helps.LogWithRequestID(ctx).Debugf("%s: failed to drop invalid reasoning encrypted_content at input[%d]: %v", provider, index, errDel)
+ if !changed || itemDoc == nil {
keep(item.Raw)
continue
}
startRebuild(index)
- keep(nextItem)
+ keep(itemDoc.String())
continue
}
- nextItem = dropped
changed = true
if !isCompat && stripOrphanReasoningIDs && item.Get("id").Exists() {
- if nextID, errID := sjson.Delete(nextItem, "id"); errID != nil {
+ if errID := ensureItemDoc().Delete("id"); errID != nil {
helps.LogWithRequestID(ctx).Debugf("%s: failed to drop reasoning id after invalid encrypted_content at input[%d]: %v", provider, index, errID)
- } else {
- nextItem = nextID
}
}
startRebuild(index)
- keep(nextItem)
+ keep(itemDoc.String())
helps.LogWithRequestID(ctx).Debugf("%s: dropped invalid reasoning encrypted_content at input[%d] item_id=%q reason=%s", provider, index, itemID, reason)
}
if rebuilt == nil {
- return body
+ return false
}
rebuilt = append(rebuilt, ']')
- updated, err := sjson.SetRawBytes(body, "input", rebuilt)
- if err != nil {
- helps.LogWithRequestID(ctx).Debugf("%s: failed to rebuild input array while sanitizing reasoning encrypted_content: %v", provider, err)
- return body
+ if errSet := doc.SetRawBytes("input", rebuilt); errSet != nil {
+ helps.LogWithRequestID(ctx).Debugf("%s: failed to rebuild input array while sanitizing reasoning encrypted_content: %v", provider, errSet)
+ return false
}
- return updated
+ return true
}
diff --git a/internal/runtime/executor/openai_responses_signature_test.go b/internal/runtime/executor/openai_responses_signature_test.go
index 15fb4f60793..13fc77705ad 100644
--- a/internal/runtime/executor/openai_responses_signature_test.go
+++ b/internal/runtime/executor/openai_responses_signature_test.go
@@ -2,20 +2,20 @@ package executor
import (
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
var benchmarkSanitizeOpenAIResponsesReasoningOutput []byte
func assertEmptyReasoningContent(t *testing.T, body []byte, path string) {
t.Helper()
- content := gjson.GetBytes(body, path)
+ content := ojson.GetBytes(body, path)
if !content.Exists() || !content.IsArray() || len(content.Array()) != 0 {
t.Fatalf("%s should be an empty array for Codex maxItems:0, got %s body=%s", path, content.Raw, body)
}
@@ -55,22 +55,22 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsOrphanIDsWhenSto
got := sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
- if gjson.GetBytes(got, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.0.encrypted_content").Exists() {
t.Fatalf("invalid encrypted_content still present: %s", got)
}
- if gjson.GetBytes(got, "input.0.id").Exists() {
+ if ojson.GetBytes(got, "input.0.id").Exists() {
t.Fatalf("invalid reasoning id should be stripped when store=false: %s", got)
}
- if gjson.GetBytes(got, "input.1.id").Exists() {
+ if ojson.GetBytes(got, "input.1.id").Exists() {
t.Fatalf("orphan reasoning id should be stripped when store=false: %s", got)
}
- if gotID := gjson.GetBytes(got, "input.2.id").String(); gotID != "rs_good" {
+ if gotID := ojson.GetBytes(got, "input.2.id").String(); gotID != "rs_good" {
t.Fatalf("valid reasoning id = %q, want rs_good; body=%s", gotID, got)
}
- if gotEC := gjson.GetBytes(got, "input.2.encrypted_content").String(); gotEC != valid {
+ if gotEC := ojson.GetBytes(got, "input.2.encrypted_content").String(); gotEC != valid {
t.Fatalf("valid encrypted_content not preserved: %s", got)
}
- if gotID := gjson.GetBytes(got, "input.3.id").String(); gotID != "msg_1" {
+ if gotID := ojson.GetBytes(got, "input.3.id").String(); gotID != "msg_1" {
t.Fatalf("non-reasoning id should stay: %s", got)
}
}
@@ -83,13 +83,13 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_KeepsIDsWhenStoreEnabl
got := sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
- if gjson.GetBytes(got, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.0.encrypted_content").Exists() {
t.Fatalf("invalid encrypted_content still present: %s", got)
}
- if gotID := gjson.GetBytes(got, "input.0.id").String(); gotID != "rs_bad" {
+ if gotID := ojson.GetBytes(got, "input.0.id").String(); gotID != "rs_bad" {
t.Fatalf("store=true should keep reasoning id after dropping invalid encrypted_content, got %q body=%s", gotID, got)
}
- if gotID := gjson.GetBytes(got, "input.1.id").String(); gotID != "rs_orphan" {
+ if gotID := ojson.GetBytes(got, "input.1.id").String(); gotID != "rs_orphan" {
t.Fatalf("store=true should keep orphan reasoning id, got %q body=%s", gotID, got)
}
}
@@ -105,16 +105,16 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_MovesCleartextContentT
got := sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
assertEmptyReasoningContent(t, got, "input.0.content")
- if gotType := gjson.GetBytes(got, "input.0.summary.0.type").String(); gotType != "summary_text" {
+ if gotType := ojson.GetBytes(got, "input.0.summary.0.type").String(); gotType != "summary_text" {
t.Fatalf("summary.0.type = %q, want summary_text; body=%s", gotType, got)
}
- if gotText := gjson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "The model thinking process from a previous turn with a third-party provider..." {
+ if gotText := ojson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "The model thinking process from a previous turn with a third-party provider..." {
t.Fatalf("summary.0.text = %q, want promoted reasoning_text; body=%s", gotText, got)
}
- if gjson.GetBytes(got, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.0.encrypted_content").Exists() {
t.Fatalf("null encrypted_content should still be stripped: %s", got)
}
- if gotType := gjson.GetBytes(got, "input.1.content.0.type").String(); gotType != "input_text" {
+ if gotType := ojson.GetBytes(got, "input.1.content.0.type").String(); gotType != "input_text" {
t.Fatalf("non-reasoning content should stay: %s", got)
}
}
@@ -125,10 +125,10 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_DoesNotDuplicateExisti
got := sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
assertEmptyReasoningContent(t, got, "input.0.content")
- if gotLen := len(gjson.GetBytes(got, "input.0.summary").Array()); gotLen != 1 {
+ if gotLen := len(ojson.GetBytes(got, "input.0.summary").Array()); gotLen != 1 {
t.Fatalf("summary length = %d, want 1 (no duplicate); body=%s", gotLen, got)
}
- if gotText := gjson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "already summarized" {
+ if gotText := ojson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "already summarized" {
t.Fatalf("existing summary overwritten: %s", got)
}
}
@@ -139,13 +139,13 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_PromotesMultipleReason
got := sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
assertEmptyReasoningContent(t, got, "input.0.content")
- if gotLen := len(gjson.GetBytes(got, "input.0.summary").Array()); gotLen != 2 {
+ if gotLen := len(ojson.GetBytes(got, "input.0.summary").Array()); gotLen != 2 {
t.Fatalf("summary length = %d, want 2; body=%s", gotLen, got)
}
- if gotText := gjson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "step one" {
+ if gotText := ojson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "step one" {
t.Fatalf("summary.0.text = %q, want step one; body=%s", gotText, got)
}
- if gotText := gjson.GetBytes(got, "input.0.summary.1.text").String(); gotText != "step two" {
+ if gotText := ojson.GetBytes(got, "input.0.summary.1.text").String(); gotText != "step two" {
t.Fatalf("summary.1.text = %q, want step two; body=%s", gotText, got)
}
}
@@ -157,13 +157,13 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_KeepsValidEncryptedCon
got := sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
assertEmptyReasoningContent(t, got, "input.0.content")
- if gotID := gjson.GetBytes(got, "input.0.id").String(); gotID != "rs_good" {
+ if gotID := ojson.GetBytes(got, "input.0.id").String(); gotID != "rs_good" {
t.Fatalf("valid reasoning id = %q, want rs_good; body=%s", gotID, got)
}
- if gotEC := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotEC != valid {
+ if gotEC := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotEC != valid {
t.Fatalf("valid encrypted_content not preserved: %s", got)
}
- if gotText := gjson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "cleartext thinking" {
+ if gotText := ojson.GetBytes(got, "input.0.summary.0.text").String(); gotText != "cleartext thinking" {
t.Fatalf("summary.0.text = %q, want promoted reasoning_text; body=%s", gotText, got)
}
}
@@ -188,7 +188,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompat_PreservesRea
got := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(context.Background(), "test", body, true)
- reasoningItem := gjson.GetBytes(got, "input.0")
+ reasoningItem := ojson.GetBytes(got, "input.0")
if gotID := reasoningItem.Get("id").String(); gotID != "rs_compat" {
t.Fatalf("reasoning id = %q, want rs_compat; body=%s", gotID, got)
}
@@ -215,18 +215,18 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompat_PreservesUnk
body := []byte(`{"store":false,"input":[{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + unknownSig + `"},{"type":"reasoning","summary":[],"content":null,"encrypted_content":""},{"type":"reasoning","summary":[],"content":null,"encrypted_content":"` + testClaudeCAISSample + `"}]}`)
got := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompat(context.Background(), "test", body, true)
- if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != unknownSig {
+ if gotSig := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != unknownSig {
t.Fatalf("compat encrypted_content = %q, want %q; body=%s", gotSig, unknownSig, got)
}
- if gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.1.encrypted_content").Exists() {
t.Fatalf("empty encrypted_content should still be stripped: %s", got)
}
- if gjson.GetBytes(got, "input.2.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.2.encrypted_content").Exists() {
t.Fatalf("Claude signature should still be stripped: %s", got)
}
got = sanitizeOpenAIResponsesReasoningEncryptedContent(context.Background(), "test", body)
- if gjson.GetBytes(got, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.0.encrypted_content").Exists() {
t.Fatalf("non-compat sanitization unexpectedly preserved unknown-format encrypted_content: %s", got)
}
}
@@ -244,34 +244,34 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign_Issue6450(t
// rs_muse: unknown format signature preserved, cleartext content moved to summary, id kept
assertEmptyReasoningContent(t, got, "input.0.content")
- if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != museSig {
+ if gotSig := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != museSig {
t.Fatalf("keepForeign encrypted_content = %q, want %q; body=%s", gotSig, museSig, got)
}
- if gotID := gjson.GetBytes(got, "input.0.id").String(); gotID != "rs_muse" {
+ if gotID := ojson.GetBytes(got, "input.0.id").String(); gotID != "rs_muse" {
t.Fatalf("keepForeign reasoning id = %q, want rs_muse; body=%s", gotID, got)
}
- if gotSummary := gjson.GetBytes(got, "input.0.summary.0.text").String(); gotSummary != "cleartext thinking" {
+ if gotSummary := ojson.GetBytes(got, "input.0.summary.0.text").String(); gotSummary != "cleartext thinking" {
t.Fatalf("keepForeign summary = %q, want 'cleartext thinking'; body=%s", gotSummary, got)
}
// rs_claude: known foreign signature stripped, id dropped when store=false
- if gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.1.encrypted_content").Exists() {
t.Fatalf("Claude signature should be stripped: %s", got)
}
- if gjson.GetBytes(got, "input.1.id").Exists() {
+ if ojson.GetBytes(got, "input.1.id").Exists() {
t.Fatalf("Claude reasoning id should be dropped: %s", got)
}
// rs_empty: empty signature stripped, id dropped
- if gjson.GetBytes(got, "input.2.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.2.encrypted_content").Exists() {
t.Fatalf("empty signature should be stripped: %s", got)
}
- if gjson.GetBytes(got, "input.2.id").Exists() {
+ if ojson.GetBytes(got, "input.2.id").Exists() {
t.Fatalf("empty reasoning id should be dropped: %s", got)
}
// rs_orphan: missing signature, id dropped when store=false
- if gjson.GetBytes(got, "input.3.id").Exists() {
+ if ojson.GetBytes(got, "input.3.id").Exists() {
t.Fatalf("orphan reasoning id should be dropped: %s", got)
}
}
@@ -293,13 +293,13 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentForMeta_PreservesRecogn
// The Meta target is native-only: the recognized Meta envelope survives,
// while unknown-format and known-foreign values are both stripped so no
// wrong signature can reach the Meta upstream.
- if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != metaSig {
+ if gotSig := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != metaSig {
t.Fatalf("recognized Meta encrypted_content not preserved; body=%s", got)
}
- if gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.1.encrypted_content").Exists() {
t.Fatalf("unknown-format encrypted_content should be stripped on Meta target: %s", got)
}
- if gjson.GetBytes(got, "input.2.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.2.encrypted_content").Exists() {
t.Fatalf("Claude signature should be stripped on Meta target: %s", got)
}
@@ -307,10 +307,10 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentForMeta_PreservesRecogn
// positively recognized family, its envelope is known-foreign there and
// must be stripped instead of riding the unknown-format exemption.
generic := sanitizeOpenAIResponsesReasoningEncryptedContentKeepForeign(context.Background(), "test", body)
- if gjson.GetBytes(generic, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(generic, "input.0.encrypted_content").Exists() {
t.Fatalf("recognized Meta signature should be stripped by generic keepForeign: %s", generic)
}
- if gotSig := gjson.GetBytes(generic, "input.1.encrypted_content").String(); gotSig != unknownSig {
+ if gotSig := ojson.GetBytes(generic, "input.1.encrypted_content").String(); gotSig != unknownSig {
t.Fatalf("unknown-format encrypted_content should remain preserved by generic keepForeign; body=%s", generic)
}
@@ -318,14 +318,14 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentForMeta_PreservesRecogn
// target model is itself a Meta-family model (cascaded/third-party
// compat endpoints serving muse-*); other compat targets strip it.
compatMeta := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(context.Background(), "test", body, true, "muse-spark-1.3")
- if gotSig := gjson.GetBytes(compatMeta, "input.0.encrypted_content").String(); gotSig != metaSig {
+ if gotSig := ojson.GetBytes(compatMeta, "input.0.encrypted_content").String(); gotSig != metaSig {
t.Fatalf("compat Meta-model target should preserve recognized Meta signature; body=%s", compatMeta)
}
- if gjson.GetBytes(compatMeta, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(compatMeta, "input.1.encrypted_content").Exists() {
t.Fatalf("compat Meta-model target should strip unknown-format signature: %s", compatMeta)
}
compatOther := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(context.Background(), "test", body, true, "deepseek-v4")
- if gjson.GetBytes(compatOther, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(compatOther, "input.0.encrypted_content").Exists() {
t.Fatalf("compat non-Meta target should strip recognized Meta signature: %s", compatOther)
}
}
@@ -342,10 +342,10 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentForMeta_StripsRouterSuf
// The native Meta upstream is fail-closed on the router suffix: the
// unsuffixed envelope survives while the routed form is stripped.
- if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != native {
+ if gotSig := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != native {
t.Fatalf("native Meta encrypted_content not preserved; body=%s", got)
}
- if gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.1.encrypted_content").Exists() {
t.Fatalf("router-suffixed Meta encrypted_content should be stripped on native Meta target: %s", got)
}
}
@@ -366,13 +366,13 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel_RoutedM
}
assertRoutedTarget := func(t *testing.T, got []byte) {
t.Helper()
- if gotSig := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != native {
+ if gotSig := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotSig != native {
t.Fatalf("native Meta encrypted_content not preserved; body=%s", got)
}
- if gotSig := gjson.GetBytes(got, "input.1.encrypted_content").String(); gotSig != routed {
+ if gotSig := ojson.GetBytes(got, "input.1.encrypted_content").String(); gotSig != routed {
t.Fatalf("routed Meta encrypted_content = %q, want verbatim; body=%s", gotSig, got)
}
- for index, item := range gjson.GetBytes(got, "input").Array() {
+ for index, item := range ojson.GetBytes(got, "input").Array() {
if index < 2 {
continue
}
@@ -396,7 +396,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel_RoutedM
assertRoutedTarget(t, got)
otherProviderMeta := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(context.Background(), "test", buildBody(), false, "meta/muse-wonder", "https://example-proxy.internal/v1")
- if gotSig := gjson.GetBytes(otherProviderMeta, "input.1.encrypted_content").String(); gotSig != native {
+ if gotSig := ojson.GetBytes(otherProviderMeta, "input.1.encrypted_content").String(); gotSig != native {
t.Fatalf("non-OpenRouter provider should strip OpenRouter suffix to native Meta envelope, got %q want %q", gotSig, native)
}
@@ -406,10 +406,10 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel_RoutedM
assertRoutedTarget(t, routedMetaURL)
nativeMetaURL := sanitizeOpenAIResponsesReasoningEncryptedContentForMetaBaseURL(context.Background(), "test", buildBody(), "https://api.meta.ai/api/v1")
- if gotSig := gjson.GetBytes(nativeMetaURL, "input.0.encrypted_content").String(); gotSig != native {
+ if gotSig := ojson.GetBytes(nativeMetaURL, "input.0.encrypted_content").String(); gotSig != native {
t.Fatalf("native Meta URL should preserve native envelope; body=%s", nativeMetaURL)
}
- if gjson.GetBytes(nativeMetaURL, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(nativeMetaURL, "input.1.encrypted_content").Exists() {
t.Fatalf("native Meta URL should strip router-suffixed envelope; body=%s", nativeMetaURL)
}
}
@@ -430,7 +430,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel_StripsM
{"deepseek-v4", true},
} {
got := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModel(context.Background(), "test", body, tc.isCompat, tc.model)
- for index, item := range gjson.GetBytes(got, "input").Array() {
+ for index, item := range ojson.GetBytes(got, "input").Array() {
if item.Get("encrypted_content").Exists() {
t.Fatalf("model=%s isCompat=%v: input[%d] Meta encrypted_content should be stripped; body=%s", tc.model, tc.isCompat, index, got)
}
@@ -454,7 +454,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsRouterSuffixOnNo
"gpt-5.4",
"https://chatgpt.com/backend-api/codex",
)
- if gotSig := gjson.GetBytes(gotOfficial, "input.0.encrypted_content").String(); gotSig != gptNative {
+ if gotSig := ojson.GetBytes(gotOfficial, "input.0.encrypted_content").String(); gotSig != gptNative {
t.Fatalf("official Codex encrypted_content = %q, want stripped native %q", gotSig, gptNative)
}
@@ -467,7 +467,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsRouterSuffixOnNo
"openrouter/openai/o4-mini",
"https://openrouter.ai/api/v1",
)
- if gotSig := gjson.GetBytes(gotOpenRouter, "input.0.encrypted_content").String(); gotSig != gptRouted {
+ if gotSig := ojson.GetBytes(gotOpenRouter, "input.0.encrypted_content").String(); gotSig != gptRouted {
t.Fatalf("OpenRouter encrypted_content = %q, want verbatim %q", gotSig, gptRouted)
}
@@ -481,7 +481,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsRouterSuffixOnNo
model,
"https://openrouter.ai/api/v1",
)
- if gjson.GetBytes(gotForeign, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(gotForeign, "input.0.encrypted_content").Exists() {
t.Fatalf("model=%s: foreign GPT encrypted_content should be stripped; body=%s", model, gotForeign)
}
}
@@ -497,7 +497,7 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsRouterSuffixOnNo
"devin/swe-2",
"https://openrouter.ai/api/v1",
)
- if gotSig := gjson.GetBytes(gotSWE, "input.0.encrypted_content").String(); gotSig != "sealed.v1.token" {
+ if gotSig := ojson.GetBytes(gotSWE, "input.0.encrypted_content").String(); gotSig != "sealed.v1.token" {
t.Fatalf("prefixed SWE encrypted_content = %q, want normalized %q", gotSig, "sealed.v1.token")
}
@@ -531,13 +531,13 @@ func TestSanitizeOpenAIResponsesReasoningEncryptedContent_StripsRouterSuffixOnNo
sanitizedOpenRouter := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
context.Background(), "test", injected, false, "o4-mini", "https://openrouter.ai/api/v1",
)
- if gotSig := gjson.GetBytes(sanitizedOpenRouter, "input.0.encrypted_content").String(); gotSig != gptRouted {
+ if gotSig := ojson.GetBytes(sanitizedOpenRouter, "input.0.encrypted_content").String(); gotSig != gptRouted {
t.Fatalf("replay cache -> OpenRouter encrypted_content = %q, want routed %q", gotSig, gptRouted)
}
sanitizedOfficial := sanitizeOpenAIResponsesReasoningEncryptedContentWithCompatModelAndBaseURL(
context.Background(), "test", injected, false, "o4-mini", "https://chatgpt.com/backend-api/codex",
)
- if gotSig := gjson.GetBytes(sanitizedOfficial, "input.0.encrypted_content").String(); gotSig != gptNative {
+ if gotSig := ojson.GetBytes(sanitizedOfficial, "input.0.encrypted_content").String(); gotSig != gptNative {
t.Fatalf("replay cache -> official Codex encrypted_content = %q, want stripped native %q", gotSig, gptNative)
}
}
diff --git a/internal/runtime/executor/payload_barrier_test.go b/internal/runtime/executor/payload_barrier_test.go
index 6cc5ddcd874..ac3b9ad95a8 100644
--- a/internal/runtime/executor/payload_barrier_test.go
+++ b/internal/runtime/executor/payload_barrier_test.go
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
@@ -15,13 +16,12 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestPayloadBarrierCodexImageFilter(t *testing.T) {
for _, transport := range []string{"http", "websocket"} {
for _, stream := range []bool{false, true} {
- for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-luna"} {
+ for _, model := range []string{"gpt-5.4", "gpt-5.5"} {
t.Run(transport+"/"+model+map[bool]string{false: "/execute", true: "/stream"}[stream], func(t *testing.T) {
captured := make(chan []byte, 1)
completed := []byte(`{"type":"response.completed","response":{"id":"resp_barrier","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
@@ -59,7 +59,7 @@ func TestPayloadBarrierCodexImageFilter(t *testing.T) {
_, _ = w.Write(append(append([]byte("data: "), completed...), '\n', '\n'))
}))
defer server.Close()
- cfg := &config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "gpt-5.6-sol"}}, Params: []string{`tools.#(type=="image_generation")#`, "instructions", "prompt_cache_key"}}}}}
+ cfg := &config.Config{Payload: config.PayloadConfig{Filter: []config.PayloadFilterRule{{Models: []config.PayloadModelRule{{Name: "gpt-5.4"}}, Params: []string{`tools.#(type=="image_generation")#`, "instructions", "prompt_cache_key"}}}}}
var executor cliproxyauth.ProviderExecutor = NewCodexExecutor(cfg)
if transport == "websocket" {
executor = NewCodexWebsocketsExecutor(cfg)
@@ -81,11 +81,11 @@ func TestPayloadBarrierCodexImageFilter(t *testing.T) {
t.Fatal(errExecute)
}
body := <-captured
- image := gjson.GetBytes(body, `tools.#(type=="image_generation")`).Exists()
- if image != (model == "gpt-5.6-luna") {
+ image := ojson.GetBytes(body, `tools.#(type=="image_generation")`).Exists()
+ if image != (model == "gpt-5.5") {
t.Fatalf("image tool presence = %v: %s", image, body)
}
- if model == "gpt-5.6-sol" && (gjson.GetBytes(body, "instructions").Exists() || gjson.GetBytes(body, "prompt_cache_key").Exists()) {
+ if model == "gpt-5.4" && (ojson.GetBytes(body, "instructions").Exists() || ojson.GetBytes(body, "prompt_cache_key").Exists()) {
t.Fatalf("late injection undid filter: %s", body)
}
})
@@ -116,13 +116,13 @@ func TestPayloadBarrierAntigravityRebuiltAttempts(t *testing.T) {
if errClose := request.Body.Close(); errClose != nil {
t.Fatal(errClose)
}
- if gjson.GetBytes(wire, "request.generationConfig.maxOutputTokens").Int() != 123 {
+ if ojson.GetBytes(wire, "request.generationConfig.maxOutputTokens").Int() != 123 {
t.Fatalf("built-in cleanup undid override: %s", wire)
}
- if gjson.GetBytes(wire, "request.sessionId").Exists() {
+ if ojson.GetBytes(wire, "request.sessionId").Exists() {
t.Fatalf("session reinjected: %s", wire)
}
- if got := gjson.GetBytes(wire, "request.contents.#").Int(); got != 1 {
+ if got := ojson.GetBytes(wire, "request.contents.#").Int(); got != 1 {
t.Fatalf("filter applied more than once: %s", wire)
}
}
@@ -139,7 +139,7 @@ func TestPayloadBarrierAIStudio(t *testing.T) {
if errTranslate != nil {
t.Fatal(errTranslate)
}
- if gjson.GetBytes(body, "contents").Exists() || gjson.GetBytes(body, "generationConfig.maxOutputTokens").Int() != 900000 || gjson.GetBytes(body, "generationConfig.thinkingConfig.thinkingLevel").String() != "low" {
+ if ojson.GetBytes(body, "contents").Exists() || ojson.GetBytes(body, "generationConfig.maxOutputTokens").Int() != 900000 || ojson.GetBytes(body, "generationConfig.thinkingConfig.thinkingLevel").String() != "low" {
t.Fatalf("late normalization undid rules: %s", body)
}
}
@@ -158,10 +158,10 @@ func TestPayloadBarrierXAIWebsocketRetry(t *testing.T) {
if !bytes.Equal(first, retry) {
t.Fatalf("retry changed configured body: %s != %s", first, retry)
}
- if gjson.GetBytes(first, "store").Bool() || gjson.GetBytes(first, "instructions").String() != "configured" || gjson.GetBytes(first, "previous_response_id").Exists() || gjson.GetBytes(first, "input.#").Int() != 1 {
+ if ojson.GetBytes(first, "store").Bool() || ojson.GetBytes(first, "instructions").String() != "configured" || ojson.GetBytes(first, "previous_response_id").Exists() || ojson.GetBytes(first, "input.#").Int() != 1 {
t.Fatalf("invalid final payload: %s", first)
}
- if gjson.GetBytes(first, "type").String() != "response.create" {
+ if ojson.GetBytes(first, "type").String() != "response.create" {
t.Fatalf("missing frame type: %s", first)
}
}
@@ -174,11 +174,11 @@ func TestPayloadBarrierClaudeDoesNotReplayFiltersOrReinjectIdentity(t *testing.T
for _, stream := range []bool{false, true} {
body := executeClaudeContextManagementRequest(t, cfg, []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]}`), stream)
for _, path := range []string{"system", "metadata", "diagnostics", "context_management"} {
- if gjson.GetBytes(body, path).Exists() {
+ if ojson.GetBytes(body, path).Exists() {
t.Fatalf("%s was reinjected: %s", path, body)
}
}
- if gjson.GetBytes(body, "messages.#").Int() != 2 || gjson.GetBytes(body, "max_tokens").Int() != 1 {
+ if ojson.GetBytes(body, "messages.#").Int() != 2 || ojson.GetBytes(body, "max_tokens").Int() != 1 {
t.Fatalf("built-in processing changed final rule semantics: %s", body)
}
}
@@ -268,7 +268,7 @@ func TestPayloadBarrierEffectiveServiceTierUsageAfterOverride(t *testing.T) {
t.Run("openai compat chat completions override", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
- if got := gjson.GetBytes(body, "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytes(body, "service_tier").String(); got != "priority" {
t.Errorf("upstream body service_tier = %q, want priority", got)
}
if r.Header.Get("Accept") == "text/event-stream" {
@@ -441,3 +441,75 @@ func TestPayloadBarrierEffectiveServiceTierUsageAfterOverride(t *testing.T) {
}
})
}
+
+func TestPayloadBarrierMultiExecutorSingleDocConsistency(t *testing.T) {
+ ctx := context.Background()
+ cfg := &config.Config{
+ Payload: config.PayloadConfig{
+ Override: []config.PayloadRule{
+ {
+ Models: []config.PayloadModelRule{
+ {Name: "*", Protocol: "claude"},
+ {Name: "*", Protocol: "codex"},
+ {Name: "*", Protocol: "gemini"},
+ {Name: "*", Protocol: "openai"},
+ },
+ Params: map[string]any{
+ "custom_override": "barrier_won",
+ },
+ },
+ },
+ Filter: []config.PayloadFilterRule{
+ {
+ Models: []config.PayloadModelRule{
+ {Name: "*", Protocol: "claude"},
+ {Name: "*", Protocol: "codex"},
+ {Name: "*", Protocol: "gemini"},
+ {Name: "*", Protocol: "openai"},
+ },
+ Params: []string{"filtered_out"},
+ },
+ },
+ },
+ }
+
+ t.Run("codex cacheHelperDoc applies FinalizePayloadDoc as final barrier", func(t *testing.T) {
+ exec := NewCodexExecutor(cfg)
+ raw := []byte(`{"model":"gpt-5","filtered_out":"drop_me","input":[{"type":"message","role":"user","id":"item_1","content":[{"type":"input_text","text":"hi"}]}]}`)
+ req := cliproxyexecutor.Request{Model: "gpt-5", Payload: raw}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}
+ reqCtx := helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(cfg, exec.Identifier(), "gpt-5", "codex", "", raw, req, opts))
+
+ doc := ojson.NewDoc(bytes.Clone(raw))
+ _, out, errCache := exec.cacheHelperDoc(reqCtx, opts.SourceFormat, "https://chatgpt.com/backend-api/codex/responses", req, doc, nil, opts.Headers)
+ if errCache != nil {
+ t.Fatalf("cacheHelperDoc error: %v", errCache)
+ }
+ if got := ojson.GetBytesNoCopy(out, "custom_override").String(); got != "barrier_won" {
+ t.Fatalf("custom_override = %q, want barrier_won", got)
+ }
+ if ojson.GetBytesNoCopy(out, "filtered_out").Exists() {
+ t.Fatalf("filtered_out should be removed by finalizer: %s", out)
+ }
+ })
+
+ t.Run("xai prepared request doc applies FinalizeDoc as final barrier", func(t *testing.T) {
+ exec := NewXAIExecutor(cfg)
+ raw := []byte(`{"model":"grok-4","filtered_out":"drop_me","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`)
+ req := cliproxyexecutor.Request{Model: "grok-4", Payload: raw}
+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FormatOpenAIResponse}
+ prepared, errPrep := exec.prepareResponsesRequest(ctx, req, opts, true)
+ if errPrep != nil {
+ t.Fatalf("prepareResponsesRequest error: %v", errPrep)
+ }
+ sanitizeXAIInputEncryptedContentForBaseURLDoc(prepared.doc, "")
+ prepared.finalizePayload.FinalizeDoc(prepared.doc)
+ out := prepared.doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "custom_override").String(); got != "barrier_won" {
+ t.Fatalf("custom_override = %q, want barrier_won", got)
+ }
+ if ojson.GetBytesNoCopy(out, "filtered_out").Exists() {
+ t.Fatalf("filtered_out should be removed by finalizer: %s", out)
+ }
+ })
+}
diff --git a/internal/runtime/executor/payload_review_test.go b/internal/runtime/executor/payload_review_test.go
index e95f304f7af..bb5bb5aa367 100644
--- a/internal/runtime/executor/payload_review_test.go
+++ b/internal/runtime/executor/payload_review_test.go
@@ -8,13 +8,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/wsrelay"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
core "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestClaudePayloadConditionsEvaluateOnceAtFinalBarrier(t *testing.T) {
@@ -30,10 +30,10 @@ func TestClaudePayloadConditionsEvaluateOnceAtFinalBarrier(t *testing.T) {
}}
for _, stream := range []bool{false, true} {
body := executeClaudeContextManagementRequest(t, cfg, []byte(`{"model":"claude-opus-5","max_tokens":100,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"second"},{"role":"user","content":"third"}]}`), stream)
- if gjson.GetBytes(body, "max_tokens").Int() != 200 || gjson.GetBytes(body, "temperature").Float() != 0.2 || gjson.GetBytes(body, "top_p").Float() != 0.4 || !gjson.GetBytes(body, "diagnostics.user").Bool() {
+ if ojson.GetBytes(body, "max_tokens").Int() != 200 || ojson.GetBytes(body, "temperature").Float() != 0.2 || ojson.GetBytes(body, "top_p").Float() != 0.4 || !ojson.GetBytes(body, "diagnostics.user").Bool() {
t.Fatalf("conditional rule lost fields (stream=%v): %s", stream, body)
}
- if gjson.GetBytes(body, "messages.#").Int() != 2 {
+ if ojson.GetBytes(body, "messages.#").Int() != 2 {
t.Fatalf("conditional filter applied more than once (stream=%v): %s", stream, body)
}
}
@@ -98,7 +98,7 @@ func TestAIStudioCountPayloadAfterCleanup(t *testing.T) {
}
body := <-captured
for _, field := range []string{"generationConfig", "tools", "safetySettings"} {
- if gjson.GetBytes(body, field).Exists() != configured {
+ if ojson.GetBytes(body, field).Exists() != configured {
t.Fatalf("count cleanup precedence for %s: %s", field, body)
}
}
@@ -153,10 +153,10 @@ func TestCodexDuplexSteerPayloadBarrier(t *testing.T) {
for {
select {
case body := <-captured:
- if gjson.GetBytes(body, "type").String() != "response.steer" || gjson.GetBytes(body, "previous_response_id").String() != "parent" {
+ if ojson.GetBytes(body, "type").String() != "response.steer" || ojson.GetBytes(body, "previous_response_id").String() != "parent" {
t.Fatalf("steer framing changed: %s", body)
}
- if filter && gjson.GetBytes(body, "input").Exists() || !filter && gjson.GetBytes(body, "input").String() != "configured" {
+ if filter && ojson.GetBytes(body, "input").Exists() || !filter && ojson.GetBytes(body, "input").String() != "configured" {
t.Fatalf("steer payload bypassed config: %s", body)
}
cancel()
@@ -188,15 +188,15 @@ func TestClaudePayloadConditionsObserveBuiltinThinkingRemoval(t *testing.T) {
}}
payload := []byte(`{"model":"claude-opus-5","max_tokens":100,"thinking":{"type":"adaptive"},"tool_choice":{"type":"any"},"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"messages":[{"role":"user","content":"hi"}]}`)
body := executeClaudeContextManagementRequest(t, cfg, payload, stream)
- if gjson.GetBytes(body, "thinking").Exists() {
+ if ojson.GetBytes(body, "thinking").Exists() {
t.Fatalf("builtin did not remove forced-tool thinking: %s", body)
}
for _, prefix := range []string{"default", "default_raw", "override", "override_raw"} {
- if !gjson.GetBytes(body, prefix+"_matched").Bool() || gjson.GetBytes(body, prefix+"_unmatched").Exists() {
+ if !ojson.GetBytes(body, prefix+"_matched").Bool() || ojson.GetBytes(body, prefix+"_unmatched").Exists() {
t.Fatalf("%s matched pre-builtin conditions (stream=%v): %s", prefix, stream, body)
}
}
- if gjson.GetBytes(body, "metadata").Exists() || !gjson.GetBytes(body, "system").Exists() {
+ if ojson.GetBytes(body, "metadata").Exists() || !ojson.GetBytes(body, "system").Exists() {
t.Fatalf("filters matched pre-builtin conditions (stream=%v): %s", stream, body)
}
}
diff --git a/internal/runtime/executor/xai_configuration_update_plugin_test.go b/internal/runtime/executor/xai_configuration_update_plugin_test.go
index 6d9195f5d26..29da5f91e38 100644
--- a/internal/runtime/executor/xai_configuration_update_plugin_test.go
+++ b/internal/runtime/executor/xai_configuration_update_plugin_test.go
@@ -4,21 +4,20 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type updateNormalizingHooks struct{ effort string }
func (h *updateNormalizingHooks) NormalizeRequest(_ context.Context, _, _ sdktranslator.Format, _ string, body []byte, _ bool) []byte {
if h.effort == "" {
- out, _ := sjson.DeleteBytes(body, "input.0")
+ out, _ := ojson.DeleteBytes(body, "input.0")
return out
}
- out, _ := sjson.SetBytes(body, "input.0.reasoning.effort", h.effort)
+ out, _ := ojson.SetBytes(body, "input.0.reasoning.effort", h.effort)
return out
}
func (*updateNormalizingHooks) TranslateRequest(context.Context, sdktranslator.Format, sdktranslator.Format, string, []byte, bool) ([]byte, bool) {
@@ -48,10 +47,10 @@ func TestXAIResponsesPluginNormalizationOverridesOldSourceUpdate(t *testing.T) {
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(prepared.body, "reasoning.effort").String(); got != tc.want {
+ if got := ojson.GetBytes(prepared.body, "reasoning.effort").String(); got != tc.want {
t.Fatalf("effort = %q, want %q; body=%s", got, tc.want, prepared.body)
}
- if len(gjson.GetBytes(prepared.body, "input").Array()) != 1 {
+ if len(ojson.GetBytes(prepared.body, "input").Array()) != 1 {
t.Fatalf("plugin-normalized update was restored: %s", prepared.body)
}
})
diff --git a/internal/runtime/executor/xai_configuration_update_test.go b/internal/runtime/executor/xai_configuration_update_test.go
index eba97b0b692..e6766a8ee01 100644
--- a/internal/runtime/executor/xai_configuration_update_test.go
+++ b/internal/runtime/executor/xai_configuration_update_test.go
@@ -3,12 +3,12 @@ package executor
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestXAIResponsesPreparationStripsUnsupportedConfigurationUpdates(t *testing.T) {
@@ -26,7 +26,7 @@ func TestXAIResponsesPreparationStripsUnsupportedConfigurationUpdates(t *testing
if errPrepare != nil {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- if got := gjson.GetBytes(prepared.body, "reasoning.effort").String(); got != tc.effort {
+ if got := ojson.GetBytes(prepared.body, "reasoning.effort").String(); got != tc.effort {
t.Fatalf("target effort = %q, want %q; body=%s", got, tc.effort, prepared.body)
}
if got := thinking.ExtractReasoningEffort([]byte(source), "openai-response", tc.model); got != "high" {
@@ -35,11 +35,11 @@ func TestXAIResponsesPreparationStripsUnsupportedConfigurationUpdates(t *testing
if got := thinking.ExtractTranslatedReasoningEffort(prepared.body, "xai"); got != tc.effort {
t.Fatalf("target usage effort = %q, want %q", got, tc.effort)
}
- input := gjson.GetBytes(prepared.body, "input").Array()
+ input := ojson.GetBytes(prepared.body, "input").Array()
if len(input) != 2 || input[0].Get("content").String() != "hi" || input[1].Get("content").String() != "again" {
t.Fatalf("unsupported updates remain or messages changed: %s", prepared.body)
}
- if got := gjson.GetBytes(prepared.body, "reasoning.summary").String(); got != "auto" {
+ if got := ojson.GetBytes(prepared.body, "reasoning.summary").String(); got != "auto" {
t.Fatalf("reasoning.summary = %q, want auto; body=%s", got, prepared.body)
}
})
@@ -58,10 +58,10 @@ func TestXAIResponsesPreparationHonorsExplicitSupportOnly(t *testing.T) {
if errPrepare != nil {
t.Fatal(errPrepare)
}
- if got := gjson.GetBytes(prepared.body, "reasoning.effort").String(); got != tc.top {
+ if got := ojson.GetBytes(prepared.body, "reasoning.effort").String(); got != tc.top {
t.Fatalf("top-level effort = %q, want %q; body=%s", got, tc.top, prepared.body)
}
- if got := gjson.GetBytes(prepared.body, "input.0.reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytes(prepared.body, "input.0.reasoning.effort").String(); got != "high" {
t.Fatalf("explicitly supported update was lost: %s", prepared.body)
}
if got := thinking.ExtractTranslatedReasoningEffort(prepared.body, "xai"); got != "high" {
diff --git a/internal/runtime/executor/xai_executor_execute.go b/internal/runtime/executor/xai_executor_execute.go
index 2b6078738da..9f28f59b0ed 100644
--- a/internal/runtime/executor/xai_executor_execute.go
+++ b/internal/runtime/executor/xai_executor_execute.go
@@ -9,13 +9,12 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
@@ -37,7 +36,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
baseURL := xaiChatBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
- prepared, err := e.prepareResponsesRequest(ctx, req, opts, true)
+ prepared, err := e.prepareResponsesRequestDoc(ctx, req, opts, true)
if err != nil {
return resp, err
}
@@ -46,8 +45,10 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
defer reporter.TrackFailure(ctx, &err)
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
- prepared.body = prepared.finalizePayload(prepared.body)
+ doc := prepared.ensureDoc()
+ sanitizeXAIInputEncryptedContentForBaseURLDoc(doc, baseURL)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, prepared.finalizePayload), doc)
+ prepared.body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if err != nil {
@@ -115,7 +116,7 @@ func (e *XAIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
}
for _, eventData := range events {
reporter.ObserveResponseModel(eventData)
- eventType := gjson.GetBytes(eventData, "type").String()
+ eventType := ojson.GetBytesNoCopy(eventData, "type").String()
switch eventType {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
@@ -190,30 +191,32 @@ func (e *XAIExecutor) executeCompactRequest(ctx context.Context, auth *cliproxya
baseURL := xaiCompactBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
- prepared, err := e.prepareResponsesRequestTo(ctx, req, opts, false, sdktranslator.FormatOpenAIResponse)
+ prepared, err := e.prepareResponsesRequestToDoc(ctx, req, opts, false, sdktranslator.FormatOpenAIResponse)
if err != nil {
return nil, nil, nil, nil, err
}
- prepared.body, _ = sjson.DeleteBytes(prepared.body, "stream")
- prepared.body, _ = sjson.DeleteBytes(prepared.body, "tools")
+ doc := prepared.ensureDoc()
+ _ = doc.Delete("stream")
+ _ = doc.Delete("tools")
// Compact deletes tools after prepareResponsesRequestTo, which can now keep
// image_generation and rewrite its forced choice to "required" on grok-4.6+.
// Drop the leftover selection so compact does not send tool_choice without tools.
- prepared.body = normalizeXAIToolChoiceForTools(prepared.body)
+ normalizeXAIToolChoiceForToolsDoc(doc)
for _, field := range []string{"max_output_tokens", "temperature", "top_p", "top_k", "stop"} {
- prepared.body, _ = sjson.DeleteBytes(prepared.body, field)
+ _ = doc.Delete(field)
}
- prepared.body = xaiRemoveInputItemsByType(prepared.body, "compaction_trigger")
- if previousResponseID := strings.TrimSpace(gjson.GetBytes(req.Payload, "previous_response_id").String()); previousResponseID != "" {
- prepared.body, _ = sjson.SetBytes(prepared.body, "previous_response_id", previousResponseID)
+ xaiRemoveInputItemsByTypeDoc(doc, "compaction_trigger")
+ if previousResponseID := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "previous_response_id").String()); previousResponseID != "" {
+ _ = doc.Set("previous_response_id", previousResponseID)
}
reporter := helps.NewExecutorUsageReporter(ctx, e, prepared.baseModel, auth)
defer reporter.TrackFailure(ctx, &err)
requestURL := strings.TrimSuffix(baseURL, "/") + "/responses/compact"
- prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
- prepared.body = prepared.finalizePayload(prepared.body)
+ sanitizeXAIInputEncryptedContentForBaseURLDoc(doc, baseURL)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, prepared.finalizePayload), doc)
+ prepared.body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(prepared.body))
if err != nil {
@@ -279,7 +282,7 @@ func (e *XAIExecutor) executeCompactionTriggerStream(ctx context.Context, auth *
}
func xaiInputHasItemType(body []byte, itemType string) bool {
- input := gjson.GetBytes(body, "input")
+ input := ojson.GetBytesNoCopy(body, "input")
if !input.IsArray() {
return false
}
@@ -292,10 +295,21 @@ func xaiInputHasItemType(body []byte, itemType string) bool {
}
func xaiRemoveInputItemsByType(body []byte, itemType string) []byte {
- input := gjson.GetBytes(body, "input")
- if !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !xaiRemoveInputItemsByTypeDoc(doc, itemType) {
return body
}
+ return doc.Bytes()
+}
+
+func xaiRemoveInputItemsByTypeDoc(doc *ojson.Doc, itemType string) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
var buf bytes.Buffer
buf.WriteByte('[')
@@ -312,21 +326,17 @@ func xaiRemoveInputItemsByType(body []byte, itemType string) []byte {
}
buf.WriteByte(']')
- updated, err := sjson.SetRawBytes(body, "input", buf.Bytes())
- if err != nil {
- return body
- }
- return updated
+ return doc.SetRawBytes("input", buf.Bytes()) == nil
}
func xaiBuildCompactionTriggerStreamChunks(prepared *xaiPreparedRequest, compactData []byte) [][]byte {
responseID := xaiCompactionResponseID(compactData)
now := time.Now().Unix()
- createdAt := gjson.GetBytes(compactData, "created_at").Int()
+ createdAt := ojson.GetBytesNoCopy(compactData, "created_at").Int()
if createdAt == 0 {
createdAt = now
}
- completedAt := gjson.GetBytes(compactData, "completed_at").Int()
+ completedAt := ojson.GetBytesNoCopy(compactData, "completed_at").Int()
if completedAt == 0 {
completedAt = now
}
@@ -342,35 +352,37 @@ func xaiBuildCompactionTriggerStreamChunks(prepared *xaiPreparedRequest, compact
completedResponse := xaiBuildCompactionBaseResponse(prepared, compactData, responseID, createdAt, "completed")
requestModelName := ""
if prepared != nil {
- requestModelName = gjson.GetBytes(prepared.originalPayload, "model").String()
+ requestModelName = ojson.GetBytesNoCopy(prepared.originalPayload, "model").String()
if requestModelName == "" {
requestModelName = prepared.baseModel
}
}
if requestModelName == "" {
- requestModelName = gjson.GetBytes(compactData, "model").String()
+ requestModelName = ojson.GetBytesNoCopy(compactData, "model").String()
}
if requestModelName != "" {
- createdResponse, _ = sjson.SetBytes(createdResponse, "model", requestModelName)
- inProgressResponse, _ = sjson.SetBytes(inProgressResponse, "model", requestModelName)
+ createdResponse, _ = ojson.SetBytes(createdResponse, "model", requestModelName)
+ inProgressResponse, _ = ojson.SetBytes(inProgressResponse, "model", requestModelName)
}
- completedResponse, _ = sjson.SetBytes(completedResponse, "completed_at", completedAt)
- completedResponse, _ = sjson.SetRawBytes(completedResponse, "output", output)
- if usage := gjson.GetBytes(compactData, "usage"); usage.Exists() {
- completedResponse, _ = sjson.SetRawBytes(completedResponse, "usage", []byte(usage.Raw))
+ completedDoc := ojson.NewDoc(completedResponse)
+ _ = completedDoc.Set("completed_at", completedAt)
+ _ = completedDoc.SetRawBytes("output", output)
+ if usage := ojson.GetBytesNoCopy(compactData, "usage"); usage.Exists() {
+ _ = completedDoc.SetRawBytes("usage", []byte(usage.Raw))
}
+ completedResponse = completedDoc.Bytes()
createdPayload := []byte(`{"type":"response.created","sequence_number":0}`)
- createdPayload, _ = sjson.SetRawBytes(createdPayload, "response", createdResponse)
+ createdPayload, _ = ojson.SetRawBytes(createdPayload, "response", createdResponse)
inProgressPayload := []byte(`{"type":"response.in_progress","sequence_number":1}`)
- inProgressPayload, _ = sjson.SetRawBytes(inProgressPayload, "response", inProgressResponse)
+ inProgressPayload, _ = ojson.SetRawBytes(inProgressPayload, "response", inProgressResponse)
addedPayload := []byte(`{"type":"response.output_item.added","sequence_number":2,"output_index":0}`)
- addedPayload, _ = sjson.SetRawBytes(addedPayload, "item", item)
+ addedPayload, _ = ojson.SetRawBytes(addedPayload, "item", item)
keepalivePayload := []byte(`{"type":"keepalive","sequence_number":3}`)
donePayload := []byte(`{"type":"response.output_item.done","sequence_number":4,"output_index":0}`)
- donePayload, _ = sjson.SetRawBytes(donePayload, "item", item)
+ donePayload, _ = ojson.SetRawBytes(donePayload, "item", item)
completedPayload := []byte(`{"type":"response.completed","sequence_number":5}`)
- completedPayload, _ = sjson.SetRawBytes(completedPayload, "response", completedResponse)
+ completedPayload, _ = ojson.SetRawBytes(completedPayload, "response", completedResponse)
completedPayload = helps.EnsureResponsesUsageDetails(completedPayload)
return [][]byte{
@@ -384,18 +396,18 @@ func xaiBuildCompactionTriggerStreamChunks(prepared *xaiPreparedRequest, compact
}
func xaiBuildCompactionBaseResponse(prepared *xaiPreparedRequest, compactData []byte, responseID string, createdAt int64, status string) []byte {
- response := []byte(`{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null,"incomplete_details":null,"output":[]}`)
- response, _ = sjson.SetBytes(response, "id", responseID)
- response, _ = sjson.SetBytes(response, "created_at", createdAt)
- response, _ = sjson.SetBytes(response, "status", status)
- if model := gjson.GetBytes(compactData, "model").String(); model != "" {
- response, _ = sjson.SetBytes(response, "model", model)
+ respDoc := ojson.NewDoc([]byte(`{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null,"incomplete_details":null,"output":[]}`))
+ _ = respDoc.Set("id", responseID)
+ _ = respDoc.Set("created_at", createdAt)
+ _ = respDoc.Set("status", status)
+ if model := ojson.GetBytesNoCopy(compactData, "model").String(); model != "" {
+ _ = respDoc.Set("model", model)
} else if prepared != nil && prepared.baseModel != "" {
- response, _ = sjson.SetBytes(response, "model", prepared.baseModel)
+ _ = respDoc.Set("model", prepared.baseModel)
}
if prepared == nil {
- return response
+ return respDoc.Bytes()
}
for _, field := range []string{
"instructions",
@@ -414,30 +426,39 @@ func xaiBuildCompactionBaseResponse(prepared *xaiPreparedRequest, compactData []
"user",
"metadata",
} {
- if value := gjson.GetBytes(prepared.body, field); value.Exists() {
- response, _ = sjson.SetRawBytes(response, field, []byte(value.Raw))
+ if value := ojson.GetBytesNoCopy(prepared.body, field); value.Exists() {
+ _ = respDoc.SetRawBytes(field, []byte(value.Raw))
}
}
- return response
+ return respDoc.Bytes()
}
func xaiCompactionOutputItem(compactData []byte, responseID string) []byte {
- itemResult := gjson.GetBytes(compactData, "output.0")
+ itemResult := ojson.GetBytesNoCopy(compactData, "output.0")
item := []byte(`{"type":"compaction"}`)
- if itemResult.Exists() && itemResult.Type == gjson.JSON {
+ if itemResult.Exists() && itemResult.Type == ojson.JSON {
item = []byte(itemResult.Raw)
}
- if !gjson.GetBytes(item, "type").Exists() {
- item, _ = sjson.SetBytes(item, "type", "compaction")
+ itemDoc := ojson.NewDoc(item)
+ changed := false
+ if !itemDoc.Get("type").Exists() {
+ if itemDoc.Set("type", "compaction") == nil {
+ changed = true
+ }
+ }
+ if !itemDoc.Get("id").Exists() {
+ if itemDoc.Set("id", xaiCompactionItemID(responseID)) == nil {
+ changed = true
+ }
}
- if !gjson.GetBytes(item, "id").Exists() {
- item, _ = sjson.SetBytes(item, "id", xaiCompactionItemID(responseID))
+ if !changed {
+ return item
}
- return item
+ return itemDoc.Bytes()
}
func xaiCompactionResponseID(compactData []byte) string {
- if responseID := strings.TrimSpace(gjson.GetBytes(compactData, "id").String()); responseID != "" {
+ if responseID := strings.TrimSpace(ojson.GetBytesNoCopy(compactData, "id").String()); responseID != "" {
if strings.HasPrefix(responseID, "resp_") {
return responseID
}
diff --git a/internal/runtime/executor/xai_executor_media.go b/internal/runtime/executor/xai_executor_media.go
index f6a8e7ac3cc..9ebbdb42bf7 100644
--- a/internal/runtime/executor/xai_executor_media.go
+++ b/internal/runtime/executor/xai_executor_media.go
@@ -8,15 +8,15 @@ import (
"net/url"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, endpointPath string) (resp cliproxyexecutor.Response, err error) {
- model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())
+ model := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "model").String())
if model == "" {
model = strings.TrimSpace(req.Model)
}
@@ -35,8 +35,10 @@ func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth
originalPayloadSource = opts.OriginalRequest
}
originalPayload := normalizeXAIImageRefs(originalPayloadSource)
- payload := normalizeXAIImageRefs(req.Payload)
- payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", originalPayload, req, opts)(payload)
+ doc := ojson.NewDoc(req.Payload)
+ normalizeXAIImageRefsDoc(doc)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", originalPayload, req, opts)), doc)
+ payload := doc.Bytes()
reporter.SetTranslatedRequestMetadata(payload, "openai")
url := strings.TrimSuffix(baseURL, "/") + endpointPath
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
@@ -79,7 +81,7 @@ func (e *XAIExecutor) executeImages(ctx context.Context, auth *cliproxyauth.Auth
}
func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
- model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())
+ model := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "model").String())
if model == "" {
model = strings.TrimSpace(req.Model)
}
@@ -90,8 +92,10 @@ func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth
baseURL := xaiChatBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
- payload := normalizeXAIImageRefs(req.Payload)
- payload = helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", req.Payload, req, opts)(payload)
+ doc := ojson.NewDoc(req.Payload)
+ normalizeXAIImageRefsDoc(doc)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", req.Payload, req, opts)), doc)
+ payload := doc.Bytes()
method := http.MethodPost
endpointPath := xaiVideosGenerationsPath
var body io.Reader = bytes.NewReader(payload)
@@ -100,7 +104,7 @@ func (e *XAIExecutor) executeVideos(ctx context.Context, auth *cliproxyauth.Auth
case xaiVideosGenerationsPath, xaiVideosEditsPath, xaiVideosExtensionsPath:
endpointPath = path
default:
- if requestID := strings.TrimSpace(gjson.GetBytes(payload, "request_id").String()); requestID != "" {
+ if requestID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "request_id").String()); requestID != "" {
method = http.MethodGet
endpointPath = xaiVideosPath + "/" + url.PathEscape(requestID)
body = nil
diff --git a/internal/runtime/executor/xai_executor_request.go b/internal/runtime/executor/xai_executor_request.go
index 569b4b91572..b8e685bc996 100644
--- a/internal/runtime/executor/xai_executor_request.go
+++ b/internal/runtime/executor/xai_executor_request.go
@@ -8,7 +8,9 @@ import (
"net/http"
"strconv"
"strings"
+ "sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
@@ -20,8 +22,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type xaiPreparedRequest struct {
@@ -33,6 +33,7 @@ type xaiPreparedRequest struct {
to sdktranslator.Format
originalPayload []byte
body []byte
+ doc *ojson.Doc
namespaceTools map[string]xaiNamespaceToolRef
clientDeclaredTools map[xaiClientToolKey]struct{}
sessionID string
@@ -41,6 +42,16 @@ type xaiPreparedRequest struct {
webSearchAlias string
}
+func (p *xaiPreparedRequest) ensureDoc() *ojson.Doc {
+ if p == nil {
+ return nil
+ }
+ if p.doc == nil {
+ p.doc = ojson.NewDoc(p.body)
+ }
+ return p.doc
+}
+
type xaiNamespaceToolRef struct {
namespace string
name string
@@ -72,7 +83,22 @@ func (e *XAIExecutor) prepareResponsesRequest(ctx context.Context, req cliproxye
return e.prepareResponsesRequestTo(ctx, req, opts, stream, sdktranslator.FormatCodex)
}
+func (e *XAIExecutor) prepareResponsesRequestDoc(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool) (*xaiPreparedRequest, error) {
+ return e.prepareResponsesRequestToDoc(ctx, req, opts, stream, sdktranslator.FormatCodex)
+}
+
func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool, to sdktranslator.Format) (*xaiPreparedRequest, error) {
+ prepared, err := e.prepareResponsesRequestToDoc(ctx, req, opts, stream, to)
+ if err != nil {
+ return nil, err
+ }
+ if prepared.doc != nil {
+ prepared.body = prepared.doc.Bytes()
+ }
+ return prepared, nil
+}
+
+func (e *XAIExecutor) prepareResponsesRequestToDoc(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, stream bool, to sdktranslator.Format) (*xaiPreparedRequest, error) {
baseModel := thinking.ParseSuffix(req.Model).ModelName
from := opts.SourceFormat
responseFormat := cliproxyexecutor.ResponseFormatOrSource(opts)
@@ -83,37 +109,38 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
originalPayload := bytes.Clone(originalPayloadSource)
targetModel := resolveXAISignatureTargetModel(baseModel, req, opts)
originalTranslated := helps.TranslateRequestWithAPIKeyModelCompatibilityForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, targetModel, originalPayload, stream, helps.APIKeyModelIsCompat(req))
- originalTranslated = helps.SetStringIfDifferent(originalTranslated, "model", baseModel)
- originalTranslated = preserveXAIResponsesOutputControls(originalTranslated, originalPayload, from)
+ origDoc := ojson.NewDoc(originalTranslated)
+ helps.SetStringIfDifferentDoc(origDoc, "model", baseModel)
+ preserveXAIResponsesOutputControlsDoc(origDoc, originalPayload, from)
+ originalTranslated = origDoc.Bytes()
+
body, updatesChanged, err := helps.TranslateRequestWithAPIKeyModelCompatibilityAndUpdateIntentForExecutor(ctx, opts.Headers, e.cfg, e.Identifier(), from, to, targetModel, bytes.Clone(req.Payload), stream, helps.APIKeyModelIsCompat(req))
if err != nil {
return nil, err
}
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = preserveXAIResponsesOutputControls(body, req.Payload, from)
+ doc := ojson.NewDoc(body)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ preserveXAIResponsesOutputControlsDoc(doc, req.Payload, from)
- body, err = helps.ApplyRequestThinking(body, req, opts, from.String(), e.Identifier(), e.Identifier(), updatesChanged)
- if err != nil {
+ if err = helps.ApplyRequestThinkingDoc(doc, req, opts, from.String(), e.Identifier(), e.Identifier(), updatesChanged); err != nil {
return nil, err
}
finalizePayload := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), baseModel, to.String(), "", originalTranslated, req, opts)
- body = helps.SetStringIfDifferent(body, "model", baseModel)
- body = helps.SetBoolIfDifferent(body, "stream", stream)
- body, _ = sjson.DeleteBytes(body, "previous_response_id")
- body, _ = sjson.DeleteBytes(body, "prompt_cache_retention")
- body, _ = sjson.DeleteBytes(body, "safety_identifier")
- body, _ = sjson.DeleteBytes(body, "stream_options")
- body = helps.RewriteCodexMultiAgentV2Input(ctx, opts.Headers, body, e.cfg)
+ helps.SetStringIfDifferentDoc(doc, "model", baseModel)
+ helps.SetBoolIfDifferentDoc(doc, "stream", stream)
+ _ = doc.Delete("previous_response_id")
+ _ = doc.Delete("prompt_cache_retention")
+ _ = doc.Delete("safety_identifier")
+ _ = doc.Delete("stream_options")
+ helps.RewriteCodexMultiAgentV2InputDoc(doc, ctx, opts.Headers, e.cfg)
applyPatch := helps.NewApplyPatchResponsesState(from, originalPayload, originalTranslated)
- var errNormalizePatch error
- body, errNormalizePatch = helps.NormalizeApplyPatchResponsesRequest(body, originalPayload)
- if errNormalizePatch != nil {
+ if errNormalizePatch := helps.NormalizeApplyPatchResponsesRequestDoc(doc, originalPayload); errNormalizePatch != nil {
return nil, errNormalizePatch
}
willInjectXSearch := e.cfg != nil && e.cfg.XAI.InjectXSearch
- shouldFold := xaiShouldFoldNamespaceTools(body, willInjectXSearch)
- namespaceTools := collectXAINamespaceToolRefsWithFold(body, shouldFold)
+ shouldFold := xaiShouldFoldNamespaceToolsDoc(doc, willInjectXSearch)
+ namespaceTools := collectXAINamespaceToolRefsWithFoldDoc(doc, shouldFold)
for name, ref := range namespaceTools {
if ref.isDispatcher {
applyPatch.AddDispatcher(name, ref.namespace)
@@ -121,54 +148,56 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
}
// Collect before normalizeXAITools flattens namespace wrappers so keys match
// the post-restore (namespace, short-name) shape used by the response filter.
- clientDeclaredTools := collectXAIClientDeclaredToolKeys(body)
- body = normalizeXAIToolsWithFold(body, shouldFold)
- body = promoteXAIAdditionalTools(body)
+ clientDeclaredTools := collectXAIClientDeclaredToolKeysDoc(doc)
+ normalizeXAIToolsWithFoldDoc(doc, shouldFold)
+ promoteXAIAdditionalToolsDoc(doc)
var webSearchAlias string
- if xaiHasClientWebSearchFunction(body, namespaceTools) {
- webSearchAlias = xaiResolveClientWebSearchAlias(body)
- body = aliasXAIClientWebSearchFunction(body, webSearchAlias, namespaceTools)
+ if xaiHasClientWebSearchFunctionDoc(doc, namespaceTools) {
+ webSearchAlias = xaiResolveClientWebSearchAliasDoc(doc)
+ aliasXAIClientWebSearchFunctionDoc(doc, webSearchAlias, namespaceTools)
}
// Drop choices that point at tools removed by normalizeXAITools before any
// configured x_search injection, so no surviving choice references a deleted tool.
- body = normalizeXAINamespaceToolChoiceWithFold(body, shouldFold)
+ normalizeXAINamespaceToolChoiceWithFoldDoc(doc, shouldFold)
// Prune before rewriting hosted tool choices so older models that still
// strip the tool do not keep a leftover "required" selection.
- body = pruneXAIOrphanedToolChoice(body)
- body = normalizeXAIForcedWebSearchToolChoice(body)
- body = normalizeXAIForcedImageGenerationToolChoice(body)
- body = normalizeXAIToolChoiceForTools(body)
+ pruneXAIOrphanedToolChoiceDoc(doc)
+ normalizeXAIForcedWebSearchToolChoiceDoc(doc)
+ normalizeXAIForcedImageGenerationToolChoiceDoc(doc)
+ normalizeXAIToolChoiceForToolsDoc(doc)
// Skip x_search injection when the request was forced to a hosted tool and
// the remaining tools list is only that hosted tool. "required" plus extra
// tools would let Grok call x_search instead of the forced hosted tool.
- if e.cfg != nil && e.cfg.XAI.InjectXSearch && !xaiToolChoiceRequiresHostedToolOnlyAny(body) {
- body = ensureXAINativeXSearchTool(body)
+ if e.cfg != nil && e.cfg.XAI.InjectXSearch && !xaiToolChoiceRequiresHostedToolOnlyAnyDoc(doc) {
+ ensureXAINativeXSearchToolDoc(doc)
}
- body = clampXAIToolsLimit(body, xaiMaxTools, namespaceTools)
+ clampXAIToolsLimitDoc(doc, xaiMaxTools, namespaceTools)
var replayScope xaiReasoningReplayScope
- body, replayScope, err = applyXAIReasoningReplayCacheRequired(ctx, from, req, opts, body)
+ replayScope, _, err = applyXAIReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
if err != nil {
return nil, err
}
- body = normalizeXAIInputCustomToolCalls(body)
- body = normalizeXAIInputNamespaceToolCallsWithFold(body, shouldFold)
+ normalizeXAIInputCustomToolCallsDoc(doc)
+ normalizeXAIInputNamespaceToolCallsWithFoldDoc(doc, shouldFold)
if webSearchAlias != "" {
- body = aliasXAIClientWebSearchInput(body, webSearchAlias, namespaceTools)
+ aliasXAIClientWebSearchInputDoc(doc, webSearchAlias, namespaceTools)
}
- body = normalizeXAIInputReasoningItems(body)
- body = sanitizeXAIInputEncryptedContentWithOptions(body, true)
- body = normalizeCodexInstructions(body)
+ normalizeXAIInputReasoningItemsDoc(doc)
+ sanitizeXAIInputEncryptedContentWithOptionsDoc(doc, true)
+ normalizeCodexInstructionsDoc(doc)
// stop is supported by Chat Completions but not by xAI's Responses API.
// Thinking was handled before payload overrides and must not be revalidated here.
- body, _ = sjson.DeleteBytes(body, "stop")
- body = normalizeXAIImageRefs(body)
+ _ = doc.Delete("stop")
+ if xaiBytesMayContainImageRef(body) {
+ normalizeXAIImageRefsDoc(doc)
+ }
sessionID, errSession := xaiResolveComposerSessionID(ctx, req, opts, baseModel)
if errSession != nil {
return nil, errSession
}
if sessionID != "" {
- body = helps.SetStringIfDifferent(body, "prompt_cache_key", sessionID)
+ helps.SetStringIfDifferentDoc(doc, "prompt_cache_key", sessionID)
}
return &xaiPreparedRequest{
@@ -179,12 +208,12 @@ func (e *XAIExecutor) prepareResponsesRequestTo(ctx context.Context, req cliprox
responseFormat: responseFormat,
to: to,
originalPayload: originalPayload,
- body: body,
+ doc: doc,
namespaceTools: namespaceTools,
clientDeclaredTools: clientDeclaredTools,
sessionID: sessionID,
replayScope: replayScope,
- filterInternalXSearch: xaiRequestHasNativeXSearch(body),
+ filterInternalXSearch: xaiRequestHasNativeXSearchDoc(doc),
webSearchAlias: webSearchAlias,
}, nil
}
@@ -414,7 +443,7 @@ func xaiExecutionSessionID(req cliproxyexecutor.Request, opts cliproxyexecutor.O
if value := xaiMetadataString(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" {
return value
}
- if promptCacheKey := gjson.GetBytes(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
+ if promptCacheKey := ojson.GetBytesNoCopy(req.Payload, "prompt_cache_key"); promptCacheKey.Exists() {
if value := strings.TrimSpace(promptCacheKey.String()); value != "" {
return value
}
@@ -449,82 +478,655 @@ func xaiImageEndpointPath(opts cliproxyexecutor.Options) string {
// Applies to image / images / reference_images anywhere in the JSON tree,
// including nested objects and array items. Does not rewrite chat content
// parts shaped as {"type":"image_url","image_url":{...}}.
-func normalizeXAIImageRefs(body []byte) []byte {
- if !gjson.ValidBytes(body) {
- return body
+func xaiBytesMayContainImageRef(b []byte) bool {
+ return bytes.Contains(b, []byte(`"image"`)) ||
+ bytes.Contains(b, []byte(`"images"`)) ||
+ bytes.Contains(b, []byte(`"reference_images"`)) ||
+ bytes.Contains(b, []byte(`\u`))
+}
+
+func xaiStringMayContainImageRef(s string) bool {
+ return strings.Contains(s, `"image"`) ||
+ strings.Contains(s, `"images"`) ||
+ strings.Contains(s, `"reference_images"`) ||
+ strings.Contains(s, `\u`)
+}
+
+const (
+ xaiKeyOther uint8 = iota
+ xaiKeyImage
+ xaiKeyImages
+ xaiKeyURL
+ xaiKeyImageURL
+)
+
+const (
+ xaiValIgnore uint8 = iota
+ xaiValRefURL
+ xaiValRefImageURL
+ xaiValRefNestedURL
+)
+
+const (
+ xaiResolveNone uint8 = iota
+ xaiResolveURL
+ xaiResolveImageURL
+ xaiResolveNestedImageURL
+)
+
+type xaiImageRefEdit struct {
+ path string
+ url string
+ resolveMode uint8
+ setURL bool
+ hasImageURL bool
+ valid bool
+}
+
+type xaiScanFrame struct {
+ isObj bool
+ inKey bool
+ isImagesArr bool
+ isImageURLObj bool
+ keyKind uint8
+ elemIdx int
+ keyStart int
+ keyEnd int
+}
+
+type xaiRefCandidate struct {
+ depth int
+ hasURL bool
+ urlNeedsTrim bool
+ urlMayNeedTrim bool
+ urlOverflow bool
+ urlLen int
+ urlBuf [256]byte
+ hasImageURL bool
+ hasImageURLVal bool
+ imageURLOverflow bool
+ imageURLNested bool
+ imageURLLen int
+ imageURLBuf [256]byte
+}
+
+type xaiImageRefScanner struct {
+ frames []xaiScanFrame
+ framesBuf [32]xaiScanFrame
+
+ refStack []xaiRefCandidate
+ refStackBuf [2]xaiRefCandidate
+
+ inStr bool
+ strEsc bool
+ strIsKey bool
+ curKeyStart int
+ curKeyHasEsc bool
+ keyArena []byte
+ keyArenaBuf [256]byte
+
+ valTarget uint8
+ valHasEsc bool
+ valHasNonSpace bool
+ valOverflow bool
+ valFirstByte byte
+ valLastByte byte
+ valLen int
+ valBuf [256]byte
+
+ edits []xaiImageRefEdit
+ editsBuf [4]xaiImageRefEdit
+}
+
+var xaiImageRefScannerPool = sync.Pool{
+ New: func() any {
+ return &xaiImageRefScanner{}
+ },
+}
+
+func (s *xaiImageRefScanner) init() {
+ s.framesBuf[0] = xaiScanFrame{}
+ s.frames = s.framesBuf[:1]
+ s.refStack = s.refStackBuf[:0]
+ s.keyArena = s.keyArenaBuf[:0]
+ s.inStr = false
+ s.strEsc = false
+ s.strIsKey = false
+ s.curKeyStart = 0
+ s.curKeyHasEsc = false
+ s.valTarget = xaiValIgnore
+ s.edits = s.editsBuf[:0]
+}
+
+func (s *xaiImageRefScanner) reset() {
+ clear(s.edits[:cap(s.edits)])
+ clear(s.editsBuf[:])
+ s.edits = nil
+ clear(s.frames[:cap(s.frames)])
+ clear(s.framesBuf[:])
+ s.frames = nil
+ clear(s.refStack[:cap(s.refStack)])
+ clear(s.refStackBuf[:])
+ s.refStack = nil
+ s.keyArena = nil
+}
+
+func (s *xaiImageRefScanner) buildPathUpTo(depth int) string {
+ var b strings.Builder
+ for d := 1; d < depth; d++ {
+ if d > 1 {
+ b.WriteByte('.')
+ }
+ f := &s.frames[d]
+ if f.isObj {
+ key := s.keyArena[f.keyStart:f.keyEnd]
+ for i := 0; i < len(key); i++ {
+ ch := key[i]
+ if ch == '.' || ch == '\\' || ch == '|' || ch == '#' || ch == '*' || ch == '?' || ch == '@' ||
+ (i == 0 && (ch == ':' || ch == '[' || ch == '{' || ch == '!')) {
+ b.WriteByte('\\')
+ }
+ b.WriteByte(ch)
+ }
+ } else {
+ var idxBuf [20]byte
+ b.Write(strconv.AppendInt(idxBuf[:0], int64(f.elemIdx), 10))
+ }
}
+ return b.String()
+}
- decoder := json.NewDecoder(bytes.NewReader(body))
- decoder.UseNumber()
- var payload any
- if errDecode := decoder.Decode(&payload); errDecode != nil {
- return body
+func (s *xaiImageRefScanner) decodeValBuf() string {
+ raw := s.valBuf[:s.valLen]
+ if !s.valHasEsc {
+ return string(raw)
}
+ var stackQuoted [514]byte
+ stackQuoted[0] = '"'
+ copy(stackQuoted[1:], raw)
+ stackQuoted[1+len(raw)] = '"'
+ return util.ParseGJSONBytesNoCopy(stackQuoted[:2+len(raw)]).String()
+}
- if !normalizeXAIImageRefsValue(payload) {
- return body
+func (s *xaiImageRefScanner) finishValString() {
+ target := s.valTarget
+ s.valTarget = xaiValIgnore
+ if len(s.refStack) == 0 {
+ return
}
- normalized, errMarshal := json.Marshal(payload)
- if errMarshal != nil {
- return body
+ ref := &s.refStack[len(s.refStack)-1]
+ switch target {
+ case xaiValRefURL:
+ if !s.valHasEsc && !s.valHasNonSpace {
+ ref.hasURL = false
+ ref.urlNeedsTrim = false
+ ref.urlMayNeedTrim = false
+ ref.urlOverflow = false
+ ref.urlLen = 0
+ return
+ }
+ if s.valOverflow {
+ ref.urlOverflow = true
+ if !s.valHasEsc && s.valFirstByte > ' ' && s.valFirstByte < 0x80 && s.valLastByte > ' ' && s.valLastByte < 0x80 {
+ ref.hasURL = true
+ ref.urlNeedsTrim = false
+ } else {
+ ref.urlMayNeedTrim = true
+ }
+ return
+ }
+ str := s.decodeValBuf()
+ trimmed := strings.TrimSpace(str)
+ if trimmed != "" {
+ ref.hasURL = true
+ ref.urlNeedsTrim = len(trimmed) != len(str)
+ ref.urlLen = copy(ref.urlBuf[:], trimmed)
+ } else {
+ ref.hasURL = false
+ ref.urlNeedsTrim = false
+ ref.urlLen = 0
+ }
+ case xaiValRefImageURL, xaiValRefNestedURL:
+ if target == xaiValRefNestedURL && ref.hasImageURLVal {
+ return
+ }
+ if !s.valHasEsc && !s.valHasNonSpace {
+ return
+ }
+ if s.valOverflow {
+ ref.hasImageURLVal = true
+ ref.imageURLOverflow = true
+ ref.imageURLNested = target == xaiValRefNestedURL
+ return
+ }
+ str := s.decodeValBuf()
+ trimmed := strings.TrimSpace(str)
+ if trimmed != "" {
+ ref.hasImageURLVal = true
+ ref.imageURLOverflow = false
+ ref.imageURLNested = target == xaiValRefNestedURL
+ ref.imageURLLen = copy(ref.imageURLBuf[:], trimmed)
+ }
}
- return normalized
}
-func normalizeXAIImageRefsValue(value any) bool {
- changed := false
- switch node := value.(type) {
- case map[string]any:
- for key, child := range node {
- switch key {
- case "image":
- changed = normalizeXAIImageRef(child) || changed
- case "images", "reference_images":
- if refs, ok := child.([]any); ok {
- for _, ref := range refs {
- changed = normalizeXAIImageRef(ref) || changed
+func (s *xaiImageRefScanner) finishRefObject(closingDepth int) {
+ ref := &s.refStack[len(s.refStack)-1]
+ s.refStack = s.refStack[:len(s.refStack)-1]
+
+ if ref.urlMayNeedTrim {
+ path := s.buildPathUpTo(closingDepth)
+ if path != "" {
+ s.edits = append(s.edits, xaiImageRefEdit{
+ path: path,
+ resolveMode: xaiResolveURL,
+ setURL: true,
+ hasImageURL: ref.hasImageURL,
+ })
+ }
+ return
+ }
+
+ if ref.hasURL {
+ if !ref.urlNeedsTrim && !ref.hasImageURL {
+ return
+ }
+ path := s.buildPathUpTo(closingDepth)
+ if path == "" {
+ return
+ }
+ if ref.urlOverflow {
+ s.edits = append(s.edits, xaiImageRefEdit{
+ path: path,
+ resolveMode: xaiResolveURL,
+ setURL: ref.urlNeedsTrim,
+ hasImageURL: ref.hasImageURL,
+ })
+ return
+ }
+ s.edits = append(s.edits, xaiImageRefEdit{
+ path: path,
+ url: strings.Clone(string(ref.urlBuf[:ref.urlLen])),
+ setURL: true,
+ hasImageURL: ref.hasImageURL,
+ })
+ return
+ }
+
+ if ref.hasImageURLVal {
+ path := s.buildPathUpTo(closingDepth)
+ if path == "" {
+ return
+ }
+ if ref.imageURLOverflow {
+ mode := xaiResolveImageURL
+ if ref.imageURLNested {
+ mode = xaiResolveNestedImageURL
+ }
+ s.edits = append(s.edits, xaiImageRefEdit{
+ path: path,
+ resolveMode: mode,
+ setURL: true,
+ hasImageURL: ref.hasImageURL,
+ })
+ return
+ }
+ s.edits = append(s.edits, xaiImageRefEdit{
+ path: path,
+ url: strings.Clone(string(ref.imageURLBuf[:ref.imageURLLen])),
+ setURL: true,
+ hasImageURL: ref.hasImageURL,
+ })
+ }
+}
+
+func (s *xaiImageRefScanner) finishKey() {
+ d := len(s.frames) - 1
+ if d <= 0 {
+ return
+ }
+ if s.curKeyHasEsc {
+ rawKey := s.keyArena[s.curKeyStart:]
+ var stackQuoted [260]byte
+ var quoted []byte
+ if 2+len(rawKey) <= len(stackQuoted) {
+ stackQuoted[0] = '"'
+ copy(stackQuoted[1:], rawKey)
+ stackQuoted[1+len(rawKey)] = '"'
+ quoted = stackQuoted[:2+len(rawKey)]
+ } else {
+ quoted = make([]byte, 2+len(rawKey))
+ quoted[0] = '"'
+ copy(quoted[1:], rawKey)
+ quoted[1+len(rawKey)] = '"'
+ }
+ unescaped := util.ParseGJSONBytesNoCopy(quoted).String()
+ s.keyArena = append(s.keyArena[:s.curKeyStart], unescaped...)
+ }
+ f := &s.frames[d]
+ f.keyStart = s.curKeyStart
+ f.keyEnd = len(s.keyArena)
+ keyBytes := s.keyArena[f.keyStart:f.keyEnd]
+ switch string(keyBytes) {
+ case "image":
+ f.keyKind = xaiKeyImage
+ case "images", "reference_images":
+ f.keyKind = xaiKeyImages
+ case "url":
+ f.keyKind = xaiKeyURL
+ case "image_url":
+ f.keyKind = xaiKeyImageURL
+ default:
+ f.keyKind = xaiKeyOther
+ }
+}
+
+func (s *xaiImageRefScanner) Write(p []byte) (int, error) {
+ i := 0
+ for i < len(p) {
+ if s.inStr {
+ if !s.strEsc && !s.strIsKey && s.valTarget == xaiValIgnore {
+ for i < len(p) && p[i] != '"' && p[i] != '\\' {
+ i++
+ }
+ if i >= len(p) {
+ break
+ }
+ }
+ c := p[i]
+ if s.strEsc {
+ if s.strIsKey {
+ s.keyArena = append(s.keyArena, c)
+ } else if s.valTarget != xaiValIgnore {
+ if !s.valOverflow {
+ if s.valLen < len(s.valBuf) {
+ s.valBuf[s.valLen] = c
+ s.valLen++
+ } else {
+ s.valOverflow = true
+ }
+ }
+ s.valLastByte = 0
+ }
+ s.strEsc = false
+ i++
+ continue
+ }
+ if c == '\\' {
+ if s.strIsKey {
+ s.curKeyHasEsc = true
+ s.keyArena = append(s.keyArena, c)
+ } else if s.valTarget != xaiValIgnore {
+ s.valHasEsc = true
+ if !s.valOverflow {
+ if s.valLen < len(s.valBuf) {
+ s.valBuf[s.valLen] = c
+ s.valLen++
+ } else {
+ s.valOverflow = true
+ }
+ }
+ s.valLastByte = 0
+ }
+ s.strEsc = true
+ i++
+ continue
+ }
+ if c == '"' {
+ s.inStr = false
+ if s.strIsKey {
+ s.strIsKey = false
+ s.finishKey()
+ } else if s.valTarget != xaiValIgnore {
+ s.finishValString()
+ }
+ i++
+ continue
+ }
+ if s.strIsKey {
+ s.keyArena = append(s.keyArena, c)
+ } else if s.valTarget != xaiValIgnore {
+ if s.valLen == 0 && !s.valOverflow {
+ s.valFirstByte = c
+ }
+ if c != ' ' && c != '\t' && c != '\r' && c != '\n' {
+ s.valHasNonSpace = true
+ }
+ s.valLastByte = c
+ if !s.valOverflow {
+ if s.valLen < len(s.valBuf) {
+ s.valBuf[s.valLen] = c
+ s.valLen++
+ } else {
+ s.valOverflow = true
}
}
}
- changed = normalizeXAIImageRefsValue(child) || changed
+ i++
+ continue
+ }
+
+ for i < len(p) && (p[i] == ' ' || p[i] == '\t' || p[i] == '\r' || p[i] == '\n') {
+ i++
+ }
+ if i >= len(p) {
+ break
}
- case []any:
- for _, child := range node {
- changed = normalizeXAIImageRefsValue(child) || changed
+ c := p[i]
+ d := len(s.frames) - 1
+ switch c {
+ case ':':
+ if d > 0 {
+ s.frames[d].inKey = false
+ }
+ i++
+ case ',':
+ if d > 0 {
+ if s.frames[d].isObj {
+ s.frames[d].inKey = true
+ } else {
+ s.frames[d].elemIdx++
+ }
+ }
+ i++
+ case '}', ']':
+ if d > 0 {
+ if len(s.refStack) > 0 && s.refStack[len(s.refStack)-1].depth == d {
+ s.finishRefObject(d)
+ }
+ s.keyArena = s.keyArena[:s.frames[d-1].keyEnd]
+ s.frames = s.frames[:d]
+ }
+ i++
+ case '"':
+ s.inStr = true
+ s.strEsc = false
+ if d > 0 && s.frames[d].isObj && s.frames[d].inKey {
+ s.strIsKey = true
+ s.valTarget = xaiValIgnore
+ s.curKeyStart = s.frames[d-1].keyEnd
+ s.curKeyHasEsc = false
+ s.keyArena = s.keyArena[:s.curKeyStart]
+ } else {
+ s.strIsKey = false
+ s.valTarget = xaiValIgnore
+ if len(s.refStack) > 0 {
+ ref := &s.refStack[len(s.refStack)-1]
+ if d == ref.depth && s.frames[d].isObj {
+ switch s.frames[d].keyKind {
+ case xaiKeyURL:
+ s.valTarget = xaiValRefURL
+ case xaiKeyImageURL:
+ ref.hasImageURL = true
+ s.valTarget = xaiValRefImageURL
+ }
+ } else if d == ref.depth+1 && s.frames[d].isImageURLObj && s.frames[d].keyKind == xaiKeyURL {
+ s.valTarget = xaiValRefNestedURL
+ }
+ if s.valTarget != xaiValIgnore {
+ s.valHasEsc = false
+ s.valHasNonSpace = false
+ s.valOverflow = false
+ s.valFirstByte = 0
+ s.valLastByte = 0
+ s.valLen = 0
+ }
+ }
+ }
+ i++
+ case '{', '[':
+ isImagesArr := false
+ isImageURLObj := false
+ isRefObj := false
+ if d > 0 {
+ parent := &s.frames[d]
+ if len(s.refStack) > 0 {
+ ref := &s.refStack[len(s.refStack)-1]
+ if d == ref.depth && parent.isObj && parent.keyKind == xaiKeyImageURL {
+ ref.hasImageURL = true
+ if c == '{' {
+ isImageURLObj = true
+ }
+ }
+ }
+ if c == '{' {
+ if (parent.isObj && parent.keyKind == xaiKeyImage) || (!parent.isObj && parent.isImagesArr) {
+ isRefObj = true
+ }
+ } else if c == '[' && parent.isObj && parent.keyKind == xaiKeyImages {
+ isImagesArr = true
+ }
+ }
+ prevKeyEnd := 0
+ if d > 0 {
+ prevKeyEnd = s.frames[d].keyEnd
+ }
+ newDepth := len(s.frames)
+ s.frames = append(s.frames, xaiScanFrame{
+ isObj: c == '{',
+ inKey: c == '{',
+ isImagesArr: isImagesArr,
+ isImageURLObj: isImageURLObj,
+ keyStart: prevKeyEnd,
+ keyEnd: prevKeyEnd,
+ })
+ if isRefObj {
+ s.refStack = append(s.refStack, xaiRefCandidate{depth: newDepth})
+ }
+ i++
+ default:
+ if d > 0 && len(s.refStack) > 0 {
+ ref := &s.refStack[len(s.refStack)-1]
+ if d == ref.depth && s.frames[d].isObj && s.frames[d].keyKind == xaiKeyImageURL {
+ ref.hasImageURL = true
+ }
+ }
+ i++
}
}
- return changed
+ return len(p), nil
}
-func normalizeXAIImageRef(value any) bool {
- ref, ok := value.(map[string]any)
- if !ok {
- return false
- }
-
- originalURL, _ := ref["url"].(string)
- url := strings.TrimSpace(originalURL)
- imageURL, hasImageURL := ref["image_url"]
- if url == "" {
- switch imageURL := imageURL.(type) {
- case string:
- url = strings.TrimSpace(imageURL)
- case map[string]any:
- url, _ = imageURL["url"].(string)
- url = strings.TrimSpace(url)
+func isSkippedUnderDeletedXAIImageURL(path string, edits []xaiImageRefEdit) bool {
+ for i := range edits {
+ outer := &edits[i]
+ if !outer.valid || !outer.hasImageURL || outer.path == "" || len(path) <= len(outer.path)+11 {
+ continue
+ }
+ if strings.HasPrefix(path, outer.path) && strings.HasPrefix(path[len(outer.path):], ".image_url.") {
+ return true
}
}
- if url == "" {
+ return false
+}
+
+func normalizeXAIImageRefsDoc(doc *ojson.Doc) bool {
+ if doc == nil || doc.Len() == 0 {
return false
}
- if url == originalURL && !hasImageURL {
+ scanner := xaiImageRefScannerPool.Get().(*xaiImageRefScanner)
+ scanner.init()
+ defer func() {
+ scanner.reset()
+ xaiImageRefScannerPool.Put(scanner)
+ }()
+ _, _ = doc.WriteTo(scanner)
+ if len(scanner.edits) == 0 {
return false
}
+ for i := range scanner.edits {
+ edit := &scanner.edits[i]
+ switch edit.resolveMode {
+ case xaiResolveNone:
+ edit.valid = true
+ case xaiResolveURL:
+ urlRes := doc.Get(edit.path + ".url")
+ var orig string
+ if urlRes.Type == ojson.String {
+ orig = urlRes.String()
+ }
+ trimmed := strings.TrimSpace(orig)
+ if trimmed == "" {
+ imgRes := doc.Get(edit.path + ".image_url")
+ if imgRes.Type == ojson.String {
+ trimmed = strings.TrimSpace(imgRes.String())
+ } else if imgRes.IsObject() {
+ if nested := imgRes.Get("url"); nested.Type == ojson.String {
+ trimmed = strings.TrimSpace(nested.String())
+ }
+ }
+ if trimmed == "" {
+ continue
+ }
+ edit.setURL = true
+ } else if !edit.hasImageURL && trimmed == orig {
+ continue
+ } else {
+ edit.setURL = trimmed != orig
+ }
+ edit.url = trimmed
+ edit.valid = true
+ case xaiResolveImageURL:
+ edit.url = strings.TrimSpace(doc.Get(edit.path + ".image_url").String())
+ if edit.url != "" {
+ edit.valid = true
+ }
+ case xaiResolveNestedImageURL:
+ edit.url = strings.TrimSpace(doc.Get(edit.path + ".image_url.url").String())
+ if edit.url != "" {
+ edit.valid = true
+ }
+ }
+ }
+ changed := false
+ for i := range scanner.edits {
+ edit := &scanner.edits[i]
+ if !edit.valid || isSkippedUnderDeletedXAIImageURL(edit.path, scanner.edits) {
+ continue
+ }
+ if edit.setURL {
+ if errSet := doc.Set(edit.path+".url", edit.url); errSet == nil {
+ changed = true
+ }
+ }
+ if edit.hasImageURL {
+ if errDel := doc.Delete(edit.path + ".image_url"); errDel == nil {
+ changed = true
+ }
+ }
+ }
+ return changed
+}
- // Always emit the xAI field name and drop the OpenAI alias.
- ref["url"] = url
- delete(ref, "image_url")
- return true
+func normalizeXAIImageRefs(body []byte) []byte {
+ if !xaiBytesMayContainImageRef(body) || !ojson.ValidBytes(body) {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIImageRefsDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
}
func xaiIsVideoRequest(opts cliproxyexecutor.Options) bool {
@@ -577,29 +1179,45 @@ func xaiMetadataString(meta map[string]any, key string) string {
}
func preserveXAIResponsesOutputControls(body, source []byte, from sdktranslator.Format) []byte {
- var maxOutputTokens gjson.Result
+ doc := ojson.NewDoc(body)
+ if !preserveXAIResponsesOutputControlsDoc(doc, source, from) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func preserveXAIResponsesOutputControlsDoc(doc *ojson.Doc, source []byte, from sdktranslator.Format) bool {
+ if doc == nil {
+ return false
+ }
+ var maxOutputTokens ojson.Result
switch from {
case sdktranslator.FormatOpenAI:
- maxOutputTokens = gjson.GetBytes(source, "max_completion_tokens")
- if !maxOutputTokens.Exists() || maxOutputTokens.Type == gjson.Null {
- maxOutputTokens = gjson.GetBytes(source, "max_tokens")
+ maxOutputTokens = ojson.GetBytesNoCopy(source, "max_completion_tokens")
+ if !maxOutputTokens.Exists() || maxOutputTokens.Type == ojson.Null {
+ maxOutputTokens = ojson.GetBytesNoCopy(source, "max_tokens")
}
case sdktranslator.FormatOpenAIResponse:
- maxOutputTokens = gjson.GetBytes(source, "max_output_tokens")
+ maxOutputTokens = ojson.GetBytesNoCopy(source, "max_output_tokens")
default:
- return body
+ return false
}
- if maxOutputTokens.Exists() && maxOutputTokens.Type != gjson.Null {
- body, _ = sjson.SetRawBytes(body, "max_output_tokens", []byte(maxOutputTokens.Raw))
+ changed := false
+ if maxOutputTokens.Exists() && maxOutputTokens.Type != ojson.Null {
+ if doc.SetRawBytes("max_output_tokens", []byte(maxOutputTokens.Raw)) == nil {
+ changed = true
+ }
}
for _, field := range []string{"temperature", "top_p", "top_k"} {
- value := gjson.GetBytes(source, field)
- if value.Exists() && value.Type != gjson.Null {
- body, _ = sjson.SetRawBytes(body, field, []byte(value.Raw))
+ value := ojson.GetBytesNoCopy(source, field)
+ if value.Exists() && value.Type != ojson.Null {
+ if doc.SetRawBytes(field, []byte(value.Raw)) == nil {
+ changed = true
+ }
}
}
- return body
+ return changed
}
// xaiGrokImageGenerationMinVersion is the first Grok line that accepts xAI's
@@ -694,48 +1312,86 @@ func xaiCompareGrokVersion(a, b xaiGrokVersion) int {
// executors both prepare payloads through prepareResponsesRequestTo, so this runs
// once before the body is submitted upstream.
func ensureXAINativeXSearchTool(body []byte) []byte {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return body
}
- if !xaiRequestHasNativeXSearch(body) {
- tools := gjson.GetBytes(body, "tools")
+ doc := ojson.NewDoc(body)
+ if !ensureXAINativeXSearchToolDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func ensureXAINativeXSearchToolDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
+ if !xaiRequestHasNativeXSearchDoc(doc) {
+ tools := doc.Get("tools")
if !tools.Exists() || !tools.IsArray() {
- body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"x_search"}]`))
+ if doc.SetRawBytes("tools", []byte(`[{"type":"x_search"}]`)) == nil {
+ changed = true
+ }
} else {
- body, _ = sjson.SetRawBytes(body, "tools.-1", xaiXSearchToolJSON)
+ if doc.SetRawBytes("tools.-1", xaiXSearchToolJSON) == nil {
+ changed = true
+ }
}
}
- return ensureXAINativeXSearchAllowedTools(body)
+ if ensureXAINativeXSearchAllowedToolsDoc(doc) {
+ changed = true
+ }
+ return changed
}
// ensureXAINativeXSearchAllowedTools appends x_search to tool_choice.tools when
// the choice mode is allowed_tools and x_search is not already listed.
func ensureXAINativeXSearchAllowedTools(body []byte) []byte {
- choice := gjson.GetBytes(body, "tool_choice")
- if !choice.IsObject() || choice.Get("type").String() != "allowed_tools" {
+ doc := ojson.NewDoc(body)
+ if !ensureXAINativeXSearchAllowedToolsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+func ensureXAINativeXSearchAllowedToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ choice := doc.Get("tool_choice")
+ if !choice.IsObject() || choice.Get("type").String() != "allowed_tools" {
+ return false
+ }
allowed := choice.Get("tools")
if !allowed.Exists() || !allowed.IsArray() {
- body, _ = sjson.SetRawBytes(body, "tool_choice.tools", []byte(`[{"type":"x_search"}]`))
- return body
+ return doc.SetRawBytes("tool_choice.tools", []byte(`[{"type":"x_search"}]`)) == nil
}
for _, tool := range allowed.Array() {
if strings.TrimSpace(tool.Get("type").String()) == xaiXSearchToolType {
- return body
+ return false
}
}
- body, _ = sjson.SetRawBytes(body, "tool_choice.tools.-1", xaiXSearchToolJSON)
- return body
+ return doc.SetRawBytes("tool_choice.tools.-1", xaiXSearchToolJSON) == nil
}
// xaiHasClientWebSearchFunction reports whether body declares a client function
// or custom tool named "web_search" (without namespace), excluding folded namespace dispatchers.
func xaiHasClientWebSearchFunction(body []byte, namespaceTools map[string]xaiNamespaceToolRef) bool {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return false
}
- tools := gjson.GetBytes(body, "tools")
+ return xaiHasClientWebSearchFunctionResult(ojson.GetBytesNoCopy(body, "tools"), namespaceTools)
+}
+
+func xaiHasClientWebSearchFunctionDoc(doc *ojson.Doc, namespaceTools map[string]xaiNamespaceToolRef) bool {
+ if doc == nil {
+ return false
+ }
+ return xaiHasClientWebSearchFunctionResult(doc.Get("tools"), namespaceTools)
+}
+
+func xaiHasClientWebSearchFunctionResult(tools ojson.Result, namespaceTools map[string]xaiNamespaceToolRef) bool {
if !tools.IsArray() {
return false
}
@@ -754,7 +1410,17 @@ func xaiHasClientWebSearchFunction(body []byte, namespaceTools map[string]xaiNam
// xaiBodyHasToolNamed reports whether any tool in tools or input has the given name.
func xaiBodyHasToolNamed(body []byte, name string) bool {
- tools := gjson.GetBytes(body, "tools")
+ return xaiToolsOrInputHaveToolNamed(ojson.GetBytesNoCopy(body, "tools"), ojson.GetBytesNoCopy(body, "input"), name)
+}
+
+func xaiBodyHasToolNamedDoc(doc *ojson.Doc, name string) bool {
+ if doc == nil {
+ return false
+ }
+ return xaiToolsOrInputHaveToolNamed(doc.Get("tools"), doc.Get("input"), name)
+}
+
+func xaiToolsOrInputHaveToolNamed(tools, input ojson.Result, name string) bool {
if tools.IsArray() {
for _, tool := range tools.Array() {
if strings.TrimSpace(tool.Get("name").String()) == name {
@@ -769,7 +1435,6 @@ func xaiBodyHasToolNamed(body []byte, name string) bool {
}
}
}
- input := gjson.GetBytes(body, "input")
if input.IsArray() {
for _, item := range input.Array() {
if strings.TrimSpace(item.Get("name").String()) == name {
@@ -795,29 +1460,56 @@ func xaiResolveClientWebSearchAlias(body []byte) string {
}
}
+func xaiResolveClientWebSearchAliasDoc(doc *ojson.Doc) string {
+ candidate := xaiClientWebSearchAlias
+ if !xaiBodyHasToolNamedDoc(doc, candidate) {
+ return candidate
+ }
+ for i := 1; ; i++ {
+ next := fmt.Sprintf("%s_%d", xaiClientWebSearchAlias, i)
+ if !xaiBodyHasToolNamedDoc(doc, next) {
+ return next
+ }
+ }
+}
+
// aliasXAIClientWebSearchInput renames client-declared function calls named
// "web_search" to alias in replayed input history.
func aliasXAIClientWebSearchInput(body []byte, alias string, namespaceTools map[string]xaiNamespaceToolRef) []byte {
- if !gjson.ValidBytes(body) || alias == "" {
+ if !ojson.ValidBytes(body) || alias == "" {
return body
}
- if _, isNamespace := namespaceTools[xaiWebSearchToolType]; isNamespace {
+ doc := ojson.NewDoc(body)
+ if !aliasXAIClientWebSearchInputDoc(doc, alias, namespaceTools) {
return body
}
- input := gjson.GetBytes(body, "input")
+ return doc.Bytes()
+}
+
+func aliasXAIClientWebSearchInputDoc(doc *ojson.Doc, alias string, namespaceTools map[string]xaiNamespaceToolRef) bool {
+ if doc == nil || alias == "" {
+ return false
+ }
+ if _, isNamespace := namespaceTools[xaiWebSearchToolType]; isNamespace {
+ return false
+ }
+ input := doc.Get("input")
if !input.IsArray() {
- return body
+ return false
}
+ changed := false
for idx, item := range input.Array() {
itemType := strings.TrimSpace(item.Get("type").String())
itemName := strings.TrimSpace(item.Get("name").String())
itemNamespace := strings.TrimSpace(item.Get("namespace").String())
if (itemType == "function_call" || itemType == "custom_tool_call" || itemType == "function_call_output") &&
itemName == xaiWebSearchToolType && itemNamespace == "" {
- body, _ = sjson.SetBytes(body, fmt.Sprintf("input.%d.name", idx), alias)
+ if doc.Set(fmt.Sprintf("input.%d.name", idx), alias) == nil {
+ changed = true
+ }
}
}
- return body
+ return changed
}
// aliasXAIClientWebSearchFunction renames client-declared function tools named
@@ -825,11 +1517,23 @@ func aliasXAIClientWebSearchInput(body []byte, alias string, namespaceTools map[
// and replayed input history to prevent xAI from hijacking them into hosted
// server-side search. Namespace dispatcher tools named "web_search" are untouched.
func aliasXAIClientWebSearchFunction(body []byte, alias string, namespaceTools map[string]xaiNamespaceToolRef) []byte {
- if !gjson.ValidBytes(body) || alias == "" {
+ if !ojson.ValidBytes(body) || alias == "" {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !aliasXAIClientWebSearchFunctionDoc(doc, alias, namespaceTools) {
return body
}
+ return doc.Bytes()
+}
+
+func aliasXAIClientWebSearchFunctionDoc(doc *ojson.Doc, alias string, namespaceTools map[string]xaiNamespaceToolRef) bool {
+ if doc == nil || alias == "" {
+ return false
+ }
+ changed := false
// 1. Alias in tools (excluding namespace dispatchers)
- tools := gjson.GetBytes(body, "tools")
+ tools := doc.Get("tools")
if tools.IsArray() {
for idx, tool := range tools.Array() {
toolType := strings.TrimSpace(tool.Get("type").String())
@@ -838,18 +1542,22 @@ func aliasXAIClientWebSearchFunction(body []byte, alias string, namespaceTools m
if _, isNamespace := namespaceTools[name]; isNamespace {
continue
}
- body, _ = sjson.SetBytes(body, fmt.Sprintf("tools.%d.name", idx), alias)
+ if doc.Set(fmt.Sprintf("tools.%d.name", idx), alias) == nil {
+ changed = true
+ }
}
}
}
// 2. Alias in tool_choice (only when unnamespaced and not a namespace dispatcher)
- choice := gjson.GetBytes(body, "tool_choice")
+ choice := doc.Get("tool_choice")
if choice.IsObject() {
if fnName := choice.Get("function.name"); fnName.Exists() && strings.TrimSpace(fnName.String()) == xaiWebSearchToolType {
if strings.TrimSpace(choice.Get("function.namespace").String()) == "" {
if _, isNamespace := namespaceTools[xaiWebSearchToolType]; !isNamespace {
- body, _ = sjson.SetBytes(body, "tool_choice.function.name", alias)
+ if doc.Set("tool_choice.function.name", alias) == nil {
+ changed = true
+ }
}
}
}
@@ -858,7 +1566,9 @@ func aliasXAIClientWebSearchFunction(body []byte, alias string, namespaceTools m
choiceType := strings.TrimSpace(choice.Get("type").String())
if choiceType == xaiFunctionToolType || choiceType == "tool" {
if _, isNamespace := namespaceTools[xaiWebSearchToolType]; !isNamespace {
- body, _ = sjson.SetBytes(body, "tool_choice.name", alias)
+ if doc.Set("tool_choice.name", alias) == nil {
+ changed = true
+ }
}
}
}
@@ -874,16 +1584,23 @@ func aliasXAIClientWebSearchFunction(body []byte, alias string, namespaceTools m
allowedType := strings.TrimSpace(allowedTool.Get("type").String())
allowedName := strings.TrimSpace(allowedTool.Get("name").String())
if (allowedType == xaiFunctionToolType || allowedType == "tool") && allowedName == xaiWebSearchToolType {
- body, _ = sjson.SetBytes(body, fmt.Sprintf("tool_choice.tools.%d.name", idx), alias)
+ if doc.Set(fmt.Sprintf("tool_choice.tools.%d.name", idx), alias) == nil {
+ changed = true
+ }
} else if allowedName == xaiWebSearchToolType && allowedType != xaiWebSearchToolType {
- body, _ = sjson.SetBytes(body, fmt.Sprintf("tool_choice.tools.%d.name", idx), alias)
+ if doc.Set(fmt.Sprintf("tool_choice.tools.%d.name", idx), alias) == nil {
+ changed = true
+ }
}
}
}
}
// 3. Alias in input (conversation history, only when unnamespaced)
- return aliasXAIClientWebSearchInput(body, alias, namespaceTools)
+ if aliasXAIClientWebSearchInputDoc(doc, alias, namespaceTools) {
+ changed = true
+ }
+ return changed
}
// normalizeXAIForcedWebSearchToolChoice rewrites web_search choices into a
@@ -892,12 +1609,20 @@ func normalizeXAIForcedWebSearchToolChoice(body []byte) []byte {
return normalizeXAIForcedHostedToolChoice(body, xaiWebSearchToolType)
}
+func normalizeXAIForcedWebSearchToolChoiceDoc(doc *ojson.Doc) bool {
+ return normalizeXAIForcedHostedToolChoiceDoc(doc, xaiWebSearchToolType)
+}
+
// normalizeXAIForcedImageGenerationToolChoice rewrites image_generation choices
// into a ModelToolChoice variant accepted by xAI chat-proxy.
func normalizeXAIForcedImageGenerationToolChoice(body []byte) []byte {
return normalizeXAIForcedHostedToolChoice(body, xaiImageGenerationToolType)
}
+func normalizeXAIForcedImageGenerationToolChoiceDoc(doc *ojson.Doc) bool {
+ return normalizeXAIForcedHostedToolChoiceDoc(doc, xaiImageGenerationToolType)
+}
+
// normalizeXAIForcedHostedToolChoice rewrites choices for a hosted tool (web_search
// or image_generation) into a ModelToolChoice variant accepted by xAI chat-proxy.
// `{type: }` becomes the string "required" and the tools list is reduced
@@ -907,21 +1632,35 @@ func normalizeXAIForcedImageGenerationToolChoice(body []byte) []byte {
// drop the hosted tool entry so the remaining hosted/function choices can still
// deserialize.
func normalizeXAIForcedHostedToolChoice(body []byte, toolType string) []byte {
- choice := gjson.GetBytes(body, "tool_choice")
- if !choice.IsObject() {
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIForcedHostedToolChoiceDoc(doc, toolType) {
return body
}
+ return doc.Bytes()
+}
+
+func normalizeXAIForcedHostedToolChoiceDoc(doc *ojson.Doc, toolType string) bool {
+ if doc == nil {
+ return false
+ }
+ choice := doc.Get("tool_choice")
+ if !choice.IsObject() {
+ return false
+ }
choiceType := strings.TrimSpace(choice.Get("type").String())
if choiceType == toolType {
- body = xaiKeepOnlyHostedTools(body, toolType)
- return xaiSetToolChoiceString(body, "required")
+ changed := xaiKeepOnlyHostedToolsDoc(doc, toolType)
+ if doc.Set("tool_choice", "required") == nil {
+ changed = true
+ }
+ return changed
}
if choiceType != "allowed_tools" {
- return body
+ return false
}
allowed := choice.Get("tools")
if !allowed.IsArray() {
- return body
+ return false
}
filtered := make([][]byte, 0, len(allowed.Array()))
stripped := false
@@ -933,27 +1672,37 @@ func normalizeXAIForcedHostedToolChoice(body []byte, toolType string) []byte {
filtered = append(filtered, []byte(tool.Raw))
}
if !stripped {
- return body
+ return false
}
if len(filtered) == 0 {
mode := strings.TrimSpace(choice.Get("mode").String())
if mode != "auto" {
mode = "required"
}
- body = xaiKeepOnlyHostedTools(body, toolType)
- return xaiSetToolChoiceString(body, mode)
+ changed := xaiKeepOnlyHostedToolsDoc(doc, toolType)
+ if doc.Set("tool_choice", mode) == nil {
+ changed = true
+ }
+ return changed
}
- updated, errSet := sjson.SetRawBytes(body, "tool_choice.tools", helps.JoinRawJSONArray(filtered))
- if errSet != nil {
+ return doc.SetRawBytes("tool_choice.tools", helps.JoinRawJSONArray(filtered)) == nil
+}
+
+func xaiKeepOnlyHostedTools(body []byte, toolType string) []byte {
+ doc := ojson.NewDoc(body)
+ if !xaiKeepOnlyHostedToolsDoc(doc, toolType) {
return body
}
- return updated
+ return doc.Bytes()
}
-func xaiKeepOnlyHostedTools(body []byte, toolType string) []byte {
- tools := gjson.GetBytes(body, "tools")
+func xaiKeepOnlyHostedToolsDoc(doc *ojson.Doc, toolType string) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("tools")
if !tools.IsArray() {
- return body
+ return false
}
kept := make([][]byte, 0, 1)
for _, tool := range tools.Array() {
@@ -962,18 +1711,24 @@ func xaiKeepOnlyHostedTools(body []byte, toolType string) []byte {
}
}
if len(kept) == 0 || len(kept) == len(tools.Array()) {
- return body
- }
- updated, errSet := sjson.SetRawBytes(body, "tools", helps.JoinRawJSONArray(kept))
- if errSet != nil {
- return body
+ return false
}
- return updated
+ return doc.SetRawBytes("tools", helps.JoinRawJSONArray(kept)) == nil
}
func xaiToolChoiceRequiresHostedToolOnly(body []byte, toolType string) bool {
- choice := gjson.GetBytes(body, "tool_choice")
- if choice.Type != gjson.String {
+ return xaiToolChoiceRequiresHostedToolOnlyResults(ojson.GetBytesNoCopy(body, "tool_choice"), ojson.GetBytesNoCopy(body, "tools"), toolType)
+}
+
+func xaiToolChoiceRequiresHostedToolOnlyDoc(doc *ojson.Doc, toolType string) bool {
+ if doc == nil {
+ return false
+ }
+ return xaiToolChoiceRequiresHostedToolOnlyResults(doc.Get("tool_choice"), doc.Get("tools"), toolType)
+}
+
+func xaiToolChoiceRequiresHostedToolOnlyResults(choice, tools ojson.Result, toolType string) bool {
+ if choice.Type != ojson.String {
return false
}
switch choice.String() {
@@ -981,7 +1736,6 @@ func xaiToolChoiceRequiresHostedToolOnly(body []byte, toolType string) bool {
default:
return false
}
- tools := gjson.GetBytes(body, "tools")
if !tools.IsArray() || len(tools.Array()) == 0 {
return false
}
@@ -1005,8 +1759,13 @@ func xaiToolChoiceRequiresHostedToolOnlyAny(body []byte) bool {
return xaiToolChoiceRequiresImageGenerationOnly(body) || xaiToolChoiceRequiresWebSearchOnly(body)
}
+func xaiToolChoiceRequiresHostedToolOnlyAnyDoc(doc *ojson.Doc) bool {
+ return xaiToolChoiceRequiresHostedToolOnlyDoc(doc, xaiImageGenerationToolType) ||
+ xaiToolChoiceRequiresHostedToolOnlyDoc(doc, xaiWebSearchToolType)
+}
+
func xaiSetToolChoiceString(body []byte, value string) []byte {
- updated, errSet := sjson.SetBytes(body, "tool_choice", value)
+ updated, errSet := ojson.SetBytes(body, "tool_choice", value)
if errSet != nil {
return body
}
@@ -1018,43 +1777,63 @@ func xaiSetToolChoiceString(body []byte, value string) []byte {
// reference a deleted tool are dropped entirely; allowed_tools lists keep only
// choices that still resolve against the post-normalization tools set.
func pruneXAIOrphanedToolChoice(body []byte) []byte {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return body
}
- choice := gjson.GetBytes(body, "tool_choice")
- if !choice.Exists() {
+ doc := ojson.NewDoc(body)
+ if !pruneXAIOrphanedToolChoiceDoc(doc) {
return body
}
- available := collectXAIAvailableToolChoiceKeys(body)
- if choice.Type == gjson.String {
+ return doc.Bytes()
+}
+
+func pruneXAIOrphanedToolChoiceDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ choice := doc.Get("tool_choice")
+ if !choice.Exists() {
+ return false
+ }
+ available := collectXAIAvailableToolChoiceKeysDoc(doc)
+ if choice.Type == ojson.String {
// auto / none / required are not tool references.
- return body
+ return false
}
if !choice.IsObject() {
- return body
+ return false
}
choiceType := strings.TrimSpace(choice.Get("type").String())
switch choiceType {
case "allowed_tools":
- return pruneXAIAllowedToolsChoice(body, available)
+ return pruneXAIAllowedToolsChoiceDoc(doc, available)
default:
if choiceType == "" {
- return body
+ return false
}
if xaiToolChoiceMatchesAvailable(choice, available) {
- return body
+ return false
}
- body, _ = sjson.DeleteBytes(body, "tool_choice")
- return body
+ return doc.Delete("tool_choice") == nil
}
}
func pruneXAIAllowedToolsChoice(body []byte, available map[xaiToolChoiceKey]struct{}) []byte {
- allowed := gjson.GetBytes(body, "tool_choice.tools")
- if !allowed.Exists() || !allowed.IsArray() {
- body, _ = sjson.DeleteBytes(body, "tool_choice")
+ doc := ojson.NewDoc(body)
+ if !pruneXAIAllowedToolsChoiceDoc(doc, available) {
return body
}
+ return doc.Bytes()
+}
+
+func pruneXAIAllowedToolsChoiceDoc(doc *ojson.Doc, available map[xaiToolChoiceKey]struct{}) bool {
+ if doc == nil {
+ return false
+ }
+ allowed := doc.Get("tool_choice.tools")
+ if !allowed.Exists() || !allowed.IsArray() {
+ return doc.Delete("tool_choice") == nil
+ }
allowedItems := allowed.Array()
filtered := make([][]byte, 0, len(allowedItems))
changed := false
@@ -1066,14 +1845,12 @@ func pruneXAIAllowedToolsChoice(body []byte, available map[xaiToolChoiceKey]stru
filtered = append(filtered, []byte(tool.Raw))
}
if !changed {
- return body
+ return false
}
if len(filtered) == 0 {
- body, _ = sjson.DeleteBytes(body, "tool_choice")
- return body
+ return doc.Delete("tool_choice") == nil
}
- body, _ = sjson.SetRawBytes(body, "tool_choice.tools", helps.JoinRawJSONArray(filtered))
- return body
+ return doc.SetRawBytes("tool_choice.tools", helps.JoinRawJSONArray(filtered)) == nil
}
// xaiToolChoiceKey identifies a selectable tool the way xAI tool_choice entries
@@ -1085,8 +1862,19 @@ type xaiToolChoiceKey struct {
}
func collectXAIAvailableToolChoiceKeys(body []byte) map[xaiToolChoiceKey]struct{} {
+ return collectXAIAvailableToolChoiceKeysResults(ojson.GetBytesNoCopy(body, "tools"), ojson.GetBytesNoCopy(body, "input"))
+}
+
+func collectXAIAvailableToolChoiceKeysDoc(doc *ojson.Doc) map[xaiToolChoiceKey]struct{} {
+ if doc == nil {
+ return make(map[xaiToolChoiceKey]struct{})
+ }
+ return collectXAIAvailableToolChoiceKeysResults(doc.Get("tools"), doc.Get("input"))
+}
+
+func collectXAIAvailableToolChoiceKeysResults(toolsResult, inputResult ojson.Result) map[xaiToolChoiceKey]struct{} {
keys := make(map[xaiToolChoiceKey]struct{})
- collect := func(tools gjson.Result) {
+ collect := func(tools ojson.Result) {
if !tools.IsArray() {
return
}
@@ -1105,10 +1893,9 @@ func collectXAIAvailableToolChoiceKeys(body []byte) map[xaiToolChoiceKey]struct{
keys[key] = struct{}{}
}
}
- collect(gjson.GetBytes(body, "tools"))
- input := gjson.GetBytes(body, "input")
- if input.IsArray() {
- for _, item := range input.Array() {
+ collect(toolsResult)
+ if inputResult.IsArray() {
+ for _, item := range inputResult.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
@@ -1117,7 +1904,7 @@ func collectXAIAvailableToolChoiceKeys(body []byte) map[xaiToolChoiceKey]struct{
return keys
}
-func xaiToolChoiceMatchesAvailable(choice gjson.Result, available map[xaiToolChoiceKey]struct{}) bool {
+func xaiToolChoiceMatchesAvailable(choice ojson.Result, available map[xaiToolChoiceKey]struct{}) bool {
toolType := strings.TrimSpace(choice.Get("type").String())
if toolType == "" {
return false
@@ -1133,7 +1920,7 @@ func xaiToolChoiceMatchesAvailable(choice gjson.Result, available map[xaiToolCho
return ok
}
-func xaiCountFlattenedToolsWithSeen(tools gjson.Result, seenFunctionNames map[string]struct{}) int {
+func xaiCountFlattenedToolsWithSeen(tools ojson.Result, seenFunctionNames map[string]struct{}) int {
if !tools.Exists() || !tools.IsArray() {
return 0
}
@@ -1177,14 +1964,14 @@ func xaiCountFlattenedToolsWithSeen(tools gjson.Result, seenFunctionNames map[st
return count
}
-func xaiCountFlattenedTools(tools gjson.Result) int {
+func xaiCountFlattenedTools(tools ojson.Result) int {
return xaiCountFlattenedToolsWithSeen(tools, make(map[string]struct{}))
}
func xaiTotalFlattenedToolsCount(body []byte, willInjectXSearch bool) int {
seenFunctionNames := make(map[string]struct{})
- count := xaiCountFlattenedToolsWithSeen(gjson.GetBytes(body, "tools"), seenFunctionNames)
- input := gjson.GetBytes(body, "input")
+ count := xaiCountFlattenedToolsWithSeen(ojson.GetBytesNoCopy(body, "tools"), seenFunctionNames)
+ input := ojson.GetBytesNoCopy(body, "input")
if input.Exists() && input.IsArray() {
for _, item := range input.Array() {
if item.Get("type").String() == "additional_tools" {
@@ -1198,13 +1985,48 @@ func xaiTotalFlattenedToolsCount(body []byte, willInjectXSearch bool) int {
return count
}
+func xaiTotalFlattenedToolsCountDoc(doc *ojson.Doc, willInjectXSearch bool) int {
+ if doc == nil {
+ return 0
+ }
+ seenFunctionNames := make(map[string]struct{})
+ count := xaiCountFlattenedToolsWithSeen(doc.Get("tools"), seenFunctionNames)
+ input := doc.Get("input")
+ if input.Exists() && input.IsArray() {
+ for _, item := range input.Array() {
+ if item.Get("type").String() == "additional_tools" {
+ count += xaiCountFlattenedToolsWithSeen(item.Get("tools"), seenFunctionNames)
+ }
+ }
+ }
+ if willInjectXSearch && !xaiRequestHasNativeXSearchDoc(doc) && !xaiToolChoiceRequiresHostedToolOnlyAnyDoc(doc) {
+ count++
+ }
+ return count
+}
+
func xaiShouldFoldNamespaceTools(body []byte, willInjectXSearch bool) bool {
return xaiTotalFlattenedToolsCount(body, willInjectXSearch) > xaiMaxTools
}
-func collectXAINamespaceDeclarationsByName(body []byte) map[string][]gjson.Result {
- byName := make(map[string][]gjson.Result)
- collect := func(tools gjson.Result) {
+func xaiShouldFoldNamespaceToolsDoc(doc *ojson.Doc, willInjectXSearch bool) bool {
+ return xaiTotalFlattenedToolsCountDoc(doc, willInjectXSearch) > xaiMaxTools
+}
+
+func collectXAINamespaceDeclarationsByName(body []byte) map[string][]ojson.Result {
+ return collectXAINamespaceDeclarationsByNameResults(ojson.GetBytesNoCopy(body, "tools"), ojson.GetBytesNoCopy(body, "input"))
+}
+
+func collectXAINamespaceDeclarationsByNameDoc(doc *ojson.Doc) map[string][]ojson.Result {
+ if doc == nil {
+ return make(map[string][]ojson.Result)
+ }
+ return collectXAINamespaceDeclarationsByNameResults(doc.Get("tools"), doc.Get("input"))
+}
+
+func collectXAINamespaceDeclarationsByNameResults(toolsResult, inputResult ojson.Result) map[string][]ojson.Result {
+ byName := make(map[string][]ojson.Result)
+ collect := func(tools ojson.Result) {
if !tools.Exists() || !tools.IsArray() {
return
}
@@ -1219,10 +2041,9 @@ func collectXAINamespaceDeclarationsByName(body []byte) map[string][]gjson.Resul
byName[namespaceName] = append(byName[namespaceName], tool)
}
}
- collect(gjson.GetBytes(body, "tools"))
- input := gjson.GetBytes(body, "input")
- if input.Exists() && input.IsArray() {
- for _, item := range input.Array() {
+ collect(toolsResult)
+ if inputResult.Exists() && inputResult.IsArray() {
+ for _, item := range inputResult.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
@@ -1231,14 +2052,14 @@ func collectXAINamespaceDeclarationsByName(body []byte) map[string][]gjson.Resul
return byName
}
-func buildXAINamespaceDispatcherTool(tool gjson.Result, extraDeclarations ...gjson.Result) []byte {
+func buildXAINamespaceDispatcherTool(tool ojson.Result, extraDeclarations ...ojson.Result) []byte {
namespaceName := strings.TrimSpace(tool.Get("name").String())
if namespaceName == "" {
return nil
}
description := strings.TrimSpace(tool.Get("description").String())
- declarations := make([]gjson.Result, 0, 1+len(extraDeclarations))
+ declarations := make([]ojson.Result, 0, 1+len(extraDeclarations))
declarations = append(declarations, tool)
declarations = append(declarations, extraDeclarations...)
@@ -1278,15 +2099,15 @@ func buildXAINamespaceDispatcherTool(tool gjson.Result, extraDeclarations ...gjs
rawParams := strings.TrimSpace(params.Raw)
if rawParams != "" && rawParams != "{}" && rawParams != `{"type":"object","properties":{}}` {
inlined := util.InlineLocalRefs(rawParams)
- if gjson.Valid(inlined) {
- cleaned := []byte(inlined)
- if gjson.GetBytes(cleaned, "$defs").Exists() {
- cleaned, _ = sjson.DeleteBytes(cleaned, "$defs")
+ if ojson.Valid(inlined) {
+ cleanDoc := ojson.NewDocString(inlined)
+ if cleanDoc.Get("$defs").Exists() {
+ _ = cleanDoc.Delete("$defs")
}
- if gjson.GetBytes(cleaned, "definitions").Exists() {
- cleaned, _ = sjson.DeleteBytes(cleaned, "definitions")
+ if cleanDoc.Get("definitions").Exists() {
+ _ = cleanDoc.Delete("definitions")
}
- paramStr = string(cleaned)
+ paramStr = cleanDoc.String()
} else {
paramStr = inlined
}
@@ -1360,18 +2181,37 @@ func normalizeXAITools(body []byte) []byte {
return normalizeXAIToolsWithFold(body, xaiShouldFoldNamespaceTools(body, false))
}
+func normalizeXAIToolsDoc(doc *ojson.Doc) bool {
+ return normalizeXAIToolsWithFoldDoc(doc, xaiShouldFoldNamespaceToolsDoc(doc, false))
+}
+
func normalizeXAIToolsWithFold(body []byte, shouldFold bool) []byte {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return body
}
- keepImageGeneration := xaiSupportsNativeImageGeneration(gjson.GetBytes(body, "model").String())
- var namespaceDecls map[string][]gjson.Result
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIToolsWithFoldDoc(doc, shouldFold) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func normalizeXAIToolsWithFoldDoc(doc *ojson.Doc, shouldFold bool) bool {
+ if doc == nil {
+ return false
+ }
+ keepImageGeneration := xaiSupportsNativeImageGeneration(doc.Get("model").String())
+ var namespaceDecls map[string][]ojson.Result
if shouldFold {
- namespaceDecls = collectXAINamespaceDeclarationsByName(body)
+ namespaceDecls = collectXAINamespaceDeclarationsByNameDoc(doc)
+ }
+ type pendingSet struct {
+ path string
+ raw []byte
}
- original := body
- normalizeAtPath := func(path string) bool {
- tools := gjson.GetBytes(body, path)
+ var updates []pendingSet
+ planAtPath := func(path string) bool {
+ tools := doc.Get(path)
if !tools.Exists() || !tools.IsArray() {
return true
}
@@ -1379,39 +2219,52 @@ func normalizeXAIToolsWithFold(body []byte, shouldFold bool) []byte {
if !ok {
return false
}
- if !changed {
- return true
- }
- updated, errSet := sjson.SetRawBytes(body, path, filtered)
- if errSet != nil {
- return false
+ if changed {
+ updates = append(updates, pendingSet{path: path, raw: filtered})
}
- body = updated
return true
}
- if !normalizeAtPath("tools") {
- return original
+ if !planAtPath("tools") {
+ return false
}
- input := gjson.GetBytes(body, "input")
+ input := doc.Get("input")
if input.Exists() && input.IsArray() {
for index, item := range input.Array() {
if item.Get("type").String() != "additional_tools" {
continue
}
- if !normalizeAtPath(fmt.Sprintf("input.%d.tools", index)) {
- return original
+ if !planAtPath(fmt.Sprintf("input.%d.tools", index)) {
+ return false
}
}
}
- return body
+ if len(updates) == 0 {
+ return false
+ }
+ for _, u := range updates {
+ if errSet := doc.SetRawBytes(u.path, u.raw); errSet != nil {
+ return false
+ }
+ }
+ return true
}
func xaiHasFunctionToolNamed(body []byte, name string) bool {
+ return xaiHasFunctionToolNamedResults(ojson.GetBytesNoCopy(body, "tools"), ojson.GetBytesNoCopy(body, "input"), name)
+}
+
+func xaiHasFunctionToolNamedDoc(doc *ojson.Doc, name string) bool {
+ if doc == nil {
+ return false
+ }
+ return xaiHasFunctionToolNamedResults(doc.Get("tools"), doc.Get("input"), name)
+}
+
+func xaiHasFunctionToolNamedResults(tools, input ojson.Result, name string) bool {
if name == "" {
return false
}
- tools := gjson.GetBytes(body, "tools")
if tools.IsArray() {
for _, tool := range tools.Array() {
if tool.Get("type").String() == xaiFunctionToolType && tool.Get("name").String() == name {
@@ -1419,7 +2272,6 @@ func xaiHasFunctionToolNamed(body []byte, name string) bool {
}
}
}
- input := gjson.GetBytes(body, "input")
if input.IsArray() {
for _, item := range input.Array() {
if item.Get("type").String() == "additional_tools" {
@@ -1435,10 +2287,21 @@ func xaiHasFunctionToolNamed(body []byte, name string) bool {
}
func clampXAIToolsLimit(body []byte, maxTools int, refs map[string]xaiNamespaceToolRef) []byte {
- tools := gjson.GetBytes(body, "tools")
- if !tools.IsArray() || len(tools.Array()) <= maxTools {
+ doc := ojson.NewDoc(body)
+ if !clampXAIToolsLimitDoc(doc, maxTools, refs) {
return body
}
+ return doc.Bytes()
+}
+
+func clampXAIToolsLimitDoc(doc *ojson.Doc, maxTools int, refs map[string]xaiNamespaceToolRef) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("tools")
+ if !tools.IsArray() || len(tools.Array()) <= maxTools {
+ return false
+ }
allTools := tools.Array()
var dispatcherTools []json.RawMessage
var regularTools []json.RawMessage
@@ -1466,20 +2329,20 @@ func clampXAIToolsLimit(body []byte, maxTools int, refs map[string]xaiNamespaceT
raw, errMarshal := json.Marshal(capped)
if errMarshal != nil {
- return body
+ return false
}
- updated, errSet := sjson.SetRawBytes(body, "tools", raw)
- if errSet != nil {
- return body
+ if errSet := doc.SetRawBytes("tools", raw); errSet != nil {
+ return false
}
- updated = pruneXAIOrphanedToolChoice(updated)
- return normalizeXAIToolChoiceForTools(updated)
+ pruneXAIOrphanedToolChoiceDoc(doc)
+ normalizeXAIToolChoiceForToolsDoc(doc)
+ return true
}
// xaiDeduplicatedToolName returns the unique name key for tool declarations that
// must not repeat in the final xAI tools list (functions, custom tools, and
// namespace dispatchers). Hosted tools without a function name return "".
-func xaiDeduplicatedToolName(tool gjson.Result) string {
+func xaiDeduplicatedToolName(tool ojson.Result) string {
switch strings.TrimSpace(tool.Get("type").String()) {
case xaiFunctionToolType, xaiCustomToolType, xaiNamespaceToolType:
return strings.TrimSpace(tool.Get("name").String())
@@ -1493,17 +2356,28 @@ func xaiDeduplicatedToolName(tool gjson.Result) string {
// Declarations are deduplicated by tool name with first-wins precedence so
// top-level tools remain authoritative over historical additional_tools copies.
func promoteXAIAdditionalTools(body []byte) []byte {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return body
}
- input := gjson.GetBytes(body, "input")
- if !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !promoteXAIAdditionalToolsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+func promoteXAIAdditionalToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
inputItems := input.Array()
remainingInput := make([]json.RawMessage, 0, len(inputItems))
- promotedTools := make([]gjson.Result, 0)
+ promotedTools := make([]ojson.Result, 0)
for _, item := range inputItems {
if item.Get("type").String() != "additional_tools" {
remainingInput = append(remainingInput, json.RawMessage(item.Raw))
@@ -1514,22 +2388,18 @@ func promoteXAIAdditionalTools(body []byte) []byte {
}
}
if len(remainingInput) == len(inputItems) {
- return body
+ return false
}
rawInput, errMarshalInput := json.Marshal(remainingInput)
if errMarshalInput != nil {
- return body
- }
- updated, errSetInput := sjson.SetRawBytes(body, "input", rawInput)
- if errSetInput != nil {
- return body
+ return false
}
if len(promotedTools) == 0 {
- return updated
+ return doc.SetRawBytes("input", rawInput) == nil
}
- topLevelTools := gjson.GetBytes(updated, "tools")
+ topLevelTools := doc.Get("tools")
topLevelArray := topLevelTools.Array()
tools := make([]json.RawMessage, 0, len(topLevelArray)+len(promotedTools))
seenNames := make(map[string]struct{}, len(topLevelArray)+len(promotedTools))
@@ -1555,25 +2425,27 @@ func promoteXAIAdditionalTools(body []byte) []byte {
}
rawTools, errMarshalTools := json.Marshal(tools)
if errMarshalTools != nil {
- return body
+ return false
}
- updated, errSetTools := sjson.SetRawBytes(updated, "tools", rawTools)
- if errSetTools != nil {
- return body
+ if errSetInput := doc.SetRawBytes("input", rawInput); errSetInput != nil {
+ return false
}
- return updated
+ if errSetTools := doc.SetRawBytes("tools", rawTools); errSetTools != nil {
+ return false
+ }
+ return true
}
-func normalizeXAIToolArray(tools gjson.Result, keepImageGeneration, shouldFold bool) ([]byte, bool, bool) {
+func normalizeXAIToolArray(tools ojson.Result, keepImageGeneration, shouldFold bool) ([]byte, bool, bool) {
return normalizeXAIToolArrayWithNamespaces(tools, keepImageGeneration, shouldFold, nil)
}
-func normalizeXAIToolArrayWithNamespaces(tools gjson.Result, keepImageGeneration, shouldFold bool, namespaceDecls map[string][]gjson.Result) ([]byte, bool, bool) {
+func normalizeXAIToolArrayWithNamespaces(tools ojson.Result, keepImageGeneration, shouldFold bool, namespaceDecls map[string][]ojson.Result) ([]byte, bool, bool) {
toolItems := tools.Array()
filtered := make([][]byte, 0, len(toolItems))
seenNames := make(map[string]struct{}, len(toolItems))
appendUnique := func(raw []byte) bool {
- if name := xaiDeduplicatedToolName(gjson.ParseBytes(raw)); name != "" {
+ if name := xaiDeduplicatedToolName(util.ParseGJSONBytesNoCopy(raw)); name != "" {
if _, exists := seenNames[name]; exists {
return true
}
@@ -1633,10 +2505,21 @@ func normalizeXAIToolArrayWithNamespaces(tools gjson.Result, keepImageGeneration
// xAI rejects payloads that include tool_choice without any tools defined.
// Existence checks avoid unnecessary sjson parse/copy passes.
func normalizeXAIToolChoiceForTools(body []byte) []byte {
- tools := gjson.GetBytes(body, "tools")
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIToolChoiceForToolsDoc(doc) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func normalizeXAIToolChoiceForToolsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ tools := doc.Get("tools")
hasTools := tools.Exists() && tools.IsArray() && len(tools.Array()) > 0
if !hasTools {
- input := gjson.GetBytes(body, "input")
+ input := doc.Get("input")
if input.Exists() && input.IsArray() {
for _, item := range input.Array() {
additionalTools := item.Get("tools")
@@ -1648,18 +2531,25 @@ func normalizeXAIToolChoiceForTools(body []byte) []byte {
}
}
if hasTools {
- return body
+ return false
}
+ changed := false
if tools.Exists() {
- body, _ = sjson.DeleteBytes(body, "tools")
+ if doc.Delete("tools") == nil {
+ changed = true
+ }
}
- if gjson.GetBytes(body, "tool_choice").Exists() {
- body, _ = sjson.DeleteBytes(body, "tool_choice")
+ if doc.Get("tool_choice").Exists() {
+ if doc.Delete("tool_choice") == nil {
+ changed = true
+ }
}
- if gjson.GetBytes(body, "parallel_tool_calls").Exists() {
- body, _ = sjson.DeleteBytes(body, "parallel_tool_calls")
+ if doc.Get("parallel_tool_calls").Exists() {
+ if doc.Delete("parallel_tool_calls") == nil {
+ changed = true
+ }
}
- return body
+ return changed
}
// normalizeXAINamespaceToolChoice qualifies namespaced function choices using
@@ -1669,13 +2559,32 @@ func normalizeXAINamespaceToolChoice(body []byte) []byte {
return normalizeXAINamespaceToolChoiceWithFold(body, xaiShouldFoldNamespaceTools(body, false))
}
+func normalizeXAINamespaceToolChoiceDoc(doc *ojson.Doc) bool {
+ return normalizeXAINamespaceToolChoiceWithFoldDoc(doc, xaiShouldFoldNamespaceToolsDoc(doc, false))
+}
+
func normalizeXAINamespaceToolChoiceWithFold(body []byte, shouldFold bool) []byte {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !normalizeXAINamespaceToolChoiceWithFoldDoc(doc, shouldFold) {
return body
}
- original := body
- normalizeAtPath := func(path string) bool {
- toolChoice := gjson.GetBytes(body, path)
+ return doc.Bytes()
+}
+
+func normalizeXAINamespaceToolChoiceWithFoldDoc(doc *ojson.Doc, shouldFold bool) bool {
+ if doc == nil {
+ return false
+ }
+ type pendingChoiceEdit struct {
+ path string
+ targetName string
+ }
+ var edits []pendingChoiceEdit
+ planAtPath := func(path string) bool {
+ toolChoice := doc.Get(path)
if !toolChoice.IsObject() || toolChoice.Get("type").String() != xaiFunctionToolType {
return true
}
@@ -1686,9 +2595,9 @@ func normalizeXAINamespaceToolChoiceWithFold(body []byte, shouldFold bool) []byt
}
qualifiedName := qualifyXAINamespaceToolName(namespaceName, toolName)
var targetName string
- if xaiHasFunctionToolNamed(body, namespaceName) {
+ if xaiHasFunctionToolNamedDoc(doc, namespaceName) {
targetName = namespaceName
- } else if xaiHasFunctionToolNamed(body, qualifiedName) {
+ } else if xaiHasFunctionToolNamedDoc(doc, qualifiedName) {
targetName = qualifiedName
} else if shouldFold {
targetName = namespaceName
@@ -1698,30 +2607,33 @@ func normalizeXAINamespaceToolChoiceWithFold(body []byte, shouldFold bool) []byt
if targetName == "" {
return true
}
- updated, errSet := sjson.SetBytes(body, path+".name", targetName)
- if errSet != nil {
- return false
- }
- updated, errDelete := sjson.DeleteBytes(updated, path+".namespace")
- if errDelete != nil {
- return false
- }
- body = updated
+ edits = append(edits, pendingChoiceEdit{path: path, targetName: targetName})
return true
}
- if !normalizeAtPath("tool_choice") {
- return original
+ if !planAtPath("tool_choice") {
+ return false
}
- tools := gjson.GetBytes(body, "tool_choice.tools")
+ tools := doc.Get("tool_choice.tools")
if tools.IsArray() {
for index := range tools.Array() {
- if !normalizeAtPath(fmt.Sprintf("tool_choice.tools.%d", index)) {
- return original
+ if !planAtPath(fmt.Sprintf("tool_choice.tools.%d", index)) {
+ return false
}
}
}
- return body
+ if len(edits) == 0 {
+ return false
+ }
+ for _, edit := range edits {
+ if errSet := doc.Set(edit.path+".name", edit.targetName); errSet != nil {
+ return false
+ }
+ if errDel := doc.Delete(edit.path + ".namespace"); errDel != nil {
+ return false
+ }
+ }
+ return true
}
// xaiUnsupportedWebSearchFields are OpenAI Responses web_search options that the
@@ -1732,22 +2644,35 @@ var xaiUnsupportedWebSearchFields = [...]string{"external_web_access", "search_c
// stripUnsupportedXAIWebSearchFields removes web_search options that xAI rejects
// and reports which ones were removed.
func stripUnsupportedXAIWebSearchFields(raw []byte) ([]byte, []string, bool) {
+ doc := ojson.NewDoc(raw)
+ removed, ok := stripUnsupportedXAIWebSearchFieldsDoc(doc)
+ if !ok {
+ return nil, nil, false
+ }
+ if len(removed) == 0 {
+ return raw, nil, true
+ }
+ return doc.Bytes(), removed, true
+}
+
+func stripUnsupportedXAIWebSearchFieldsDoc(doc *ojson.Doc) ([]string, bool) {
+ if doc == nil {
+ return nil, false
+ }
var removed []string
for _, field := range xaiUnsupportedWebSearchFields {
- if !gjson.GetBytes(raw, field).Exists() {
+ if !doc.Get(field).Exists() {
continue
}
- updated, errDel := sjson.DeleteBytes(raw, field)
- if errDel != nil {
- return nil, nil, false
+ if errDel := doc.Delete(field); errDel != nil {
+ return nil, false
}
- raw = updated
removed = append(removed, field)
}
- return raw, removed, true
+ return removed, true
}
-func normalizeXAITool(tool gjson.Result, namespaceName string, keepImageGeneration bool) ([]byte, bool, bool) {
+func normalizeXAITool(tool ojson.Result, namespaceName string, keepImageGeneration bool) ([]byte, bool, bool) {
toolType := tool.Get("type").String()
changed := false
if toolType == xaiToolSearchType {
@@ -1758,77 +2683,79 @@ func normalizeXAITool(tool gjson.Result, namespaceName string, keepImageGenerati
}
raw := []byte(tool.Raw)
- schemaTool := tool
+ var toolDoc *ojson.Doc
+ ensureToolDoc := func() *ojson.Doc {
+ if toolDoc == nil {
+ toolDoc = ojson.NewDoc(raw)
+ }
+ return toolDoc
+ }
+ getField := func(path string) ojson.Result {
+ if toolDoc != nil {
+ return toolDoc.Get(path)
+ }
+ return tool.Get(path)
+ }
+
if toolType == xaiFunctionToolType || toolType == xaiCustomToolType {
- if rawParams := schemaTool.Get("parameters"); rawParams.Exists() {
+ if rawParams := getField("parameters"); rawParams.Exists() {
inlinedParams := util.InlineLocalRefs(rawParams.Raw)
if inlinedParams != rawParams.Raw {
- if updated, errSet := sjson.SetRawBytes(raw, "parameters", []byte(inlinedParams)); errSet == nil {
- if inlinedDefs := gjson.GetBytes(updated, "parameters.$defs"); inlinedDefs.Exists() {
- updated, _ = sjson.DeleteBytes(updated, "parameters.$defs")
+ td := ensureToolDoc()
+ if errSet := td.SetRawBytes("parameters", []byte(inlinedParams)); errSet == nil {
+ if td.Get("parameters.$defs").Exists() {
+ _ = td.Delete("parameters.$defs")
}
- if inlinedDefinitions := gjson.GetBytes(updated, "parameters.definitions"); inlinedDefinitions.Exists() {
- updated, _ = sjson.DeleteBytes(updated, "parameters.definitions")
+ if td.Get("parameters.definitions").Exists() {
+ _ = td.Delete("parameters.definitions")
}
- raw = updated
- schemaTool = gjson.ParseBytes(raw)
changed = true
}
}
}
- updatedTool, schemaChanged, ok := normalizeXAIObjectRootUnionBranchTypes(raw)
+ schemaChanged, ok := normalizeXAIObjectRootUnionBranchTypesDoc(ensureToolDoc())
if !ok {
return nil, false, false
}
- raw = updatedTool
if schemaChanged {
- schemaTool = gjson.ParseBytes(raw)
changed = true
log.Debugf("xai: added object types to root union branches for tool %s.%s", namespaceName, tool.Get("name").String())
}
}
if toolType == xaiCustomToolType {
- updatedTool, errSet := sjson.SetBytes(raw, "type", xaiFunctionToolType)
- if errSet != nil {
+ if errSet := ensureToolDoc().Set("type", xaiFunctionToolType); errSet != nil {
return nil, false, false
}
- raw = updatedTool
toolType = xaiFunctionToolType
changed = true
}
if toolType == xaiWebSearchToolType {
- updatedTool, removed, ok := stripUnsupportedXAIWebSearchFields(raw)
+ removed, ok := stripUnsupportedXAIWebSearchFieldsDoc(ensureToolDoc())
if !ok {
return nil, false, false
}
if len(removed) > 0 {
- raw = updatedTool
changed = true
log.Debugf("xai: removed unsupported web_search fields %v", removed)
}
}
- if toolType == xaiFunctionToolType && !schemaTool.Get("parameters").Exists() {
- updatedTool, errSet := sjson.SetRawBytes(raw, "parameters", []byte(`{"type":"object","properties":{}}`))
- if errSet != nil {
+ if toolType == xaiFunctionToolType && !getField("parameters").Exists() {
+ if errSet := ensureToolDoc().SetRawBytes("parameters", []byte(`{"type":"object","properties":{}}`)); errSet != nil {
return nil, false, false
}
- raw = updatedTool
changed = true
}
// Simplify the Codex Desktop automation schema and root unions that xAI
// rejects because function parameters must resolve exclusively to objects.
- if toolType == xaiFunctionToolType && xaiFunctionParametersNeedSimplification(schemaTool, namespaceName) {
- updatedTool, errSet := sjson.SetRawBytes(raw, "parameters", []byte(xaiSafeFunctionParameters))
- if errSet != nil {
+ if toolType == xaiFunctionToolType && xaiFunctionParametersNeedSimplificationFields(tool.Get("type").String(), getField("name").String(), getField("parameters"), namespaceName) {
+ td := ensureToolDoc()
+ if errSet := td.SetRawBytes("parameters", []byte(xaiSafeFunctionParameters)); errSet != nil {
return nil, false, false
}
- raw = updatedTool
if strict := tool.Get("strict"); strict.Exists() && strict.Bool() {
- updatedTool, errSet = sjson.SetBytes(raw, "strict", false)
- if errSet != nil {
+ if errSet := td.Set("strict", false); errSet != nil {
return nil, false, false
}
- raw = updatedTool
}
changed = true
log.Debugf("xai: simplified parameters for tool %s.%s to avoid upstream schema rejection or hang", namespaceName, tool.Get("name").String())
@@ -1838,14 +2765,15 @@ func normalizeXAITool(tool gjson.Result, namespaceName string, keepImageGenerati
if qualifiedName == "" {
return nil, false, false
}
- updatedTool, errSet := sjson.SetBytes(raw, "name", qualifiedName)
- if errSet != nil {
+ if errSet := ensureToolDoc().Set("name", qualifiedName); errSet != nil {
return nil, false, false
}
- raw = updatedTool
changed = true
}
- return raw, changed, true
+ if changed && toolDoc != nil {
+ return toolDoc.Bytes(), true, true
+ }
+ return raw, false, true
}
func qualifyXAINamespaceToolName(namespaceName, toolName string) string {
@@ -1869,9 +2797,20 @@ func collectXAINamespaceToolRefs(body []byte) map[string]xaiNamespaceToolRef {
}
func collectXAINamespaceToolRefsWithFold(body []byte, shouldFold bool) map[string]xaiNamespaceToolRef {
+ return collectXAINamespaceToolRefsWithFoldResults(ojson.GetBytesNoCopy(body, "tools"), ojson.GetBytesNoCopy(body, "input"), shouldFold)
+}
+
+func collectXAINamespaceToolRefsWithFoldDoc(doc *ojson.Doc, shouldFold bool) map[string]xaiNamespaceToolRef {
+ if doc == nil {
+ return make(map[string]xaiNamespaceToolRef)
+ }
+ return collectXAINamespaceToolRefsWithFoldResults(doc.Get("tools"), doc.Get("input"), shouldFold)
+}
+
+func collectXAINamespaceToolRefsWithFoldResults(toolsResult, inputResult ojson.Result, shouldFold bool) map[string]xaiNamespaceToolRef {
refs := make(map[string]xaiNamespaceToolRef)
claimed := make(map[string]struct{})
- collect := func(tools gjson.Result) {
+ collect := func(tools ojson.Result) {
if !tools.Exists() || !tools.IsArray() {
return
}
@@ -1934,10 +2873,9 @@ func collectXAINamespaceToolRefsWithFold(body []byte, shouldFold bool) map[strin
}
}
}
- collect(gjson.GetBytes(body, "tools"))
- input := gjson.GetBytes(body, "input")
- if input.Exists() && input.IsArray() {
- for _, item := range input.Array() {
+ collect(toolsResult)
+ if inputResult.Exists() && inputResult.IsArray() {
+ for _, item := range inputResult.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
@@ -1947,10 +2885,21 @@ func collectXAINamespaceToolRefsWithFold(body []byte, shouldFold bool) map[strin
}
func normalizeXAIInputCustomToolCalls(body []byte) []byte {
- input := gjson.GetBytes(body, "input")
- if !input.Exists() || !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIInputCustomToolCallsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+func normalizeXAIInputCustomToolCallsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.Exists() || !input.IsArray() {
+ return false
+ }
changed := false
inputArray := input.Array()
@@ -1965,19 +2914,21 @@ func normalizeXAIInputCustomToolCalls(body []byte) []byte {
changed = true
continue
}
- normalized = []byte(`{"type":"function_call"}`)
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
- normalized, _ = sjson.SetBytes(normalized, "name", name)
- normalized, _ = sjson.SetBytes(normalized, "arguments", xaiCustomToolCallArguments(item.Get("input")))
+ itemDoc := ojson.NewDoc([]byte(`{"type":"function_call"}`))
+ _ = itemDoc.Set("call_id", callID)
+ _ = itemDoc.Set("name", name)
+ _ = itemDoc.Set("arguments", xaiCustomToolCallArguments(item.Get("input")))
+ normalized = itemDoc.Bytes()
case "custom_tool_call_output":
callID := strings.TrimSpace(item.Get("call_id").String())
if callID == "" {
changed = true
continue
}
- normalized = []byte(`{"type":"function_call_output"}`)
- normalized, _ = sjson.SetBytes(normalized, "call_id", callID)
- normalized, _ = sjson.SetBytes(normalized, "output", xaiCustomToolCallOutput(item.Get("output")))
+ itemDoc := ojson.NewDoc([]byte(`{"type":"function_call_output"}`))
+ _ = itemDoc.Set("call_id", callID)
+ _ = itemDoc.Set("output", xaiCustomToolCallOutput(item.Get("output")))
+ normalized = itemDoc.Bytes()
default:
items = append(items, json.RawMessage(item.Raw))
continue
@@ -1986,29 +2937,25 @@ func normalizeXAIInputCustomToolCalls(body []byte) []byte {
changed = true
}
if !changed {
- return body
+ return false
}
rawInput, errMarshal := json.Marshal(items)
if errMarshal != nil {
- return body
- }
- updated, errSet := sjson.SetRawBytes(body, "input", rawInput)
- if errSet != nil {
- return body
+ return false
}
- return updated
+ return doc.SetRawBytes("input", rawInput) == nil
}
-func xaiCustomToolCallArguments(input gjson.Result) string {
+func xaiCustomToolCallArguments(input ojson.Result) string {
if !input.Exists() {
return "{}"
}
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
text := input.String()
trimmed := strings.TrimSpace(text)
- if gjson.Valid(trimmed) {
- parsed := gjson.Parse(trimmed)
+ if ojson.Valid(trimmed) {
+ parsed := ojson.Parse(trimmed)
if parsed.IsObject() {
return parsed.Raw
}
@@ -2028,11 +2975,11 @@ func xaiCustomToolCallArguments(input gjson.Result) string {
return "{}"
}
-func xaiCustomToolCallOutput(output gjson.Result) string {
+func xaiCustomToolCallOutput(output ojson.Result) string {
if !output.Exists() {
return ""
}
- if output.Type == gjson.String {
+ if output.Type == ojson.String {
return output.String()
}
return output.Raw
diff --git a/internal/runtime/executor/xai_executor_response.go b/internal/runtime/executor/xai_executor_response.go
index e98ce760614..a9afe42821b 100644
--- a/internal/runtime/executor/xai_executor_response.go
+++ b/internal/runtime/executor/xai_executor_response.go
@@ -9,11 +9,11 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// xAI executes these x_search subtools server-side but exposes their trace as
@@ -37,13 +37,51 @@ func newXAIInternalXSearchResponseFilter(enabled bool, clientDeclaredTools map[x
return filter
}
+func xaiToolsArrayHasNativeXSearch(tools ojson.Result) bool {
+ if !tools.IsArray() {
+ return false
+ }
+ for _, tool := range tools.Array() {
+ if tool.Get("type").String() == "x_search" {
+ return true
+ }
+ }
+ return false
+}
+
func xaiRequestHasNativeXSearch(body []byte) bool {
- if gjson.GetBytes(body, `tools.#(type=="x_search")`).Exists() {
+ if xaiToolsArrayHasNativeXSearch(ojson.GetBytesNoCopy(body, "tools")) {
+ return true
+ }
+ input := ojson.GetBytesNoCopy(body, "input")
+ if !input.IsArray() {
+ return false
+ }
+ for _, item := range input.Array() {
+ if item.Get("type").String() == "additional_tools" && xaiToolsArrayHasNativeXSearch(item.Get("tools")) {
+ return true
+ }
+ }
+ return false
+}
+
+func xaiRequestHasNativeXSearchDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ if xaiToolsArrayHasNativeXSearch(doc.Get("tools")) {
return true
}
- // Multipath queries return an array of matches; an empty array still Exists().
- // Check the match count instead of Exists() for additional_tools injection.
- return len(gjson.GetBytes(body, `input.#(type=="additional_tools")#.tools.#(type=="x_search")`).Array()) > 0
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
+ for _, item := range input.Array() {
+ if item.Get("type").String() == "additional_tools" && xaiToolsArrayHasNativeXSearch(item.Get("tools")) {
+ return true
+ }
+ }
+ return false
}
// collectXAIClientDeclaredToolKeys records client-declared function/custom tools
@@ -52,8 +90,19 @@ func xaiRequestHasNativeXSearch(body []byte) bool {
// are normalized to function before being sent to xAI, so keys use function for
// both declaration kinds. Must run before normalizeXAITools flattens namespace wrappers.
func collectXAIClientDeclaredToolKeys(body []byte) map[xaiClientToolKey]struct{} {
+ return collectXAIClientDeclaredToolKeysResults(ojson.GetBytesNoCopy(body, "tools"), ojson.GetBytesNoCopy(body, "input"))
+}
+
+func collectXAIClientDeclaredToolKeysDoc(doc *ojson.Doc) map[xaiClientToolKey]struct{} {
+ if doc == nil {
+ return make(map[xaiClientToolKey]struct{})
+ }
+ return collectXAIClientDeclaredToolKeysResults(doc.Get("tools"), doc.Get("input"))
+}
+
+func collectXAIClientDeclaredToolKeysResults(toolsResult, inputResult ojson.Result) map[xaiClientToolKey]struct{} {
keys := make(map[xaiClientToolKey]struct{})
- collect := func(tools gjson.Result) {
+ collect := func(tools ojson.Result) {
if !tools.Exists() || !tools.IsArray() {
return
}
@@ -86,10 +135,9 @@ func collectXAIClientDeclaredToolKeys(body []byte) map[xaiClientToolKey]struct{}
}
}
}
- collect(gjson.GetBytes(body, "tools"))
- input := gjson.GetBytes(body, "input")
- if input.Exists() && input.IsArray() {
- for _, item := range input.Array() {
+ collect(toolsResult)
+ if inputResult.Exists() && inputResult.IsArray() {
+ for _, item := range inputResult.Array() {
if item.Get("type").String() == "additional_tools" {
collect(item.Get("tools"))
}
@@ -153,7 +201,7 @@ func xaiIsInternalXSearchCallID(callID string) bool {
// function and therefore preserves function_call while still filtering genuine
// internal custom_tool_call / xs_call* traces. Namespaced restored client tools
// are never treated as internal.
-func xaiIsInternalXSearchCall(item gjson.Result, clientDeclaredTools map[xaiClientToolKey]struct{}) bool {
+func xaiIsInternalXSearchCall(item ojson.Result, clientDeclaredTools map[xaiClientToolKey]struct{}) bool {
itemType := strings.TrimSpace(item.Get("type").String())
declaredType := xaiResponseCallDeclaredType(itemType)
if declaredType == "" {
@@ -182,11 +230,11 @@ func xaiIsInternalXSearchCall(item gjson.Result, clientDeclaredTools map[xaiClie
}
func (f *xaiInternalXSearchResponseFilter) apply(eventData []byte) []byte {
- if f == nil || !f.enabled || len(eventData) == 0 || !gjson.ValidBytes(eventData) {
+ if f == nil || !f.enabled || len(eventData) == 0 || !ojson.ValidBytes(eventData) {
return eventData
}
- if item := gjson.GetBytes(eventData, "item"); xaiIsInternalXSearchCall(item, f.clientDeclaredTools) {
+ if item := ojson.GetBytesNoCopy(eventData, "item"); xaiIsInternalXSearchCall(item, f.clientDeclaredTools) {
f.recordDroppedItem(eventData, item)
return nil
}
@@ -198,25 +246,25 @@ func (f *xaiInternalXSearchResponseFilter) apply(eventData []byte) []byte {
return f.compactOutputIndex(eventData)
}
-func (f *xaiInternalXSearchResponseFilter) recordDroppedItem(eventData []byte, item gjson.Result) {
- if outputIndex := gjson.GetBytes(eventData, "output_index"); outputIndex.Exists() {
+func (f *xaiInternalXSearchResponseFilter) recordDroppedItem(eventData []byte, item ojson.Result) {
+ if outputIndex := ojson.GetBytesNoCopy(eventData, "output_index"); outputIndex.Exists() {
f.droppedOutputIndexes[outputIndex.Int()] = struct{}{}
}
for _, path := range []string{"id", "call_id"} {
if id := strings.TrimSpace(item.Get(path).String()); id != "" {
- f.droppedItemIDs[id] = struct{}{}
+ f.droppedItemIDs[strings.Clone(id)] = struct{}{}
}
}
}
func (f *xaiInternalXSearchResponseFilter) referencesDroppedItem(eventData []byte) bool {
- if outputIndex := gjson.GetBytes(eventData, "output_index"); outputIndex.Exists() {
+ if outputIndex := ojson.GetBytesNoCopy(eventData, "output_index"); outputIndex.Exists() {
if _, dropped := f.droppedOutputIndexes[outputIndex.Int()]; dropped {
return true
}
}
for _, path := range []string{"item_id", "call_id"} {
- id := strings.TrimSpace(gjson.GetBytes(eventData, path).String())
+ id := strings.TrimSpace(ojson.GetBytesNoCopy(eventData, path).String())
if _, dropped := f.droppedItemIDs[id]; id != "" && dropped {
return true
}
@@ -225,7 +273,7 @@ func (f *xaiInternalXSearchResponseFilter) referencesDroppedItem(eventData []byt
}
func (f *xaiInternalXSearchResponseFilter) compactOutputIndex(eventData []byte) []byte {
- outputIndex := gjson.GetBytes(eventData, "output_index")
+ outputIndex := ojson.GetBytesNoCopy(eventData, "output_index")
if !outputIndex.Exists() {
return eventData
}
@@ -239,7 +287,7 @@ func (f *xaiInternalXSearchResponseFilter) compactOutputIndex(eventData []byte)
if removedBefore == 0 {
return eventData
}
- updated, errSet := sjson.SetBytes(eventData, "output_index", original-removedBefore)
+ updated, errSet := ojson.SetBytes(eventData, "output_index", original-removedBefore)
if errSet != nil {
return eventData
}
@@ -247,7 +295,7 @@ func (f *xaiInternalXSearchResponseFilter) compactOutputIndex(eventData []byte)
}
func (f *xaiInternalXSearchResponseFilter) filterCompletedOutput(eventData []byte) []byte {
- output := gjson.GetBytes(eventData, "response.output")
+ output := ojson.GetBytesNoCopy(eventData, "response.output")
if !output.IsArray() {
return eventData
}
@@ -271,7 +319,7 @@ func (f *xaiInternalXSearchResponseFilter) filterCompletedOutput(eventData []byt
if errMarshal != nil {
return eventData
}
- updated, errSet := sjson.SetRawBytes(eventData, "response.output", rawOutput)
+ updated, errSet := ojson.SetRawBytes(eventData, "response.output", rawOutput)
if errSet != nil {
return eventData
}
@@ -282,14 +330,30 @@ func normalizeXAIInputNamespaceToolCalls(body []byte) []byte {
return normalizeXAIInputNamespaceToolCallsWithFold(body, xaiShouldFoldNamespaceTools(body, false))
}
+func normalizeXAIInputNamespaceToolCallsDoc(doc *ojson.Doc) bool {
+ return normalizeXAIInputNamespaceToolCallsWithFoldDoc(doc, xaiShouldFoldNamespaceToolsDoc(doc, false))
+}
+
func normalizeXAIInputNamespaceToolCallsWithFold(body []byte, shouldFold bool) []byte {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return body
}
- input := gjson.GetBytes(body, "input")
- if !input.Exists() || !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIInputNamespaceToolCallsWithFoldDoc(doc, shouldFold) {
return body
}
+ return doc.Bytes()
+}
+
+func normalizeXAIInputNamespaceToolCallsWithFoldDoc(doc *ojson.Doc, shouldFold bool) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.Exists() || !input.IsArray() {
+ return false
+ }
+ changed := false
for index, item := range input.Array() {
if item.Get("type").String() != "function_call" {
continue
@@ -301,9 +365,9 @@ func normalizeXAIInputNamespaceToolCallsWithFold(body []byte, shouldFold bool) [
}
qualifiedName := qualifyXAINamespaceToolName(namespaceName, toolName)
var isFolded bool
- if xaiHasFunctionToolNamed(body, namespaceName) {
+ if xaiHasFunctionToolNamedDoc(doc, namespaceName) {
isFolded = true
- } else if xaiHasFunctionToolNamed(body, qualifiedName) {
+ } else if xaiHasFunctionToolNamedDoc(doc, qualifiedName) {
isFolded = false
} else {
isFolded = shouldFold
@@ -317,7 +381,7 @@ func normalizeXAIInputNamespaceToolCallsWithFold(body []byte, shouldFold bool) [
"name": toolName,
}
if rawArgs := item.Get("arguments").String(); rawArgs != "" {
- if gjson.Valid(rawArgs) {
+ if ojson.Valid(rawArgs) {
dispatcherArgs["arguments"] = json.RawMessage(rawArgs)
} else {
dispatcherArgs["arguments"] = rawArgs
@@ -328,19 +392,16 @@ func normalizeXAIInputNamespaceToolCallsWithFold(body []byte, shouldFold bool) [
continue
}
- updated, errSet := sjson.SetBytes(body, namePath, namespaceName)
- if errSet != nil {
+ if errSet := doc.Set(namePath, namespaceName); errSet != nil {
continue
}
- updated, errSet = sjson.SetBytes(updated, argsPath, string(encodedArgs))
- if errSet != nil {
+ if errSet := doc.Set(argsPath, string(encodedArgs)); errSet != nil {
continue
}
- updated, errDelete := sjson.DeleteBytes(updated, namespacePath)
- if errDelete != nil {
+ if errDelete := doc.Delete(namespacePath); errDelete != nil {
continue
}
- body = updated
+ changed = true
continue
}
@@ -349,17 +410,15 @@ func normalizeXAIInputNamespaceToolCallsWithFold(body []byte, shouldFold bool) [
}
namePath := fmt.Sprintf("input.%d.name", index)
namespacePath := fmt.Sprintf("input.%d.namespace", index)
- updated, errSet := sjson.SetBytes(body, namePath, qualifiedName)
- if errSet != nil {
+ if errSet := doc.Set(namePath, qualifiedName); errSet != nil {
continue
}
- updated, errDelete := sjson.DeleteBytes(updated, namespacePath)
- if errDelete != nil {
+ if errDelete := doc.Delete(namespacePath); errDelete != nil {
continue
}
- body = updated
+ changed = true
}
- return body
+ return changed
}
type xaiNamespaceRestorer struct {
@@ -375,31 +434,31 @@ func newXAINamespaceRestorer(refs map[string]xaiNamespaceToolRef) *xaiNamespaceR
}
func (r *xaiNamespaceRestorer) restore(data []byte) []byte {
- if r == nil || len(r.refs) == 0 || len(data) == 0 || !gjson.ValidBytes(data) {
+ if r == nil || len(r.refs) == 0 || len(data) == 0 || !ojson.ValidBytes(data) {
return data
}
- eventType := gjson.GetBytes(data, "type").String()
+ eventType := ojson.GetBytesNoCopy(data, "type").String()
switch eventType {
case "response.output_item.added":
- item := gjson.GetBytes(data, "item")
+ item := ojson.GetBytesNoCopy(data, "item")
if item.Get("type").String() == "function_call" {
name := strings.TrimSpace(item.Get("name").String())
itemID := strings.TrimSpace(item.Get("id").String())
if ref, ok := r.refs[name]; ok && ref.isDispatcher {
if itemID != "" {
- r.dispatcherItemIDs[itemID] = ref.namespace
+ r.dispatcherItemIDs[strings.Clone(itemID)] = ref.namespace
}
- data, _ = sjson.SetBytes(data, "item.namespace", ref.namespace)
+ data, _ = ojson.SetBytes(data, "item.namespace", ref.namespace)
}
}
return data
case "response.function_call_arguments.done":
- itemID := strings.TrimSpace(gjson.GetBytes(data, "item_id").String())
+ itemID := strings.TrimSpace(ojson.GetBytesNoCopy(data, "item_id").String())
if namespaceName, isDisp := r.dispatcherItemIDs[itemID]; isDisp {
- rawArgs := gjson.GetBytes(data, "arguments").String()
+ rawArgs := ojson.GetBytesNoCopy(data, "arguments").String()
if _, childArgs, ok := unwrapXAIDispatcherArguments(rawArgs, namespaceName, r.refs); ok {
- updated, errSet := sjson.SetBytes(data, "arguments", string(childArgs))
+ updated, errSet := ojson.SetBytes(data, "arguments", string(childArgs))
if errSet == nil {
data = updated
}
@@ -408,67 +467,74 @@ func (r *xaiNamespaceRestorer) restore(data []byte) []byte {
return data
default:
- data = r.restoreAtPath(data, "item")
- output := gjson.GetBytes(data, "response.output")
+ doc := ojson.NewDoc(data)
+ changed := r.restoreAtPathDoc(doc, "item")
+ output := doc.Get("response.output")
if output.Exists() && output.IsArray() {
for index := range output.Array() {
- data = r.restoreAtPath(data, fmt.Sprintf("response.output.%d", index))
+ if r.restoreAtPathDoc(doc, fmt.Sprintf("response.output.%d", index)) {
+ changed = true
+ }
}
}
- return data
+ if !changed {
+ return data
+ }
+ return doc.Bytes()
}
}
func (r *xaiNamespaceRestorer) restoreAtPath(data []byte, path string) []byte {
- if gjson.GetBytes(data, path+".type").String() != "function_call" {
+ doc := ojson.NewDoc(data)
+ if !r.restoreAtPathDoc(doc, path) {
return data
}
- qualifiedName := strings.TrimSpace(gjson.GetBytes(data, path+".name").String())
+ return doc.Bytes()
+}
+
+func (r *xaiNamespaceRestorer) restoreAtPathDoc(doc *ojson.Doc, path string) bool {
+ if doc == nil || doc.Get(path+".type").String() != "function_call" {
+ return false
+ }
+ qualifiedName := strings.TrimSpace(doc.Get(path + ".name").String())
ref, ok := r.refs[qualifiedName]
if !ok {
- return data
+ return false
}
if ref.isDispatcher {
- rawArgs := gjson.GetBytes(data, path+".arguments").String()
+ rawArgs := doc.Get(path + ".arguments").String()
childName, childArgs, unwrapped := unwrapXAIDispatcherArguments(rawArgs, ref.namespace, r.refs)
if !unwrapped && childName == "" {
childName = ref.name
}
- updated, errSet := sjson.SetBytes(data, path+".namespace", ref.namespace)
- if errSet != nil {
- return data
+ if errSet := doc.Set(path+".namespace", ref.namespace); errSet != nil {
+ return false
}
if childName != "" {
- if updatedName, errSetName := sjson.SetBytes(updated, path+".name", childName); errSetName == nil {
- updated = updatedName
- }
+ _ = doc.Set(path+".name", childName)
}
if len(childArgs) > 0 {
- if updatedArgs, errSetArgs := sjson.SetBytes(updated, path+".arguments", string(childArgs)); errSetArgs == nil {
- updated = updatedArgs
- }
+ _ = doc.Set(path+".arguments", string(childArgs))
}
- return updated
+ return true
}
- updated, errSet := sjson.SetBytes(data, path+".name", ref.name)
- if errSet != nil {
- return data
+ if errSet := doc.Set(path+".name", ref.name); errSet != nil {
+ return false
}
- updated, errSet = sjson.SetBytes(updated, path+".namespace", ref.namespace)
- if errSet != nil {
- return data
+ if errSet := doc.Set(path+".namespace", ref.namespace); errSet != nil {
+ return false
}
- return updated
+ return true
}
func unwrapXAIDispatcherArguments(rawArgs string, namespaceName string, refs map[string]xaiNamespaceToolRef) (string, []byte, bool) {
- if !gjson.Valid(rawArgs) {
+ if !ojson.Valid(rawArgs) {
return "", nil, false
}
- argsParsed := gjson.Parse(rawArgs)
+ argsParsed := ojson.Parse(rawArgs)
nameField := argsParsed.Get("name")
- if !nameField.Exists() || nameField.Type != gjson.String {
+ if !nameField.Exists() || nameField.Type != ojson.String {
return "", nil, false
}
childName := strings.TrimSpace(nameField.String())
@@ -502,13 +568,13 @@ func unwrapXAIDispatcherArguments(rawArgs string, namespaceName string, refs map
var childArgs []byte
if argsField := argsParsed.Get("arguments"); argsField.Exists() {
- if argsField.Type == gjson.String {
+ if argsField.Type == ojson.String {
childArgs = []byte(argsField.String())
} else {
childArgs = []byte(argsField.Raw)
}
} else {
- cleaned, errDel := sjson.DeleteBytes([]byte(rawArgs), "name")
+ cleaned, errDel := ojson.DeleteBytes([]byte(rawArgs), "name")
if errDel == nil && len(cleaned) > 0 && string(cleaned) != "{}" {
childArgs = cleaned
} else {
@@ -534,74 +600,107 @@ func restoreXAIClientWebSearchName(data []byte, alias string) []byte {
if alias == "" || !bytes.Contains(data, []byte(alias)) {
return data
}
- if !gjson.ValidBytes(data) {
+ if !ojson.ValidBytes(data) {
return data
}
+ doc := ojson.NewDoc(data)
+ changed := false
// Check item (e.g. response.output_item.added / response.output_item.done)
- if strings.TrimSpace(gjson.GetBytes(data, "item.namespace").String()) == "" {
- if strings.TrimSpace(gjson.GetBytes(data, "item.name").String()) == alias {
- data, _ = sjson.SetBytes(data, "item.name", xaiWebSearchToolType)
+ if strings.TrimSpace(doc.Get("item.namespace").String()) == "" {
+ if strings.TrimSpace(doc.Get("item.name").String()) == alias {
+ if doc.Set("item.name", xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
- if strings.TrimSpace(gjson.GetBytes(data, "item.function.name").String()) == alias {
- data, _ = sjson.SetBytes(data, "item.function.name", xaiWebSearchToolType)
+ if strings.TrimSpace(doc.Get("item.function.name").String()) == alias {
+ if doc.Set("item.function.name", xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
}
// Check response.output array (e.g. response.completed / response.incomplete)
- respOutput := gjson.GetBytes(data, "response.output")
+ respOutput := doc.Get("response.output")
if respOutput.IsArray() {
for idx, item := range respOutput.Array() {
if strings.TrimSpace(item.Get("namespace").String()) != "" {
continue
}
if strings.TrimSpace(item.Get("name").String()) == alias {
- data, _ = sjson.SetBytes(data, fmt.Sprintf("response.output.%d.name", idx), xaiWebSearchToolType)
+ if doc.Set(fmt.Sprintf("response.output.%d.name", idx), xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
if strings.TrimSpace(item.Get("function.name").String()) == alias {
- data, _ = sjson.SetBytes(data, fmt.Sprintf("response.output.%d.function.name", idx), xaiWebSearchToolType)
+ if doc.Set(fmt.Sprintf("response.output.%d.function.name", idx), xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
}
}
// Check top-level output array (non-stream responses translated)
- topOutput := gjson.GetBytes(data, "output")
+ topOutput := doc.Get("output")
if topOutput.IsArray() {
for idx, item := range topOutput.Array() {
if strings.TrimSpace(item.Get("namespace").String()) != "" {
continue
}
if strings.TrimSpace(item.Get("name").String()) == alias {
- data, _ = sjson.SetBytes(data, fmt.Sprintf("output.%d.name", idx), xaiWebSearchToolType)
+ if doc.Set(fmt.Sprintf("output.%d.name", idx), xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
if strings.TrimSpace(item.Get("function.name").String()) == alias {
- data, _ = sjson.SetBytes(data, fmt.Sprintf("output.%d.function.name", idx), xaiWebSearchToolType)
+ if doc.Set(fmt.Sprintf("output.%d.function.name", idx), xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
}
}
// Check top-level name
- if strings.TrimSpace(gjson.GetBytes(data, "namespace").String()) == "" {
- if strings.TrimSpace(gjson.GetBytes(data, "name").String()) == alias {
- data, _ = sjson.SetBytes(data, "name", xaiWebSearchToolType)
+ if strings.TrimSpace(doc.Get("namespace").String()) == "" {
+ if strings.TrimSpace(doc.Get("name").String()) == alias {
+ if doc.Set("name", xaiWebSearchToolType) == nil {
+ changed = true
+ }
}
}
- return data
+ if !changed {
+ return data
+ }
+ return doc.Bytes()
}
// normalizeXAIObjectRootUnionBranchTypes makes untyped root union branches
// explicitly object-only when the parameter root already permits only objects.
// This preserves the original schema semantics while satisfying xAI validation.
func normalizeXAIObjectRootUnionBranchTypes(tool []byte) ([]byte, bool, bool) {
- parameters := gjson.GetBytes(tool, "parameters")
- rootType := parameters.Get("type")
- if rootType.Type != gjson.String || rootType.String() != "object" {
+ doc := ojson.NewDoc(tool)
+ changed, ok := normalizeXAIObjectRootUnionBranchTypesDoc(doc)
+ if !ok {
+ return tool, false, false
+ }
+ if !changed {
return tool, false, true
}
+ return doc.Bytes(), true, true
+}
- original := tool
- changed := false
+func normalizeXAIObjectRootUnionBranchTypesDoc(doc *ojson.Doc) (bool, bool) {
+ if doc == nil {
+ return false, false
+ }
+ parameters := doc.Get("parameters")
+ rootType := parameters.Get("type")
+ if rootType.Type != ojson.String || rootType.String() != "object" {
+ return false, true
+ }
+
+ var paths []string
for _, unionName := range []string{"anyOf", "oneOf"} {
union := parameters.Get(unionName)
if !union.IsArray() {
@@ -611,19 +710,22 @@ func normalizeXAIObjectRootUnionBranchTypes(tool []byte) ([]byte, bool, bool) {
if !branch.IsObject() || branch.Get("type").Exists() || branch.Get("$ref").Exists() {
continue
}
- updated, errSet := sjson.SetBytes(tool, fmt.Sprintf("parameters.%s.%d.type", unionName, index), "object")
- if errSet != nil {
- return original, false, false
- }
- tool = updated
- changed = true
+ paths = append(paths, fmt.Sprintf("parameters.%s.%d.type", unionName, index))
+ }
+ }
+ if len(paths) == 0 {
+ return false, true
+ }
+ for _, p := range paths {
+ if errSet := doc.Set(p, "object"); errSet != nil {
+ return false, false
}
}
- return tool, changed, true
+ return true, true
}
-func xaiSchemaTypeIsObjectOnly(schemaType gjson.Result) bool {
- if schemaType.Type == gjson.String {
+func xaiSchemaTypeIsObjectOnly(schemaType ojson.Result) bool {
+ if schemaType.Type == ojson.String {
return strings.EqualFold(strings.TrimSpace(schemaType.String()), "object")
}
if !schemaType.IsArray() {
@@ -634,7 +736,7 @@ func xaiSchemaTypeIsObjectOnly(schemaType gjson.Result) bool {
return false
}
for _, schemaTypeItem := range types {
- if schemaTypeItem.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(schemaTypeItem.String()), "object") {
+ if schemaTypeItem.Type != ojson.String || !strings.EqualFold(strings.TrimSpace(schemaTypeItem.String()), "object") {
return false
}
}
@@ -655,20 +757,23 @@ func isXAICodexAppAutomationUpdate(toolName, namespaceName string) bool {
// xaiFunctionParametersNeedSimplification reports whether a function tool, or
// a custom tool normalized to a function, has a schema that xAI cannot accept.
-func xaiFunctionParametersNeedSimplification(tool gjson.Result, namespaceName string) bool {
- toolType := strings.TrimSpace(tool.Get("type").String())
+func xaiFunctionParametersNeedSimplification(tool ojson.Result, namespaceName string) bool {
+ return xaiFunctionParametersNeedSimplificationFields(tool.Get("type").String(), tool.Get("name").String(), tool.Get("parameters"), namespaceName)
+}
+
+func xaiFunctionParametersNeedSimplificationFields(toolType, toolName string, parameters ojson.Result, namespaceName string) bool {
+ toolType = strings.TrimSpace(toolType)
isFunction := strings.EqualFold(toolType, xaiFunctionToolType)
isNormalizedCustom := strings.EqualFold(toolType, xaiCustomToolType)
if !isFunction && !isNormalizedCustom {
return false
}
- toolName := strings.TrimSpace(tool.Get("name").String())
+ toolName = strings.TrimSpace(toolName)
if isFunction && isXAICodexAppAutomationUpdate(toolName, namespaceName) {
return true
}
- parameters := tool.Get("parameters")
for _, unionName := range []string{"anyOf", "oneOf"} {
union := parameters.Get(unionName)
if !union.IsArray() {
@@ -687,15 +792,34 @@ func sanitizeXAIInputEncryptedContent(body []byte) []byte {
return sanitizeXAIInputEncryptedContentWithOptions(body, false)
}
+func sanitizeXAIInputEncryptedContentDoc(doc *ojson.Doc) bool {
+ return sanitizeXAIInputEncryptedContentWithOptionsDoc(doc, false)
+}
+
func sanitizeXAIInputEncryptedContentForBaseURL(body []byte, baseURL string) []byte {
return sanitizeXAIInputEncryptedContentWithOptions(body, isOpenRouterUpstreamURL(baseURL))
}
+func sanitizeXAIInputEncryptedContentForBaseURLDoc(doc *ojson.Doc, baseURL string) bool {
+ return sanitizeXAIInputEncryptedContentWithOptionsDoc(doc, isOpenRouterUpstreamURL(baseURL))
+}
+
func sanitizeXAIInputEncryptedContentWithOptions(body []byte, allowRouterSuffix bool) []byte {
- input := gjson.GetBytes(body, "input")
- if !input.Exists() || !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !sanitizeXAIInputEncryptedContentWithOptionsDoc(doc, allowRouterSuffix) {
return body
}
+ return doc.Bytes()
+}
+
+func sanitizeXAIInputEncryptedContentWithOptionsDoc(doc *ojson.Doc, allowRouterSuffix bool) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.Exists() || !input.IsArray() {
+ return false
+ }
items := make([]json.RawMessage, 0, len(input.Array()))
changed := false
dropCount := 0
@@ -715,21 +839,21 @@ func sanitizeXAIInputEncryptedContentWithOptions(body []byte, allowRouterSuffix
reason := ""
rewrittenSig := ""
switch encryptedContent.Type {
- case gjson.String:
+ case ojson.String:
rawSig := encryptedContent.String()
if info, err := signature.InspectGrokEncryptedContent(rawSig); err != nil {
reason = err.Error()
} else if info.RouterSuffix != "" && !allowRouterSuffix {
rewrittenSig = stripRouterSuffix(rawSig, info.RouterSuffix)
}
- case gjson.Null:
+ case ojson.Null:
reason = "encrypted_content is null"
default:
reason = fmt.Sprintf("encrypted_content must be a string, got %s", encryptedContent.Type.String())
}
if reason == "" {
if rewrittenSig != "" {
- if next, errSet := sjson.SetBytes([]byte(item.Raw), "encrypted_content", rewrittenSig); errSet == nil {
+ if next, errSet := ojson.SetBytes([]byte(item.Raw), "encrypted_content", rewrittenSig); errSet == nil {
items = append(items, json.RawMessage(next))
changed = true
continue
@@ -749,7 +873,7 @@ func sanitizeXAIInputEncryptedContentWithOptions(body []byte, allowRouterSuffix
continue
}
- next, err := sjson.DeleteBytes([]byte(item.Raw), "encrypted_content")
+ next, err := ojson.DeleteBytes([]byte(item.Raw), "encrypted_content")
if err != nil {
items = append(items, json.RawMessage(item.Raw))
continue
@@ -763,15 +887,14 @@ func sanitizeXAIInputEncryptedContentWithOptions(body []byte, allowRouterSuffix
}
}
if !changed {
- return body
+ return false
}
rawInput, err := json.Marshal(items)
if err != nil {
- return body
+ return false
}
- updated, err := sjson.SetRawBytes(body, "input", rawInput)
- if err != nil {
- return body
+ if errSet := doc.SetRawBytes("input", rawInput); errSet != nil {
+ return false
}
if dropCount > 0 {
log.WithFields(log.Fields{
@@ -781,45 +904,68 @@ func sanitizeXAIInputEncryptedContentWithOptions(body []byte, allowRouterSuffix
"first_reason": firstReason,
}).Debug("xai executor: removed invalid encrypted_content before upstream")
}
- return mergeAdjacentXAIInputReasoningSummaries(updated)
+ mergeAdjacentXAIInputReasoningSummariesDoc(doc)
+ return true
}
func normalizeXAIInputReasoningItems(body []byte) []byte {
- input := gjson.GetBytes(body, "input")
- if !input.Exists() || !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !normalizeXAIInputReasoningItemsDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+func normalizeXAIInputReasoningItemsDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.Exists() || !input.IsArray() {
+ return false
+ }
- updated := body
+ var deletePaths []string
for i, item := range input.Array() {
if item.Get("type").String() != "reasoning" {
continue
}
- contentPath := fmt.Sprintf("input.%d.content", i)
- if content := gjson.GetBytes(updated, contentPath); content.Exists() && content.Type == gjson.Null {
- updatedBody, errDel := sjson.DeleteBytes(updated, contentPath)
- if errDel != nil {
- return body
- }
- updated = updatedBody
+ if content := item.Get("content"); content.Exists() && content.Type == ojson.Null {
+ deletePaths = append(deletePaths, fmt.Sprintf("input.%d.content", i))
}
- encryptedContentPath := fmt.Sprintf("input.%d.encrypted_content", i)
- if encryptedContent := gjson.GetBytes(updated, encryptedContentPath); encryptedContent.Exists() && encryptedContent.Type == gjson.Null {
- updatedBody, errDel := sjson.DeleteBytes(updated, encryptedContentPath)
- if errDel != nil {
- return body
- }
- updated = updatedBody
+ if encryptedContent := item.Get("encrypted_content"); encryptedContent.Exists() && encryptedContent.Type == ojson.Null {
+ deletePaths = append(deletePaths, fmt.Sprintf("input.%d.encrypted_content", i))
}
}
- return mergeAdjacentXAIInputReasoningSummaries(updated)
+ changed := false
+ for _, p := range deletePaths {
+ if errDel := doc.Delete(p); errDel != nil {
+ return false
+ }
+ changed = true
+ }
+ if mergeAdjacentXAIInputReasoningSummariesDoc(doc) {
+ changed = true
+ }
+ return changed
}
func mergeAdjacentXAIInputReasoningSummaries(body []byte) []byte {
- input := gjson.GetBytes(body, "input")
- if !input.Exists() || !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !mergeAdjacentXAIInputReasoningSummariesDoc(doc) {
return body
}
+ return doc.Bytes()
+}
+
+func mergeAdjacentXAIInputReasoningSummariesDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.Exists() || !input.IsArray() {
+ return false
+ }
changed := false
items := make([]json.RawMessage, 0, len(input.Array()))
@@ -835,22 +981,18 @@ func mergeAdjacentXAIInputReasoningSummaries(body []byte) []byte {
items = append(items, json.RawMessage(item.Raw))
}
if !changed {
- return body
+ return false
}
rawInput, errMarshal := json.Marshal(items)
if errMarshal != nil {
- return body
- }
- updated, errSet := sjson.SetRawBytes(body, "input", rawInput)
- if errSet != nil {
- return body
+ return false
}
- return updated
+ return doc.SetRawBytes("input", rawInput) == nil
}
-func canMergeXAIReasoningSummary(previous json.RawMessage, current gjson.Result) bool {
- previousItem := gjson.ParseBytes(previous)
+func canMergeXAIReasoningSummary(previous json.RawMessage, current ojson.Result) bool {
+ previousItem := util.ParseGJSONBytesNoCopy(previous)
if previousItem.Get("type").String() != "reasoning" || current.Get("type").String() != "reasoning" {
return false
}
@@ -868,21 +1010,19 @@ func canMergeXAIReasoningSummary(previous json.RawMessage, current gjson.Result)
return true
}
-func appendXAIReasoningSummary(previous json.RawMessage, currentSummary []gjson.Result) ([]byte, bool) {
- updated := []byte(previous)
- summary := gjson.GetBytes(updated, "summary")
+func appendXAIReasoningSummary(previous json.RawMessage, currentSummary []ojson.Result) ([]byte, bool) {
+ summary := ojson.GetBytesNoCopy(previous, "summary")
if !summary.IsArray() {
return previous, false
}
nextIndex := len(summary.Array())
+ doc := ojson.NewDoc(previous)
for i, item := range currentSummary {
- updatedItem, errSet := sjson.SetRawBytes(updated, fmt.Sprintf("summary.%d", nextIndex+i), []byte(item.Raw))
- if errSet != nil {
+ if errSet := doc.SetRawBytes(fmt.Sprintf("summary.%d", nextIndex+i), []byte(item.Raw)); errSet != nil {
return previous, false
}
- updated = updatedItem
}
- return updated, true
+ return doc.Bytes(), true
}
func xaiNormalizeReasoningSummaryEventLine(line []byte, eventName string) []byte {
@@ -908,77 +1048,129 @@ func xaiNormalizeReasoningSummaryEventName(eventName string) string {
}
func xaiNormalizeReasoningSummaryData(eventData []byte) []byte {
- if len(eventData) == 0 || !gjson.ValidBytes(eventData) {
+ if len(eventData) == 0 || !ojson.ValidBytes(eventData) {
return eventData
}
- normalized := eventData
- switch gjson.GetBytes(normalized, "type").String() {
+ doc := ojson.NewDoc(eventData)
+ changed := false
+ switch doc.Get("type").String() {
case "response.reasoning_text.delta":
- normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_text.delta")
- normalized = xaiNormalizeReasoningSummaryIndex(normalized)
+ if doc.Set("type", "response.reasoning_summary_text.delta") == nil {
+ changed = true
+ }
+ if xaiNormalizeReasoningSummaryIndexDoc(doc) {
+ changed = true
+ }
case "response.reasoning_text.done":
- normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_part.done")
- normalized, _ = sjson.SetBytes(normalized, "part.type", "summary_text")
- if text := gjson.GetBytes(normalized, "text"); text.Exists() {
- normalized, _ = sjson.SetBytes(normalized, "part.text", text.String())
+ if doc.Set("type", "response.reasoning_summary_part.done") == nil {
+ changed = true
+ }
+ if doc.Set("part.type", "summary_text") == nil {
+ changed = true
+ }
+ if text := doc.Get("text"); text.Exists() {
+ if doc.Set("part.text", text.String()) == nil {
+ changed = true
+ }
+ }
+ if doc.Delete("text") == nil {
+ changed = true
+ }
+ if xaiNormalizeReasoningSummaryIndexDoc(doc) {
+ changed = true
}
- normalized, _ = sjson.DeleteBytes(normalized, "text")
- normalized = xaiNormalizeReasoningSummaryIndex(normalized)
case "response.content_part.added":
- if gjson.GetBytes(normalized, "part.type").String() == "reasoning_text" {
- normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_part.added")
- normalized, _ = sjson.SetBytes(normalized, "part.type", "summary_text")
- normalized = xaiNormalizeReasoningSummaryIndex(normalized)
+ if doc.Get("part.type").String() == "reasoning_text" {
+ if doc.Set("type", "response.reasoning_summary_part.added") == nil {
+ changed = true
+ }
+ if doc.Set("part.type", "summary_text") == nil {
+ changed = true
+ }
+ if xaiNormalizeReasoningSummaryIndexDoc(doc) {
+ changed = true
+ }
}
case "response.content_part.done":
- if gjson.GetBytes(normalized, "part.type").String() == "reasoning_text" {
- normalized, _ = sjson.SetBytes(normalized, "type", "response.reasoning_summary_part.done")
- normalized, _ = sjson.SetBytes(normalized, "part.type", "summary_text")
- normalized = xaiNormalizeReasoningSummaryIndex(normalized)
+ if doc.Get("part.type").String() == "reasoning_text" {
+ if doc.Set("type", "response.reasoning_summary_part.done") == nil {
+ changed = true
+ }
+ if doc.Set("part.type", "summary_text") == nil {
+ changed = true
+ }
+ if xaiNormalizeReasoningSummaryIndexDoc(doc) {
+ changed = true
+ }
}
}
- if item := gjson.GetBytes(normalized, "item"); item.Exists() && item.Type == gjson.JSON {
+ if item := doc.Get("item"); item.Exists() && item.Type == ojson.JSON {
updatedItem := xaiNormalizeReasoningOutputItem([]byte(item.Raw))
if !bytes.Equal(updatedItem, []byte(item.Raw)) {
- normalized, _ = sjson.SetRawBytes(normalized, "item", updatedItem)
+ if doc.SetRawBytes("item", updatedItem) == nil {
+ changed = true
+ }
}
}
- if output := gjson.GetBytes(normalized, "response.output"); output.IsArray() {
- updatedOutput, changed := xaiNormalizeReasoningOutputItems(output.Array())
- if changed {
- normalized, _ = sjson.SetRawBytes(normalized, "response.output", updatedOutput)
+ if output := doc.Get("response.output"); output.IsArray() {
+ updatedOutput, outputChanged := xaiNormalizeReasoningOutputItems(output.Array())
+ if outputChanged {
+ if doc.SetRawBytes("response.output", updatedOutput) == nil {
+ changed = true
+ }
}
}
- return normalized
+ if !changed {
+ return eventData
+ }
+ return doc.Bytes()
}
func xaiNormalizeReasoningSummaryDataEvents(eventData []byte) [][]byte {
- if len(eventData) == 0 || !gjson.ValidBytes(eventData) {
+ if len(eventData) == 0 || !ojson.ValidBytes(eventData) {
return [][]byte{eventData}
}
- if gjson.GetBytes(eventData, "type").String() != "response.reasoning_text.done" {
+ if ojson.GetBytesNoCopy(eventData, "type").String() != "response.reasoning_text.done" {
return [][]byte{xaiNormalizeReasoningSummaryData(eventData)}
}
- textDone, _ := sjson.SetBytes(eventData, "type", "response.reasoning_summary_text.done")
- textDone = xaiNormalizeReasoningSummaryIndex(textDone)
+ textDoneDoc := ojson.NewDoc(eventData)
+ _ = textDoneDoc.Set("type", "response.reasoning_summary_text.done")
+ xaiNormalizeReasoningSummaryIndexDoc(textDoneDoc)
+ textDone := textDoneDoc.Bytes()
partDone := xaiNormalizeReasoningSummaryData(eventData)
return [][]byte{textDone, partDone}
}
func xaiNormalizeReasoningSummaryIndex(eventData []byte) []byte {
- contentIndex := gjson.GetBytes(eventData, "content_index")
- if contentIndex.Exists() && contentIndex.Raw != "" && !gjson.GetBytes(eventData, "summary_index").Exists() {
- eventData, _ = sjson.SetRawBytes(eventData, "summary_index", []byte(contentIndex.Raw))
+ doc := ojson.NewDoc(eventData)
+ if !xaiNormalizeReasoningSummaryIndexDoc(doc) {
+ return eventData
+ }
+ return doc.Bytes()
+}
+
+func xaiNormalizeReasoningSummaryIndexDoc(doc *ojson.Doc) bool {
+ if doc == nil {
+ return false
}
- eventData, _ = sjson.DeleteBytes(eventData, "content_index")
- return eventData
+ changed := false
+ contentIndex := doc.Get("content_index")
+ if contentIndex.Exists() && contentIndex.Raw != "" && !doc.Get("summary_index").Exists() {
+ if doc.SetRawBytes("summary_index", []byte(contentIndex.Raw)) == nil {
+ changed = true
+ }
+ }
+ if doc.Delete("content_index") == nil {
+ changed = true
+ }
+ return changed
}
-func xaiNormalizeReasoningOutputItems(items []gjson.Result) ([]byte, bool) {
+func xaiNormalizeReasoningOutputItems(items []ojson.Result) ([]byte, bool) {
var buf bytes.Buffer
buf.WriteByte('[')
changed := false
@@ -997,40 +1189,49 @@ func xaiNormalizeReasoningOutputItems(items []gjson.Result) ([]byte, bool) {
}
func xaiNormalizeReasoningOutputItem(item []byte) []byte {
- if !gjson.ValidBytes(item) || gjson.GetBytes(item, "type").String() != "reasoning" {
+ if !ojson.ValidBytes(item) || ojson.GetBytesNoCopy(item, "type").String() != "reasoning" {
return item
}
- normalized := item
- if summary := gjson.GetBytes(normalized, "summary"); summary.IsArray() {
- updatedSummary, changed := xaiNormalizeReasoningSummaryItems(summary.Array())
- if changed {
- normalized, _ = sjson.SetRawBytes(normalized, "summary", updatedSummary)
+ doc := ojson.NewDoc(item)
+ changed := false
+ if summary := doc.Get("summary"); summary.IsArray() {
+ updatedSummary, summaryChanged := xaiNormalizeReasoningSummaryItems(summary.Array())
+ if summaryChanged {
+ if doc.SetRawBytes("summary", updatedSummary) == nil {
+ changed = true
+ }
}
}
- content := gjson.GetBytes(normalized, "content")
+ content := doc.Get("content")
if !content.IsArray() {
- return normalized
+ if !changed {
+ return item
+ }
+ return doc.Bytes()
}
- summaryItems := make([]gjson.Result, 0, len(content.Array()))
+ summaryItems := make([]ojson.Result, 0, len(content.Array()))
for _, part := range content.Array() {
if part.Get("type").String() == "reasoning_text" {
summaryItems = append(summaryItems, part)
}
}
if len(summaryItems) == 0 {
- return normalized
+ if !changed {
+ return item
+ }
+ return doc.Bytes()
}
updatedSummary, _ := xaiNormalizeReasoningSummaryItems(summaryItems)
- normalized, _ = sjson.SetRawBytes(normalized, "summary", updatedSummary)
- normalized, _ = sjson.DeleteBytes(normalized, "content")
- return normalized
+ _ = doc.SetRawBytes("summary", updatedSummary)
+ _ = doc.Delete("content")
+ return doc.Bytes()
}
-func xaiNormalizeReasoningSummaryItems(items []gjson.Result) ([]byte, bool) {
+func xaiNormalizeReasoningSummaryItems(items []ojson.Result) ([]byte, bool) {
var buf bytes.Buffer
buf.WriteByte('[')
changed := false
@@ -1041,7 +1242,7 @@ func xaiNormalizeReasoningSummaryItems(items []gjson.Result) ([]byte, bool) {
itemRaw := []byte(item.Raw)
if item.Get("type").String() == "reasoning_text" {
var errSet error
- itemRaw, errSet = sjson.SetBytes(itemRaw, "type", "summary_text")
+ itemRaw, errSet = ojson.SetBytes(itemRaw, "type", "summary_text")
if errSet == nil {
changed = true
}
@@ -1053,11 +1254,11 @@ func xaiNormalizeReasoningSummaryItems(items []gjson.Result) ([]byte, bool) {
}
func xaiCollectOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
- itemResult := gjson.GetBytes(eventData, "item")
- if !itemResult.Exists() || itemResult.Type != gjson.JSON {
+ itemResult := ojson.GetBytesNoCopy(eventData, "item")
+ if !itemResult.Exists() || itemResult.Type != ojson.JSON {
return
}
- outputIndexResult := gjson.GetBytes(eventData, "output_index")
+ outputIndexResult := ojson.GetBytesNoCopy(eventData, "output_index")
if outputIndexResult.Exists() {
outputItemsByIndex[outputIndexResult.Int()] = []byte(itemResult.Raw)
return
@@ -1067,7 +1268,7 @@ func xaiCollectOutputItemDone(eventData []byte, outputItemsByIndex map[int64][]b
func xaiPatchCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
eventData = helps.EnsureResponsesUsageDetails(eventData)
- outputResult := gjson.GetBytes(eventData, "response.output")
+ outputResult := ojson.GetBytesNoCopy(eventData, "response.output")
shouldPatchOutput := (!outputResult.Exists() || !outputResult.IsArray() || len(outputResult.Array()) == 0) && (len(outputItemsByIndex) > 0 || len(outputItemsFallback) > 0)
if !shouldPatchOutput {
return eventData
@@ -1104,7 +1305,7 @@ func xaiPatchCompletedOutput(eventData []byte, outputItemsByIndex map[int64][]by
outputArray = buf.Bytes()
}
- patched, _ := sjson.SetRawBytes(eventData, "response.output", outputArray)
+ patched, _ := ojson.SetRawBytes(eventData, "response.output", outputArray)
return patched
}
@@ -1133,8 +1334,8 @@ func xaiStatusErr(code int, body []byte) statusErr {
if code != http.StatusTooManyRequests {
return err
}
- codeStr := strings.ToLower(gjson.GetBytes(body, "code").String())
- msg := strings.ToLower(gjson.GetBytes(body, "error").String())
+ codeStr := strings.ToLower(ojson.GetBytesNoCopy(body, "code").String())
+ msg := strings.ToLower(ojson.GetBytesNoCopy(body, "error").String())
if msg == "" {
msg = strings.ToLower(string(body))
}
@@ -1153,12 +1354,12 @@ func xaiStatusErr(code int, body []byte) statusErr {
// error.code / error.message shapes are checked as well as flat bodies.
func isXAIBadCredentialsBody(body []byte) bool {
for _, path := range []string{"code", "error.code", "body.error.code"} {
- if strings.Contains(strings.ToLower(gjson.GetBytes(body, path).String()), "bad-credentials") {
+ if strings.Contains(strings.ToLower(ojson.GetBytesNoCopy(body, path).String()), "bad-credentials") {
return true
}
}
for _, path := range []string{"error", "error.message", "message", "body.error", "body.error.message"} {
- msg := strings.ToLower(gjson.GetBytes(body, path).String())
+ msg := strings.ToLower(ojson.GetBytesNoCopy(body, path).String())
if strings.Contains(msg, "access token could not be validated") {
return true
}
diff --git a/internal/runtime/executor/xai_executor_speech.go b/internal/runtime/executor/xai_executor_speech.go
index 3b51a0b658d..93b678af64d 100644
--- a/internal/runtime/executor/xai_executor_speech.go
+++ b/internal/runtime/executor/xai_executor_speech.go
@@ -7,11 +7,11 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
// xaiSpeechRequestError marks upstream speech failures caused by the request itself,
@@ -45,11 +45,11 @@ var xaiSpeechModelUnavailablePatterns = [...]string{
// xaiSpeechModelUnavailable reports whether an upstream error body says the speech model
// itself is missing, unsupported or unavailable, as opposed to a bad request value.
func xaiSpeechModelUnavailable(body []byte) bool {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
return xaiSpeechModelUnavailableText(string(body))
}
for _, path := range []string{"code", "error.code", "type", "error.type", "error", "error.message", "message", "detail"} {
- if xaiSpeechModelUnavailableText(gjson.GetBytes(body, path).String()) {
+ if xaiSpeechModelUnavailableText(ojson.GetBytesNoCopy(body, path).String()) {
return true
}
}
@@ -79,7 +79,7 @@ func xaiSpeechStatusErr(code int, body []byte) error {
}
func (e *XAIExecutor) executeSpeech(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
- model := strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String())
+ model := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "model").String())
if model == "" {
model = strings.TrimSpace(req.Model)
}
@@ -91,7 +91,9 @@ func (e *XAIExecutor) executeSpeech(ctx context.Context, auth *cliproxyauth.Auth
logXAIResolvedBaseURL(ctx, strings.TrimSuffix(requestURL, xaiTTSPath))
// Payload rules target the "openai" protocol, matching xAI image/video and config.example.yaml.
- payload := helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", req.Payload, req, opts)(req.Payload)
+ doc := ojson.NewDoc(req.Payload)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, helps.NewPayloadFinalizer(e.cfg, e.Identifier(), model, "openai", "", req.Payload, req, opts)), doc)
+ payload := doc.Bytes()
reporter.SetTranslatedRequestMetadata(payload, "openai")
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(payload))
if err != nil {
diff --git a/internal/runtime/executor/xai_executor_speech_test.go b/internal/runtime/executor/xai_executor_speech_test.go
index 3515ab297bf..25e99f3721c 100644
--- a/internal/runtime/executor/xai_executor_speech_test.go
+++ b/internal/runtime/executor/xai_executor_speech_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -18,7 +19,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestXAISpeechRequestURLStaysOnOfficialAPI(t *testing.T) {
@@ -163,13 +163,13 @@ func TestXAIExecutorExecuteSpeechPayloadRulesMatchOpenAIProtocol(t *testing.T) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.Get(gotBody, "voice_id").String(); got != "ara" {
+ if got := ojson.Get(gotBody, "voice_id").String(); got != "ara" {
t.Fatalf("voice_id = %q, want payload rule override ara; body=%s", got, gotBody)
}
- if got := gjson.Get(gotBody, "language").String(); got != "en" {
+ if got := ojson.Get(gotBody, "language").String(); got != "en" {
t.Fatalf("language = %q, want payload rule override en; body=%s", got, gotBody)
}
- if got := gjson.Get(gotBody, "text").String(); got != "hello" {
+ if got := ojson.Get(gotBody, "text").String(); got != "hello" {
t.Fatalf("text = %q, want hello; body=%s", got, gotBody)
}
}
diff --git a/internal/runtime/executor/xai_executor_stream.go b/internal/runtime/executor/xai_executor_stream.go
index e4d7148ad44..5c7c55bf9c8 100644
--- a/internal/runtime/executor/xai_executor_stream.go
+++ b/internal/runtime/executor/xai_executor_stream.go
@@ -8,11 +8,11 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
@@ -31,7 +31,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
baseURL := xaiChatBaseURL(auth)
logXAIResolvedBaseURL(ctx, baseURL)
- prepared, err := e.prepareResponsesRequest(ctx, req, opts, true)
+ prepared, err := e.prepareResponsesRequestDoc(ctx, req, opts, true)
if err != nil {
return nil, err
}
@@ -40,8 +40,10 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
defer reporter.TrackFailure(ctx, &err)
url := strings.TrimSuffix(baseURL, "/") + "/responses"
- prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
- prepared.body = prepared.finalizePayload(prepared.body)
+ doc := prepared.ensureDoc()
+ sanitizeXAIInputEncryptedContentForBaseURLDoc(doc, baseURL)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, prepared.finalizePayload), doc)
+ prepared.body = doc.Bytes()
reporter.SetTranslatedReasoningEffort(prepared.body, e.Identifier())
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(prepared.body))
if err != nil {
@@ -100,14 +102,14 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
for _, line := range lines {
if bytes.HasPrefix(line, xaiDataTag) {
eventData := bytes.TrimSpace(line[len(xaiDataTag):])
- switch gjson.GetBytes(eventData, "type").String() {
+ switch ojson.GetBytesNoCopy(eventData, "type").String() {
case "response.output_item.done":
xaiCollectOutputItemDone(eventData, outputItemsByIndex, &outputItemsFallback)
case "response.completed", "response.incomplete":
// Reconstruct only after the bridge has restored dispatcher children.
eventData = xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
eventData = xaiNormalizeReasoningSummaryData(eventData)
- if gjson.GetBytes(eventData, "type").String() == "response.completed" {
+ if ojson.GetBytesNoCopy(eventData, "type").String() == "response.completed" {
// Only completed responses carry replayable terminal state.
cacheXAIReasoningReplayFromCompleted(ctx, prepared.replayScope, eventData)
}
@@ -155,7 +157,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
eventDataList := xaiNormalizeReasoningSummaryDataEvents(bytes.TrimSpace(line[len(xaiDataTag):]))
hasPendingEventLine := pendingEventLine != nil
for i, eventData := range eventDataList {
- if rawEventType := gjson.GetBytes(eventData, "type").String(); rawEventType == "response.output_item.done" || rawEventType == "response.completed" || rawEventType == "response.incomplete" || rawEventType == "response.done" {
+ if rawEventType := ojson.GetBytesNoCopy(eventData, "type").String(); rawEventType == "response.output_item.done" || rawEventType == "response.completed" || rawEventType == "response.incomplete" || rawEventType == "response.done" {
streamUsage.ObserveResponsesStream(eventData)
}
prepared.applyPatch.RememberDispatcherEvent(eventData)
@@ -171,7 +173,7 @@ func (e *XAIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth
continue
}
reporter.ObserveResponseModel(eventData)
- normalizedEventName := gjson.GetBytes(eventData, "type").String()
+ normalizedEventName := ojson.GetBytesNoCopy(eventData, "type").String()
if hasPendingEventLine {
eventLine := []byte("event: " + normalizedEventName)
diff --git a/internal/runtime/executor/xai_executor_test.go b/internal/runtime/executor/xai_executor_test.go
index 1c14c2323e6..581b8bf47ba 100644
--- a/internal/runtime/executor/xai_executor_test.go
+++ b/internal/runtime/executor/xai_executor_test.go
@@ -4,17 +4,19 @@ import (
"bytes"
"context"
"crypto/sha256"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/http/httptest"
+ "runtime"
"strings"
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
@@ -27,8 +29,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
"github.com/tiktoken-go/tokenizer"
)
@@ -205,7 +205,7 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if gjson.GetBytes(result.Payload, "output.1.type").String() != "custom_tool_call" || gjson.GetBytes(result.Payload, "output.1.input").String() != "p" {
+ if ojson.GetBytes(result.Payload, "output.1.type").String() != "custom_tool_call" || ojson.GetBytes(result.Payload, "output.1.input").String() != "p" {
t.Fatalf("retained patch was not actually bridged: %s", result.Payload)
}
if gotPath != "/responses" {
@@ -223,34 +223,34 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) {
if gotAccountID != "" {
t.Fatalf("Chatgpt-Account-Id = %q, want empty", gotAccountID)
}
- if gjson.GetBytes(gotBody, "prompt_cache_key").String() != "conv-xai-1" {
+ if ojson.GetBytes(gotBody, "prompt_cache_key").String() != "conv-xai-1" {
t.Fatalf("prompt_cache_key missing from body: %s", string(gotBody))
}
- if !gjson.GetBytes(gotBody, "stream").Bool() {
+ if !ojson.GetBytes(gotBody, "stream").Bool() {
t.Fatalf("stream = false, want true; body=%s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "reasoning.effort").String() != "high" {
- t.Fatalf("reasoning.effort = %q, want high; body=%s", gjson.GetBytes(gotBody, "reasoning.effort").String(), string(gotBody))
+ if ojson.GetBytes(gotBody, "reasoning.effort").String() != "high" {
+ t.Fatalf("reasoning.effort = %q, want high; body=%s", ojson.GetBytes(gotBody, "reasoning.effort").String(), string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.0.content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.content").Exists() {
t.Fatalf("input.0.content exists, want removed; body=%s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
t.Fatalf("input.0.encrypted_content exists, want removed; body=%s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
+ if got := ojson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
t.Fatalf("input.0.summary.0.text = %q, want test; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
+ if got := ojson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
t.Fatalf("input.0.summary.1.text = %q, want second; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.2").Exists() {
+ if ojson.GetBytes(gotBody, "input.2").Exists() {
t.Fatalf("input.2 exists, want consecutive reasoning item merged; body=%s", string(gotBody))
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
if len(tools) != 7 {
t.Fatalf("tools length = %d, want 7; body=%s", len(tools), string(gotBody))
}
@@ -300,25 +300,25 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) {
if !foundXSearch {
t.Fatalf("native x_search tool was not injected; body=%s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "tool_choice.tools.0.name").String(); got != "codex_app__automation_update" {
+ if got := ojson.GetBytes(gotBody, "tool_choice.tools.0.name").String(); got != "codex_app__automation_update" {
t.Fatalf("tool_choice.tools.0.name = %q, want codex_app__automation_update; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "tool_choice.tools.0.namespace").Exists() {
+ if ojson.GetBytes(gotBody, "tool_choice.tools.0.namespace").Exists() {
t.Fatalf("tool_choice.tools.0.namespace should be removed for xAI upstream: %s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "tool_choice.tools.1.name").String(); got != "lookup" {
+ if got := ojson.GetBytes(gotBody, "tool_choice.tools.1.name").String(); got != "lookup" {
t.Fatalf("tool_choice.tools.1.name = %q, want lookup; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "tool_choice.tools.2.type").String(); got != "x_search" {
+ if got := ojson.GetBytes(gotBody, "tool_choice.tools.2.type").String(); got != "x_search" {
t.Fatalf("tool_choice.tools.2.type = %q, want x_search; body=%s", got, string(gotBody))
}
- for _, tool := range gjson.GetBytes(gotBody, "tool_choice.tools").Array() {
+ for _, tool := range ojson.GetBytes(gotBody, "tool_choice.tools").Array() {
if tool.Get("type").String() == "web_search" {
t.Fatalf("web_search must not remain in allowed_tools: %s", string(gotBody))
}
}
xSearchAllowedCount := 0
- for _, tool := range gjson.GetBytes(gotBody, "tool_choice.tools").Array() {
+ for _, tool := range ojson.GetBytes(gotBody, "tool_choice.tools").Array() {
if tool.Get("type").String() == "x_search" {
xSearchAllowedCount++
}
@@ -327,7 +327,7 @@ func TestXAIExecutorExecuteShapesResponsesRequest(t *testing.T) {
t.Fatalf("allowed_tools x_search count = %d, want 1; body=%s", xSearchAllowedCount, string(gotBody))
}
foundEncryptedReasoningInclude := false
- for _, include := range gjson.GetBytes(gotBody, "include").Array() {
+ for _, include := range ojson.GetBytes(gotBody, "include").Array() {
if include.String() == "reasoning.encrypted_content" {
foundEncryptedReasoningInclude = true
break
@@ -367,7 +367,7 @@ func TestXAIExecutorPrepareResponsesRequestRewritesCodexAgentMessage(t *testing.
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- message := gjson.GetBytes(prepared.body, "input.0")
+ message := ojson.GetBytes(prepared.body, "input.0")
if message.Get("type").String() != "message" || message.Get("role").String() != "user" {
t.Fatalf("agent message was not rewritten: %s", prepared.body)
}
@@ -436,7 +436,7 @@ func TestXAIExecutorExecuteFoldsNamespacesWhenToolsExceed200(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
if len(tools) != 47 {
t.Fatalf("upstream tools count = %d, want 47 dispatcher tools; body=%s", len(tools), string(gotBody))
}
@@ -447,7 +447,7 @@ func TestXAIExecutorExecuteFoldsNamespacesWhenToolsExceed200(t *testing.T) {
t.Fatalf("upstream tools[0].type = %q, want function", got)
}
- output := gjson.GetBytes(resp.Payload, "output.0")
+ output := ojson.GetBytes(resp.Payload, "output.0")
if got := output.Get("name").String(); got != "tool_2" {
t.Fatalf("response output name = %q, want tool_2; payload=%s", got, resp.Payload)
}
@@ -481,7 +481,7 @@ func TestXAIExecutorExecuteFoldsNamespacesWhenToolsExceed200(t *testing.T) {
t.Fatalf("Execute() turn 2 error = %v", err2)
}
- input := gjson.GetBytes(gotBody, "input").Array()
+ input := ojson.GetBytes(gotBody, "input").Array()
if len(input) != 3 {
t.Fatalf("upstream input count = %d, want 3; body=%s", len(input), string(gotBody))
}
@@ -553,7 +553,7 @@ func TestXAIExecutorExecuteStreamFoldsNamespacesWhenToolsExceed200(t *testing.T)
chunks = append(chunks, chunk.Payload)
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
if len(tools) != 47 {
t.Fatalf("upstream tools count = %d, want 47 dispatcher tools; body=%s", len(tools), string(gotBody))
}
@@ -605,15 +605,15 @@ func TestXAIExecutorExecuteRestoresAdditionalToolsNamespaceCalls(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ for _, item := range ojson.GetBytes(gotBody, "input").Array() {
if got := item.Get("type").String(); got == "additional_tools" {
t.Fatalf("upstream input contains unsupported additional_tools item: %s", gotBody)
}
}
- if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want user; body=%s", got, gotBody)
}
- tool := gjson.GetBytes(gotBody, "tools.0")
+ tool := ojson.GetBytes(gotBody, "tools.0")
if got := tool.Get("name").String(); got != "mcp__exa__web_search_exa" {
t.Fatalf("upstream tool name = %q, want qualified name; body=%s", got, gotBody)
}
@@ -623,7 +623,7 @@ func TestXAIExecutorExecuteRestoresAdditionalToolsNamespaceCalls(t *testing.T) {
if tool.Get("tools").Exists() {
t.Fatalf("upstream tool should not contain namespace children: %s", gotBody)
}
- output := gjson.GetBytes(resp.Payload, "output.0")
+ output := ojson.GetBytes(resp.Payload, "output.0")
if got := output.Get("name").String(); got != "web_search_exa" {
t.Fatalf("response output name = %q, want child name; payload=%s", got, resp.Payload)
}
@@ -640,7 +640,7 @@ func TestXAIExecutorExecuteNormalizesCustomToolCallHistory(t *testing.T) {
if errRead != nil {
t.Fatalf("read body: %v", errRead)
}
- for _, item := range gjson.GetBytes(gotBody, "input").Array() {
+ for _, item := range ojson.GetBytes(gotBody, "input").Array() {
if strings.HasPrefix(item.Get("type").String(), "custom_tool_call") {
http.Error(w, `{"error":"data did not match any variant of untagged enum ModelInput"}`, http.StatusUnprocessableEntity)
return
@@ -686,14 +686,14 @@ func TestXAIExecutorExecuteNormalizesCustomToolCallHistory(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- input := gjson.GetBytes(gotBody, "input").Array()
+ input := ojson.GetBytes(gotBody, "input").Array()
if len(input) != 5 {
t.Fatalf("input length = %d, want 5; body=%s", len(input), gotBody)
}
if got := input[1].Get("type").String(); got != "function_call" {
t.Fatalf("input.1.type = %q, want function_call; body=%s", got, gotBody)
}
- if got := gjson.Get(input[1].Get("arguments").String(), "query").String(); got != "US stocks" {
+ if got := ojson.Get(input[1].Get("arguments").String(), "query").String(); got != "US stocks" {
t.Fatalf("input.1 arguments query = %q, want US stocks; body=%s", got, gotBody)
}
if input[1].Get("input").Exists() || input[1].Get("internal_chat_message_metadata_passthrough").Exists() {
@@ -705,13 +705,13 @@ func TestXAIExecutorExecuteNormalizesCustomToolCallHistory(t *testing.T) {
if got := input[2].Get("output").String(); got != "unsupported custom tool call: x_semantic_search" {
t.Fatalf("input.2.output = %q; body=%s", got, gotBody)
}
- if got := gjson.Get(input[3].Get("arguments").String(), "input").String(); got != "*** Begin Patch" {
+ if got := ojson.Get(input[3].Get("arguments").String(), "input").String(); got != "*** Begin Patch" {
t.Fatalf("input.3 freeform arguments = %q, want patch input; body=%s", got, gotBody)
}
if got := input[4].Get("output").String(); got != `[{"type":"input_text","text":"done"}]` {
t.Fatalf("input.4 output = %q, want flattened JSON string; body=%s", got, gotBody)
}
- if got := gjson.GetBytes(gotBody, "tools.0.type").String(); got != "x_search" {
+ if got := ojson.GetBytes(gotBody, "tools.0.type").String(); got != "x_search" {
t.Fatalf("tools.0.type = %q, want x_search; body=%s", got, gotBody)
}
}
@@ -720,18 +720,18 @@ func TestXAIExecutorExecuteStreamFiltersInternalXSearchCalls(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
names := []string{"x_user_search", "x_semantic_search", "x_keyword_search", "x_thread_fetch"}
- completed := []byte(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
+ completedDoc := ojson.NewDoc([]byte(`{"type":"response.completed","response":{"id":"resp_1","object":"response","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`))
for i, name := range names {
itemID := fmt.Sprintf("ctc_%d", i)
callID := fmt.Sprintf("xs_call-%d", i)
_, _ = fmt.Fprintf(w, "event: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"output_index\":%d,\"item\":{\"id\":%q,\"type\":\"custom_tool_call\",\"call_id\":%q,\"name\":%q,\"input\":\"\",\"status\":\"in_progress\"}}\n\n", i, itemID, callID, name)
_, _ = fmt.Fprintf(w, "event: response.custom_tool_call_input.done\ndata: {\"type\":\"response.custom_tool_call_input.done\",\"output_index\":%d,\"item_id\":%q,\"input\":\"{}\"}\n\n", i, itemID)
_, _ = fmt.Fprintf(w, "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":%d,\"item\":{\"id\":%q,\"type\":\"custom_tool_call\",\"call_id\":%q,\"name\":%q,\"input\":\"{}\",\"status\":\"completed\"}}\n\n", i, itemID, callID, name)
- item := []byte(`{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"{}","status":"completed"}`)
- item, _ = sjson.SetBytes(item, "id", itemID)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item, _ = sjson.SetBytes(item, "name", name)
- completed, _ = sjson.SetRawBytes(completed, "response.output.-1", item)
+ itemDoc := ojson.NewDoc([]byte(`{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"{}","status":"completed"}`))
+ _ = itemDoc.Set("id", itemID)
+ _ = itemDoc.Set("call_id", callID)
+ _ = itemDoc.Set("name", name)
+ _ = completedDoc.SetRawBytes("response.output.-1", itemDoc.Bytes())
}
messageIndex := len(names)
@@ -739,7 +739,8 @@ func TestXAIExecutorExecuteStreamFiltersInternalXSearchCalls(t *testing.T) {
_, _ = fmt.Fprintf(w, "event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"output_index\":%d,\"item_id\":\"msg_1\",\"content_index\":0,\"delta\":\"answer\"}\n\n", messageIndex)
_, _ = fmt.Fprintf(w, "event: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"output_index\":%d,\"item\":{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"answer\"}],\"status\":\"completed\"}}\n\n", messageIndex)
message := []byte(`{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}],"status":"completed"}`)
- completed, _ = sjson.SetRawBytes(completed, "response.output.-1", message)
+ _ = completedDoc.SetRawBytes("response.output.-1", message)
+ completed := completedDoc.Bytes()
_, _ = fmt.Fprintf(w, "event: response.completed\ndata: %s\n\n", completed)
}))
defer server.Close()
@@ -779,14 +780,14 @@ func TestXAIExecutorExecuteStreamFiltersInternalXSearchCalls(t *testing.T) {
t.Fatalf("custom tool input event leaked downstream: %s", streamText)
}
- var completed gjson.Result
+ var completed ojson.Result
messageIndexChecks := 0
for _, line := range strings.Split(streamText, "\n") {
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if !gjson.Valid(line) {
+ if !ojson.Valid(line) {
continue
}
- event := gjson.Parse(line)
+ event := ojson.Parse(line)
if event.Get("item.id").String() == "msg_1" || event.Get("item_id").String() == "msg_1" {
messageIndexChecks++
if got := event.Get("output_index").Int(); got != 0 {
@@ -836,10 +837,10 @@ func TestXAIExecutorExecuteFiltersInternalXSearchCalls(t *testing.T) {
if strings.Contains(string(resp.Payload), "x_user_search") || strings.Contains(string(resp.Payload), "custom_tool_call") {
t.Fatalf("internal X search call leaked into response: %s", resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.#").Int(); got != 1 {
+ if got := ojson.GetBytes(resp.Payload, "output.#").Int(); got != 1 {
t.Fatalf("response output length = %d, want 1; payload=%s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.0.content.0.text").String(); got != "answer" {
+ if got := ojson.GetBytes(resp.Payload, "output.0.content.0.text").String(); got != "answer" {
t.Fatalf("response text = %q, want answer; payload=%s", got, resp.Payload)
}
}
@@ -868,13 +869,13 @@ func TestXAIExecutorExecuteAcceptsResponseIncomplete(t *testing.T) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(resp.Payload, "status").String(); got != "incomplete" {
+ if got := ojson.GetBytes(resp.Payload, "status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; payload=%s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "incomplete_details.reason").String(); got != "max_output_tokens" {
+ if got := ojson.GetBytes(resp.Payload, "incomplete_details.reason").String(); got != "max_output_tokens" {
t.Fatalf("incomplete reason = %q, want max_output_tokens; payload=%s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.#").Int(); got != 1 {
+ if got := ojson.GetBytes(resp.Payload, "output.#").Int(); got != 1 {
t.Fatalf("output length = %d, want 1; payload=%s", got, resp.Payload)
}
}
@@ -913,13 +914,13 @@ func TestXAIExecutorExecuteStreamAcceptsResponseIncomplete(t *testing.T) {
stream.WriteByte('\n')
}
- var incomplete gjson.Result
+ var incomplete ojson.Result
for _, line := range strings.Split(stream.String(), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if !gjson.Valid(line) {
+ if !ojson.Valid(line) {
continue
}
- if event := gjson.Parse(line); event.Get("type").String() == "response.incomplete" {
+ if event := ojson.Parse(line); event.Get("type").String() == "response.incomplete" {
incomplete = event
}
}
@@ -971,7 +972,7 @@ func TestXAIExecutorPrepareHonorsInjectXSearchConfig(t *testing.T) {
if tt.wantXSearch {
wantXSearchCount = 1
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1+wantXSearchCount {
t.Fatalf("tools length = %d, want %d; body=%s", len(tools), 1+wantXSearchCount, prepared.body)
}
@@ -989,7 +990,7 @@ func TestXAIExecutorPrepareHonorsInjectXSearchConfig(t *testing.T) {
}
xSearchAllowed := 0
- for _, tool := range gjson.GetBytes(prepared.body, "tool_choice.tools").Array() {
+ for _, tool := range ojson.GetBytes(prepared.body, "tool_choice.tools").Array() {
if tool.Get("type").String() == "x_search" {
xSearchAllowed++
}
@@ -1009,7 +1010,7 @@ func TestEnsureXAINativeXSearchTool(t *testing.T) {
// Missing tools array: inject a top-level x_search tool.
out := ensureXAINativeXSearchTool([]byte(`{"model":"grok-4.5","input":"hi"}`))
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), out)
}
@@ -1019,7 +1020,7 @@ func TestEnsureXAINativeXSearchTool(t *testing.T) {
// Existing tools without x_search: append once.
out = ensureXAINativeXSearchTool([]byte(`{"tools":[{"type":"web_search"},{"type":"function","name":"lookup","parameters":{"type":"object"}}]}`))
- tools = gjson.GetBytes(out, "tools").Array()
+ tools = ojson.GetBytes(out, "tools").Array()
if len(tools) != 3 {
t.Fatalf("tools length = %d, want 3; body=%s", len(tools), out)
}
@@ -1030,7 +1031,7 @@ func TestEnsureXAINativeXSearchTool(t *testing.T) {
// Already present: leave body unchanged (no duplicate).
in := []byte(`{"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"x_search"}]}`)
out = ensureXAINativeXSearchTool(in)
- tools = gjson.GetBytes(out, "tools").Array()
+ tools = ojson.GetBytes(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), out)
}
@@ -1049,10 +1050,10 @@ func TestEnsureXAINativeXSearchTool(t *testing.T) {
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
"tool_choice":{"type":"allowed_tools","tools":[{"type":"function","name":"lookup"}]}
}`))
- if got := gjson.GetBytes(out, "tools.1.type").String(); got != "x_search" {
+ if got := ojson.GetBytes(out, "tools.1.type").String(); got != "x_search" {
t.Fatalf("tools.1.type = %q, want x_search; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "tool_choice.tools.1.type").String(); got != "x_search" {
+ if got := ojson.GetBytes(out, "tool_choice.tools.1.type").String(); got != "x_search" {
t.Fatalf("tool_choice.tools.1.type = %q, want x_search; body=%s", got, out)
}
@@ -1061,11 +1062,11 @@ func TestEnsureXAINativeXSearchTool(t *testing.T) {
"tools":[{"type":"web_search"},{"type":"x_search"}],
"tool_choice":{"type":"allowed_tools","tools":[{"type":"web_search"},{"type":"x_search"}]}
}`))
- tools = gjson.GetBytes(out, "tools").Array()
+ tools = ojson.GetBytes(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), out)
}
- allowed := gjson.GetBytes(out, "tool_choice.tools").Array()
+ allowed := ojson.GetBytes(out, "tool_choice.tools").Array()
if len(allowed) != 2 {
t.Fatalf("tool_choice.tools length = %d, want 2; body=%s", len(allowed), out)
}
@@ -1104,11 +1105,11 @@ func TestXAIExecutorPrepareNormalizesClaudeWebSearchToolChoice(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1141,11 +1142,11 @@ func TestXAIExecutorPrepareNormalizesClaudeWebSearchToolChoice_Grok46(t *testing
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1179,7 +1180,7 @@ func TestXAIExecutorPrepareStripsUnsupportedWebSearchOptions(t *testing.T) {
}
webSearchTools := 0
- for i, tool := range gjson.GetBytes(prepared.body, "tools").Array() {
+ for i, tool := range ojson.GetBytes(prepared.body, "tools").Array() {
if tool.Get("type").String() != "web_search" {
continue
}
@@ -1215,7 +1216,7 @@ func TestPruneXAIOrphanedToolChoice(t *testing.T) {
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],
"tool_choice":{"type":"image_generation"}
}`))
- if gjson.GetBytes(out, "tool_choice").Exists() {
+ if ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("orphaned forced tool_choice should be removed: %s", out)
}
@@ -1228,7 +1229,7 @@ func TestPruneXAIOrphanedToolChoice(t *testing.T) {
{"type":"web_search"}
]}
}`))
- allowed := gjson.GetBytes(out, "tool_choice.tools").Array()
+ allowed := ojson.GetBytes(out, "tool_choice.tools").Array()
if len(allowed) != 2 {
t.Fatalf("allowed_tools length = %d, want 2; body=%s", len(allowed), out)
}
@@ -1244,7 +1245,7 @@ func TestPruneXAIOrphanedToolChoice(t *testing.T) {
"tools":[],
"tool_choice":{"type":"allowed_tools","tools":[{"type":"image_generation"}]}
}`))
- if gjson.GetBytes(out, "tool_choice").Exists() {
+ if ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("fully orphaned allowed_tools should be removed: %s", out)
}
@@ -1324,10 +1325,10 @@ func TestNormalizeXAITools_ImageGenerationByModel(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
out := normalizeXAITools(tt.body)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
foundImage := false
foundWebSearch := false
- var imageTool gjson.Result
+ var imageTool ojson.Result
for _, tool := range tools {
switch tool.Get("type").String() {
case "image_generation":
@@ -1372,7 +1373,7 @@ func TestXAIExecutorPrepareKeepsNativeImageGenerationForGrok46(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1382,8 +1383,8 @@ func TestXAIExecutorPrepareKeepsNativeImageGenerationForGrok46(t *testing.T) {
if got := tools[0].Get("action").String(); got != "generate" {
t.Fatalf("tools.0.action = %q, want generate; body=%s", got, prepared.body)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
}
@@ -1408,11 +1409,11 @@ func TestXAIExecutorPrepareRewritesImageGenerationAllowedToolsToRequired(t *test
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1441,11 +1442,11 @@ func TestXAIExecutorPrepareForcedImageGenerationDropsOtherToolsAndSkipsXSearchIn
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1474,8 +1475,8 @@ func TestXAIExecutorPrepareRewritesImageOnlyAllowedToolsAutoToAuto(t *testing.T)
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "auto" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "auto" {
t.Fatalf("tool_choice = %s, want string auto; body=%s", choice.Raw, prepared.body)
}
}
@@ -1503,7 +1504,7 @@ func TestXAIExecutorPrepareStripsImageGenerationFromMixedAllowedTools(t *testing
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
if got := choice.Get("type").String(); got != "allowed_tools" {
t.Fatalf("tool_choice.type = %q, want allowed_tools; body=%s", got, prepared.body)
}
@@ -1541,11 +1542,11 @@ func TestXAIExecutorPrepareRewritesWebSearchAllowedToolsToRequired(t *testing.T)
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1574,11 +1575,11 @@ func TestXAIExecutorPrepareForcedWebSearchDropsOtherToolsAndSkipsXSearchInject(t
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "required" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "required" {
t.Fatalf("tool_choice = %s, want string required; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1607,11 +1608,11 @@ func TestXAIExecutorPrepareRewritesWebSearchOnlyAllowedToolsAutoToAuto(t *testin
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
- if choice.Type != gjson.String || choice.String() != "auto" {
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
+ if choice.Type != ojson.String || choice.String() != "auto" {
t.Fatalf("tool_choice = %s, want string auto; body=%s", choice.Raw, prepared.body)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -1643,7 +1644,7 @@ func TestXAIExecutorPrepareStripsWebSearchFromMixedAllowedTools(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- choice := gjson.GetBytes(prepared.body, "tool_choice")
+ choice := ojson.GetBytes(prepared.body, "tool_choice")
if got := choice.Get("type").String(); got != "allowed_tools" {
t.Fatalf("tool_choice.type = %q, want allowed_tools; body=%s", got, prepared.body)
}
@@ -1683,14 +1684,14 @@ func TestXAIExecutorPrepareDropsOrphanedToolChoiceBeforeXSearchInject(t *testing
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
if got := tools[0].Get("type").String(); got != "x_search" {
t.Fatalf("tools.0.type = %q, want x_search; body=%s", got, prepared.body)
}
- if gjson.GetBytes(prepared.body, "tool_choice").Exists() {
+ if ojson.GetBytes(prepared.body, "tool_choice").Exists() {
t.Fatalf("orphaned image_generation tool_choice must not reach upstream: %s", prepared.body)
}
}
@@ -1785,12 +1786,12 @@ func TestXAIExecutorPrepareResponsesRequestPreservesSupportedOutputControls(t *t
}
for path, want := range tt.want {
- if got := gjson.GetBytes(prepared.body, path).Raw; got != want {
+ if got := ojson.GetBytes(prepared.body, path).Raw; got != want {
t.Fatalf("%s = %s, want %s; body=%s", path, got, want, prepared.body)
}
}
for _, path := range tt.absent {
- if gjson.GetBytes(prepared.body, path).Exists() {
+ if ojson.GetBytes(prepared.body, path).Exists() {
t.Fatalf("%s should be absent; body=%s", path, prepared.body)
}
}
@@ -1821,7 +1822,7 @@ func TestXAIExecutorPrepareResponsesRequestDropsPayloadStopOverride(t *testing.T
if errPrepare != nil {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- if gjson.GetBytes(prepared.body, "stop").Exists() {
+ if ojson.GetBytes(prepared.body, "stop").Exists() {
t.Fatalf("stop should be removed after payload config; body=%s", prepared.body)
}
}
@@ -1895,8 +1896,8 @@ func TestXAIExecutorPrepareResponsesRequestAddsObjectTypeToRootUnionBranches(t *
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- var cropTool gjson.Result
- for _, tool := range gjson.GetBytes(prepared.body, "tools").Array() {
+ var cropTool ojson.Result
+ for _, tool := range ojson.GetBytes(prepared.body, "tools").Array() {
if tool.Get("type").String() == xaiFunctionToolType && tool.Get("name").String() == "crop_around_point" {
cropTool = tool
break
@@ -1921,7 +1922,7 @@ func TestXAIExecutorPrepareResponsesRequestAddsObjectTypeToRootUnionBranches(t *
t.Fatalf("properties.%s missing: %s", propertyName, parameters.Raw)
}
}
- if parameters.Get("additionalProperties").Type != gjson.False {
+ if parameters.Get("additionalProperties").Type != ojson.False {
t.Fatalf("additionalProperties changed: %s", parameters.Raw)
}
if !branches[0].Get("not.required").Exists() || !branches[1].Get("not.required").Exists() {
@@ -1958,7 +1959,7 @@ func TestXAIExecutorPrepareAllowedToolsSyncsInjectedXSearch(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), prepared.body)
}
@@ -1980,7 +1981,7 @@ func TestXAIExecutorPrepareAllowedToolsSyncsInjectedXSearch(t *testing.T) {
t.Fatalf("expected lookup + x_search tools; body=%s", prepared.body)
}
- allowed := gjson.GetBytes(prepared.body, "tool_choice.tools").Array()
+ allowed := ojson.GetBytes(prepared.body, "tool_choice.tools").Array()
if len(allowed) != 2 {
t.Fatalf("tool_choice.tools length = %d, want 2; body=%s", len(allowed), prepared.body)
}
@@ -2042,24 +2043,24 @@ func TestXAIIsInternalXSearchCallPreservesClientDeclaredTools(t *testing.T) {
}
// Names not declared by the client remain internal X Search traces.
- internalCustom := gjson.Parse(`{"type":"custom_tool_call","name":"x_user_search"}`)
+ internalCustom := ojson.Parse(`{"type":"custom_tool_call","name":"x_user_search"}`)
if !xaiIsInternalXSearchCall(internalCustom, clientTools) {
t.Fatal("undeclared internal custom_tool_call should be filtered")
}
- internalFunction := gjson.Parse(`{"type":"function_call","name":"x_semantic_search"}`)
+ internalFunction := ojson.Parse(`{"type":"function_call","name":"x_semantic_search"}`)
if !xaiIsInternalXSearchCall(internalFunction, clientTools) {
t.Fatal("undeclared internal function_call should be filtered")
}
// Same short name as a client-declared function/custom tool is preserved only for function_call
// (the response shape after custom → function normalization).
- plainClient := gjson.Parse(`{"type":"function_call","name":"x_keyword_search","call_id":"call_plain"}`)
+ plainClient := ojson.Parse(`{"type":"function_call","name":"x_keyword_search","call_id":"call_plain"}`)
if xaiIsInternalXSearchCall(plainClient, clientTools) {
t.Fatal("client-declared plain x_keyword_search function_call must be preserved")
}
// Genuine internal custom_tool_call with the same short name must still be filtered,
// even when the client also declared an ordinary function/custom tool of that name.
- internalSameName := gjson.Parse(`{"type":"custom_tool_call","call_id":"xs_call-1","name":"x_keyword_search"}`)
+ internalSameName := ojson.Parse(`{"type":"custom_tool_call","call_id":"xs_call-1","name":"x_keyword_search"}`)
if !xaiIsInternalXSearchCall(internalSameName, clientTools) {
t.Fatal("genuine internal custom_tool_call x_keyword_search must be filtered despite client function declaration")
}
@@ -2067,7 +2068,7 @@ func TestXAIIsInternalXSearchCallPreservesClientDeclaredTools(t *testing.T) {
functionOnlyTools := collectXAIClientDeclaredToolKeys([]byte(`{
"tools":[{"type":"function","name":"x_keyword_search","parameters":{"type":"object"}}]
}`))
- plainInternalCustom := gjson.Parse(`{"type":"custom_tool_call","name":"x_keyword_search","call_id":"call_other"}`)
+ plainInternalCustom := ojson.Parse(`{"type":"custom_tool_call","name":"x_keyword_search","call_id":"call_other"}`)
if !xaiIsInternalXSearchCall(plainInternalCustom, functionOnlyTools) {
t.Fatal("custom_tool_call must not be exempted by a function declaration of the same name")
}
@@ -2079,7 +2080,7 @@ func TestXAIIsInternalXSearchCallPreservesClientDeclaredTools(t *testing.T) {
if _, ok := customOnlyTools[xaiClientToolKey{namespace: "", name: "x_keyword_search", toolType: xaiFunctionToolType}]; !ok {
t.Fatalf("client custom tool must be keyed as effective function: %#v", customOnlyTools)
}
- clientCustomAsFunction := gjson.Parse(`{"type":"function_call","name":"x_keyword_search","call_id":"call_custom_fn"}`)
+ clientCustomAsFunction := ojson.Parse(`{"type":"function_call","name":"x_keyword_search","call_id":"call_custom_fn"}`)
if xaiIsInternalXSearchCall(clientCustomAsFunction, customOnlyTools) {
t.Fatal("normalized client custom tool function_call must be preserved")
}
@@ -2091,7 +2092,7 @@ func TestXAIIsInternalXSearchCallPreservesClientDeclaredTools(t *testing.T) {
t.Fatal("xs_call internal custom_tool_call must stay filtered when client declares custom same-name tool")
}
// After restoreXAINamespaceToolCalls, namespaced tools regain namespace.
- namespacedClient := gjson.Parse(`{"type":"function_call","name":"x_keyword_search","namespace":"acme"}`)
+ namespacedClient := ojson.Parse(`{"type":"function_call","name":"x_keyword_search","namespace":"acme"}`)
if xaiIsInternalXSearchCall(namespacedClient, clientTools) {
t.Fatal("client-declared namespaced x_keyword_search must be preserved")
}
@@ -2122,19 +2123,19 @@ func TestXAIInternalXSearchResponseFilterPreservesClientToolsInCompletedOutput(t
if got == nil {
t.Fatal("filter dropped entire completed event")
}
- if gjson.GetBytes(got, "response.output.#").Int() != 3 {
- t.Fatalf("completed output length = %d, want 3; event=%s", gjson.GetBytes(got, "response.output.#").Int(), got)
+ if ojson.GetBytes(got, "response.output.#").Int() != 3 {
+ t.Fatalf("completed output length = %d, want 3; event=%s", ojson.GetBytes(got, "response.output.#").Int(), got)
}
- if gjson.GetBytes(got, `response.output.#(type=="custom_tool_call")`).Exists() {
+ if ojson.GetBytes(got, `response.output.#(type=="custom_tool_call")`).Exists() {
t.Fatalf("internal custom_tool_call x_keyword_search leaked: %s", got)
}
- if gotName := gjson.GetBytes(got, "response.output.0.name").String(); gotName != "x_keyword_search" {
+ if gotName := ojson.GetBytes(got, "response.output.0.name").String(); gotName != "x_keyword_search" {
t.Fatalf("output.0.name = %q, want x_keyword_search; event=%s", gotName, got)
}
- if gotType := gjson.GetBytes(got, "response.output.0.type").String(); gotType != "function_call" {
+ if gotType := ojson.GetBytes(got, "response.output.0.type").String(); gotType != "function_call" {
t.Fatalf("output.0.type = %q, want function_call; event=%s", gotType, got)
}
- if gotNS := gjson.GetBytes(got, "response.output.1.namespace").String(); gotNS != "acme" {
+ if gotNS := ojson.GetBytes(got, "response.output.1.namespace").String(); gotNS != "acme" {
t.Fatalf("output.1.namespace = %q, want acme; event=%s", gotNS, got)
}
}
@@ -2185,12 +2186,12 @@ func TestXAIExecutorExecutePreservesClientSameNameToolsWithXSearch(t *testing.T)
if strings.Contains(payload, "custom_tool_call") {
t.Fatalf("internal custom_tool_call leaked into response: %s", payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.#").Int(); got != 3 {
+ if got := ojson.GetBytes(resp.Payload, "output.#").Int(); got != 3 {
t.Fatalf("response output length = %d, want 3; payload=%s", got, payload)
}
var foundPlain, foundNamespaced bool
- for _, item := range gjson.GetBytes(resp.Payload, "output").Array() {
+ for _, item := range ojson.GetBytes(resp.Payload, "output").Array() {
switch item.Get("type").String() {
case "function_call":
if item.Get("name").String() == "x_keyword_search" && item.Get("namespace").String() == "acme" {
@@ -2268,13 +2269,13 @@ func TestXAIExecutorExecuteStreamPreservesClientSameNameToolsWithXSearch(t *test
}
var foundPlain, foundNamespaced bool
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range strings.Split(streamText, "\n") {
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if !gjson.Valid(line) {
+ if !ojson.Valid(line) {
continue
}
- event := gjson.Parse(line)
+ event := ojson.Parse(line)
if event.Get("type").String() == "response.completed" {
completed = event
}
@@ -2374,7 +2375,7 @@ func TestXAIExecutorExecutePreservesNormalizedCustomSameNameToolWithXSearch(t *t
// Assert the client custom tool was normalized to function in the upstream request.
var foundNormalizedFunction bool
var foundRawCustom bool
- for _, tool := range gjson.GetBytes(gotBody, "tools").Array() {
+ for _, tool := range ojson.GetBytes(gotBody, "tools").Array() {
switch tool.Get("type").String() {
case "function":
if tool.Get("name").String() == "x_keyword_search" {
@@ -2400,11 +2401,11 @@ func TestXAIExecutorExecutePreservesNormalizedCustomSameNameToolWithXSearch(t *t
if strings.Contains(payload, "custom_tool_call") {
t.Fatalf("internal custom_tool_call leaked into response: %s", payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.#").Int(); got != 2 {
+ if got := ojson.GetBytes(resp.Payload, "output.#").Int(); got != 2 {
t.Fatalf("response output length = %d, want 2; payload=%s", got, payload)
}
var foundClientFunction bool
- for _, item := range gjson.GetBytes(resp.Payload, "output").Array() {
+ for _, item := range ojson.GetBytes(resp.Payload, "output").Array() {
if item.Get("type").String() == "function_call" &&
item.Get("name").String() == "x_keyword_search" &&
item.Get("call_id").String() == "call_custom" {
@@ -2464,7 +2465,7 @@ func TestXAIExecutorExecuteStreamPreservesNormalizedCustomSameNameToolWithXSearc
var foundNormalizedFunction bool
var foundRawCustom bool
- for _, tool := range gjson.GetBytes(gotBody, "tools").Array() {
+ for _, tool := range ojson.GetBytes(gotBody, "tools").Array() {
switch tool.Get("type").String() {
case "function":
if tool.Get("name").String() == "x_keyword_search" {
@@ -2500,13 +2501,13 @@ func TestXAIExecutorExecuteStreamPreservesNormalizedCustomSameNameToolWithXSearc
}
var foundClientFunction bool
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range strings.Split(streamText, "\n") {
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if !gjson.Valid(line) {
+ if !ojson.Valid(line) {
continue
}
- event := gjson.Parse(line)
+ event := ojson.Parse(line)
if event.Get("type").String() == "response.completed" {
completed = event
}
@@ -2592,7 +2593,7 @@ func TestXAIExecutorComposerSessionIsolation(t *testing.T) {
}
gotSession := prepared.sessionID
- gotPromptCacheKey := gjson.GetBytes(prepared.body, "prompt_cache_key").String()
+ gotPromptCacheKey := ojson.GetBytes(prepared.body, "prompt_cache_key").String()
httpReq, errRequest := http.NewRequest(http.MethodPost, "https://example.test/responses", bytes.NewReader(prepared.body))
if errRequest != nil {
t.Fatalf("NewRequest() error = %v", errRequest)
@@ -2704,7 +2705,7 @@ func TestXAIExecutorCompactUsesCompactEndpoint(t *testing.T) {
}
payload := []byte(`{"model":"grok-4.3","stream":true,"max_output_tokens":64,"temperature":0.3,"top_p":0.8,"stop":["END"],"input":[{"type":"compaction","encrypted_content":""},{"role":"user","content":"hello"}]}`)
- payload, _ = sjson.SetBytes(payload, "input.0.encrypted_content", validEncryptedContent)
+ payload, _ = ojson.SetBytes(payload, "input.0.encrypted_content", validEncryptedContent)
resp, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{
Model: "grok-4.3",
Payload: payload,
@@ -2726,14 +2727,14 @@ func TestXAIExecutorCompactUsesCompactEndpoint(t *testing.T) {
t.Fatalf("Accept = %q, want application/json", gotAccept)
}
for _, field := range []string{"stream", "max_output_tokens", "temperature", "top_p", "stop"} {
- if gjson.GetBytes(gotBody, field).Exists() {
+ if ojson.GetBytes(gotBody, field).Exists() {
t.Fatalf("%s exists in compact body: %s", field, string(gotBody))
}
}
- if got := gjson.GetBytes(gotBody, "top_k").Int(); got != 10 {
+ if got := ojson.GetBytes(gotBody, "top_k").Int(); got != 10 {
t.Fatalf("payload override top_k = %d, want 10", got)
}
- if got := gjson.GetBytes(gotBody, "input.0.encrypted_content").String(); got != validEncryptedContent {
+ if got := ojson.GetBytes(gotBody, "input.0.encrypted_content").String(); got != validEncryptedContent {
t.Fatalf("input.0.encrypted_content = %q, want valid sample; body=%s", got, string(gotBody))
}
if string(resp.Payload) != `{"id":"resp_1","object":"response.compaction","output":[{"type":"compaction","encrypted_content":"opaque-out"}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}` {
@@ -2779,13 +2780,13 @@ func TestXAIExecutorCompactDropsOrphanedImageGenerationToolChoice(t *testing.T)
if err != nil {
t.Fatalf("Execute compact error: %v", err)
}
- if gjson.GetBytes(gotBody, "tools").Exists() {
+ if ojson.GetBytes(gotBody, "tools").Exists() {
t.Fatalf("tools exists in compact body: %s", gotBody)
}
- if gjson.GetBytes(gotBody, "tool_choice").Exists() {
+ if ojson.GetBytes(gotBody, "tool_choice").Exists() {
t.Fatalf("orphaned tool_choice leaked into compact body: %s", gotBody)
}
- if gjson.GetBytes(gotBody, "parallel_tool_calls").Exists() {
+ if ojson.GetBytes(gotBody, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls exists in compact body: %s", gotBody)
}
}
@@ -2894,14 +2895,14 @@ func TestXAIExecutorCompactClearsReplayBeforePostCompactTurn(t *testing.T) {
}
compactEncryptedContent := testValidGrokEncryptedContentForSeed(41)
compactPayload := []byte(`{"model":"grok-4.3","prompt_cache_key":"compact-session","input":[{"type":"compaction","encrypted_content":""},{"type":"message","role":"user","content":[{"type":"input_text","text":"compact"}]}]}`)
- compactPayload, _ = sjson.SetBytes(compactPayload, "input.0.encrypted_content", compactEncryptedContent)
+ compactPayload, _ = ojson.SetBytes(compactPayload, "input.0.encrypted_content", compactEncryptedContent)
compactReq := cliproxyexecutor.Request{Model: "grok-4.3", Payload: compactPayload}
scope := xaiReasoningReplayScopeFromRequest(ctx, sdktranslator.FormatOpenAIResponse, compactReq, opts, compactPayload)
if !scope.valid() {
t.Fatal("compact replay scope must be valid")
}
reasoning := []byte(`{"type":"reasoning","summary":[],"encrypted_content":""}`)
- reasoning, _ = sjson.SetBytes(reasoning, "encrypted_content", testValidGrokEncryptedContentForSeed(42))
+ reasoning, _ = ojson.SetBytes(reasoning, "encrypted_content", testValidGrokEncryptedContentForSeed(42))
if !internalcache.CacheXAIReasoningReplayItems(scope.modelName, scope.sessionKey, [][]byte{
reasoning,
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"pre-compact answer"}]}`),
@@ -2917,7 +2918,7 @@ func TestXAIExecutorCompactClearsReplayBeforePostCompactTurn(t *testing.T) {
}
postCompactPayload := []byte(`{"model":"grok-4.3","prompt_cache_key":"compact-session","input":[{"type":"compaction","encrypted_content":""},{"type":"message","role":"user","content":[{"type":"input_text","text":"after compact"}]}]}`)
- postCompactPayload, _ = sjson.SetBytes(postCompactPayload, "input.0.encrypted_content", compactEncryptedContent)
+ postCompactPayload, _ = ojson.SetBytes(postCompactPayload, "input.0.encrypted_content", compactEncryptedContent)
prepared, errPrepare := exec.prepareResponsesRequest(ctx, cliproxyexecutor.Request{
Model: "grok-4.3",
Payload: postCompactPayload,
@@ -2928,7 +2929,7 @@ func TestXAIExecutorCompactClearsReplayBeforePostCompactTurn(t *testing.T) {
if errPrepare != nil {
t.Fatalf("prepare post-compact request: %v", errPrepare)
}
- input := gjson.GetBytes(prepared.body, "input").Array()
+ input := ojson.GetBytes(prepared.body, "input").Array()
if len(input) != 2 || input[0].Get("type").String() != "compaction" || input[1].Get("role").String() != "user" {
t.Fatalf("post-compact input contains stale replay state: %s", prepared.body)
}
@@ -2959,7 +2960,7 @@ func TestXAIExecutorCompactFailureRetainsReplay(t *testing.T) {
req := cliproxyexecutor.Request{Model: "grok-4.3", Payload: payload}
scope := xaiReasoningReplayScopeFromRequest(ctx, sdktranslator.FormatOpenAIResponse, req, opts, payload)
reasoning := []byte(`{"type":"reasoning","summary":[],"encrypted_content":""}`)
- reasoning, _ = sjson.SetBytes(reasoning, "encrypted_content", testValidGrokEncryptedContentForSeed(43))
+ reasoning, _ = ojson.SetBytes(reasoning, "encrypted_content", testValidGrokEncryptedContentForSeed(43))
if !internalcache.CacheXAIReasoningReplayItems(scope.modelName, scope.sessionKey, [][]byte{reasoning}) {
t.Fatal("failed to seed xAI replay cache")
}
@@ -3018,7 +3019,7 @@ func TestXAIExecutorExecuteStreamCompactionTriggerUsesCompactEndpoint(t *testing
if xaiInputHasItemType(gotBody, "compaction_trigger") {
t.Fatalf("compaction_trigger reached xai compact body: %s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "stream").Exists() {
+ if ojson.GetBytes(gotBody, "stream").Exists() {
t.Fatalf("stream exists in compact body: %s", string(gotBody))
}
@@ -3084,7 +3085,7 @@ func TestXAIExecutorOmitsUnsupportedReasoningEffort(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if gjson.GetBytes(gotBody, "reasoning").Exists() {
+ if ojson.GetBytes(gotBody, "reasoning").Exists() {
t.Fatalf("unsupported xAI model must omit reasoning key: %s", string(gotBody))
}
}
@@ -3162,7 +3163,7 @@ func TestXAIExecutorThinkingPayloadOverride(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
prepared.body = prepared.finalizePayload(prepared.body)
- if got := gjson.GetBytes(prepared.body, "reasoning.effort").String(); got != wantEffort {
+ if got := ojson.GetBytes(prepared.body, "reasoning.effort").String(); got != wantEffort {
t.Fatalf("reasoning.effort = %q, want %q; body=%s", got, wantEffort, prepared.body)
}
})
@@ -3204,10 +3205,10 @@ func TestXAIExecutorKeepsReasoningEffortForGrok45(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "grok-4.5" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "grok-4.5" {
t.Fatalf("model = %q, want grok-4.5; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytes(gotBody, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high; body=%s", got, string(gotBody))
}
}
@@ -3255,7 +3256,7 @@ func TestXAIExecutorKeepsPayloadOverrideReasoningEffortForGrok45(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(gotBody, "reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytes(gotBody, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high from payload.override; body=%s", got, string(gotBody))
}
}
@@ -3294,10 +3295,10 @@ func TestXAIExecutorAppliesThinkingSuffix(t *testing.T) {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(gotBody, "model").String(); got != "grok-4.3" {
+ if got := ojson.GetBytes(gotBody, "model").String(); got != "grok-4.3" {
t.Fatalf("model = %q, want grok-4.3; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "reasoning.effort").String(); got != "low" {
+ if got := ojson.GetBytes(gotBody, "reasoning.effort").String(); got != "low" {
t.Fatalf("reasoning.effort = %q, want low; body=%s", got, string(gotBody))
}
}
@@ -3343,26 +3344,26 @@ func TestXAIExecutorExecuteStreamFiltersToolSearchTool(t *testing.T) {
if !bytes.Contains(output, []byte(`"type":"custom_tool_call"`)) || !bytes.Contains(output, []byte(`"input":"p"`)) || !bytes.Contains(output, []byte("response.custom_tool_call_input.done")) {
t.Fatalf("retained patch was not actually bridged: %s", output)
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
if len(tools) != 7 {
t.Fatalf("tools length = %d, want 7; body=%s", len(tools), string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.0.content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.content").Exists() {
t.Fatalf("input.0.content exists, want removed; body=%s", string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
+ if ojson.GetBytes(gotBody, "input.0.encrypted_content").Exists() {
t.Fatalf("input.0.encrypted_content exists, want removed; body=%s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
+ if got := ojson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "test" {
t.Fatalf("input.0.summary.0.text = %q, want test; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
+ if got := ojson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
t.Fatalf("input.0.summary.1.text = %q, want second; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.2.summary.0.text").String(); got != "separate" {
+ if got := ojson.GetBytes(gotBody, "input.2.summary.0.text").String(); got != "separate" {
t.Fatalf("input.2.summary.0.text = %q, want separate; body=%s", got, string(gotBody))
}
foundAutomationUpdate := false
@@ -3515,13 +3516,13 @@ func TestXAIExecutorExecuteNormalizesReasoningOutputForNonStreamTranslation(t *t
if strings.Contains(string(resp.Payload), "reasoning_text") {
t.Fatalf("payload contains xAI reasoning_text shape: %s", string(resp.Payload))
}
- if got := gjson.GetBytes(resp.Payload, "response.output.0.summary.0.type").String(); got != "summary_text" {
+ if got := ojson.GetBytes(resp.Payload, "response.output.0.summary.0.type").String(); got != "summary_text" {
t.Fatalf("response.output.0.summary.0.type = %q, want summary_text; payload=%s", got, string(resp.Payload))
}
- if got := gjson.GetBytes(resp.Payload, "response.output.0.summary.0.text").String(); got != "thinking" {
+ if got := ojson.GetBytes(resp.Payload, "response.output.0.summary.0.text").String(); got != "thinking" {
t.Fatalf("response.output.0.summary.0.text = %q, want thinking; payload=%s", got, string(resp.Payload))
}
- if gjson.GetBytes(resp.Payload, "response.output.0.content").Exists() {
+ if ojson.GetBytes(resp.Payload, "response.output.0.content").Exists() {
t.Fatalf("reasoning output content exists, want summary only: %s", string(resp.Payload))
}
}
@@ -3597,7 +3598,7 @@ func TestXAIExecutorExecuteImagesUsesImagesEndpointAndPublishesUsage(t *testing.
if string(gotBody) != `{"model":"grok-imagine-image-quality","prompt":"draw"}` {
t.Fatalf("body = %s", string(gotBody))
}
- if gjson.GetBytes(resp.Payload, "data.0.b64_json").String() != "AA==" {
+ if ojson.GetBytes(resp.Payload, "data.0.b64_json").String() != "AA==" {
t.Fatalf("payload = %s", string(resp.Payload))
}
@@ -3834,13 +3835,13 @@ func TestXAIExecutorExecuteImagesPayloadDefaultUsesOriginalRequest(t *testing.T)
t.Fatalf("Execute() error = %v", errExec)
}
- if got := gjson.GetBytes(gotBody, "response_format").String(); got != "url" {
+ if got := ojson.GetBytes(gotBody, "response_format").String(); got != "url" {
t.Fatalf("response_format = %q, want url; body=%s", got, gotBody)
}
- if got := gjson.GetBytes(gotBody, "aspect_ratio").String(); got != "16:9" {
+ if got := ojson.GetBytes(gotBody, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("aspect_ratio = %q, want 16:9; body=%s", got, gotBody)
}
- if got := gjson.GetBytes(gotBody, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytes(gotBody, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k; body=%s", got, gotBody)
}
}
@@ -3859,40 +3860,40 @@ func TestNormalizeXAIImageRefsRewritesImageURLField(t *testing.T) {
}`)
out := normalizeXAIImageRefs(in)
- if got := gjson.GetBytes(out, "image.url").String(); got != "https://example.com/a.png" {
+ if got := ojson.GetBytes(out, "image.url").String(); got != "https://example.com/a.png" {
t.Fatalf("image.url = %q, want https://example.com/a.png; body=%s", got, out)
}
- if gjson.GetBytes(out, "image.image_url").Exists() {
+ if ojson.GetBytes(out, "image.image_url").Exists() {
t.Fatalf("image.image_url should be removed; body=%s", out)
}
- if got := gjson.GetBytes(out, "image.type").String(); got != "image_url" {
+ if got := ojson.GetBytes(out, "image.type").String(); got != "image_url" {
t.Fatalf("image.type = %q, want image_url; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "images.0.url").String(); got != "https://example.com/b.png" {
+ if got := ojson.GetBytes(out, "images.0.url").String(); got != "https://example.com/b.png" {
t.Fatalf("images.0.url = %q, want https://example.com/b.png; body=%s", got, out)
}
- if gjson.GetBytes(out, "images.0.image_url").Exists() {
+ if ojson.GetBytes(out, "images.0.image_url").Exists() {
t.Fatalf("images.0.image_url should be removed; body=%s", out)
}
- if got := gjson.GetBytes(out, "images.1.url").String(); got != "https://example.com/c.png" {
+ if got := ojson.GetBytes(out, "images.1.url").String(); got != "https://example.com/c.png" {
t.Fatalf("images.1.url = %q, want existing url kept; body=%s", got, out)
}
- if gjson.GetBytes(out, "images.1.image_url").Exists() {
+ if ojson.GetBytes(out, "images.1.image_url").Exists() {
t.Fatalf("images.1.image_url should be removed when url already set; body=%s", out)
}
- if got := gjson.GetBytes(out, "reference_images.0.url").String(); got != "https://example.com/d.png" {
+ if got := ojson.GetBytes(out, "reference_images.0.url").String(); got != "https://example.com/d.png" {
t.Fatalf("reference_images.0.url = %q, want https://example.com/d.png; body=%s", got, out)
}
- if gjson.GetBytes(out, "reference_images.0.image_url").Exists() {
+ if ojson.GetBytes(out, "reference_images.0.image_url").Exists() {
t.Fatalf("reference_images.0.image_url should be removed; body=%s", out)
}
- if got := gjson.GetBytes(out, "nested.image.url").String(); got != "https://example.com/e.png" {
+ if got := ojson.GetBytes(out, "nested.image.url").String(); got != "https://example.com/e.png" {
t.Fatalf("nested.image.url = %q, want https://example.com/e.png; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "content.0.image_url.url").String(); got != "https://example.com/keep.png" {
+ if got := ojson.GetBytes(out, "content.0.image_url.url").String(); got != "https://example.com/keep.png" {
t.Fatalf("chat content image_url.url should be preserved, got %q; body=%s", got, out)
}
- if gjson.GetBytes(out, "content.0.url").Exists() {
+ if ojson.GetBytes(out, "content.0.url").Exists() {
t.Fatalf("chat content parts must not be rewritten to url; body=%s", out)
}
}
@@ -3933,6 +3934,131 @@ func TestNormalizeXAIImageRefsSupportsSpecialJSONKeys(t *testing.T) {
}
}
+func TestNormalizeXAIImageRefsDocSplitTailFields(t *testing.T) {
+ t.Parallel()
+
+ t.Run("preserves_unparsed_tail_url_when_image_url_edited", func(t *testing.T) {
+ doc := ojson.NewDoc([]byte(`{"image":{"image_url":"old","url":"keep"}}`))
+ _ = doc.Set("image.image_url", "new")
+ if !normalizeXAIImageRefsDoc(doc) {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "image.url").String(); got != "keep" {
+ t.Fatalf("image.url = %q, want keep; body=%s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "image.image_url").Exists() {
+ t.Fatalf("image.image_url should be deleted; body=%s", out)
+ }
+ })
+
+ t.Run("normalizes_unparsed_tail_image_url_after_earlier_sibling_edit", func(t *testing.T) {
+ doc := ojson.NewDoc([]byte(`{"image":{"a":1,"image_url":"new"}}`))
+ _ = doc.Set("image.a", 2)
+ if !normalizeXAIImageRefsDoc(doc) {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "image.a").Int(); got != 2 {
+ t.Fatalf("image.a = %d, want 2; body=%s", got, out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "image.url").String(); got != "new" {
+ t.Fatalf("image.url = %q, want new; body=%s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "image.image_url").Exists() {
+ t.Fatalf("image.image_url should be deleted; body=%s", out)
+ }
+ })
+
+ t.Run("normalizes_unparsed_tail_array_elements_in_images", func(t *testing.T) {
+ doc := ojson.NewDoc([]byte(`{"images":[{"image_url":"first"},{"image_url":"second"}]}`))
+ _ = doc.Set("images.0.image_url", "updated")
+ if !normalizeXAIImageRefsDoc(doc) {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "images.0.url").String(); got != "updated" {
+ t.Fatalf("images.0.url = %q, want updated; body=%s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "images.0.image_url").Exists() {
+ t.Fatalf("images.0.image_url should be deleted; body=%s", out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "images.1.url").String(); got != "second" {
+ t.Fatalf("images.1.url = %q, want second; body=%s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "images.1.image_url").Exists() {
+ t.Fatalf("images.1.image_url should be deleted; body=%s", out)
+ }
+ })
+}
+
+func TestNormalizeXAIImageRefsDocLargeInlineImageAllocations(t *testing.T) {
+ dataURI := "data:image/png;base64," + strings.Repeat("A", 1<<20)
+ warmDoc := ojson.NewDoc([]byte(`{"image":{"url":"https://example.com/warm.png"}}`))
+
+ t.Run("already_normalized_1MiB_inline_image_allocates_under_4KiB", func(t *testing.T) {
+ raw := []byte(`{"image":{"url":"` + dataURI + `"}}`)
+ doc := ojson.NewDoc(raw)
+
+ var mBefore, mAfter runtime.MemStats
+ runtime.GC()
+ normalizeXAIImageRefsDoc(warmDoc)
+ runtime.ReadMemStats(&mBefore)
+ changed := normalizeXAIImageRefsDoc(doc)
+ runtime.ReadMemStats(&mAfter)
+
+ if changed {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report false for already-normalized inline image")
+ }
+ allocated := mAfter.TotalAlloc - mBefore.TotalAlloc
+ if allocated >= 32<<10 {
+ t.Fatalf("already-normalized 1MiB inline image allocated %d bytes, want < 32 KiB", allocated)
+ }
+ })
+
+ t.Run("unnormalized_1MiB_inline_image_avoids_scanner_buffering", func(t *testing.T) {
+ raw := []byte(`{"image":{"image_url":"` + dataURI + `"}}`)
+ doc := ojson.NewDoc(raw)
+
+ var mBefore, mAfter runtime.MemStats
+ runtime.GC()
+ normalizeXAIImageRefsDoc(warmDoc)
+ runtime.ReadMemStats(&mBefore)
+ changed := normalizeXAIImageRefsDoc(doc)
+ runtime.ReadMemStats(&mAfter)
+
+ if !changed {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true for unnormalized inline image")
+ }
+ allocated := mAfter.TotalAlloc - mBefore.TotalAlloc
+ if allocated >= 2200*1024 {
+ t.Fatalf("unnormalized 1MiB inline image allocated %d bytes, want < 2.2 MiB", allocated)
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "image.url").String(); len(got) != len(dataURI) {
+ t.Fatalf("len(image.url) = %d, want %d", len(got), len(dataURI))
+ }
+ if ojson.GetBytesNoCopy(out, "image.image_url").Exists() {
+ t.Fatal("expected image.image_url to be deleted")
+ }
+ })
+
+ t.Run("long_whitespace_outer_url_does_not_skip_nested_image_in_image_url", func(t *testing.T) {
+ raw := []byte(`{"image":{"url":"` + strings.Repeat(" ", 300) + `","image_url":{"image":{"image_url":"https://example.com/inner.png"}}}}`)
+ doc := ojson.NewDoc(raw)
+ if !normalizeXAIImageRefsDoc(doc) {
+ t.Fatal("expected normalizeXAIImageRefsDoc to report true")
+ }
+ out := doc.Bytes()
+ if got := ojson.GetBytesNoCopy(out, "image.image_url.image.url").String(); got != "https://example.com/inner.png" {
+ t.Fatalf("image.image_url.image.url = %q, want https://example.com/inner.png; body=%s", got, out)
+ }
+ if ojson.GetBytesNoCopy(out, "image.image_url.image.image_url").Exists() {
+ t.Fatalf("expected inner image_url to be deleted: %s", out)
+ }
+ })
+}
+
func TestXAIExecutorExecuteImagesRewritesImageURLToURL(t *testing.T) {
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -3965,10 +4091,10 @@ func TestXAIExecutorExecuteImagesRewritesImageURLToURL(t *testing.T) {
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(gotBody, "image.url").String(); got != "https://example.com/a.png" {
+ if got := ojson.GetBytes(gotBody, "image.url").String(); got != "https://example.com/a.png" {
t.Fatalf("upstream image.url = %q, want https://example.com/a.png; body=%s", got, gotBody)
}
- if gjson.GetBytes(gotBody, "image.image_url").Exists() {
+ if ojson.GetBytes(gotBody, "image.image_url").Exists() {
t.Fatalf("upstream body still has image.image_url: %s", gotBody)
}
}
@@ -4037,7 +4163,7 @@ func TestXAIExecutorExecuteVideosCreate(t *testing.T) {
if string(gotBody) != `{"model":"grok-imagine-video","prompt":"animate","duration":4}` {
t.Fatalf("body = %s", string(gotBody))
}
- if gjson.GetBytes(resp.Payload, "request_id").String() != "vid_123" {
+ if ojson.GetBytes(resp.Payload, "request_id").String() != "vid_123" {
t.Fatalf("payload = %s", string(resp.Payload))
}
@@ -4170,7 +4296,7 @@ func TestXAIExecutorExecuteVideosRetrieve(t *testing.T) {
if gotPath != "/videos/vid_123" {
t.Fatalf("path = %q, want /videos/vid_123", gotPath)
}
- if gjson.GetBytes(resp.Payload, "video.url").String() != "https://vidgen.x.ai/video.mp4" {
+ if ojson.GetBytes(resp.Payload, "video.url").String() != "https://vidgen.x.ai/video.mp4" {
t.Fatalf("payload = %s", string(resp.Payload))
}
}
@@ -4273,8 +4399,8 @@ func TestXAIExecutorPrepareResponsesRequest_SimplifiesMCPCodexAppAutomationUpdat
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- var autoTool gjson.Result
- for _, tool := range gjson.GetBytes(prepared.body, "tools").Array() {
+ var autoTool ojson.Result
+ for _, tool := range ojson.GetBytes(prepared.body, "tools").Array() {
if tool.Get("name").String() == "mcp__codex_app__automation_update" {
autoTool = tool
break
@@ -4289,7 +4415,7 @@ func TestXAIExecutorPrepareResponsesRequest_SimplifiesMCPCodexAppAutomationUpdat
if autoTool.Get("parameters.oneOf").Exists() {
t.Fatalf("parameters.oneOf should be removed, got: %s", autoTool.Get("parameters").Raw)
}
- if autoTool.Get("parameters.additionalProperties").Type != gjson.True {
+ if autoTool.Get("parameters.additionalProperties").Type != ojson.True {
t.Fatalf("parameters.additionalProperties = %v, want true", autoTool.Get("parameters.additionalProperties").Raw)
}
}
@@ -4300,7 +4426,7 @@ func TestNormalizeXAITools_SimplifiesCodexAppAutomationUpdateSchema(t *testing.T
body := []byte(`{"model":"grok-4.5","tools":[{"type":"namespace","name":"mcp__codex_app","tools":[{"type":"function","name":"automation_update","description":"sched","strict":true,"parameters":` + params + `}]},{"type":"function","name":"exec_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}]}`)
out := normalizeXAITools(body)
- tools := gjson.GetBytes(out, "tools")
+ tools := ojson.GetBytes(out, "tools")
if !tools.IsArray() {
t.Fatalf("tools missing: %s", string(out))
}
@@ -4317,10 +4443,10 @@ func TestNormalizeXAITools_SimplifiesCodexAppAutomationUpdateSchema(t *testing.T
if tool.Get("parameters.type").String() != "object" {
t.Fatalf("automation_update parameters.type = %q, want object", tool.Get("parameters.type").String())
}
- if tool.Get("parameters.additionalProperties").Type != gjson.True {
+ if tool.Get("parameters.additionalProperties").Type != ojson.True {
t.Fatalf("automation_update parameters should allow additionalProperties: %s", paramsRaw)
}
- if tool.Get("strict").Type != gjson.False {
+ if tool.Get("strict").Type != ojson.False {
t.Fatalf("automation_update strict = %s, want false", tool.Get("strict").Raw)
}
case "exec_command":
@@ -4346,7 +4472,7 @@ func TestXAIExecutorPrepareResponsesRequestPreservesCodexNumberToolSchemas(t *te
t.Fatalf("prepareResponsesRequest() error = %v", err)
}
for _, path := range []string{"tools.0.parameters.properties.yield_time_ms.type", "tools.1.parameters.properties.yield_time_ms.type"} {
- if got := gjson.GetBytes(prepared.body, path).String(); got != "integer" {
+ if got := ojson.GetBytes(prepared.body, path).String(); got != "integer" {
t.Errorf("%s = %q, want integer; body=%s", path, got, prepared.body)
}
}
@@ -4356,7 +4482,7 @@ func TestNormalizeXAITools_SimplifiesFlattenedAndInvalidRootSchemas(t *testing.T
body := []byte(`{"tools":[{"type":"function","name":"codex_app__automation_update","strict":true,"parameters":{"oneOf":[{"type":"object","properties":{"action":{"type":"string"}},"required":["action"]},{"type":"null"}]}},{"type":"function","name":"nullable_lookup","strict":true,"parameters":{"anyOf":[{"type":"object","properties":{"query":{"type":"string"}}},{"type":["object","null"]}]}},{"type":"custom","name":"nullable_custom","strict":true,"parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}},{"type":"function","name":"mixed_nullable","strict":true,"parameters":{"type":"object","oneOf":[{"required":["query"]},{"type":"null"}],"properties":{"query":{"type":"string"}}}},{"type":"function","name":"array_root_union","strict":true,"parameters":{"type":["object"],"anyOf":[{"required":["query"]},{"required":["id"]}],"properties":{"query":{"type":"string"},"id":{"type":"integer"}}}},{"type":"function","name":"echo_tool","strict":true,"parameters":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"],"additionalProperties":false}}]}`)
out := normalizeXAITools(body)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 6 {
t.Fatalf("tools length = %d, want 6; body=%s", len(tools), string(out))
}
@@ -4371,10 +4497,10 @@ func TestNormalizeXAITools_SimplifiesFlattenedAndInvalidRootSchemas(t *testing.T
if got := tool.Get("parameters.type").String(); got != "object" {
t.Fatalf("tools.%d parameters.type = %q, want object; body=%s", index, got, string(out))
}
- if tool.Get("parameters.additionalProperties").Type != gjson.True {
+ if tool.Get("parameters.additionalProperties").Type != ojson.True {
t.Fatalf("tools.%d parameters should allow additionalProperties: %s", index, string(out))
}
- if tool.Get("strict").Type != gjson.False {
+ if tool.Get("strict").Type != ojson.False {
t.Fatalf("tools.%d strict = %s, want false; body=%s", index, tool.Get("strict").Raw, string(out))
}
}
@@ -4383,10 +4509,10 @@ func TestNormalizeXAITools_SimplifiesFlattenedAndInvalidRootSchemas(t *testing.T
if got := echoTool.Get("parameters.properties.message.type").String(); got != "string" {
t.Fatalf("echo_tool schema changed, message type = %q; body=%s", got, string(out))
}
- if echoTool.Get("strict").Type != gjson.True {
+ if echoTool.Get("strict").Type != ojson.True {
t.Fatalf("echo_tool strict changed: %s", string(out))
}
- if echoTool.Get("parameters.additionalProperties").Type != gjson.False {
+ if echoTool.Get("parameters.additionalProperties").Type != ojson.False {
t.Fatalf("echo_tool additionalProperties changed: %s", string(out))
}
}
@@ -4436,7 +4562,7 @@ func TestNormalizeXAITools_InlinesLocalRefs(t *testing.T) {
}`)
out := normalizeXAITools(body)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), string(out))
}
@@ -4516,7 +4642,7 @@ func TestNormalizeXAITools_AddsObjectTypeToRootUnionBranches(t *testing.T) {
out := normalizeXAITools(body)
for toolIndex, unionName := range []string{"oneOf", "anyOf"} {
- tool := gjson.GetBytes(out, fmt.Sprintf("tools.%d", toolIndex))
+ tool := ojson.GetBytes(out, fmt.Sprintf("tools.%d", toolIndex))
branches := tool.Get("parameters." + unionName).Array()
if len(branches) != 2 {
t.Fatalf("tools.%d %s branch count = %d, want 2; body=%s", toolIndex, unionName, len(branches), string(out))
@@ -4526,13 +4652,13 @@ func TestNormalizeXAITools_AddsObjectTypeToRootUnionBranches(t *testing.T) {
t.Fatalf("tools.%d parameters.%s.%d.type = %q, want object; body=%s", toolIndex, unionName, branchIndex, got, string(out))
}
}
- if tool.Get("strict").Type != gjson.True {
+ if tool.Get("strict").Type != ojson.True {
t.Fatalf("tools.%d strict changed: %s", toolIndex, string(out))
}
}
- cropParameters := gjson.GetBytes(out, "tools.0.parameters")
- if cropParameters.Get("additionalProperties").Type != gjson.False {
+ cropParameters := ojson.GetBytes(out, "tools.0.parameters")
+ if cropParameters.Get("additionalProperties").Type != ojson.False {
t.Fatalf("crop additionalProperties changed: %s", cropParameters.Raw)
}
if got := cropParameters.Get("required.#").Int(); got != 2 {
@@ -4545,7 +4671,7 @@ func TestNormalizeXAITools_AddsObjectTypeToRootUnionBranches(t *testing.T) {
t.Fatalf("nested union branch must not be changed: %s", cropParameters.Raw)
}
- customTool := gjson.GetBytes(out, "tools.2")
+ customTool := ojson.GetBytes(out, "tools.2")
if got := customTool.Get("type").String(); got != xaiFunctionToolType {
t.Fatalf("custom tool type = %q, want function; body=%s", got, string(out))
}
@@ -4554,7 +4680,7 @@ func TestNormalizeXAITools_AddsObjectTypeToRootUnionBranches(t *testing.T) {
t.Fatalf("custom tool oneOf.%d.type = %q, want object; body=%s", branchIndex, got, string(out))
}
}
- if customTool.Get("strict").Type != gjson.True {
+ if customTool.Get("strict").Type != ojson.True {
t.Fatalf("custom tool strict changed: %s", string(out))
}
}
@@ -4568,7 +4694,7 @@ func TestNormalizeXAITools_QualifiesSameNamedNamespaceTools(t *testing.T) {
}`)
out := normalizeXAITools(body)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), string(out))
}
@@ -4595,7 +4721,7 @@ func TestNormalizeXAITools_WhenFlattenedCountExceedsLimit_FoldsNamespaces(t *tes
refs := collectXAINamespaceToolRefs(body)
out := normalizeXAITools(body)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) > 200 {
t.Fatalf("tools length = %d, must be <= 200; body length=%d", len(tools), len(out))
}
@@ -4613,26 +4739,26 @@ func TestNormalizeXAITools_WhenFlattenedCountExceedsLimit_FoldsNamespaces(t *tes
// Verify restore of a dispatcher tool call
event := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"mcp__app_0","call_id":"call_1","arguments":"{\"name\":\"tool_3\",\"arguments\":{\"p\":\"val\"}}"}}`)
restored := restoreXAINamespaceToolCalls(event, refs)
- if got := gjson.GetBytes(restored, "item.name").String(); got != "tool_3" {
+ if got := ojson.GetBytes(restored, "item.name").String(); got != "tool_3" {
t.Fatalf("restored item.name = %q, want tool_3; event=%s", got, string(restored))
}
- if got := gjson.GetBytes(restored, "item.namespace").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(restored, "item.namespace").String(); got != "mcp__app_0" {
t.Fatalf("restored item.namespace = %q, want mcp__app_0; event=%s", got, string(restored))
}
- if got := gjson.GetBytes(restored, "item.arguments").String(); got != `{"p":"val"}` {
+ if got := ojson.GetBytes(restored, "item.arguments").String(); got != `{"p":"val"}` {
t.Fatalf("restored item.arguments = %q, want {\"p\":\"val\"}; event=%s", got, string(restored))
}
// Verify normalization of historical tool call in next turn
turn2Body := []byte(fmt.Sprintf(`{"tools":[%s],"input":[{"type":"function_call","name":"tool_3","namespace":"mcp__app_0","call_id":"call_1","arguments":"{\"p\":\"val\"}"}]}`, strings.Join(nsList, ",")))
turn2Normalized := normalizeXAIInputNamespaceToolCalls(turn2Body)
- if got := gjson.GetBytes(turn2Normalized, "input.0.name").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(turn2Normalized, "input.0.name").String(); got != "mcp__app_0" {
t.Fatalf("turn2 normalized name = %q, want mcp__app_0; body=%s", got, string(turn2Normalized))
}
- if gjson.GetBytes(turn2Normalized, "input.0.namespace").Exists() {
+ if ojson.GetBytes(turn2Normalized, "input.0.namespace").Exists() {
t.Fatalf("turn2 namespace should be removed; body=%s", string(turn2Normalized))
}
- if got := gjson.GetBytes(turn2Normalized, "input.0.arguments").String(); got != `{"arguments":{"p":"val"},"name":"tool_3"}` && got != `{"name":"tool_3","arguments":{"p":"val"}}` {
+ if got := ojson.GetBytes(turn2Normalized, "input.0.arguments").String(); got != `{"arguments":{"p":"val"},"name":"tool_3"}` && got != `{"name":"tool_3","arguments":{"p":"val"}}` {
t.Fatalf("turn2 normalized arguments = %q; body=%s", got, string(turn2Normalized))
}
}
@@ -4652,10 +4778,10 @@ func TestNormalizeXAINamespaceToolChoice_WhenFolding(t *testing.T) {
out := normalizeXAITools(body)
out = normalizeXAINamespaceToolChoice(out)
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != "mcp__app_0" {
t.Fatalf("tool_choice.name = %q, want mcp__app_0; body=%s", got, string(out))
}
- if gjson.GetBytes(out, "tool_choice.namespace").Exists() {
+ if ojson.GetBytes(out, "tool_choice.namespace").Exists() {
t.Fatalf("tool_choice.namespace should be removed; body=%s", string(out))
}
}
@@ -4668,33 +4794,33 @@ func TestRestoreXAINamespaceToolCalls_DispatcherVariants(t *testing.T) {
// Variant 1: arguments flattened in root
event1 := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"mcp__app_0","call_id":"c1","arguments":"{\"name\":\"tool_a\",\"arg1\":\"val1\"}"}}`)
restored1 := restoreXAINamespaceToolCalls(event1, refs)
- if got := gjson.GetBytes(restored1, "item.name").String(); got != "tool_a" {
+ if got := ojson.GetBytes(restored1, "item.name").String(); got != "tool_a" {
t.Fatalf("variant 1 name = %q, want tool_a", got)
}
- if got := gjson.GetBytes(restored1, "item.namespace").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(restored1, "item.namespace").String(); got != "mcp__app_0" {
t.Fatalf("variant 1 namespace = %q, want mcp__app_0", got)
}
- if got := gjson.GetBytes(restored1, "item.arguments").String(); got != `{"arg1":"val1"}` {
+ if got := ojson.GetBytes(restored1, "item.arguments").String(); got != `{"arg1":"val1"}` {
t.Fatalf("variant 1 arguments = %q, want {\"arg1\":\"val1\"}", got)
}
// Variant 2: arguments in nested string
event2 := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"mcp__app_0","call_id":"c2","arguments":"{\"name\":\"tool_b\",\"arguments\":\"{\\\"k\\\":\\\"v\\\"}\"}"}}`)
restored2 := restoreXAINamespaceToolCalls(event2, refs)
- if got := gjson.GetBytes(restored2, "item.name").String(); got != "tool_b" {
+ if got := ojson.GetBytes(restored2, "item.name").String(); got != "tool_b" {
t.Fatalf("variant 2 name = %q, want tool_b", got)
}
- if got := gjson.GetBytes(restored2, "item.arguments").String(); got != `{"k":"v"}` {
+ if got := ojson.GetBytes(restored2, "item.arguments").String(); got != `{"k":"v"}` {
t.Fatalf("variant 2 arguments = %q, want {\"k\":\"v\"}", got)
}
// Variant 3: no arguments property
event3 := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"mcp__app_0","call_id":"c3","arguments":"{\"name\":\"tool_c\"}"}}`)
restored3 := restoreXAINamespaceToolCalls(event3, refs)
- if got := gjson.GetBytes(restored3, "item.name").String(); got != "tool_c" {
+ if got := ojson.GetBytes(restored3, "item.name").String(); got != "tool_c" {
t.Fatalf("variant 3 name = %q, want tool_c", got)
}
- if got := gjson.GetBytes(restored3, "item.arguments").String(); got != `{}` {
+ if got := ojson.GetBytes(restored3, "item.arguments").String(); got != `{}` {
t.Fatalf("variant 3 arguments = %q, want {}", got)
}
}
@@ -4707,22 +4833,22 @@ func TestRestoreXAINamespaceToolCalls_FunctionCallArgumentsDone(t *testing.T) {
addedEvent := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"item_1","type":"function_call","name":"mcp__app_0"}}`)
restoredAdded := restorer.restore(addedEvent)
- if got := gjson.GetBytes(restoredAdded, "item.namespace").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(restoredAdded, "item.namespace").String(); got != "mcp__app_0" {
t.Fatalf("restored item.namespace = %q, want mcp__app_0", got)
}
doneArgsEvent := []byte(`{"type":"response.function_call_arguments.done","item_id":"item_1","output_index":0,"arguments":"{\"name\":\"tool_x\",\"arguments\":{\"count\":42}}"}`)
restoredArgs := restorer.restore(doneArgsEvent)
- if got := gjson.GetBytes(restoredArgs, "arguments").String(); got != `{"count":42}` {
+ if got := ojson.GetBytes(restoredArgs, "arguments").String(); got != `{"count":42}` {
t.Fatalf("restored arguments.done = %q, want {\"count\":42}", got)
}
doneItemEvent := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"item_1","type":"function_call","name":"mcp__app_0","arguments":"{\"name\":\"tool_x\",\"arguments\":{\"count\":42}}"}}`)
restoredItem := restorer.restore(doneItemEvent)
- if got := gjson.GetBytes(restoredItem, "item.name").String(); got != "tool_x" {
+ if got := ojson.GetBytes(restoredItem, "item.name").String(); got != "tool_x" {
t.Fatalf("restored item.name = %q, want tool_x", got)
}
- if got := gjson.GetBytes(restoredItem, "item.namespace").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(restoredItem, "item.namespace").String(); got != "mcp__app_0" {
t.Fatalf("restored item.namespace = %q, want mcp__app_0", got)
}
}
@@ -4739,7 +4865,7 @@ func TestNormalizeXAIInputNamespaceToolCalls_PreservesLargeInts(t *testing.T) {
body := []byte(fmt.Sprintf(`{"tools":[%s],"input":[{"type":"function_call","name":"tool_1","namespace":"mcp__app_0","call_id":"c1","arguments":"{\"large_id\":9223372036854775807}"}]}`, strings.Join(nsList, ",")))
normalized := normalizeXAIInputNamespaceToolCalls(body)
- args := gjson.GetBytes(normalized, "input.0.arguments").String()
+ args := ojson.GetBytes(normalized, "input.0.arguments").String()
if !strings.Contains(args, "9223372036854775807") {
t.Fatalf("large int lost precision in normalized arguments: %s", args)
}
@@ -4754,14 +4880,14 @@ func TestRestoreXAINamespaceToolCalls_FoldModePreservesNonDispatcherArgumentsDon
// Step 1: added event for a non-dispatcher tool (e.g. web_search or regular function)
addedNonDisp := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"non_disp_1","type":"function_call","name":"web_search"}}`)
restoredAdded := restorer.restore(addedNonDisp)
- if got := gjson.GetBytes(restoredAdded, "item.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(restoredAdded, "item.name").String(); got != "web_search" {
t.Fatalf("restored name = %q, want web_search", got)
}
// Step 2: arguments.done for non_disp_1 containing a "name" property
doneNonDisp := []byte(`{"type":"response.function_call_arguments.done","item_id":"non_disp_1","output_index":0,"arguments":"{\"name\":\"golang\",\"query\":\"test\"}"}`)
restoredDone := restorer.restore(doneNonDisp)
- if got := gjson.GetBytes(restoredDone, "arguments").String(); got != `{"name":"golang","query":"test"}` {
+ if got := ojson.GetBytes(restoredDone, "arguments").String(); got != `{"name":"golang","query":"test"}` {
t.Fatalf("non-dispatcher arguments.done was incorrectly mutated: %s", got)
}
}
@@ -4792,7 +4918,7 @@ func TestPrepareResponsesRequest_CapsAt200WithInjectXSearch(t *testing.T) {
t.Fatalf("prepareResponsesRequestTo error = %v", err)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) > 200 {
t.Fatalf("prepared tools length = %d, want <= 200", len(tools))
}
@@ -4821,7 +4947,7 @@ func TestRestoreXAINamespaceToolCalls_FlattenModePreservesNameInArgumentsDone(t
event := []byte(`{"type":"response.function_call_arguments.done","item_id":"item_1","output_index":0,"arguments":"{\"name\":\"my-awesome-repo\",\"private\":true}"}`)
restored := restoreXAINamespaceToolCalls(event, refs)
- if got := gjson.GetBytes(restored, "arguments").String(); got != `{"name":"my-awesome-repo","private":true}` {
+ if got := ojson.GetBytes(restored, "arguments").String(); got != `{"name":"my-awesome-repo","private":true}` {
t.Fatalf("arguments.done was mutated in flatten mode: %s", got)
}
}
@@ -4835,10 +4961,10 @@ func TestRestoreXAINamespaceToolCalls_OutputItemAddedInDispatcherMode(t *testing
event := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","name":"mcp__app_0","status":"in_progress"}}`)
restored := restoreXAINamespaceToolCalls(event, refs)
- if got := gjson.GetBytes(restored, "item.namespace").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(restored, "item.namespace").String(); got != "mcp__app_0" {
t.Fatalf("item.namespace = %q, want mcp__app_0", got)
}
- if got := gjson.GetBytes(restored, "item.name").String(); got != "mcp__app_0" {
+ if got := ojson.GetBytes(restored, "item.name").String(); got != "mcp__app_0" {
t.Fatalf("item.name = %q, want mcp__app_0 at added phase", got)
}
}
@@ -4859,7 +4985,7 @@ func TestClampXAIToolsLimit_PreservesDispatchersOverRegularTools(t *testing.T) {
body := []byte(fmt.Sprintf(`{"tools":[%s]}`, strings.Join(toolList, ",")))
out := clampXAIToolsLimit(body, xaiMaxTools, refs)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 200 {
t.Fatalf("clamped tools count = %d, want 200", len(tools))
}
@@ -4891,11 +5017,11 @@ func TestPromoteXAIAdditionalTools(t *testing.T) {
}`)
out := promoteXAIAdditionalTools(normalizeXAITools(body))
- input := gjson.GetBytes(out, "input").Array()
+ input := ojson.GetBytes(out, "input").Array()
if len(input) != 1 || input[0].Get("role").String() != "user" {
t.Fatalf("input should contain only the user message: %s", string(out))
}
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 3 {
t.Fatalf("tools length = %d, want 3; body=%s", len(tools), string(out))
}
@@ -4973,15 +5099,15 @@ func TestXAIExecutorDeduplicatesToolsAcrossAdditionalTools(t *testing.T) {
if errExec != nil {
t.Fatalf("Execute() error = %v", errExec)
}
- if got := gjson.GetBytes(resp.Payload, "output.0.name").String(); got != "sleep" {
+ if got := ojson.GetBytes(resp.Payload, "output.0.name").String(); got != "sleep" {
t.Fatalf("restored output.0.name = %q, want sleep; payload=%s", got, resp.Payload)
}
- if got := gjson.GetBytes(resp.Payload, "output.0.namespace").String(); got != "clock" {
+ if got := ojson.GetBytes(resp.Payload, "output.0.namespace").String(); got != "clock" {
t.Fatalf("restored output.0.namespace = %q, want clock; payload=%s", got, resp.Payload)
}
}
- tools := gjson.GetBytes(gotBody, "tools").Array()
+ tools := ojson.GetBytes(gotBody, "tools").Array()
count := 0
for _, tool := range tools {
if tool.Get("name").String() == "clock__sleep" {
@@ -4991,16 +5117,16 @@ func TestXAIExecutorDeduplicatesToolsAcrossAdditionalTools(t *testing.T) {
if count != 1 {
t.Fatalf("clock__sleep declarations = %d, want 1; body=%s", count, gotBody)
}
- if got := gjson.GetBytes(gotBody, "tools.0.description").String(); got != "top declaration" {
+ if got := ojson.GetBytes(gotBody, "tools.0.description").String(); got != "top declaration" {
t.Fatalf("tools.0.description = %q, want top declaration; body=%s", got, gotBody)
}
- if !gjson.GetBytes(gotBody, "tools.0.parameters.properties.duration_ms").Exists() {
+ if !ojson.GetBytes(gotBody, "tools.0.parameters.properties.duration_ms").Exists() {
t.Fatalf("tools.0.parameters.properties.duration_ms missing; body=%s", gotBody)
}
- if gjson.GetBytes(gotBody, "tools.0.parameters.properties.seconds").Exists() {
+ if ojson.GetBytes(gotBody, "tools.0.parameters.properties.seconds").Exists() {
t.Fatalf("tools.0.parameters.properties.seconds should not exist when top-level wins; body=%s", gotBody)
}
- for _, input := range gjson.GetBytes(gotBody, "input").Array() {
+ for _, input := range ojson.GetBytes(gotBody, "input").Array() {
if input.Get("type").String() == "additional_tools" {
t.Fatalf("unpromoted additional_tools input remained: %s", gotBody)
}
@@ -5022,7 +5148,7 @@ func TestPromoteXAIAdditionalTools_DeduplicatesAndPreservesWinningNamespaceRef(t
refs := collectXAINamespaceToolRefs(body)
out := promoteXAIAdditionalTools(normalizeXAITools(body))
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytes(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), out)
}
@@ -5046,7 +5172,7 @@ func TestXAIShouldFoldNamespaceTools_DeduplicatesAcrossAdditionalTools(t *testin
t.Fatalf("xaiShouldFoldNamespaceTools should be false when 120 + 120 overlapping tools deduplicate to 120 (<= %d)", xaiMaxTools)
}
out := promoteXAIAdditionalTools(normalizeXAITools(body))
- if got := len(gjson.GetBytes(out, "tools").Array()); got != 120 {
+ if got := len(ojson.GetBytes(out, "tools").Array()); got != 120 {
t.Fatalf("deduplicated tools length = %d, want 120", got)
}
@@ -5065,7 +5191,7 @@ func TestXAIShouldFoldNamespaceTools_DeduplicatesAcrossAdditionalTools(t *testin
t.Fatalf("xaiShouldFoldNamespaceTools should be true when unique flattened tools > %d", xaiMaxTools)
}
foldedOut := promoteXAIAdditionalTools(normalizeXAITools(foldBody))
- foldedTools := gjson.GetBytes(foldedOut, "tools").Array()
+ foldedTools := ojson.GetBytes(foldedOut, "tools").Array()
if len(foldedTools) != 3 {
t.Fatalf("folded tools length = %d, want 3 (clock, mcp__app, mcp__extra); body=%s", len(foldedTools), foldedOut)
}
@@ -5099,24 +5225,24 @@ func TestXAIShouldFoldNamespaceTools_DeduplicatesAcrossAdditionalTools(t *testin
}
clockEvent := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"clock","call_id":"call_0","arguments":"{\"name\":\"sleep\",\"arguments\":{\"duration_ms\":5}}"}}`)
restoredClock := restoreXAINamespaceToolCalls(clockEvent, collisionRefs)
- if got := gjson.GetBytes(restoredClock, "item.namespace").String(); got != "clock" {
+ if got := ojson.GetBytes(restoredClock, "item.namespace").String(); got != "clock" {
t.Fatalf("restoredClock item.namespace = %q, want clock; event=%s", got, restoredClock)
}
- if got := gjson.GetBytes(restoredClock, "item.name").String(); got != "sleep" {
+ if got := ojson.GetBytes(restoredClock, "item.name").String(); got != "sleep" {
t.Fatalf("restoredClock item.name = %q, want sleep; event=%s", got, restoredClock)
}
- if got := gjson.GetBytes(restoredClock, "item.arguments").String(); got != `{"duration_ms":5}` {
+ if got := ojson.GetBytes(restoredClock, "item.arguments").String(); got != `{"duration_ms":5}` {
t.Fatalf("restoredClock item.arguments = %q, want {\"duration_ms\":5}; event=%s", got, restoredClock)
}
event := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"clock__sleep","call_id":"call_1","arguments":"{\"name\":\"alarm\",\"arguments\":{\"ring\":true}}"}}`)
restored := restoreXAINamespaceToolCalls(event, collisionRefs)
- if got := gjson.GetBytes(restored, "item.namespace").String(); got != "clock__sleep" {
+ if got := ojson.GetBytes(restored, "item.namespace").String(); got != "clock__sleep" {
t.Fatalf("restored item.namespace = %q, want clock__sleep; event=%s", got, restored)
}
- if got := gjson.GetBytes(restored, "item.name").String(); got != "alarm" {
+ if got := ojson.GetBytes(restored, "item.name").String(); got != "alarm" {
t.Fatalf("restored item.name = %q, want alarm; event=%s", got, restored)
}
- if got := gjson.GetBytes(restored, "item.arguments").String(); got != `{"ring":true}` {
+ if got := ojson.GetBytes(restored, "item.arguments").String(); got != `{"ring":true}` {
t.Fatalf("restored item.arguments = %q, want {\"ring\":true}; event=%s", got, restored)
}
@@ -5164,10 +5290,10 @@ func TestXAIShouldFoldNamespaceTools_DeduplicatesAcrossAdditionalTools(t *testin
}
flatCallEvent := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"clock__sleep","call_id":"call_flat","arguments":"{\"raw\":true}"}}`)
restoredFlatCall := restoreXAINamespaceToolCalls(flatCallEvent, foldedBeforeFlatRefs)
- if got := gjson.GetBytes(restoredFlatCall, "item.name").String(); got != "clock__sleep" {
+ if got := ojson.GetBytes(restoredFlatCall, "item.name").String(); got != "clock__sleep" {
t.Fatalf("restoredFlatCall item.name = %q, want clock__sleep; event=%s", got, restoredFlatCall)
}
- if gjson.GetBytes(restoredFlatCall, "item.namespace").Exists() {
+ if ojson.GetBytes(restoredFlatCall, "item.namespace").Exists() {
t.Fatalf("restoredFlatCall item.namespace should not be set for flat function: %s", restoredFlatCall)
}
}
@@ -5180,13 +5306,13 @@ func TestNormalizeXAINamespaceToolChoice(t *testing.T) {
out := normalizeXAITools(body)
out = normalizeXAINamespaceToolChoice(out)
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "mcp__exa__search" {
+ if got := ojson.GetBytes(out, "tools.0.name").String(); got != "mcp__exa__search" {
t.Fatalf("tools.0.name = %q, want mcp__exa__search; body=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "mcp__exa__search" {
+ if got := ojson.GetBytes(out, "tool_choice.name").String(); got != "mcp__exa__search" {
t.Fatalf("tool_choice.name = %q, want mcp__exa__search; body=%s", got, string(out))
}
- if gjson.GetBytes(out, "tool_choice.namespace").Exists() {
+ if ojson.GetBytes(out, "tool_choice.namespace").Exists() {
t.Fatalf("tool_choice.namespace should be removed for xAI upstream: %s", string(out))
}
}
@@ -5205,22 +5331,22 @@ func TestNormalizeXAINamespaceToolChoiceAllowedTools(t *testing.T) {
}`)
out := normalizeXAINamespaceToolChoice(body)
- if got := gjson.GetBytes(out, "tool_choice.tools.0.name").String(); got != "mcp__exa__search" {
+ if got := ojson.GetBytes(out, "tool_choice.tools.0.name").String(); got != "mcp__exa__search" {
t.Fatalf("tool_choice.tools.0.name = %q, want mcp__exa__search; body=%s", got, string(out))
}
- if gjson.GetBytes(out, "tool_choice.tools.0.namespace").Exists() {
+ if ojson.GetBytes(out, "tool_choice.tools.0.namespace").Exists() {
t.Fatalf("tool_choice.tools.0.namespace should be removed: %s", string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.tools.1.name").String(); got != "collaboration__send_message" {
+ if got := ojson.GetBytes(out, "tool_choice.tools.1.name").String(); got != "collaboration__send_message" {
t.Fatalf("tool_choice.tools.1.name = %q, want collaboration__send_message; body=%s", got, string(out))
}
- if gjson.GetBytes(out, "tool_choice.tools.1.namespace").Exists() {
+ if ojson.GetBytes(out, "tool_choice.tools.1.namespace").Exists() {
t.Fatalf("tool_choice.tools.1.namespace should be removed: %s", string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.tools.2.name").String(); got != "lookup" {
+ if got := ojson.GetBytes(out, "tool_choice.tools.2.name").String(); got != "lookup" {
t.Fatalf("tool_choice.tools.2.name = %q, want lookup; body=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.tools.3.namespace").String(); got != "ignored" {
+ if got := ojson.GetBytes(out, "tool_choice.tools.3.namespace").String(); got != "ignored" {
t.Fatalf("non-function namespace = %q, want ignored; body=%s", got, string(out))
}
}
@@ -5293,8 +5419,8 @@ func TestNormalizeXAITools_PreservesUnrelatedSchemas(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := normalizeXAITools(tt.body)
- tool := gjson.GetBytes(out, "tools.0")
- if tool.Get("strict").Type != gjson.True {
+ tool := ojson.GetBytes(out, "tools.0")
+ if tool.Get("strict").Type != ojson.True {
t.Fatalf("strict changed for unrelated tool: %s", string(out))
}
params := tool.Get("parameters")
@@ -5307,7 +5433,7 @@ func TestNormalizeXAITools_PreservesUnrelatedSchemas(t *testing.T) {
if got := params.Get("properties.cron.type").String(); got != "string" {
t.Fatalf("schema was simplified, cron type = %q: %s", got, string(out))
}
- if params.Get("additionalProperties").Type != gjson.False {
+ if params.Get("additionalProperties").Type != ojson.False {
t.Fatalf("additionalProperties changed: %s", string(out))
}
})
@@ -5315,7 +5441,7 @@ func TestNormalizeXAITools_PreservesUnrelatedSchemas(t *testing.T) {
}
func TestXAIFunctionParametersNeedSimplification(t *testing.T) {
- auto := gjson.Parse(`{"type":"function","name":"automation_update","parameters":{"type":"object"}}`)
+ auto := ojson.Parse(`{"type":"function","name":"automation_update","parameters":{"type":"object"}}`)
if !xaiFunctionParametersNeedSimplification(auto, "codex_app") {
t.Fatal("codex_app.automation_update should need simplification")
}
@@ -5334,55 +5460,55 @@ func TestXAIFunctionParametersNeedSimplification(t *testing.T) {
if xaiFunctionParametersNeedSimplification(auto, "") {
t.Fatal("top-level automation_update should not need simplification")
}
- flattened := gjson.Parse(`{"type":"function","name":"codex_app__automation_update","parameters":{"type":"object"}}`)
+ flattened := ojson.Parse(`{"type":"function","name":"codex_app__automation_update","parameters":{"type":"object"}}`)
if !xaiFunctionParametersNeedSimplification(flattened, "") {
t.Fatal("flattened codex_app__automation_update should need simplification")
}
- flattenedMCP := gjson.Parse(`{"type":"function","name":"mcp__codex_app__automation_update","parameters":{"type":"object"}}`)
+ flattenedMCP := ojson.Parse(`{"type":"function","name":"mcp__codex_app__automation_update","parameters":{"type":"object"}}`)
if !xaiFunctionParametersNeedSimplification(flattenedMCP, "") {
t.Fatal("flattened mcp__codex_app__automation_update should need simplification")
}
- flattenedApps := gjson.Parse(`{"type":"function","name":"codex_apps__automation_update","parameters":{"type":"object"}}`)
+ flattenedApps := ojson.Parse(`{"type":"function","name":"codex_apps__automation_update","parameters":{"type":"object"}}`)
if !xaiFunctionParametersNeedSimplification(flattenedApps, "") {
t.Fatal("flattened codex_apps__automation_update should need simplification")
}
- flattenedMCPApps := gjson.Parse(`{"type":"function","name":"mcp__codex_apps__automation_update","parameters":{"type":"object"}}`)
+ flattenedMCPApps := ojson.Parse(`{"type":"function","name":"mcp__codex_apps__automation_update","parameters":{"type":"object"}}`)
if !xaiFunctionParametersNeedSimplification(flattenedMCPApps, "") {
t.Fatal("flattened mcp__codex_apps__automation_update should need simplification")
}
- custom := gjson.Parse(`{"type":"custom","name":"automation_update","parameters":{"type":"object"}}`)
+ custom := ojson.Parse(`{"type":"custom","name":"automation_update","parameters":{"type":"object"}}`)
if xaiFunctionParametersNeedSimplification(custom, "codex_app") {
t.Fatal("custom codex_app.automation_update with an object schema should not need simplification")
}
- invalidCustom := gjson.Parse(`{"type":"custom","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}}`)
+ invalidCustom := ojson.Parse(`{"type":"custom","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}}`)
if !xaiFunctionParametersNeedSimplification(invalidCustom, "") {
t.Fatal("custom tool normalized to a function should simplify an invalid root union")
}
- invalidOneOf := gjson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}}`)
+ invalidOneOf := ojson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"type":"null"}]}}`)
if !xaiFunctionParametersNeedSimplification(invalidOneOf, "") {
t.Fatal("root oneOf with a non-object branch should need simplification")
}
- invalidAnyOf := gjson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"anyOf":[{"type":"object"},{"type":["object","null"]}]}}`)
+ invalidAnyOf := ojson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"anyOf":[{"type":"object"},{"type":["object","null"]}]}}`)
if !xaiFunctionParametersNeedSimplification(invalidAnyOf, "") {
t.Fatal("root anyOf with a non-object type should need simplification")
}
- untypedBranch := gjson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"const":null}]}}`)
+ untypedBranch := ojson.Parse(`{"type":"function","name":"nullable_lookup","parameters":{"oneOf":[{"type":"object"},{"const":null}]}}`)
if !xaiFunctionParametersNeedSimplification(untypedBranch, "") {
t.Fatal("root union with an untyped branch should need simplification")
}
- refBranch := gjson.Parse(`{"type":"function","name":"ref_tool","parameters":{"oneOf":[{"$ref":"#/$defs/schema0"}]}}`)
+ refBranch := ojson.Parse(`{"type":"function","name":"ref_tool","parameters":{"oneOf":[{"$ref":"#/$defs/schema0"}]}}`)
if !xaiFunctionParametersNeedSimplification(refBranch, "") {
t.Fatal("root union with a $ref branch should need simplification")
}
- objectUnion := gjson.Parse(`{"type":"function","name":"lookup","parameters":{"oneOf":[{"type":"object"},{"type":"object"}]}}`)
+ objectUnion := ojson.Parse(`{"type":"function","name":"lookup","parameters":{"oneOf":[{"type":"object"},{"type":"object"}]}}`)
if xaiFunctionParametersNeedSimplification(objectUnion, "") {
t.Fatal("root union containing only object branches should not need simplification")
}
- nestedUnion := gjson.Parse(`{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"value":{"oneOf":[{"type":"string"},{"type":"null"}]}}}}`)
+ nestedUnion := ojson.Parse(`{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"value":{"oneOf":[{"type":"string"},{"type":"null"}]}}}}`)
if xaiFunctionParametersNeedSimplification(nestedUnion, "") {
t.Fatal("nested union should not need root schema simplification")
}
- safe := gjson.Parse(`{"type":"function","name":"exec_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}`)
+ safe := ojson.Parse(`{"type":"function","name":"exec_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}}`)
if xaiFunctionParametersNeedSimplification(safe, "codex_app") {
t.Fatal("unrelated codex_app function should not need simplification")
}
@@ -5392,13 +5518,13 @@ func TestNormalizeXAIInputNamespaceToolCalls(t *testing.T) {
body := []byte(`{"input":[{"type":"function_call","name":"web_search_exa","namespace":"mcp__exa","call_id":"call_1","arguments":"{}"},{"type":"function_call","name":"plain_tool","call_id":"call_2","arguments":"{}"}]}`)
out := normalizeXAIInputNamespaceToolCalls(body)
- if got := gjson.GetBytes(out, "input.0.name").String(); got != "mcp__exa__web_search_exa" {
+ if got := ojson.GetBytes(out, "input.0.name").String(); got != "mcp__exa__web_search_exa" {
t.Fatalf("input.0.name = %q, want qualified namespace name; body=%s", got, string(out))
}
- if gjson.GetBytes(out, "input.0.namespace").Exists() {
+ if ojson.GetBytes(out, "input.0.namespace").Exists() {
t.Fatalf("input.0.namespace should be removed for xAI upstream: %s", string(out))
}
- if got := gjson.GetBytes(out, "input.1.name").String(); got != "plain_tool" {
+ if got := ojson.GetBytes(out, "input.1.name").String(); got != "plain_tool" {
t.Fatalf("plain function call name changed to %q", got)
}
}
@@ -5409,19 +5535,19 @@ func TestRestoreXAINamespaceToolCalls(t *testing.T) {
event := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"mcp__exa__web_search_exa","call_id":"call_1","arguments":"{}"}}`)
restoredEvent := restoreXAINamespaceToolCalls(event, refs)
- if got := gjson.GetBytes(restoredEvent, "item.name").String(); got != "web_search_exa" {
+ if got := ojson.GetBytes(restoredEvent, "item.name").String(); got != "web_search_exa" {
t.Fatalf("item.name = %q, want child name; event=%s", got, string(restoredEvent))
}
- if got := gjson.GetBytes(restoredEvent, "item.namespace").String(); got != "mcp__exa" {
+ if got := ojson.GetBytes(restoredEvent, "item.namespace").String(); got != "mcp__exa" {
t.Fatalf("item.namespace = %q, want mcp__exa; event=%s", got, string(restoredEvent))
}
completed := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"mcp__exa__web_search_exa","call_id":"call_1","arguments":"{}"}]}}`)
restoredCompleted := restoreXAINamespaceToolCalls(completed, refs)
- if got := gjson.GetBytes(restoredCompleted, "response.output.0.name").String(); got != "web_search_exa" {
+ if got := ojson.GetBytes(restoredCompleted, "response.output.0.name").String(); got != "web_search_exa" {
t.Fatalf("response.output.0.name = %q, want child name; event=%s", got, string(restoredCompleted))
}
- if got := gjson.GetBytes(restoredCompleted, "response.output.0.namespace").String(); got != "mcp__exa" {
+ if got := ojson.GetBytes(restoredCompleted, "response.output.0.namespace").String(); got != "mcp__exa" {
t.Fatalf("response.output.0.namespace = %q, want mcp__exa; event=%s", got, string(restoredCompleted))
}
}
@@ -5441,13 +5567,13 @@ func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsEmpty(t *testing.T) {
body := []byte(`{"model":"grok-4","tools":[],"tool_choice":"auto","parallel_tool_calls":true,"input":"hi"}`)
out := normalizeXAIToolChoiceForTools(body)
- if gjson.GetBytes(out, "tools").Exists() {
+ if ojson.GetBytes(out, "tools").Exists() {
t.Fatalf("empty tools should be removed: %s", string(out))
}
- if gjson.GetBytes(out, "tool_choice").Exists() {
+ if ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("tool_choice should be removed when tools empty: %s", string(out))
}
- if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
+ if ojson.GetBytes(out, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls should be removed when tools empty: %s", string(out))
}
}
@@ -5456,7 +5582,7 @@ func TestNormalizeXAIToolChoiceForTools_DropsWhenToolsMissing(t *testing.T) {
body := []byte(`{"model":"grok-4","tool_choice":"auto","input":"hi"}`)
out := normalizeXAIToolChoiceForTools(body)
- if gjson.GetBytes(out, "tool_choice").Exists() {
+ if ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("tool_choice should be removed when tools missing: %s", string(out))
}
}
@@ -5465,7 +5591,7 @@ func TestNormalizeXAIToolChoiceForTools_DropsOrphanedParallelToolCalls(t *testin
body := []byte(`{"model":"grok-4","parallel_tool_calls":true,"input":"hi"}`)
out := normalizeXAIToolChoiceForTools(body)
- if gjson.GetBytes(out, "parallel_tool_calls").Exists() {
+ if ojson.GetBytes(out, "parallel_tool_calls").Exists() {
t.Fatalf("parallel_tool_calls should be removed when tools missing even without tool_choice: %s", string(out))
}
}
@@ -5474,10 +5600,10 @@ func TestNormalizeXAIToolChoiceForTools_KeepsWhenToolsPresent(t *testing.T) {
body := []byte(`{"model":"grok-4","tools":[{"type":"function","name":"Bash"}],"tool_choice":"auto","input":"hi"}`)
out := normalizeXAIToolChoiceForTools(body)
- if !gjson.GetBytes(out, "tools").Exists() {
+ if !ojson.GetBytes(out, "tools").Exists() {
t.Fatalf("tools should be kept: %s", string(out))
}
- if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" {
+ if got := ojson.GetBytes(out, "tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto: %s", got, string(out))
}
}
@@ -5486,10 +5612,10 @@ func TestNormalizeXAIToolChoiceForTools_KeepsWhenAdditionalToolsPresent(t *testi
body := []byte(`{"model":"grok-4","input":[{"type":"additional_tools","tools":[{"type":"function","name":"Bash"}]}],"tool_choice":"auto","parallel_tool_calls":true}`)
out := normalizeXAIToolChoiceForTools(body)
- if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" {
+ if got := ojson.GetBytes(out, "tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto: %s", got, string(out))
}
- if !gjson.GetBytes(out, "parallel_tool_calls").Bool() {
+ if !ojson.GetBytes(out, "parallel_tool_calls").Bool() {
t.Fatalf("parallel_tool_calls should be kept: %s", string(out))
}
}
@@ -5498,7 +5624,7 @@ func TestNormalizeXAIToolChoiceForTools_NoOpWhenBothAbsent(t *testing.T) {
body := []byte(`{"model":"grok-4","input":"hi"}`)
out := normalizeXAIToolChoiceForTools(body)
- if gjson.GetBytes(out, "tool_choice").Exists() {
+ if ojson.GetBytes(out, "tool_choice").Exists() {
t.Fatalf("tool_choice should not appear: %s", string(out))
}
}
@@ -5522,8 +5648,8 @@ func TestXAIExecutorComposerReusesClaudeCodeSession(t *testing.T) {
t.Fatalf("prepareResponsesRequest second error: %v", err)
}
- firstKey := gjson.GetBytes(first.body, "prompt_cache_key").String()
- secondKey := gjson.GetBytes(second.body, "prompt_cache_key").String()
+ firstKey := ojson.GetBytes(first.body, "prompt_cache_key").String()
+ secondKey := ojson.GetBytes(second.body, "prompt_cache_key").String()
if firstKey == "" {
t.Fatalf("first prompt_cache_key is empty; body=%s", string(first.body))
}
@@ -5585,7 +5711,7 @@ func TestApplyXAIHeaders_EmptyAPIKey_OmitsAuthorization(t *testing.T) {
func TestSanitizeXAIInputEncryptedContent_DropsInvalidReasoningBlob(t *testing.T) {
body := []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[],"encrypted_content":"bad"},{"type":"reasoning","summary":[],"encrypted_content":"gAAAAABinvalid-gpt-shape"},{"role":"user","content":"hi"}]}`)
got := sanitizeXAIInputEncryptedContent(body)
- if gjson.GetBytes(got, "input.0.encrypted_content").Exists() || gjson.GetBytes(got, "input.1.encrypted_content").Exists() {
+ if ojson.GetBytes(got, "input.0.encrypted_content").Exists() || ojson.GetBytes(got, "input.1.encrypted_content").Exists() {
t.Fatalf("invalid encrypted_content should be removed: %s", string(got))
}
}
@@ -5593,21 +5719,21 @@ func TestSanitizeXAIInputEncryptedContent_DropsInvalidReasoningBlob(t *testing.T
func TestSanitizeXAIInputEncryptedContent_PreservesValidBlob(t *testing.T) {
sample := testValidGrokEncryptedContent()
body := []byte(`{"model":"grok-4.3","input":[{"type":"reasoning","summary":[],"encrypted_content":""}]}`)
- body, _ = sjson.SetBytes(body, "input.0.encrypted_content", sample)
+ body, _ = ojson.SetBytes(body, "input.0.encrypted_content", sample)
got := sanitizeXAIInputEncryptedContent(body)
- if gotEnc := gjson.GetBytes(got, "input.0.encrypted_content").String(); gotEnc != sample {
+ if gotEnc := ojson.GetBytes(got, "input.0.encrypted_content").String(); gotEnc != sample {
t.Fatalf("valid encrypted_content should be preserved, got %q", gotEnc)
}
routedSample := sample + "." + strings.Repeat("Ab3", 21)[:62]
- routedBody, _ := sjson.SetBytes(body, "input.0.encrypted_content", routedSample)
+ routedBody, _ := ojson.SetBytes(body, "input.0.encrypted_content", routedSample)
gotRouted := sanitizeXAIInputEncryptedContent(routedBody)
- if gotEnc := gjson.GetBytes(gotRouted, "input.0.encrypted_content").String(); gotEnc != sample {
+ if gotEnc := ojson.GetBytes(gotRouted, "input.0.encrypted_content").String(); gotEnc != sample {
t.Fatalf("router suffix should be stripped for native xAI upstream, got %q want %q", gotEnc, sample)
}
gotOpenRouter := sanitizeXAIInputEncryptedContentForBaseURL(routedBody, "https://openrouter.ai/api/v1")
- if gotEnc := gjson.GetBytes(gotOpenRouter, "input.0.encrypted_content").String(); gotEnc != routedSample {
+ if gotEnc := ojson.GetBytes(gotOpenRouter, "input.0.encrypted_content").String(); gotEnc != routedSample {
t.Fatalf("router suffix should be preserved for OpenRouter xAI upstream, got %q want %q", gotEnc, routedSample)
}
}
@@ -5646,16 +5772,16 @@ func TestXAIExecutorReMergesReasoningAfterDroppingInvalidEncryptedContent(t *tes
t.Fatalf("Execute() error = %v", err)
}
- if got := gjson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "first" {
+ if got := ojson.GetBytes(gotBody, "input.0.summary.0.text").String(); got != "first" {
t.Fatalf("input.0.summary.0.text = %q, want first; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
+ if got := ojson.GetBytes(gotBody, "input.0.summary.1.text").String(); got != "second" {
t.Fatalf("input.0.summary.1.text = %q, want second; body=%s", got, string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.1.role").String(); got != "user" {
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.2").Exists() {
+ if ojson.GetBytes(gotBody, "input.2").Exists() {
t.Fatalf("input.2 exists, want invalid reasoning blob removed and summaries re-merged; body=%s", string(gotBody))
}
}
@@ -5693,10 +5819,10 @@ func TestXAIExecutorDropsInvalidCompactionItem(t *testing.T) {
if xaiInputHasItemType(gotBody, "compaction") {
t.Fatalf("invalid compaction item reached upstream body: %s", string(gotBody))
}
- if got := gjson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
+ if got := ojson.GetBytes(gotBody, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want user after dropping invalid compaction; body=%s", got, string(gotBody))
}
- if gjson.GetBytes(gotBody, "input.1").Exists() {
+ if ojson.GetBytes(gotBody, "input.1").Exists() {
t.Fatalf("input.1 exists, want only user item after dropping invalid compaction; body=%s", string(gotBody))
}
}
@@ -5760,13 +5886,13 @@ func TestXAIExecutorReasoningReplayCacheStoresFinalDoneAndInjectsNextClaudeReque
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "reasoning" {
t.Fatalf("input.0.type = %q, want reasoning; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
+ if got := ojson.GetBytes(secondBody, "input.0.encrypted_content").String(); got != doneEncryptedContent {
t.Fatalf("injected encrypted_content = %q, want final done %q; body=%s", got, doneEncryptedContent, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.1.role").String(); got != "user" {
+ if got := ojson.GetBytes(secondBody, "input.1.role").String(); got != "user" {
t.Fatalf("input.1.role = %q, want user; body=%s", got, string(secondBody))
}
}
@@ -5832,7 +5958,7 @@ func TestXAIExecutorResponsesSSEReplaysEncryptedReasoningAndAssistantMessage(t *
if len(bodies) != 2 {
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
- if includes := gjson.GetBytes(bodies[0], "include").Array(); len(includes) != 1 || includes[0].String() != "reasoning.encrypted_content" {
+ if includes := ojson.GetBytes(bodies[0], "include").Array(); len(includes) != 1 || includes[0].String() != "reasoning.encrypted_content" {
t.Fatalf("first request include was not preserved: %s", bodies[0])
}
var downstreamEncryptedContent string
@@ -5841,29 +5967,29 @@ func TestXAIExecutorResponsesSSEReplaysEncryptedReasoningAndAssistantMessage(t *
continue
}
eventData := bytes.TrimSpace(line[len(xaiDataTag):])
- if gjson.GetBytes(eventData, "type").String() != "response.output_item.done" ||
- gjson.GetBytes(eventData, "item.type").String() != "reasoning" {
+ if ojson.GetBytes(eventData, "type").String() != "response.output_item.done" ||
+ ojson.GetBytes(eventData, "item.type").String() != "reasoning" {
continue
}
- downstreamEncryptedContent = gjson.GetBytes(eventData, "item.encrypted_content").String()
+ downstreamEncryptedContent = ojson.GetBytes(eventData, "item.encrypted_content").String()
break
}
if downstreamEncryptedContent != encryptedContent {
t.Fatalf("downstream encrypted_content = %q, want upstream Grok blob; stream=%s", downstreamEncryptedContent, streamedResponses[0])
}
- if got := gjson.GetBytes(bodies[1], "input.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(bodies[1], "input.0.type").String(); got != "reasoning" {
t.Fatalf("second input.0.type = %q, want reasoning; body=%s", got, bodies[1])
}
- if got := gjson.GetBytes(bodies[1], "input.0.encrypted_content").String(); got != encryptedContent {
+ if got := ojson.GetBytes(bodies[1], "input.0.encrypted_content").String(); got != encryptedContent {
t.Fatalf("replayed encrypted_content = %q, want cached Grok blob; body=%s", got, bodies[1])
}
- if got := gjson.GetBytes(bodies[1], "input.1.type").String(); got != "message" {
+ if got := ojson.GetBytes(bodies[1], "input.1.type").String(); got != "message" {
t.Fatalf("second input.1.type = %q, want assistant message; body=%s", got, bodies[1])
}
- if got := gjson.GetBytes(bodies[1], "input.1.content.0.text").String(); got != "first answer" {
+ if got := ojson.GetBytes(bodies[1], "input.1.content.0.text").String(); got != "first answer" {
t.Fatalf("replayed assistant text = %q, want first answer; body=%s", got, bodies[1])
}
- if got := gjson.GetBytes(bodies[1], "input.2.content.0.text").String(); got != "second" {
+ if got := ojson.GetBytes(bodies[1], "input.2.content.0.text").String(); got != "second" {
t.Fatalf("new user text = %q, want second; body=%s", got, bodies[1])
}
}
@@ -5871,12 +5997,12 @@ func TestXAIExecutorResponsesSSEReplaysEncryptedReasoningAndAssistantMessage(t *
func TestFilterXAIReasoningReplayItemsSkipsMatchingCachedTurn(t *testing.T) {
encryptedContent := testValidGrokEncryptedContentForSeed(10)
body := []byte(`{"input":[{"type":"reasoning","summary":[],"encrypted_content":""},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"second"}]}]}`)
- body, _ = sjson.SetBytes(body, "input.0.encrypted_content", encryptedContent)
+ body, _ = ojson.SetBytes(body, "input.0.encrypted_content", encryptedContent)
items := [][]byte{
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]}`),
}
- items[0], _ = sjson.SetBytes(items[0], "encrypted_content", encryptedContent)
+ items[0], _ = ojson.SetBytes(items[0], "encrypted_content", encryptedContent)
filtered := filterXAIReasoningReplayItemsForInput(body, items)
if len(filtered) != 0 {
@@ -5888,12 +6014,12 @@ func TestFilterXAIReasoningReplayItemsSkipsAmbiguousCachedTurnWhenInputHasOlderR
oldEncryptedContent := testValidGrokEncryptedContentForSeed(10)
newEncryptedContent := testValidGrokEncryptedContentForSeed(12)
body := []byte(`{"input":[{"type":"reasoning","summary":[],"encrypted_content":""},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"older answer"}]},{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}]}`)
- body, _ = sjson.SetBytes(body, "input.0.encrypted_content", oldEncryptedContent)
+ body, _ = ojson.SetBytes(body, "input.0.encrypted_content", oldEncryptedContent)
items := [][]byte{
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"new answer"}]}`),
}
- items[0], _ = sjson.SetBytes(items[0], "encrypted_content", newEncryptedContent)
+ items[0], _ = ojson.SetBytes(items[0], "encrypted_content", newEncryptedContent)
filtered := filterXAIReasoningReplayItemsForInput(body, items)
if len(filtered) != 0 {
@@ -5908,10 +6034,10 @@ func TestFilterXAIReasoningReplayItemsSkipsDuplicateAssistantMessage(t *testing.
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]}`),
}
- items[0], _ = sjson.SetBytes(items[0], "encrypted_content", encryptedContent)
+ items[0], _ = ojson.SetBytes(items[0], "encrypted_content", encryptedContent)
filtered := filterXAIReasoningReplayItemsForInput(body, items)
- if len(filtered) != 1 || gjson.GetBytes(filtered[0], "type").String() != "reasoning" {
+ if len(filtered) != 1 || ojson.GetBytes(filtered[0], "type").String() != "reasoning" {
t.Fatalf("filtered replay items = %q, want reasoning only", filtered)
}
}
@@ -5923,17 +6049,17 @@ func TestFilterXAIReasoningReplayItemsRecognizesRoleOnlyAssistantMessage(t *test
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]}`),
}
- items[0], _ = sjson.SetBytes(items[0], "encrypted_content", encryptedContent)
+ items[0], _ = ojson.SetBytes(items[0], "encrypted_content", encryptedContent)
filtered := filterXAIReasoningReplayItemsForInput(body, items)
- if len(filtered) != 1 || gjson.GetBytes(filtered[0], "type").String() != "reasoning" {
+ if len(filtered) != 1 || ojson.GetBytes(filtered[0], "type").String() != "reasoning" {
t.Fatalf("filtered replay items = %q, want reasoning only", filtered)
}
updated, ok := insertCodexReasoningReplayItems(body, filtered)
if !ok {
t.Fatal("insertCodexReasoningReplayItems failed")
}
- input := gjson.GetBytes(updated, "input").Array()
+ input := ojson.GetBytes(updated, "input").Array()
if len(input) != 3 || input[0].Get("type").String() != "reasoning" || input[1].Get("role").String() != "assistant" {
t.Fatalf("unexpected role-only replay order: %s", updated)
}
@@ -5955,7 +6081,7 @@ func TestFilterXAIReasoningReplayItemsDoesNotMatchOlderAssistantMessage(t *testi
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}`),
}
- items[0], _ = sjson.SetBytes(items[0], "encrypted_content", encryptedContent)
+ items[0], _ = ojson.SetBytes(items[0], "encrypted_content", encryptedContent)
filtered := filterXAIReasoningReplayItemsForInput(body, items)
if len(filtered) != 0 {
@@ -5973,7 +6099,7 @@ func TestFilterXAIReasoningReplayItemsSkipsAmbiguousTurnWhenLastAssistantTextDri
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"first answer"}]}`),
}
- items[0], _ = sjson.SetBytes(items[0], "encrypted_content", encryptedContent)
+ items[0], _ = ojson.SetBytes(items[0], "encrypted_content", encryptedContent)
filtered := filterXAIReasoningReplayItemsForInput(body, items)
if len(filtered) != 0 {
@@ -6041,7 +6167,7 @@ func TestXAIExecutorClaudeInjectsLatestCachedReasoningWhenHistoryHasOnlyOlderSig
{"role":"user","content":[{"type":"text","text":"next"}]}
]
}`)
- secondPayload, _ = sjson.SetBytes(secondPayload, "messages.1.content.0.signature", oldEncrypted)
+ secondPayload, _ = ojson.SetBytes(secondPayload, "messages.1.content.0.signature", oldEncrypted)
_, err = executor.Execute(ctx, auth, cliproxyexecutor.Request{
Model: "grok-4.5",
@@ -6060,7 +6186,7 @@ func TestXAIExecutorClaudeInjectsLatestCachedReasoningWhenHistoryHasOnlyOlderSig
foundLatest := false
foundOld := false
assistantCount := 0
- for _, item := range gjson.GetBytes(second, "input").Array() {
+ for _, item := range ojson.GetBytes(second, "input").Array() {
switch item.Get("type").String() {
case "reasoning":
enc := item.Get("encrypted_content").String()
@@ -6098,7 +6224,7 @@ func TestCacheXAIReasoningReplayFromCompletedClearsPreviousEntryWhenNoReplayable
[]byte(`{"type":"reasoning","summary":[],"content":null,"encrypted_content":""}`),
[]byte(`{"type":"message","role":"assistant","content":[{"type":"output_text","text":"previous answer"}]}`),
}
- previousItems[0], _ = sjson.SetBytes(previousItems[0], "encrypted_content", encryptedContent)
+ previousItems[0], _ = ojson.SetBytes(previousItems[0], "encrypted_content", encryptedContent)
if !internalcache.CacheXAIReasoningReplayItems(modelName, sessionKey, previousItems) {
t.Fatal("failed to seed xAI reasoning replay cache")
}
@@ -6246,7 +6372,7 @@ func TestXAIReasoningReplayCacheReplaysFunctionCallWithoutReasoning(t *testing.T
if !scope.valid() {
t.Fatal("tool-call-only replay scope must remain valid")
}
- input := gjson.GetBytes(updated, "input").Array()
+ input := ojson.GetBytes(updated, "input").Array()
if len(input) != 3 {
t.Fatalf("input length = %d, want 3; body=%s", len(input), updated)
}
@@ -6333,22 +6459,22 @@ func TestXAIExecutorReasoningReplayCacheReplaysFunctionCallForClaudeToolResult(t
t.Fatalf("upstream request count = %d, want 2", len(bodies))
}
secondBody := bodies[1]
- if got := gjson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
+ if got := ojson.GetBytes(secondBody, "input.0.type").String(); got != "message" {
t.Fatalf("input.0.type = %q, want initial user message; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
+ if got := ojson.GetBytes(secondBody, "input.1.type").String(); got != "reasoning" {
t.Fatalf("input.1.type = %q, want cached reasoning; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
+ if got := ojson.GetBytes(secondBody, "input.2.type").String(); got != "function_call" {
t.Fatalf("input.2.type = %q, want cached function_call; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.2.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytes(secondBody, "input.2.call_id").String(); got != "call_1" {
t.Fatalf("input.2.call_id = %q, want call_1; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
+ if got := ojson.GetBytes(secondBody, "input.3.type").String(); got != "function_call_output" {
t.Fatalf("input.3.type = %q, want function_call_output after cached call; body=%s", got, string(secondBody))
}
- if got := gjson.GetBytes(secondBody, "input.3.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytes(secondBody, "input.3.call_id").String(); got != "call_1" {
t.Fatalf("input.3.call_id = %q, want call_1; body=%s", got, string(secondBody))
}
}
@@ -6837,17 +6963,17 @@ func TestXAIPatchCompletedOutput_EnsuresUsageDetails(t *testing.T) {
var outputItemsFallback [][]byte
got := xaiPatchCompletedOutput(eventData, outputItemsByIndex, outputItemsFallback)
- if !gjson.GetBytes(got, "response.usage.output_tokens_details").Exists() {
+ if !ojson.GetBytes(got, "response.usage.output_tokens_details").Exists() {
t.Fatalf("expected output_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(got, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
- t.Fatalf("expected reasoning_tokens == 0, got %d", gjson.GetBytes(got, "response.usage.output_tokens_details.reasoning_tokens").Int())
+ if ojson.GetBytes(got, "response.usage.output_tokens_details.reasoning_tokens").Int() != 0 {
+ t.Fatalf("expected reasoning_tokens == 0, got %d", ojson.GetBytes(got, "response.usage.output_tokens_details.reasoning_tokens").Int())
}
- if !gjson.GetBytes(got, "response.usage.input_tokens_details").Exists() {
+ if !ojson.GetBytes(got, "response.usage.input_tokens_details").Exists() {
t.Fatalf("expected input_tokens_details to exist, got %s", string(got))
}
- if gjson.GetBytes(got, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
- t.Fatalf("expected cached_tokens == 0, got %d", gjson.GetBytes(got, "response.usage.input_tokens_details.cached_tokens").Int())
+ if ojson.GetBytes(got, "response.usage.input_tokens_details.cached_tokens").Int() != 0 {
+ t.Fatalf("expected cached_tokens == 0, got %d", ojson.GetBytes(got, "response.usage.input_tokens_details.cached_tokens").Int())
}
}
@@ -7051,7 +7177,7 @@ func TestXAIExecutorAliasesClientWebSearchFunctionInRequest(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), prepared.body)
}
@@ -7061,10 +7187,10 @@ func TestXAIExecutorAliasesClientWebSearchFunctionInRequest(t *testing.T) {
if got := tools[1].Get("name").String(); got != "read" {
t.Fatalf("tools.1.name = %q, want read; body=%s", got, prepared.body)
}
- if got := gjson.GetBytes(prepared.body, "tool_choice.name").String(); got != "clientfn_web_search" {
+ if got := ojson.GetBytes(prepared.body, "tool_choice.name").String(); got != "clientfn_web_search" {
t.Fatalf("tool_choice.name = %q, want clientfn_web_search; body=%s", got, prepared.body)
}
- if got := gjson.GetBytes(prepared.body, "input.1.name").String(); got != "clientfn_web_search" {
+ if got := ojson.GetBytes(prepared.body, "input.1.name").String(); got != "clientfn_web_search" {
t.Fatalf("input.1.name = %q, want clientfn_web_search; body=%s", got, prepared.body)
}
@@ -7083,7 +7209,7 @@ func TestXAIExecutorAliasesClientWebSearchFunctionInRequest(t *testing.T) {
if errAllowed != nil {
t.Fatalf("prepareResponsesRequest() error = %v", errAllowed)
}
- if got := gjson.GetBytes(preparedAllowed.body, "tool_choice.tools.0.name").String(); got != "clientfn_web_search" {
+ if got := ojson.GetBytes(preparedAllowed.body, "tool_choice.tools.0.name").String(); got != "clientfn_web_search" {
t.Fatalf("tool_choice.tools.0.name = %q, want clientfn_web_search; body=%s", got, preparedAllowed.body)
}
}
@@ -7109,7 +7235,7 @@ func TestXAIExecutorPreservesHostedWebSearchToolType(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 1 {
t.Fatalf("tools length = %d, want 1; body=%s", len(tools), prepared.body)
}
@@ -7187,7 +7313,7 @@ func TestXAIExecutorRestoresAliasedWebSearchInStreamAndExecute(t *testing.T) {
if strings.Contains(string(execRes.Payload), "clientfn_web_search") {
t.Fatalf("clientfn_web_search leaked into non-stream response: %s", execRes.Payload)
}
- if got := gjson.GetBytes(execRes.Payload, "output.0.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(execRes.Payload, "output.0.name").String(); got != "web_search" {
t.Fatalf("non-stream output.0.name = %q, want web_search; payload=%s", got, execRes.Payload)
}
}
@@ -7220,7 +7346,7 @@ func TestXAIExecutorAliasesClientWebSearchWithExistingAliasCollision(t *testing.
if prepared.webSearchAlias != "clientfn_web_search_1" {
t.Fatalf("prepared.webSearchAlias = %q, want clientfn_web_search_1", prepared.webSearchAlias)
}
- tools := gjson.GetBytes(prepared.body, "tools").Array()
+ tools := ojson.GetBytes(prepared.body, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools length = %d, want 2; body=%s", len(tools), prepared.body)
}
@@ -7230,23 +7356,23 @@ func TestXAIExecutorAliasesClientWebSearchWithExistingAliasCollision(t *testing.
if got := tools[1].Get("name").String(); got != "clientfn_web_search_1" {
t.Fatalf("tools.1.name = %q, want clientfn_web_search_1; body=%s", got, prepared.body)
}
- if got := gjson.GetBytes(prepared.body, "input.0.name").String(); got != "clientfn_web_search" {
+ if got := ojson.GetBytes(prepared.body, "input.0.name").String(); got != "clientfn_web_search" {
t.Fatalf("input.0.name = %q, want original clientfn_web_search; body=%s", got, prepared.body)
}
- if got := gjson.GetBytes(prepared.body, "input.1.name").String(); got != "clientfn_web_search_1" {
+ if got := ojson.GetBytes(prepared.body, "input.1.name").String(); got != "clientfn_web_search_1" {
t.Fatalf("input.1.name = %q, want clientfn_web_search_1; body=%s", got, prepared.body)
}
// Verify restoration only targets clientfn_web_search_1
event := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"clientfn_web_search","call_id":"call_1"}}`)
restoredOriginal := restoreXAIClientWebSearchName(event, prepared.webSearchAlias)
- if got := gjson.GetBytes(restoredOriginal, "item.name").String(); got != "clientfn_web_search" {
+ if got := ojson.GetBytes(restoredOriginal, "item.name").String(); got != "clientfn_web_search" {
t.Fatalf("original clientfn_web_search must not be overwritten: %s", restoredOriginal)
}
eventAliased := []byte(`{"type":"response.output_item.done","item":{"type":"function_call","name":"clientfn_web_search_1","call_id":"call_2"}}`)
restoredAliased := restoreXAIClientWebSearchName(eventAliased, prepared.webSearchAlias)
- if got := gjson.GetBytes(restoredAliased, "item.name").String(); got != "web_search" {
+ if got := ojson.GetBytes(restoredAliased, "item.name").String(); got != "web_search" {
t.Fatalf("aliased clientfn_web_search_1 must be restored to web_search: %s", restoredAliased)
}
}
@@ -7276,7 +7402,7 @@ func TestXAIExecutorDoesNotAliasNamespacedWebSearchToolChoice(t *testing.T) {
t.Fatalf("prepareResponsesRequest() error = %v", errPrepare)
}
- allowedTools := gjson.GetBytes(prepared.body, "tool_choice.tools").Array()
+ allowedTools := ojson.GetBytes(prepared.body, "tool_choice.tools").Array()
if len(allowedTools) != 2 {
t.Fatalf("tool_choice.tools length = %d, want 2; body=%s", len(allowedTools), prepared.body)
}
@@ -7411,7 +7537,7 @@ func TestXAIExecutorAliasesReplayedReasoningCacheWebSearchCall(t *testing.T) {
}
// In the prepared body, the replayed function_call must have been aliased to clientfn_web_search
- input := gjson.GetBytes(prepared.body, "input").Array()
+ input := ojson.GetBytes(prepared.body, "input").Array()
foundReplayedCall := false
for _, item := range input {
if item.Get("type").String() == "function_call" && item.Get("call_id").String() == "call_search_1" {
@@ -7479,7 +7605,7 @@ func TestXAIExecutorFoldsNamespaceNamedWebSearchWithoutAliasing(t *testing.T) {
// Verify upstream tools contains the dispatcher named "web_search" (not clientfn_web_search)
foundDispatcher := false
- for _, tool := range gjson.GetBytes(gotBody, "tools").Array() {
+ for _, tool := range ojson.GetBytes(gotBody, "tools").Array() {
if tool.Get("name").String() == "web_search" {
foundDispatcher = true
}
@@ -7492,7 +7618,7 @@ func TestXAIExecutorFoldsNamespaceNamedWebSearchWithoutAliasing(t *testing.T) {
}
// Verify response unwrapped into child tool call
- output := gjson.GetBytes(resp.Payload, "output.0")
+ output := ojson.GetBytes(resp.Payload, "output.0")
if got := output.Get("name").String(); got != "query_web" {
t.Fatalf("output.0.name = %q, want query_web; payload=%s", got, resp.Payload)
}
@@ -7548,7 +7674,7 @@ func testApplyPatchResponsesExecutor(t *testing.T, provider string, exec interfa
t.Fatal(errExecute)
}
output = result.Payload
- root := gjson.ParseBytes(output)
+ root := ojson.ParseBytes(output)
if root.Get("response").Exists() {
root = root.Get("response")
}
@@ -7557,10 +7683,10 @@ func testApplyPatchResponsesExecutor(t *testing.T, provider string, exec interfa
}
}
body := <-bodies
- if gjson.GetBytes(body, "tools.0.type").String() != "function" || !gjson.GetBytes(body, "tools.0.parameters.properties.input").Exists() || gjson.GetBytes(body, "tool_choice.type").String() != "function" {
+ if ojson.GetBytes(body, "tools.0.type").String() != "function" || !ojson.GetBytes(body, "tools.0.parameters.properties.input").Exists() || ojson.GetBytes(body, "tool_choice.type").String() != "function" {
t.Fatalf("patch declaration missing: %s", body)
}
- if gjson.GetBytes(body, "input.0.arguments").String() != `{"input":"old\n"}` || gjson.GetBytes(body, "input.1.type").String() != "function_call_output" {
+ if ojson.GetBytes(body, "input.0.arguments").String() != `{"input":"old\n"}` || ojson.GetBytes(body, "input.1.type").String() != "function_call_output" {
t.Fatalf("history lost: %s", body)
}
})
@@ -7607,7 +7733,7 @@ func TestApplyPatchActualCodexExecutorNativePassthrough(t *testing.T) {
output = append(output, chunk.Payload...)
}
body := <-bodies
- if gjson.GetBytes(body, "tools.0").Raw != tool || gjson.GetBytes(body, "input.0").Raw != history {
+ if ojson.GetBytes(body, "tools.0").Raw != tool || ojson.GetBytes(body, "input.0").Raw != history {
t.Fatalf("native declarations/history altered: %s", body)
}
for _, event := range events {
@@ -7690,8 +7816,8 @@ func TestXAIApplyPatchFoldedNamespaceHTTP(t *testing.T) {
output = append(output, chunk.Payload...)
for _, line := range bytes.Split(chunk.Payload, []byte("\n")) {
payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
- if gjson.GetBytes(payload, "type").String() == "response.completed" {
- final = []byte(gjson.GetBytes(payload, "response").Raw)
+ if ojson.GetBytes(payload, "type").String() == "response.completed" {
+ final = []byte(ojson.GetBytes(payload, "response").Raw)
}
}
}
@@ -7702,14 +7828,14 @@ func TestXAIApplyPatchFoldedNamespaceHTTP(t *testing.T) {
}
final = result.Payload
}
- if gjson.GetBytes(final, "output.0.type").String() != "custom_tool_call" || gjson.GetBytes(final, "output.0.namespace").String() != "n" || gjson.GetBytes(final, "output.0.input").String() != "p" || gjson.GetBytes(final, "output.1.name").String() != "lookup0" || gjson.GetBytes(final, "output.1.arguments").String() != `{"x":1}` {
+ if ojson.GetBytes(final, "output.0.type").String() != "custom_tool_call" || ojson.GetBytes(final, "output.0.namespace").String() != "n" || ojson.GetBytes(final, "output.0.input").String() != "p" || ojson.GetBytes(final, "output.1.name").String() != "lookup0" || ojson.GetBytes(final, "output.1.arguments").String() != `{"x":1}` {
t.Fatalf("mixed namespace restoration: %s", final)
}
if stream && snapshotOnly && (bytes.Contains(output, []byte(`"response.custom_tool_call_input.delta"`)) || bytes.Count(output, []byte(`"type":"response.custom_tool_call_input.done"`)) != 1) {
t.Fatalf("snapshot-only completion invented progress or lost input.done: %s", output)
}
body := <-bodies
- if gjson.GetBytes(body, "tools.0.name").String() != "n" || gjson.GetBytes(body, "input.0.name").String() != "n" || gjson.GetBytes(body, "input.0.arguments").String() != `{"arguments":{"input":"old"},"name":"apply_patch"}` || gjson.GetBytes(body, "input.1.type").String() != "function_call_output" {
+ if ojson.GetBytes(body, "tools.0.name").String() != "n" || ojson.GetBytes(body, "input.0.name").String() != "n" || ojson.GetBytes(body, "input.0.arguments").String() != `{"arguments":{"input":"old"},"name":"apply_patch"}` || ojson.GetBytes(body, "input.1.type").String() != "function_call_output" {
t.Fatalf("dispatcher declaration/history: %s", body)
}
})
@@ -7836,8 +7962,8 @@ func TestXAIApplyPatchDispatcherEvidenceLifecycle(t *testing.T) {
output = append(output, chunk.Payload...)
for _, line := range bytes.Split(chunk.Payload, []byte("\n")) {
payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
- if gjson.GetBytes(payload, "type").String() == "response.completed" {
- final = []byte(gjson.GetBytes(payload, "response").Raw)
+ if ojson.GetBytes(payload, "type").String() == "response.completed" {
+ final = []byte(ojson.GetBytes(payload, "response").Raw)
}
}
}
@@ -7854,7 +7980,7 @@ func TestXAIApplyPatchDispatcherEvidenceLifecycle(t *testing.T) {
if mode != "http" && bytes.Count(output, []byte(`"type":"response.completed"`)) != 1 {
t.Fatalf("missing or repeated response completion: %s", output)
}
- item := gjson.GetBytes(final, "output.0")
+ item := ojson.GetBytes(final, "output.0")
if item.Get("name").String() != tc.wantName || item.Get("call_id").String() != "c" {
t.Fatalf("child identity lost: %s", final)
}
diff --git a/internal/runtime/executor/xai_executor_tokens.go b/internal/runtime/executor/xai_executor_tokens.go
index 2a3b5c9d431..6ffbbfb230f 100644
--- a/internal/runtime/executor/xai_executor_tokens.go
+++ b/internal/runtime/executor/xai_executor_tokens.go
@@ -5,16 +5,18 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
"github.com/tiktoken-go/tokenizer"
)
// CountTokens estimates token count for xAI Responses requests.
func (e *XAIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
- prepared, err := e.prepareResponsesRequest(ctx, req, opts, false)
+ prepared, err := e.prepareResponsesRequestDoc(ctx, req, opts, false)
if err != nil {
return cliproxyexecutor.Response{}, err
}
@@ -22,7 +24,9 @@ func (e *XAIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth,
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("xai executor: tokenizer init failed: %w", err)
}
- prepared.body = prepared.finalizePayload(prepared.body)
+ doc := prepared.ensureDoc()
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, prepared.finalizePayload), doc)
+ prepared.body = doc.Bytes()
count, err := countXAIInputTokens(enc, prepared.body)
if err != nil {
return cliproxyexecutor.Response{}, fmt.Errorf("xai executor: token counting failed: %w", err)
@@ -40,7 +44,7 @@ func countXAIInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
return 0, nil
}
- root := gjson.ParseBytes(body)
+ root := util.ParseGJSONBytesNoCopy(body)
segments := make([]string, 0, 32)
xaiAppendTokenString(&segments, root.Get("instructions"))
xaiCollectInputTokenSegments(root.Get("input"), &segments)
@@ -62,8 +66,8 @@ func countXAIInputTokens(enc tokenizer.Codec, body []byte) (int64, error) {
return int64(count), nil
}
-func xaiCollectInputTokenSegments(input gjson.Result, segments *[]string) {
- if input.Type == gjson.String {
+func xaiCollectInputTokenSegments(input ojson.Result, segments *[]string) {
+ if input.Type == ojson.String {
xaiAppendTokenString(segments, input)
return
}
@@ -87,8 +91,8 @@ func xaiCollectInputTokenSegments(input gjson.Result, segments *[]string) {
}
}
-func xaiCollectContentTokenSegments(content gjson.Result, segments *[]string) {
- if content.Type == gjson.String {
+func xaiCollectContentTokenSegments(content ojson.Result, segments *[]string) {
+ if content.Type == ojson.String {
xaiAppendTokenString(segments, content)
return
}
@@ -116,7 +120,7 @@ func xaiCollectContentTokenSegments(content gjson.Result, segments *[]string) {
}
}
-func xaiCollectToolTokenSegments(tools gjson.Result, segments *[]string) {
+func xaiCollectToolTokenSegments(tools ojson.Result, segments *[]string) {
if !tools.IsArray() {
return
}
@@ -130,17 +134,17 @@ func xaiCollectToolTokenSegments(tools gjson.Result, segments *[]string) {
}
}
-func xaiAppendTokenString(segments *[]string, value gjson.Result) {
+func xaiAppendTokenString(segments *[]string, value ojson.Result) {
if text := strings.TrimSpace(value.String()); text != "" {
*segments = append(*segments, text)
}
}
-func xaiAppendTokenJSON(segments *[]string, value gjson.Result) {
+func xaiAppendTokenJSON(segments *[]string, value ojson.Result) {
if !value.Exists() {
return
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
xaiAppendTokenString(segments, value)
return
}
diff --git a/internal/runtime/executor/xai_reasoning_replay.go b/internal/runtime/executor/xai_reasoning_replay.go
index 3cb31650c51..f27d4f2f51d 100644
--- a/internal/runtime/executor/xai_reasoning_replay.go
+++ b/internal/runtime/executor/xai_reasoning_replay.go
@@ -6,14 +6,15 @@ import (
"encoding/hex"
"strings"
+ "github.com/MoeForge/moego/ojson"
internalcache "github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
type xaiReasoningReplayScope struct {
@@ -28,39 +29,51 @@ func (s xaiReasoningReplayScope) valid() bool {
}
func applyXAIReasoningReplayCacheRequired(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) ([]byte, xaiReasoningReplayScope, error) {
- scope := xaiReasoningReplayScopeFromRequest(ctx, from, req, opts, body)
+ doc := ojson.NewDoc(body)
+ scope, changed, err := applyXAIReasoningReplayCacheRequiredDoc(ctx, from, req, opts, doc)
+ if err != nil || !changed {
+ return body, scope, err
+ }
+ return doc.Bytes(), scope, nil
+}
+
+func applyXAIReasoningReplayCacheRequiredDoc(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) (xaiReasoningReplayScope, bool, error) {
+ if doc == nil {
+ return xaiReasoningReplayScope{}, false, nil
+ }
+ scope := xaiReasoningReplayScopeFromRequestDoc(ctx, from, req, opts, doc)
if !scope.valid() {
- return body, scope, nil
+ return scope, false, nil
}
items, ok, errReplay := getXAIReasoningReplayItemsRequired(ctx, scope.modelName, scope.sessionKey)
if errReplay != nil {
log.Warnf("xai reasoning replay cache read failed: %v", errReplay)
- return body, scope, nil
+ return scope, false, nil
}
if !ok {
- return body, scope, nil
+ return scope, false, nil
}
- items = filterXAIReasoningReplayItemsForInput(body, items)
+ items = filterXAIReasoningReplayItemsForInputDoc(doc, items)
if len(items) == 0 {
- return body, scope, nil
+ return scope, false, nil
}
- updated, ok := insertCodexReasoningReplayItems(body, items)
- if !ok {
- return body, scope, nil
- }
- return updated, scope, nil
+ return scope, insertCodexReasoningReplayItemsDoc(doc, items), nil
}
func xaiReasoningReplayScopeFromRequest(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, body []byte) xaiReasoningReplayScope {
+ return xaiReasoningReplayScopeFromRequestDoc(ctx, from, req, opts, ojson.NewDoc(body))
+}
+
+func xaiReasoningReplayScopeFromRequestDoc(ctx context.Context, from sdktranslator.Format, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, doc *ojson.Doc) xaiReasoningReplayScope {
if !xaiReasoningReplayEnabledForSource(from) {
return xaiReasoningReplayScope{}
}
// End-to-end WebSocket requests use upstream previous_response_id state.
// Replaying encrypted reasoning as input as well would duplicate the turn.
- if cliproxyexecutor.DownstreamWebsocket(ctx) && strings.TrimSpace(gjson.GetBytes(req.Payload, "previous_response_id").String()) != "" {
+ if cliproxyexecutor.DownstreamWebsocket(ctx) && strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "previous_response_id").String()) != "" {
return xaiReasoningReplayScope{}
}
- sessionKey := codexReasoningReplaySessionKey(ctx, from, req, opts, body)
+ sessionKey := codexReasoningReplaySessionKeyDoc(ctx, from, req, opts, doc)
sessionKey = xaiReasoningReplayIsolateSessionKey(ctx, sessionKey)
return xaiReasoningReplayScope{
modelName: thinking.ParseSuffix(req.Model).ModelName,
@@ -102,7 +115,7 @@ func canonicalXAIReasoningReplaySignatureKey(rawSignature string) string {
return sig
}
-func xaiInputHasReasoningEncryptedContent(inputItems []gjson.Result, encryptedContent string) bool {
+func xaiInputHasReasoningEncryptedContent(inputItems []ojson.Result, encryptedContent string) bool {
canonicalTarget := canonicalXAIReasoningReplaySignatureKey(encryptedContent)
if canonicalTarget == "" {
return false
@@ -112,7 +125,7 @@ func xaiInputHasReasoningEncryptedContent(inputItems []gjson.Result, encryptedCo
continue
}
inputEncryptedContent := item.Get("encrypted_content")
- if inputEncryptedContent.Type != gjson.String {
+ if inputEncryptedContent.Type != ojson.String {
continue
}
if canonicalXAIReasoningReplaySignatureKey(inputEncryptedContent.String()) == canonicalTarget {
@@ -123,7 +136,17 @@ func xaiInputHasReasoningEncryptedContent(inputItems []gjson.Result, encryptedCo
}
func filterXAIReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte {
- input := gjson.GetBytes(body, "input")
+ return filterXAIReasoningReplayItemsForInputResult(ojson.GetBytesNoCopy(body, "input"), items)
+}
+
+func filterXAIReasoningReplayItemsForInputDoc(doc *ojson.Doc, items [][]byte) [][]byte {
+ if doc == nil {
+ return nil
+ }
+ return filterXAIReasoningReplayItemsForInputResult(doc.Get("input"), items)
+}
+
+func filterXAIReasoningReplayItemsForInputResult(input ojson.Result, items [][]byte) [][]byte {
if !input.IsArray() {
return nil
}
@@ -156,7 +179,7 @@ func filterXAIReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte
filtered := make([][]byte, 0, len(items))
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
switch strings.TrimSpace(itemResult.Get("type").String()) {
case "reasoning":
if xaiInputHasReasoningEncryptedContent(inputItems, itemResult.Get("encrypted_content").String()) {
@@ -195,7 +218,7 @@ func filterXAIReasoningReplayItemsForInput(body []byte, items [][]byte) [][]byte
return filtered
}
-func xaiInputLastAssistantMessage(inputItems []gjson.Result) (gjson.Result, bool) {
+func xaiInputLastAssistantMessage(inputItems []ojson.Result) (ojson.Result, bool) {
for i := len(inputItems) - 1; i >= 0; i-- {
inputItem := inputItems[i]
itemType := strings.TrimSpace(inputItem.Get("type").String())
@@ -204,18 +227,18 @@ func xaiInputLastAssistantMessage(inputItems []gjson.Result) (gjson.Result, bool
}
return inputItem, true
}
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
-func xaiReplayAssistantMessage(items [][]byte) (gjson.Result, bool) {
+func xaiReplayAssistantMessage(items [][]byte) (ojson.Result, bool) {
for _, item := range items {
- itemResult := gjson.ParseBytes(item)
+ itemResult := util.ParseGJSONBytesNoCopy(item)
if strings.TrimSpace(itemResult.Get("type").String()) == "message" &&
strings.EqualFold(strings.TrimSpace(itemResult.Get("role").String()), "assistant") {
return itemResult, true
}
}
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
type xaiAssistantMessagePart struct {
@@ -223,7 +246,7 @@ type xaiAssistantMessagePart struct {
value string
}
-func xaiAssistantMessageContentEqual(left, right gjson.Result) bool {
+func xaiAssistantMessageContentEqual(left, right ojson.Result) bool {
leftParts, leftOK := xaiAssistantMessageParts(left)
rightParts, rightOK := xaiAssistantMessageParts(right)
if !leftOK || !rightOK || len(leftParts) != len(rightParts) {
@@ -237,8 +260,8 @@ func xaiAssistantMessageContentEqual(left, right gjson.Result) bool {
return true
}
-func xaiAssistantMessageParts(content gjson.Result) ([]xaiAssistantMessagePart, bool) {
- if content.Type == gjson.String {
+func xaiAssistantMessageParts(content ojson.Result) ([]xaiAssistantMessagePart, bool) {
+ if content.Type == ojson.String {
return []xaiAssistantMessagePart{{partType: "output_text", value: content.String()}}, true
}
if !content.IsArray() {
@@ -250,13 +273,13 @@ func xaiAssistantMessageParts(content gjson.Result) ([]xaiAssistantMessagePart,
switch partType {
case "output_text":
text := part.Get("text")
- if text.Type != gjson.String {
+ if text.Type != ojson.String {
return nil, false
}
parts = append(parts, xaiAssistantMessagePart{partType: partType, value: text.String()})
case "refusal":
refusal := part.Get("refusal")
- if refusal.Type != gjson.String {
+ if refusal.Type != ojson.String {
return nil, false
}
parts = append(parts, xaiAssistantMessagePart{partType: partType, value: refusal.String()})
@@ -274,7 +297,7 @@ func cacheXAIReasoningReplayFromCompleted(ctx context.Context, scope xaiReasonin
if ctx == nil {
ctx = context.Background()
}
- output := gjson.GetBytes(completedData, "response.output")
+ output := ojson.GetBytesNoCopy(completedData, "response.output")
if !output.IsArray() {
return
}
diff --git a/internal/runtime/executor/xai_websockets_executor.go b/internal/runtime/executor/xai_websockets_executor.go
index 276ba250198..a86beff28ca 100644
--- a/internal/runtime/executor/xai_websockets_executor.go
+++ b/internal/runtime/executor/xai_websockets_executor.go
@@ -15,6 +15,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -23,8 +24,6 @@ import (
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// XAIWebsocketsExecutor executes xAI Responses requests using a WebSocket transport.
@@ -109,7 +108,7 @@ func newXAIWebsocketRequestIDMapper(store *xaiWebsocketIDStateStore, sessionID s
if state == nil {
return nil
}
- downstreamPreviousID := strings.TrimSpace(gjson.GetBytes(downstreamRequest, "previous_response_id").String())
+ downstreamPreviousID := strings.TrimSpace(ojson.GetBytesNoCopy(downstreamRequest, "previous_response_id").String())
upstreamPreviousID := downstreamPreviousID
if downstreamPreviousID != "" {
upstreamPreviousID = state.upstreamIDForDownstream(downstreamPreviousID)
@@ -163,6 +162,17 @@ func (s *xaiWebsocketIDState) prependTranscriptInput(payload []byte) []byte {
if s == nil || len(payload) == 0 {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !s.prependTranscriptInputDoc(doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func (s *xaiWebsocketIDState) prependTranscriptInputDoc(doc *ojson.Doc) bool {
+ if s == nil || doc == nil {
+ return false
+ }
s.mu.Lock()
prefix := make([]json.RawMessage, 0, len(s.transcriptInput))
for _, item := range s.transcriptInput {
@@ -170,23 +180,19 @@ func (s *xaiWebsocketIDState) prependTranscriptInput(payload []byte) []byte {
}
s.mu.Unlock()
if len(prefix) == 0 {
- return payload
+ return false
}
- current := xaiJSONRawMessages(gjson.GetBytes(payload, "input"))
+ current := xaiJSONRawMessages(doc.Get("input"))
merged := append(prefix, current...)
- out, errSet := sjson.SetRawBytes(payload, "input", xaiMarshalRawMessages(merged))
- if errSet != nil {
- return payload
- }
- return out
+ return doc.SetRawBytes("input", xaiMarshalRawMessages(merged)) == nil
}
func (s *xaiWebsocketIDState) recordTranscriptTurn(requestPayload []byte, completedPayload []byte, reset bool) {
if s == nil || len(requestPayload) == 0 || len(completedPayload) == 0 {
return
}
- inputItems := xaiJSONRawMessages(gjson.GetBytes(requestPayload, "input"))
- outputItems := xaiJSONRawMessages(gjson.GetBytes(completedPayload, "response.output"))
+ inputItems := xaiJSONRawMessages(ojson.GetBytesNoCopy(requestPayload, "input"))
+ outputItems := xaiJSONRawMessages(ojson.GetBytesNoCopy(completedPayload, "response.output"))
s.mu.Lock()
defer s.mu.Unlock()
@@ -223,10 +229,21 @@ func (s *xaiWebsocketIDState) prependCompactedTranscriptOnReset(payload []byte)
if s == nil || len(payload) == 0 {
return payload, false
}
+ doc := ojson.NewDoc(payload)
+ if !s.prependCompactedTranscriptOnResetDoc(doc) {
+ return payload, false
+ }
+ return doc.Bytes(), true
+}
+
+func (s *xaiWebsocketIDState) prependCompactedTranscriptOnResetDoc(doc *ojson.Doc) bool {
+ if s == nil || doc == nil {
+ return false
+ }
s.mu.Lock()
if !s.replayCompactedTranscriptOnReset || len(s.transcriptInput) == 0 {
s.mu.Unlock()
- return payload, false
+ return false
}
prefix := make([]json.RawMessage, 0, len(s.transcriptInput))
for _, item := range s.transcriptInput {
@@ -234,16 +251,12 @@ func (s *xaiWebsocketIDState) prependCompactedTranscriptOnReset(payload []byte)
}
s.mu.Unlock()
- current := xaiJSONRawMessages(gjson.GetBytes(payload, "input"))
+ current := xaiJSONRawMessages(doc.Get("input"))
merged := append(prefix, current...)
- out, errSet := sjson.SetRawBytes(payload, "input", xaiMarshalRawMessages(merged))
- if errSet != nil {
- return payload, false
- }
- return out, true
+ return doc.SetRawBytes("input", xaiMarshalRawMessages(merged)) == nil
}
-func xaiJSONRawMessages(result gjson.Result) []json.RawMessage {
+func xaiJSONRawMessages(result ojson.Result) []json.RawMessage {
if !result.Exists() || !result.IsArray() {
return nil
}
@@ -276,38 +289,44 @@ func (m *xaiWebsocketRequestIDMapper) upstreamRequestPayload(payload []byte) []b
if m == nil || len(payload) == 0 {
return payload
}
+ doc := ojson.NewDoc(payload)
+ if !m.upstreamRequestPayloadDoc(doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func (m *xaiWebsocketRequestIDMapper) upstreamRequestPayloadDoc(doc *ojson.Doc) bool {
+ if m == nil || doc == nil {
+ return false
+ }
if m.downstreamPreviousID == m.upstreamPreviousID {
- requestType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
+ requestType := strings.TrimSpace(doc.Get("type").String())
if m.downstreamPreviousID == "" && requestType == "response.append" && m.state != nil {
- out, replayed := m.state.prependCompactedTranscriptOnReset(payload)
+ replayed := m.state.prependCompactedTranscriptOnResetDoc(doc)
m.replayedCompactedTranscript = replayed
- return out
+ return replayed
}
- return payload
+ return false
}
if m.upstreamPreviousID == "" {
- out, errDelete := sjson.DeleteBytes(payload, "previous_response_id")
- if errDelete == nil {
+ if errDelete := doc.Delete("previous_response_id"); errDelete == nil {
if m.downstreamPreviousID != "" && m.state != nil {
- out = m.state.prependTranscriptInput(out)
+ m.state.prependTranscriptInputDoc(doc)
m.replayedCompactedTranscript = true
}
- return out
+ return true
}
- return payload
- }
- out, errSet := sjson.SetBytes(payload, "previous_response_id", m.upstreamPreviousID)
- if errSet != nil {
- return payload
+ return false
}
- return out
+ return doc.Set("previous_response_id", m.upstreamPreviousID) == nil
}
func (m *xaiWebsocketRequestIDMapper) downstreamResponsePayload(payload []byte) []byte {
if m == nil || len(payload) == 0 {
return payload
}
- upstreamResponseID := strings.TrimSpace(gjson.GetBytes(payload, "response.id").String())
+ upstreamResponseID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response.id").String())
downstreamResponseID := m.downstreamIDForUpstreamResponse(upstreamResponseID)
if downstreamResponseID == "" {
return payload
@@ -471,7 +490,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
baseURL = xaiauth.DefaultAPIBaseURL
}
- prepared, err := e.prepareResponsesWebsocketRequest(ctx, req, opts)
+ prepared, err := e.prepareResponsesWebsocketRequestDoc(ctx, req, opts)
if err != nil {
return nil, err
}
@@ -512,18 +531,20 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
}
idMapper := newXAIWebsocketRequestIDMapper(e.idStore, stateSessionID, req.Payload)
sessCfg := sess.effectiveConfig(authID, e.cfg)
+ doc := prepared.ensureDoc()
if idMapper != nil {
if websocketSessionTargetChanged(sess, authID, wsURL, executionProxyURL(ctx, sessCfg, auth)) {
idMapper.upstreamPreviousID = ""
}
- prepared.body = idMapper.upstreamRequestPayload(prepared.body)
+ idMapper.upstreamRequestPayloadDoc(doc)
}
- prepared.body = sanitizeXAIInputEncryptedContentForBaseURL(prepared.body, baseURL)
+ sanitizeXAIInputEncryptedContentForBaseURLDoc(doc, baseURL)
wsHeaders := applyXAIWebsocketHeaders(ctx, http.Header{}, auth, token, prepared.sessionID, opts.Headers)
- wsReqBody := buildXAIWebsocketRequestBody(prepared.body, prepared.finalizePayload)
+ wsReqBody := buildXAIWebsocketRequestBodyDoc(ctx, doc, prepared.finalizePayload)
+ prepared.body = wsReqBody
reporter.SetTranslatedReasoningEffort(wsReqBody, e.Identifier())
- requestType := strings.TrimSpace(gjson.GetBytes(req.Payload, "type").String())
- transcriptReset := strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) == "" &&
+ requestType := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "type").String())
+ transcriptReset := strings.TrimSpace(ojson.GetBytesNoCopy(wsReqBody, "previous_response_id").String()) == "" &&
(requestType != "response.append" || (idMapper != nil && idMapper.replayedCompactedTranscript))
warmupRequest := xaiWebsocketGenerateFalse(wsReqBody)
@@ -808,7 +829,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
}
for _, payload := range xaiNormalizeReasoningSummaryDataEvents(payload) {
- if rawEventType := gjson.GetBytes(payload, "type").String(); rawEventType == "response.output_item.done" || rawEventType == "response.completed" || rawEventType == "response.incomplete" || rawEventType == "response.done" {
+ if rawEventType := ojson.GetBytesNoCopy(payload, "type").String(); rawEventType == "response.output_item.done" || rawEventType == "response.completed" || rawEventType == "response.incomplete" || rawEventType == "response.done" {
streamUsage.ObserveResponsesStream(payload)
}
prepared.applyPatch.RememberDispatcherEvent(payload)
@@ -836,7 +857,7 @@ func (e *XAIWebsocketsExecutor) ExecuteStream(ctx context.Context, auth *cliprox
return
}
for _, payload := range events {
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
patchTerminal := prepared.applyPatch.Active() && (eventType == "response.incomplete" || eventType == "response.failed")
isTerminalEvent := eventType == "response.completed" || eventType == "response.done" || eventType == "error" || patchTerminal
reporter.ObserveResponseModel(payload)
@@ -956,10 +977,10 @@ func (e *XAIWebsocketsExecutor) executeCompactionTriggerFromWebsocketContext(ctx
if errBuild != nil {
return nil, errBuild
}
- inputItemsCount = len(gjson.ParseBytes(transcriptInput).Array())
+ inputItemsCount = len(util.ParseGJSONBytesNoCopy(transcriptInput).Array())
} else {
filteredPayload := xaiRemoveInputItemsByType(req.Payload, "compaction_trigger")
- payloadInput := gjson.GetBytes(filteredPayload, "input")
+ payloadInput := ojson.GetBytesNoCopy(filteredPayload, "input")
if payloadInput.IsArray() && len(payloadInput.Array()) > 0 {
var errBuild error
compactPayload, errBuild = buildXAIWebsocketCompactionPayload(filteredPayload, []byte(payloadInput.Raw))
@@ -970,13 +991,14 @@ func (e *XAIWebsocketsExecutor) executeCompactionTriggerFromWebsocketContext(ctx
} else {
prevID := idMapper.upstreamPreviousID
if prevID == "" {
- prevID = strings.TrimSpace(gjson.GetBytes(req.Payload, "previous_response_id").String())
+ prevID = strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "previous_response_id").String())
}
if prevID != "" {
keepPreviousResponseID = true
- compactPayload = bytes.Clone(req.Payload)
- compactPayload = xaiRemoveInputItemsByType(compactPayload, "compaction_trigger")
- compactPayload, _ = sjson.SetBytes(compactPayload, "previous_response_id", prevID)
+ compactDoc := ojson.NewDoc(req.Payload)
+ xaiRemoveInputItemsByTypeDoc(compactDoc, "compaction_trigger")
+ _ = compactDoc.Set("previous_response_id", prevID)
+ compactPayload = compactDoc.Bytes()
} else {
return nil, statusErr{code: http.StatusBadRequest, msg: "xai websocket compaction context is empty"}
}
@@ -1029,9 +1051,9 @@ func validateXAIWebsocketCompactionResponse(data []byte) (string, []byte, error)
if len(data) == 0 || !json.Valid(data) {
return "", nil, statusErr{code: http.StatusBadGateway, msg: "xai websocket compaction returned invalid JSON"}
}
- responseIDResult := gjson.GetBytes(data, "id")
- output := gjson.GetBytes(data, "output")
- if responseIDResult.Type != gjson.String || strings.TrimSpace(responseIDResult.String()) == "" || !output.Exists() || !output.IsArray() {
+ responseIDResult := ojson.GetBytesNoCopy(data, "id")
+ output := ojson.GetBytesNoCopy(data, "output")
+ if responseIDResult.Type != ojson.String || strings.TrimSpace(responseIDResult.String()) == "" || !output.Exists() || !output.IsArray() {
return "", nil, statusErr{code: http.StatusBadGateway, msg: "xai websocket compaction response is missing compacted state"}
}
items := output.Array()
@@ -1041,8 +1063,8 @@ func validateXAIWebsocketCompactionResponse(data []byte) (string, []byte, error)
item := items[0]
itemType := item.Get("type")
encryptedContent := item.Get("encrypted_content")
- if item.Type != gjson.JSON || itemType.Type != gjson.String || strings.TrimSpace(itemType.String()) != "compaction" ||
- encryptedContent.Type != gjson.String || strings.TrimSpace(encryptedContent.String()) == "" {
+ if item.Type != ojson.JSON || itemType.Type != ojson.String || strings.TrimSpace(itemType.String()) != "compaction" ||
+ encryptedContent.Type != ojson.String || strings.TrimSpace(encryptedContent.String()) == "" {
return "", nil, statusErr{code: http.StatusBadGateway, msg: "xai websocket compaction response is missing compacted state"}
}
normalizedResponseID := xaiCompactionResponseID(data)
@@ -1056,38 +1078,36 @@ func buildXAIWebsocketCompactionPayload(payload []byte, transcriptInput []byte)
if len(transcriptInput) == 0 {
transcriptInput = []byte("[]")
}
- out := bytes.Clone(payload)
- var err error
- out, err = sjson.SetRawBytes(out, "input", transcriptInput)
- if err != nil {
- return nil, err
+ doc := ojson.NewDoc(payload)
+ if errSet := doc.SetRawBytes("input", transcriptInput); errSet != nil {
+ return nil, errSet
}
- out, _ = sjson.DeleteBytes(out, "previous_response_id")
- return out, nil
+ _ = doc.Delete("previous_response_id")
+ return doc.Bytes(), nil
}
func xaiWebsocketGenerateFalse(payload []byte) bool {
- generate := gjson.GetBytes(payload, "generate")
+ generate := ojson.GetBytesNoCopy(payload, "generate")
return generate.Exists() && !generate.Bool()
}
func buildXAIWebsocketWarmupCompletedPayload(createdPayload []byte) []byte {
- completed := []byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`)
- if sequence := gjson.GetBytes(createdPayload, "sequence_number"); sequence.Exists() {
- completed, _ = sjson.SetBytes(completed, "sequence_number", sequence.Int()+1)
- }
- if response := gjson.GetBytes(createdPayload, "response"); response.Exists() && response.IsObject() {
- responsePayload := []byte(response.Raw)
- responsePayload, _ = sjson.SetBytes(responsePayload, "status", "completed")
- if !gjson.GetBytes(responsePayload, "output").Exists() {
- responsePayload, _ = sjson.SetRawBytes(responsePayload, "output", []byte("[]"))
+ completedDoc := ojson.NewDoc([]byte(`{"type":"response.completed","response":{"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`))
+ if sequence := ojson.GetBytesNoCopy(createdPayload, "sequence_number"); sequence.Exists() {
+ _ = completedDoc.Set("sequence_number", sequence.Int()+1)
+ }
+ if response := ojson.GetBytesNoCopy(createdPayload, "response"); response.Exists() && response.IsObject() {
+ respDoc := ojson.NewDoc([]byte(response.Raw))
+ _ = respDoc.Set("status", "completed")
+ if !respDoc.Get("output").Exists() {
+ _ = respDoc.SetRawBytes("output", []byte("[]"))
}
- if !gjson.GetBytes(responsePayload, "usage").Exists() {
- responsePayload, _ = sjson.SetRawBytes(responsePayload, "usage", []byte(`{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`))
+ if !respDoc.Get("usage").Exists() {
+ _ = respDoc.SetRawBytes("usage", []byte(`{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`))
}
- completed, _ = sjson.SetRawBytes(completed, "response", responsePayload)
+ _ = completedDoc.SetRawBytes("response", respDoc.Bytes())
}
- return helps.EnsureResponsesUsageDetails(completed)
+ return helps.EnsureResponsesUsageDetails(completedDoc.Bytes())
}
func parseXAIWebsocketError(payload []byte) (error, bool) {
@@ -1104,28 +1124,28 @@ func parseXAIWebsocketError(payload []byte) (error, bool) {
}
return wsErr, true
}
- if len(payload) == 0 || !gjson.GetBytes(payload, "error").Exists() {
+ if len(payload) == 0 || !ojson.GetBytesNoCopy(payload, "error").Exists() {
return nil, false
}
- status := int(gjson.GetBytes(payload, "status").Int())
+ status := int(ojson.GetBytesNoCopy(payload, "status").Int())
if status <= 0 {
- status = int(gjson.GetBytes(payload, "status_code").Int())
+ status = int(ojson.GetBytesNoCopy(payload, "status_code").Int())
}
if status <= 0 {
status = xaiBareWebsocketErrorStatus(payload)
}
- out := []byte(`{}`)
- out, _ = sjson.SetBytes(out, "type", "error")
- out, _ = sjson.SetBytes(out, "status", status)
- if errNode := gjson.GetBytes(payload, "error"); errNode.Exists() {
- out, _ = sjson.SetRawBytes(out, "error", []byte(errNode.Raw))
+ outDoc := ojson.NewDoc([]byte(`{}`))
+ _ = outDoc.Set("type", "error")
+ _ = outDoc.Set("status", status)
+ if errNode := ojson.GetBytesNoCopy(payload, "error"); errNode.Exists() {
+ _ = outDoc.SetRawBytes("error", []byte(errNode.Raw))
}
- return xaiStatusErr(status, out), true
+ return xaiStatusErr(status, outDoc.Bytes()), true
}
func xaiBareWebsocketErrorStatus(payload []byte) int {
for _, path := range []string{"error.code", "error.status", "code"} {
- raw := strings.TrimSpace(gjson.GetBytes(payload, path).String())
+ raw := strings.TrimSpace(ojson.GetBytesNoCopy(payload, path).String())
if raw == "" {
continue
}
@@ -1134,7 +1154,7 @@ func xaiBareWebsocketErrorStatus(payload []byte) int {
return status
}
}
- message := strings.TrimSpace(gjson.GetBytes(payload, "error.message").String())
+ message := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "error.message").String())
if strings.Contains(message, `"code":"400"`) || strings.Contains(message, "Request validation error") {
return http.StatusBadRequest
}
@@ -1142,12 +1162,23 @@ func xaiBareWebsocketErrorStatus(payload []byte) int {
}
func (e *XAIWebsocketsExecutor) prepareResponsesWebsocketRequest(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*xaiPreparedRequest, error) {
- prepared, err := e.prepareResponsesRequest(ctx, req, opts, true)
+ prepared, err := e.prepareResponsesWebsocketRequestDoc(ctx, req, opts)
if err != nil {
return nil, err
}
- if previousResponseID := strings.TrimSpace(gjson.GetBytes(req.Payload, "previous_response_id").String()); previousResponseID != "" {
- prepared.body, _ = sjson.SetBytes(prepared.body, "previous_response_id", previousResponseID)
+ if prepared.doc != nil {
+ prepared.body = prepared.doc.Bytes()
+ }
+ return prepared, nil
+}
+
+func (e *XAIWebsocketsExecutor) prepareResponsesWebsocketRequestDoc(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*xaiPreparedRequest, error) {
+ prepared, err := e.prepareResponsesRequestDoc(ctx, req, opts, true)
+ if err != nil {
+ return nil, err
+ }
+ if previousResponseID := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "previous_response_id").String()); previousResponseID != "" {
+ _ = prepared.ensureDoc().Set("previous_response_id", previousResponseID)
}
return prepared, nil
}
@@ -1409,7 +1440,7 @@ func (e *XAIWebsocketsExecutor) readUpstreamLoop(sess *codexWebsocketSession, co
payload = bytes.TrimSpace(payload)
if len(payload) > 0 {
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := strings.Clone(ojson.GetBytesNoCopy(payload, "type").String())
if eventType != "" {
sess.setLastEventType(conn, eventType)
}
@@ -1613,20 +1644,26 @@ func buildXAIWebsocketRequestBody(body []byte, finalizers ...helps.PayloadFinali
if len(body) == 0 {
return nil
}
- wsReqBody := bytes.Clone(body)
- wsReqBody, _ = sjson.SetBytes(wsReqBody, "type", "response.create")
- wsReqBody, _ = sjson.DeleteBytes(wsReqBody, "stream")
- wsReqBody, _ = sjson.DeleteBytes(wsReqBody, "stream_options")
- wsReqBody, _ = sjson.DeleteBytes(wsReqBody, "background")
- wsReqBody, _ = sjson.SetBytes(wsReqBody, "store", true)
- if strings.TrimSpace(gjson.GetBytes(wsReqBody, "previous_response_id").String()) != "" {
- wsReqBody, _ = sjson.DeleteBytes(wsReqBody, "instructions")
+ return buildXAIWebsocketRequestBodyDoc(context.Background(), ojson.NewDoc(body), finalizers...)
+}
+
+func buildXAIWebsocketRequestBodyDoc(ctx context.Context, doc *ojson.Doc, finalizers ...helps.PayloadFinalizer) []byte {
+ if doc == nil || doc.Len() == 0 {
+ return nil
+ }
+ _ = doc.Set("type", "response.create")
+ _ = doc.Delete("stream")
+ _ = doc.Delete("stream_options")
+ _ = doc.Delete("background")
+ _ = doc.Set("store", true)
+ if strings.TrimSpace(doc.Get("previous_response_id").String()) != "" {
+ _ = doc.Delete("instructions")
}
if len(finalizers) > 0 && finalizers[0] != nil {
- wsReqBody = finalizers[0](wsReqBody)
+ helps.FinalizePayloadDoc(helps.WithPayloadFinalizer(ctx, finalizers[0]), doc)
}
- wsReqBody, _ = sjson.SetBytes(wsReqBody, "type", "response.create")
- return wsReqBody
+ _ = doc.Set("type", "response.create")
+ return doc.Bytes()
}
func buildXAIResponsesWebsocketURL(httpURL string) (string, error) {
@@ -1683,7 +1720,7 @@ func logXAIWebsocketRequest(sessionID string, authID string, wsURL string, paylo
return
}
generateValue := "default"
- if generate := gjson.GetBytes(payload, "generate"); generate.Exists() {
+ if generate := ojson.GetBytesNoCopy(payload, "generate"); generate.Exists() {
generateValue = strings.TrimSpace(generate.Raw)
}
log.Infof(
@@ -1691,10 +1728,10 @@ func logXAIWebsocketRequest(sessionID string, authID string, wsURL string, paylo
strings.TrimSpace(sessionID),
strings.TrimSpace(authID),
strings.TrimSpace(wsURL),
- strings.TrimSpace(gjson.GetBytes(payload, "type").String()),
- strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String()),
+ strings.TrimSpace(ojson.GetBytesNoCopy(payload, "type").String()),
+ strings.TrimSpace(ojson.GetBytesNoCopy(payload, "previous_response_id").String()),
generateValue,
- len(gjson.GetBytes(payload, "input").Array()),
+ len(ojson.GetBytesNoCopy(payload, "input").Array()),
)
}
@@ -1704,7 +1741,7 @@ func logXAIWebsocketWarmupCompleted(sessionID string, authID string, wsURL strin
strings.TrimSpace(sessionID),
strings.TrimSpace(authID),
strings.TrimSpace(wsURL),
- strings.TrimSpace(gjson.GetBytes(payload, "response.id").String()),
+ strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response.id").String()),
)
}
@@ -1715,8 +1752,8 @@ func logXAIWebsocketTerminalResponse(sessionID string, authID string, wsURL stri
strings.TrimSpace(authID),
strings.TrimSpace(wsURL),
strings.TrimSpace(eventType),
- strings.TrimSpace(gjson.GetBytes(payload, "response.id").String()),
- strings.TrimSpace(gjson.GetBytes(payload, "response.previous_response_id").String()),
+ strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response.id").String()),
+ strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response.previous_response_id").String()),
)
}
diff --git a/internal/runtime/executor/xai_websockets_executor_test.go b/internal/runtime/executor/xai_websockets_executor_test.go
index 0c503941e33..5e3a11950b4 100644
--- a/internal/runtime/executor/xai_websockets_executor_test.go
+++ b/internal/runtime/executor/xai_websockets_executor_test.go
@@ -13,13 +13,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestXAIWebsocketsEnabledForConfigAPIKey(t *testing.T) {
@@ -58,7 +58,7 @@ func TestXAIAutoExecutorRequiredUpstreamWebsocketRejectsHTTPFallback(t *testing.
if !ok || statusErr.StatusCode() != http.StatusUpgradeRequired {
t.Fatalf("ExecuteStream() error = %T %v, want status 426", errExecute, errExecute)
}
- if got := gjson.Get(errExecute.Error(), "error.code").String(); got != "upstream_http_replay_required" {
+ if got := ojson.Get(errExecute.Error(), "error.code").String(); got != "upstream_http_replay_required" {
t.Fatalf("ExecuteStream() error code = %q, want upstream_http_replay_required", got)
}
requestScoped, ok := errExecute.(cliproxyexecutor.RequestScopedError)
@@ -254,7 +254,7 @@ func TestXAIWebsocketsExecuteStreamMapsMessageTooBigClose(t *testing.T) {
if !ok || statusErr.StatusCode() != http.StatusRequestEntityTooLarge {
t.Fatalf("status error = %v, want %d; err=%v", statusErr, http.StatusRequestEntityTooLarge, chunk.Err)
}
- if got := gjson.Get(chunk.Err.Error(), "error.code").String(); got != "message_too_big" {
+ if got := ojson.Get(chunk.Err.Error(), "error.code").String(); got != "message_too_big" {
t.Fatalf("error code = %q, want message_too_big; err=%v", got, chunk.Err)
}
requestErr, ok := chunk.Err.(interface{ IsRequestScoped() bool })
@@ -334,22 +334,22 @@ func TestXAIWebsocketsExecuteStreamSendsResponseCreateWithPreviousResponseID(t *
select {
case payload := <-capturedPayload:
- if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(payload, "type").String(); got != "response.create" {
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "resp-prev" {
+ if got := ojson.GetBytes(payload, "previous_response_id").String(); got != "resp-prev" {
t.Fatalf("previous_response_id = %q, want resp-prev; payload=%s", got, payload)
}
- if gjson.GetBytes(payload, "stream").Exists() {
+ if ojson.GetBytes(payload, "stream").Exists() {
t.Fatalf("stream must be omitted for xAI websocket payload: %s", payload)
}
- if gjson.GetBytes(payload, "instructions").Exists() {
+ if ojson.GetBytes(payload, "instructions").Exists() {
t.Fatalf("instructions must be omitted when previous_response_id is set: %s", payload)
}
- if got := gjson.GetBytes(payload, "prompt_cache_key").String(); got != "execution-session-1" {
+ if got := ojson.GetBytes(payload, "prompt_cache_key").String(); got != "execution-session-1" {
t.Fatalf("prompt_cache_key = %q, want execution-session-1; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "store").Bool(); !got {
+ if got := ojson.GetBytes(payload, "store").Bool(); !got {
t.Fatalf("store = false, want true; payload=%s", payload)
}
case <-time.After(5 * time.Second):
@@ -364,7 +364,7 @@ func TestXAIWebsocketsExecuteStreamSendsResponseCreateWithPreviousResponseID(t *
if chunk.Err != nil {
t.Fatalf("chunk error = %v", chunk.Err)
}
- if got := gjson.GetBytes(bytes.TrimSpace(chunk.Payload), "type").String(); got != "response.completed" {
+ if got := ojson.GetBytes(bytes.TrimSpace(chunk.Payload), "type").String(); got != "response.completed" {
t.Fatalf("chunk type = %q, want response.completed; payload=%s", got, chunk.Payload)
}
case <-time.After(5 * time.Second):
@@ -440,15 +440,15 @@ func TestXAIWebsocketsExecuteStreamRestoresNamespaceToolCalls(t *testing.T) {
select {
case payload := <-capturedPayload:
- for _, item := range gjson.GetBytes(payload, "input").Array() {
+ for _, item := range ojson.GetBytes(payload, "input").Array() {
if got := item.Get("type").String(); got == "additional_tools" {
t.Fatalf("upstream input contains unsupported additional_tools item: %s", payload)
}
}
- if got := gjson.GetBytes(payload, "input.0.role").String(); got != "user" {
+ if got := ojson.GetBytes(payload, "input.0.role").String(); got != "user" {
t.Fatalf("input.0.role = %q, want user; payload=%s", got, payload)
}
- tool := gjson.GetBytes(payload, "tools.0")
+ tool := ojson.GetBytes(payload, "tools.0")
if got := tool.Get("name").String(); got != "mcp__exa__web_search_exa" {
t.Fatalf("upstream tool name = %q, want qualified name; payload=%s", got, payload)
}
@@ -459,12 +459,12 @@ func TestXAIWebsocketsExecuteStreamRestoresNamespaceToolCalls(t *testing.T) {
t.Fatal("timed out waiting for upstream websocket payload")
}
- var outputItemDone, completed gjson.Result
+ var outputItemDone, completed ojson.Result
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
- payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
+ payload := ojson.ParseBytes(bytes.TrimSpace(chunk.Payload))
switch payload.Get("type").String() {
case "response.output_item.done":
outputItemDone = payload
@@ -473,7 +473,7 @@ func TestXAIWebsocketsExecuteStreamRestoresNamespaceToolCalls(t *testing.T) {
}
}
- for label, item := range map[string]gjson.Result{
+ for label, item := range map[string]ojson.Result{
"output_item.done": outputItemDone.Get("item"),
"completed": completed.Get("response.output.0"),
} {
@@ -548,7 +548,7 @@ func TestXAIWebsocketsExecuteStreamRestoresAliasedWebSearch(t *testing.T) {
select {
case payload := <-capturedPayload:
- tool := gjson.GetBytes(payload, "tools.0")
+ tool := ojson.GetBytes(payload, "tools.0")
if got := tool.Get("name").String(); got != "clientfn_web_search" {
t.Fatalf("upstream tool name = %q, want clientfn_web_search; payload=%s", got, payload)
}
@@ -556,12 +556,12 @@ func TestXAIWebsocketsExecuteStreamRestoresAliasedWebSearch(t *testing.T) {
t.Fatal("timed out waiting for upstream websocket payload")
}
- var outputItemDone, completed gjson.Result
+ var outputItemDone, completed ojson.Result
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
- payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
+ payload := ojson.ParseBytes(bytes.TrimSpace(chunk.Payload))
switch payload.Get("type").String() {
case "response.output_item.done":
outputItemDone = payload
@@ -570,7 +570,7 @@ func TestXAIWebsocketsExecuteStreamRestoresAliasedWebSearch(t *testing.T) {
}
}
- for label, item := range map[string]gjson.Result{
+ for label, item := range map[string]ojson.Result{
"output_item.done": outputItemDone.Get("item"),
"completed": completed.Get("response.output.0"),
} {
@@ -642,13 +642,13 @@ func TestXAIWebsocketsExecuteStreamDoesNotRestoreNamespacedClientfnWebSearch(t *
t.Fatalf("ExecuteStream() error = %v", err)
}
- var outputItemsDone []gjson.Result
- var completed gjson.Result
+ var outputItemsDone []ojson.Result
+ var completed ojson.Result
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
- payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
+ payload := ojson.ParseBytes(bytes.TrimSpace(chunk.Payload))
switch payload.Get("type").String() {
case "response.output_item.done":
outputItemsDone = append(outputItemsDone, payload)
@@ -751,12 +751,12 @@ func TestXAIWebsocketsExecuteStreamPreservesClientSameNameToolsWithXSearch(t *te
}
var foundPlain, foundNamespaced bool
- var completed gjson.Result
+ var completed ojson.Result
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
- payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
+ payload := ojson.ParseBytes(bytes.TrimSpace(chunk.Payload))
if strings.Contains(payload.Raw, "xs_call") {
t.Fatalf("internal X search call_id leaked downstream: %s", payload.Raw)
}
@@ -880,12 +880,12 @@ func TestXAIWebsocketsExecuteStreamPreservesNormalizedCustomSameNameToolWithXSea
}
var foundClientFunction bool
- var completed gjson.Result
+ var completed ojson.Result
for chunk := range result.Chunks {
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
- payload := gjson.ParseBytes(bytes.TrimSpace(chunk.Payload))
+ payload := ojson.ParseBytes(bytes.TrimSpace(chunk.Payload))
if strings.Contains(payload.Raw, "xs_call") {
t.Fatalf("internal X search call_id leaked downstream: %s", payload.Raw)
}
@@ -935,7 +935,7 @@ func TestXAIWebsocketsExecuteStreamPreservesNormalizedCustomSameNameToolWithXSea
t.Fatal("timed out waiting for upstream websocket request body")
}
// response.create keeps tools at the top level of the websocket payload.
- tools := gjson.GetBytes(gotBody, "tools")
+ tools := ojson.GetBytes(gotBody, "tools")
var foundNormalizedFunction bool
var foundRawCustom bool
for _, tool := range tools.Array() {
@@ -1060,7 +1060,7 @@ func TestXAIWebsocketsExecuteStreamRewritesRepeatedResponseIDForDownstream(t *te
t.Errorf("read upstream websocket message: %v", errRead)
return
}
- previousID := gjson.GetBytes(payload, "previous_response_id").String()
+ previousID := ojson.GetBytes(payload, "previous_response_id").String()
capturedPreviousIDs <- previousID
completed := []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp-real","previous_response_id":%q,"output":[{"id":"rs_resp-real","type":"reasoning","status":"completed"}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`, previousID))
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
@@ -1112,9 +1112,9 @@ func TestXAIWebsocketsExecuteStreamRewritesRepeatedResponseIDForDownstream(t *te
t.Fatalf("chunk error = %v", chunk.Err)
}
payload := bytes.TrimSpace(chunk.Payload)
- return gjson.GetBytes(payload, "response.id").String(),
- gjson.GetBytes(payload, "response.output.0.id").String(),
- gjson.GetBytes(payload, "response.previous_response_id").String()
+ return ojson.GetBytes(payload, "response.id").String(),
+ ojson.GetBytes(payload, "response.output.0.id").String(),
+ ojson.GetBytes(payload, "response.previous_response_id").String()
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for completed chunk")
}
@@ -1185,7 +1185,7 @@ func TestXAIWebsocketsExecuteStreamRewritesRepeatedResponseIDWithoutPreviousResp
t.Errorf("read upstream websocket message: %v", errRead)
return
}
- capturedPreviousIDs <- gjson.GetBytes(payload, "previous_response_id").String()
+ capturedPreviousIDs <- ojson.GetBytes(payload, "previous_response_id").String()
completed := []byte(`{"type":"response.completed","response":{"id":"resp-real","output":[{"id":"msg_resp-real","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`)
if errWrite := conn.WriteMessage(websocket.TextMessage, completed); errWrite != nil {
t.Errorf("write completed websocket message: %v", errWrite)
@@ -1233,8 +1233,8 @@ func TestXAIWebsocketsExecuteStreamRewritesRepeatedResponseIDWithoutPreviousResp
t.Fatalf("chunk error = %v", chunk.Err)
}
payload := bytes.TrimSpace(chunk.Payload)
- return gjson.GetBytes(payload, "response.id").String(),
- gjson.GetBytes(payload, "response.output.0.id").String()
+ return ojson.GetBytes(payload, "response.id").String(),
+ ojson.GetBytes(payload, "response.output.0.id").String()
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for completed chunk")
}
@@ -1342,7 +1342,7 @@ func TestXAIWebsocketsExecuteStreamReplaysTranscriptWhenAuthChanges(t *testing.T
if chunk.Err != nil {
t.Fatalf("stream chunk error = %v", chunk.Err)
}
- if gjson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
+ if ojson.GetBytes(chunk.Payload, "type").String() == "response.completed" {
completed = bytes.Clone(chunk.Payload)
}
}
@@ -1353,7 +1353,7 @@ func TestXAIWebsocketsExecuteStreamReplaysTranscriptWhenAuthChanges(t *testing.T
}
firstCompleted := runRequest(newAuth("auth-a", "token-a", server.URL), []byte(`{"model":"grok-4.3","input":[{"type":"message","id":"user-1","role":"user","content":"first"}]}`))
- firstResponseID := gjson.GetBytes(firstCompleted, "response.id").String()
+ firstResponseID := ojson.GetBytes(firstCompleted, "response.id").String()
if firstResponseID != "resp-auth-a" {
t.Fatalf("first response ID = %q, want resp-auth-a", firstResponseID)
}
@@ -1371,10 +1371,10 @@ func TestXAIWebsocketsExecuteStreamReplaysTranscriptWhenAuthChanges(t *testing.T
if secondUpstream.authorization != "Bearer token-c" {
t.Fatalf("second successful Authorization = %q, want Bearer token-c", secondUpstream.authorization)
}
- if gjson.GetBytes(secondUpstream.payload, "previous_response_id").Exists() {
+ if ojson.GetBytes(secondUpstream.payload, "previous_response_id").Exists() {
t.Fatalf("previous_response_id was sent after auth switch: %s", secondUpstream.payload)
}
- input := gjson.GetBytes(secondUpstream.payload, "input").Array()
+ input := ojson.GetBytes(secondUpstream.payload, "input").Array()
if len(input) != 3 {
t.Fatalf("replayed input len = %d, want 3: %s", len(input), secondUpstream.payload)
}
@@ -1468,14 +1468,14 @@ func TestXAIWebsocketsExecuteStreamCompactionTriggerUsesHTTPCompactWithRecordedC
select {
case payload := <-capturedWebsocketPayload:
- if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(payload, "type").String(); got != "response.create" {
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
}
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytes(payload, "input")
if !input.IsArray() || len(input.Array()) != 1 {
t.Fatalf("input = %s, want one first-turn item", input.Raw)
}
- if gjson.GetBytes(payload, "stream").Exists() {
+ if ojson.GetBytes(payload, "stream").Exists() {
t.Fatalf("stream must be omitted for xAI websocket payload: %s", payload)
}
case <-time.After(5 * time.Second):
@@ -1500,7 +1500,7 @@ func TestXAIWebsocketsExecuteStreamCompactionTriggerUsesHTTPCompactWithRecordedC
if xaiInputHasItemType(payload, "compaction_trigger") {
t.Fatalf("compaction_trigger reached xai compact body: %s", payload)
}
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytes(payload, "input")
if !input.IsArray() || len(input.Array()) != 2 {
t.Fatalf("compact input = %s, want first request input plus response output", input.Raw)
}
@@ -1510,7 +1510,7 @@ func TestXAIWebsocketsExecuteStreamCompactionTriggerUsesHTTPCompactWithRecordedC
if got := input.Array()[1].Get("id").String(); got != "out-1" {
t.Fatalf("compact input[1].id = %q, want out-1; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "" {
+ if got := ojson.GetBytes(payload, "previous_response_id").String(); got != "" {
t.Fatalf("compact previous_response_id = %q, want empty; payload=%s", got, payload)
}
case <-time.After(5 * time.Second):
@@ -1531,10 +1531,10 @@ func TestXAIWebsocketsExecuteStreamCompactionTriggerUsesHTTPCompactWithRecordedC
}
select {
case payload := <-capturedWebsocketPayload:
- if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "" {
+ if got := ojson.GetBytes(payload, "previous_response_id").String(); got != "" {
t.Fatalf("post-compaction previous_response_id = %q, want empty; payload=%s", got, payload)
}
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytes(payload, "input")
if !input.IsArray() || len(input.Array()) != 2 {
t.Fatalf("post-compaction input = %s, want compaction item plus new message", input.Raw)
}
@@ -1560,14 +1560,14 @@ func TestXAIWebsocketPostCompactionAppendWithoutPreviousReplaysCompactedTranscri
fullReset := []byte(`{"type":"response.create","model":"grok-4.3","input":[{"type":"message","id":"msg-full"}]}`)
fullMapper := newXAIWebsocketRequestIDMapper(store, "post-compaction-append-session", fullReset)
- if full := fullMapper.upstreamRequestPayload(fullReset); len(gjson.GetBytes(full, "input").Array()) != 1 {
+ if full := fullMapper.upstreamRequestPayload(fullReset); len(ojson.GetBytes(full, "input").Array()) != 1 {
t.Fatalf("self-contained response.create unexpectedly replayed compacted transcript: %s", full)
}
payload := []byte(`{"type":"response.append","model":"grok-4.3","input":[{"type":"message","id":"msg-2","role":"user","content":"second"}]}`)
mapper := newXAIWebsocketRequestIDMapper(store, "post-compaction-append-session", payload)
got := mapper.upstreamRequestPayload(payload)
- input := gjson.GetBytes(got, "input").Array()
+ input := ojson.GetBytes(got, "input").Array()
if len(input) != 2 {
t.Fatalf("post-compaction append input len = %d, want 2: %s", len(input), got)
}
@@ -1582,7 +1582,7 @@ func TestXAIWebsocketPostCompactionAppendWithoutPreviousReplaysCompactedTranscri
nextPayload := []byte(`{"type":"response.create","model":"grok-4.3","input":[{"type":"message","id":"msg-3"}]}`)
nextMapper := newXAIWebsocketRequestIDMapper(store, "post-compaction-append-session", nextPayload)
next := nextMapper.upstreamRequestPayload(nextPayload)
- if nextInput := gjson.GetBytes(next, "input").Array(); len(nextInput) != 1 || nextInput[0].Get("id").String() != "msg-3" {
+ if nextInput := ojson.GetBytes(next, "input").Array(); len(nextInput) != 1 || nextInput[0].Get("id").String() != "msg-3" {
t.Fatalf("compacted transcript replay was not cleared after success: %s", next)
}
}
@@ -1607,7 +1607,7 @@ func TestXAIWebsocketPostCompactionWarmupPreservesTranscriptForLaterCompaction(t
appendPayload := []byte(`{"type":"response.append","model":"grok-4.3","input":[{"type":"message","id":"msg-after-warmup"}]}`)
appendMapper := newXAIWebsocketRequestIDMapper(store, "warmup-reset-session", appendPayload)
appendUpstream := appendMapper.upstreamRequestPayload(appendPayload)
- input := gjson.GetBytes(appendUpstream, "input").Array()
+ input := ojson.GetBytes(appendUpstream, "input").Array()
if len(input) != 1 || input[0].Get("id").String() != "msg-after-warmup" {
t.Fatalf("warmup retained pending replay instead of native append: %s", appendUpstream)
}
@@ -1617,7 +1617,7 @@ func TestXAIWebsocketPostCompactionWarmupPreservesTranscriptForLaterCompaction(t
false,
)
- transcript := gjson.ParseBytes(state.snapshotTranscriptInput()).Array()
+ transcript := ojson.ParseBytes(state.snapshotTranscriptInput()).Array()
wantTypes := []string{"compaction", "message", "message", "message"}
if len(transcript) != len(wantTypes) {
t.Fatalf("post-warmup transcript len = %d, want %d: %s", len(transcript), len(wantTypes), state.snapshotTranscriptInput())
@@ -1642,7 +1642,7 @@ func TestXAIWebsocketEmptyFullResetClearsPendingCompactionReplay(t *testing.T) {
appendPayload := []byte(`{"type":"response.append","model":"grok-4.3","input":[{"type":"message","id":"msg-new"}]}`)
mapper := newXAIWebsocketRequestIDMapper(store, "empty-reset-session", appendPayload)
got := mapper.upstreamRequestPayload(appendPayload)
- input := gjson.GetBytes(got, "input").Array()
+ input := ojson.GetBytes(got, "input").Array()
if len(input) != 1 || input[0].Get("id").String() != "msg-new" {
t.Fatalf("empty full reset retained stale compaction replay: %s", got)
}
@@ -1654,7 +1654,7 @@ func TestValidateXAIWebsocketCompactionResponse(t *testing.T) {
if err != nil {
t.Fatalf("valid compaction response error: %v", err)
}
- if responseID != "resp_compact" || gjson.GetBytes(item, "encrypted_content").String() != "opaque-state" {
+ if responseID != "resp_compact" || ojson.GetBytes(item, "encrypted_content").String() != "opaque-state" {
t.Fatalf("validated compaction response = id:%q item:%s", responseID, item)
}
@@ -1678,25 +1678,25 @@ func TestBuildXAIWebsocketRequestBodySetsStoreAndKeepsPromptCacheKey(t *testing.
payload := buildXAIWebsocketRequestBody(body)
- if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(payload, "type").String(); got != "response.create" {
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
}
- if gjson.GetBytes(payload, "stream").Exists() {
+ if ojson.GetBytes(payload, "stream").Exists() {
t.Fatalf("stream must be omitted for xAI websocket payload: %s", payload)
}
- if gjson.GetBytes(payload, "stream_options").Exists() {
+ if ojson.GetBytes(payload, "stream_options").Exists() {
t.Fatalf("stream_options must be omitted for xAI websocket payload: %s", payload)
}
- if gjson.GetBytes(payload, "background").Exists() {
+ if ojson.GetBytes(payload, "background").Exists() {
t.Fatalf("background must be omitted for xAI websocket payload: %s", payload)
}
- if got := gjson.GetBytes(payload, "prompt_cache_key").String(); got != "cache-1" {
+ if got := ojson.GetBytes(payload, "prompt_cache_key").String(); got != "cache-1" {
t.Fatalf("prompt_cache_key = %q, want cache-1; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "store").Bool(); !got {
+ if got := ojson.GetBytes(payload, "store").Bool(); !got {
t.Fatalf("store = false, want true; payload=%s", payload)
}
- if gjson.GetBytes(payload, "instructions").Exists() {
+ if ojson.GetBytes(payload, "instructions").Exists() {
t.Fatalf("instructions must be omitted when previous_response_id is set: %s", payload)
}
}
@@ -1756,13 +1756,13 @@ func TestXAIWebsocketsExecuteStreamCompletesGenerateFalseWarmup(t *testing.T) {
select {
case payload := <-capturedPayload:
- if got := gjson.GetBytes(payload, "generate").Bool(); got {
+ if got := ojson.GetBytes(payload, "generate").Bool(); got {
t.Fatalf("generate = true, want false; payload=%s", payload)
}
- if got := gjson.GetBytes(payload, "type").String(); got != "response.create" {
+ if got := ojson.GetBytes(payload, "type").String(); got != "response.create" {
t.Fatalf("type = %q, want response.create; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "store").Bool(); !got {
+ if got := ojson.GetBytes(payload, "store").Bool(); !got {
t.Fatalf("store = false, want true; payload=%s", payload)
}
case <-time.After(5 * time.Second):
@@ -1782,7 +1782,7 @@ func TestXAIWebsocketsExecuteStreamCompletesGenerateFalseWarmup(t *testing.T) {
if chunk.Err != nil {
t.Fatalf("chunk error = %v", chunk.Err)
}
- gotTypes = append(gotTypes, gjson.GetBytes(bytes.TrimSpace(chunk.Payload), "type").String())
+ gotTypes = append(gotTypes, ojson.GetBytes(bytes.TrimSpace(chunk.Payload), "type").String())
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for warmup stream to close; event types so far: %v", gotTypes)
}
@@ -1857,7 +1857,7 @@ func TestParseXAIWebsocketErrorFreeUsageExhaustedSetsRetryAfter(t *testing.T) {
if got := *retryable.RetryAfter(); got != 24*time.Hour {
t.Fatalf("RetryAfter = %v, want 24h", got)
}
- parsed := gjson.Parse(err.Error())
+ parsed := ojson.Parse(err.Error())
if got := parsed.Get("status").Int(); got != http.StatusTooManyRequests {
t.Fatalf("error status = %d, want 429; payload=%s", got, err)
}
@@ -1884,7 +1884,7 @@ func TestParseXAIWebsocketErrorBadCredentialsRemapsToUnauthorized(t *testing.T)
if got := headerSource.Headers().Get("x-request-id"); got != "req-bad-credentials" {
t.Fatalf("x-request-id = %q, want req-bad-credentials", got)
}
- parsed := gjson.Parse(err.Error())
+ parsed := ojson.Parse(err.Error())
if got := parsed.Get("error.code").String(); got != "unauthenticated:bad-credentials" {
t.Fatalf("error code = %q, want unauthenticated:bad-credentials; payload=%s", got, err)
}
@@ -1917,7 +1917,7 @@ func TestParseXAIWebsocketBareErrorFreeUsageExhaustedSetsRetryAfter(t *testing.T
if got := *retryable.RetryAfter(); got != 24*time.Hour {
t.Fatalf("RetryAfter = %v, want 24h", got)
}
- parsed := gjson.Parse(err.Error())
+ parsed := ojson.Parse(err.Error())
if got := parsed.Get("type").String(); got != "error" {
t.Fatalf("error type = %q, want error; payload=%s", got, err)
}
@@ -2050,14 +2050,14 @@ func TestXAIWebsocketsCompactionTriggerFreshSessionFallback(t *testing.T) {
if xaiInputHasItemType(payload, "compaction_trigger") {
t.Fatalf("compaction_trigger reached xai compact body: %s", payload)
}
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytes(payload, "input")
if !input.IsArray() || len(input.Array()) != 1 {
t.Fatalf("compact input = %s, want 1 item", input.Raw)
}
if got := input.Array()[0].Get("id").String(); got != "msg-fresh-1" {
t.Fatalf("compact input[0].id = %q, want msg-fresh-1; payload=%s", got, payload)
}
- if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "" {
+ if got := ojson.GetBytes(payload, "previous_response_id").String(); got != "" {
t.Fatalf("compact previous_response_id = %q, want empty; payload=%s", got, payload)
}
case <-time.After(5 * time.Second):
@@ -2090,7 +2090,7 @@ func TestXAIWebsocketsCompactionTriggerFreshSessionFallback(t *testing.T) {
if xaiInputHasItemType(payload, "compaction_trigger") {
t.Fatalf("compaction_trigger reached xai compact body: %s", payload)
}
- if got := gjson.GetBytes(payload, "previous_response_id").String(); got != "resp-prev-123" {
+ if got := ojson.GetBytes(payload, "previous_response_id").String(); got != "resp-prev-123" {
t.Fatalf("compact previous_response_id = %q, want resp-prev-123; payload=%s", got, payload)
}
case <-time.After(5 * time.Second):
@@ -2543,8 +2543,8 @@ func TestXAIWebsocketApplyPatchTransport(t *testing.T) {
output = append(output, chunk.Payload...)
lifecycle = append(lifecycle, applyPatchTestPayloads(chunk.Payload)...)
payload := bytes.TrimSpace(bytes.TrimPrefix(chunk.Payload, []byte("data:")))
- if gjson.GetBytes(payload, "type").String() == "response.completed" {
- final = []byte(gjson.GetBytes(payload, "response").Raw)
+ if ojson.GetBytes(payload, "type").String() == "response.completed" {
+ final = []byte(ojson.GetBytes(payload, "response").Raw)
}
}
if !bytes.Contains(output, []byte(`"custom_tool_call"`)) || !bytes.Contains(output, []byte(`"response.custom_tool_call_input.done"`)) || !bytes.Contains(output, []byte(`"input":"p\n中😀"`)) {
@@ -2558,7 +2558,7 @@ func TestXAIWebsocketApplyPatchTransport(t *testing.T) {
fragments = []string{"p\n中😀"}
}
assertApplyPatchIdentityLifecycle(t, lifecycle, "a", "c", "p\n中😀", 0, fragments)
- if gjson.GetBytes(final, "output.0.type").String() != "custom_tool_call" || gjson.GetBytes(final, "output.0.input").String() != "p\n中😀" || gjson.GetBytes(final, "output.0.call_id").String() != "c" || (fold && gjson.GetBytes(final, "output.0.namespace").String() != "n") {
+ if ojson.GetBytes(final, "output.0.type").String() != "custom_tool_call" || ojson.GetBytes(final, "output.0.input").String() != "p\n中😀" || ojson.GetBytes(final, "output.0.call_id").String() != "c" || (fold && ojson.GetBytes(final, "output.0.namespace").String() != "n") {
t.Fatalf("terminal lost child input/identity: %s", final)
}
if bytes.Count(output, []byte(`"type":"response.custom_tool_call_input.done"`)) != 1 || bytes.Count(output, []byte(`"type":"response.completed"`)) != 1 {
@@ -2566,10 +2566,10 @@ func TestXAIWebsocketApplyPatchTransport(t *testing.T) {
}
body := <-bodies
if fold {
- if gjson.GetBytes(body, "tools.0.name").String() != "n" || !strings.Contains(gjson.GetBytes(body, "tools.0.description").String(), "apply_patch") {
+ if ojson.GetBytes(body, "tools.0.name").String() != "n" || !strings.Contains(ojson.GetBytes(body, "tools.0.description").String(), "apply_patch") {
t.Fatalf("dispatcher lost patch: %s", body)
}
- } else if !gjson.GetBytes(body, "tools.0.parameters.properties.input").Exists() {
+ } else if !ojson.GetBytes(body, "tools.0.parameters.properties.input").Exists() {
t.Fatalf("missing patch schema: %s", body)
}
})
diff --git a/internal/signature/claude.go b/internal/signature/claude.go
index 4b3fbde2530..e6c284ebe20 100644
--- a/internal/signature/claude.go
+++ b/internal/signature/claude.go
@@ -1,11 +1,10 @@
package signature
import (
- "bytes"
+ "fmt"
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// StripInvalidClaudeThinkingBlocks removes Claude thinking blocks whose
@@ -13,84 +12,77 @@ import (
// an optional cache prefix, unless the validation options allow an empty
// thinking placeholder.
func StripInvalidClaudeThinkingBlocks(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
- messages := gjson.GetBytes(payload, "messages")
+ return stripInvalidClaudeThinkingBlocks(payload, false, opts...)
+}
+
+// StripInvalidClaudeThinkingBlocksAndEmptyMessages also removes messages whose
+// content becomes empty after invalid thinking blocks are removed.
+func StripInvalidClaudeThinkingBlocksAndEmptyMessages(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
+ return stripInvalidClaudeThinkingBlocks(payload, true, opts...)
+}
+
+func stripInvalidClaudeThinkingBlocks(payload []byte, dropEmptyMessages bool, opts ...ClaudeSignatureValidationOptions) []byte {
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() {
return payload
}
opt := claudeSignatureValidationOptions(opts)
messageResults := messages.Array()
- keptMessages := make([]string, 0, len(messageResults))
+ doc := ojson.NewDoc(payload)
modified := false
- for _, msg := range messageResults {
+ var droppedMessages []int
+ for i, msg := range messageResults {
content := msg.Get("content")
if !content.IsArray() {
- keptMessages = append(keptMessages, msg.Raw)
continue
}
contentResults := content.Array()
- keptParts := make([]string, 0, len(contentResults))
- stripped := false
- for _, part := range contentResults {
+ var droppedParts []int
+ for j, part := range contentResults {
if part.Get("type").String() == "thinking" && shouldStripClaudeThinkingBlock(part, opt) {
- stripped = true
- continue
+ droppedParts = append(droppedParts, j)
}
- keptParts = append(keptParts, part.Raw)
}
- if stripped {
- modified = true
- updated, _ := sjson.SetRaw(msg.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
- keptMessages = append(keptMessages, updated)
+ if dropEmptyMessages && len(droppedParts) == len(contentResults) {
+ droppedMessages = append(droppedMessages, i)
+ if len(droppedParts) > 0 {
+ modified = true
+ }
continue
}
- keptMessages = append(keptMessages, msg.Raw)
+ if len(droppedParts) == 0 {
+ continue
+ }
+ modified = true
+ for k := len(droppedParts) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d.content.%d", i, droppedParts[k]))
+ }
}
if !modified {
return payload
}
- output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
- return output
-}
-
-// StripInvalidClaudeThinkingBlocksAndEmptyMessages also removes messages whose
-// content becomes empty after invalid thinking blocks are removed.
-func StripInvalidClaudeThinkingBlocksAndEmptyMessages(payload []byte, opts ...ClaudeSignatureValidationOptions) []byte {
- stripped := StripInvalidClaudeThinkingBlocks(payload, opts...)
- if bytes.Equal(stripped, payload) {
- return payload
- }
- messages := gjson.GetBytes(stripped, "messages")
- if !messages.IsArray() {
- return stripped
- }
- kept := make([]string, 0, len(messages.Array()))
- for _, message := range messages.Array() {
- content := message.Get("content")
- if content.IsArray() && len(content.Array()) == 0 {
- continue
- }
- kept = append(kept, message.Raw)
+ for k := len(droppedMessages) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d", droppedMessages[k]))
}
- stripped, _ = sjson.SetRawBytes(stripped, "messages", []byte("["+strings.Join(kept, ",")+"]"))
- return stripped
+ return doc.Bytes()
}
-func shouldStripClaudeThinkingBlock(part gjson.Result, opt ClaudeSignatureValidationOptions) bool {
+func shouldStripClaudeThinkingBlock(part ojson.Result, opt ClaudeSignatureValidationOptions) bool {
if opt.AllowEmptySignatureWithEmptyText && isEmptyClaudeThinkingPlaceholder(part) {
return false
}
return !IsValidClaudeThinkingSignature(part.Get("signature").String(), opt)
}
-func isEmptyClaudeThinkingPlaceholder(part gjson.Result) bool {
+func isEmptyClaudeThinkingPlaceholder(part ojson.Result) bool {
if strings.TrimSpace(part.Get("signature").String()) != "" {
return false
}
return strings.TrimSpace(claudeThinkingBlockText(part)) == ""
}
-func claudeThinkingBlockText(part gjson.Result) string {
- if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
+func claudeThinkingBlockText(part ojson.Result) string {
+ if text := part.Get("text"); text.Exists() && text.Type == ojson.String {
return text.String()
}
@@ -98,14 +90,14 @@ func claudeThinkingBlockText(part gjson.Result) string {
if !thinkingField.Exists() {
return ""
}
- if thinkingField.Type == gjson.String {
+ if thinkingField.Type == ojson.String {
return thinkingField.String()
}
if thinkingField.IsObject() {
- if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
+ if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == ojson.String {
return inner.String()
}
- if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
+ if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == ojson.String {
return inner.String()
}
}
diff --git a/internal/signature/claude_antigravity_boundaries_test.go b/internal/signature/claude_antigravity_boundaries_test.go
index 80648113658..14c0c0fbd7e 100644
--- a/internal/signature/claude_antigravity_boundaries_test.go
+++ b/internal/signature/claude_antigravity_boundaries_test.go
@@ -1,11 +1,11 @@
package signature
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -43,7 +43,7 @@ func TestAntigravityCAQSNativeSanitizerRejectsPrefixedWrappers(t *testing.T) {
}
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reasoning","signature":` + string(encoded) + `},{"type":"text","text":"answer"}]}]}`)
output, report := SanitizeClaudeMessagesForClaudeUpstream(payload, "claude-opus-5-5")
- parts := gjson.GetBytes(output, "messages.0.content").Array()
+ parts := ojson.GetBytes(output, "messages.0.content").Array()
if len(parts) != 1 || parts[0].Get("type").String() != "text" || parts[0].Get("text").String() != "answer" || report.DroppedBlocks != 1 || report.Preserved != 0 {
t.Errorf("native sanitizer retained Google thinking wrapper: report=%+v", report)
}
diff --git a/internal/signature/claude_antigravity_validation.go b/internal/signature/claude_antigravity_validation.go
index 75120331cab..3a006dfd10b 100644
--- a/internal/signature/claude_antigravity_validation.go
+++ b/internal/signature/claude_antigravity_validation.go
@@ -1,8 +1,8 @@
package signature
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
)
diff --git a/internal/signature/claude_antigravity_validation_test.go b/internal/signature/claude_antigravity_validation_test.go
index 8b6ec972ced..6dc68f9670a 100644
--- a/internal/signature/claude_antigravity_validation_test.go
+++ b/internal/signature/claude_antigravity_validation_test.go
@@ -1,7 +1,7 @@
package signature
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go
index 0f2c0d5e26e..b9046acb170 100644
--- a/internal/signature/claude_messages_sanitize.go
+++ b/internal/signature/claude_messages_sanitize.go
@@ -4,8 +4,7 @@ import (
"fmt"
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
type ClaudeMessagesSignatureSanitizeOptions struct {
@@ -44,50 +43,68 @@ func IsCodexCommentarySignatureMarker(rawSignature string) bool {
// with empty thinking text are dropped because they carry no history. When no
// marker is present the payload is returned unchanged.
func RestoreCodexCommentaryMarkers(payload []byte) ([]byte, int) {
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() {
return payload, 0
}
+ doc := ojson.NewDoc(payload)
+ restored := RestoreCodexCommentaryMarkersDoc(doc)
+ if restored == 0 {
+ return payload, 0
+ }
+ return doc.Bytes(), restored
+}
+
+// RestoreCodexCommentaryMarkersDoc rewrites every assistant thinking block that
+// carries the internal commentary marker on doc in place.
+func RestoreCodexCommentaryMarkersDoc(doc *ojson.Doc) int {
+ if doc == nil {
+ return 0
+ }
+ messages := doc.Get("messages")
+ if !messages.IsArray() {
+ return 0
+ }
messageResults := messages.Array()
restored := 0
- keptMessages := make([]string, 0, len(messageResults))
- for _, message := range messageResults {
+ var droppedMessages []int
+ for i, message := range messageResults {
content := message.Get("content")
if !content.IsArray() {
- keptMessages = append(keptMessages, message.Raw)
continue
}
contentResults := content.Array()
- keptParts := make([]string, 0, len(contentResults))
- messageModified := false
- for _, part := range contentResults {
+ var droppedParts []int
+ for j, part := range contentResults {
if part.Get("type").String() != "thinking" || !IsCodexCommentarySignatureMarker(part.Get("signature").String()) {
- keptParts = append(keptParts, part.Raw)
continue
}
restored++
- messageModified = true
+ partPath := fmt.Sprintf("messages.%d.content.%d", i, j)
if text := part.Get("thinking").String(); text != "" {
- textBlock := []byte(`{"type":"text","text":""}`)
- textBlock, _ = sjson.SetBytes(textBlock, "text", text)
- keptParts = append(keptParts, string(textBlock))
+ _ = doc.Set(partPath+".type", "text")
+ _ = doc.Delete(partPath + ".thinking")
+ _ = doc.Delete(partPath + ".signature")
+ _ = doc.Set(partPath+".text", text)
+ } else {
+ droppedParts = append(droppedParts, j)
}
}
- if !messageModified {
- keptMessages = append(keptMessages, message.Raw)
+ if len(droppedParts) == 0 {
continue
}
- if len(keptParts) == 0 {
+ if len(droppedParts) == len(contentResults) {
+ droppedMessages = append(droppedMessages, i)
continue
}
- updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
- keptMessages = append(keptMessages, updated)
+ for k := len(droppedParts) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d.content.%d", i, droppedParts[k]))
+ }
}
- if restored == 0 {
- return payload, 0
+ for k := len(droppedMessages) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d", droppedMessages[k]))
}
- output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
- return output, restored
+ return restored
}
type SignatureSanitizeReport struct {
@@ -139,42 +156,40 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
}
report := SignatureSanitizeReport{TargetProvider: targetProvider}
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() {
return payload, report
}
messageResults := messages.Array()
- keptMessages := make([]string, 0, len(messageResults))
+ doc := ojson.NewDoc(payload)
modified := false
+ var droppedMessages []int
for i, message := range messageResults {
content := message.Get("content")
if !content.IsArray() {
- keptMessages = append(keptMessages, message.Raw)
continue
}
contentResults := content.Array()
- keptParts := make([]string, 0, len(contentResults))
- messageModified := false
+ var droppedParts []int
for j, part := range contentResults {
+ partPath := fmt.Sprintf("messages.%d.content.%d", i, j)
partType := part.Get("type").String()
if partType == "tool_use" {
if opts.DropToolSignatures {
- updatedPart, changed := stripClaudeToolUseSignatureFields(part)
- if changed {
- messageModified = true
+ if stripClaudeToolUseSignatureFieldsDoc(doc, partPath) {
+ modified = true
report.DroppedSignatures++
}
- keptParts = append(keptParts, updatedPart)
continue
}
- updatedPart, changed, decisions := sanitizeClaudeToolUseSignature(part, targetProvider, opts.TargetModel, i, j)
+ changed, decisions := sanitizeClaudeToolUseSignatureDoc(doc, part, partPath, targetProvider, opts.TargetModel, i, j)
report.Decisions = append(report.Decisions, decisions...)
if changed {
- messageModified = true
+ modified = true
}
for _, decision := range decisions {
switch decision.Action {
@@ -186,12 +201,10 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
report.DroppedSignatures++
}
}
- keptParts = append(keptParts, updatedPart)
continue
}
if partType != "thinking" {
- keptParts = append(keptParts, part.Raw)
continue
}
@@ -201,24 +214,24 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
// restore its text as a plain assistant text block so neither
// the marker nor a fake signature reaches the upstream.
if text := part.Get("thinking").String(); text != "" {
- textBlock := []byte(`{"type":"text","text":""}`)
- textBlock, _ = sjson.SetBytes(textBlock, "text", text)
- keptParts = append(keptParts, string(textBlock))
+ _ = doc.Set(partPath+".type", "text")
+ _ = doc.Delete(partPath + ".thinking")
+ _ = doc.Delete(partPath + ".signature")
+ _ = doc.Set(partPath+".text", text)
report.DroppedSignatures++
- messageModified = true
+ modified = true
continue
}
report.DroppedBlocks++
- messageModified = true
+ droppedParts = append(droppedParts, j)
+ modified = true
continue
}
if opts.PreserveEmptyThinkingBlocks {
report.Preserved++
- keptParts = append(keptParts, part.Raw)
continue
}
if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders {
- keptParts = append(keptParts, part.Raw)
continue
}
@@ -230,79 +243,75 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag
case SignatureActionPreserve:
report.Preserved++
if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature {
- updated, _ := sjson.Set(part.Raw, "signature", decision.NormalizedSignature)
- keptParts = append(keptParts, updated)
- messageModified = true
- continue
+ _ = doc.Set(partPath+".signature", decision.NormalizedSignature)
+ modified = true
}
- keptParts = append(keptParts, part.Raw)
case SignatureActionReplaceWithGeminiBypass:
report.ReplacedSignatures++
- updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature)
- keptParts = append(keptParts, updated)
- messageModified = true
+ _ = doc.Set(partPath+".signature", decision.ReplacementSignature)
+ modified = true
case SignatureActionDropSignature:
report.DroppedSignatures++
- updated, _ := sjson.Delete(part.Raw, "signature")
- keptParts = append(keptParts, updated)
- messageModified = true
+ _ = doc.Delete(partPath + ".signature")
+ modified = true
default:
report.DroppedBlocks++
- messageModified = true
+ droppedParts = append(droppedParts, j)
+ modified = true
}
}
- if messageModified {
- modified = true
- if len(keptParts) == 0 && opts.DropEmptyMessages {
+ if len(droppedParts) > 0 {
+ if len(droppedParts) == len(contentResults) && opts.DropEmptyMessages {
+ droppedMessages = append(droppedMessages, i)
continue
}
- updated, _ := sjson.SetRaw(message.Raw, "content", "["+strings.Join(keptParts, ",")+"]")
- keptMessages = append(keptMessages, updated)
- continue
+ for k := len(droppedParts) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d.content.%d", i, droppedParts[k]))
+ }
}
+ }
- keptMessages = append(keptMessages, message.Raw)
+ for k := len(droppedMessages) - 1; k >= 0; k-- {
+ _ = doc.Delete(fmt.Sprintf("messages.%d", droppedMessages[k]))
}
if !modified {
return payload, report
}
- output, _ := sjson.SetRawBytes(payload, "messages", []byte("["+strings.Join(keptMessages, ",")+"]"))
- return output, report
+ return doc.Bytes(), report
}
-func stripClaudeToolUseSignatureFields(part gjson.Result) (string, bool) {
- updated := part.Raw
+func stripClaudeToolUseSignatureFieldsDoc(doc *ojson.Doc, partPath string) bool {
changed := false
- for _, sigPath := range claudeToolUseProvenancePaths() {
- if !gjson.Get(updated, sigPath).Exists() {
+ for _, relPath := range claudeToolUseProvenancePaths() {
+ sigPath := partPath + "." + relPath
+ if !doc.Get(sigPath).Exists() {
continue
}
- updated, _ = sjson.Delete(updated, sigPath)
- changed = true
+ if doc.Delete(sigPath) == nil {
+ changed = true
+ }
}
- if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
- updated = cleaned
+ if deleteEmptyJSONObjectPathDoc(doc, partPath+".extra_content.google") {
changed = true
}
- if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
- updated = cleaned
+ if deleteEmptyJSONObjectPathDoc(doc, partPath+".extra_content") {
changed = true
}
- return updated, changed
+ return changed
}
-func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureProvider, targetModel string, messageIdx, partIdx int) (string, bool, []SignatureCompatibilityDecision) {
- updated := part.Raw
+func sanitizeClaudeToolUseSignatureDoc(doc *ojson.Doc, part ojson.Result, partPath string, targetProvider SignatureProvider, targetModel string, messageIdx, partIdx int) (bool, []SignatureCompatibilityDecision) {
changed := false
var decisions []SignatureCompatibilityDecision
- for _, sigPath := range claudeToolUseSignaturePaths() {
- sigResult := part.Get(sigPath)
+ for _, relPath := range claudeToolUseSignaturePaths() {
+ sigResult := part.Get(relPath)
if !sigResult.Exists() {
continue
}
+ sigPath := partPath + "." + relPath
blockKind := SignatureBlockKindGeminiFunctionCall
if targetProvider == SignatureProviderClaude {
@@ -311,34 +320,43 @@ func sanitizeClaudeToolUseSignature(part gjson.Result, targetProvider SignatureP
blockKind = SignatureBlockKindGPTReasoning
}
decision := DecideSignatureCompatibilityForModel(targetProvider, targetModel, sigResult.String(), blockKind)
- decision.Reason = fmt.Sprintf("messages[%d].content[%d].%s: %s", messageIdx, partIdx, sigPath, decision.Reason)
+ decision.Reason = fmt.Sprintf("messages[%d].content[%d].%s: %s", messageIdx, partIdx, relPath, decision.Reason)
decisions = append(decisions, decision)
switch decision.Action {
case SignatureActionPreserve:
if decision.NormalizedSignature != "" && decision.NormalizedSignature != sigResult.String() {
- updated, _ = sjson.Set(updated, sigPath, decision.NormalizedSignature)
- changed = true
+ if doc.Set(sigPath, decision.NormalizedSignature) == nil {
+ changed = true
+ }
}
case SignatureActionReplaceWithGeminiBypass:
- updated, _ = sjson.Set(updated, sigPath, decision.ReplacementSignature)
- changed = true
+ if doc.Set(sigPath, decision.ReplacementSignature) == nil {
+ changed = true
+ }
default:
- updated, _ = sjson.Delete(updated, sigPath)
- changed = true
+ if doc.Delete(sigPath) == nil {
+ changed = true
+ }
}
}
- if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content.google"); ok {
- updated = cleaned
+ if deleteEmptyJSONObjectPathDoc(doc, partPath+".extra_content.google") {
changed = true
}
- if cleaned, ok := deleteEmptyJSONObjectPath(updated, "extra_content"); ok {
- updated = cleaned
+ if deleteEmptyJSONObjectPathDoc(doc, partPath+".extra_content") {
changed = true
}
- return updated, changed, decisions
+ return changed, decisions
+}
+
+func deleteEmptyJSONObjectPathDoc(doc *ojson.Doc, path string) bool {
+ result := doc.Get(path)
+ if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
+ return false
+ }
+ return doc.Delete(path) == nil
}
func claudeToolUseSignaturePaths() []string {
@@ -353,15 +371,3 @@ func claudeToolUseSignaturePaths() []string {
func claudeToolUseProvenancePaths() []string {
return append(claudeToolUseSignaturePaths(), "model")
}
-
-func deleteEmptyJSONObjectPath(raw, path string) (string, bool) {
- result := gjson.Get(raw, path)
- if !result.Exists() || !result.IsObject() || len(result.Map()) != 0 {
- return raw, false
- }
- updated, err := sjson.Delete(raw, path)
- if err != nil {
- return raw, false
- }
- return updated, true
-}
diff --git a/internal/signature/claude_messages_sanitize_compat_test.go b/internal/signature/claude_messages_sanitize_compat_test.go
index 1cecc0c65a1..14ef359555c 100644
--- a/internal/signature/claude_messages_sanitize_compat_test.go
+++ b/internal/signature/claude_messages_sanitize_compat_test.go
@@ -3,19 +3,19 @@ package signature
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesEmptyThinkingInCompatMode(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":""}]}]}`)
withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4")
- if gjson.GetBytes(withoutCompat, "messages.0.content.#").Int() != 0 {
+ if ojson.GetBytes(withoutCompat, "messages.0.content.#").Int() != 0 {
t.Fatalf("default sanitizer preserved empty thinking: %s", withoutCompat)
}
withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true)
- part := gjson.GetBytes(withCompat, "messages.0.content.0")
+ part := ojson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" {
t.Fatalf("compat sanitizer dropped empty thinking: %s", withCompat)
}
@@ -26,14 +26,14 @@ func TestSanitizeClaudeMessagesForClaudeUpstreamRestoresCommentaryMarkerAsText(t
for _, compat := range []bool{false, true} {
out, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", compat)
- if gjson.GetBytes(out, "messages.0.content.#").Int() != 2 {
+ if ojson.GetBytes(out, "messages.0.content.#").Int() != 2 {
t.Fatalf("compat=%v: commentary marker block was lost: %s", compat, out)
}
- first := gjson.GetBytes(out, "messages.0.content.0")
+ first := ojson.GetBytes(out, "messages.0.content.0")
if first.Get("type").String() != "text" || first.Get("text").String() != "COMMENTARY_SENTINEL" || first.Get("signature").Exists() {
t.Fatalf("compat=%v: commentary marker should become a plain text block: %s", compat, out)
}
- if got := gjson.GetBytes(out, "messages.0.content.1.text").String(); got != "FINAL_SENTINEL" {
+ if got := ojson.GetBytes(out, "messages.0.content.1.text").String(); got != "FINAL_SENTINEL" {
t.Fatalf("compat=%v: final text = %q, want FINAL_SENTINEL: %s", compat, got, out)
}
}
@@ -44,7 +44,7 @@ func TestSanitizeClaudeMessagesSignaturesForModelRestoresCommentaryMarkerAsText(
for _, model := range []string{"claude-sonnet-5", "gpt-5.4", "gemini-3.5-flash", "unknown-model"} {
out, _ := SanitizeClaudeMessagesSignaturesForModel(input, model)
- part := gjson.GetBytes(out, "messages.0.content.0")
+ part := ojson.GetBytes(out, "messages.0.content.0")
if part.Get("type").String() != "text" || part.Get("text").String() != "COMMENTARY_SENTINEL" || part.Get("signature").Exists() {
t.Fatalf("model=%s: commentary marker should become a plain text block: %s", model, out)
}
@@ -58,11 +58,11 @@ func TestRestoreCodexCommentaryMarkersRestoresTextOnAnyTarget(t *testing.T) {
if restored != 1 {
t.Fatalf("restored = %d, want 1: %s", restored, out)
}
- first := gjson.GetBytes(out, "messages.1.content.0")
+ first := ojson.GetBytes(out, "messages.1.content.0")
if first.Get("type").String() != "text" || first.Get("text").String() != "COMMENTARY_SENTINEL" || first.Get("signature").Exists() {
t.Fatalf("commentary marker should become a plain text block: %s", out)
}
- if got := gjson.GetBytes(out, "messages.1.content.1.text").String(); got != "FINAL_SENTINEL" {
+ if got := ojson.GetBytes(out, "messages.1.content.1.text").String(); got != "FINAL_SENTINEL" {
t.Fatalf("final text = %q, want FINAL_SENTINEL: %s", got, out)
}
@@ -77,12 +77,12 @@ func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignature
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-deepseek-id"}]}]}`)
withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4")
- if gjson.GetBytes(withoutCompat, "messages.0.content.0.signature").String() != "" {
+ if ojson.GetBytes(withoutCompat, "messages.0.content.0.signature").String() != "" {
t.Fatalf("default sanitizer preserved opaque signature: %s", withoutCompat)
}
withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true)
- part := gjson.GetBytes(withCompat, "messages.0.content.0")
+ part := ojson.GetBytes(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "opaque-deepseek-id" {
t.Fatalf("compat sanitizer dropped opaque signature: %s", withCompat)
}
diff --git a/internal/signature/claude_test.go b/internal/signature/claude_test.go
index 08d4eab57cf..10f2e23bc68 100644
--- a/internal/signature/claude_test.go
+++ b/internal/signature/claude_test.go
@@ -1,11 +1,11 @@
package signature
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -21,7 +21,7 @@ func TestStripInvalidClaudeThinkingBlocks_RemovesGPTEncryptedContent(t *testing.
}`)
out := StripInvalidClaudeThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("messages.0.content length = %d, want 1: %s", len(content), string(out))
}
@@ -44,7 +44,7 @@ func TestStripInvalidClaudeThinkingBlocksAndEmptyMessages_DropsMessagesLeftEmpty
}`)
out := StripInvalidClaudeThinkingBlocksAndEmptyMessages(input)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytes(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("messages length = %d, want 1: %s", len(messages), string(out))
}
@@ -56,6 +56,27 @@ func TestStripInvalidClaudeThinkingBlocksAndEmptyMessages_DropsMessagesLeftEmpty
}
}
+func TestStripInvalidClaudeThinkingBlocksAndEmptyMessages_DropsPreExistingAndNewlyEmptyMessages(t *testing.T) {
+ input := []byte(`{
+ "messages": [
+ {"role":"assistant","content":[]},
+ {"role":"assistant","content":[
+ {"type":"thinking","thinking":"codex reasoning","signature":"gAAAAABopenai-encrypted-content"}
+ ]},
+ {"role":"user","content":[{"type":"text","text":"next"}]}
+ ]
+ }`)
+
+ out := StripInvalidClaudeThinkingBlocksAndEmptyMessages(input)
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
+ if len(messages) != 1 {
+ t.Fatalf("messages length = %d, want 1: %s", len(messages), string(out))
+ }
+ if got := messages[0].Get("role").String(); got != "user" {
+ t.Fatalf("remaining role = %q, want user", got)
+ }
+}
+
func TestStripInvalidClaudeThinkingBlocks_RemovesMalformedEPrefix(t *testing.T) {
input := []byte(`{
"messages": [{"role":"assistant","content":[
@@ -65,7 +86,7 @@ func TestStripInvalidClaudeThinkingBlocks_RemovesMalformedEPrefix(t *testing.T)
}`)
out := StripInvalidClaudeThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
}
@@ -83,7 +104,7 @@ func TestStripInvalidClaudeThinkingBlocks_Base64OnlyKeepsDecodableEPrefix(t *tes
}`)
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
}
@@ -98,7 +119,7 @@ func TestStripInvalidClaudeThinkingBlocks_Base64OnlyRemovesInvalidBase64(t *test
}`)
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Base64Only: true})
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
}
@@ -119,7 +140,7 @@ func TestStripInvalidClaudeThinkingBlocks_AllowsEmptySignatureEmptyTextPlacehold
Base64Only: true,
AllowEmptySignatureWithEmptyText: true,
})
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
}
@@ -135,7 +156,7 @@ func TestStripInvalidClaudeThinkingBlocks_StrictRemovesMalformedClaudeTree(t *te
}`)
out := StripInvalidClaudeThinkingBlocks(input, ClaudeSignatureValidationOptions{Strict: true})
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("content length = %d, want 1: %s", len(content), string(out))
}
@@ -155,7 +176,7 @@ func TestStripInvalidClaudeThinkingBlocks_KeepsClaudeSignaturePrefixes(t *testin
}`)
out := StripInvalidClaudeThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytes(out, "messages.0.content").Array()
if len(content) != 2 {
t.Fatalf("content length = %d, want 2: %s", len(content), string(out))
}
@@ -485,7 +506,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_ClaudeCAIS(t *testing.T) {
if reportSame.Preserved != 1 || reportSame.DroppedBlocks != 0 {
t.Fatalf("unexpected report for same model: %+v", reportSame)
}
- if got := gjson.GetBytes(outputSame, "messages.0.content.0.signature").String(); got != observedFable5Sample {
+ if got := ojson.GetBytes(outputSame, "messages.0.content.0.signature").String(); got != observedFable5Sample {
t.Fatalf("signature = %q, want preserved %q", got, observedFable5Sample)
}
@@ -494,7 +515,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_ClaudeCAIS(t *testing.T) {
if reportTool.Preserved != 1 {
t.Fatalf("unexpected report for tool input: %+v", reportTool)
}
- partsTool := gjson.GetBytes(outputTool, "messages.0.content").Array()
+ partsTool := ojson.GetBytes(outputTool, "messages.0.content").Array()
if len(partsTool) != 3 {
t.Fatalf("content len = %d, want 3", len(partsTool))
}
@@ -510,7 +531,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_ClaudeCAIS(t *testing.T) {
if reportDiff.Preserved != 1 || reportDiff.DroppedBlocks != 0 {
t.Fatalf("unexpected report for cross model: %+v", reportDiff)
}
- partsDiff := gjson.GetBytes(outputDiff, "messages.0.content").Array()
+ partsDiff := ojson.GetBytes(outputDiff, "messages.0.content").Array()
if len(partsDiff) != 2 {
t.Fatalf("content len = %d, want 2: %s", len(partsDiff), outputDiff)
}
@@ -594,7 +615,7 @@ func TestClaudeCAISSignature_ToleratesUpstreamFieldDrift(t *testing.T) {
if report.Preserved != 1 || report.DroppedBlocks != 0 {
t.Fatalf("report = %+v, want preserved thinking block", report)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
+ if got := ojson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
t.Fatalf("signature = %q, want preserved %q", got, sig)
}
})
@@ -700,7 +721,7 @@ func TestClaudeCAISSignature_DoesNotShadowClassicClaudeSignature(t *testing.T) {
if report.Preserved != 1 || report.DroppedBlocks != 0 {
t.Fatalf("report = %+v, want preserved classic thinking block", report)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != classic {
+ if got := ojson.GetBytes(output, "messages.0.content.0.signature").String(); got != classic {
t.Fatalf("signature = %q, want provider-native E-form %q", got, classic)
}
}
@@ -718,7 +739,7 @@ func TestClaudeCAISSignature_CachePrefixSurvivesClaudeUpstreamSanitize(t *testin
if report.Preserved != 1 || report.DroppedBlocks != 0 {
t.Fatalf("report = %+v, want preserved thinking block", report)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != observedFable5Sample {
+ if got := ojson.GetBytes(output, "messages.0.content.0.signature").String(); got != observedFable5Sample {
t.Fatalf("signature = %q, want unprefixed %q", got, observedFable5Sample)
}
})
diff --git a/internal/signature/claude_validation.go b/internal/signature/claude_validation.go
index 29191559d56..5ae20cb9f54 100644
--- a/internal/signature/claude_validation.go
+++ b/internal/signature/claude_validation.go
@@ -114,12 +114,12 @@
package signature
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"unicode/utf8"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -193,15 +193,15 @@ func HasDecodableClaudeThinkingSignature(rawSignature string) bool {
_, err := InspectAntigravityClaudeCAQSSignature(sig)
return err == nil
case 'E':
- decoded, err := base64.StdEncoding.DecodeString(sig)
- return err == nil && len(decoded) > 0
+ n, err := base64.StdEncoding.ValidateString(sig)
+ return err == nil && n > 0
case 'R':
decoded, err := base64.StdEncoding.DecodeString(sig)
if err != nil || len(decoded) == 0 || decoded[0] != 'E' {
return false
}
- innerDecoded, err := base64.StdEncoding.DecodeString(string(decoded))
- return err == nil && len(innerDecoded) > 0
+ n, err := base64.StdEncoding.ValidateString(string(decoded))
+ return err == nil && n > 0
default:
return false
}
@@ -235,7 +235,7 @@ func stripClaudeSignaturePrefix(rawSignature string) string {
// ValidateClaudeThinkingSignatures validates every thinking block signature in a
// Claude messages payload.
func ValidateClaudeThinkingSignatures(inputRawJSON []byte, opts ...ClaudeSignatureValidationOptions) error {
- messages := gjson.GetBytes(inputRawJSON, "messages")
+ messages := ojson.GetBytesNoCopy(inputRawJSON, "messages")
if !messages.IsArray() {
return nil
}
diff --git a/internal/signature/gemini_sanitize.go b/internal/signature/gemini_sanitize.go
index 174eaa38755..f493826e3e6 100644
--- a/internal/signature/gemini_sanitize.go
+++ b/internal/signature/gemini_sanitize.go
@@ -1,26 +1,19 @@
package signature
import (
+ "fmt"
"strings"
- "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// geminiSanitizeOptions scopes Gemini replay policy to the upstream endpoint.
type geminiSanitizeOptions struct {
- // rejectBareTink treats Vertex bare-Tink signatures as incompatible. Live
- // replay on 2026-10-26 showed Antigravity (cloudcode-pa) and AI Studio
- // (generativelanguage) reject them with 400 "Corrupted thought signature",
- // while they are the native form for Vertex AI.
rejectBareTink bool
}
// GeminiReplaySignatureOrBypass returns a Gemini-replayable thoughtSignature.
-// Compatible Gemini signatures are normalized and preserved. Missing, unknown,
-// or cross-provider signatures are replaced with Gemini's bypass sentinel.
func GeminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlockKind) string {
return geminiReplaySignatureOrBypass(rawSignature, blockKind, geminiSanitizeOptions{})
}
@@ -36,8 +29,6 @@ func geminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlock
return GeminiSkipThoughtSignatureValidator
}
-// geminiSanitizeDecision applies endpoint scoping on top of the family-level
-// compatibility decision.
func geminiSanitizeDecision(rawSignature string, blockKind SignatureBlockKind, opts geminiSanitizeOptions) SignatureCompatibilityDecision {
decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind)
if !opts.rejectBareTink || !decision.Compatible || !IsGeminiVertexBareTinkSignature(rawSignature) {
@@ -52,42 +43,135 @@ func geminiSanitizeDecision(rawSignature string, blockKind SignatureBlockKind, o
}
// SanitizeGeminiRequestThoughtSignatures applies Gemini replay policy to a
-// Gemini-shaped request. Existing provider signatures stay on their original
-// model parts. Server-side tool blocks (toolCall and toolResponse) are echoed back
-// untouched per the Gemini API contract. Only a missing or incompatible first
-// functionCall in each parallel-call group (at the start of a model turn or after
-// a non-thought part) gets the bypass sentinel; unsigned sibling calls remain
-// unsigned, matching native Gemini parallel-call history. functionResponse parts
-// never carry signatures.
-//
-// Vertex bare-Tink signatures are preserved here because the target endpoint is
-// not known at this layer; executors for other Gemini endpoints must also call
-// SanitizeNonVertexGeminiRequestThoughtSignatures before sending.
+// Gemini-shaped request.
func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte {
return sanitizeGeminiRequestThoughtSignatures(payload, contentsPath, geminiSanitizeOptions{})
}
+// SanitizeGeminiRequestThoughtSignaturesDoc applies Gemini replay policy onto doc in place.
+func SanitizeGeminiRequestThoughtSignaturesDoc(doc *ojson.Doc, contentsPath string) {
+ _ = sanitizeGeminiRequestThoughtSignaturesDoc(doc, contentsPath, geminiSanitizeOptions{})
+}
+
// SanitizeNonVertexGeminiRequestThoughtSignatures applies the same policy for
// Gemini endpoints other than Vertex AI (Antigravity, AI Studio, Gemini API).
-// Vertex bare-Tink signatures are treated as incompatible: the first
-// functionCall in a group gets the bypass sentinel and other parts drop it.
func SanitizeNonVertexGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte {
return sanitizeGeminiRequestThoughtSignatures(payload, contentsPath, geminiSanitizeOptions{rejectBareTink: true})
}
+// SanitizeNonVertexGeminiRequestThoughtSignaturesDoc applies non-Vertex Gemini
+// signature sanitization onto doc in place.
+func SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc *ojson.Doc, contentsPath string) {
+ _ = sanitizeGeminiRequestThoughtSignaturesDoc(doc, contentsPath, geminiSanitizeOptions{rejectBareTink: true})
+}
+
+// SanitizeGeminiSystemInstruction removes empty part objects ({}) from
+// systemInstruction.parts (and system_instruction.parts) at the same envelope
+// level as contentsPath.
+func SanitizeGeminiSystemInstruction(payload []byte, contentsPath string) []byte {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !sanitizeGeminiSystemInstructionDoc(doc, contentsPath) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+// SanitizeGeminiSystemInstructionDoc removes empty part objects ({}) from
+// systemInstruction.parts (and system_instruction.parts) on doc in place.
+func SanitizeGeminiSystemInstructionDoc(doc *ojson.Doc, contentsPath string) {
+ _ = sanitizeGeminiSystemInstructionDoc(doc, contentsPath)
+}
+
+func sanitizeGeminiSystemInstructionDoc(doc *ojson.Doc, contentsPath string) bool {
+ if doc == nil {
+ return false
+ }
+ prefix := ""
+ if trimmed := strings.TrimSpace(contentsPath); trimmed != "" {
+ if idx := strings.LastIndex(trimmed, "."); idx > 0 {
+ prefix = trimmed[:idx+1]
+ }
+ }
+ modified := false
+ for _, key := range []string{"systemInstruction", "system_instruction"} {
+ sysPath := prefix + key
+ sys := doc.Get(sysPath)
+ if !sys.Exists() || !sys.IsObject() {
+ continue
+ }
+ parts := sys.Get("parts")
+ if !parts.IsArray() {
+ continue
+ }
+ var emptyIndices []int
+ totalParts := 0
+ parts.ForEach(func(_, part ojson.Result) bool {
+ if isEmptyGeminiPartObject(part) {
+ emptyIndices = append(emptyIndices, totalParts)
+ }
+ totalParts++
+ return true
+ })
+ if len(emptyIndices) == 0 {
+ continue
+ }
+ if len(emptyIndices) == totalParts {
+ if doc.Delete(sysPath) == nil {
+ modified = true
+ }
+ continue
+ }
+ for i := len(emptyIndices) - 1; i >= 0; i-- {
+ if doc.Delete(fmt.Sprintf("%s.parts.%d", sysPath, emptyIndices[i])) == nil {
+ modified = true
+ }
+ }
+ }
+ return modified
+}
+
+func isEmptyGeminiPartObject(part ojson.Result) bool {
+ if !part.IsObject() {
+ return false
+ }
+ hasField := false
+ part.ForEach(func(_, _ ojson.Result) bool {
+ hasField = true
+ return false
+ })
+ return !hasField
+}
+
func sanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string, opts geminiSanitizeOptions) []byte {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !sanitizeGeminiRequestThoughtSignaturesDoc(doc, contentsPath, opts) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func sanitizeGeminiRequestThoughtSignaturesDoc(doc *ojson.Doc, contentsPath string, opts geminiSanitizeOptions) bool {
+ if doc == nil {
+ return false
+ }
contentsPath = strings.TrimSpace(contentsPath)
if contentsPath == "" {
contentsPath = "contents"
}
+ modified := sanitizeGeminiSystemInstructionDoc(doc, contentsPath)
- contents := util.GetGJSONBytesNoCopy(payload, contentsPath)
+ contents := doc.Get(contentsPath)
if !contents.IsArray() || !geminiContentsThoughtSignaturesNeedSanitize(contents, opts) {
- return payload
+ return modified
}
contentsChanged := false
- contentItems := make([][]byte, 0, int(contents.Get("#").Int()))
type sanitizeLogKey struct {
action SignatureCompatibilityAction
reason string
@@ -109,24 +193,28 @@ func sanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string,
sanitizeCounts[key]++
}
- contents.ForEach(func(_, content gjson.Result) bool {
+ contentIdx := 0
+ contents.ForEach(func(_, content ojson.Result) bool {
+ cIdx := contentIdx
+ contentIdx++
parts := content.Get("parts")
if !parts.IsArray() {
- contentItems = append(contentItems, []byte(content.Raw))
return true
}
isModelTurn := content.Get("role").String() == "model"
firstFunctionCallSeen := false
- partsChanged := false
- partItems := make([][]byte, 0, int(parts.Get("#").Int()))
- parts.ForEach(func(_, part gjson.Result) bool {
- partJSON := []byte(part.Raw)
+ partIdx := 0
+ parts.ForEach(func(_, part ojson.Result) bool {
+ pIdx := partIdx
+ partIdx++
+ partPath := fmt.Sprintf("%s.%d.parts.%d", contentsPath, cIdx, pIdx)
rawSignature, hasSignature := geminiPartThoughtSignature(part)
if part.Get("functionResponse").Exists() {
if hasSignature {
- partJSON = deleteGeminiPartThoughtSignatureFields(partJSON)
- partsChanged = true
+ if deleteGeminiPartThoughtSignatureFieldsDoc(doc, partPath) {
+ contentsChanged = true
+ }
countSanitize(SignatureCompatibilityDecision{
TargetProvider: SignatureProviderGemini,
DetectedProvider: DetectSignatureProviderForBlock(rawSignature, SignatureBlockKindGeminiModelPart),
@@ -135,27 +223,21 @@ func sanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string,
Reason: "tool responses cannot carry signatures",
})
}
- partItems = append(partItems, partJSON)
return true
}
if !isModelTurn {
- partItems = append(partItems, partJSON)
return true
}
if part.Get("toolCall").Exists() || part.Get("tool_call").Exists() || part.Get("toolResponse").Exists() || part.Get("tool_response").Exists() {
- partItems = append(partItems, partJSON)
return true
}
hasFunctionCall := part.Get("functionCall").Exists()
isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen
- // A text part, even an empty signature carrier, ends the parallel group:
- // the Gemini API rejects an unsigned functionCall after it. Thought parts do not.
if hasFunctionCall || !part.Get("thought").Bool() {
firstFunctionCallSeen = hasFunctionCall
}
if !hasFunctionCall && !hasSignature {
- partItems = append(partItems, partJSON)
return true
}
@@ -186,39 +268,28 @@ func sanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string,
partChanged := false
if replaySignature != "" {
if !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) {
- partJSON = deleteGeminiPartThoughtSignatureFields(partJSON)
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", replaySignature)
+ deleteGeminiPartThoughtSignatureFieldsDoc(doc, partPath)
+ _ = doc.Set(partPath+".thoughtSignature", replaySignature)
partChanged = true
}
} else if hasSignature {
- partJSON = deleteGeminiPartThoughtSignatureFields(partJSON)
- partChanged = true
+ if deleteGeminiPartThoughtSignatureFieldsDoc(doc, partPath) {
+ partChanged = true
+ }
}
if partChanged {
- partsChanged = true
+ contentsChanged = true
if decision.Action != SignatureActionPreserve {
countSanitize(decision)
}
}
- partItems = append(partItems, partJSON)
return true
})
-
- contentJSON := []byte(content.Raw)
- if partsChanged {
- contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts", joinGeminiSignatureRawArray(partItems))
- contentsChanged = true
- }
- contentItems = append(contentItems, contentJSON)
return true
})
if !contentsChanged {
- return payload
- }
- updated, errSet := sjson.SetRawBytes(payload, contentsPath, joinGeminiSignatureRawArray(contentItems))
- if errSet != nil {
- return payload
+ return modified
}
for _, key := range sanitizeOrder {
count := sanitizeCounts[key]
@@ -235,7 +306,7 @@ func sanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string,
contentsPath,
key.reason)
}
- return updated
+ return true
}
func sanitizeActionName(action SignatureCompatibilityAction) string {
@@ -271,16 +342,16 @@ func sanitizeSigTypeName(provider SignatureProvider) string {
}
}
-func geminiContentsThoughtSignaturesNeedSanitize(contents gjson.Result, opts geminiSanitizeOptions) bool {
+func geminiContentsThoughtSignaturesNeedSanitize(contents ojson.Result, opts geminiSanitizeOptions) bool {
needsSanitize := false
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
if !parts.IsArray() {
return true
}
isModelTurn := content.Get("role").String() == "model"
firstFunctionCallSeen := false
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
rawSignature, hasSignature := geminiPartThoughtSignature(part)
if part.Get("functionResponse").Exists() {
needsSanitize = hasSignature
@@ -332,7 +403,7 @@ var geminiPartThoughtSignaturePaths = []string{
"extra_content.google.thought_signature",
}
-func geminiPartThoughtSignature(part gjson.Result) (string, bool) {
+func geminiPartThoughtSignature(part ojson.Result) (string, bool) {
for _, path := range geminiPartThoughtSignaturePaths {
result := part.Get(path)
if result.Exists() {
@@ -342,16 +413,16 @@ func geminiPartThoughtSignature(part gjson.Result) (string, bool) {
return "", false
}
-func hasNormalizedGeminiPartThoughtSignature(part gjson.Result, replaySignature string) bool {
+func hasNormalizedGeminiPartThoughtSignature(part ojson.Result, replaySignature string) bool {
canonicalCount := 0
- part.ForEach(func(key, _ gjson.Result) bool {
+ part.ForEach(func(key, _ ojson.Result) bool {
if key.String() == "thoughtSignature" {
canonicalCount++
}
return true
})
canonical := part.Get("thoughtSignature")
- if canonicalCount != 1 || canonical.Type != gjson.String || canonical.String() != replaySignature {
+ if canonicalCount != 1 || canonical.Type != ojson.String || canonical.String() != replaySignature {
return false
}
for _, path := range geminiPartThoughtSignaturePaths[1:] {
@@ -362,31 +433,20 @@ func hasNormalizedGeminiPartThoughtSignature(part gjson.Result, replaySignature
return true
}
-func deleteGeminiPartThoughtSignatureFields(payload []byte) []byte {
- for _, path := range geminiPartThoughtSignaturePaths {
- for gjson.GetBytes(payload, path).Exists() {
- updated, errDelete := sjson.DeleteBytes(payload, path)
- if errDelete != nil || len(updated) >= len(payload) {
+func deleteGeminiPartThoughtSignatureFieldsDoc(doc *ojson.Doc, partPath string) bool {
+ modified := false
+ for _, relPath := range geminiPartThoughtSignaturePaths {
+ path := relPath
+ if partPath != "" {
+ path = partPath + "." + relPath
+ }
+ for doc.Get(path).Exists() {
+ beforeLen := doc.Len()
+ if errDelete := doc.Delete(path); errDelete != nil || doc.Len() >= beforeLen {
break
}
- payload = updated
- }
- }
- return payload
-}
-
-func joinGeminiSignatureRawArray(items [][]byte) []byte {
- size := len(items) + 1
- for _, item := range items {
- size += len(item)
- }
- out := make([]byte, 0, size)
- out = append(out, '[')
- for index, item := range items {
- if index > 0 {
- out = append(out, ',')
+ modified = true
}
- out = append(out, item...)
}
- return append(out, ']')
+ return modified
}
diff --git a/internal/signature/gemini_sanitize_test.go b/internal/signature/gemini_sanitize_test.go
index 06dadf7aa6e..0f642a68b98 100644
--- a/internal/signature/gemini_sanitize_test.go
+++ b/internal/signature/gemini_sanitize_test.go
@@ -5,9 +5,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
)
func newSignatureDebugHook(t *testing.T) *test.Hook {
@@ -49,7 +49,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesPreservesGeminiSignature(t *testi
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != sig {
t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, sig, string(out))
}
if &out[0] != &input[0] {
@@ -62,7 +62,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesNormalizesDuplicateCanonicalField
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, out)
}
if count := strings.Count(string(out), `"thoughtSignature"`); count != 1 {
@@ -75,10 +75,10 @@ func TestSanitizeGeminiRequestThoughtSignaturesParallelSyntheticOnlyFirstGetsByp
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("first call signature = %q, want bypass sentinel; output=%s", got, out)
}
- if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("second parallel call should remain unsigned; output=%s", out)
}
}
@@ -89,13 +89,13 @@ func TestSanitizeGeminiRequestThoughtSignaturesCallAfterTextGetsBypass(t *testin
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("first call signature = %q, want bypass sentinel; output=%s", got, out)
}
- if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("second parallel call should remain unsigned; output=%s", out)
}
- if got := gjson.GetBytes(out, "contents.0.parts.3.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.3.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("call after text signature = %q, want bypass sentinel; output=%s", got, out)
}
})
@@ -109,13 +109,13 @@ func TestSanitizeGeminiRequestThoughtSignaturesCallAfterTextGetsBypass(t *testin
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("first call signature = %q, want bypass sentinel; output=%s", got, out)
}
- if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("second parallel call should have bypass stripped; output=%s", out)
}
- if got := gjson.GetBytes(out, "contents.0.parts.3.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.3.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("call after text signature = %q, want bypass sentinel preserved; output=%s", got, out)
}
})
@@ -128,10 +128,10 @@ func TestSanitizeGeminiRequestThoughtSignaturesCallAfterTextGetsBypass(t *testin
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("first call signature = %q, want bypass sentinel; output=%s", got, out)
}
- if signature := gjson.GetBytes(out, "contents.0.parts.2.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.2.thoughtSignature"); signature.Exists() {
t.Fatalf("call after thought part should remain unsigned sibling; output=%s", out)
}
})
@@ -143,10 +143,10 @@ func TestSanitizeGeminiRequestThoughtSignaturesNativeParallelPreservesUnsignedSi
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature {
t.Fatalf("first call signature = %q, want native signature; output=%s", got, out)
}
- if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("native unsigned sibling should remain unsigned; output=%s", out)
}
if &out[0] != &input[0] {
@@ -160,10 +160,10 @@ func TestSanitizeGeminiRequestThoughtSignaturesRemovesPollutedSiblingBypass(t *t
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature {
t.Fatalf("first call signature = %q, want native signature; output=%s", got, out)
}
- if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("polluted sibling bypass should be removed; output=%s", out)
}
}
@@ -176,7 +176,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesRemovesPrefixedSiblingBypass(t *t
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() {
t.Fatalf("prefixed sibling bypass should be removed; output=%s", out)
}
})
@@ -188,7 +188,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesLeavesUnsignedThoughtUnsigned(t *
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if signature := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature"); signature.Exists() {
t.Fatalf("unsigned thought should remain unsigned; output=%s", out)
}
if &out[0] != &input[0] {
@@ -215,10 +215,10 @@ func TestSanitizeGeminiRequestThoughtSignaturesReplacesBase64UUIDFunctionCall(t
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "contents.0.parts.0.functionCall.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "contents.0.parts.0.functionCall.thoughtSignature").Exists() {
t.Fatalf("nested functionCall thoughtSignature should be removed. Output: %s", string(out))
}
}
@@ -229,7 +229,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesLogsBypassReplacement(t *testing.
input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{},"thoughtSignature":"` + sig + `"}}]}]}`)
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out))
}
@@ -267,7 +267,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesAggregatesRepeatedLogs(t *testing
for i := 0; i < 3; i++ {
path := fmt.Sprintf("contents.%d.parts.0.thoughtSignature", i)
- if gjson.GetBytes(out, path).Exists() {
+ if ojson.GetBytes(out, path).Exists() {
t.Fatalf("expected part %d signature to be dropped, got: %s", i, string(out))
}
}
@@ -309,7 +309,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesDistinguishesDetectedProviders(t
for i := 0; i < 4; i++ {
path := fmt.Sprintf("contents.%d.parts.0.thoughtSignature", i)
- if gjson.GetBytes(out, path).Exists() {
+ if ojson.GetBytes(out, path).Exists() {
t.Fatalf("expected part %d signature to be dropped, got: %s", i, string(out))
}
}
@@ -342,8 +342,8 @@ func TestSanitizeGeminiRequestThoughtSignaturesSiblingBypassLogsSingleAggregate(
`{"functionCall":{"name":"third","args":{}},"thoughtSignature":"` + prefix + GeminiSkipThoughtSignatureValidator + `"}]}]}`)
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature").Exists() ||
- gjson.GetBytes(out, "contents.0.parts.2.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature").Exists() ||
+ ojson.GetBytes(out, "contents.0.parts.2.thoughtSignature").Exists() {
t.Fatalf("sibling bypass was not removed: %s", out)
}
entries := hook.AllEntries()
@@ -377,7 +377,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesInvalidSiblingStillLogsAtDebug(t
for range 2 {
hook.Reset()
out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents")
- if gjson.GetBytes(out, "request.contents.0.parts.1.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "request.contents.0.parts.1.thoughtSignature").Exists() {
t.Fatalf("invalid sibling signature was not removed: %s", out)
}
entries := hook.AllEntries()
@@ -420,7 +420,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesForeignPrefixedBypassLogsAtDebug(
input := []byte(`{"contents":[{"role":"model","parts":[` + parts + `]}]}`)
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
for i := range siblings {
- if gjson.GetBytes(out, fmt.Sprintf("contents.0.parts.%d.thoughtSignature", i+1)).Exists() {
+ if ojson.GetBytes(out, fmt.Sprintf("contents.0.parts.%d.thoughtSignature", i+1)).Exists() {
t.Fatalf("foreign sibling signature was not removed: %s", out)
}
}
@@ -467,7 +467,7 @@ func TestSanitizeGeminiRequestThoughtSignaturesPreservesField2WrappedUUIDFunctio
out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents")
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != sig {
t.Fatalf("thoughtSignature = %q, want wrapped UUID signature preserved. Output: %s", got, string(out))
}
}
@@ -515,10 +515,10 @@ func TestSanitizeGeminiRequestThoughtSignaturesRemovesFunctionResponseSignature(
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() {
t.Fatalf("functionResponse top-level thoughtSignature should be removed. Output: %s", string(out))
}
- if gjson.GetBytes(out, "contents.0.parts.0.functionResponse.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "contents.0.parts.0.functionResponse.thoughtSignature").Exists() {
t.Fatalf("functionResponse nested thoughtSignature should be removed. Output: %s", string(out))
}
}
@@ -531,13 +531,13 @@ func TestSanitizeGeminiRequestThoughtSignatures_PreservesToolCallAndResponseSign
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.1.parts.0.thoughtSignature").String(); got != liveCapturedToolCallSig {
+ if got := ojson.GetBytes(out, "contents.1.parts.0.thoughtSignature").String(); got != liveCapturedToolCallSig {
t.Fatalf("toolCall thoughtSignature = %q, want %q. Output: %s", got, liveCapturedToolCallSig, string(out))
}
- if got := gjson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != liveCapturedToolCallSig {
+ if got := ojson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != liveCapturedToolCallSig {
t.Fatalf("toolResponse thoughtSignature = %q, want %q. Output: %s", got, liveCapturedToolCallSig, string(out))
}
- if got := gjson.GetBytes(out, "contents.1.parts.2.thoughtSignature").String(); got != sigModel {
+ if got := ojson.GetBytes(out, "contents.1.parts.2.thoughtSignature").String(); got != sigModel {
t.Fatalf("functionCall thoughtSignature = %q, want %q. Output: %s", got, sigModel, string(out))
}
}
@@ -553,10 +553,10 @@ func TestSanitizeGeminiRequestThoughtSignatures_SkipsToolCallAndToolResponsePart
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != arbitrarySig {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != arbitrarySig {
t.Fatalf("toolCall thoughtSignature = %q, want preserved %q", got, arbitrarySig)
}
- if got := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature").String(); got != arbitrarySig {
+ if got := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature").String(); got != arbitrarySig {
t.Fatalf("toolResponse thoughtSignature = %q, want preserved %q", got, arbitrarySig)
}
}
@@ -571,10 +571,10 @@ func TestSanitizeGeminiRequestThoughtSignatures_SkipsSnakeCaseToolCallAndRespons
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thought_signature").String(); got != arbitrarySig {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thought_signature").String(); got != arbitrarySig {
t.Fatalf("tool_call thought_signature = %q, want preserved %q", got, arbitrarySig)
}
- if got := gjson.GetBytes(out, "contents.0.parts.1.thought_signature").String(); got != arbitrarySig {
+ if got := ojson.GetBytes(out, "contents.0.parts.1.thought_signature").String(); got != arbitrarySig {
t.Fatalf("tool_response thought_signature = %q, want preserved %q", got, arbitrarySig)
}
}
@@ -587,7 +587,7 @@ func TestSanitizeGeminiRequestThoughtSignatures_DropsForeignSignatureOnTextPart(
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").Exists() {
t.Fatalf("expected foreign signature on text part to be dropped, got %s", string(out))
}
}
@@ -603,10 +603,10 @@ func TestSanitizeGeminiRequestThoughtSignatures_MixedToolCallAndUnsignedFunction
out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
- if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != toolSig {
+ if got := ojson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != toolSig {
t.Fatalf("toolCall signature = %q, want preserved %q", got, toolSig)
}
- if got := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.0.parts.1.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("functionCall signature = %q, want bypass %q. Output: %s", got, GeminiSkipThoughtSignatureValidator, string(out))
}
}
@@ -625,10 +625,10 @@ func TestSanitizeGeminiRequestThoughtSignatures_PreservesVertexBareTink(t *testi
sig := testGeminiBareTinkSignature(405)
out := SanitizeGeminiRequestThoughtSignatures(bareTinkReplayPayload(sig), "contents")
- if got := gjson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != sig {
t.Fatalf("functionCall signature = %q, want original bare-Tink value", got)
}
- if got := gjson.GetBytes(out, "contents.3.parts.0.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "contents.3.parts.0.thoughtSignature").String(); got != sig {
t.Fatalf("text signature = %q, want original bare-Tink value", got)
}
}
@@ -637,13 +637,13 @@ func TestSanitizeNonVertexGeminiRequestThoughtSignatures_ReplacesVertexBareTink(
sig := testGeminiBareTinkSignature(405)
out := SanitizeNonVertexGeminiRequestThoughtSignatures(bareTinkReplayPayload(sig), "contents")
- if got := gjson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ if got := ojson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
t.Fatalf("functionCall signature = %q, want bypass sentinel", got)
}
- if gjson.GetBytes(out, "contents.3.parts.0.thoughtSignature").Exists() {
+ if ojson.GetBytes(out, "contents.3.parts.0.thoughtSignature").Exists() {
t.Fatalf("text signature should be dropped: %s", out)
}
- if got := gjson.GetBytes(out, "contents.1.parts.0.thought").Bool(); !got {
+ if got := ojson.GetBytes(out, "contents.1.parts.0.thought").Bool(); !got {
t.Fatalf("thought part must stay untouched: %s", out)
}
}
@@ -652,10 +652,50 @@ func TestSanitizeNonVertexGeminiRequestThoughtSignatures_PreservesWrappedEnvelop
sig := testGeminiThoughtSignatureEnvelope()
out := SanitizeNonVertexGeminiRequestThoughtSignatures(bareTinkReplayPayload(sig), "contents")
- if got := gjson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "contents.1.parts.1.thoughtSignature").String(); got != sig {
t.Fatalf("functionCall signature = %q, want wrapped envelope preserved", got)
}
- if got := gjson.GetBytes(out, "contents.3.parts.0.thoughtSignature").String(); got != sig {
+ if got := ojson.GetBytes(out, "contents.3.parts.0.thoughtSignature").String(); got != sig {
t.Fatalf("text signature = %q, want wrapped envelope preserved", got)
}
}
+
+func TestSanitizeGeminiRequestThoughtSignatures_StripsEmptySystemInstructionParts(t *testing.T) {
+ t.Run("drops_systemInstruction_when_all_parts_are_empty_objects", func(t *testing.T) {
+ input := []byte(`{"request":{"systemInstruction":{"parts":[{}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}}`)
+ out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents")
+ if ojson.GetBytes(out, "request.systemInstruction").Exists() {
+ t.Fatalf("expected request.systemInstruction to be removed, got %s", out)
+ }
+ })
+
+ t.Run("retains_non_empty_systemInstruction_parts", func(t *testing.T) {
+ input := []byte(`{"systemInstruction":{"parts":[{},{"text":"keep rule"},{}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
+ parts := ojson.GetBytes(out, "systemInstruction.parts").Array()
+ if len(parts) != 1 || parts[0].Get("text").String() != "keep rule" {
+ t.Fatalf("unexpected systemInstruction.parts: %s", out)
+ }
+ })
+
+ t.Run("reuses_payload_when_systemInstruction_parts_are_valid", func(t *testing.T) {
+ input := []byte(`{"systemInstruction":{"parts":[{"text":"keep rule"}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents")
+ if &out[0] != &input[0] {
+ t.Fatal("valid systemInstruction payload was unexpectedly copied")
+ }
+ })
+}
+
+func TestSanitizeNonVertexGeminiRequestThoughtSignaturesDoc_ReplacesVertexBareTink(t *testing.T) {
+ sig := testGeminiBareTinkSignature(405)
+ doc := ojson.NewDoc(bareTinkReplayPayload(sig))
+ SanitizeNonVertexGeminiRequestThoughtSignaturesDoc(doc, "contents")
+
+ if got := doc.Get("contents.1.parts.1.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator {
+ t.Fatalf("functionCall signature = %q, want bypass sentinel", got)
+ }
+ if doc.Get("contents.3.parts.0.thoughtSignature").Exists() {
+ t.Fatalf("text signature should be dropped: %s", doc.String())
+ }
+}
diff --git a/internal/signature/gemini_validation.go b/internal/signature/gemini_validation.go
index aa6b1637381..2b40a7d84f0 100644
--- a/internal/signature/gemini_validation.go
+++ b/internal/signature/gemini_validation.go
@@ -69,12 +69,12 @@
package signature
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -147,7 +147,7 @@ type geminiFunctionCallRef struct {
}
type geminiFunctionResponseRef struct {
- part gjson.Result
+ part ojson.Result
path string
}
@@ -302,7 +302,7 @@ func ValidateGeminiFunctionCallPairing(inputRawJSON []byte) error {
var pending []geminiFunctionCallRef
var validationErr error
- contents.ForEach(func(contentIndex, content gjson.Result) bool {
+ contents.ForEach(func(contentIndex, content ojson.Result) bool {
i := int(contentIndex.Int())
parts := content.Get("parts")
if !parts.IsArray() || parts.Raw == "[]" || !parts.Get("0").Exists() {
@@ -319,7 +319,7 @@ func ValidateGeminiFunctionCallPairing(inputRawJSON []byte) error {
var calls []geminiFunctionCallRef
var responses []geminiFunctionResponseRef
- parts.ForEach(func(partIndex, part gjson.Result) bool {
+ parts.ForEach(func(partIndex, part ojson.Result) bool {
j := int(partIndex.Int())
partPath := fmt.Sprintf("%s[%d].parts[%d]", contentsPath, i, j)
if call := part.Get("functionCall"); call.Exists() {
@@ -665,7 +665,7 @@ func isASCIIUUIDBytes(decoded []byte) bool {
return true
}
-func geminiContents(inputRawJSON []byte) (gjson.Result, string) {
+func geminiContents(inputRawJSON []byte) (ojson.Result, string) {
if contents := util.GetGJSONBytesNoCopy(inputRawJSON, "contents"); contents.Exists() {
return contents, "contents"
}
diff --git a/internal/signature/gemini_validation_test.go b/internal/signature/gemini_validation_test.go
index 90e31dd599c..217c4c7cf07 100644
--- a/internal/signature/gemini_validation_test.go
+++ b/internal/signature/gemini_validation_test.go
@@ -1,8 +1,8 @@
package signature
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"os"
"path/filepath"
"runtime"
diff --git a/internal/signature/gpt_validation.go b/internal/signature/gpt_validation.go
index e69da4b8462..7cda22e7134 100644
--- a/internal/signature/gpt_validation.go
+++ b/internal/signature/gpt_validation.go
@@ -1,8 +1,8 @@
package signature
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"unicode/utf8"
)
diff --git a/internal/signature/gpt_validation_test.go b/internal/signature/gpt_validation_test.go
index e1e19ca81a3..2049b479ab4 100644
--- a/internal/signature/gpt_validation_test.go
+++ b/internal/signature/gpt_validation_test.go
@@ -1,7 +1,7 @@
package signature
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
)
diff --git a/internal/signature/grok_validation.go b/internal/signature/grok_validation.go
index 60fdb67de31..b50a14b3da3 100644
--- a/internal/signature/grok_validation.go
+++ b/internal/signature/grok_validation.go
@@ -1,8 +1,8 @@
package signature
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"math"
"strings"
"unicode/utf8"
diff --git a/internal/signature/grok_validation_test.go b/internal/signature/grok_validation_test.go
index 76005cfd54c..dbaaa1f7a35 100644
--- a/internal/signature/grok_validation_test.go
+++ b/internal/signature/grok_validation_test.go
@@ -3,8 +3,8 @@ package signature
import (
"bytes"
"crypto/sha256"
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"os"
"path/filepath"
"runtime"
diff --git a/internal/signature/kimi_validation.go b/internal/signature/kimi_validation.go
index c7c600b863d..d4edf5d1954 100644
--- a/internal/signature/kimi_validation.go
+++ b/internal/signature/kimi_validation.go
@@ -1,8 +1,8 @@
package signature
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
)
diff --git a/internal/signature/kimi_validation_test.go b/internal/signature/kimi_validation_test.go
index 304fc4fe545..76aeb7694d4 100644
--- a/internal/signature/kimi_validation_test.go
+++ b/internal/signature/kimi_validation_test.go
@@ -1,8 +1,8 @@
package signature
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"math/rand"
"os"
"path/filepath"
diff --git a/internal/signature/meta_validation.go b/internal/signature/meta_validation.go
index fe75192e069..c07481010c7 100644
--- a/internal/signature/meta_validation.go
+++ b/internal/signature/meta_validation.go
@@ -2,8 +2,8 @@ package signature
import (
"bytes"
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"unicode/utf8"
)
diff --git a/internal/signature/meta_validation_test.go b/internal/signature/meta_validation_test.go
index 29856c95bb1..5c9f14c7376 100644
--- a/internal/signature/meta_validation_test.go
+++ b/internal/signature/meta_validation_test.go
@@ -1,7 +1,7 @@
package signature
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
)
diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go
index 1bc5824eba4..d1e883742b8 100644
--- a/internal/signature/provider_compatibility_test.go
+++ b/internal/signature/provider_compatibility_test.go
@@ -1,11 +1,11 @@
package signature
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -353,7 +353,7 @@ func TestSanitizeClaudeMessagesSignaturesForModel_NormalizesSameProviderClaude(t
if report.Preserved != 1 || report.DroppedBlocks != 0 {
t.Fatalf("unexpected report: %+v", report)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != expectedSig {
+ if got := ojson.GetBytes(output, "messages.0.content.0.signature").String(); got != expectedSig {
t.Fatalf("signature = %q, want normalized %q", got, expectedSig)
}
}
@@ -366,7 +366,7 @@ func TestSanitizeClaudeMessagesSignaturesForModel_DropsClaudeThinkingForGemini(t
if report.DroppedBlocks != 1 {
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
}
- content := gjson.GetBytes(output, "messages.0.content").Array()
+ content := ojson.GetBytes(output, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("content length = %d, want 1: %s", len(content), output)
}
@@ -384,7 +384,7 @@ func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGeminiThinkingForGemi
if report.Preserved != 1 || report.DroppedBlocks != 0 {
t.Fatalf("unexpected report: %+v", report)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != nativeSig {
+ if got := ojson.GetBytes(output, "messages.0.content.0.signature").String(); got != nativeSig {
t.Fatalf("signature = %q, want normalized %q", got, nativeSig)
}
}
@@ -397,7 +397,7 @@ func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGPTForGPT(t *testing.
if report.Preserved != 1 || report.DroppedBlocks != 0 {
t.Fatalf("unexpected report: %+v", report)
}
- if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
+ if got := ojson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
t.Fatalf("signature = %q, want preserved %q", got, sig)
}
}
@@ -410,7 +410,7 @@ func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t *
if report.DroppedBlocks != 1 {
t.Fatalf("DroppedBlocks = %d, want 1", report.DroppedBlocks)
}
- messages := gjson.GetBytes(output, "messages").Array()
+ messages := ojson.GetBytes(output, "messages").Array()
if len(messages) != 1 {
t.Fatalf("messages length = %d, want 1: %s", len(messages), output)
}
@@ -426,7 +426,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_DropsInvalidThinkingAndCleansTo
if report.DroppedBlocks != 1 {
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
}
- parts := gjson.GetBytes(output, "messages.0.content").Array()
+ parts := ojson.GetBytes(output, "messages.0.content").Array()
if len(parts) != 2 {
t.Fatalf("content length = %d, want 2: %s", len(parts), output)
}
@@ -462,7 +462,7 @@ func TestSanitizeClaudeMessagesForClaudeUpstream_NormalizesValidThinkingAndDrops
if report.Preserved != 1 || report.DroppedBlocks != 1 {
t.Fatalf("unexpected report: %+v", report)
}
- messages := gjson.GetBytes(output, "messages").Array()
+ messages := ojson.GetBytes(output, "messages").Array()
if len(messages) != 2 {
t.Fatalf("messages length = %d, want 2: %s", len(messages), output)
}
diff --git a/internal/signature/signaturetest/claude.go b/internal/signature/signaturetest/claude.go
index 358e5758517..1748f7613f9 100644
--- a/internal/signature/signaturetest/claude.go
+++ b/internal/signature/signaturetest/claude.go
@@ -2,7 +2,7 @@
package signaturetest
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"google.golang.org/protobuf/encoding/protowire"
)
diff --git a/internal/store/xai_filename_adoption_test.go b/internal/store/xai_filename_adoption_test.go
new file mode 100644
index 00000000000..31e2f662fa0
--- /dev/null
+++ b/internal/store/xai_filename_adoption_test.go
@@ -0,0 +1,167 @@
+package store
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ git "github.com/go-git/go-git/v6"
+ cliproxyauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// TestFileNameStoresExposeBaseDirCapability pins the capability marker the
+// login flows use to decide whether renaming onto a richer credential file
+// is safe: every built-in backend resolves the save path from the record
+// name and must keep exposing SetBaseDir.
+func TestFileNameStoresExposeBaseDirCapability(t *testing.T) {
+ stores := map[string]cliproxyauth.Store{
+ "git": &GitTokenStore{},
+ "postgres": &PostgresStore{},
+ "object": &ObjectTokenStore{},
+ }
+ for name, store := range stores {
+ if _, ok := store.(interface{ SetBaseDir(string) }); !ok {
+ t.Errorf("%s store does not expose SetBaseDir; xAI filename adoption would be disabled for it", name)
+ }
+ }
+}
+
+// TestGitTokenStoreSaveResolvesFromRecordFileName proves the behavior the
+// adoption rename relies on outside FileTokenStore: when a record's name is
+// changed before Save, the new file lands at the adopted (email) name and
+// not at the superseded (subject) name it carried before the rename.
+func TestGitTokenStoreSaveResolvesFromRecordFileName(t *testing.T) {
+ root := t.TempDir()
+ remoteDir := filepath.Join(root, "remote.git")
+ if _, errInit := git.PlainInit(remoteDir, true); errInit != nil {
+ t.Fatalf("PlainInit error: %v", errInit)
+ }
+ store := NewGitTokenStore(remoteDir, "", "", "")
+ authDir := filepath.Join(root, "work", "auths")
+ store.SetBaseDir(authDir)
+ if errEnsure := store.EnsureRepository(); errEnsure != nil {
+ t.Fatalf("EnsureRepository error: %v", errEnsure)
+ }
+
+ const emailFileName = "xai-user@example.com-0123456789abcdef.json"
+ const subjectFileName = "xai-sub-1-fedcba9876543210.json"
+ record := &cliproxyauth.Auth{
+ ID: subjectFileName,
+ FileName: subjectFileName,
+ Provider: "xai",
+ Metadata: map[string]any{
+ "type": "xai",
+ "sub": "sub-1",
+ "access_token": "token-1",
+ },
+ }
+ // Simulate the login-flow adoption rename onto the richer email file.
+ record.ID = emailFileName
+ record.FileName = emailFileName
+ record.Metadata["email"] = "user@example.com"
+
+ savedPath, errSave := store.Save(context.Background(), record)
+ if errSave != nil {
+ t.Fatalf("Save error: %v", errSave)
+ }
+ if want := filepath.Join(authDir, emailFileName); savedPath != want {
+ t.Fatalf("savedPath = %s, want adopted email path %s", savedPath, want)
+ }
+ if _, errStat := os.Stat(savedPath); errStat != nil {
+ t.Fatalf("saved credential missing at %s: %v", savedPath, errStat)
+ }
+ if _, errStat := os.Stat(filepath.Join(authDir, subjectFileName)); !os.IsNotExist(errStat) {
+ t.Fatalf("superseded subject file should not exist: %v", errStat)
+ }
+}
+
+// TestRemoteStoresSaveResolvesFromRecordFileName proves the same rename
+// behavior for the Postgres and object backends: the remote key follows the
+// resolved local path, so an adopted email filename is what reaches the
+// remote store.
+func TestRemoteStoresSaveResolvesFromRecordFileName(t *testing.T) {
+ const emailFileName = "xai-user@example.com-0123456789abcdef.json"
+ const subjectFileName = "xai-sub-1-fedcba9876543210.json"
+
+ tests := []struct {
+ name string
+ build func(authDir string, onRemoteWrite func(id string, data []byte) error) cliproxyauth.Store
+ wantRemoteID string
+ }{
+ {
+ name: "postgres",
+ build: func(authDir string, onRemoteWrite func(id string, data []byte) error) cliproxyauth.Store {
+ return &PostgresStore{
+ authDir: authDir,
+ cfg: PostgresStoreConfig{AuthTable: "auth_tokens"},
+ persistAuthFn: func(_ context.Context, relID string, data []byte) error {
+ return onRemoteWrite(relID, data)
+ },
+ }
+ },
+ wantRemoteID: emailFileName,
+ },
+ {
+ name: "object",
+ build: func(authDir string, onRemoteWrite func(id string, data []byte) error) cliproxyauth.Store {
+ return &ObjectTokenStore{
+ authDir: authDir,
+ cfg: ObjectStoreConfig{Bucket: "bucket"},
+ putObjectFn: func(_ context.Context, key string, data []byte, _ string) error {
+ return onRemoteWrite(key, data)
+ },
+ }
+ },
+ wantRemoteID: "auths/" + emailFileName,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ authDir := filepath.Join(t.TempDir(), "auths")
+ if errMkdir := os.MkdirAll(authDir, 0o700); errMkdir != nil {
+ t.Fatal(errMkdir)
+ }
+ remoteSaved := make(map[string]string)
+ store := tt.build(authDir, func(id string, data []byte) error {
+ remoteSaved[id] = string(data)
+ return nil
+ })
+
+ record := &cliproxyauth.Auth{
+ ID: subjectFileName,
+ FileName: subjectFileName,
+ Provider: "xai",
+ Metadata: map[string]any{
+ "type": "xai",
+ "sub": "sub-1",
+ "access_token": "token-1",
+ },
+ }
+ // Simulate the login-flow adoption rename onto the email file.
+ record.ID = emailFileName
+ record.FileName = emailFileName
+ record.Metadata["email"] = "user@example.com"
+
+ savedPath, errSave := store.Save(context.Background(), record)
+ if errSave != nil {
+ t.Fatalf("Save error: %v", errSave)
+ }
+ if want := filepath.Join(authDir, emailFileName); savedPath != want {
+ t.Fatalf("savedPath = %s, want adopted email path %s", savedPath, want)
+ }
+ remotePayload, ok := remoteSaved[tt.wantRemoteID]
+ if !ok {
+ t.Fatalf("remote records = %v, want key %q", remoteSaved, tt.wantRemoteID)
+ }
+ if !strings.Contains(remotePayload, `"access_token":"token-1"`) {
+ t.Fatalf("remote payload = %s, want refreshed token", remotePayload)
+ }
+ if _, errStat := os.Stat(filepath.Join(authDir, subjectFileName)); !os.IsNotExist(errStat) {
+ t.Fatalf("superseded subject file should not exist: %v", errStat)
+ }
+ })
+ }
+}
diff --git a/internal/thinking/apply.go b/internal/thinking/apply.go
index 3f11d9d5bc6..348d40fcf09 100644
--- a/internal/thinking/apply.go
+++ b/internal/thinking/apply.go
@@ -5,10 +5,10 @@ import (
"strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type pluginProviderApplier struct {
@@ -134,13 +134,6 @@ func normalizedProviderName(provider string) string {
// IsUserDefinedModel reports whether the model is a user-defined model that should
// have thinking configuration passed through without validation.
-//
-// User-defined models are configured via config file's models[] array
-// (e.g., openai-compatibility.*.models[], *-api-key.models[]). These models
-// are marked with UserDefined=true at registration time.
-//
-// User-defined models should have their thinking configuration applied directly,
-// letting the upstream service validate the configuration.
func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
if modelInfo == nil {
return true
@@ -149,63 +142,53 @@ func IsUserDefinedModel(modelInfo *registry.ModelInfo) bool {
}
// ApplyThinking applies thinking configuration to a request body.
-//
-// This is the unified entry point for all providers. It follows the processing
-// order defined in FR25: route check → model capability query → config extraction
-// → validation → application.
-//
-// Suffix Priority: When the model name includes a thinking suffix (e.g., "gemini-2.5-pro(8192)"),
-// the suffix configuration takes priority over any thinking parameters in the request body.
-// This enables users to override thinking settings via the model name without modifying their
-// request payload.
-//
-// Parameters:
-// - body: Original request body JSON
-// - model: Model name, optionally with thinking suffix (e.g., "claude-sonnet-4-5(16384)")
-// - fromFormat: Source request format (e.g., openai, codex, gemini)
-// - toFormat: Target provider format for the request body (gemini, antigravity, claude, openai, codex, kimi, xai)
-// - providerKey: Provider identifier used for registry model lookups (may differ from toFormat, e.g., openrouter -> openai)
-//
-// Returns:
-// - Modified request body JSON with thinking configuration applied
-// - Error if validation fails (ThinkingError). On error, a non-nil target
-// body is returned (with unsupported updates already removed).
-//
-// Passthrough behavior (returns original body without error):
-// - Unknown provider (not in providerAppliers map)
-// - modelInfo.Thinking is nil (model doesn't support thinking)
-//
-// Note: Unknown models (modelInfo is nil) are treated as user-defined models: we skip
-// validation and still apply the thinking config so the upstream can validate it.
-//
-// Example:
-//
-// // With suffix - suffix config takes priority
-// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro(8192)", "gemini", "gemini", "gemini")
-//
-// // Without suffix - uses body config
-// result, err := thinking.ApplyThinking(body, "gemini-2.5-pro", "gemini", "gemini", "gemini")
func ApplyThinking(body []byte, model string, fromFormat string, toFormat string, providerKey string) ([]byte, error) {
summaryConfig := ExtractSummaryConfig(body, toFormat)
return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig)
}
+// ApplyThinkingDoc applies thinking configuration to doc in place.
+func ApplyThinkingDoc(doc *ojson.Doc, model, fromFormat, toFormat, providerKey string) error {
+ if doc == nil {
+ return nil
+ }
+ summaryConfig := ExtractSummaryConfigDoc(doc, toFormat)
+ _, err := applyThinkingDocCore(doc, doc.Len() > 0, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig)
+ return err
+}
+
// ApplyThinkingWithSummary applies canonical thinking effort while preserving
-// summary visibility extracted from the original source request. Callers that
-// translate before applying thinking must pass the source config explicitly:
-// a target Claude body can temporarily lack display while disabled thinking is
-// being rewritten by a model suffix.
+// summary visibility extracted from the original source request.
func ApplyThinkingWithSummary(body []byte, model string, fromFormat string, toFormat string, providerKey string, summaryConfig SummaryConfig) ([]byte, error) {
return applyThinking(body, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig)
}
+// ApplyThinkingWithSummaryDoc applies canonical thinking effort onto doc in place
+// while preserving summary visibility extracted from the original source request.
+func ApplyThinkingWithSummaryDoc(doc *ojson.Doc, model, fromFormat, toFormat, providerKey string, summaryConfig SummaryConfig) error {
+ if doc == nil {
+ return nil
+ }
+ _, err := applyThinkingDocCore(doc, doc.Len() > 0, nil, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig)
+ return err
+}
+
// ApplyThinkingWithSourceAndSummary applies thinking using the original source
-// request when translation has already changed the target protocol. Without a
-// bound model definition, an unknown model has no configuration update support.
+// request when translation has already changed the target protocol.
func ApplyThinkingWithSourceAndSummary(body, sourceBody []byte, model, fromFormat, toFormat, providerKey string, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) ([]byte, error) {
return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig, normalizedUpdatesChanged...)
}
+// ApplyThinkingWithSourceAndSummaryDoc applies thinking onto doc in place using
+// the original source request.
+func ApplyThinkingWithSourceAndSummaryDoc(doc *ojson.Doc, sourceBody []byte, model, fromFormat, toFormat, providerKey string, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) error {
+ if doc == nil {
+ return nil
+ }
+ _, err := applyThinkingDocCore(doc, doc.Len() > 0, sourceBody, model, fromFormat, toFormat, providerKey, nil, false, summaryConfig, normalizedUpdatesChanged...)
+ return err
+}
+
// ApplyThinkingWithFallbackModelInfoAndSummary applies thinking using the global
// registry first and falls back to fallbackModelInfo when the registry has no
// entry for the model.
@@ -213,6 +196,16 @@ func ApplyThinkingWithFallbackModelInfoAndSummary(body, sourceBody []byte, model
return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, fallbackModelInfo, false, summaryConfig, normalizedUpdatesChanged...)
}
+// ApplyThinkingWithFallbackModelInfoAndSummaryDoc applies thinking onto doc in place
+// using the global registry first and falling back to fallbackModelInfo.
+func ApplyThinkingWithFallbackModelInfoAndSummaryDoc(doc *ojson.Doc, sourceBody []byte, model, fromFormat, toFormat, providerKey string, fallbackModelInfo *registry.ModelInfo, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) error {
+ if doc == nil {
+ return nil
+ }
+ _, err := applyThinkingDocCore(doc, doc.Len() > 0, sourceBody, model, fromFormat, toFormat, providerKey, fallbackModelInfo, false, summaryConfig, normalizedUpdatesChanged...)
+ return err
+}
+
// ApplyThinkingWithModelInfo applies thinking with the exact configured model
// definition selected for an API-key execution attempt while preserving summary
// visibility from the original source body.
@@ -224,6 +217,19 @@ func ApplyThinkingWithModelInfo(body, sourceBody []byte, model string, fromForma
return ApplyThinkingWithModelInfoAndSummary(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, summaryConfig)
}
+// ApplyThinkingWithModelInfoDoc applies thinking onto doc in place with the exact
+// configured model definition.
+func ApplyThinkingWithModelInfoDoc(doc *ojson.Doc, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
+ }
+ summaryConfig := ExtractSummaryConfig(sourceBody, fromFormat)
+ if len(sourceBody) == 0 {
+ summaryConfig = ExtractSummaryConfigDoc(doc, toFormat)
+ }
+ return ApplyThinkingWithModelInfoAndSummaryDoc(doc, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, summaryConfig)
+}
+
// ApplyThinkingWithModelInfoAndSummary applies the exact configured model
// definition with a summary intent already resolved across source translation
// and plugin normalization.
@@ -231,7 +237,31 @@ func ApplyThinkingWithModelInfoAndSummary(body, sourceBody []byte, model string,
return applyThinking(body, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig, normalizedUpdatesChanged...)
}
+// ApplyThinkingWithModelInfoAndSummaryDoc applies the exact configured model
+// definition onto doc in place with a pre-resolved summary intent.
+func ApplyThinkingWithModelInfoAndSummaryDoc(doc *ojson.Doc, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, modelInfo *registry.ModelInfo, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) error {
+ if doc == nil {
+ return nil
+ }
+ _, err := applyThinkingDocCore(doc, doc.Len() > 0, sourceBody, model, fromFormat, toFormat, providerKey, modelInfo, true, summaryConfig, normalizedUpdatesChanged...)
+ return err
+}
+
func applyThinking(body, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) ([]byte, error) {
+ docValid := len(body) > 0 && ojson.ValidBytes(body)
+ doc := ojson.NewDoc(body)
+ modified, err := applyThinkingDocCore(doc, docValid, sourceBody, model, fromFormat, toFormat, providerKey, resolvedModelInfo, modelInfoResolved, summaryConfig, normalizedUpdatesChanged...)
+ if !modified {
+ return body, err
+ }
+ return doc.Bytes(), err
+}
+
+func applyThinkingDocCore(doc *ojson.Doc, docValid bool, sourceBody []byte, model string, fromFormat string, toFormat string, providerKey string, resolvedModelInfo *registry.ModelInfo, modelInfoResolved bool, summaryConfig SummaryConfig, normalizedUpdatesChanged ...bool) (bool, error) {
+ if doc == nil {
+ return false, nil
+ }
+ modified := false
providerFormat := strings.ToLower(strings.TrimSpace(toFormat))
if providerFormat == "openai-response" {
providerFormat = "codex"
@@ -244,13 +274,9 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
if fromFormat == "" {
fromFormat = providerFormat
}
- // Summary visibility is orthogonal to thinking effort. Keep the original
- // source intent before a suffix-specific applier rewrites provider fields,
- // then restore it after the canonical effort has been applied.
// 1. Parse suffix and get modelInfo
suffixResult := ParseSuffix(model)
baseModel := suffixResult.ModelName
- // Use provider-specific lookup to handle capability differences across providers.
modelInfo := resolvedModelInfo
if !modelInfoResolved {
if lookedUp := registry.LookupModelInfo(baseModel, providerKey); lookedUp != nil {
@@ -262,18 +288,20 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
updatesChanged := len(normalizedUpdatesChanged) > 0 && normalizedUpdatesChanged[0]
var sourceConfig ThinkingConfig
if isResponsesFormat(fromFormat) {
- sourceRequest := body
- if !updatesChanged && len(sourceBody) > 0 {
- sourceRequest = sourceBody
- }
if !updatesChanged || providerFormat == "codex" || providerFormat == "xai" {
- sourceConfig = extractCodexUsageConfig(sourceRequest)
+ if !updatesChanged && len(sourceBody) > 0 {
+ sourceConfig = extractCodexUsageConfig(sourceBody)
+ } else {
+ sourceConfig = extractCodexUsageConfigDoc(doc, docValid)
+ }
}
}
responseTarget := providerFormat == "codex" || providerFormat == "xai"
supportsUpdates := modelInfo != nil && modelInfo.SupportConfigurationUpdate
if responseTarget && !supportsUpdates {
- body = stripConfigurationUpdates(body)
+ if stripConfigurationUpdatesDoc(doc, docValid) {
+ modified = true
+ }
}
nativeResponses := responseTarget && isResponsesFormat(fromFormat) && supportsUpdates
@@ -284,21 +312,18 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
"provider": providerFormat,
"model": model,
}).Debug("thinking: unknown provider, passthrough |")
- return body, nil
+ return modified, nil
}
// 3. Model capability check
- // Unknown models are treated as user-defined so thinking config can still be applied.
- // The upstream service is responsible for validating the configuration.
- if !suffixResult.HasSuffix && len(sourceBody) > 0 && isResponsesFormat(fromFormat) && !gjson.ValidBytes(body) && hasThinkingConfig(extractConfigurationUpdateConfig(sourceBody)) {
+ if !suffixResult.HasSuffix && len(sourceBody) > 0 && isResponsesFormat(fromFormat) && !docValid && hasThinkingConfig(extractConfigurationUpdateConfig(sourceBody)) {
// Do not rebuild a malformed target from a separate source update.
- return body, nil
+ return modified, nil
}
if nativeResponses && !suffixResult.HasSuffix {
// Native Responses keeps the top-level baseline and in-turn updates as-is.
- // Log the effective effort without validating or rewriting the payload.
if log.IsLevelEnabled(log.DebugLevel) && (modelInfo.Thinking != nil || modelInfo.UserDefined) {
- if config := extractCodexUsageConfig(body); hasThinkingConfig(config) {
+ if config := extractCodexUsageConfigDoc(doc, docValid); hasThinkingConfig(config) {
fields := log.Fields{
"provider": providerFormat,
"model": modelInfo.ID,
@@ -306,7 +331,7 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
"budget": config.Budget,
"level": config.Level,
}
- if baseline := extractCodexConfig(body); baseline.Mode == ModeLevel {
+ if baseline := extractCodexConfigFromGetter(doc.Get); baseline.Mode == ModeLevel {
fields["baseline_level"] = baseline.Level
}
entry := log.WithFields(fields)
@@ -314,33 +339,42 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
entry.Debug("thinking: processed config to apply |")
}
}
- return body, nil
+ return modified, nil
}
- if !isPluginApplier && fromFormat == "claude" && providerFormat == "claude" && !suffixResult.HasSuffix && !gjson.GetBytes(body, "thinking").Exists() {
- // Native Claude effort is independent of thinking activation. Preserve the
- // model's thinking default unless a suffix or explicit summary changes it.
- return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
+ if !isPluginApplier && fromFormat == "claude" && providerFormat == "claude" && !suffixResult.HasSuffix && (!docValid || !doc.Get("thinking").Exists()) {
+ // Native Claude effort is independent of thinking activation.
+ if applySummaryConfigForProviderDoc(doc, docValid, providerFormat, baseModel, providerKey, modelInfo, summaryConfig) {
+ modified = true
+ }
+ return modified, nil
}
if IsUserDefinedModel(modelInfo) || (isPluginApplier && modelInfo.Thinking == nil) {
- return applyUserDefinedModel(body, modelInfo, fromFormat, providerFormat, providerKey, suffixResult, sourceConfig, nativeResponses, summaryConfig)
+ userMod, errUser := applyUserDefinedModelDoc(doc, docValid, modelInfo, fromFormat, providerFormat, providerKey, suffixResult, sourceConfig, nativeResponses, summaryConfig)
+ return modified || userMod, errUser
}
if modelInfo.Thinking == nil {
- config := extractThinkingConfig(body, providerFormat)
+ config := extractThinkingConfigDoc(doc, docValid, providerFormat)
if hasThinkingConfig(config) || summaryConfig.Mode != SummaryUnspecified {
log.WithFields(log.Fields{
"model": baseModel,
"provider": providerFormat,
}).Debug("thinking: model does not support thinking, stripping config |")
if responseTarget {
- return stripResponsesEffort(body), nil
+ if stripResponsesEffortDoc(doc, docValid) {
+ modified = true
+ }
+ return modified, nil
+ }
+ if docValid && stripThinkingFieldsDoc(doc, providerFormat) {
+ modified = true
}
- return StripThinkingConfig(body, providerFormat), nil
+ return modified, nil
}
log.WithFields(log.Fields{
"provider": providerFormat,
"model": baseModel,
}).Debug("thinking: model does not support thinking, passthrough |")
- return body, nil
+ return modified, nil
}
// 4. Get config: suffix priority over body
@@ -363,10 +397,10 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
config = extractSourceThinkingConfig(sourceBody, fromFormat)
}
if !hasThinkingConfig(config) {
- config = extractThinkingConfig(body, providerFormat)
+ config = extractThinkingConfigDoc(doc, docValid, providerFormat)
}
if !hasThinkingConfig(config) && fromFormat != providerFormat {
- config = extractThinkingConfig(body, fromFormat)
+ config = extractThinkingConfigDoc(doc, docValid, fromFormat)
}
if hasThinkingConfig(config) {
log.WithFields(log.Fields{
@@ -385,20 +419,17 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
"model": modelInfo.ID,
}).Debug("thinking: no config found, passthrough |")
if nativeResponses {
- return body, nil
+ return modified, nil
}
if modelInfoResolved && providerFormat == "claude" && fromFormat != providerFormat && ExtractSummaryConfig(sourceBody, fromFormat).Mode == SummaryEnabled {
- // Registry translation can only see aggregate model capabilities. For a
- // cross-protocol summary-only request it may have activated adaptive
- // thinking solely to make display valid. The selected API-key model is
- // authoritative at execution time, so discard that inferred activation
- // when the exact model supports only manual extended thinking. Use the
- // source intent here even if a target normalizer removed display; in that
- // case the inferred amount must disappear with it. Explicit native Claude
- // thinking never reaches this cross-protocol branch.
- body = stripInferredClaudeSummaryActivation(body, modelInfo)
+ if stripInferredClaudeSummaryActivationDoc(doc, docValid, modelInfo) {
+ modified = true
+ }
}
- return applySummaryConfigForProvider(body, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
+ if applySummaryConfigForProviderDoc(doc, docValid, providerFormat, baseModel, providerKey, modelInfo, summaryConfig) {
+ modified = true
+ }
+ return modified, nil
}
if modelInfoResolved && config.Mode == ModeLevel && modelInfo != nil && modelInfo.Thinking != nil && shouldMapConfiguredHighIntent(fromFormat, providerFormat, modelInfo) {
config.Level = mapConfiguredHighIntent(config.Level, modelInfo)
@@ -412,18 +443,15 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
"model": modelInfo.ID,
"error": err.Error(),
}).Warn("thinking: validation failed |")
- // Return the target body on validation failure (defensive programming).
- // Unsupported update items were already removed before validation.
- return body, err
+ return modified, err
}
- // Defensive check: ValidateConfig should never return (nil, nil)
if validated == nil {
log.WithFields(log.Fields{
"provider": providerFormat,
"model": modelInfo.ID,
}).Warn("thinking: ValidateConfig returned nil config without error, passthrough |")
- return body, nil
+ return modified, nil
}
log.WithFields(log.Fields{
@@ -436,25 +464,47 @@ func applyThinking(body, sourceBody []byte, model string, fromFormat string, toF
// 6. Apply configuration using provider-specific applier, then restore the
// target summary intent that was explicit before suffix processing.
- applied, err := applier.Apply(body, *validated, modelInfo)
- if err != nil {
- return applied, err
+ appliedMod, errApply := invokeProviderApplierOnDoc(applier, doc, &docValid, *validated, modelInfo)
+ if appliedMod {
+ modified = true
+ }
+ if errApply != nil {
+ return modified, errApply
}
- // A fully disabled amount takes precedence over visibility. Re-applying a
- // summary-only field can recreate an otherwise removed provider config and
- // make a default-on model think again.
if thinkingIsFullyDisabled(*validated) || nativeResponses {
- return applied, nil
+ return modified, nil
}
if providerFormat == "claude" && thinkingRequestedOff(config) {
- // The model cannot turn thinking off, so ValidateConfig fell back to its
- // lowest level. Drop any stale block_binding from the off request and keep
- // the caller's intent of seeing no thinking: without an explicit omitted
- // display, cloaking would add progress updates.
- applied, _ = sjson.DeleteBytes(applied, "thinking.block_binding")
+ if doc.Get("thinking.block_binding").Exists() && util.DeleteJSONDocIfExists(doc, "thinking.block_binding") == nil {
+ modified = true
+ }
summaryConfig = SummaryConfig{Mode: SummaryDisabled}
}
- return applySummaryConfigForProvider(applied, providerFormat, baseModel, providerKey, modelInfo, summaryConfig), nil
+ if applySummaryConfigForProviderDoc(doc, docValid, providerFormat, baseModel, providerKey, modelInfo, summaryConfig) {
+ modified = true
+ }
+ return modified, nil
+}
+
+func invokeProviderApplierOnDoc(applier ProviderApplier, doc *ojson.Doc, docValid *bool, config ThinkingConfig, modelInfo *registry.ModelInfo) (bool, error) {
+ if docApplier, ok := applier.(ProviderDocApplier); ok {
+ if !*docValid {
+ *doc = *ojson.NewDoc([]byte("{}"))
+ *docValid = true
+ }
+ if errApply := docApplier.ApplyDoc(doc, config, modelInfo); errApply != nil {
+ return false, errApply
+ }
+ return true, nil
+ }
+ raw := doc.Bytes()
+ applied, errApply := applier.Apply(raw, config, modelInfo)
+ if errApply != nil {
+ return false, errApply
+ }
+ *doc = *ojson.NewDoc(applied)
+ *docValid = len(applied) > 0 && ojson.ValidBytes(applied)
+ return true, nil
}
func thinkingIsFullyDisabled(config ThinkingConfig) bool {
@@ -508,14 +558,6 @@ func extractSourceThinkingConfig(body []byte, provider string) ThinkingConfig {
return extractThinkingConfig(body, provider)
}
-// parseSuffixToConfig converts a raw suffix string to ThinkingConfig.
-//
-// Parsing priority:
-// 1. Special values: "none" → ModeNone, "auto"/"-1" → ModeAuto
-// 2. Level names: "minimal", "low", "medium", "high", "xhigh" → ModeLevel
-// 3. Numeric values: positive integers → ModeBudget, 0 → ModeNone
-//
-// If none of the above match, returns empty ThinkingConfig (treated as no config).
func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
// 1. Try special values first (none, auto, -1)
if mode, ok := ParseSpecialSuffix(rawSuffix); ok {
@@ -540,7 +582,6 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
return ThinkingConfig{Mode: ModeBudget, Budget: budget}
}
- // Unknown suffix format - return empty config
log.WithFields(log.Fields{
"provider": provider,
"model": model,
@@ -549,10 +590,7 @@ func parseSuffixToConfig(rawSuffix, provider, model string) ThinkingConfig {
return ThinkingConfig{}
}
-// applyUserDefinedModel applies thinking configuration for user-defined models
-// without ThinkingSupport validation.
-func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromFormat, toFormat, providerKey string, suffixResult SuffixResult, sourceConfig ThinkingConfig, nativeResponses bool, summaryConfig SummaryConfig) ([]byte, error) {
- // Get model ID for logging
+func applyUserDefinedModelDoc(doc *ojson.Doc, docValid bool, modelInfo *registry.ModelInfo, fromFormat, toFormat, providerKey string, suffixResult SuffixResult, sourceConfig ThinkingConfig, nativeResponses bool, summaryConfig SummaryConfig) (bool, error) {
modelID := ""
if modelInfo != nil {
modelID = modelInfo.ID
@@ -560,7 +598,6 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma
modelID = suffixResult.ModelName
}
- // Get config: suffix priority over body
var config ThinkingConfig
if suffixResult.HasSuffix {
config = parseSuffixToConfig(suffixResult.RawSuffix, toFormat, modelID)
@@ -574,10 +611,10 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma
} else {
config = sourceConfig
if !hasThinkingConfig(config) {
- config = extractThinkingConfig(body, fromFormat)
+ config = extractThinkingConfigDoc(doc, docValid, fromFormat)
}
if !hasThinkingConfig(config) && fromFormat != toFormat {
- config = extractThinkingConfig(body, toFormat)
+ config = extractThinkingConfigDoc(doc, docValid, toFormat)
}
if hasThinkingConfig(config) {
log.WithFields(log.Fields{
@@ -595,7 +632,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma
"model": modelID,
"provider": toFormat,
}).Debug("thinking: user-defined model, passthrough (no config) |")
- return applySummaryConfigForProvider(body, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil
+ return applySummaryConfigForProviderDoc(doc, docValid, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil
}
applier, isPluginApplier := resolveProviderApplier(toFormat, providerKey)
@@ -604,7 +641,7 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma
"model": modelID,
"provider": toFormat,
}).Debug("thinking: user-defined model, passthrough (unknown provider) |")
- return body, nil
+ return false, nil
}
if !isPluginApplier {
@@ -617,14 +654,17 @@ func applyUserDefinedModel(body []byte, modelInfo *registry.ModelInfo, fromForma
"budget": config.Budget,
"level": config.Level,
}).Debug("thinking: processed config to apply |")
- applied, err := applier.Apply(body, config, modelInfo)
+ modified, err := invokeProviderApplierOnDoc(applier, doc, &docValid, config, modelInfo)
if err != nil {
- return applied, err
+ return modified, err
}
if thinkingIsFullyDisabled(config) || nativeResponses {
- return applied, nil
+ return modified, nil
+ }
+ if applySummaryConfigForProviderDoc(doc, docValid, toFormat, modelID, providerKey, modelInfo, summaryConfig) {
+ modified = true
}
- return applySummaryConfigForProvider(applied, toFormat, modelID, providerKey, modelInfo, summaryConfig), nil
+ return modified, nil
}
func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat string) ThinkingConfig {
@@ -649,23 +689,35 @@ func normalizeUserDefinedConfig(config ThinkingConfig, fromFormat, toFormat stri
// extractThinkingConfig extracts provider-specific thinking config from request body.
func extractThinkingConfig(body []byte, provider string) ThinkingConfig {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return ThinkingConfig{}
}
+ return extractThinkingConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ }, provider)
+}
+
+func extractThinkingConfigDoc(doc *ojson.Doc, docValid bool, provider string) ThinkingConfig {
+ if doc == nil || !docValid {
+ return ThinkingConfig{}
+ }
+ return extractThinkingConfigFromGetter(doc.Get, provider)
+}
+func extractThinkingConfigFromGetter(get func(string) ojson.Result, provider string) ThinkingConfig {
switch provider {
case "claude":
- return extractClaudeConfig(body)
+ return extractClaudeConfigFromGetter(get)
case "gemini", "antigravity":
- return extractGeminiConfig(body, provider)
+ return extractGeminiConfigFromGetter(get, provider)
case "interactions":
- return extractInteractionsConfig(body)
+ return extractInteractionsConfigFromGetter(get)
case "openai":
- return extractOpenAIConfig(body)
+ return extractOpenAIConfigFromGetter(get)
case "codex", "xai":
- return extractCodexConfig(body)
+ return extractCodexConfigFromGetter(get)
case "kimi", "kimi-ai", "kimi.ai", "kimi.com":
- return extractKimiConfig(body)
+ return extractKimiConfigFromGetter(get)
default:
return ThinkingConfig{}
}
@@ -676,9 +728,7 @@ func hasThinkingConfig(config ThinkingConfig) bool {
}
// ExtractReasoningEffort returns the source request's thinking setting as a
-// canonical reasoning_effort label for usage logging. Responses updates take
-// precedence over a suffix because they describe the source turn's intent;
-// otherwise a valid suffix overrides the top-level setting.
+// canonical reasoning_effort label for usage logging.
func ExtractReasoningEffort(body []byte, provider, model string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
if isResponsesFormat(provider) {
@@ -724,11 +774,18 @@ func extractThinkingConfigForUsage(body []byte, provider string) ThinkingConfig
case "codex", "xai", "openai-response":
return extractCodexUsageConfig(body)
case "claude":
- // between_tools is how claude-sonnet-5-5 turns thinking off.
- if gjson.GetBytes(body, "thinking.type").String() == "between_tools" {
+ if ojson.GetBytesNoCopy(body, "thinking.type").String() == "between_tools" {
return ThinkingConfig{Mode: ModeNone, Budget: 0}
}
- return extractThinkingConfig(body, provider)
+ config := extractThinkingConfig(body, provider)
+ if config.Mode == ModeNone && hasThinkingConfig(config) {
+ return config
+ }
+ // The last effort directive, not the cached top-level placeholder, is the turn's effort.
+ if directive := extractClaudeEffortDirectiveConfig(body); hasThinkingConfig(directive) {
+ return directive
+ }
+ return config
default:
return extractThinkingConfig(body, provider)
}
@@ -763,27 +820,19 @@ func reasoningEffortFromConfig(config ThinkingConfig) string {
}
}
-// extractClaudeConfig extracts thinking configuration from Claude format request body.
-//
-// Claude API format:
-// - thinking.type: "enabled", "adaptive", "auto", or "disabled"
-// - thinking.budget_tokens: integer (-1=auto, 0=disabled, >0=budget)
-// - output_config.effort: string ("none", "auto", "low", "medium", "high", "max")
-//
-// Priority: thinking.type="disabled" takes precedence over budget_tokens.
-// When type="enabled", budget_tokens takes precedence over output_config.effort.
-// When type="enabled" without budget_tokens, output_config.effort is preserved if present;
-// otherwise returns ModeAuto to indicate the user wants thinking enabled with default budget.
func extractClaudeConfig(body []byte) ThinkingConfig {
- thinkingType := gjson.GetBytes(body, "thinking.type").String()
+ return extractClaudeConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ })
+}
+
+func extractClaudeConfigFromGetter(get func(string) ojson.Result) ThinkingConfig {
+ thinkingType := get("thinking.type").String()
if thinkingType == "disabled" {
return ThinkingConfig{Mode: ModeNone, Budget: 0}
}
- if !gjson.GetBytes(body, "thinking").Exists() || thinkingType == "adaptive" || thinkingType == "auto" {
- // Claude output_config.effort also applies when thinking is omitted.
- // We only treat it as a thinking config when effort is explicitly present;
- // otherwise we passthrough and let upstream defaults apply.
- if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String {
+ if !get("thinking").Exists() || thinkingType == "adaptive" || thinkingType == "auto" {
+ if effort := get("output_config.effort"); effort.Exists() && effort.Type == ojson.String {
value := strings.ToLower(strings.TrimSpace(effort.String()))
if value == "" {
return ThinkingConfig{}
@@ -800,8 +849,7 @@ func extractClaudeConfig(body []byte) ThinkingConfig {
return ThinkingConfig{}
}
- // Check budget_tokens
- if budget := gjson.GetBytes(body, "thinking.budget_tokens"); budget.Exists() {
+ if budget := get("thinking.budget_tokens"); budget.Exists() {
value := int(budget.Int())
switch value {
case 0:
@@ -813,9 +861,8 @@ func extractClaudeConfig(body []byte) ThinkingConfig {
}
}
- // If type="enabled", check output_config.effort before falling back to auto
if thinkingType == "enabled" {
- if effort := gjson.GetBytes(body, "output_config.effort"); effort.Exists() && effort.Type == gjson.String {
+ if effort := get("output_config.effort"); effort.Exists() && effort.Type == ojson.String {
value := strings.ToLower(strings.TrimSpace(effort.String()))
if value != "" {
switch value {
@@ -834,27 +881,21 @@ func extractClaudeConfig(body []byte) ThinkingConfig {
return ThinkingConfig{}
}
-// extractGeminiConfig extracts thinking configuration from Gemini format request body.
-//
-// Gemini API format:
-// - generationConfig.thinkingConfig.thinkingLevel: "none", "auto", or level name (Gemini 3)
-// - generationConfig.thinkingConfig.thinkingBudget: integer (Gemini 2.5)
-//
-// For antigravity providers, the path is prefixed with "request.".
-//
-// Priority: thinkingLevel is checked first (Gemini 3 format), then thinkingBudget (Gemini 2.5 format).
-// This allows newer Gemini 3 level-based configs to take precedence.
func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
+ return extractGeminiConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ }, provider)
+}
+
+func extractGeminiConfigFromGetter(get func(string) ojson.Result, provider string) ThinkingConfig {
prefix := "generationConfig.thinkingConfig"
if provider == "antigravity" {
prefix = "request.generationConfig.thinkingConfig"
}
- // Check thinkingLevel first (Gemini 3 format takes precedence)
- level := gjson.GetBytes(body, prefix+".thinkingLevel")
+ level := get(prefix + ".thinkingLevel")
if !level.Exists() {
- // Google official Gemini Python SDK sends snake_case field names
- level = gjson.GetBytes(body, prefix+".thinking_level")
+ level = get(prefix + ".thinking_level")
}
if level.Exists() {
value := level.String()
@@ -868,11 +909,9 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
}
}
- // Check thinkingBudget (Gemini 2.5 format)
- budget := gjson.GetBytes(body, prefix+".thinkingBudget")
+ budget := get(prefix + ".thinkingBudget")
if !budget.Exists() {
- // Google official Gemini Python SDK sends snake_case field names
- budget = gjson.GetBytes(body, prefix+".thinking_budget")
+ budget = get(prefix + ".thinking_budget")
}
if budget.Exists() {
value := int(budget.Int())
@@ -890,6 +929,12 @@ func extractGeminiConfig(body []byte, provider string) ThinkingConfig {
}
func extractInteractionsConfig(body []byte) ThinkingConfig {
+ return extractInteractionsConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ })
+}
+
+func extractInteractionsConfigFromGetter(get func(string) ojson.Result) ThinkingConfig {
for _, path := range []string{
"generation_config.thinking_level",
"generation_config.thinkingLevel",
@@ -898,7 +943,7 @@ func extractInteractionsConfig(body []byte) ThinkingConfig {
"generation_config.thinkingConfig.thinking_level",
"generation_config.thinkingConfig.thinkingLevel",
} {
- level := gjson.GetBytes(body, path)
+ level := get(path)
if !level.Exists() {
continue
}
@@ -921,7 +966,7 @@ func extractInteractionsConfig(body []byte) ThinkingConfig {
"generation_config.thinkingConfig.thinking_budget",
"generation_config.thinkingConfig.thinkingBudget",
} {
- budget := gjson.GetBytes(body, path)
+ budget := get(path)
if !budget.Exists() {
continue
}
@@ -939,47 +984,43 @@ func extractInteractionsConfig(body []byte) ThinkingConfig {
return ThinkingConfig{}
}
-// extractOpenAIConfig extracts thinking configuration from OpenAI format request body.
-//
-// OpenAI API format:
-// - reasoning_effort: "none", "low", "medium", "high" (discrete levels)
-//
-// OpenAI uses level-based thinking configuration only, no numeric budget support.
-// The "none" value is treated specially to return ModeNone.
func extractOpenAIConfig(body []byte) ThinkingConfig {
- // Check reasoning_effort (OpenAI Chat Completions format)
- if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Exists() {
+ return extractOpenAIConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ })
+}
+
+func extractOpenAIConfigFromGetter(get func(string) ojson.Result) ThinkingConfig {
+ if effort := get("reasoning_effort"); effort.Exists() {
value := effort.String()
if value == "none" {
return ThinkingConfig{Mode: ModeNone, Budget: 0}
}
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
}
-
return ThinkingConfig{}
}
-// extractKimiConfig extracts Kimi's native thinking object while retaining
-// reasoning_effort as a legacy input fallback.
-//
-// Native fields take precedence over reasoning_effort. In particular,
-// thinking.type="enabled" without an explicit effort means "use the upstream
-// default" and therefore returns an empty config so ApplyThinking preserves the
-// request unchanged instead of interpreting it as CPA's ModeAuto.
func extractKimiConfig(body []byte) ThinkingConfig {
- thinkingType := gjson.GetBytes(body, "thinking.type")
+ return extractKimiConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ })
+}
+
+func extractKimiConfigFromGetter(get func(string) ojson.Result) ThinkingConfig {
+ thinkingType := get("thinking.type")
if thinkingType.Exists() {
switch strings.ToLower(strings.TrimSpace(thinkingType.String())) {
case "disabled":
return ThinkingConfig{Mode: ModeNone, Budget: 0}
case "enabled":
- if !gjson.GetBytes(body, "thinking.effort").Exists() {
+ if !get("thinking.effort").Exists() {
return ThinkingConfig{}
}
}
}
- if effort := gjson.GetBytes(body, "thinking.effort"); effort.Exists() {
+ if effort := get("thinking.effort"); effort.Exists() {
value := strings.ToLower(strings.TrimSpace(effort.String()))
switch value {
case "":
@@ -993,39 +1034,32 @@ func extractKimiConfig(body []byte) ThinkingConfig {
}
}
- // An explicit native thinking object without an effort should be left for
- // the Kimi upstream to interpret and must not be overridden by the legacy
- // field.
if thinkingType.Exists() {
return ThinkingConfig{}
}
- return extractOpenAIConfig(body)
+ return extractOpenAIConfigFromGetter(get)
}
-// extractCodexConfig extracts thinking configuration from Codex format request body.
-//
-// Codex API format (OpenAI Responses API):
-// - reasoning.effort: "none", "low", "medium", "high"
-//
-// This is similar to OpenAI but uses nested field "reasoning.effort" instead of "reasoning_effort".
func extractCodexConfig(body []byte) ThinkingConfig {
- // Check reasoning.effort (Codex / OpenAI Responses API format)
- if effort := gjson.GetBytes(body, "reasoning.effort"); effort.Exists() {
+ return extractCodexConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ })
+}
+
+func extractCodexConfigFromGetter(get func(string) ojson.Result) ThinkingConfig {
+ if effort := get("reasoning.effort"); effort.Exists() {
value := effort.String()
if value == "none" {
return ThinkingConfig{Mode: ModeNone, Budget: 0}
}
return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(value)}
}
-
return ThinkingConfig{}
}
-// extractCodexUsageConfig returns the last effective Responses update for usage
-// reporting, falling back to the top-level effort when no update applies.
func extractCodexUsageConfig(body []byte) ThinkingConfig {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return ThinkingConfig{}
}
if config := extractConfigurationUpdateConfig(body); hasThinkingConfig(config) {
@@ -1033,3 +1067,13 @@ func extractCodexUsageConfig(body []byte) ThinkingConfig {
}
return extractCodexConfig(body)
}
+
+func extractCodexUsageConfigDoc(doc *ojson.Doc, docValid bool) ThinkingConfig {
+ if doc == nil || !docValid {
+ return ThinkingConfig{}
+ }
+ if config := extractConfigurationUpdateConfigDoc(doc, docValid); hasThinkingConfig(config) {
+ return config
+ }
+ return extractCodexConfigFromGetter(doc.Get)
+}
diff --git a/internal/thinking/apply_codex_usage_test.go b/internal/thinking/apply_codex_usage_test.go
index 5b521ac48c8..684c95b1ba5 100644
--- a/internal/thinking/apply_codex_usage_test.go
+++ b/internal/thinking/apply_codex_usage_test.go
@@ -4,12 +4,14 @@ import (
"bytes"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/codex"
+ _ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/openai"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
)
func TestExtractCodexReasoningEffortWithConfigurationUpdate(t *testing.T) {
@@ -164,10 +166,10 @@ func TestExtractCodexReasoningEffortWithConfigurationUpdateTargetRouting(t *test
if got := thinking.ExtractTranslatedReasoningEffort(body, "codex"); got != want {
t.Fatalf("final effort = %q, want %q; body=%s", got, want, body)
}
- if got := gjson.GetBytes(body, "reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytes(body, "reasoning.effort").String(); got != "high" {
t.Fatalf("final top-level effort = %q, want high; body=%s", got, body)
}
- if hasUpdate := gjson.GetBytes(body, "input.0.type").String() == "configuration_update"; hasUpdate != supported {
+ if hasUpdate := ojson.GetBytes(body, "input.0.type").String() == "configuration_update"; hasUpdate != supported {
t.Fatalf("final update present = %t, want %t; body=%s", hasUpdate, supported, body)
}
})
@@ -301,19 +303,19 @@ func TestApplyConfigurationUpdateRouting(t *testing.T) {
t.Fatalf("body changed: got %s, want %s", applied, body)
}
if !tc.skipEffort {
- if got := gjson.GetBytes(applied, "reasoning.effort").String(); got != tc.wantEffort {
+ if got := ojson.GetBytes(applied, "reasoning.effort").String(); got != tc.wantEffort {
t.Errorf("reasoning.effort = %q, want %q; body=%s", got, tc.wantEffort, applied)
}
}
- if tc.wantInput != "" && gjson.GetBytes(applied, "input").Raw != tc.wantInput {
- t.Errorf("input = %s, want %s; body=%s", gjson.GetBytes(applied, "input").Raw, tc.wantInput, applied)
+ if tc.wantInput != "" && ojson.GetBytes(applied, "input").Raw != tc.wantInput {
+ t.Errorf("input = %s, want %s; body=%s", ojson.GetBytes(applied, "input").Raw, tc.wantInput, applied)
}
- if gjson.GetBytes(body, "reasoning.summary").Exists() {
- if got := gjson.GetBytes(applied, "reasoning.summary").String(); got != "auto" {
+ if ojson.GetBytes(body, "reasoning.summary").Exists() {
+ if got := ojson.GetBytes(applied, "reasoning.summary").String(); got != "auto" {
t.Errorf("reasoning.summary = %q, want auto; body=%s", got, applied)
}
}
- if gjson.GetBytes(body, "reasoning.other").Exists() && gjson.GetBytes(applied, "reasoning.other").Int() != 7 {
+ if ojson.GetBytes(body, "reasoning.other").Exists() && ojson.GetBytes(applied, "reasoning.other").Int() != 7 {
t.Errorf("reasoning.other changed: %s", applied)
}
if tc.supported && tc.suffix != "" {
@@ -451,13 +453,13 @@ func TestApplyThinkingPreservesCodexTopLevelReasoningEffortBaseline(t *testing.T
}
// Verify top-level request baseline is preserved for upstream prompt caching.
- if gotTopEffort := gjson.GetBytes(applied, "reasoning.effort").String(); gotTopEffort != "xhigh" {
+ if gotTopEffort := ojson.GetBytes(applied, "reasoning.effort").String(); gotTopEffort != "xhigh" {
t.Fatalf("ApplyThinking() top-level reasoning.effort = %q, want %q", gotTopEffort, "xhigh")
}
- if gotSummary := gjson.GetBytes(applied, "reasoning.summary").String(); gotSummary != "auto" {
+ if gotSummary := ojson.GetBytes(applied, "reasoning.summary").String(); gotSummary != "auto" {
t.Fatalf("ApplyThinking() reasoning.summary = %q, want %q", gotSummary, "auto")
}
- if gotInputEffort := gjson.GetBytes(applied, "input.0.reasoning.effort").String(); gotInputEffort != "low" {
+ if gotInputEffort := ojson.GetBytes(applied, "input.0.reasoning.effort").String(); gotInputEffort != "low" {
t.Fatalf("ApplyThinking() input[0].reasoning.effort = %q, want %q", gotInputEffort, "low")
}
@@ -467,3 +469,24 @@ func TestApplyThinkingPreservesCodexTopLevelReasoningEffortBaseline(t *testing.T
t.Fatalf("ExtractTranslatedReasoningEffort(applied) = %q, want %q", effort, "low")
}
}
+
+func TestCrossMethodDocMutationsAndThinking(t *testing.T) {
+ raw := []byte(`{"model":"gpt-5","reasoning_effort":"low","extra":"remove_me"}`)
+ doc := ojson.NewDoc(raw)
+ if errSet := doc.Set("model", "gpt-5"); errSet != nil {
+ t.Fatalf("doc.Set error: %v", errSet)
+ }
+ if errThink := thinking.ApplyThinkingDoc(doc, "gpt-5(high)", "openai", "openai", "openai"); errThink != nil {
+ t.Fatalf("ApplyThinkingDoc error: %v", errThink)
+ }
+ if got := doc.Get("reasoning_effort").String(); got != "high" {
+ t.Fatalf("doc.Get(reasoning_effort) = %q, want high", got)
+ }
+ if errDel := util.DeleteJSONDocIfExists(doc, "extra"); errDel != nil {
+ t.Fatalf("DeleteJSONDocIfExists error: %v", errDel)
+ }
+ out := doc.Bytes()
+ if ojson.GetBytesNoCopy(out, "extra").Exists() {
+ t.Fatalf("expected extra to be deleted in %s", string(out))
+ }
+}
diff --git a/internal/thinking/apply_configured_api_key_test.go b/internal/thinking/apply_configured_api_key_test.go
index 786d67c3f4f..f3d958976eb 100644
--- a/internal/thinking/apply_configured_api_key_test.go
+++ b/internal/thinking/apply_configured_api_key_test.go
@@ -3,12 +3,12 @@ package thinking_test
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/codex"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/openai"
- "github.com/tidwall/gjson"
)
func TestApplyThinkingWithModelInfoMapsCrossFamilyHighIntent(t *testing.T) {
@@ -38,7 +38,7 @@ func TestApplyThinkingWithModelInfoMapsCrossFamilyHighIntent(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != tc.want {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != tc.want {
t.Fatalf("output effort = %q, want %q; body=%s", got, tc.want, out)
}
})
@@ -57,7 +57,7 @@ func TestApplyThinkingWithModelInfoMapsOpenAICompatibilityHighIntent(t *testing.
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "max" {
+ if got := ojson.GetBytes(out, "reasoning_effort").String(); got != "max" {
t.Fatalf("reasoning_effort = %q, want max; body=%s", got, out)
}
}
@@ -74,7 +74,7 @@ func TestApplyThinkingWithModelInfoMapsResponsesToCodexHighIntent(t *testing.T)
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "xhigh" {
+ if got := ojson.GetBytes(out, "reasoning.effort").String(); got != "xhigh" {
t.Fatalf("reasoning.effort = %q, want xhigh; body=%s", got, out)
}
}
@@ -106,10 +106,10 @@ func TestApplyThinkingWithModelInfoAppliesEnabledSummaryOnlyClaudeVisibility(t *
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
+ if got := ojson.GetBytes(out, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
}
}
@@ -129,7 +129,7 @@ func TestApplyThinkingWithModelInfoAndSummaryDropsInferredClaudeModeWhenSummaryR
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfoAndSummary() error = %v", err)
}
- if gjson.GetBytes(out, "thinking").Exists() {
+ if ojson.GetBytes(out, "thinking").Exists() {
t.Fatalf("removed summary retained globally inferred adaptive thinking: %s", out)
}
}
@@ -148,7 +148,7 @@ func TestApplyThinkingWithModelInfoDoesNotActivateClaudeForDisabledSummary(t *te
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if gjson.GetBytes(out, "thinking").Exists() {
+ if ojson.GetBytes(out, "thinking").Exists() {
t.Fatalf("disabled summary activated Claude thinking: %s", out)
}
}
@@ -167,7 +167,7 @@ func TestApplyThinkingWithModelInfoSummaryOnlyDoesNotInventOpenAIEffort(t *testi
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out)
}
- if gjson.GetBytes(out, "reasoning_effort").Exists() {
+ if ojson.GetBytes(out, "reasoning_effort").Exists() {
t.Fatalf("summary-only request invented reasoning_effort: %s", out)
}
}
@@ -181,7 +181,7 @@ func TestApplyThinkingWithSummaryKeepsOpenAIChatSuffixNone(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithSummary() error = %v; body=%s", err, out)
}
- if got := gjson.GetBytes(out, "reasoning_effort").String(); got != "none" {
+ if got := ojson.GetBytes(out, "reasoning_effort").String(); got != "none" {
t.Fatalf("reasoning_effort = %q, want none; body=%s", got, out)
}
}
@@ -200,10 +200,10 @@ func TestApplyThinkingWithModelInfoUsesOpenRouterVisibility(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v; body=%s", err, out)
}
- if exclude := gjson.GetBytes(out, "reasoning.exclude"); !exclude.Exists() || exclude.Bool() {
+ if exclude := ojson.GetBytes(out, "reasoning.exclude"); !exclude.Exists() || exclude.Bool() {
t.Fatalf("OpenRouter summary visibility not enabled: %s", out)
}
- if gjson.GetBytes(out, "reasoning_effort").Exists() {
+ if ojson.GetBytes(out, "reasoning_effort").Exists() {
t.Fatalf("OpenRouter summary visibility invented reasoning_effort: %s", out)
}
}
@@ -247,10 +247,10 @@ func TestApplyConfigurationUpdateCrossProtocol(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if got := gjson.GetBytes(out, tc.wantPath).String(); got != tc.want {
+ if got := ojson.GetBytes(out, tc.wantPath).String(); got != tc.want {
t.Errorf("%s = %q, want %q; body=%s", tc.wantPath, got, tc.want, out)
}
- if !gjson.GetBytes(out, "other").Bool() {
+ if !ojson.GetBytes(out, "other").Bool() {
t.Errorf("non-thinking field lost: %s", out)
}
})
@@ -305,11 +305,11 @@ func TestApplyConfigurationUpdateSourceEntry(t *testing.T) {
if format == "openai" {
path = "reasoning_effort"
}
- if got := gjson.GetBytes(out, path).String(); got != tc.want {
+ if got := ojson.GetBytes(out, path).String(); got != tc.want {
t.Errorf("%s = %q, want %q; body=%s", path, got, tc.want, out)
}
- if tc.wantInput != "" && gjson.GetBytes(out, "input").Raw != tc.wantInput {
- t.Errorf("input = %s, want %s; body=%s", gjson.GetBytes(out, "input").Raw, tc.wantInput, out)
+ if tc.wantInput != "" && ojson.GetBytes(out, "input").Raw != tc.wantInput {
+ t.Errorf("input = %s, want %s; body=%s", ojson.GetBytes(out, "input").Raw, tc.wantInput, out)
}
if tc.wantSame && string(out) != tc.body {
t.Errorf("native body changed: got %s, want %s", out, tc.body)
@@ -377,10 +377,10 @@ func TestApplyConfigurationUpdateBoundModelWithoutThinking(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfoAndSummary() error = %v", err)
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "low" {
+ if got := ojson.GetBytes(out, "reasoning.effort").String(); got != "low" {
t.Errorf("unresolved binding used static support: effort=%q; body=%s", got, out)
}
- if len(gjson.GetBytes(out, "input").Array()) != 1 {
+ if len(ojson.GetBytes(out, "input").Array()) != 1 {
t.Errorf("unresolved binding retained an update: %s", out)
}
@@ -389,10 +389,10 @@ func TestApplyConfigurationUpdateBoundModelWithoutThinking(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfoAndSummary(user-defined) error = %v", err)
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "low" {
+ if got := ojson.GetBytes(out, "reasoning.effort").String(); got != "low" {
t.Errorf("user-defined model lost source effort: effort=%q; body=%s", got, out)
}
- if len(gjson.GetBytes(out, "input").Array()) != 1 {
+ if len(ojson.GetBytes(out, "input").Array()) != 1 {
t.Errorf("user-defined model retained an update: %s", out)
}
@@ -410,13 +410,13 @@ func TestApplyConfigurationUpdateBoundModelWithoutThinking(t *testing.T) {
if errApply != nil {
t.Fatalf("ApplyThinkingWithModelInfoAndSummary() error = %v", errApply)
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != tc.want {
+ if got := ojson.GetBytes(out, "reasoning.effort").String(); got != tc.want {
t.Errorf("effort = %q, want %q; body=%s", got, tc.want, out)
}
- if got := gjson.GetBytes(out, "input.0.reasoning.effort").String(); got != "low" {
+ if got := ojson.GetBytes(out, "input.0.reasoning.effort").String(); got != "low" {
t.Errorf("input update effort changed to %q: %s", got, out)
}
- if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "auto" {
+ if got := ojson.GetBytes(out, "reasoning.summary").String(); got != "auto" {
t.Errorf("summary = %q, want auto; body=%s", got, out)
}
})
@@ -435,7 +435,7 @@ func TestApplyThinkingWithModelInfoUsesOriginalResponsesEffort(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo() error = %v", err)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "max" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "max" {
t.Fatalf("output effort = %q, want max; body=%s", got, out)
}
}
diff --git a/internal/thinking/claude_effort_directive.go b/internal/thinking/claude_effort_directive.go
new file mode 100644
index 00000000000..0041055fb6f
--- /dev/null
+++ b/internal/thinking/claude_effort_directive.go
@@ -0,0 +1,57 @@
+package thinking
+
+import (
+ "strings"
+
+ "github.com/MoeForge/moego/ojson"
+)
+
+// ClaudeEffortDirective returns the effort of the last Claude mid-conversation
+// effort directive in body, lowercased, or "" when there is none or the
+// request turns thinking off (thinking.type "disabled" or "between_tools").
+//
+// A directive is a role=system message carrying output_config.effort
+// (mid-conversation-output-config beta). It sets the effort for every later
+// reply, so the last one is the effort of the current turn. Clients that send
+// directives keep the top-level output_config.effort fixed for prompt caching,
+// which makes that value a placeholder rather than the requested effort.
+func ClaudeEffortDirective(body []byte) string {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ return ""
+ }
+ switch strings.ToLower(strings.TrimSpace(ojson.GetBytesNoCopy(body, "thinking.type").String())) {
+ case "disabled", "between_tools":
+ return ""
+ }
+ messages := ojson.GetBytesNoCopy(body, "messages")
+ if !messages.IsArray() {
+ return ""
+ }
+ var effort string
+ messages.ForEach(func(_, message ojson.Result) bool {
+ if !strings.EqualFold(strings.TrimSpace(message.Get("role").String()), "system") {
+ return true
+ }
+ value := message.Get("output_config.effort")
+ if value.Type == ojson.String {
+ if normalized := strings.ToLower(strings.TrimSpace(value.String())); normalized != "" {
+ effort = normalized
+ }
+ }
+ return true
+ })
+ return effort
+}
+
+func extractClaudeEffortDirectiveConfig(body []byte) ThinkingConfig {
+ switch effort := ClaudeEffortDirective(body); effort {
+ case "":
+ return ThinkingConfig{}
+ case "none":
+ return ThinkingConfig{Mode: ModeNone, Budget: 0}
+ case "auto":
+ return ThinkingConfig{Mode: ModeAuto, Budget: -1}
+ default:
+ return ThinkingConfig{Mode: ModeLevel, Level: ThinkingLevel(effort)}
+ }
+}
diff --git a/internal/thinking/claude_effort_directive_test.go b/internal/thinking/claude_effort_directive_test.go
new file mode 100644
index 00000000000..3af9623d845
--- /dev/null
+++ b/internal/thinking/claude_effort_directive_test.go
@@ -0,0 +1,103 @@
+package thinking_test
+
+import (
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+)
+
+func TestClaudeEffortDirective(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ body string
+ want string
+ }{
+ {name: "no messages", body: `{"output_config":{"effort":"low"}}`, want: ""},
+ {name: "no directive", body: `{"messages":[{"role":"user","content":"hi"}]}`, want: ""},
+ {
+ name: "last directive wins",
+ body: `{"messages":[{"role":"system","content":[],"output_config":{"effort":"medium"}},` +
+ `{"role":"user","content":"hi"},{"role":"system","content":[],"output_config":{"effort":" HIGH "}}]}`,
+ want: "high",
+ },
+ {
+ name: "empty directive is ignored",
+ body: `{"messages":[{"role":"system","content":[],"output_config":{"effort":"max"}},` +
+ `{"role":"system","content":[],"output_config":{"effort":""}}]}`,
+ want: "max",
+ },
+ {name: "non-system output_config is ignored", body: `{"messages":[{"role":"user","content":"hi","output_config":{"effort":"high"}}]}`, want: ""},
+ {name: "invalid json", body: `{`, want: ""},
+ {name: "disabled thinking ignores directives", body: `{"thinking":{"type":"disabled"},"messages":[{"role":"system","content":[],"output_config":{"effort":"high"}}]}`, want: ""},
+ {name: "between_tools thinking ignores directives", body: `{"thinking":{"type":"between_tools"},"messages":[{"role":"system","content":[],"output_config":{"effort":"high"}}]}`, want: ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := thinking.ClaudeEffortDirective([]byte(tc.body)); got != tc.want {
+ t.Fatalf("ClaudeEffortDirective() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestClaudeUsageEffortPrefersDirective(t *testing.T) {
+ const directives = `"messages":[{"role":"system","content":[],"output_config":{"effort":"low"}},{"role":"user","content":"hi"},` +
+ `{"role":"system","content":[],"output_config":{"effort":"high"}}]`
+ for _, tc := range []struct {
+ name string
+ body string
+ model string
+ want string
+ }{
+ {
+ name: "directive overrides top-level placeholder",
+ body: `{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},` + directives + `}`,
+ model: "claude-opus-5-5",
+ want: "high",
+ },
+ {
+ name: "last non-empty directive wins and is normalized",
+ body: `{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[{"role":"system","content":[],"output_config":{"effort":"medium"}},{"role":"user","content":"hi"},{"role":"system","content":[],"output_config":{"effort":" HIGH "}},{"role":"system","content":[],"output_config":{"effort":" "}}]}`,
+ model: "claude-haiku-5-5",
+ want: "high",
+ },
+ {
+ name: "non-system output_config does not override top-level effort",
+ body: `{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[{"role":"user","content":"hi","output_config":{"effort":"high"}}]}`,
+ model: "claude-opus-5-5",
+ want: "low",
+ },
+ {
+ name: "top-level effort without directives",
+ body: `{"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"},"messages":[{"role":"user","content":"hi"}]}`,
+ model: "claude-opus-5-5",
+ want: "medium",
+ },
+ {
+ name: "disabled thinking stays none",
+ body: `{"thinking":{"type":"disabled"},` + directives + `}`,
+ model: "claude-opus-5-5",
+ want: "none",
+ },
+ {
+ name: "between_tools thinking stays none",
+ body: `{"thinking":{"type":"between_tools"},` + directives + `}`,
+ model: "claude-opus-5-5",
+ want: "none",
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := thinking.ExtractReasoningEffort([]byte(tc.body), "claude", tc.model); got != tc.want {
+ t.Fatalf("ExtractReasoningEffort() = %q, want %q", got, tc.want)
+ }
+ if got := thinking.ExtractTranslatedReasoningEffort([]byte(tc.body), "claude"); got != tc.want {
+ t.Fatalf("ExtractTranslatedReasoningEffort() = %q, want %q", got, tc.want)
+ }
+ })
+ }
+
+ // A model suffix still wins for the client-requested effort.
+ body := `{"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},` + directives + `}`
+ if got := thinking.ExtractReasoningEffort([]byte(body), "claude", "claude-opus-5-5(medium)"); got != "medium" {
+ t.Fatalf("ExtractReasoningEffort() with suffix = %q, want medium", got)
+ }
+}
diff --git a/internal/thinking/claude_enabled_effort_test.go b/internal/thinking/claude_enabled_effort_test.go
index ac50cdfc5e0..0208329b474 100644
--- a/internal/thinking/claude_enabled_effort_test.go
+++ b/internal/thinking/claude_enabled_effort_test.go
@@ -3,11 +3,11 @@ package thinking_test
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/openai"
openaiclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/openai/claude"
- "github.com/tidwall/gjson"
)
func TestApplyThinking_ClaudeEnabledWithOutputConfigEffort(t *testing.T) {
@@ -54,7 +54,7 @@ func TestApplyThinking_ClaudeEnabledWithOutputConfigEffort(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking returned error: %v", err)
}
- if got := gjson.GetBytes(out, "reasoning_effort").String(); got != tt.wantEffort {
+ if got := ojson.GetBytes(out, "reasoning_effort").String(); got != tt.wantEffort {
t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, tt.wantEffort, out)
}
})
@@ -64,7 +64,7 @@ func TestApplyThinking_ClaudeEnabledWithOutputConfigEffort(t *testing.T) {
func TestClaudeToOpenAITranslationAndThinkingChained(t *testing.T) {
rawClaude := []byte(`{"model":"custom-openai","messages":[{"role":"user","content":"hi"}],"thinking":{"type":"enabled"},"output_config":{"effort":"high"}}`)
translated, _ := openaiclaude.ConvertClaudeRequestToOpenAI("custom-openai", rawClaude, false)
- if got := gjson.GetBytes(translated, "reasoning_effort").String(); got != "high" {
+ if got := ojson.GetBytes(translated, "reasoning_effort").String(); got != "high" {
t.Fatalf("translated reasoning_effort = %q, want high; body=%s", got, translated)
}
@@ -72,7 +72,7 @@ func TestClaudeToOpenAITranslationAndThinkingChained(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinkingWithSourceAndSummary returned error: %v", err)
}
- if got := gjson.GetBytes(final, "reasoning_effort").String(); got != "high" {
+ if got := ojson.GetBytes(final, "reasoning_effort").String(); got != "high" {
t.Fatalf("final reasoning_effort = %q, want high; body=%s", got, final)
}
}
diff --git a/internal/thinking/claude_issue_6698_test.go b/internal/thinking/claude_issue_6698_test.go
index dd76aded6ad..06a18129046 100644
--- a/internal/thinking/claude_issue_6698_test.go
+++ b/internal/thinking/claude_issue_6698_test.go
@@ -3,10 +3,10 @@ package thinking_test
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
- "github.com/tidwall/gjson"
)
// claude-sonnet-5-5 rejects thinking.type="disabled" and requires "between_tools"
@@ -62,10 +62,10 @@ func TestApplyThinking_ClaudeSonnet55DisabledUsesBetweenTools(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
+ if got := ojson.GetBytes(out, "thinking").Raw; got != `{"type":"between_tools"}` {
t.Fatalf("thinking = %s, want {\"type\":\"between_tools\"}; body=%s", got, out)
}
- if gjson.GetBytes(out, "output_config.effort").Exists() {
+ if ojson.GetBytes(out, "output_config.effort").Exists() {
t.Fatalf("between_tools retained output_config.effort: %s", out)
}
if got := thinking.ExtractTranslatedReasoningEffort(out, "claude"); got != "none" {
@@ -107,23 +107,23 @@ func TestApplyThinking_ClaudeDisabledFallsBackOnAdaptiveOnlyModels(t *testing.T)
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != tt.wantThinking {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != tt.wantThinking {
t.Fatalf("thinking.type = %q, want %q; body=%s", got, tt.wantThinking, out)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != tt.wantEffort {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != tt.wantEffort {
t.Fatalf("output_config.effort = %q, want %q; body=%s", got, tt.wantEffort, out)
}
- if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(out, "thinking.budget_tokens").Exists() {
t.Fatalf("thinking retained budget_tokens: %s", out)
}
- if gjson.GetBytes(out, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(out, "thinking.block_binding").Exists() {
t.Fatalf("thinking retained block_binding on off rewrite: %s", out)
}
if tt.wantThinking == "adaptive" {
- if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" {
+ if got := ojson.GetBytes(out, "thinking.display").String(); got != "omitted" {
t.Fatalf("thinking.display = %q, want omitted; body=%s", got, out)
}
- } else if gjson.GetBytes(out, "thinking.display").Exists() {
+ } else if ojson.GetBytes(out, "thinking.display").Exists() {
t.Fatalf("disabled thinking retained display: %s", out)
}
})
@@ -148,25 +148,25 @@ func TestApplyThinking_ClaudeDisabledFallbackInputs(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(out, "thinking.budget_tokens").Exists() {
t.Fatalf("retained budget_tokens: %s", out)
}
- if gjson.GetBytes(out, "thinking.block_binding").Exists() {
+ if ojson.GetBytes(out, "thinking.block_binding").Exists() {
t.Fatalf("retained block_binding: %s", out)
}
if tt.model == "claude-opus-5" {
- if got := gjson.GetBytes(out, "thinking").Raw; got != `{"type":"disabled"}` {
+ if got := ojson.GetBytes(out, "thinking").Raw; got != `{"type":"disabled"}` {
t.Fatalf("thinking = %s, want {\"type\":\"disabled\"}; body=%s", got, out)
}
return
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "low" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "low" {
t.Fatalf("output_config.effort = %q, want low; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "thinking.display").String(); got != "omitted" {
+ if got := ojson.GetBytes(out, "thinking.display").String(); got != "omitted" {
t.Fatalf("thinking.display = %q, want omitted; body=%s", got, out)
}
})
@@ -191,13 +191,13 @@ func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(out, "thinking.budget_tokens").Exists() {
t.Fatalf("thinking.budget_tokens still present: %s", out)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "high" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "high" {
t.Fatalf("output_config.effort = %q, want high; body=%s", got, out)
}
})
@@ -208,13 +208,13 @@ func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(out, "thinking.budget_tokens").Exists() {
t.Fatalf("thinking.budget_tokens still present: %s", out)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "xhigh" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "xhigh" {
t.Fatalf("output_config.effort = %q, want xhigh; body=%s", got, out)
}
})
@@ -227,13 +227,13 @@ func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(out, "thinking.budget_tokens").Exists() {
t.Fatalf("thinking.budget_tokens still present: %s", out)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "medium" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "medium" {
t.Fatalf("output_config.effort = %q, want medium; body=%s", got, out)
}
})
@@ -244,13 +244,13 @@ func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if gjson.GetBytes(out, "thinking.budget_tokens").Exists() {
+ if ojson.GetBytes(out, "thinking.budget_tokens").Exists() {
t.Fatalf("auto synthesized budget_tokens: %s", out)
}
- if gjson.GetBytes(out, "output_config.effort").Exists() {
+ if ojson.GetBytes(out, "output_config.effort").Exists() {
t.Fatalf("auto set explicit effort: %s", out)
}
})
@@ -277,10 +277,10 @@ func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T
if err != nil {
t.Fatalf("ApplyThinkingWithModelInfo(%v): %v", levels, err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("levels=%v thinking.type = %q, want adaptive; body=%s", levels, got, out)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "low" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "low" {
t.Fatalf("levels=%v output_config.effort = %q, want low; body=%s", levels, got, out)
}
}
@@ -293,10 +293,10 @@ func TestApplyThinking_ClaudeAdaptiveOnlyModelsConvertBudgetAndAuto(t *testing.T
if err != nil {
t.Fatalf("ApplyThinking: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "enabled" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "enabled" {
t.Fatalf("thinking.type = %q, want enabled; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "thinking.budget_tokens").Int(); got != 16384 {
+ if got := ojson.GetBytes(out, "thinking.budget_tokens").Int(); got != 16384 {
t.Fatalf("thinking.budget_tokens = %d, want 16384; body=%s", got, out)
}
})
diff --git a/internal/thinking/claude_standalone_effort_test.go b/internal/thinking/claude_standalone_effort_test.go
index b76800bfce1..16ac37e498a 100644
--- a/internal/thinking/claude_standalone_effort_test.go
+++ b/internal/thinking/claude_standalone_effort_test.go
@@ -3,11 +3,11 @@ package thinking_test
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/codex"
codexclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/codex/claude"
- "github.com/tidwall/gjson"
)
func TestApplyThinking_ClaudeStandaloneEffort(t *testing.T) {
@@ -37,7 +37,7 @@ func TestApplyThinking_ClaudeStandaloneEffort(t *testing.T) {
if errApply != nil {
t.Fatalf("ApplyThinking returned error: %v", errApply)
}
- if got := gjson.GetBytes(out, "reasoning_effort").String(); got != tt.wantEffort {
+ if got := ojson.GetBytes(out, "reasoning_effort").String(); got != tt.wantEffort {
t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, tt.wantEffort, out)
}
})
@@ -80,7 +80,7 @@ func TestClaudeStandaloneEffortPreservesNativeThinkingDefault(t *testing.T) {
if errApply != nil {
t.Fatalf("ApplyThinkingWithModelInfo with suffix returned error: %v", errApply)
}
- if !gjson.GetBytes(out, "thinking.type").Exists() {
+ if !ojson.GetBytes(out, "thinking.type").Exists() {
t.Fatalf("explicit suffix must still configure thinking; body=%s", out)
}
})
@@ -128,7 +128,7 @@ func TestClaudeToCodexStandaloneEffort(t *testing.T) {
if errApply != nil {
t.Fatalf("bound=%v: ApplyThinking returned error: %v", bound, errApply)
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != tt.wantEffort {
+ if got := ojson.GetBytes(out, "reasoning.effort").String(); got != tt.wantEffort {
t.Fatalf("bound=%v: reasoning.effort = %q, want %q; body=%s", bound, got, tt.wantEffort, out)
}
}
diff --git a/internal/thinking/configuration_update.go b/internal/thinking/configuration_update.go
index a2e5f50e97c..94ef8cb3334 100644
--- a/internal/thinking/configuration_update.go
+++ b/internal/thinking/configuration_update.go
@@ -3,8 +3,7 @@ package thinking
import (
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func isResponsesFormat(format string) bool {
@@ -13,19 +12,29 @@ func isResponsesFormat(format string) bool {
// extractConfigurationUpdateConfig returns the last nonempty Responses effort update.
func extractConfigurationUpdateConfig(body []byte) ThinkingConfig {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return ThinkingConfig{}
}
- input := gjson.GetBytes(body, "input")
+ return extractConfigurationUpdateConfigFromInput(ojson.GetBytesNoCopy(body, "input"))
+}
+
+func extractConfigurationUpdateConfigDoc(doc *ojson.Doc, docValid bool) ThinkingConfig {
+ if doc == nil || !docValid {
+ return ThinkingConfig{}
+ }
+ return extractConfigurationUpdateConfigFromInput(doc.Get("input"))
+}
+
+func extractConfigurationUpdateConfigFromInput(input ojson.Result) ThinkingConfig {
if !input.IsArray() {
return ThinkingConfig{}
}
var effort string
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "configuration_update" {
value := item.Get("reasoning.effort")
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
if normalized := strings.ToLower(strings.TrimSpace(value.String())); normalized != "" {
effort = normalized
}
@@ -56,19 +65,37 @@ func extractConfigurationUpdateConfig(body []byte) ThinkingConfig {
// item is returned unchanged, and the top-level effort is only touched when
// an update carries a usable effort.
func CollapseConfigurationUpdates(body []byte, keepTopLevelEffort ...bool) []byte {
- updated := stripConfigurationUpdates(body)
- if len(keepTopLevelEffort) > 0 && keepTopLevelEffort[0] {
- return updated
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ return body
}
- effort := reasoningEffortFromConfig(extractConfigurationUpdateConfig(body))
- if effort == "" {
- return updated
+ doc := ojson.NewDoc(body)
+ if !collapseConfigurationUpdatesDoc(doc, true, keepTopLevelEffort...) {
+ return body
}
- collapsed, errSet := sjson.SetBytes(updated, "reasoning.effort", effort)
- if errSet != nil {
- return updated
+ return doc.Bytes()
+}
+
+// CollapseConfigurationUpdatesDoc removes every configuration_update input item
+// on doc and optionally promotes the last nonempty update effort to reasoning.effort.
+func CollapseConfigurationUpdatesDoc(doc *ojson.Doc, keepTopLevelEffort ...bool) {
+ _ = collapseConfigurationUpdatesDoc(doc, true, keepTopLevelEffort...)
+}
+
+func collapseConfigurationUpdatesDoc(doc *ojson.Doc, docValid bool, keepTopLevelEffort ...bool) bool {
+ if doc == nil || !docValid {
+ return false
+ }
+ effort := ""
+ if !(len(keepTopLevelEffort) > 0 && keepTopLevelEffort[0]) {
+ effort = reasoningEffortFromConfig(extractConfigurationUpdateConfigDoc(doc, docValid))
+ }
+ modified := stripConfigurationUpdatesDoc(doc, docValid)
+ if effort != "" {
+ if doc.Set("reasoning.effort", effort) == nil {
+ modified = true
+ }
}
- return collapsed
+ return modified
}
// StripConfigurationUpdates removes configuration_update input items without changing reasoning.
@@ -76,20 +103,36 @@ func StripConfigurationUpdates(body []byte) []byte {
return stripConfigurationUpdates(body)
}
+// StripConfigurationUpdatesDoc removes configuration_update input items on doc.
+func StripConfigurationUpdatesDoc(doc *ojson.Doc) {
+ _ = stripConfigurationUpdatesDoc(doc, true)
+}
+
// stripConfigurationUpdates removes unsupported Responses input items without
// modifying other input items or introducing an input field.
func stripConfigurationUpdates(body []byte) []byte {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return body
}
- input := gjson.GetBytes(body, "input")
- if !input.IsArray() {
+ doc := ojson.NewDoc(body)
+ if !stripConfigurationUpdatesDoc(doc, true) {
return body
}
+ return doc.Bytes()
+}
+
+func stripConfigurationUpdatesDoc(doc *ojson.Doc, docValid bool) bool {
+ if doc == nil || !docValid {
+ return false
+ }
+ input := doc.Get("input")
+ if !input.IsArray() {
+ return false
+ }
var kept []string
removed := false
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "configuration_update" {
removed = true
} else {
@@ -98,26 +141,32 @@ func stripConfigurationUpdates(body []byte) []byte {
return true
})
if !removed {
- return body
- }
- updated, errSet := sjson.SetRawBytes(body, "input", []byte("["+strings.Join(kept, ",")+"]"))
- if errSet != nil {
- return body
+ return false
}
- return updated
+ return doc.SetRaw("input", "["+strings.Join(kept, ",")+"]") == nil
}
// stripResponsesEffort leaves summary and unrelated reasoning fields intact.
func stripResponsesEffort(body []byte) []byte {
- if len(body) == 0 || !gjson.ValidBytes(body) || !gjson.GetBytes(body, "reasoning.effort").Exists() {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return body
}
- result, errDelete := sjson.DeleteBytes(body, "reasoning.effort")
- if errDelete != nil {
+ doc := ojson.NewDoc(body)
+ if !stripResponsesEffortDoc(doc, true) {
return body
}
- if reasoning := gjson.GetBytes(result, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "reasoning")
+ return doc.Bytes()
+}
+
+func stripResponsesEffortDoc(doc *ojson.Doc, docValid bool) bool {
+ if doc == nil || !docValid || !doc.Get("reasoning.effort").Exists() {
+ return false
+ }
+ if errDelete := doc.Delete("reasoning.effort"); errDelete != nil {
+ return false
+ }
+ if reasoning := doc.Get("reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
+ _ = doc.Delete("reasoning")
}
- return result
+ return true
}
diff --git a/internal/thinking/configuration_update_test.go b/internal/thinking/configuration_update_test.go
index b3e0255f338..f593ee8d2ce 100644
--- a/internal/thinking/configuration_update_test.go
+++ b/internal/thinking/configuration_update_test.go
@@ -4,7 +4,8 @@ import (
"bytes"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
)
func TestCollapseConfigurationUpdatesBoundaries(t *testing.T) {
@@ -26,10 +27,10 @@ func TestCollapseConfigurationUpdatesBoundaries(t *testing.T) {
t.Run("only configuration updates", func(t *testing.T) {
body := []byte(`{"input":[{"type":"configuration_update","reasoning":{"effort":"low"}},{"type":"configuration_update","reasoning":{"effort":"high"}}]}`)
got := CollapseConfigurationUpdates(body)
- if input := gjson.GetBytes(got, "input"); !input.IsArray() || len(input.Array()) != 0 {
+ if input := ojson.GetBytes(got, "input"); !input.IsArray() || len(input.Array()) != 0 {
t.Fatalf("input = %s, want []; body=%s", input.Raw, got)
}
- if effort := gjson.GetBytes(got, "reasoning.effort").String(); effort != "high" {
+ if effort := ojson.GetBytes(got, "reasoning.effort").String(); effort != "high" {
t.Fatalf("reasoning.effort = %q, want high; body=%s", effort, got)
}
})
@@ -39,10 +40,10 @@ func TestStripConfigurationUpdatesExported(t *testing.T) {
t.Run("items removed without changing top level effort", func(t *testing.T) {
body := []byte(`{"reasoning":{"effort":"medium"},"input":[{"type":"configuration_update","reasoning":{"effort":"high"}},{"role":"user","content":"hi"}]}`)
got := StripConfigurationUpdates(body)
- if input := gjson.GetBytes(got, "input").Raw; input != `[{"role":"user","content":"hi"}]` {
+ if input := ojson.GetBytes(got, "input").Raw; input != `[{"role":"user","content":"hi"}]` {
t.Fatalf("input = %s, want user message; body=%s", input, got)
}
- if effort := gjson.GetBytes(got, "reasoning.effort").String(); effort != "medium" {
+ if effort := ojson.GetBytes(got, "reasoning.effort").String(); effort != "medium" {
t.Fatalf("reasoning.effort = %q, want medium; body=%s", effort, got)
}
})
@@ -84,3 +85,28 @@ func TestHasThinkingSuffix(t *testing.T) {
})
}
}
+
+type inPlaceLegacyApplier struct{}
+
+func (inPlaceLegacyApplier) Apply(body []byte, _ ThinkingConfig, _ *registry.ModelInfo) ([]byte, error) {
+ idx := bytes.Index(body, []byte(`"low"`))
+ if idx >= 0 {
+ copy(body[idx:], []byte(`"max"`))
+ }
+ return body, nil
+}
+
+func TestInvokeProviderApplierOnDocPreservesInPlaceMutation(t *testing.T) {
+ doc := ojson.NewDocString(`{"effort":"low"}`)
+ docValid := true
+ modified, err := invokeProviderApplierOnDoc(inPlaceLegacyApplier{}, doc, &docValid, ThinkingConfig{Mode: ModeLevel, Level: LevelMax}, nil)
+ if err != nil {
+ t.Fatalf("invokeProviderApplierOnDoc error: %v", err)
+ }
+ if !modified {
+ t.Fatalf("expected modified=true")
+ }
+ if got := doc.Get("effort").String(); got != "max" {
+ t.Fatalf("doc.Get(effort) = %q, want max", got)
+ }
+}
diff --git a/internal/thinking/kimi_max_clamp_repro_test.go b/internal/thinking/kimi_max_clamp_repro_test.go
index cf7a61fb1db..6c590e221cf 100644
--- a/internal/thinking/kimi_max_clamp_repro_test.go
+++ b/internal/thinking/kimi_max_clamp_repro_test.go
@@ -3,11 +3,11 @@ package thinking_test
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/claude"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/kimi"
- "github.com/tidwall/gjson"
)
// TestKimiK28ClaudeMessagesMaxPreservesMax verifies that K2.8 models preserve effort=max
@@ -24,7 +24,7 @@ func TestKimiK28ClaudeMessagesMaxPreservesMax(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking returned error: %v", err)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "max" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "max" {
t.Fatalf("output_config.effort = %q, want max", got)
}
}
@@ -42,10 +42,10 @@ func TestKimiClaudeMessagesMaxClampsToHigh(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking returned error: %v", err)
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive", got)
}
- if got := gjson.GetBytes(out, "output_config.effort").String(); got != "high" {
+ if got := ojson.GetBytes(out, "output_config.effort").String(); got != "high" {
t.Fatalf("output_config.effort = %q, want high", got)
}
}
diff --git a/internal/thinking/provider/antigravity/apply.go b/internal/thinking/provider/antigravity/apply.go
index e4e9349dae4..307c77503b3 100644
--- a/internal/thinking/provider/antigravity/apply.go
+++ b/internal/thinking/provider/antigravity/apply.go
@@ -1,25 +1,19 @@
// Package antigravity implements thinking configuration for Antigravity API format.
-//
-// Antigravity uses request.generationConfig.thinkingConfig.* path.
-// but requires additional normalization for Claude models:
-// - Ensure thinking budget < max_tokens
-// - Remove thinkingConfig if budget < minimum allowed
package antigravity
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Applier applies thinking configuration for Antigravity API format.
type Applier struct{}
-var _ thinking.ProviderApplier = (*Applier)(nil)
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
// NewApplier creates a new Antigravity thinking applier.
func NewApplier() *Applier {
@@ -31,187 +25,168 @@ func init() {
}
// Apply applies thinking configuration to Antigravity request body.
-//
-// For Claude models, additional constraints are applied:
-// - Ensure thinking budget < max_tokens
-// - Remove thinkingConfig if budget < minimum allowed
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
- if thinking.IsUserDefinedModel(modelInfo) {
- return a.applyCompatible(body, config, modelInfo)
- }
- if modelInfo.Thinking == nil {
+ if !thinking.IsUserDefinedModel(modelInfo) && modelInfo.Thinking == nil {
return body, nil
}
-
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
return body, nil
}
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
+ }
+ doc := ojson.NewDoc(body)
+ if errApply := a.ApplyDoc(doc, config, modelInfo); errApply != nil {
+ return body, errApply
+ }
+ return doc.Bytes(), nil
+}
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+// ApplyDoc applies thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
+ }
+ if thinking.IsUserDefinedModel(modelInfo) {
+ return a.applyCompatibleDoc(doc, config, modelInfo)
+ }
+ if modelInfo.Thinking == nil {
+ return nil
+ }
+ if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
+ return nil
}
isClaude := strings.Contains(strings.ToLower(modelInfo.ID), "claude")
-
- // ModeAuto: Always use Budget format with thinkingBudget=-1
- if config.Mode == thinking.ModeAuto {
- return a.applyBudgetFormat(body, config, modelInfo, isClaude)
- }
- if config.Mode == thinking.ModeBudget {
- return a.applyBudgetFormat(body, config, modelInfo, isClaude)
+ if config.Mode == thinking.ModeAuto || config.Mode == thinking.ModeBudget {
+ return a.applyBudgetFormatDoc(doc, config, modelInfo, isClaude)
}
- // For non-auto modes, choose format based on model capabilities
- support := modelInfo.Thinking
- if len(support.Levels) > 0 {
- return a.applyLevelFormat(body, config)
+ if len(modelInfo.Thinking.Levels) > 0 {
+ return a.applyLevelFormatDoc(doc, config)
}
- return a.applyBudgetFormat(body, config, modelInfo, isClaude)
+ return a.applyBudgetFormatDoc(doc, config, modelInfo, isClaude)
}
-func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
+func (a *Applier) applyCompatibleDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
- return body, nil
+ return nil
}
-
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
- }
-
isClaude := false
if modelInfo != nil {
isClaude = strings.Contains(strings.ToLower(modelInfo.ID), "claude")
}
-
if config.Mode == thinking.ModeAuto {
- return a.applyBudgetFormat(body, config, modelInfo, isClaude)
+ return a.applyBudgetFormatDoc(doc, config, modelInfo, isClaude)
}
-
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
- return a.applyLevelFormat(body, config)
+ return a.applyLevelFormatDoc(doc, config)
}
-
- return a.applyBudgetFormat(body, config, modelInfo, isClaude)
+ return a.applyBudgetFormatDoc(doc, config, modelInfo, isClaude)
}
-func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
- result, _ := util.DeleteJSONBytesIfExists(body, "request.generationConfig.thinkingConfig.thinkingBudget")
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.thinking_budget")
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.thinking_level")
- // Normalize includeThoughts field name and retain only documented booleans.
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.includeThoughts")
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.include_thoughts")
+func (a *Applier) applyLevelFormatDoc(doc *ojson.Doc, config thinking.ThinkingConfig) error {
+ includeThoughts, hasIncludeThoughts := extractAntigravityIncludeThoughtsDoc(doc)
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.thinkingBudget")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.thinking_budget")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.thinking_level")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.includeThoughts")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.include_thoughts")
if config.Mode == thinking.ModeNone {
if config.Budget == 0 && config.Level == "" {
- // With the amount fully disabled, visibility is irrelevant. Restoring
- // includeThoughts alone would recreate thinkingConfig and let a
- // default-on model think again.
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig")
- return result, nil
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig")
+ return nil
}
if config.Level != "" {
- result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
+ _ = doc.Set("request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
}
- return applyAntigravityIncludeThoughts(result, body), nil
+ if hasIncludeThoughts {
+ _ = doc.Set("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ }
+ return nil
}
- // Only handle ModeLevel - budget conversion should be done by upper layer
if config.Mode != thinking.ModeLevel {
- return body, nil
+ return nil
}
- level := string(config.Level)
- result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel", level)
- return applyAntigravityIncludeThoughts(result, body), nil
+ _ = doc.Set("request.generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
+ if hasIncludeThoughts {
+ _ = doc.Set("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ }
+ return nil
}
-func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) ([]byte, error) {
- // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
- result, _ := util.DeleteJSONBytesIfExists(body, "request.generationConfig.thinkingConfig.thinkingLevel")
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.thinking_level")
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.thinking_budget")
- // Normalize includeThoughts field name and retain only documented booleans.
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.includeThoughts")
- result, _ = util.DeleteJSONBytesIfExists(result, "request.generationConfig.thinkingConfig.include_thoughts")
+func (a *Applier) applyBudgetFormatDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, isClaude bool) error {
+ includeThoughts, hasIncludeThoughts := extractAntigravityIncludeThoughtsDoc(doc)
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.thinkingLevel")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.thinking_level")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.thinking_budget")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.includeThoughts")
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig.include_thoughts")
budget := config.Budget
-
- // Apply Claude-specific constraints first to get the final budget value
if isClaude && modelInfo != nil {
- budget, result = a.normalizeClaudeBudget(budget, result, modelInfo)
- // Check if the thinking amount was removed entirely. Summary visibility is
- // independent, so retain an explicit includeThoughts control if present.
+ budget = a.normalizeClaudeBudgetDoc(budget, doc, modelInfo)
if budget == -2 {
- return applyAntigravityIncludeThoughts(result, body), nil
+ if hasIncludeThoughts {
+ _ = doc.Set("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ }
+ return nil
}
}
- result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
- return applyAntigravityIncludeThoughts(result, body), nil
+ _ = doc.Set("request.generationConfig.thinkingConfig.thinkingBudget", budget)
+ if hasIncludeThoughts {
+ _ = doc.Set("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ }
+ return nil
}
-func applyAntigravityIncludeThoughts(result, original []byte) []byte {
+func extractAntigravityIncludeThoughtsDoc(doc *ojson.Doc) (bool, bool) {
for _, path := range []string{
"request.generationConfig.thinkingConfig.includeThoughts",
"request.generationConfig.thinkingConfig.include_thoughts",
} {
- switch value := gjson.GetBytes(original, path); value.Type {
- case gjson.True:
- result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", true)
- return result
- case gjson.False:
- result, _ = sjson.SetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts", false)
- return result
+ switch value := doc.Get(path); value.Type {
+ case ojson.True:
+ return true, true
+ case ojson.False:
+ return false, true
}
}
- return result
+ return false, false
}
-// normalizeClaudeBudget applies Claude-specific constraints to thinking budget.
-//
-// It handles:
-// - Ensuring thinking budget < max_tokens
-// - Removing thinkingConfig if budget < minimum allowed
-//
-// Returns the normalized budget and updated payload.
-// Returns budget=-2 as a sentinel indicating thinkingConfig was removed entirely.
-func (a *Applier) normalizeClaudeBudget(budget int, payload []byte, modelInfo *registry.ModelInfo) (int, []byte) {
+func (a *Applier) normalizeClaudeBudgetDoc(budget int, doc *ojson.Doc, modelInfo *registry.ModelInfo) int {
if modelInfo == nil {
- return budget, payload
+ return budget
}
- // Get effective max tokens
- effectiveMax, setDefaultMax := a.effectiveMaxTokens(payload, modelInfo)
+ effectiveMax, setDefaultMax := a.effectiveMaxTokensDoc(doc, modelInfo)
if effectiveMax > 0 && budget >= effectiveMax {
budget = effectiveMax - 1
}
- // Check minimum budget
minBudget := 0
if modelInfo.Thinking != nil {
minBudget = modelInfo.Thinking.Min
}
if minBudget > 0 && budget >= 0 && budget < minBudget {
- // Budget is below minimum, remove thinking config entirely
- payload, _ = util.DeleteJSONBytesIfExists(payload, "request.generationConfig.thinkingConfig")
- return -2, payload
+ _ = util.DeleteJSONDocIfExists(doc, "request.generationConfig.thinkingConfig")
+ return -2
}
- // Set default max tokens if needed
if setDefaultMax && effectiveMax > 0 {
- payload, _ = sjson.SetBytes(payload, "request.generationConfig.maxOutputTokens", effectiveMax)
+ _ = doc.Set("request.generationConfig.maxOutputTokens", effectiveMax)
}
- return budget, payload
+ return budget
}
-// effectiveMaxTokens returns the max tokens to cap thinking:
-// prefer request-provided maxOutputTokens; otherwise fall back to model default.
-// The boolean indicates whether the value came from the model default (and thus should be written back).
-func (a *Applier) effectiveMaxTokens(payload []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
- if maxTok := gjson.GetBytes(payload, "request.generationConfig.maxOutputTokens"); maxTok.Exists() && maxTok.Int() > 0 {
+func (a *Applier) effectiveMaxTokensDoc(doc *ojson.Doc, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
+ if maxTok := doc.Get("request.generationConfig.maxOutputTokens"); maxTok.Exists() && maxTok.Int() > 0 {
return int(maxTok.Int()), false
}
if modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
diff --git a/internal/thinking/provider/claude/apply.go b/internal/thinking/provider/claude/apply.go
index 57ea7d47a8f..2c8a7a2a10a 100644
--- a/internal/thinking/provider/claude/apply.go
+++ b/internal/thinking/provider/claude/apply.go
@@ -1,24 +1,18 @@
// Package claude implements thinking configuration scaffolding for Claude models.
-//
-// Claude models support two thinking control styles:
-// - Manual thinking: thinking.type="enabled" with thinking.budget_tokens (token budget)
-// - Adaptive thinking (Claude 4.6): thinking.type="adaptive" with output_config.effort (low/medium/high/max)
-//
-// Some Claude models support ZeroAllowed (sonnet-4-5, opus-4-5), while older models do not.
-// See: _bmad-output/planning-artifacts/architecture.md#Epic-6
package claude
import (
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// Applier implements thinking.ProviderApplier for Claude models.
-// This applier is stateless and holds no configuration.
type Applier struct{}
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
+
// NewApplier creates a new Claude thinking applier.
func NewApplier() *Applier {
return &Applier{}
@@ -29,167 +23,128 @@ func init() {
}
// Apply applies thinking configuration to Claude request body.
-//
-// IMPORTANT: This method expects config to be pre-validated by thinking.ValidateConfig.
-// ValidateConfig handles:
-// - Mode conversion (Level→Budget, Auto→Budget)
-// - Budget clamping to model range
-// - ZeroAllowed constraint enforcement
-//
-// Apply processes:
-// - ModeBudget: manual thinking budget_tokens
-// - ModeLevel: adaptive thinking effort (Claude 4.6)
-// - ModeAuto: provider default adaptive/manual behavior
-// - ModeNone: disabled, or the model's ThinkingSupport.DisableType (between_tools on claude-sonnet-5-5)
-//
-// Expected output format when enabled:
-//
-// {
-// "thinking": {
-// "type": "enabled",
-// "budget_tokens": 16384
-// }
-// }
-//
-// Expected output format for adaptive:
-//
-// {
-// "thinking": {
-// "type": "adaptive"
-// },
-// "output_config": {
-// "effort": "high"
-// }
-// }
-//
-// Expected output format when disabled:
-//
-// {
-// "thinking": {
-// "type": "disabled"
-// }
-// }
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
- if thinking.IsUserDefinedModel(modelInfo) {
- return applyCompatibleClaude(body, config)
+ if !thinking.IsUserDefinedModel(modelInfo) && modelInfo.Thinking == nil {
+ return body, nil
}
- if modelInfo.Thinking == nil {
+ if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel {
+ return body, nil
+ }
+ if thinking.IsUserDefinedModel(modelInfo) && config.Mode == thinking.ModeLevel && config.Level == "" {
return body, nil
}
+ if !thinking.IsUserDefinedModel(modelInfo) && config.Mode == thinking.ModeLevel {
+ supportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0
+ if !(supportsAdaptive && config.Level != "") {
+ if _, ok := thinking.ConvertLevelToBudget(string(config.Level)); !ok {
+ return body, nil
+ }
+ }
+ }
+
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
+ }
+ doc := ojson.NewDoc(body)
+ if errApply := a.ApplyDoc(doc, config, modelInfo); errApply != nil {
+ return body, errApply
+ }
+ return doc.Bytes(), nil
+}
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+// ApplyDoc applies thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
+ }
+ if thinking.IsUserDefinedModel(modelInfo) {
+ return applyCompatibleClaudeDoc(doc, config)
+ }
+ if modelInfo.Thinking == nil {
+ return nil
}
supportsAdaptive := modelInfo != nil && modelInfo.Thinking != nil && len(modelInfo.Thinking.Levels) > 0
switch config.Mode {
case thinking.ModeNone:
- return disableThinking(body, modelInfo), nil
+ disableThinkingDoc(doc, modelInfo)
+ return nil
case thinking.ModeLevel:
- // Adaptive thinking effort is only valid when the model advertises discrete levels.
- // (Claude 4.6 uses output_config.effort.)
if supportsAdaptive && config.Level != "" {
- result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
- return result, nil
+ _ = doc.Set("thinking.type", "adaptive")
+ _ = util.DeleteJSONDocIfExists(doc, "thinking.budget_tokens")
+ _ = doc.Set("output_config.effort", string(config.Level))
+ return nil
}
- // Fallback for non-adaptive Claude models: convert level to budget_tokens.
if budget, ok := thinking.ConvertLevelToBudget(string(config.Level)); ok {
config.Mode = thinking.ModeBudget
config.Budget = budget
config.Level = ""
} else {
- return body, nil
+ return nil
}
fallthrough
case thinking.ModeBudget:
- // Budget is expected to be pre-validated by ValidateConfig (clamped, ZeroAllowed enforced).
- // Decide enabled/disabled based on budget value.
if config.Budget == 0 {
- return disableThinking(body, modelInfo), nil
+ disableThinkingDoc(doc, modelInfo)
+ return nil
}
- result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
- result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
+ _ = doc.Set("thinking.type", "enabled")
+ _ = doc.Set("thinking.budget_tokens", config.Budget)
+ _ = util.DeleteJSONDocIfExists(doc, "output_config.effort")
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ _ = doc.Delete("output_config")
}
- // Ensure max_tokens > thinking.budget_tokens (Anthropic API constraint).
- result = a.normalizeClaudeBudget(result, config.Budget, modelInfo)
- return result, nil
+ a.normalizeClaudeBudgetDoc(doc, config.Budget, modelInfo)
+ return nil
case thinking.ModeAuto:
- // For Claude 4.6 models, auto maps to adaptive thinking with upstream defaults.
+ thinkingType := "enabled"
if supportsAdaptive {
- result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- // Explicit effort is optional for adaptive thinking; omit it to allow upstream default.
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
- }
- return result, nil
+ thinkingType = "adaptive"
}
-
- // Legacy fallback: enable thinking without specifying budget_tokens.
- result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
+ _ = doc.Set("thinking.type", thinkingType)
+ _ = util.DeleteJSONDocIfExists(doc, "thinking.budget_tokens")
+ _ = util.DeleteJSONDocIfExists(doc, "output_config.effort")
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ _ = doc.Delete("output_config")
}
- return result, nil
+ return nil
default:
- return body, nil
+ return nil
}
}
-// disableThinking turns thinking off with the type the model accepts. Most Claude
-// models take "disabled"; claude-sonnet-5-5 rejects it and needs "between_tools".
-// Neither type accepts extra thinking fields (budget_tokens, display, block_binding),
-// and between_tools only accepts effort high or below, so effort is left to the
-// model default in both cases.
-func disableThinking(body []byte, modelInfo *registry.ModelInfo) []byte {
+func disableThinkingDoc(doc *ojson.Doc, modelInfo *registry.ModelInfo) {
disableType := "disabled"
if modelInfo != nil && modelInfo.Thinking != nil && modelInfo.Thinking.DisableType != "" {
disableType = modelInfo.Thinking.DisableType
}
- result, _ := sjson.SetRawBytes(body, "thinking", []byte(`{}`))
- result, _ = sjson.SetBytes(result, "thinking.type", disableType)
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
+ _ = doc.SetRaw("thinking", "{}")
+ _ = doc.Set("thinking.type", disableType)
+ _ = util.DeleteJSONDocIfExists(doc, "output_config.effort")
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ _ = doc.Delete("output_config")
}
- return result
}
-// normalizeClaudeBudget applies Claude-specific constraints to ensure max_tokens > budget_tokens.
-// Anthropic API requires this constraint; violating it returns a 400 error.
-func (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo *registry.ModelInfo) []byte {
+func (a *Applier) normalizeClaudeBudgetDoc(doc *ojson.Doc, budgetTokens int, modelInfo *registry.ModelInfo) {
if budgetTokens <= 0 {
- return body
+ return
}
- // Ensure the request satisfies Claude constraints:
- // 1) Determine effective max_tokens (request overrides model default)
- // 2) If budget_tokens >= max_tokens, reduce budget_tokens to max_tokens-1
- // 3) If the adjusted budget falls below the model minimum, leave the request unchanged
- // 4) If max_tokens came from model default, write it back into the request
-
- effectiveMax, setDefaultMax := a.effectiveMaxTokens(body, modelInfo)
+ effectiveMax, setDefaultMax := a.effectiveMaxTokensDoc(doc, modelInfo)
if setDefaultMax && effectiveMax > 0 {
- body, _ = sjson.SetBytes(body, "max_tokens", effectiveMax)
+ _ = doc.Set("max_tokens", effectiveMax)
}
- // Compute the budget we would apply after enforcing budget_tokens < max_tokens.
adjustedBudget := budgetTokens
if effectiveMax > 0 && adjustedBudget >= effectiveMax {
adjustedBudget = effectiveMax - 1
@@ -200,23 +155,16 @@ func (a *Applier) normalizeClaudeBudget(body []byte, budgetTokens int, modelInfo
minBudget = modelInfo.Thinking.Min
}
if minBudget > 0 && adjustedBudget > 0 && adjustedBudget < minBudget {
- // If enforcing the max_tokens constraint would push the budget below the model minimum,
- // leave the request unchanged.
- return body
+ return
}
if adjustedBudget != budgetTokens {
- body, _ = sjson.SetBytes(body, "thinking.budget_tokens", adjustedBudget)
+ _ = doc.Set("thinking.budget_tokens", adjustedBudget)
}
-
- return body
}
-// effectiveMaxTokens returns the max tokens to cap thinking:
-// prefer request-provided max_tokens; otherwise fall back to model default.
-// The boolean indicates whether the value came from the model default (and thus should be written back).
-func (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
- if maxTok := gjson.GetBytes(body, "max_tokens"); maxTok.Exists() && maxTok.Int() > 0 {
+func (a *Applier) effectiveMaxTokensDoc(doc *ojson.Doc, modelInfo *registry.ModelInfo) (max int, fromModel bool) {
+ if maxTok := doc.Get("max_tokens"); maxTok.Exists() && maxTok.Int() > 0 {
return int(maxTok.Int()), false
}
if modelInfo != nil && modelInfo.MaxCompletionTokens > 0 {
@@ -225,46 +173,40 @@ func (a *Applier) effectiveMaxTokens(body []byte, modelInfo *registry.ModelInfo)
return 0, false
}
-func applyCompatibleClaude(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto && config.Mode != thinking.ModeLevel {
- return body, nil
- }
-
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
- }
-
+func applyCompatibleClaudeDoc(doc *ojson.Doc, config thinking.ThinkingConfig) error {
switch config.Mode {
case thinking.ModeNone:
- return disableThinking(body, nil), nil
+ disableThinkingDoc(doc, nil)
+ return nil
case thinking.ModeAuto:
- result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
+ _ = doc.Set("thinking.type", "enabled")
+ _ = util.DeleteJSONDocIfExists(doc, "thinking.budget_tokens")
+ _ = util.DeleteJSONDocIfExists(doc, "output_config.effort")
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ _ = doc.Delete("output_config")
}
- return result, nil
+ return nil
case thinking.ModeLevel:
- // For user-defined models, interpret ModeLevel as Claude adaptive thinking effort.
- // Upstream is responsible for validating whether the target model supports it.
if config.Level == "" {
- return body, nil
+ return nil
}
- result, _ := sjson.SetBytes(body, "thinking.type", "adaptive")
- result, _ = sjson.DeleteBytes(result, "thinking.budget_tokens")
- result, _ = sjson.SetBytes(result, "output_config.effort", string(config.Level))
- return result, nil
- default:
+ _ = doc.Set("thinking.type", "adaptive")
+ _ = util.DeleteJSONDocIfExists(doc, "thinking.budget_tokens")
+ _ = doc.Set("output_config.effort", string(config.Level))
+ return nil
+ case thinking.ModeBudget:
if config.Budget == 0 {
- return disableThinking(body, nil), nil
+ disableThinkingDoc(doc, nil)
+ return nil
}
- result, _ := sjson.SetBytes(body, "thinking.type", "enabled")
- result, _ = sjson.SetBytes(result, "thinking.budget_tokens", config.Budget)
- result, _ = sjson.DeleteBytes(result, "output_config.effort")
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
+ _ = doc.Set("thinking.type", "enabled")
+ _ = doc.Set("thinking.budget_tokens", config.Budget)
+ _ = util.DeleteJSONDocIfExists(doc, "output_config.effort")
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ _ = doc.Delete("output_config")
}
- return result, nil
+ return nil
+ default:
+ return nil
}
}
diff --git a/internal/thinking/provider/codex/apply.go b/internal/thinking/provider/codex/apply.go
index 9a1a960ccf4..661a430d599 100644
--- a/internal/thinking/provider/codex/apply.go
+++ b/internal/thinking/provider/codex/apply.go
@@ -1,27 +1,16 @@
// Package codex implements thinking configuration for Codex (OpenAI Responses API) models.
-//
-// Codex models use the reasoning.effort format with discrete levels
-// (low/medium/high). This is similar to OpenAI but uses nested field
-// "reasoning.effort" instead of "reasoning_effort".
-// See: _bmad-output/planning-artifacts/architecture.md#Epic-8
package codex
import (
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Applier implements thinking.ProviderApplier for Codex models.
-//
-// Codex-specific behavior:
-// - Output format: reasoning.effort (string: low/medium/high/xhigh)
-// - Level-only mode: no numeric budget support
-// - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)
type Applier struct{}
-var _ thinking.ProviderApplier = (*Applier)(nil)
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
// NewApplier creates a new Codex thinking applier.
func NewApplier() *Applier {
@@ -33,34 +22,65 @@ func init() {
}
// Apply applies thinking configuration to Codex request body.
-//
-// Expected output format:
-//
-// {
-// "reasoning": {
-// "effort": "high"
-// }
-// }
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
- if thinking.IsUserDefinedModel(modelInfo) {
- return applyCompatibleCodex(body, config)
- }
- if modelInfo.Thinking == nil {
+ effort, ok := resolveCodexEffort(config, modelInfo)
+ if !ok {
return body, nil
}
-
- // Only handle ModeLevel and ModeNone; other modes pass through unchanged.
- if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
- return body, nil
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
}
+ result, _ := ojson.SetBytes(body, "reasoning.effort", effort)
+ return result, nil
+}
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+// ApplyDoc applies thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
}
+ effort, ok := resolveCodexEffort(config, modelInfo)
+ if !ok {
+ return nil
+ }
+ _ = doc.Set("reasoning.effort", effort)
+ return nil
+}
+func resolveCodexEffort(config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) (string, bool) {
+ if thinking.IsUserDefinedModel(modelInfo) {
+ switch config.Mode {
+ case thinking.ModeLevel:
+ if config.Level == "" {
+ return "", false
+ }
+ return string(config.Level), true
+ case thinking.ModeNone:
+ effort := string(thinking.LevelNone)
+ if config.Level != "" {
+ effort = string(config.Level)
+ }
+ return effort, true
+ case thinking.ModeAuto:
+ return string(thinking.LevelAuto), true
+ case thinking.ModeBudget:
+ level, ok := thinking.ConvertBudgetToLevel(config.Budget)
+ if !ok {
+ return "", false
+ }
+ return level, true
+ default:
+ return "", false
+ }
+ }
+ if modelInfo.Thinking == nil {
+ return "", false
+ }
+ if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
+ return "", false
+ }
if config.Mode == thinking.ModeLevel {
- result, _ := sjson.SetBytes(body, "reasoning.effort", string(config.Level))
- return result, nil
+ return string(config.Level), true
}
effort := ""
@@ -77,44 +97,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
effort = support.Levels[0]
}
if effort == "" {
- return body, nil
- }
-
- result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
- return result, nil
-}
-
-func applyCompatibleCodex(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
- }
-
- var effort string
- switch config.Mode {
- case thinking.ModeLevel:
- if config.Level == "" {
- return body, nil
- }
- effort = string(config.Level)
- case thinking.ModeNone:
- effort = string(thinking.LevelNone)
- if config.Level != "" {
- effort = string(config.Level)
- }
- case thinking.ModeAuto:
- // Auto mode for user-defined models: pass through as "auto"
- effort = string(thinking.LevelAuto)
- case thinking.ModeBudget:
- // Budget mode: convert budget to level using threshold mapping
- level, ok := thinking.ConvertBudgetToLevel(config.Budget)
- if !ok {
- return body, nil
- }
- effort = level
- default:
- return body, nil
+ return "", false
}
-
- result, _ := sjson.SetBytes(body, "reasoning.effort", effort)
- return result, nil
+ return effort, true
}
diff --git a/internal/thinking/provider/gemini/apply.go b/internal/thinking/provider/gemini/apply.go
index bfe4f67b2a7..69be11b27ef 100644
--- a/internal/thinking/provider/gemini/apply.go
+++ b/internal/thinking/provider/gemini/apply.go
@@ -1,32 +1,18 @@
// Package gemini implements thinking configuration for Gemini models.
-//
-// Gemini models have two formats:
-// - Gemini 2.5: Uses thinkingBudget (numeric)
-// - Gemini 3.x: Uses thinkingLevel (string: minimal/low/medium/high)
-// or thinkingBudget=-1 for auto/dynamic mode
-//
-// Output format is determined by ThinkingConfig.Mode and ThinkingSupport.Levels:
-// - ModeAuto: Always uses thinkingBudget=-1 (both Gemini 2.5 and 3.x)
-// - len(Levels) > 0: Uses thinkingLevel (Gemini 3.x discrete levels)
-// - len(Levels) == 0: Uses thinkingBudget (Gemini 2.5)
package gemini
import (
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Applier applies thinking configuration for Gemini models.
-//
-// Gemini-specific behavior:
-// - Gemini 2.5: thinkingBudget format, flash series supports ZeroAllowed
-// - Gemini 3.x: thinkingLevel format, disable by removing thinkingConfig when zero is allowed
-// - Use ThinkingSupport.Levels to decide output format
type Applier struct{}
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
+
// NewApplier creates a new Gemini thinking applier.
func NewApplier() *Applier {
return &Applier{}
@@ -37,147 +23,123 @@ func init() {
}
// Apply applies thinking configuration to Gemini request body.
-//
-// Expected output format (Gemini 2.5):
-//
-// {
-// "generationConfig": {
-// "thinkingConfig": {
-// "thinkingBudget": 8192,
-// "includeThoughts": true
-// }
-// }
-// }
-//
-// Expected output format (Gemini 3.x):
-//
-// {
-// "generationConfig": {
-// "thinkingConfig": {
-// "thinkingLevel": "high",
-// "includeThoughts": true
-// }
-// }
-// }
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
- if thinking.IsUserDefinedModel(modelInfo) {
- return a.applyCompatible(body, config)
- }
- if modelInfo.Thinking == nil {
+ if !thinking.IsUserDefinedModel(modelInfo) && modelInfo.Thinking == nil {
return body, nil
}
-
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
return body, nil
}
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
+ }
+ doc := ojson.NewDoc(body)
+ if errApply := a.ApplyDoc(doc, config, modelInfo); errApply != nil {
+ return body, errApply
+ }
+ return doc.Bytes(), nil
+}
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+// ApplyDoc applies thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
+ }
+ if thinking.IsUserDefinedModel(modelInfo) {
+ return a.applyCompatibleDoc(doc, config)
+ }
+ if modelInfo.Thinking == nil {
+ return nil
+ }
+ if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
+ return nil
}
- // Choose format based on config.Mode and model capabilities:
- // - ModeLevel: use Level format (validation will reject unsupported levels)
- // - ModeNone: use Level format if model has Levels, else Budget format
- // - ModeBudget/ModeAuto: use Budget format
switch config.Mode {
case thinking.ModeLevel:
- return a.applyLevelFormat(body, config)
+ return a.applyLevelFormatDoc(doc, config)
case thinking.ModeNone:
- // ModeNone: route based on model capability (has Levels or not)
if len(modelInfo.Thinking.Levels) > 0 {
- return a.applyLevelFormat(body, config)
+ return a.applyLevelFormatDoc(doc, config)
}
- return a.applyBudgetFormat(body, config)
+ return a.applyBudgetFormatDoc(doc, config)
default:
- return a.applyBudgetFormat(body, config)
+ return a.applyBudgetFormatDoc(doc, config)
}
}
-func (a *Applier) applyCompatible(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
+func (a *Applier) applyCompatibleDoc(doc *ojson.Doc, config thinking.ThinkingConfig) error {
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
- return body, nil
- }
-
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+ return nil
}
-
if config.Mode == thinking.ModeAuto {
- return a.applyBudgetFormat(body, config)
+ return a.applyBudgetFormatDoc(doc, config)
}
-
if config.Mode == thinking.ModeLevel || (config.Mode == thinking.ModeNone && config.Level != "") {
- return a.applyLevelFormat(body, config)
+ return a.applyLevelFormatDoc(doc, config)
}
-
- return a.applyBudgetFormat(body, config)
+ return a.applyBudgetFormatDoc(doc, config)
}
-func (a *Applier) applyLevelFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- // ModeNone semantics:
- // - ModeNone + Budget=0: remove the thinking amount configuration.
- // - ModeNone + Budget>0: clamp to the model's lowest supported amount.
- // Summary visibility remains independent and is restored only when explicitly set.
-
- // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
- result, _ := util.DeleteJSONBytesIfExists(body, "generationConfig.thinkingConfig.thinkingBudget")
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.thinking_budget")
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.thinking_level")
- // Normalize includeThoughts field name and retain only documented booleans.
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.includeThoughts")
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.include_thoughts")
+func (a *Applier) applyLevelFormatDoc(doc *ojson.Doc, config thinking.ThinkingConfig) error {
+ includeThoughts, hasIncludeThoughts := extractGeminiIncludeThoughtsDoc(doc)
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.thinkingBudget")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.thinking_budget")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.thinking_level")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.includeThoughts")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.include_thoughts")
if config.Mode == thinking.ModeNone {
if config.Budget == 0 && config.Level == "" {
- // With the amount fully disabled, visibility is irrelevant. Restoring
- // includeThoughts alone would recreate thinkingConfig and let a
- // default-on model think again.
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig")
- return result, nil
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig")
+ return nil
}
if config.Level != "" {
- result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
+ _ = doc.Set("generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
+ }
+ if hasIncludeThoughts {
+ _ = doc.Set("generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
- return applyGeminiIncludeThoughts(result, body), nil
+ return nil
}
- // Only handle ModeLevel - budget conversion should be done by upper layer
if config.Mode != thinking.ModeLevel {
- return body, nil
+ return nil
}
- level := string(config.Level)
- result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingLevel", level)
- return applyGeminiIncludeThoughts(result, body), nil
+ _ = doc.Set("generationConfig.thinkingConfig.thinkingLevel", string(config.Level))
+ if hasIncludeThoughts {
+ _ = doc.Set("generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ }
+ return nil
}
-func (a *Applier) applyBudgetFormat(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- // Remove conflicting fields to avoid both thinkingLevel and thinkingBudget in output
- result, _ := util.DeleteJSONBytesIfExists(body, "generationConfig.thinkingConfig.thinkingLevel")
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.thinking_level")
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.thinking_budget")
- // Normalize includeThoughts field name and retain only documented booleans.
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.includeThoughts")
- result, _ = util.DeleteJSONBytesIfExists(result, "generationConfig.thinkingConfig.include_thoughts")
-
- budget := config.Budget
- result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.thinkingBudget", budget)
- return applyGeminiIncludeThoughts(result, body), nil
+func (a *Applier) applyBudgetFormatDoc(doc *ojson.Doc, config thinking.ThinkingConfig) error {
+ includeThoughts, hasIncludeThoughts := extractGeminiIncludeThoughtsDoc(doc)
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.thinkingLevel")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.thinking_level")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.thinking_budget")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.includeThoughts")
+ _ = util.DeleteJSONDocIfExists(doc, "generationConfig.thinkingConfig.include_thoughts")
+
+ _ = doc.Set("generationConfig.thinkingConfig.thinkingBudget", config.Budget)
+ if hasIncludeThoughts {
+ _ = doc.Set("generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ }
+ return nil
}
-func applyGeminiIncludeThoughts(result, original []byte) []byte {
+func extractGeminiIncludeThoughtsDoc(doc *ojson.Doc) (bool, bool) {
for _, path := range []string{
"generationConfig.thinkingConfig.includeThoughts",
"generationConfig.thinkingConfig.include_thoughts",
} {
- switch value := gjson.GetBytes(original, path); value.Type {
- case gjson.True:
- result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", true)
- return result
- case gjson.False:
- result, _ = sjson.SetBytes(result, "generationConfig.thinkingConfig.includeThoughts", false)
- return result
+ switch value := doc.Get(path); value.Type {
+ case ojson.True:
+ return true, true
+ case ojson.False:
+ return false, true
}
}
- return result
+ return false, false
}
diff --git a/internal/thinking/provider/interactions/apply.go b/internal/thinking/provider/interactions/apply.go
index eedbb414e99..158014899df 100644
--- a/internal/thinking/provider/interactions/apply.go
+++ b/internal/thinking/provider/interactions/apply.go
@@ -4,15 +4,17 @@ package interactions
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// Applier implements thinking.ProviderApplier for the native Interactions API.
type Applier struct{}
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
+
// NewApplier creates a new Interactions thinking applier.
func NewApplier() *Applier {
return &Applier{}
@@ -27,64 +29,76 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
return body, nil
}
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
+ }
+ doc := ojson.NewDoc(body)
+ if errApply := a.ApplyDoc(doc, config, modelInfo); errApply != nil {
+ return body, errApply
+ }
+ return doc.Bytes(), nil
+}
+
+// ApplyDoc writes thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
+ }
+ if config.Mode != thinking.ModeBudget && config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone && config.Mode != thinking.ModeAuto {
+ return nil
}
- result := stripInteractionsThinkingFields(body)
+ summaries, hasSummaries := originalInteractionsThinkingSummariesDoc(doc)
+ includeThoughts, hasIncludeThoughts := originalInteractionsIncludeThoughtsDoc(doc)
+ stripInteractionsThinkingFieldsDoc(doc)
+
switch config.Mode {
case thinking.ModeLevel:
- return applyInteractionsLevel(result, body, string(config.Level), modelInfo), nil
+ applyInteractionsLevelDoc(doc, string(config.Level), modelInfo, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
case thinking.ModeBudget:
- return applyInteractionsBudget(result, body, config.Budget, modelInfo), nil
+ applyInteractionsBudgetDoc(doc, config.Budget, modelInfo, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
case thinking.ModeAuto:
- return setInteractionsThinkingSummaries(result, body), nil
+ restoreInteractionsThinkingSummariesDoc(doc, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
case thinking.ModeNone:
- return applyInteractionsNone(result, body, config, modelInfo), nil
- default:
- return body, nil
+ applyInteractionsNoneDoc(doc, config, modelInfo, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
}
+ return nil
}
-func applyInteractionsBudget(result, original []byte, budget int, modelInfo *registry.ModelInfo) []byte {
+func applyInteractionsBudgetDoc(doc *ojson.Doc, budget int, modelInfo *registry.ModelInfo, summaries string, hasSummaries bool, includeThoughts, hasIncludeThoughts bool) {
level, ok := thinking.ConvertBudgetToLevel(budget)
if !ok {
- return setInteractionsThinkingSummaries(result, original)
+ restoreInteractionsThinkingSummariesDoc(doc, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
+ return
}
switch level {
case string(thinking.LevelNone), string(thinking.LevelAuto):
- // Thinking amount and summary visibility are independent. Interactions has
- // no wire-level "none" thinking level, so preserve only explicit summary
- // intent and otherwise let the target model use its documented default.
- return setInteractionsThinkingSummaries(result, original)
+ restoreInteractionsThinkingSummariesDoc(doc, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
default:
- return applyInteractionsLevel(result, original, level, modelInfo)
+ applyInteractionsLevelDoc(doc, level, modelInfo, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
}
}
-func applyInteractionsLevel(result, original []byte, level string, modelInfo *registry.ModelInfo) []byte {
+func applyInteractionsLevelDoc(doc *ojson.Doc, level string, modelInfo *registry.ModelInfo, summaries string, hasSummaries bool, includeThoughts, hasIncludeThoughts bool) {
level = normalizeInteractionsLevel(level, modelInfo)
if level != "" {
- result, _ = sjson.SetBytes(result, "generation_config.thinking_level", level)
+ _ = doc.Set("generation_config.thinking_level", level)
}
- return setInteractionsThinkingSummaries(result, original)
+ restoreInteractionsThinkingSummariesDoc(doc, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
}
-func applyInteractionsNone(result, original []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) []byte {
+func applyInteractionsNoneDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo, summaries string, hasSummaries bool, includeThoughts, hasIncludeThoughts bool) {
if config.Level != "" {
- return applyInteractionsLevel(result, original, string(config.Level), modelInfo)
+ applyInteractionsLevelDoc(doc, string(config.Level), modelInfo, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
+ return
}
if config.Budget > 0 {
- return applyInteractionsBudget(result, original, config.Budget, modelInfo)
+ applyInteractionsBudgetDoc(doc, config.Budget, modelInfo, summaries, hasSummaries, includeThoughts, hasIncludeThoughts)
+ return
}
- // With the amount fully disabled, visibility is irrelevant. Restoring
- // thinking_summaries alone could make a default-on model reason and return a
- // summary despite the explicit none override.
- return result
}
-func stripInteractionsThinkingFields(body []byte) []byte {
- result := body
+func stripInteractionsThinkingFieldsDoc(doc *ojson.Doc) {
for _, path := range []string{
"generation_config.thinking_level",
"generation_config.thinkingLevel",
@@ -102,33 +116,31 @@ func stripInteractionsThinkingFields(body []byte) []byte {
"generationConfig.thinking_summaries",
"generationConfig.thinkingConfig",
} {
- result, _ = sjson.DeleteBytes(result, path)
+ _ = util.DeleteJSONDocIfExists(doc, path)
}
- return result
}
-func setInteractionsThinkingSummaries(result, original []byte) []byte {
- if value, okValue := originalInteractionsThinkingSummaries(original); okValue {
- result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value)
- return result
+func restoreInteractionsThinkingSummariesDoc(doc *ojson.Doc, summaries string, hasSummaries bool, includeThoughts, hasIncludeThoughts bool) {
+ if hasSummaries {
+ _ = doc.Set("generation_config.thinking_summaries", summaries)
+ return
}
- if includeThoughts, okValue := originalInteractionsIncludeThoughts(original); okValue {
+ if hasIncludeThoughts {
value := "none"
if includeThoughts {
value = "auto"
}
- result, _ = sjson.SetBytes(result, "generation_config.thinking_summaries", value)
+ _ = doc.Set("generation_config.thinking_summaries", value)
}
- return result
}
-func originalInteractionsThinkingSummaries(body []byte) (string, bool) {
+func originalInteractionsThinkingSummariesDoc(doc *ojson.Doc) (string, bool) {
for _, path := range []string{
"generation_config.thinking_summaries",
"generation_config.thinkingSummaries",
} {
- value := gjson.GetBytes(body, path)
- if value.Type != gjson.String {
+ value := doc.Get(path)
+ if value.Type != ojson.String {
continue
}
switch normalized := strings.ToLower(strings.TrimSpace(value.String())); normalized {
@@ -139,17 +151,17 @@ func originalInteractionsThinkingSummaries(body []byte) (string, bool) {
return "", false
}
-func originalInteractionsIncludeThoughts(body []byte) (bool, bool) {
+func originalInteractionsIncludeThoughtsDoc(doc *ojson.Doc) (bool, bool) {
for _, path := range []string{
"generation_config.thinking_config.include_thoughts",
"generation_config.thinking_config.includeThoughts",
"generation_config.thinkingConfig.include_thoughts",
"generation_config.thinkingConfig.includeThoughts",
} {
- switch value := gjson.GetBytes(body, path); value.Type {
- case gjson.True:
+ switch value := doc.Get(path); value.Type {
+ case ojson.True:
return true, true
- case gjson.False:
+ case ojson.False:
return false, true
}
}
diff --git a/internal/thinking/provider/kimi/apply.go b/internal/thinking/provider/kimi/apply.go
index 95b0446e27e..7ad309a851e 100644
--- a/internal/thinking/provider/kimi/apply.go
+++ b/internal/thinking/provider/kimi/apply.go
@@ -1,29 +1,19 @@
// Package kimi implements thinking configuration for Kimi (Moonshot AI) models.
-//
-// Kimi models use a native thinking object for both enabled and disabled thinking.
-// The top-level reasoning_effort field is accepted only as a legacy input by the
-// unified extraction layer and is removed from the final Kimi payload.
package kimi
import (
"fmt"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// Applier implements thinking.ProviderApplier for Kimi models.
-//
-// Kimi-specific behavior:
-// - Enabled thinking: thinking.type="enabled" + thinking.effort=
-// - Disabled thinking: thinking.type="disabled"
-// - Supports budget-to-level conversion
-// - Preserves existing thinking.keep when enabling or changing effort
type Applier struct{}
-var _ thinking.ProviderApplier = (*Applier)(nil)
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
// NewApplier creates a new Kimi thinking applier.
func NewApplier() *Applier {
@@ -39,134 +29,120 @@ func init() {
}
// Apply applies thinking configuration to Kimi request body.
-//
-// Expected output format (enabled):
-//
-// {
-// "thinking": {
-// "type": "enabled",
-// "effort": "high"
-// }
-// }
-//
-// Expected output format (disabled):
-//
-// {
-// "thinking": {
-// "type": "disabled"
-// }
-// }
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
- if thinking.IsUserDefinedModel(modelInfo) {
- return applyCompatibleKimi(body, config)
+ if !thinking.IsUserDefinedModel(modelInfo) && modelInfo.Thinking == nil {
+ return body, nil
}
- if modelInfo.Thinking == nil {
+ effort, disable, ok := resolveKimiAction(config, modelInfo)
+ if !ok {
return body, nil
}
-
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
}
-
- var effort string
- switch config.Mode {
- case thinking.ModeLevel:
- if config.Level == "" {
- return body, nil
- }
- effort = string(config.Level)
- case thinking.ModeNone:
- // Respect clamped fallback level for models that cannot disable thinking.
- if config.Level != "" && config.Level != thinking.LevelNone {
- effort = string(config.Level)
- break
- }
- // Kimi requires explicit disabled thinking object.
- return applyDisabledThinking(body)
- case thinking.ModeBudget:
- // Convert budget to level using threshold mapping
- level, ok := thinking.ConvertBudgetToLevel(config.Budget)
- if !ok {
- return body, nil
- }
- effort = level
- case thinking.ModeAuto:
- // Auto mode maps to "auto" effort
- effort = string(thinking.LevelAuto)
- default:
- return body, nil
+ doc := ojson.NewDoc(body)
+ var err error
+ if disable {
+ err = applyDisabledThinkingDoc(doc)
+ } else {
+ err = applyEnabledThinkingDoc(doc, effort)
+ }
+ if err != nil {
+ return body, err
}
+ return doc.Bytes(), nil
+}
- if effort == "" {
- return body, nil
+// ApplyDoc applies thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
}
- return applyEnabledThinking(body, effort)
+ if !thinking.IsUserDefinedModel(modelInfo) && modelInfo.Thinking == nil {
+ return nil
+ }
+ effort, disable, ok := resolveKimiAction(config, modelInfo)
+ if !ok {
+ return nil
+ }
+ if disable {
+ return applyDisabledThinkingDoc(doc)
+ }
+ return applyEnabledThinkingDoc(doc, effort)
}
-// applyCompatibleKimi applies thinking config for user-defined Kimi models.
-func applyCompatibleKimi(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+func resolveKimiAction(config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) (effort string, disable bool, ok bool) {
+ if thinking.IsUserDefinedModel(modelInfo) {
+ switch config.Mode {
+ case thinking.ModeLevel:
+ if config.Level == "" {
+ return "", false, false
+ }
+ return string(config.Level), false, true
+ case thinking.ModeNone:
+ if config.Level == "" || config.Level == thinking.LevelNone {
+ return "", true, true
+ }
+ return string(config.Level), false, true
+ case thinking.ModeAuto:
+ return string(thinking.LevelAuto), false, true
+ case thinking.ModeBudget:
+ level, okConvert := thinking.ConvertBudgetToLevel(config.Budget)
+ if !okConvert {
+ return "", false, false
+ }
+ return level, false, true
+ default:
+ return "", false, false
+ }
}
- var effort string
switch config.Mode {
case thinking.ModeLevel:
if config.Level == "" {
- return body, nil
+ return "", false, false
}
- effort = string(config.Level)
+ return string(config.Level), false, true
case thinking.ModeNone:
- if config.Level == "" || config.Level == thinking.LevelNone {
- return applyDisabledThinking(body)
- }
- if config.Level != "" {
- effort = string(config.Level)
+ if config.Level != "" && config.Level != thinking.LevelNone {
+ return string(config.Level), false, true
}
- case thinking.ModeAuto:
- effort = string(thinking.LevelAuto)
+ return "", true, true
case thinking.ModeBudget:
- // Convert budget to level
- level, ok := thinking.ConvertBudgetToLevel(config.Budget)
- if !ok {
- return body, nil
+ level, okConvert := thinking.ConvertBudgetToLevel(config.Budget)
+ if !okConvert {
+ return "", false, false
}
- effort = level
+ return level, false, true
+ case thinking.ModeAuto:
+ return string(thinking.LevelAuto), false, true
default:
- return body, nil
+ return "", false, false
}
-
- return applyEnabledThinking(body, effort)
}
-func applyEnabledThinking(body []byte, effort string) ([]byte, error) {
- result, errDeleteLegacyEffort := sjson.DeleteBytes(body, "reasoning_effort")
- if errDeleteLegacyEffort != nil {
- return body, fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteLegacyEffort)
+func applyEnabledThinkingDoc(doc *ojson.Doc, effort string) error {
+ if errDeleteLegacyEffort := util.DeleteJSONDocIfExists(doc, "reasoning_effort"); errDeleteLegacyEffort != nil {
+ return fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteLegacyEffort)
}
- result, errSetType := sjson.SetBytes(result, "thinking.type", "enabled")
- if errSetType != nil {
- return body, fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType)
+ if errSetType := doc.Set("thinking.type", "enabled"); errSetType != nil {
+ return fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType)
}
- result, errSetEffort := sjson.SetBytes(result, "thinking.effort", effort)
- if errSetEffort != nil {
- return body, fmt.Errorf("kimi thinking: failed to set thinking.effort: %w", errSetEffort)
+ if errSetEffort := doc.Set("thinking.effort", effort); errSetEffort != nil {
+ return fmt.Errorf("kimi thinking: failed to set thinking.effort: %w", errSetEffort)
}
- return result, nil
+ return nil
}
-func applyDisabledThinking(body []byte) ([]byte, error) {
- result, errDeleteThinking := sjson.DeleteBytes(body, "thinking")
- if errDeleteThinking != nil {
- return body, fmt.Errorf("kimi thinking: failed to clear thinking object: %w", errDeleteThinking)
+func applyDisabledThinkingDoc(doc *ojson.Doc) error {
+ if errDeleteThinking := util.DeleteJSONDocIfExists(doc, "thinking"); errDeleteThinking != nil {
+ return fmt.Errorf("kimi thinking: failed to clear thinking object: %w", errDeleteThinking)
}
- result, errDeleteEffort := sjson.DeleteBytes(result, "reasoning_effort")
- if errDeleteEffort != nil {
- return body, fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteEffort)
+ if errDeleteEffort := util.DeleteJSONDocIfExists(doc, "reasoning_effort"); errDeleteEffort != nil {
+ return fmt.Errorf("kimi thinking: failed to clear reasoning_effort: %w", errDeleteEffort)
}
- result, errSetType := sjson.SetBytes(result, "thinking.type", "disabled")
- if errSetType != nil {
- return body, fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType)
+ if errSetType := doc.Set("thinking.type", "disabled"); errSetType != nil {
+ return fmt.Errorf("kimi thinking: failed to set thinking.type: %w", errSetType)
}
- return result, nil
+ return nil
}
diff --git a/internal/thinking/provider/openai/apply.go b/internal/thinking/provider/openai/apply.go
index 31955ccfca3..d57a542448b 100644
--- a/internal/thinking/provider/openai/apply.go
+++ b/internal/thinking/provider/openai/apply.go
@@ -1,26 +1,16 @@
// Package openai implements thinking configuration for OpenAI/Codex models.
-//
-// OpenAI models use the reasoning_effort format with discrete levels
-// (low/medium/high). Some models support xhigh and none levels.
-// See: _bmad-output/planning-artifacts/architecture.md#Epic-8
package openai
import (
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Applier implements thinking.ProviderApplier for OpenAI models.
-//
-// OpenAI-specific behavior:
-// - Output format: reasoning_effort (string: low/medium/high/xhigh)
-// - Level-only mode: no numeric budget support
-// - Some models support ZeroAllowed (gpt-5.1, gpt-5.2)
type Applier struct{}
-var _ thinking.ProviderApplier = (*Applier)(nil)
+var _ thinking.ProviderDocApplier = (*Applier)(nil)
// NewApplier creates a new OpenAI thinking applier.
func NewApplier() *Applier {
@@ -32,32 +22,65 @@ func init() {
}
// Apply applies thinking configuration to OpenAI request body.
-//
-// Expected output format:
-//
-// {
-// "reasoning_effort": "high"
-// }
func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) {
- if thinking.IsUserDefinedModel(modelInfo) {
- return applyCompatibleOpenAI(body, config)
- }
- if modelInfo.Thinking == nil {
+ effort, ok := resolveOpenAIEffort(config, modelInfo)
+ if !ok {
return body, nil
}
-
- // Only handle ModeLevel and ModeNone; other modes pass through unchanged.
- if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
- return body, nil
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ body = []byte("{}")
}
+ result, _ := ojson.SetBytes(body, "reasoning_effort", effort)
+ return result, nil
+}
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
+// ApplyDoc applies thinking configuration onto doc in place.
+func (a *Applier) ApplyDoc(doc *ojson.Doc, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) error {
+ if doc == nil {
+ return nil
}
+ effort, ok := resolveOpenAIEffort(config, modelInfo)
+ if !ok {
+ return nil
+ }
+ _ = doc.Set("reasoning_effort", effort)
+ return nil
+}
+func resolveOpenAIEffort(config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) (string, bool) {
+ if thinking.IsUserDefinedModel(modelInfo) {
+ switch config.Mode {
+ case thinking.ModeLevel:
+ if config.Level == "" {
+ return "", false
+ }
+ return string(config.Level), true
+ case thinking.ModeNone:
+ effort := string(thinking.LevelNone)
+ if config.Level != "" {
+ effort = string(config.Level)
+ }
+ return effort, true
+ case thinking.ModeAuto:
+ return string(thinking.LevelAuto), true
+ case thinking.ModeBudget:
+ level, ok := thinking.ConvertBudgetToLevel(config.Budget)
+ if !ok {
+ return "", false
+ }
+ return level, true
+ default:
+ return "", false
+ }
+ }
+ if modelInfo.Thinking == nil {
+ return "", false
+ }
+ if config.Mode != thinking.ModeLevel && config.Mode != thinking.ModeNone {
+ return "", false
+ }
if config.Mode == thinking.ModeLevel {
- result, _ := sjson.SetBytes(body, "reasoning_effort", string(config.Level))
- return result, nil
+ return string(config.Level), true
}
effort := ""
@@ -74,44 +97,7 @@ func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *
effort = support.Levels[0]
}
if effort == "" {
- return body, nil
- }
-
- result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
- return result, nil
-}
-
-func applyCompatibleOpenAI(body []byte, config thinking.ThinkingConfig) ([]byte, error) {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- body = []byte(`{}`)
- }
-
- var effort string
- switch config.Mode {
- case thinking.ModeLevel:
- if config.Level == "" {
- return body, nil
- }
- effort = string(config.Level)
- case thinking.ModeNone:
- effort = string(thinking.LevelNone)
- if config.Level != "" {
- effort = string(config.Level)
- }
- case thinking.ModeAuto:
- // Auto mode for user-defined models: pass through as "auto"
- effort = string(thinking.LevelAuto)
- case thinking.ModeBudget:
- // Budget mode: convert budget to level using threshold mapping
- level, ok := thinking.ConvertBudgetToLevel(config.Budget)
- if !ok {
- return body, nil
- }
- effort = level
- default:
- return body, nil
+ return "", false
}
-
- result, _ := sjson.SetBytes(body, "reasoning_effort", effort)
- return result, nil
+ return effort, true
}
diff --git a/internal/thinking/provider/xai/apply.go b/internal/thinking/provider/xai/apply.go
index 21a540a662e..9920cd42aae 100644
--- a/internal/thinking/provider/xai/apply.go
+++ b/internal/thinking/provider/xai/apply.go
@@ -14,7 +14,10 @@ type Applier struct {
codex.Applier
}
-var _ thinking.ProviderApplier = (*Applier)(nil)
+var (
+ _ thinking.ProviderApplier = (*Applier)(nil)
+ _ thinking.ProviderDocApplier = (*Applier)(nil)
+)
// NewApplier creates a new xAI thinking applier.
func NewApplier() *Applier {
diff --git a/internal/thinking/strip.go b/internal/thinking/strip.go
index 8263cd594df..17676edca2f 100644
--- a/internal/thinking/strip.go
+++ b/internal/thinking/strip.go
@@ -2,8 +2,10 @@
package thinking
import (
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "strings"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// StripThinkingConfig removes thinking configuration fields from request body.
@@ -23,14 +25,39 @@ import (
// - provider is unknown
// - no thinking configuration found
func StripThinkingConfig(body []byte, provider string) []byte {
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ return body
+ }
+ doc := ojson.NewDoc(body)
+ if !stripThinkingFieldsDoc(doc, provider) {
return body
}
+ return doc.Bytes()
+}
+
+// StripThinkingFieldsDoc removes thinking configuration fields from doc in place.
+func StripThinkingFieldsDoc(doc *ojson.Doc, format string, keepTopLevelEffort ...bool) {
+ _ = stripThinkingFieldsDoc(doc, format, keepTopLevelEffort...)
+}
+
+func stripThinkingFieldsDoc(doc *ojson.Doc, format string, keepTopLevelEffort ...bool) bool {
+ if doc == nil {
+ return false
+ }
+ provider := strings.ToLower(strings.TrimSpace(format))
+ if provider == "openai-response" {
+ provider = "codex"
+ }
+ keepEffort := len(keepTopLevelEffort) > 0 && keepTopLevelEffort[0]
var paths []string
switch provider {
case "claude":
- paths = []string{"thinking", "output_config.effort"}
+ if keepEffort {
+ paths = []string{"thinking"}
+ } else {
+ paths = []string{"thinking", "output_config.effort"}
+ }
case "gemini":
paths = []string{"generationConfig.thinkingConfig"}
case "antigravity":
@@ -47,28 +74,41 @@ func StripThinkingConfig(body []byte, provider string) []byte {
"generation_config.thinkingConfig",
}
case "openai":
- paths = []string{"reasoning_effort", "reasoning"}
+ if keepEffort {
+ paths = []string{"reasoning"}
+ } else {
+ paths = []string{"reasoning_effort", "reasoning"}
+ }
case "kimi", "kimi-ai", "kimi.ai", "kimi.com":
- paths = []string{
- "reasoning_effort",
- "thinking",
+ if keepEffort {
+ paths = []string{"thinking"}
+ } else {
+ paths = []string{"reasoning_effort", "thinking"}
}
case "codex", "xai":
- paths = []string{"reasoning"}
+ if !keepEffort {
+ paths = []string{"reasoning"}
+ }
default:
- return body
+ return false
}
- result := body
+ modified := false
for _, path := range paths {
- result, _ = sjson.DeleteBytes(result, path)
+ if doc.Get(path).Exists() {
+ if doc.Delete(path) == nil {
+ modified = true
+ }
+ }
}
// Avoid leaving an empty output_config object for Claude when effort was the only field.
- if provider == "claude" {
- if oc := gjson.GetBytes(result, "output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
- result, _ = sjson.DeleteBytes(result, "output_config")
+ if provider == "claude" && !keepEffort {
+ if oc := doc.Get("output_config"); oc.Exists() && oc.IsObject() && len(oc.Map()) == 0 {
+ if util.DeleteJSONDocIfExists(doc, "output_config") == nil {
+ modified = true
+ }
}
}
- return result
+ return modified
}
diff --git a/internal/thinking/summary.go b/internal/thinking/summary.go
index 6f0c6c24d73..78d3862afd9 100644
--- a/internal/thinking/summary.go
+++ b/internal/thinking/summary.go
@@ -3,10 +3,9 @@ package thinking
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// SummaryMode represents whether the client explicitly requested reasoning summaries.
@@ -35,16 +34,34 @@ type SummaryConfig struct {
func ExtractSummaryConfig(body []byte, format string) SummaryConfig {
normalized := strings.ToLower(strings.TrimSpace(format))
// Check the format first so unsupported targets skip whole-body validation.
- if !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) {
+ if !summaryFormatSupported(normalized) || len(body) == 0 || !ojson.ValidBytes(body) {
return SummaryConfig{}
}
+ return extractSummaryConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ }, normalized)
+}
+
+// ExtractSummaryConfigDoc reads protocol-specific summary visibility intent from doc.
+func ExtractSummaryConfigDoc(doc *ojson.Doc, format string) SummaryConfig {
+ return extractSummaryConfigDoc(doc, true, format)
+}
+
+func extractSummaryConfigDoc(doc *ojson.Doc, docValid bool, format string) SummaryConfig {
+ normalized := strings.ToLower(strings.TrimSpace(format))
+ if doc == nil || !docValid || !summaryFormatSupported(normalized) {
+ return SummaryConfig{}
+ }
+ return extractSummaryConfigFromGetter(doc.Get, normalized)
+}
+func extractSummaryConfigFromGetter(get func(string) ojson.Result, normalized string) SummaryConfig {
switch normalized {
case "openai":
- if config, ok := extractOpenAIExplicitSummaryConfig(body); ok {
+ if config, ok := extractOpenAIExplicitSummaryConfigFromGetter(get); ok {
return config
}
- if effort := gjson.GetBytes(body, "reasoning_effort"); effort.Type == gjson.String {
+ if effort := get("reasoning_effort"); effort.Type == ojson.String {
value := strings.ToLower(strings.TrimSpace(effort.String()))
if value == "" {
return SummaryConfig{}
@@ -55,22 +72,22 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig {
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}
}
case "openai-response", "codex":
- if config, ok := responsesSummaryConfig(body, "reasoning.summary"); ok {
+ if config, ok := responsesSummaryConfigFromGetter(get, "reasoning.summary"); ok {
return config
}
- if config, ok := responsesSummaryConfig(body, "reasoning.generate_summary"); ok {
+ if config, ok := responsesSummaryConfigFromGetter(get, "reasoning.generate_summary"); ok {
return config
}
case "claude":
// Anthropic only accepts display alongside active adaptive/manual thinking.
- if !claudeThinkingAcceptsDisplay(body) {
+ if !claudeThinkingAcceptsDisplayFromGetter(get) {
return SummaryConfig{}
}
- if config, ok := claudeSummaryConfig(body, "thinking.display"); ok {
+ if config, ok := claudeSummaryConfigFromGetter(get, "thinking.display"); ok {
return config
}
case "gemini":
- if config, ok := firstSummaryBoolConfig(body, []string{
+ if config, ok := firstSummaryBoolConfigFromGetter(get, []string{
"generationConfig.thinkingConfig.includeThoughts",
"generationConfig.thinkingConfig.include_thoughts",
"generation_config.thinking_config.include_thoughts",
@@ -79,7 +96,7 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig {
return config
}
case "antigravity":
- if config, ok := firstSummaryBoolConfig(body, []string{
+ if config, ok := firstSummaryBoolConfigFromGetter(get, []string{
"request.generationConfig.thinkingConfig.includeThoughts",
"request.generationConfig.thinkingConfig.include_thoughts",
"request.generationConfig.thinking_config.includeThoughts",
@@ -92,17 +109,17 @@ func ExtractSummaryConfig(body []byte, format string) SummaryConfig {
"generation_config.thinking_summaries",
"generation_config.thinkingSummaries",
} {
- if config, ok := interactionsSummaryConfig(body, path); ok {
+ if config, ok := interactionsSummaryConfigFromGetter(get, path); ok {
return config
}
}
// Existing Interactions translators accept the OpenAI-style top-level
// compatibility object. Keep the official generation_config selector
// authoritative when both are present.
- if config, ok := interactionsSummaryConfig(body, "reasoning.summary"); ok {
+ if config, ok := interactionsSummaryConfigFromGetter(get, "reasoning.summary"); ok {
return config
}
- if config, ok := firstSummaryBoolConfig(body, []string{
+ if config, ok := firstSummaryBoolConfigFromGetter(get, []string{
"generation_config.thinking_config.include_thoughts",
"generation_config.thinking_config.includeThoughts",
"generation_config.thinkingConfig.include_thoughts",
@@ -124,10 +141,25 @@ func ExtractExplicitSummaryConfig(body []byte, format string) SummaryConfig {
if normalized != "openai" {
return ExtractSummaryConfig(body, normalized)
}
- if len(body) == 0 || !gjson.ValidBytes(body) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
return SummaryConfig{}
}
- config, _ := extractOpenAIExplicitSummaryConfig(body)
+ config, _ := extractOpenAIExplicitSummaryConfigFromGetter(func(path string) ojson.Result {
+ return ojson.GetBytesNoCopy(body, path)
+ })
+ return config
+}
+
+// ExtractExplicitSummaryConfigDoc reads only explicit visibility controls from doc.
+func ExtractExplicitSummaryConfigDoc(doc *ojson.Doc, format string) SummaryConfig {
+ normalized := strings.ToLower(strings.TrimSpace(format))
+ if normalized != "openai" {
+ return ExtractSummaryConfigDoc(doc, normalized)
+ }
+ if doc == nil {
+ return SummaryConfig{}
+ }
+ config, _ := extractOpenAIExplicitSummaryConfigFromGetter(doc.Get)
return config
}
@@ -166,6 +198,89 @@ func ApplySummaryConfigForModel(body []byte, format, model string, config Summar
return applySummaryConfigForModel(body, format, model, nil, config)
}
+// ApplySummaryConfigForModelDoc writes canonical summary intent onto doc in place.
+func ApplySummaryConfigForModelDoc(doc *ojson.Doc, format, model string, config SummaryConfig) {
+ _ = applySummaryConfigForProviderDoc(doc, true, format, model, "", nil, config)
+}
+
+// CanRepresentSummaryConfigForModelDoc reports whether ApplySummaryConfigForModelDoc
+// would produce an explicit summary control on doc, without mutating or serializing doc.
+func CanRepresentSummaryConfigForModelDoc(doc *ojson.Doc, format, model string, config SummaryConfig) bool {
+ normalized := strings.ToLower(strings.TrimSpace(format))
+ if doc == nil || doc.Len() == 0 || config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) {
+ return false
+ }
+ enabled := config.Mode == SummaryEnabled
+ switch normalized {
+ case "gemini", "antigravity", "interactions":
+ return true
+ case "openai-response", "codex":
+ return enabled
+ case "openai":
+ return doc.Get("reasoning.exclude").IsBool() || doc.Get("include_reasoning").IsBool() ||
+ doc.Get("reasoning.enabled").IsBool() || extractExplicitSummaryNonBoolOpenAIFromGetter(doc.Get)
+ case "claude":
+ if claudeThinkingAcceptsDisplayFromGetter(doc.Get) {
+ return true
+ }
+ if !enabled || doc.Get("thinking.type").Exists() {
+ return false
+ }
+ baseModel := ParseSuffix(model).ModelName
+ if baseModel == "" {
+ baseModel = ParseSuffix(doc.Get("model").String()).ModelName
+ }
+ modelInfo := registry.LookupModelInfo(baseModel, "claude")
+ if modelInfo == nil || modelInfo.Thinking == nil {
+ return false
+ }
+ if len(modelInfo.Thinking.Levels) > 0 {
+ return true
+ }
+ budget := modelInfo.Thinking.Min
+ if budget <= 0 {
+ return false
+ }
+ if maxTokens := doc.Get("max_tokens"); maxTokens.Exists() && maxTokens.Int() <= int64(budget) {
+ return false
+ }
+ return true
+ default:
+ return false
+ }
+}
+
+func extractExplicitSummaryNonBoolOpenAIFromGetter(get func(string) ojson.Result) bool {
+ for _, path := range []string{
+ "extra_body.google.thinking_config.include_thoughts",
+ "extra_body.google.thinking_config.includeThoughts",
+ "extra_body.google.thinkingConfig.include_thoughts",
+ "extra_body.google.thinkingConfig.includeThoughts",
+ "extra_body.extra_body.google.thinking_config.include_thoughts",
+ "extra_body.extra_body.google.thinking_config.includeThoughts",
+ "google.thinking_config.include_thoughts",
+ "google.thinking_config.includeThoughts",
+ "thinking.includeThoughts",
+ "thinking.include_thoughts",
+ "reasoning.includeThoughts",
+ "reasoning.include_thoughts",
+ "generationConfig.thinkingConfig.includeThoughts",
+ "generationConfig.thinkingConfig.include_thoughts",
+ "generation_config.thinking_config.include_thoughts",
+ "generation_config.thinking_config.includeThoughts",
+ } {
+ if _, ok := summaryBoolConfigFromGetter(get, path); ok {
+ return true
+ }
+ }
+ for _, path := range []string{"reasoning.summary", "reasoning.generate_summary"} {
+ if _, ok := responsesSummaryConfigFromGetter(get, path); ok {
+ return true
+ }
+ }
+ return false
+}
+
// applySummaryConfigForModel uses the resolved model definition when execution
// selected a configured API-key model whose capability is not globally visible.
func applySummaryConfigForModel(body []byte, format, model string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte {
@@ -176,57 +291,68 @@ func applySummaryConfigForModel(body []byte, format, model string, modelInfo *re
// dialects whose visibility controls are not part of the OpenAI wire format.
func applySummaryConfigForProvider(body []byte, format, model, provider string, modelInfo *registry.ModelInfo, config SummaryConfig) []byte {
normalized := strings.ToLower(strings.TrimSpace(format))
- if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !gjson.ValidBytes(body) {
+ if config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) || len(body) == 0 || !ojson.ValidBytes(body) {
return body
}
+ doc := ojson.NewDoc(body)
+ if !applySummaryConfigForProviderDoc(doc, true, normalized, model, provider, modelInfo, config) {
+ return body
+ }
+ return doc.Bytes()
+}
+
+func applySummaryConfigForProviderDoc(doc *ojson.Doc, docValid bool, format, model, provider string, modelInfo *registry.ModelInfo, config SummaryConfig) bool {
+ normalized := strings.ToLower(strings.TrimSpace(format))
+ if doc == nil || !docValid || config.Mode == SummaryUnspecified || !summaryFormatSupported(normalized) {
+ return false
+ }
enabled := config.Mode == SummaryEnabled
+ modified := false
switch normalized {
case "openai":
- body = applyOpenAIChatSummaryConfig(body, provider, enabled)
+ return applyOpenAIChatSummaryConfigDoc(doc, provider, enabled)
case "claude":
- // Anthropic documents display as invalid with thinking.type=disabled and
- // requires it alongside adaptive or enabled thinking. Model defaults differ:
- // Opus 5 and Sonnet 5 default to adaptive thinking; Fable/Mythos 5 are always
- // on. Opus 4.8/4.7/4.6, Sonnet 4.6, and the 4.5 models default to thinking
- // off. The newest models also default display to omitted. Keeping a missing
- // thinking block absent therefore preserves both kinds of model default;
- // absence does not mean every Claude model runs without thinking. Only an
- // enabled summary may activate a valid target thinking mode so that summarized
- // text can be returned. A disabled summary only adds omitted to an
- // already-active target mode.
- //
- // Anthropic docs:
- // https://platform.claude.com/docs/en/build-with-claude/thinking
- // https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models
- if enabled && !gjson.GetBytes(body, "thinking.type").Exists() {
- body = enableClaudeThinkingForSummary(body, model, modelInfo)
- }
- if !claudeThinkingAcceptsDisplay(body) {
- return body
+ if enabled && !doc.Get("thinking.type").Exists() {
+ if enableClaudeThinkingForSummaryDoc(doc, model, modelInfo) {
+ modified = true
+ }
+ }
+ if !claudeThinkingAcceptsDisplayFromGetter(doc.Get) {
+ return modified
}
value := "omitted"
if enabled {
value = "summarized"
}
- body, _ = sjson.SetBytes(body, "thinking.display", value)
+ if doc.Set("thinking.display", value) == nil {
+ modified = true
+ }
case "gemini":
- body, _ = sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", enabled)
+ if doc.Set("generationConfig.thinkingConfig.includeThoughts", enabled) == nil {
+ modified = true
+ }
for _, path := range []string{
"generationConfig.thinkingConfig.include_thoughts",
"generation_config.thinking_config.include_thoughts",
"generation_config.thinking_config.includeThoughts",
} {
- body, _ = util.DeleteJSONBytesIfExists(body, path)
+ if doc.Get(path).Exists() && doc.Delete(path) == nil {
+ modified = true
+ }
}
case "antigravity":
- body, _ = sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", enabled)
+ if doc.Set("request.generationConfig.thinkingConfig.includeThoughts", enabled) == nil {
+ modified = true
+ }
for _, path := range []string{
"request.generationConfig.thinkingConfig.include_thoughts",
- "request.generationConfig.thinking_config.include_thoughts",
"request.generationConfig.thinking_config.includeThoughts",
+ "request.generationConfig.thinking_config.include_thoughts",
} {
- body, _ = util.DeleteJSONBytesIfExists(body, path)
+ if doc.Get(path).Exists() && doc.Delete(path) == nil {
+ modified = true
+ }
}
case "interactions":
// Google Interactions only accepts auto or none. OpenAI's concise and
@@ -235,23 +361,37 @@ func applySummaryConfigForProvider(body []byte, format, model, provider string,
if enabled {
value = "auto"
}
- body, _ = sjson.SetBytes(body, "generation_config.thinking_summaries", value)
- body, _ = sjson.DeleteBytes(body, "generation_config.thinkingSummaries")
+ if doc.Set("generation_config.thinking_summaries", value) == nil {
+ modified = true
+ }
+ if doc.Get("generation_config.thinkingSummaries").Exists() && doc.Delete("generation_config.thinkingSummaries") == nil {
+ modified = true
+ }
case "openai-response", "codex":
if enabled {
- body, _ = sjson.SetBytes(body, "reasoning.summary", normalizedSummaryDetail(config.Detail))
- body, _ = sjson.DeleteBytes(body, "reasoning.generate_summary")
+ if doc.Set("reasoning.summary", normalizedSummaryDetail(config.Detail)) == nil {
+ modified = true
+ }
+ if doc.Get("reasoning.generate_summary").Exists() && doc.Delete("reasoning.generate_summary") == nil {
+ modified = true
+ }
break
}
// Omitting the field is the documented way to disable summaries; an
// explicit null is not accepted by every Responses-compatible backend.
- body, _ = util.DeleteJSONBytesIfExists(body, "reasoning.summary")
- body, _ = util.DeleteJSONBytesIfExists(body, "reasoning.generate_summary")
- if reasoning := gjson.GetBytes(body, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
- body, _ = sjson.DeleteBytes(body, "reasoning")
+ if doc.Get("reasoning.summary").Exists() && util.DeleteJSONDocIfExists(doc, "reasoning.summary") == nil {
+ modified = true
+ }
+ if doc.Get("reasoning.generate_summary").Exists() && util.DeleteJSONDocIfExists(doc, "reasoning.generate_summary") == nil {
+ modified = true
+ }
+ if reasoning := doc.Get("reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
+ if doc.Delete("reasoning") == nil {
+ modified = true
+ }
}
}
- return body
+ return modified
}
// summaryFormatSupported reports whether a protocol carries summary visibility
@@ -265,18 +405,13 @@ func summaryFormatSupported(format string) bool {
}
}
-// claudeThinkingAcceptsDisplay reports whether the body carries an active
-// thinking block that can hold a display field.
-func claudeThinkingAcceptsDisplay(body []byte) bool {
- switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String())) {
+func claudeThinkingAcceptsDisplayFromGetter(get func(string) ojson.Result) bool {
+ switch strings.ToLower(strings.TrimSpace(get("thinking.type").String())) {
case "adaptive":
return true
case "enabled":
- // This runs before ApplyThinking normalizes the request, so a missing
- // budget_tokens is an unfinished body rather than inactive thinking. CPA
- // also accepts -1 as its compatibility representation for auto thinking.
- budget := gjson.GetBytes(body, "thinking.budget_tokens")
- if budget.Type != gjson.Number {
+ budget := get("thinking.budget_tokens")
+ if budget.Type != ojson.Number {
return true
}
value := budget.Int()
@@ -286,30 +421,19 @@ func claudeThinkingAcceptsDisplay(body []byte) bool {
}
}
-// applyOpenAIChatSummaryConfig writes only documented Chat visibility controls.
-//
-// OpenAI Chat Completions exposes reasoning_effort but no reasoning summary or
-// visibility parameter. DeepSeek and Kimi Chat return reasoning_content while
-// thinking is active, but likewise document no independent hide/show switch.
-// Summary intent must therefore never invent or overwrite thinking effort for
-// those dialects. OpenRouter is the exception: reasoning.exclude is its
-// documented "reason but hide" control, and include_reasoning is its deprecated
-// inverse alias. Unknown OpenAI-compatible providers are handled conservatively
-// by updating those fields only when the payload already carries them.
-//
-// Docs:
-// https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
-// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens
-// https://api-docs.deepseek.com/guides/thinking_mode
-// https://platform.kimi.ai/docs/api/chat
-func applyOpenAIChatSummaryConfig(body []byte, provider string, enabled bool) []byte {
- if isOpenRouterProvider(provider) || gjson.GetBytes(body, "reasoning.exclude").IsBool() {
- body, _ = sjson.SetBytes(body, "reasoning.exclude", !enabled)
- }
- if gjson.GetBytes(body, "include_reasoning").IsBool() {
- body, _ = sjson.SetBytes(body, "include_reasoning", enabled)
- }
- return body
+func applyOpenAIChatSummaryConfigDoc(doc *ojson.Doc, provider string, enabled bool) bool {
+ modified := false
+ if isOpenRouterProvider(provider) || doc.Get("reasoning.exclude").IsBool() {
+ if doc.Set("reasoning.exclude", !enabled) == nil {
+ modified = true
+ }
+ }
+ if doc.Get("include_reasoning").IsBool() {
+ if doc.Set("include_reasoning", enabled) == nil {
+ modified = true
+ }
+ }
+ return modified
}
func isOpenRouterProvider(provider string) bool {
@@ -327,10 +451,7 @@ func isOpenRouterProvider(provider string) bool {
return false
}
-func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) {
- // Google's documented Chat Completions extension is the authoritative
- // explicit visibility control when present, ahead of CPA compatibility
- // aliases and Chat's reasoning_effort fallback.
+func extractOpenAIExplicitSummaryConfigFromGetter(get func(string) ojson.Result) (SummaryConfig, bool) {
for _, path := range []string{
"extra_body.google.thinking_config.include_thoughts",
"extra_body.google.thinking_config.includeThoughts",
@@ -349,7 +470,7 @@ func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) {
"generation_config.thinking_config.include_thoughts",
"generation_config.thinking_config.includeThoughts",
} {
- if config, ok := summaryBoolConfig(body, path); ok {
+ if config, ok := summaryBoolConfigFromGetter(get, path); ok {
return config, true
}
}
@@ -358,30 +479,24 @@ func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) {
"reasoning.summary",
"reasoning.generate_summary",
} {
- if config, ok := responsesSummaryConfig(body, path); ok {
+ if config, ok := responsesSummaryConfigFromGetter(get, path); ok {
return config, true
}
}
- // reasoning.exclude is OpenRouter's documented "reason but hide" bit, not an
- // OpenAI wire field; include_reasoning is its documented legacy alias
- // (include_reasoning: false is equivalent to reasoning: {exclude: true}).
- // Only accept actual JSON booleans.
- if exclude := gjson.GetBytes(body, "reasoning.exclude"); exclude.IsBool() {
+ if exclude := get("reasoning.exclude"); exclude.IsBool() {
if exclude.Bool() {
return SummaryConfig{Mode: SummaryDisabled}, true
}
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
}
- if include := gjson.GetBytes(body, "include_reasoning"); include.IsBool() {
+ if include := get("include_reasoning"); include.IsBool() {
if include.Bool() {
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
}
return SummaryConfig{Mode: SummaryDisabled}, true
}
- // OpenRouter's reasoning.enabled turns reasoning on "with no exclusions", so
- // it also decides visibility when no dedicated bit was sent.
- if enabled := gjson.GetBytes(body, "reasoning.enabled"); enabled.IsBool() {
+ if enabled := get("reasoning.enabled"); enabled.IsBool() {
if enabled.Bool() {
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
}
@@ -390,35 +505,35 @@ func extractOpenAIExplicitSummaryConfig(body []byte) (SummaryConfig, bool) {
return SummaryConfig{}, false
}
-func firstSummaryBoolConfig(body []byte, paths []string) (SummaryConfig, bool) {
+func firstSummaryBoolConfigFromGetter(get func(string) ojson.Result, paths []string) (SummaryConfig, bool) {
for _, path := range paths {
- if config, ok := summaryBoolConfig(body, path); ok {
+ if config, ok := summaryBoolConfigFromGetter(get, path); ok {
return config, true
}
}
return SummaryConfig{}, false
}
-func summaryBoolConfig(body []byte, path string) (SummaryConfig, bool) {
- switch value := gjson.GetBytes(body, path); value.Type {
- case gjson.True:
+func summaryBoolConfigFromGetter(get func(string) ojson.Result, path string) (SummaryConfig, bool) {
+ switch value := get(path); value.Type {
+ case ojson.True:
return SummaryConfig{Mode: SummaryEnabled, Detail: "auto"}, true
- case gjson.False:
+ case ojson.False:
return SummaryConfig{Mode: SummaryDisabled}, true
default:
return SummaryConfig{}, false
}
}
-func responsesSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
- value := gjson.GetBytes(body, path)
+func responsesSummaryConfigFromGetter(get func(string) ojson.Result, path string) (SummaryConfig, bool) {
+ value := get(path)
if value.Raw == "" {
return SummaryConfig{}, false
}
- if value.Type == gjson.Null {
+ if value.Type == ojson.Null {
return SummaryConfig{Mode: SummaryDisabled}, true
}
- if value.Type != gjson.String {
+ if value.Type != ojson.String {
return SummaryConfig{}, false
}
@@ -427,17 +542,15 @@ func responsesSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
case "auto", "concise", "detailed":
return SummaryConfig{Mode: SummaryEnabled, Detail: raw}, true
case "none":
- // Compatibility with clients that expose a none enum; the OpenAI wire
- // representation disables summaries by omitting the field.
return SummaryConfig{Mode: SummaryDisabled}, true
default:
return SummaryConfig{}, false
}
}
-func claudeSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
- value := gjson.GetBytes(body, path)
- if value.Type != gjson.String {
+func claudeSummaryConfigFromGetter(get func(string) ojson.Result, path string) (SummaryConfig, bool) {
+ value := get(path)
+ if value.Type != ojson.String {
return SummaryConfig{}, false
}
switch strings.ToLower(strings.TrimSpace(value.String())) {
@@ -450,9 +563,9 @@ func claudeSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
}
}
-func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
- value := gjson.GetBytes(body, path)
- if value.Type != gjson.String {
+func interactionsSummaryConfigFromGetter(get func(string) ojson.Result, path string) (SummaryConfig, bool) {
+ value := get(path)
+ if value.Type != ojson.String {
return SummaryConfig{}, false
}
switch strings.ToLower(strings.TrimSpace(value.String())) {
@@ -465,63 +578,64 @@ func interactionsSummaryConfig(body []byte, path string) (SummaryConfig, bool) {
}
}
-// stripInferredClaudeSummaryActivation removes a globally inferred adaptive
-// mode when the selected API-key model supports only manual extended thinking.
-// The exact model-aware summary pass can then activate enabled thinking with a
-// valid budget, or leave thinking absent when max_tokens cannot accommodate it.
-func stripInferredClaudeSummaryActivation(body []byte, modelInfo *registry.ModelInfo) []byte {
- if modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) > 0 || modelInfo.Thinking.Min <= 0 {
- return body
+func stripInferredClaudeSummaryActivationDoc(doc *ojson.Doc, docValid bool, modelInfo *registry.ModelInfo) bool {
+ if doc == nil || !docValid || modelInfo == nil || modelInfo.Thinking == nil || len(modelInfo.Thinking.Levels) > 0 || modelInfo.Thinking.Min <= 0 {
+ return false
}
- if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "thinking.type").String()), "adaptive") {
- return body
+ if !strings.EqualFold(strings.TrimSpace(doc.Get("thinking.type").String()), "adaptive") {
+ return false
}
+ modified := false
for _, path := range []string{
"thinking.type",
"thinking.budget_tokens",
"thinking.display",
"output_config.effort",
} {
- body, _ = util.DeleteJSONBytesIfExists(body, path)
+ if doc.Get(path).Exists() && util.DeleteJSONDocIfExists(doc, path) == nil {
+ modified = true
+ }
}
for _, path := range []string{"thinking", "output_config"} {
- if object := gjson.GetBytes(body, path); object.Exists() && object.IsObject() && len(object.Map()) == 0 {
- body, _ = sjson.DeleteBytes(body, path)
+ if object := doc.Get(path); object.Exists() && object.IsObject() && len(object.Map()) == 0 {
+ if doc.Delete(path) == nil {
+ modified = true
+ }
}
}
- return body
+ return modified
}
-func enableClaudeThinkingForSummary(body []byte, model string, resolvedModelInfo *registry.ModelInfo) []byte {
+func enableClaudeThinkingForSummaryDoc(doc *ojson.Doc, model string, resolvedModelInfo *registry.ModelInfo) bool {
modelInfo := resolvedModelInfo
if modelInfo == nil {
baseModel := ParseSuffix(model).ModelName
if baseModel == "" {
- baseModel = ParseSuffix(gjson.GetBytes(body, "model").String()).ModelName
+ baseModel = ParseSuffix(doc.Get("model").String()).ModelName
}
modelInfo = registry.LookupModelInfo(baseModel, "claude")
}
if modelInfo == nil || modelInfo.Thinking == nil {
- return body
+ return false
}
if len(modelInfo.Thinking.Levels) > 0 {
- body, _ = sjson.SetBytes(body, "thinking.type", "adaptive")
- body, _ = sjson.DeleteBytes(body, "thinking.budget_tokens")
- return body
+ _ = doc.Set("thinking.type", "adaptive")
+ _ = util.DeleteJSONDocIfExists(doc, "thinking.budget_tokens")
+ return true
}
budget := modelInfo.Thinking.Min
if budget <= 0 {
- return body
+ return false
}
- if maxTokens := gjson.GetBytes(body, "max_tokens"); maxTokens.Exists() && maxTokens.Int() <= int64(budget) {
- return body
+ if maxTokens := doc.Get("max_tokens"); maxTokens.Exists() && maxTokens.Int() <= int64(budget) {
+ return false
}
- body, _ = sjson.SetBytes(body, "thinking.type", "enabled")
- body, _ = sjson.SetBytes(body, "thinking.budget_tokens", budget)
- return body
+ _ = doc.Set("thinking.type", "enabled")
+ _ = doc.Set("thinking.budget_tokens", budget)
+ return true
}
func normalizedSummaryDetail(detail string) string {
diff --git a/internal/thinking/summary_test.go b/internal/thinking/summary_test.go
index e038fc1464f..ba18c9d45ac 100644
--- a/internal/thinking/summary_test.go
+++ b/internal/thinking/summary_test.go
@@ -4,7 +4,7 @@ import (
"bytes"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestExtractSummaryConfig(t *testing.T) {
@@ -123,7 +123,7 @@ func TestApplySummaryConfig(t *testing.T) {
body = `{}`
}
out := ApplySummaryConfig([]byte(body), test.format, test.config)
- if got := gjson.GetBytes(out, test.path).String(); got != test.want {
+ if got := ojson.GetBytes(out, test.path).String(); got != test.want {
t.Fatalf("%s = %q, want %q; body=%s", test.path, got, test.want, out)
}
})
@@ -151,14 +151,14 @@ func TestApplySummaryConfig_OpenAIChatProviderDialects(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out := applySummaryConfigForProvider([]byte(test.body), "openai", "model", test.provider, nil, SummaryConfig{Mode: test.mode})
- exclude := gjson.GetBytes(out, "reasoning.exclude")
+ exclude := ojson.GetBytes(out, "reasoning.exclude")
if exclude.Exists() != test.wantExisting {
t.Fatalf("reasoning.exclude exists = %v, want %v; body=%s", exclude.Exists(), test.wantExisting, out)
}
if test.wantExisting && exclude.String() != test.wantExclude {
t.Fatalf("reasoning.exclude = %q, want %q; body=%s", exclude.String(), test.wantExclude, out)
}
- effort := gjson.GetBytes(out, "reasoning_effort")
+ effort := ojson.GetBytes(out, "reasoning_effort")
if test.wantEffort == "" {
if effort.Exists() {
t.Fatalf("summary visibility invented reasoning_effort: %s", out)
@@ -183,10 +183,10 @@ func TestApplySummaryConfigNormalizesTargetAliases(t *testing.T) {
}
for _, test := range tests {
out := ApplySummaryConfig([]byte(test.body), test.format, SummaryConfig{Mode: SummaryEnabled})
- if !gjson.GetBytes(out, test.canonical).Exists() {
+ if !ojson.GetBytes(out, test.canonical).Exists() {
t.Fatalf("%s missing canonical field: %s", test.format, out)
}
- if gjson.GetBytes(out, test.alias).Exists() {
+ if ojson.GetBytes(out, test.alias).Exists() {
t.Fatalf("%s retained alias %s: %s", test.format, test.alias, out)
}
}
@@ -203,7 +203,7 @@ func TestApplySummaryConfig_ClaudeDisplayRequiresActiveThinking(t *testing.T) {
for _, mode := range []SummaryMode{SummaryEnabled, SummaryDisabled} {
for _, body := range bodies {
out := ApplySummaryConfig([]byte(body), "claude", SummaryConfig{Mode: mode})
- if gjson.GetBytes(out, "thinking.display").Exists() {
+ if ojson.GetBytes(out, "thinking.display").Exists() {
t.Fatalf("mode %v wrote display without active thinking: %s", mode, out)
}
if !bytes.Equal(out, []byte(body)) {
@@ -227,14 +227,14 @@ func TestApplySummaryConfigForModel_ClaudeEnabledSummaryUsesValidThinkingMode(t
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out := ApplySummaryConfigForModel([]byte(test.body), "claude", test.model, SummaryConfig{Mode: SummaryEnabled})
- if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType {
+ if got := ojson.GetBytes(out, "thinking.type").String(); got != test.wantType {
t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out)
}
- if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
+ if got := ojson.GetBytes(out, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
}
- if test.wantBudget > 0 && gjson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget {
- t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", gjson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out)
+ if test.wantBudget > 0 && ojson.GetBytes(out, "thinking.budget_tokens").Int() != test.wantBudget {
+ t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", ojson.GetBytes(out, "thinking.budget_tokens").Int(), test.wantBudget, out)
}
})
}
@@ -247,7 +247,7 @@ func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t
for _, model := range []string{"claude-opus-5", "claude-haiku-4-5-20251001"} {
body := []byte(`{"model":"` + model + `","max_tokens":32000}`)
out := ApplySummaryConfigForModel(body, "claude", model, SummaryConfig{Mode: SummaryDisabled})
- if gjson.GetBytes(out, "thinking").Exists() {
+ if ojson.GetBytes(out, "thinking").Exists() {
t.Fatalf("model %s gained thinking for a disabled summary: %s", model, out)
}
}
@@ -255,27 +255,27 @@ func TestApplySummaryConfigForModel_ClaudeDisabledSummaryDoesNotEnableThinking(t
func TestApplySummaryConfig_ResponsesNormalizesDeprecatedGenerateSummary(t *testing.T) {
out := ApplySummaryConfig([]byte(`{"reasoning":{"generate_summary":"detailed"}}`), "openai-response", SummaryConfig{Mode: SummaryEnabled, Detail: "detailed"})
- if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "detailed" {
+ if got := ojson.GetBytes(out, "reasoning.summary").String(); got != "detailed" {
t.Fatalf("reasoning.summary = %q, want detailed; body=%s", got, out)
}
- if gjson.GetBytes(out, "reasoning.generate_summary").Exists() {
+ if ojson.GetBytes(out, "reasoning.generate_summary").Exists() {
t.Fatalf("deprecated reasoning.generate_summary remained: %s", out)
}
}
func TestApplySummaryConfig_ResponsesDisabledOmitsSummary(t *testing.T) {
out := ApplySummaryConfig([]byte(`{"reasoning":{"effort":"high","summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled})
- if result := gjson.GetBytes(out, "reasoning.summary"); result.Exists() {
+ if result := ojson.GetBytes(out, "reasoning.summary"); result.Exists() {
t.Fatalf("reasoning.summary = %s, want absent; body=%s", result.Raw, out)
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytes(out, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high; body=%s", got, out)
}
}
func TestApplySummaryConfig_ResponsesDisabledDropsEmptyReasoning(t *testing.T) {
out := ApplySummaryConfig([]byte(`{"model":"gpt-5.4","reasoning":{"summary":"auto"}}`), "openai-response", SummaryConfig{Mode: SummaryDisabled})
- if gjson.GetBytes(out, "reasoning").Exists() {
+ if ojson.GetBytes(out, "reasoning").Exists() {
t.Fatalf("empty reasoning object left behind: %s", out)
}
}
diff --git a/internal/thinking/text.go b/internal/thinking/text.go
index eed1ba2879a..8c3cca9e642 100644
--- a/internal/thinking/text.go
+++ b/internal/thinking/text.go
@@ -1,7 +1,7 @@
package thinking
import (
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// GetThinkingText extracts the thinking text from a content part.
@@ -10,9 +10,9 @@ import (
// - Wrapped object: { "thinking": { "text": "text", "cache_control": {...} } }
// - Gemini-style: { "thought": true, "text": "text" }
// Returns the extracted text string.
-func GetThinkingText(part gjson.Result) string {
+func GetThinkingText(part ojson.Result) string {
// Try direct text field first (Gemini-style)
- if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
+ if text := part.Get("text"); text.Exists() && text.Type == ojson.String {
return text.String()
}
@@ -23,16 +23,16 @@ func GetThinkingText(part gjson.Result) string {
}
// thinking is a string
- if thinkingField.Type == gjson.String {
+ if thinkingField.Type == ojson.String {
return thinkingField.String()
}
// thinking is an object with inner text/thinking
if thinkingField.IsObject() {
- if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == gjson.String {
+ if inner := thinkingField.Get("text"); inner.Exists() && inner.Type == ojson.String {
return inner.String()
}
- if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == gjson.String {
+ if inner := thinkingField.Get("thinking"); inner.Exists() && inner.Type == ojson.String {
return inner.String()
}
}
diff --git a/internal/thinking/types.go b/internal/thinking/types.go
index b897d5709ce..24f04336fc2 100644
--- a/internal/thinking/types.go
+++ b/internal/thinking/types.go
@@ -4,7 +4,10 @@
// thinking configurations across various AI providers (Claude, Gemini, OpenAI, Codex, Antigravity, Kimi, xAI).
package thinking
-import "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+import (
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+)
// ThinkingMode represents the type of thinking configuration mode.
type ThinkingMode int
@@ -117,3 +120,10 @@ type ProviderApplier interface {
// - ThinkingError if the configuration is invalid or unsupported
Apply(body []byte, config ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error)
}
+
+// ProviderDocApplier extends ProviderApplier to mutate an *ojson.Doc in place
+// without intermediate byte-slice serialization.
+type ProviderDocApplier interface {
+ ProviderApplier
+ ApplyDoc(doc *ojson.Doc, config ThinkingConfig, modelInfo *registry.ModelInfo) error
+}
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go
index 5d328469453..0271b07eb13 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request.go
@@ -9,6 +9,7 @@ import (
"context"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
@@ -16,8 +17,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func resolveThinkingSignature(modelName, thinkingText, rawSignature string) string {
@@ -99,7 +98,7 @@ func RequireCachedThinkingSignatures(ctx context.Context, modelName string, rawJ
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
return nil
}
- messagesResult := gjson.GetBytes(rawJSON, "messages")
+ messagesResult := ojson.GetBytesNoCopy(rawJSON, "messages")
if !messagesResult.IsArray() {
return nil
}
@@ -182,7 +181,7 @@ func resolveProviderCompatibleSignature(targetProvider sigcompat.SignatureProvid
return signature
}
-func resolveToolUseThoughtSignature(modelName string, contentResult gjson.Result, allowSyntheticFallback bool) string {
+func resolveToolUseThoughtSignature(modelName string, contentResult ojson.Result, allowSyntheticFallback bool) string {
targetProvider := sigcompat.SignatureProviderFromModelName(modelName)
if targetProvider == sigcompat.SignatureProviderGemini {
for _, path := range []string{
@@ -219,7 +218,7 @@ func resolveToolUseThoughtSignature(modelName string, contentResult gjson.Result
return sigcompat.GeminiSkipThoughtSignatureValidator
}
-func firstToolUseSignatureField(contentResult gjson.Result) (string, string, bool) {
+func firstToolUseSignatureField(contentResult ojson.Result) (string, string, bool) {
for _, path := range []string{
"signature",
"thought_signature",
@@ -233,7 +232,7 @@ func firstToolUseSignatureField(contentResult gjson.Result) (string, string, boo
return "", "", false
}
-func logDroppedAntigravityThinkingSignature(modelName string, messageIndex, contentIndex int, thinkingText string, signatureResult gjson.Result) {
+func logDroppedAntigravityThinkingSignature(modelName string, messageIndex, contentIndex int, thinkingText string, signatureResult ojson.Result) {
rawSignature := signatureResult.String()
fields := log.Fields{
"component": "signature_sanitizer",
@@ -267,7 +266,7 @@ func logDroppedAntigravityEmptyThinking(modelName string, messageIndex, contentI
}).Debug("antigravity claude translator: dropped empty thinking block")
}
-func logDroppedAntigravityToolUseSignature(modelName string, messageIndex, contentIndex int, contentResult gjson.Result) {
+func logDroppedAntigravityToolUseSignature(modelName string, messageIndex, contentIndex int, contentResult ojson.Result) {
path, rawSignature, ok := firstToolUseSignatureField(contentResult)
if !ok {
return
@@ -325,46 +324,46 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// system instruction
systemParts := make([][]byte, 0, 2)
- systemResult := gjson.GetBytes(rawJSON, "system")
+ systemResult := ojson.GetBytesNoCopy(rawJSON, "system")
if systemResult.IsArray() {
systemResults := systemResult.Array()
for i := 0; i < len(systemResults); i++ {
systemPromptResult := systemResults[i]
systemTypePromptResult := systemPromptResult.Get("type")
- if systemTypePromptResult.Type == gjson.String && systemTypePromptResult.String() == "text" {
+ if systemTypePromptResult.Type == ojson.String && systemTypePromptResult.String() == "text" {
systemPrompt := systemPromptResult.Get("text").String()
if util.IsClaudeCodeAttributionSystemText(systemPrompt) {
continue
}
partJSON := []byte(`{}`)
if systemPrompt != "" {
- partJSON, _ = sjson.SetBytes(partJSON, "text", systemPrompt)
+ partJSON, _ = ojson.SetBytes(partJSON, "text", systemPrompt)
}
systemParts = append(systemParts, partJSON)
}
}
- } else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
+ } else if systemResult.Type == ojson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
partJSON := []byte(`{"text":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", systemResult.String())
+ partJSON, _ = ojson.SetBytes(partJSON, "text", systemResult.String())
systemParts = append(systemParts, partJSON)
}
// contents
- contentItems := translatorcommon.NewRawArrayItems(gjson.GetBytes(rawJSON, "messages.#").Int())
+ contentItems := translatorcommon.NewRawArrayItems(ojson.GetBytesNoCopy(rawJSON, "messages.#").Int())
// tool_use_id → tool_name lookup, populated incrementally during the main loop.
// Claude's tool_result references tool_use by ID; Gemini requires functionResponse.name.
toolNameByID := make(map[string]string)
var pendingToolUseIDs []string
- messagesResult := gjson.GetBytes(rawJSON, "messages")
+ messagesResult := ojson.GetBytesNoCopy(rawJSON, "messages")
if messagesResult.IsArray() {
messageResults := messagesResult.Array()
numMessages := len(messageResults)
for i := 0; i < numMessages; i++ {
messageResult := messageResults[i]
roleResult := messageResult.Get("role")
- if roleResult.Type != gjson.String {
+ if roleResult.Type != ojson.String {
continue
}
originalRole := roleResult.String()
@@ -382,7 +381,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
partItems := make([][]byte, 0, 4)
appendDetachedCarrier := func(signature string, _ bool) {
carrier := []byte(`{"text":"","thoughtSignature":""}`)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", signature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", signature)
partItems = append(partItems, carrier)
}
pendingDetachedSignature := ""
@@ -402,7 +401,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if originalRole == "system" || originalRole == "developer" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok {
partJSON := []byte(`{}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", reminderText)
+ partJSON, _ = ojson.SetBytes(partJSON, "text", reminderText)
partItems = append(partItems, partJSON)
contentItems = append(contentItems, antigravityClaudeContent(role, partItems))
}
@@ -417,7 +416,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
for j := 0; j < numContents; j++ {
contentResult := contentResults[j]
contentTypeResult := contentResult.Get("type")
- if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
+ if contentTypeResult.Type == ojson.String && contentTypeResult.String() == "thinking" {
if originalRole != "assistant" {
continue
}
@@ -474,24 +473,24 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// follows hidden thought text. Keep the thought text, but defer its
// opaque signature to that native neighboring part.
if thinkingText != "" {
- partJSON := []byte(`{}`)
- partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
- partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText)
+ partDoc := ojson.NewDoc([]byte(`{}`))
+ _ = partDoc.Set("thought", true)
+ _ = partDoc.Set("text", thinkingText)
if signatureFromPendingCarrier {
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
+ _ = partDoc.Set("thoughtSignature", signature)
} else if markedCarrier {
carrierTargetsNext := carrierTargetKind == geminiClaudeCarrierAny || carrierTargetKind == nextTargetKind
if validCarrier && carrierDirection == geminiClaudeCarrierStandalone && (carrierTargetKind == geminiClaudeCarrierText || carrierTargetKind == geminiClaudeCarrierAny) {
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
+ _ = partDoc.Set("thoughtSignature", signature)
} else if validCarrier && carrierDirection == geminiClaudeCarrierNext && nextAcceptsDetachedSignature && carrierTargetsNext {
setPendingDetachedSignature(signature, carrierTargetKind)
}
} else if isGeminiSignature && nextAcceptsDetachedSignature {
setPendingDetachedSignature(signature, nextTargetKind)
} else if signature != "" {
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
+ _ = partDoc.Set("thoughtSignature", signature)
}
- partItems = append(partItems, partJSON)
+ partItems = append(partItems, partDoc.Bytes())
continue
}
@@ -526,7 +525,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
attached := false
foundSemanticPart := false
for partIndex := len(partItems) - 1; partIndex >= 0; partIndex-- {
- part := gjson.ParseBytes(partItems[partIndex])
+ part := util.ParseGJSONBytesNoCopy(partItems[partIndex])
partTargetKind := ""
switch {
case part.Get("functionCall").Exists():
@@ -543,7 +542,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
partSignature := strings.TrimSpace(part.Get("thoughtSignature").String())
replaceFallback := bindBackward && partTargetKind == geminiClaudeCarrierFunction && partSignature == sigcompat.GeminiSkipThoughtSignatureValidator
if partSignature == "" || replaceFallback {
- partItems[partIndex], _ = sjson.SetBytes(partItems[partIndex], "thoughtSignature", signature)
+ partItems[partIndex], _ = ojson.SetBytes(partItems[partIndex], "thoughtSignature", signature)
attached = true
}
break
@@ -553,25 +552,25 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
} else if !attached {
setPendingDetachedSignature(signature, carrierTargetKind)
}
- } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
+ } else if contentTypeResult.Type == ojson.String && contentTypeResult.String() == "text" {
prompt := contentResult.Get("text").String()
// Skip empty text parts to avoid Gemini API error:
// "required oneof field 'data' must have one initialized field"
if prompt == "" {
continue
}
- partJSON := []byte(`{}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", prompt)
+ partDoc := ojson.NewDoc([]byte(`{}`))
+ _ = partDoc.Set("text", prompt)
if pendingDetachedSignature != "" {
if pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierText {
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", pendingDetachedSignature)
+ _ = partDoc.Set("thoughtSignature", pendingDetachedSignature)
} else {
appendDetachedCarrier(pendingDetachedSignature, true)
}
clearPendingDetachedSignature()
}
- partItems = append(partItems, partJSON)
- } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
+ partItems = append(partItems, partDoc.Bytes())
+ } else if contentTypeResult.Type == ojson.String && contentTypeResult.String() == "tool_use" {
// NOTE: Do NOT inject dummy thinking blocks here.
// Antigravity API validates signatures, so dummy values are rejected.
@@ -590,15 +589,15 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
argsRaw = argsResult.Raw
} else if argsResult.Exists() {
switch argsResult.Type {
- case gjson.String:
+ case ojson.String:
// Parse JSON-encoded object strings while preserving other strings as JSON strings.
- parsed := gjson.Parse(argsResult.String())
+ parsed := ojson.Parse(argsResult.String())
if parsed.IsObject() {
argsRaw = parsed.Raw
} else {
argsRaw = argsResult.Raw
}
- case gjson.Null:
+ case ojson.Null:
argsRaw = `{}`
default:
argsRaw = argsResult.Raw
@@ -606,7 +605,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
if argsRaw != "" {
- partJSON := []byte(`{}`)
+ partDoc := ojson.NewDoc([]byte(`{}`))
signature := resolveToolUseThoughtSignature(modelName, contentResult, true)
if pendingDetachedSignature != "" {
@@ -619,22 +618,22 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
clearPendingDetachedSignature()
}
if signature != "" {
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature)
+ _ = partDoc.Set("thoughtSignature", signature)
} else {
logDroppedAntigravityToolUseSignature(modelName, i, j, contentResult)
}
if functionID != "" {
- partJSON, _ = sjson.SetBytes(partJSON, "functionCall.id", functionID)
+ _ = partDoc.Set("functionCall.id", functionID)
}
- partJSON, _ = sjson.SetBytes(partJSON, "functionCall.name", functionName)
- partJSON, _ = sjson.SetRawBytes(partJSON, "functionCall.args", []byte(argsRaw))
- partItems = append(partItems, partJSON)
+ _ = partDoc.Set("functionCall.name", functionName)
+ _ = partDoc.SetRaw("functionCall.args", argsRaw)
+ partItems = append(partItems, partDoc.Bytes())
if originalRole == "assistant" {
pendingToolUseIDs = append(pendingToolUseIDs, functionID)
}
}
- } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
+ } else if contentTypeResult.Type == ojson.String && contentTypeResult.String() == "tool_result" {
toolCallID := contentResult.Get("tool_use_id").String()
if toolCallID != "" {
funcName, ok := toolNameByID[toolCallID]
@@ -653,35 +652,34 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
functionResponseResult := contentResult.Get("content")
- functionResponseJSON := []byte(`{}`)
- functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "id", toolCallID)
- functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, "name", util.MapSanitizedFunctionName(functionNameMap, funcName))
+ functionResponseDoc := ojson.NewDoc([]byte(`{}`))
+ _ = functionResponseDoc.Set("id", toolCallID)
+ _ = functionResponseDoc.Set("name", util.MapSanitizedFunctionName(functionNameMap, funcName))
responsePath := "response.result"
- if contentResult.Get("is_error").Type == gjson.True {
+ if contentResult.Get("is_error").Type == ojson.True {
responsePath = "response.error"
}
responseData := ""
- if functionResponseResult.Type == gjson.String {
+ if functionResponseResult.Type == ojson.String {
responseData = functionResponseResult.String()
- functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, responsePath, responseData)
+ _ = functionResponseDoc.Set(responsePath, responseData)
} else if functionResponseResult.IsArray() {
frResults := functionResponseResult.Array()
nonImageItems := make([][]byte, 0, len(frResults))
imagePartItems := make([][]byte, 0, 2)
for _, fr := range frResults {
if fr.Get("type").String() == "image" && fr.Get("source.type").String() == "base64" {
- inlineDataJSON := []byte(`{}`)
+ inlineDataDoc := ojson.NewDoc([]byte(`{}`))
if mimeType := fr.Get("source.media_type").String(); mimeType != "" {
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
+ _ = inlineDataDoc.Set("mimeType", mimeType)
}
if data := fr.Get("source.data").String(); data != "" {
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
+ _ = inlineDataDoc.Set("data", data)
}
- imagePartJSON := []byte(`{}`)
- imagePartJSON, _ = sjson.SetRawBytes(imagePartJSON, "inlineData", inlineDataJSON)
+ imagePartJSON, _ := ojson.SetRawBytes([]byte(`{}`), "inlineData", inlineDataDoc.Bytes())
imagePartItems = append(imagePartItems, imagePartJSON)
continue
}
@@ -690,68 +688,65 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
if len(nonImageItems) == 1 {
- functionResponseJSON = translatorcommon.SetGeminiFunctionResponseRaw(functionResponseJSON, responsePath, string(nonImageItems[0]))
+ translatorcommon.SetGeminiFunctionResponseRawDoc(functionResponseDoc, responsePath, string(nonImageItems[0]))
} else if len(nonImageItems) > 1 {
- functionResponseJSON = translatorcommon.SetGeminiFunctionResponseRaw(functionResponseJSON, responsePath, string(translatorcommon.JoinRawArray(nonImageItems)))
+ translatorcommon.SetGeminiFunctionResponseRawDoc(functionResponseDoc, responsePath, string(translatorcommon.JoinRawArray(nonImageItems)))
} else {
- functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, responsePath, "")
+ _ = functionResponseDoc.Set(responsePath, "")
}
// Place image data inside functionResponse.parts as inlineData
// instead of as sibling parts in the outer content, to avoid
// base64 data bloating the text context.
if len(imagePartItems) > 0 {
- functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "parts", translatorcommon.JoinRawArray(imagePartItems))
+ _ = functionResponseDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(imagePartItems))
}
} else if functionResponseResult.IsObject() {
if functionResponseResult.Get("type").String() == "image" && functionResponseResult.Get("source.type").String() == "base64" {
- inlineDataJSON := []byte(`{}`)
+ inlineDataDoc := ojson.NewDoc([]byte(`{}`))
if mimeType := functionResponseResult.Get("source.media_type").String(); mimeType != "" {
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
+ _ = inlineDataDoc.Set("mimeType", mimeType)
}
if data := functionResponseResult.Get("source.data").String(); data != "" {
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
+ _ = inlineDataDoc.Set("data", data)
}
- imagePartJSON := []byte(`{}`)
- imagePartJSON, _ = sjson.SetRawBytes(imagePartJSON, "inlineData", inlineDataJSON)
- functionResponseJSON, _ = sjson.SetRawBytes(functionResponseJSON, "parts", translatorcommon.JoinRawArray([][]byte{imagePartJSON}))
- functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, responsePath, "")
+ imagePartJSON, _ := ojson.SetRawBytes([]byte(`{}`), "inlineData", inlineDataDoc.Bytes())
+ _ = functionResponseDoc.SetRawBytes("parts", translatorcommon.JoinRawArray([][]byte{imagePartJSON}))
+ _ = functionResponseDoc.Set(responsePath, "")
} else {
- functionResponseJSON = translatorcommon.SetGeminiFunctionResponseResult(functionResponseJSON, responsePath, functionResponseResult)
+ translatorcommon.SetGeminiFunctionResponseResultDoc(functionResponseDoc, responsePath, functionResponseResult)
}
} else if functionResponseResult.Raw != "" {
- functionResponseJSON = translatorcommon.SetGeminiFunctionResponseResult(functionResponseJSON, responsePath, functionResponseResult)
+ translatorcommon.SetGeminiFunctionResponseResultDoc(functionResponseDoc, responsePath, functionResponseResult)
} else {
// Content field is missing entirely — .Raw is empty which
- // causes sjson.SetRaw to produce invalid JSON (e.g. "result":}).
- functionResponseJSON, _ = sjson.SetBytes(functionResponseJSON, responsePath, "")
+ // causes ojson.SetRaw to produce invalid JSON (e.g. "result":}).
+ _ = functionResponseDoc.Set(responsePath, "")
}
- partJSON := []byte(`{}`)
- partJSON, _ = sjson.SetRawBytes(partJSON, "functionResponse", functionResponseJSON)
+ partJSON, _ := ojson.SetRawBytes([]byte(`{}`), "functionResponse", functionResponseDoc.Bytes())
partItems = append(partItems, partJSON)
}
- } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "image" {
+ } else if contentTypeResult.Type == ojson.String && contentTypeResult.String() == "image" {
sourceResult := contentResult.Get("source")
if sourceResult.Get("type").String() == "base64" {
- inlineDataJSON := []byte(`{}`)
+ inlineDataDoc := ojson.NewDoc([]byte(`{}`))
if mimeType := sourceResult.Get("media_type").String(); mimeType != "" {
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
+ _ = inlineDataDoc.Set("mimeType", mimeType)
}
if data := sourceResult.Get("data").String(); data != "" {
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
+ _ = inlineDataDoc.Set("data", data)
}
- partJSON := []byte(`{}`)
- partJSON, _ = sjson.SetRawBytes(partJSON, "inlineData", inlineDataJSON)
+ partJSON, _ := ojson.SetRawBytes([]byte(`{}`), "inlineData", inlineDataDoc.Bytes())
partItems = append(partItems, partJSON)
} else if originalRole == "user" {
// A part that cannot be inlined (url or file source) is dropped; the turn is refused only if nothing else is left.
drops.Drop("image")
}
- } else if contentTypeResult.Type == gjson.String && (contentTypeResult.String() == "document" || contentTypeResult.String() == "container_upload") {
+ } else if contentTypeResult.Type == ojson.String && (contentTypeResult.String() == "document" || contentTypeResult.String() == "container_upload") {
if partJSON := claudeBase64InlineData(contentResult.Get("source")); partJSON != nil {
partItems = append(partItems, partJSON)
} else if originalRole == "user" {
@@ -782,7 +777,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
previousCategory := -1
seenFunctionCall := false
for _, partJSON := range partItems {
- part := gjson.ParseBytes(partJSON)
+ part := util.ParseGJSONBytesNoCopy(partJSON)
category := 1
isSignatureCarrier := part.Get("text").Exists() && part.Get("text").String() == "" && strings.TrimSpace(part.Get("thoughtSignature").String()) != ""
isFunctionTailCarrier := isSignatureCarrier && seenFunctionCall
@@ -804,14 +799,14 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
newParts = append(newParts, thinkingParts...)
newParts = append(newParts, regularParts...)
newParts = append(newParts, trailingParts...)
- clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts", translatorcommon.JoinRawArray(newParts))
+ clientContentJSON, _ = ojson.SetRawBytes(clientContentJSON, "parts", translatorcommon.JoinRawArray(newParts))
}
}
contentItems = append(contentItems, clientContentJSON)
- } else if contentsResult.Type == gjson.String {
+ } else if contentsResult.Type == ojson.String {
partJSON := []byte(`{}`)
if prompt := contentsResult.String(); prompt != "" {
- partJSON, _ = sjson.SetBytes(partJSON, "text", prompt)
+ partJSON, _ = ojson.SetBytes(partJSON, "text", prompt)
}
contentItems = append(contentItems, antigravityClaudeContent(role, [][]byte{partJSON}))
}
@@ -822,7 +817,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
var toolsJSON []byte
toolDeclCount := 0
allowedToolKeys := []string{"name", "description", "behavior", "parameters", "parametersJsonSchema", "response", "responseJsonSchema"}
- toolsResult := gjson.GetBytes(rawJSON, "tools")
+ toolsResult := ojson.GetBytesNoCopy(rawJSON, "tools")
if toolsResult.IsArray() {
var functionDeclarations [][]byte
toolsResults := toolsResult.Array()
@@ -835,47 +830,48 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
// Sanitize the input schema for Antigravity API compatibility
inputSchema := util.CleanJSONSchemaForAntigravity(inputSchemaResult.Raw)
- tool, _ := sjson.DeleteBytes([]byte(toolResult.Raw), "input_schema")
- tool, _ = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema))
- nameResult := gjson.GetBytes(tool, "name")
+ toolDoc := ojson.NewDocString(toolResult.Raw)
+ _ = toolDoc.Delete("input_schema")
+ _ = toolDoc.SetRaw("parametersJsonSchema", inputSchema)
+ nameResult := toolResult.Get("name")
originalName := nameResult.String()
mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName)
- if nameResult.Type != gjson.String || mappedName != originalName {
- tool, _ = sjson.SetBytes(tool, "name", mappedName)
+ if nameResult.Type != ojson.String || mappedName != originalName {
+ _ = toolDoc.Set("name", mappedName)
}
- for toolKey := range gjson.ParseBytes(tool).Map() {
- if util.InArray(allowedToolKeys, toolKey) {
+ for toolKey := range toolResult.Map() {
+ if toolKey == "input_schema" || util.InArray(allowedToolKeys, toolKey) {
continue
}
- tool, _ = sjson.DeleteBytes(tool, toolKey)
+ _ = toolDoc.Delete(toolKey)
}
- functionDeclarations = append(functionDeclarations, tool)
+ functionDeclarations = append(functionDeclarations, toolDoc.Bytes())
}
}
if len(functionDeclarations) > 0 {
deduplicated := util.DeduplicateFunctionDeclarations(translatorcommon.JoinRawArray(functionDeclarations))
- toolDeclCount = len(gjson.ParseBytes(deduplicated).Array())
+ toolDeclCount = len(util.ParseGJSONBytesNoCopy(deduplicated).Array())
if toolDeclCount > 0 {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
- functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
+ functionToolNode, _ = ojson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
toolsJSON = translatorcommon.JoinRawArray([][]byte{functionToolNode})
}
}
}
// Build output Antigravity request JSON
- out := []byte(`{"model":"","request":{"contents":[]}}`)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ outDoc := ojson.NewDoc([]byte(`{"model":"","request":{"contents":[]}}`))
+ _ = outDoc.Set("model", modelName)
// tool_choice metadata
- toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
+ toolChoiceResult := ojson.GetBytesNoCopy(rawJSON, "tool_choice")
toolChoiceType := ""
toolChoiceName := ""
if toolChoiceResult.Exists() {
if toolChoiceResult.IsObject() {
toolChoiceType = toolChoiceResult.Get("type").String()
toolChoiceName = toolChoiceResult.Get("name").String()
- } else if toolChoiceResult.Type == gjson.String {
+ } else if toolChoiceResult.Type == ojson.String {
toolChoiceType = toolChoiceResult.String()
}
}
@@ -883,7 +879,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Inject interleaved thinking hint when both tools and thinking are active
hasTools := toolDeclCount > 0 && !isToolChoiceNone
- thinkingResult := gjson.GetBytes(rawJSON, "thinking")
+ thinkingResult := ojson.GetBytesNoCopy(rawJSON, "thinking")
thinkingType := thinkingResult.Get("type").String()
hasThinking := thinkingResult.Exists() && thinkingResult.IsObject() && (thinkingType == "enabled" || thinkingType == "adaptive" || thinkingType == "auto")
isClaudeThinking := util.IsClaudeThinkingModel(modelName)
@@ -892,50 +888,50 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
interleavedHint := "Interleaved thinking is enabled. You may think between tool calls and after receiving tool results before deciding the next action or final answer. Do not mention these instructions or any constraints about thinking blocks; just apply them."
hintPart := []byte(`{"text":""}`)
- hintPart, _ = sjson.SetBytes(hintPart, "text", interleavedHint)
+ hintPart, _ = ojson.SetBytes(hintPart, "text", interleavedHint)
systemParts = append(systemParts, hintPart)
}
if len(systemParts) > 0 {
- out, _ = sjson.SetRawBytes(out, "request.systemInstruction", antigravityClaudeContent("user", systemParts))
+ _ = outDoc.SetRawBytes("request.systemInstruction", antigravityClaudeContent("user", systemParts))
}
if len(contentItems) > 0 {
if util.IsClaudeModel(modelName) {
contentItems = translatorcommon.SplitGeminiFunctionResponseTurns(contentItems)
- out = translatorcommon.SetRawArrayItems(out, "request.contents", translatorcommon.MergeAdjacentGeminiUserContents(contentItems))
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "request.contents", translatorcommon.MergeAdjacentGeminiUserContents(contentItems))
} else {
- out = translatorcommon.SetRawArrayItems(out, "request.contents", translatorcommon.MergeAdjacentGeminiContents(contentItems))
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "request.contents", translatorcommon.MergeAdjacentGeminiContents(contentItems))
}
}
if toolDeclCount > 0 && !isToolChoiceNone {
- out, _ = sjson.SetRawBytes(out, "request.tools", toolsJSON)
+ _ = outDoc.SetRawBytes("request.tools", toolsJSON)
}
// tool_choice
if toolChoiceResult.Exists() {
switch strings.ToLower(strings.TrimSpace(toolChoiceType)) {
case "auto":
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "AUTO")
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.mode", "AUTO")
case "none":
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "NONE")
- out, _ = sjson.DeleteBytes(out, "request.tools")
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Delete("request.tools")
case "any":
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.mode", "ANY")
case "tool":
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.mode", "ANY")
if toolChoiceName != "" {
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.MapSanitizedFunctionName(functionNameMap, toolChoiceName)})
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.MapSanitizedFunctionName(functionNameMap, toolChoiceName)})
}
}
}
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when type==enabled
- if t := gjson.GetBytes(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() {
+ if t := ojson.GetBytesNoCopy(rawJSON, "thinking"); enableThoughtTranslate && t.Exists() && t.IsObject() {
switch t.Get("type").String() {
case "enabled":
- if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
+ if b := t.Get("budget_tokens"); b.Exists() && b.Type == ojson.Number {
budget := int(b.Int())
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.thinkingBudget", budget)
}
case "adaptive", "auto":
// For adaptive thinking:
@@ -943,32 +939,35 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// - Otherwise, treat it as "enabled with target-model maximum" and emit high.
// ApplyThinking handles clamping to target model's supported levels.
effort := ""
- if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
+ if v := ojson.GetBytesNoCopy(rawJSON, "output_config.effort"); v.Exists() && v.Type == ojson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.thinkingLevel", effort)
} else {
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", "high")
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.thinkingLevel", "high")
}
}
}
- if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.temperature", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "temperature"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.temperature", v.Num)
}
- if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.topP", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "top_p"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.topP", v.Num)
}
- if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.topK", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "top_k"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.topK", v.Num)
}
- if v := gjson.GetBytes(rawJSON, "max_tokens"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "max_tokens"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.maxOutputTokens", v.Num)
}
- out = common.AttachDefaultSafetySettings(out, "request.safetySettings")
+ common.AttachDefaultSafetySettingsDoc(outDoc, "request.safetySettings")
+ out := outDoc.Bytes()
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
out = sigcompat.SanitizeGeminiRequestThoughtSignatures(out, "request.contents")
+ } else {
+ out = sigcompat.SanitizeGeminiSystemInstruction(out, "request.contents")
}
return out, drops.Err()
@@ -977,7 +976,7 @@ func convertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
// claudeBase64InlineData converts a Claude base64 source into an Antigravity
// inlineData part. A file id or url carries no bytes, so it stays unconverted
// and the caller reports it.
-func claudeBase64InlineData(source gjson.Result) []byte {
+func claudeBase64InlineData(source ojson.Result) []byte {
if source.Get("type").String() != "base64" {
return nil
}
@@ -986,17 +985,16 @@ func claudeBase64InlineData(source gjson.Result) []byte {
if mimeType == "" || data == "" {
return nil
}
- inlineDataJSON := []byte(`{}`)
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "mimeType", mimeType)
- inlineDataJSON, _ = sjson.SetBytes(inlineDataJSON, "data", data)
- partJSON := []byte(`{}`)
- partJSON, _ = sjson.SetRawBytes(partJSON, "inlineData", inlineDataJSON)
+ inlineDataDoc := ojson.NewDoc([]byte(`{}`))
+ _ = inlineDataDoc.Set("mimeType", mimeType)
+ _ = inlineDataDoc.Set("data", data)
+ partJSON, _ := ojson.SetRawBytes([]byte(`{}`), "inlineData", inlineDataDoc.Bytes())
return partJSON
}
func antigravityClaudeContent(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDoc([]byte(`{"role":"","parts":[]}`))
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
index bbb232f0748..a116fa05e5e 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go
@@ -2,17 +2,17 @@ package claude
import (
"bytes"
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
log "github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -126,9 +126,9 @@ func TestConvertClaudeRequestToAntigravity_StripsClaudeCodeAttribution(t *testin
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- parts := gjson.Get(outputStr, "request.systemInstruction.parts").Array()
+ parts := ojson.Get(outputStr, "request.systemInstruction.parts").Array()
if len(parts) != 1 {
- t.Fatalf("Expected 1 system part after attribution strip, got %d: %s", len(parts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw)
+ t.Fatalf("Expected 1 system part after attribution strip, got %d: %s", len(parts), ojson.Get(outputStr, "request.systemInstruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "Antigravity system prompt" {
t.Fatalf("Unexpected system part: %q", got)
@@ -149,13 +149,13 @@ func TestConvertClaudeRequestToAntigravity_ConvertsMessageSystemRoleToUserConten
output, _ := ConvertClaudeRequestToAntigravity("gemini-3-flash-agent", inputJSON, false)
outputStr := string(output)
- if systemContent := gjson.Get(outputStr, `request.contents.#(role=="system")`); systemContent.Exists() {
+ if systemContent := ojson.Get(outputStr, `request.contents.#(role=="system")`); systemContent.Exists() {
t.Fatalf("system role should not be emitted in request.contents: %s", systemContent.Raw)
}
- contents := gjson.Get(outputStr, "request.contents").Array()
+ contents := ojson.Get(outputStr, "request.contents").Array()
if len(contents) != 1 {
- t.Fatalf("Expected consecutive user and message-level system turns to be merged into a single user turn, got %d: %s", len(contents), gjson.Get(outputStr, "request.contents").Raw)
+ t.Fatalf("Expected consecutive user and message-level system turns to be merged into a single user turn, got %d: %s", len(contents), ojson.Get(outputStr, "request.contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
@@ -174,9 +174,9 @@ func TestConvertClaudeRequestToAntigravity_ConvertsMessageSystemRoleToUserConten
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
- systemInstructionParts := gjson.Get(outputStr, "request.systemInstruction.parts").Array()
+ systemInstructionParts := ojson.Get(outputStr, "request.systemInstruction.parts").Array()
if len(systemInstructionParts) != 1 {
- t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw)
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), ojson.Get(outputStr, "request.systemInstruction.parts").Raw)
}
if got := systemInstructionParts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
@@ -197,13 +197,13 @@ func TestConvertClaudeRequestToAntigravity_MessageLevelDeveloperInstructionsBeco
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.5-flash", inputJSON, false)
outputStr := string(output)
- if devContent := gjson.Get(outputStr, `request.contents.#(role=="developer")`); devContent.Exists() {
+ if devContent := ojson.Get(outputStr, `request.contents.#(role=="developer")`); devContent.Exists() {
t.Fatalf("developer role should not be emitted in request.contents: %s", devContent.Raw)
}
- contents := gjson.Get(outputStr, "request.contents").Array()
+ contents := ojson.Get(outputStr, "request.contents").Array()
if len(contents) != 1 {
- t.Fatalf("Expected consecutive user and developer turns to be merged into a single user turn, got %d: %s", len(contents), gjson.Get(outputStr, "request.contents").Raw)
+ t.Fatalf("Expected consecutive user and developer turns to be merged into a single user turn, got %d: %s", len(contents), ojson.Get(outputStr, "request.contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
@@ -222,9 +222,9 @@ func TestConvertClaudeRequestToAntigravity_MessageLevelDeveloperInstructionsBeco
t.Fatalf("Unexpected array developer content text: %q", got)
}
- systemInstructionParts := gjson.Get(outputStr, "request.systemInstruction.parts").Array()
+ systemInstructionParts := ojson.Get(outputStr, "request.systemInstruction.parts").Array()
if len(systemInstructionParts) != 1 {
- t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), gjson.Get(outputStr, "request.systemInstruction.parts").Raw)
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), ojson.Get(outputStr, "request.systemInstruction.parts").Raw)
}
if got := systemInstructionParts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
@@ -266,7 +266,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesToolPairingWithInterveningSy
t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v\noutput: %s", errPairing, output)
}
- contents := gjson.GetBytes(output, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 merged contents (user, model, user), got %d: %s", len(contents), output)
}
@@ -310,25 +310,25 @@ func TestConvertClaudeRequestToAntigravity_MapsTypedWebSearchToIndependentSearch
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.1-flash-lite", inputJSON, true)
- if got := gjson.GetBytes(output, "requestType").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(output, "requestType").String(); got != "web_search" {
t.Fatalf("requestType = %q, want web_search: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "北京天气 2026-06-12" {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.text").String(); got != "北京天气 2026-06-12" {
t.Fatalf("search query = %q, want original user query: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.systemInstruction.parts.0.text").String(); got != antigravityWebSearchSystemInstruction {
+ if got := ojson.GetBytesNoCopy(output, "request.systemInstruction.parts.0.text").String(); got != antigravityWebSearchSystemInstruction {
t.Fatalf("unexpected search system instruction: %q", got)
}
- if got := gjson.GetBytes(output, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount").Int(); got != 8 {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount").Int(); got != 8 {
t.Fatalf("image search maxResultCount = %d, want 8: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.tools.0.googleSearch.includedDomains.0").String(); got != "www.baidu.com" {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.0.googleSearch.includedDomains.0").String(); got != "www.baidu.com" {
t.Fatalf("includedDomains.0 = %q, want www.baidu.com: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.tools.0.googleSearch.includedDomains.1").String(); got != "weather.com.cn" {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.0.googleSearch.includedDomains.1").String(); got != "weather.com.cn" {
t.Fatalf("includedDomains.1 = %q, want weather.com.cn: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.generationConfig.candidateCount").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(output, "request.generationConfig.candidateCount").Int(); got != 1 {
t.Fatalf("candidateCount = %d, want 1: %s", got, output)
}
}
@@ -346,7 +346,7 @@ func TestConvertClaudeRequestToAntigravity_UsesDefaultWebSearchMaxResultCountWit
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.1-flash-lite", inputJSON, true)
- if got := gjson.GetBytes(output, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount").Int(); got != 5 {
t.Fatalf("image search maxResultCount = %d, want default 5: %s", got, output)
}
}
@@ -367,13 +367,13 @@ func TestConvertClaudeRequestToAntigravity_DoesNotMapTypedWebSearchWhenMixedWith
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.1-flash-lite", inputJSON, true)
- if got := gjson.GetBytes(output, "requestType").String(); got == "web_search" {
+ if got := ojson.GetBytesNoCopy(output, "requestType").String(); got == "web_search" {
t.Fatalf("mixed tools should not become independent web_search request: %s", output)
}
- if got := gjson.GetBytes(output, "request.tools.#(googleSearch)").Raw; got != "" {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.#(googleSearch)").Raw; got != "" {
t.Fatalf("mixed tools should not inject native googleSearch into chat request: %s", output)
}
- if got := gjson.GetBytes(output, `request.tools.#.functionDeclarations.#(name=="lookup")`).Raw; got == "" {
+ if got := ojson.GetBytesNoCopy(output, `request.tools.#.functionDeclarations.#(name=="lookup")`).Raw; got == "" {
t.Fatalf("custom tool declaration should be preserved: %s", output)
}
}
@@ -392,10 +392,10 @@ func TestConvertClaudeRequestToAntigravity_DoesNotMapTypedWebSearchForUnsupporte
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.5-flash", inputJSON, true)
- if got := gjson.GetBytes(output, "model").String(); got != "gemini-3.5-flash" {
+ if got := ojson.GetBytesNoCopy(output, "model").String(); got != "gemini-3.5-flash" {
t.Fatalf("web search request model = %q, want original route model: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.tools.#(googleSearch)").Raw; got != "" {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.#(googleSearch)").Raw; got != "" {
t.Fatalf("typed web_search should not become native googleSearch for unsupported route model: %s", output)
}
}
@@ -414,10 +414,10 @@ func TestConvertClaudeRequestToAntigravity_DoesNotMapTypedWebSearchForFlashAgent
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3-flash-agent", inputJSON, true)
- if got := gjson.GetBytes(output, "model").String(); got != "gemini-3-flash-agent" {
+ if got := ojson.GetBytesNoCopy(output, "model").String(); got != "gemini-3-flash-agent" {
t.Fatalf("web search request model = %q, want original route model: %s", got, output)
}
- if got := gjson.GetBytes(output, "request.tools.#(googleSearch)").Raw; got != "" {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.#(googleSearch)").Raw; got != "" {
t.Fatalf("typed web_search should not become native googleSearch for flash-agent without capability: %s", output)
}
}
@@ -430,7 +430,7 @@ func TestConvertClaudeRequestToAntigravity_DoesNotMapTypedWebSearchForOtherModel
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", inputJSON, true)
- if got := gjson.GetBytes(output, "request.tools.#(googleSearch)").Raw; got != "" {
+ if got := ojson.GetBytesNoCopy(output, "request.tools.#(googleSearch)").Raw; got != "" {
t.Fatalf("model without Antigravity web search capability should not get native googleSearch: %s", output)
}
}
@@ -484,7 +484,7 @@ func TestConvertClaudeRequestToAntigravity_ReattachesDetachedGeminiSignature(t *
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("parts = %d, want one native text part; output=%s", len(parts), output)
}
@@ -506,7 +506,7 @@ func TestConvertClaudeRequestToAntigravity_ReattachesLeadingDetachedGeminiSignat
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != geminiSig {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.thoughtSignature").String(); got != geminiSig {
t.Fatalf("leading detached signature = %q, want %q; output=%s", got, geminiSig, output)
}
}
@@ -516,13 +516,13 @@ func TestConvertClaudeRequestToAntigravity_DropsLegacyRawCarrierFromUserMessage(
inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"user","content":[{"type":"thinking","thinking":"","signature":"` + geminiSig + `"},{"type":"text","text":"user text"}]}]}`)
filtered := StripInvalidGeminiSignatureThinkingBlocks(inputJSON)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", filtered, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "user text" || parts[0].Get("thoughtSignature").Exists() {
t.Fatalf("user legacy carrier reached Gemini after filtering: %s", output)
}
directOutput, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- directParts := gjson.GetBytes(directOutput, "request.contents.0.parts").Array()
+ directParts := ojson.GetBytesNoCopy(directOutput, "request.contents.0.parts").Array()
if len(directParts) != 1 || directParts[0].Get("text").String() != "user text" || directParts[0].Get("thoughtSignature").Exists() {
t.Fatalf("user legacy carrier reached Gemini without prefilter: %s", directOutput)
}
@@ -541,7 +541,7 @@ func TestConvertClaudeRequestToAntigravity_DistributesConsecutiveTrailingGeminiC
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 3 {
t.Fatalf("parts = %d, want two text parts + detached carrier; output=%s", len(parts), output)
}
@@ -568,7 +568,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesConsecutiveLeadingGeminiCarr
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want carrier + signed tool; output=%s", len(parts), output)
}
@@ -591,7 +591,7 @@ func TestConvertClaudeRequestToAntigravity_DirectToolSignatureWinsOverLeadingCar
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want carrier + directly signed tool; output=%s", len(parts), output)
}
@@ -621,7 +621,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesCarrierBetweenDirectlySigned
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 3 {
t.Fatalf("parts = %d, want tool + carrier + tool; output=%s", len(parts), output)
}
@@ -650,7 +650,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesCarrierOnlyAssistantMessage(
t.Run(tc.name, func(t *testing.T) {
inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":` + tc.content + `}]}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != len(tc.signatures) {
t.Fatalf("parts = %d, want %d carriers; output=%s", len(parts), len(tc.signatures), output)
}
@@ -675,7 +675,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesConsecutiveLeadingGeminiCarr
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want carrier + signed text; output=%s", len(parts), output)
}
@@ -699,7 +699,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesTrailingCarrierAfterSignedTo
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want signed tool + trailing carrier; output=%s", len(parts), output)
}
@@ -722,10 +722,10 @@ func TestConvertClaudeRequestToAntigravity_DetachedToolCarrierTargetsFollowingTo
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != "" {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.thoughtSignature").String(); got != "" {
t.Fatalf("detached tool signature attached backward to text: %q; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); got != geminiSig {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.1.thoughtSignature").String(); got != geminiSig {
t.Fatalf("tool signature = %q, want %q; output=%s", got, geminiSig, output)
}
}
@@ -740,10 +740,10 @@ func TestConvertClaudeRequestToAntigravity_GeminiThinkingSignatureTargetsFollowi
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != "" {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.thoughtSignature").String(); got != "" {
t.Fatalf("Gemini signature remained on thought part: %q; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); got != geminiSig {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.1.thoughtSignature").String(); got != geminiSig {
t.Fatalf("visible signature = %q, want %q; output=%s", got, geminiSig, output)
}
}
@@ -756,7 +756,7 @@ func TestConvertClaudeRequestToAntigravity_LeadingCarrierDoesNotCrossSignedThink
inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"` + leading + `"},{"type":"thinking","thinking":"reason","signature":"` + signedThought + `"},{"type":"text","text":"answer"}]}]}`)
inputJSON = StripInvalidGeminiSignatureThinkingBlocks(inputJSON)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 3 || parts[0].Get("text").String() != "reason" || !parts[0].Get("thought").Bool() || parts[0].Get("thoughtSignature").String() != signature2 || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature1 || parts[2].Get("text").String() != "answer" || parts[2].Get("thoughtSignature").String() != "" {
t.Fatalf("leading carrier crossed signed thinking: %s", output)
}
@@ -784,7 +784,7 @@ func TestConvertClaudeRequestToAntigravity_GeminiThoughtTextWithFollowingPreviou
inputJSON = StripInvalidGeminiSignatureThinkingBlocks(inputJSON)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.1-pro-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts count = %d, want 2; output=%s", len(parts), output)
}
@@ -806,7 +806,7 @@ func TestConvertClaudeRequestToAntigravity_GeminiThinkingSignatureTargetsFollowi
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 || !parts[0].Get("thought").Bool() {
t.Fatalf("thought/tool parts malformed: %s", output)
}
@@ -828,7 +828,7 @@ func TestConvertClaudeRequestToAntigravity_PreservesGeminiToolSignature(t *testi
]}]
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != geminiSig {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.thoughtSignature").String(); got != geminiSig {
t.Fatalf("tool signature = %q, want %q; output=%s", got, geminiSig, output)
}
}
@@ -845,7 +845,7 @@ func TestConvertClaudeRequestToAntigravity_NativeParallelToolLeavesUnsignedSibli
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want 2 function calls; output=%s", len(parts), output)
}
@@ -867,7 +867,7 @@ func TestConvertClaudeRequestToAntigravity_SyntheticParallelToolOnlyFirstGetsSen
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want 2 function calls; output=%s", len(parts), output)
}
@@ -899,24 +899,24 @@ func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) {
outputStr := string(output)
// Check model
- if gjson.Get(outputStr, "model").String() != "claude-sonnet-4-5" {
- t.Errorf("Expected model 'claude-sonnet-4-5', got '%s'", gjson.Get(outputStr, "model").String())
+ if ojson.Get(outputStr, "model").String() != "claude-sonnet-4-5" {
+ t.Errorf("Expected model 'claude-sonnet-4-5', got '%s'", ojson.Get(outputStr, "model").String())
}
// Check contents exist
- contents := gjson.Get(outputStr, "request.contents")
+ contents := ojson.Get(outputStr, "request.contents")
if !contents.Exists() || !contents.IsArray() {
t.Error("request.contents should exist and be an array")
}
// Check role mapping (assistant -> model)
- firstContent := gjson.Get(outputStr, "request.contents.0")
+ firstContent := ojson.Get(outputStr, "request.contents.0")
if firstContent.Get("role").String() != "user" {
t.Errorf("Expected role 'user', got '%s'", firstContent.Get("role").String())
}
// Check systemInstruction
- sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
+ sysInstruction := ojson.Get(outputStr, "request.systemInstruction")
if !sysInstruction.Exists() {
t.Error("systemInstruction should exist")
}
@@ -938,7 +938,7 @@ func TestConvertClaudeRequestToAntigravity_RoleMapping(t *testing.T) {
outputStr := string(output)
// assistant should be mapped to model
- secondContent := gjson.Get(outputStr, "request.contents.1")
+ secondContent := ojson.Get(outputStr, "request.contents.1")
if secondContent.Get("role").String() != "model" {
t.Errorf("Expected role 'model' (mapped from 'assistant'), got '%s'", secondContent.Get("role").String())
}
@@ -974,7 +974,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlocks(t *testing.T) {
outputStr := string(output)
// Check thinking block conversion (now in contents.1 due to user message)
- firstPart := gjson.Get(outputStr, "request.contents.1.parts.0")
+ firstPart := ojson.Get(outputStr, "request.contents.1.parts.0")
if !firstPart.Get("thought").Bool() {
t.Error("thinking block should have thought: true")
}
@@ -1349,7 +1349,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModeNormalizesESignature(t *tes
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
- part := gjson.Get(outputStr, "request.contents.0.parts.0")
+ part := ojson.Get(outputStr, "request.contents.0.parts.0")
if part.Get("thoughtSignature").String() != expectedSignature {
t.Fatalf("Expected bypass-mode signature '%s', got '%s'", expectedSignature, part.Get("thoughtSignature").String())
}
@@ -1383,7 +1383,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModePreservesShortValidSignatur
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("expected thinking part to be preserved in bypass mode, got %d parts", len(parts))
}
@@ -1396,10 +1396,10 @@ func TestConvertClaudeRequestToAntigravity_BypassModePreservesShortValidSignatur
if parts[1].Get("text").String() != "Answer" {
t.Fatalf("expected trailing text part, got %s", parts[1].Raw)
}
- if thoughtSig := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); thoughtSig != "" {
+ if thoughtSig := ojson.GetBytesNoCopy(output, "request.contents.0.parts.1.thoughtSignature").String(); thoughtSig != "" {
t.Fatalf("expected plain text part to have no thought signature, got %q", thoughtSig)
}
- if functionSig := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.thoughtSignature").String(); functionSig != "" {
+ if functionSig := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.functionCall.thoughtSignature").String(); functionSig != "" {
t.Fatalf("unexpected functionCall payload in thinking part: %q", functionSig)
}
}
@@ -1468,7 +1468,7 @@ func TestConvertClaudeRequestToAntigravity_CacheModeAcceptsNativeSignature(t *te
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected native signature thinking block to be preserved in cache mode, got %d parts; output=%s", len(parts), output)
}
@@ -1509,7 +1509,7 @@ func TestConvertClaudeRequestToAntigravity_CacheModeDropsInvalidSignature(t *tes
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected invalid signature thinking block to be dropped in cache mode even if cache exists, got %d parts", len(parts))
}
@@ -1546,7 +1546,7 @@ func TestConvertClaudeRequestToAntigravity_CacheModeRecoversSignatureWhenClientO
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected omitted signature thinking block to be recovered from cache, got %d parts; output=%s", len(parts), output)
}
@@ -1584,7 +1584,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsInvalidSignature(t *te
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected invalid thinking block to be removed, got %d parts", len(parts))
}
@@ -1621,7 +1621,7 @@ func TestConvertClaudeRequestToAntigravity_LogsDroppedInvalidThinkingSignature(t
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "Answer" {
t.Fatalf("expected invalid thinking block to be dropped, output: %s", output)
}
@@ -1672,7 +1672,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiSignature(t *tes
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("expected Gemini-signed thinking block to be dropped, got %d parts", len(parts))
}
@@ -1705,7 +1705,7 @@ func TestConvertClaudeRequestToAntigravity_BypassModeDropsGeminiEPrefixSignature
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("expected Gemini E-prefix signed thinking block to be dropped, got %d parts: %s", len(parts), output)
}
@@ -1738,7 +1738,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingBlockWithoutSignature(t *test
outputStr := string(output)
// Without signature, thinking block should be removed (not converted to text)
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
}
@@ -1775,12 +1775,12 @@ func TestConvertClaudeRequestToAntigravity_ToolDeclarations(t *testing.T) {
outputStr := string(output)
// Check tools structure
- tools := gjson.Get(outputStr, "request.tools")
+ tools := ojson.Get(outputStr, "request.tools")
if !tools.Exists() {
t.Error("Tools should exist in output")
}
- funcDecl := gjson.Get(outputStr, "request.tools.0.functionDeclarations.0")
+ funcDecl := ojson.Get(outputStr, "request.tools.0.functionDeclarations.0")
if funcDecl.Get("name").String() != "test_tool" {
t.Errorf("Expected tool name 'test_tool', got '%s'", funcDecl.Get("name").String())
}
@@ -1812,7 +1812,7 @@ func TestConvertClaudeRequestToAntigravity_DeduplicatesAndDisambiguatesTools(t *
}`)
out, _ := ConvertClaudeRequestToAntigravity("gemini-3-flash", inputJSON, false)
- declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
+ declarations := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations").Array()
if len(declarations) != 3 {
t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out)
}
@@ -1821,13 +1821,13 @@ func TestConvertClaudeRequestToAntigravity_DeduplicatesAndDisambiguatesTools(t *
if firstMapped == secondMapped || len(secondMapped) > 64 {
t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped)
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
}
}
@@ -1845,8 +1845,8 @@ func TestConvertClaudeRequestToAntigravity_MapsToolResultNameOnce(t *testing.T)
}`)
out, _ := ConvertClaudeRequestToAntigravity("gemini-3-flash", inputJSON, false)
- callName := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String()
- responseName := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String()
+ callName := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionCall.name").String()
+ responseName := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionResponse.name").String()
if callName == "" || responseName != callName {
t.Fatalf("function names call=%q response=%q, want the same non-empty mapping. Output: %s", callName, responseName, out)
}
@@ -1879,12 +1879,12 @@ func TestConvertClaudeRequestToAntigravity_ToolChoice_SpecificTool(t *testing.T)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3-flash-preview", inputJSON, false)
outputStr := string(output)
- if got := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
+ if got := ojson.Get(outputStr, "request.toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
}
- allowed := gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array()
+ allowed := ojson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if len(allowed) != 1 || allowed[0].String() != "json" {
- t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
+ t.Fatalf("Expected allowedFunctionNames ['json'], got %s", ojson.Get(outputStr, "request.toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
}
}
@@ -1910,7 +1910,7 @@ func TestConvertClaudeRequestToAntigravity_ToolUse(t *testing.T) {
outputStr := string(output)
// Now we expect only 1 part (tool_use), no dummy thinking block injected
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (tool only, no dummy injection), got %d", len(parts))
}
@@ -1965,7 +1965,7 @@ func TestConvertClaudeRequestToAntigravity_ToolUsePreservesPresentNonObjectInput
}`, inputField))
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- part := gjson.GetBytes(output, "request.contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0")
functionCall := part.Get("functionCall")
if tc.wantFunctionCall {
if !functionCall.Exists() {
@@ -2005,7 +2005,7 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_DropsInvalidThoughtSignatureO
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- part := gjson.GetBytes(output, "request.contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0")
if !part.Get("functionCall").Exists() {
t.Fatalf("functionCall should be preserved, output: %s", output)
@@ -2068,7 +2068,7 @@ func TestConvertClaudeRequestToAntigravity_ToolUse_DoesNotReuseThinkingSignature
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
outputStr := string(output)
- part := gjson.Get(outputStr, "request.contents.1.parts.1")
+ part := ojson.Get(outputStr, "request.contents.1.parts.1")
if part.Get("functionCall.name").String() != "get_weather" {
t.Errorf("Expected functionCall, got %s", part.Raw)
}
@@ -2107,7 +2107,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinking(t *testing.T) {
outputStr := string(output)
// Verify order: Thinking block MUST be first (now in contents.1 due to user message)
- parts := gjson.Get(outputStr, "request.contents.1.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.1.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts))
}
@@ -2157,7 +2157,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderTextAfterFunctionCall(t *testi
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 3 {
t.Fatalf("Expected 3 parts, got %d", len(parts))
}
@@ -2206,7 +2206,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderParallelFunctionCalls(t *testi
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 4 {
t.Fatalf("Expected 4 parts, got %d", len(parts))
}
@@ -2251,7 +2251,7 @@ func TestConvertClaudeRequestToAntigravity_AlignsPermutedParallelToolResultsWith
}`)
output, _ := ConvertClaudeRequestToAntigravity("gemini-3.7-flash-high", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.1.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.1.parts").Array()
if len(parts) != 8 {
t.Fatalf("parts = %d, want eight parts; output=%s", len(parts), output)
}
@@ -2314,7 +2314,7 @@ func TestConvertClaudeRequestToAntigravity_ReorderThinkingAndTextBeforeFunctionC
outputStr := string(output)
// contents.1 = assistant message (contents.0 = user)
- parts := gjson.Get(outputStr, "request.contents.1.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.1.parts").Array()
if len(parts) != 4 {
t.Fatalf("Expected 4 parts, got %d", len(parts))
}
@@ -2366,7 +2366,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResult(t *testing.T) {
outputStr := string(output)
// Check function response conversion
- funcResp := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
if !funcResp.Exists() {
t.Error("functionResponse should exist")
}
@@ -2391,14 +2391,14 @@ func TestConvertClaudeRequestToAntigravity_NonThinkingClaudePreservesToolResultA
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- contents := gjson.GetBytes(output, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected model turn, function response turn, and text turn; got %d: %s", len(contents), output)
}
- if !gjson.GetBytes([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
+ if !ojson.GetBytesNoCopy([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
t.Fatalf("expected function response immediately after model turn: %s", output)
}
- if got := gjson.GetBytes([]byte(contents[2].Raw), "parts.0.text").String(); got != "Continue after the tool result." {
+ if got := ojson.GetBytesNoCopy([]byte(contents[2].Raw), "parts.0.text").String(); got != "Continue after the tool result." {
t.Fatalf("text turn = %q, want reminder text", got)
}
}
@@ -2414,14 +2414,14 @@ func TestConvertClaudeRequestToAntigravity_InterveningSystemReminderPreservesToo
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
- contents := gjson.GetBytes(output, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected model turn, function response turn, and reminder turn; got %d: %s", len(contents), output)
}
- if !gjson.GetBytes([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
+ if !ojson.GetBytesNoCopy([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
t.Fatalf("expected function response immediately after model turn: %s", output)
}
- if got := gjson.GetBytes([]byte(contents[2].Raw), "parts.0.text").String(); got != "\nSystem reminder instruction.\n " {
+ if got := ojson.GetBytesNoCopy([]byte(contents[2].Raw), "parts.0.text").String(); got != "\nSystem reminder instruction.\n " {
t.Fatalf("text turn = %q, want reminder text", got)
}
}
@@ -2437,14 +2437,14 @@ func TestConvertClaudeRequestToAntigravity_InterveningDeveloperReminderPreserves
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- contents := gjson.GetBytes(output, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected model turn, function response turn, and reminder turn; got %d: %s", len(contents), output)
}
- if !gjson.GetBytes([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
+ if !ojson.GetBytesNoCopy([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
t.Fatalf("expected function response immediately after model turn: %s", output)
}
- if got := gjson.GetBytes([]byte(contents[2].Raw), "parts.0.text").String(); got != "\nDeveloper reminder instruction.\n " {
+ if got := ojson.GetBytesNoCopy([]byte(contents[2].Raw), "parts.0.text").String(); got != "\nDeveloper reminder instruction.\n " {
t.Fatalf("text turn = %q, want reminder text", got)
}
}
@@ -2463,11 +2463,11 @@ func TestConvertClaudeRequestToAntigravity_ParallelToolResultsAcrossMessagesPres
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- contents := gjson.GetBytes(output, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("expected model turn and merged function response turn; got %d: %s", len(contents), output)
}
- parts := gjson.GetBytes([]byte(contents[1].Raw), "parts").Array()
+ parts := ojson.GetBytesNoCopy([]byte(contents[1].Raw), "parts").Array()
if len(parts) != 2 {
t.Fatalf("expected 2 function response parts in single user turn, got %d: %s", len(parts), contents[1].Raw)
}
@@ -2497,21 +2497,21 @@ func TestConvertClaudeRequestToAntigravity_ClaudeThinkingPreservesToolResultAdja
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
- contents := gjson.GetBytes(output, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected model turn, function response turn, and text turn; got %d: %s", len(contents), output)
}
- modelParts := gjson.GetBytes([]byte(contents[0].Raw), "parts").Array()
+ modelParts := ojson.GetBytesNoCopy([]byte(contents[0].Raw), "parts").Array()
if len(modelParts) < 2 || !modelParts[0].Get("thought").Bool() {
t.Fatalf("expected thinking block in model turn: %s", contents[0].Raw)
}
if modelParts[0].Get("thoughtSignature").String() != antigravitySignature {
t.Fatalf("expected thoughtSignature %q, got %q", antigravitySignature, modelParts[0].Get("thoughtSignature").String())
}
- if !gjson.GetBytes([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
+ if !ojson.GetBytesNoCopy([]byte(contents[1].Raw), "parts.0.functionResponse").Exists() {
t.Fatalf("expected function response immediately after model turn: %s", output)
}
- if got := gjson.GetBytes([]byte(contents[2].Raw), "parts.0.text").String(); got != "Check details." {
+ if got := ojson.GetBytesNoCopy([]byte(contents[2].Raw), "parts.0.text").String(); got != "Check details." {
t.Fatalf("text turn = %q, want text", got)
}
}
@@ -2558,7 +2558,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultName_TouluFormat(t *testing
output, _ := ConvertClaudeRequestToAntigravity("claude-haiku-4-5-20251001", inputJSON, false)
outputStr := string(output)
- funcResp0 := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
+ funcResp0 := ojson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
if !funcResp0.Exists() {
t.Fatal("first functionResponse should exist")
}
@@ -2566,7 +2566,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultName_TouluFormat(t *testing
t.Errorf("Expected name 'Glob' for toolu_ format, got '%s'", got)
}
- funcResp1 := gjson.Get(outputStr, "request.contents.1.parts.1.functionResponse")
+ funcResp1 := ojson.Get(outputStr, "request.contents.1.parts.1.functionResponse")
if !funcResp1.Exists() {
t.Fatal("second functionResponse should exist")
}
@@ -2606,7 +2606,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultName_CustomFormat(t *testin
output, _ := ConvertClaudeRequestToAntigravity("claude-haiku-4-5-20251001", inputJSON, false)
outputStr := string(output)
- funcResp := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.1.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -2635,7 +2635,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultName_NoMatchingToolUse_Heur
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -2664,7 +2664,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultName_NoMatchingToolUse_RawI
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -2694,7 +2694,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingConfig(t *testing.T) {
outputStr := string(output)
// Check thinking config conversion (only if model supports thinking in registry)
- thinkingConfig := gjson.Get(outputStr, "request.generationConfig.thinkingConfig")
+ thinkingConfig := ojson.Get(outputStr, "request.generationConfig.thinkingConfig")
if thinkingConfig.Exists() {
if thinkingConfig.Get("thinkingBudget").Int() != 8000 {
t.Errorf("Expected thinkingBudget 8000, got %d", thinkingConfig.Get("thinkingBudget").Int())
@@ -2731,7 +2731,7 @@ func TestConvertClaudeRequestToAntigravity_ImageContent(t *testing.T) {
outputStr := string(output)
// Check inline data conversion
- inlineData := gjson.Get(outputStr, "request.contents.0.parts.0.inlineData")
+ inlineData := ojson.Get(outputStr, "request.contents.0.parts.0.inlineData")
if !inlineData.Exists() {
t.Error("inlineData should exist")
}
@@ -2756,7 +2756,7 @@ func TestConvertClaudeRequestToAntigravity_GenerationConfig(t *testing.T) {
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- genConfig := gjson.Get(outputStr, "request.generationConfig")
+ genConfig := ojson.Get(outputStr, "request.generationConfig")
if genConfig.Get("temperature").Float() != 0.7 {
t.Errorf("Expected temperature 0.7, got %f", genConfig.Get("temperature").Float())
}
@@ -2798,7 +2798,7 @@ func TestConvertClaudeRequestToAntigravity_TrailingUnsignedThinking_Removed(t *t
outputStr := string(output)
// The last part of the last assistant message should NOT be a thinking block
- lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
+ lastMessageParts := ojson.Get(outputStr, "request.contents.1.parts")
if !lastMessageParts.IsArray() {
t.Fatal("Last message should have parts array")
}
@@ -2844,7 +2844,7 @@ func TestConvertClaudeRequestToAntigravity_TrailingSignedThinking_Kept(t *testin
outputStr := string(output)
// The signed thinking block should be preserved
- lastMessageParts := gjson.Get(outputStr, "request.contents.1.parts")
+ lastMessageParts := ojson.Get(outputStr, "request.contents.1.parts")
parts := lastMessageParts.Array()
if len(parts) < 2 {
t.Error("Signed thinking block should be preserved")
@@ -2874,7 +2874,7 @@ func TestConvertClaudeRequestToAntigravity_MiddleUnsignedThinking_Removed(t *tes
outputStr := string(output)
// Unsigned thinking should be removed entirely
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part (thinking removed), got %d", len(parts))
}
@@ -2912,7 +2912,7 @@ func TestConvertClaudeRequestToAntigravity_ToolAndThinking_HintInjected(t *testi
outputStr := string(output)
// System instruction should contain the interleaved thinking hint
- sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
+ sysInstruction := ojson.Get(outputStr, "request.systemInstruction")
if !sysInstruction.Exists() {
t.Fatal("systemInstruction should exist")
}
@@ -2950,7 +2950,7 @@ func TestConvertClaudeRequestToAntigravity_ToolsOnly_NoHint(t *testing.T) {
outputStr := string(output)
// System instruction should NOT contain the hint
- sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
+ sysInstruction := ojson.Get(outputStr, "request.systemInstruction")
if sysInstruction.Exists() {
for _, part := range sysInstruction.Get("parts").Array() {
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
@@ -2973,7 +2973,7 @@ func TestConvertClaudeRequestToAntigravity_ThinkingOnly_NoHint(t *testing.T) {
outputStr := string(output)
// System instruction should NOT contain the hint (no tools)
- sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
+ sysInstruction := ojson.Get(outputStr, "request.systemInstruction")
if sysInstruction.Exists() {
for _, part := range sysInstruction.Get("parts").Array() {
if strings.Contains(part.Get("text").String(), "Interleaved thinking is enabled") {
@@ -3014,12 +3014,12 @@ func TestConvertClaudeRequestToAntigravity_ToolResultNoContent(t *testing.T) {
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, true)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Errorf("Result is not valid JSON:\n%s", outputStr)
}
// Verify the functionResponse has a valid result value
- fr := gjson.Get(outputStr, "request.contents.1.parts.0.functionResponse.response.result")
+ fr := ojson.Get(outputStr, "request.contents.1.parts.0.functionResponse.response.result")
if !fr.Exists() {
t.Error("functionResponse.response.result should exist")
}
@@ -3057,7 +3057,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultNullContent(t *testing.T) {
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, true)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Errorf("Result is not valid JSON:\n%s", outputStr)
}
}
@@ -3098,12 +3098,12 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) {
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
// Image should be inside functionResponse.parts, not as outer sibling part
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3127,7 +3127,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithImage(t *testing.T) {
}
// Image should NOT be in outer parts (only functionResponse part should exist)
- outerParts := gjson.Get(outputStr, "request.contents.0.parts")
+ outerParts := ojson.Get(outputStr, "request.contents.0.parts")
if outerParts.IsArray() && len(outerParts.Array()) > 1 {
t.Errorf("Expected only 1 outer part (functionResponse), got %d", len(outerParts.Array()))
}
@@ -3162,11 +3162,11 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3186,7 +3186,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithSingleImage(t *testing.
}
// Image should NOT be in outer parts
- outerParts := gjson.Get(outputStr, "request.contents.0.parts")
+ outerParts := ojson.Get(outputStr, "request.contents.0.parts")
if outerParts.IsArray() && len(outerParts.Array()) > 1 {
t.Errorf("Expected only 1 outer part, got %d", len(outerParts.Array()))
}
@@ -3225,11 +3225,11 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3263,7 +3263,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithMultipleImagesAndTexts(
}
// Only 1 outer part (the functionResponse itself)
- outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ outerParts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(outerParts) != 1 {
t.Errorf("Expected 1 outer part, got %d", len(outerParts))
}
@@ -3299,11 +3299,11 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *t
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3326,7 +3326,7 @@ func TestConvertClaudeRequestToAntigravity_ToolResultWithOnlyMultipleImages(t *t
}
// Only 1 outer part
- outerParts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ outerParts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(outerParts) != 1 {
t.Errorf("Expected 1 outer part, got %d", len(outerParts))
}
@@ -3359,11 +3359,11 @@ func TestConvertClaudeRequestToAntigravity_ToolResultImageNotBase64(t *testing.T
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3412,11 +3412,11 @@ func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingData(t *testing
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3462,11 +3462,11 @@ func TestConvertClaudeRequestToAntigravity_ToolResultImageMissingMediaType(t *te
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
outputStr := string(output)
- if !gjson.Valid(outputStr) {
+ if !ojson.Valid(outputStr) {
t.Fatalf("Result is not valid JSON:\n%s", outputStr)
}
- funcResp := gjson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
+ funcResp := ojson.Get(outputStr, "request.contents.0.parts.0.functionResponse")
if !funcResp.Exists() {
t.Fatal("functionResponse should exist")
}
@@ -3520,10 +3520,10 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_DropsRedactedThinkingBlock
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6", inputJSON, false)
- assistantParts := gjson.GetBytes(output, "request.contents.1.parts").Array()
+ assistantParts := ojson.GetBytesNoCopy(output, "request.contents.1.parts").Array()
if len(assistantParts) != 1 {
t.Fatalf("Expected 1 part (redacted thinking dropped), got %d: %s",
- len(assistantParts), gjson.GetBytes(output, "request.contents.1.parts").Raw)
+ len(assistantParts), ojson.GetBytesNoCopy(output, "request.contents.1.parts").Raw)
}
if assistantParts[0].Get("thought").Bool() {
t.Fatal("Redacted thinking block with empty text should be dropped")
@@ -3568,10 +3568,10 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_DropsWrappedRedactedThinki
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", inputJSON, false)
- assistantParts := gjson.GetBytes(output, "request.contents.1.parts").Array()
+ assistantParts := ojson.GetBytesNoCopy(output, "request.contents.1.parts").Array()
if len(assistantParts) != 1 {
t.Fatalf("Expected 1 part (wrapped redacted thinking dropped), got %d: %s",
- len(assistantParts), gjson.GetBytes(output, "request.contents.1.parts").Raw)
+ len(assistantParts), ojson.GetBytesNoCopy(output, "request.contents.1.parts").Raw)
}
if assistantParts[0].Get("text").String() != "Answer" {
t.Fatalf("Expected text part preserved, got: %s", assistantParts[0].Raw)
@@ -3603,7 +3603,7 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_DropsWrappedRedactedThinki
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", inputJSON, false)
- toolPart := gjson.GetBytes(output, "request.contents.0.parts.0")
+ toolPart := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0")
if !toolPart.Get("functionCall").Exists() {
t.Fatalf("Expected tool part preserved: %s", output)
}
@@ -3643,7 +3643,7 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_KeepsNonEmptyThinking(t *t
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6", inputJSON, false)
- assistantParts := gjson.GetBytes(output, "request.contents.1.parts").Array()
+ assistantParts := ojson.GetBytesNoCopy(output, "request.contents.1.parts").Array()
if len(assistantParts) != 2 {
t.Fatalf("Expected 2 parts (thinking + text), got %d", len(assistantParts))
}
@@ -3701,11 +3701,11 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_MultiTurnRedactedThinking(
output, _ := ConvertClaudeRequestToAntigravity("claude-opus-4-6", inputJSON, false)
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
t.Fatalf("Output is not valid JSON: %s", string(output))
}
- firstAssistantParts := gjson.GetBytes(output, "request.contents.1.parts").Array()
+ firstAssistantParts := ojson.GetBytesNoCopy(output, "request.contents.1.parts").Array()
for _, p := range firstAssistantParts {
if p.Get("thought").Bool() {
t.Fatal("Redacted thinking should be dropped from first assistant message")
@@ -3723,10 +3723,10 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_MultiTurnRedactedThinking(
}
if !hasText || !hasFC {
t.Fatalf("First assistant should have text + functionCall, got: %s",
- gjson.GetBytes(output, "request.contents.1.parts").Raw)
+ ojson.GetBytesNoCopy(output, "request.contents.1.parts").Raw)
}
- secondAssistantParts := gjson.GetBytes(output, "request.contents.3.parts").Array()
+ secondAssistantParts := ojson.GetBytesNoCopy(output, "request.contents.3.parts").Array()
for _, p := range secondAssistantParts {
if p.Get("thought").Bool() {
t.Fatal("Redacted thinking should be dropped from second assistant message")
@@ -3734,7 +3734,7 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_MultiTurnRedactedThinking(
}
if len(secondAssistantParts) != 1 || secondAssistantParts[0].Get("text").String() != "Here are the files." {
t.Fatalf("Second assistant should have only text part, got: %s",
- gjson.GetBytes(output, "request.contents.3.parts").Raw)
+ ojson.GetBytesNoCopy(output, "request.contents.3.parts").Raw)
}
}
@@ -3757,7 +3757,7 @@ func TestConvertClaudeRequestToAntigravity_ToolAndThinking_NoExistingSystem(t *t
outputStr := string(output)
// System instruction should be created with hint
- sysInstruction := gjson.Get(outputStr, "request.systemInstruction")
+ sysInstruction := ojson.Get(outputStr, "request.systemInstruction")
if !sysInstruction.Exists() {
t.Fatal("systemInstruction should be created when tools + thinking are active")
}
@@ -3815,7 +3815,7 @@ func TestConvertClaudeRequestToAntigravityStripsPropertyNames(t *testing.T) {
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- decls := gjson.GetBytes(output, "request.tools.0.functionDeclarations")
+ decls := ojson.GetBytesNoCopy(output, "request.tools.0.functionDeclarations")
if !decls.IsArray() || len(decls.Array()) != 2 {
t.Fatalf("expected two function declarations, got: %s", decls.Raw)
}
@@ -3847,11 +3847,11 @@ func TestConvertClaudeRequestToAntigravityToolChoiceNoneOmitsTools(t *testing.T)
"tool_choice":` + tc.toolChoice + `
}`)
out, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
t.Fatalf("expected mode NONE, got %q", got)
}
- if gjson.GetBytes(out, "request.tools").Exists() {
- t.Fatalf("expected request.tools to be omitted, got %s", gjson.GetBytes(out, "request.tools").Raw)
+ if ojson.GetBytesNoCopy(out, "request.tools").Exists() {
+ t.Fatalf("expected request.tools to be omitted, got %s", ojson.GetBytesNoCopy(out, "request.tools").Raw)
}
})
}
@@ -3874,13 +3874,13 @@ func TestConvertClaudeRequestToAntigravityToolChoiceNoneOmitsInterleavedThinking
"thinking":{"type":"enabled","budget_tokens":1024}
}`)
out, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5-thinking", inputJSON, false)
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
t.Fatalf("expected mode NONE, got %q", got)
}
- if gjson.GetBytes(out, "request.tools").Exists() {
- t.Fatalf("expected request.tools to be omitted, got %s", gjson.GetBytes(out, "request.tools").Raw)
+ if ojson.GetBytesNoCopy(out, "request.tools").Exists() {
+ t.Fatalf("expected request.tools to be omitted, got %s", ojson.GetBytesNoCopy(out, "request.tools").Raw)
}
- sysInstr := gjson.GetBytes(out, "request.systemInstruction").Raw
+ sysInstr := ojson.GetBytesNoCopy(out, "request.systemInstruction").Raw
if strings.Contains(sysInstr, "Interleaved thinking is enabled") {
t.Fatalf("expected interleaved thinking hint to be omitted when tool_choice is none, got systemInstruction: %s", sysInstr)
}
@@ -3916,8 +3916,8 @@ func TestConvertClaudeRequestToAntigravity_FunctionResponseJSONRef(t *testing.T)
}`)
output, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-5", inputJSON, false)
- result := gjson.GetBytes(output, "request.contents.1.parts.0.functionResponse.response.result")
- if result.Type != gjson.String {
+ result := ojson.GetBytesNoCopy(output, "request.contents.1.parts.0.functionResponse.response.result")
+ if result.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got %s (raw: %s)", result.Type, result.Raw)
}
if !strings.Contains(result.String(), "#/components/schemas/ErrorModel") {
@@ -4008,10 +4008,10 @@ func TestConvertClaudeRequestToAntigravity_PreservesToolResultErrors(t *testing.
if errTranslate != nil {
t.Fatal(errTranslate)
}
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
t.Fatalf("invalid translated JSON: %s", output)
}
- response := gjson.GetBytes(output, "request.contents.1.parts.0.functionResponse")
+ response := ojson.GetBytesNoCopy(output, "request.contents.1.parts.0.functionResponse")
if response.Get("id").String() != "call_1" || response.Get("name").String() != "Read" {
t.Fatalf("tool result lost its call identity: %s", response.Raw)
}
diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go
index 81778875443..ab41a1f218d 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_response.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_response.go
@@ -9,8 +9,8 @@ package claude
import (
"bytes"
"context"
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"sync/atomic"
"time"
@@ -21,8 +21,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// decodeSignature decodes R... (2-layer Base64) to E... (1-layer Base64, Anthropic format).
@@ -102,7 +101,7 @@ type Params struct {
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
var toolUseIDCounter uint64
-func antigravityClaudeToolUseID(modelName string, functionCall gjson.Result, fallback string) string {
+func antigravityClaudeToolUseID(modelName string, functionCall ojson.Result, fallback string) string {
if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini {
if stableID := util.GeminiClaudeToolUseID(functionCall.Get("id").String(), functionCall.Get("name").String(), functionCall.Get("args").Raw); stableID != "" {
return stableID
@@ -140,7 +139,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
// Request metadata is resolved once per stream; callers pass the same
// request bytes for the stream's lifetime.
if !params.RequestMetadataResolved {
- params.ModelName = gjson.GetBytes(requestRawJSON, "model").String()
+ params.ModelName = strings.Clone(ojson.GetBytesNoCopy(requestRawJSON, "model").String())
params.WebSearchStreamMode = shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON)
params.RequestMetadataResolved = true
}
@@ -175,14 +174,14 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.CurrentThinkingText.Reset()
}
sigValue := formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind)
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
appendEvent("content_block_delta", string(data))
params.CurrentThinkingSigned = true
params.HasContent = true
}
flushPendingToolInput := func() {
if params.ResponseType == 3 && params.CurrentToolPendingEmpty && !params.CurrentToolInputEmitted {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", "{}")
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", "{}")
appendEvent("content_block_delta", string(data))
params.CurrentToolInputEmitted = true
params.CurrentToolPendingEmpty = false
@@ -209,7 +208,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
return
}
sigValue := formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind)
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue)
appendEvent("content_block_delta", string(data))
params.CurrentThinkingSigned = true
params.HasContent = true
@@ -237,31 +236,31 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
if !params.HasFirstResponse {
// Create the initial message structure with default values according to Claude Code API specification
// This follows the Claude Code API specification for streaming message initialization
- messageStartTemplate := []byte(`{"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0}}}`)
+ messageStartDoc := ojson.NewDoc([]byte(`{"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0}}}`))
// Use cpaUsageMetadata within the message_start event for Claude.
- if promptTokenCount := gjson.GetBytes(rawJSON, "response.cpaUsageMetadata.promptTokenCount"); promptTokenCount.Exists() {
- messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.usage.input_tokens", promptTokenCount.Int())
+ if promptTokenCount := ojson.GetBytesNoCopy(rawJSON, "response.cpaUsageMetadata.promptTokenCount"); promptTokenCount.Exists() {
+ _ = messageStartDoc.Set("message.usage.input_tokens", promptTokenCount.Int())
}
- if candidatesTokenCount := gjson.GetBytes(rawJSON, "response.cpaUsageMetadata.candidatesTokenCount"); candidatesTokenCount.Exists() && !webSearchStreamMode {
- messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.usage.output_tokens", candidatesTokenCount.Int())
+ if candidatesTokenCount := ojson.GetBytesNoCopy(rawJSON, "response.cpaUsageMetadata.candidatesTokenCount"); candidatesTokenCount.Exists() && !webSearchStreamMode {
+ _ = messageStartDoc.Set("message.usage.output_tokens", candidatesTokenCount.Int())
}
// Override default values with actual response metadata if available from the Antigravity response
- if modelVersionResult := gjson.GetBytes(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
- messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.model", modelVersionResult.String())
+ if modelVersionResult := ojson.GetBytesNoCopy(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
+ _ = messageStartDoc.Set("message.model", modelVersionResult.String())
}
- if responseIDResult := gjson.GetBytes(rawJSON, "response.responseId"); responseIDResult.Exists() {
- messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.id", responseIDResult.String())
+ if responseIDResult := ojson.GetBytesNoCopy(rawJSON, "response.responseId"); responseIDResult.Exists() {
+ _ = messageStartDoc.Set("message.id", responseIDResult.String())
}
- appendEvent("message_start", string(messageStartTemplate))
+ appendEvent("message_start", messageStartDoc.String())
params.HasFirstResponse = true
}
handledWebSearchGrounding := false
if webSearchStreamMode && !params.HasWebSearchTool {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
if groundingMetadata := antigravityGroundingMetadata(root); groundingMetadata.Exists() {
toolUseID := newClaudeWebSearchToolUseID()
textContent := params.WebSearchTextBuffer.String() + antigravityTextContent(root)
@@ -277,7 +276,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
// Process the response parts array from the backend client
// Each part can contain text content, thinking content, or function calls
- partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
+ partsResult := ojson.GetBytesNoCopy(rawJSON, "response.candidates.0.content.parts")
if partsResult.IsArray() && webSearchStreamMode && !params.HasWebSearchTool && !handledWebSearchGrounding {
appendWebSearchBufferedText(partsResult, ¶ms.WebSearchTextBuffer)
} else if partsResult.IsArray() && !handledWebSearchGrounding {
@@ -317,7 +316,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
}
if params.ResponseType == 2 {
params.CurrentThinkingText.WriteString(partText)
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
appendEvent("content_block_delta", string(data))
params.HasContent = true
} else {
@@ -328,7 +327,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex))
params.CurrentThinkingSigned = false
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText)
appendEvent("content_block_delta", string(data))
params.ResponseType = 2
params.HasContent = true
@@ -345,7 +344,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
signatureTargetsVisibleText = appendPartSignature(thoughtSignatureResult.String(), geminiClaudeCarrierNext, geminiClaudeCarrierText)
}
if params.ResponseType == 1 {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText)
appendEvent("content_block_delta", string(data))
params.HasContent = true
} else if partText != "" {
@@ -355,7 +354,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.ResponseIndex++
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex))
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", partText)
appendEvent("content_block_delta", string(data))
params.ResponseType = 1
params.HasContent = true
@@ -398,7 +397,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.CurrentToolPendingEmpty = false
}
if partialJSON != "" || !params.CurrentToolPendingEmpty {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", partialJSON)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", partialJSON)
appendEvent("content_block_delta", string(data))
}
}
@@ -431,21 +430,21 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.ResponseIndex++
}
- params.CurrentToolName = fcName
+ params.CurrentToolName = strings.Clone(fcName)
params.CurrentToolIsParameterless = util.ClaudeToolIsParameterless(fcName, originalRequestRawJSON)
params.CurrentToolInputEmitted = false
params.CurrentToolPendingEmpty = false
// Start a new tool use content block
// This creates the structure for a function call in Claude Code format
// Create the tool use block with unique ID and function details
- data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, params.ResponseIndex))
+ dataDoc := ojson.NewDocString(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, params.ResponseIndex))
fallbackID := fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&toolUseIDCounter, 1))
- data, _ = sjson.SetBytes(data, "content_block.id", antigravityClaudeToolUseID(modelName, functionCallResult, fallbackID))
- data, _ = sjson.SetBytes(data, "content_block.name", fcName)
+ _ = dataDoc.Set("content_block.id", antigravityClaudeToolUseID(modelName, functionCallResult, fallbackID))
+ _ = dataDoc.Set("content_block.name", fcName)
if cache.GetModelGroup(modelName) == "claude" && toolSignature != "" {
- data, _ = sjson.SetBytes(data, "content_block.signature", formatClaudeSignatureValue(modelName, toolSignature))
+ _ = dataDoc.Set("content_block.signature", formatClaudeSignatureValue(modelName, toolSignature))
}
- appendEvent("content_block_start", string(data))
+ appendEvent("content_block_start", dataDoc.String())
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
cleanArgs := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, fcName, originalRequestRawJSON, requestRawJSON)
@@ -462,7 +461,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
params.CurrentToolInputEmitted = true
}
if cleanArgs != "" || !params.CurrentToolPendingEmpty {
- data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", cleanArgs)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", cleanArgs)
appendEvent("content_block_delta", string(data))
}
}
@@ -474,15 +473,15 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
}
}
- if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
+ if finishReasonResult := ojson.GetBytesNoCopy(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
params.HasFinishReason = true
- params.FinishReason = finishReasonResult.String()
- if params.FinishReason != "" {
+ if finishReason := strings.TrimSpace(finishReasonResult.String()); finishReason != "" {
+ params.FinishReason = strings.Clone(finishReason)
flushPendingToolInput()
}
}
- if usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata"); usageResult.Exists() {
+ if usageResult := ojson.GetBytesNoCopy(rawJSON, "response.usageMetadata"); usageResult.Exists() {
params.HasUsageMetadata = true
rawPromptTokenCount := usageResult.Get("promptTokenCount").Int()
params.CachedTokenCount = usageResult.Get("cachedContentTokenCount").Int()
@@ -514,7 +513,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR
return [][]byte{output}
}
-func appendWebSearchBufferedText(partsResult gjson.Result, buffer *strings.Builder) {
+func appendWebSearchBufferedText(partsResult ojson.Result, buffer *strings.Builder) {
for _, partResult := range partsResult.Array() {
if partResult.Get("thought").Bool() || partResult.Get("functionCall").Exists() {
continue
@@ -532,7 +531,7 @@ func appendBufferedWebSearchTextBlock(params *Params, appendEvent func(string, s
return
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex))
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", text)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex)), "delta.text", text)
appendEvent("content_block_delta", string(data))
params.ResponseType = 1
params.HasContent = true
@@ -553,7 +552,7 @@ func appendFinalEvents(params *Params, output *[]byte, force bool) {
}
if params.ResponseType == 3 && params.CurrentToolPendingEmpty && !params.CurrentToolInputEmitted {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", "{}")
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, params.ResponseIndex)), "delta.partial_json", "{}")
*output = translatorcommon.AppendSSEEventString(*output, "content_block_delta", string(data), 3)
params.CurrentToolInputEmitted = true
params.CurrentToolPendingEmpty = false
@@ -574,36 +573,40 @@ func appendFinalEvents(params *Params, output *[]byte, force bool) {
}
}
- delta := []byte(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":%d,"output_tokens":%d}}`, stopReason, params.PromptTokenCount, usageOutputTokens))
+ deltaDoc := ojson.NewDocString(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":%d,"output_tokens":%d}}`, stopReason, params.PromptTokenCount, usageOutputTokens))
if params.WebSearchRequests > 0 {
- delta, _ = sjson.SetBytes(delta, "usage.server_tool_use.web_search_requests", params.WebSearchRequests)
+ _ = deltaDoc.Set("usage.server_tool_use.web_search_requests", params.WebSearchRequests)
}
// Add cache_read_input_tokens if cached tokens are present (indicates prompt caching is working)
if params.CachedTokenCount > 0 {
- var err error
- delta, err = sjson.SetBytes(delta, "usage.cache_read_input_tokens", params.CachedTokenCount)
- if err != nil {
- log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", err)
+ if errSet := deltaDoc.Set("usage.cache_read_input_tokens", params.CachedTokenCount); errSet != nil {
+ log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", errSet)
}
}
- *output = translatorcommon.AppendSSEEventString(*output, "message_delta", string(delta), 3)
+ *output = translatorcommon.AppendSSEEventString(*output, "message_delta", deltaDoc.String(), 3)
params.HasSentFinalEvents = true
}
func resolveStopReason(params *Params) string {
- if params.HasToolUse {
+ return resolveAntigravityClaudeStopReason(params.FinishReason, params.HasToolUse)
+}
+
+func resolveAntigravityClaudeStopReason(finishReason string, hasToolUse bool) string {
+ if hasToolUse {
return "tool_use"
}
- switch params.FinishReason {
+ switch strings.ToUpper(strings.TrimSpace(finishReason)) {
case "MAX_TOKENS":
return "max_tokens"
- case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
+ case "SAFETY", "RECITATION", "PROHIBITED_CONTENT", "SPII", "BLOCKLIST", "IMAGE_SAFETY", "MALFORMED_RESPONSE", "MALFORMED_FUNCTION_CALL":
+ return "refusal"
+ case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN", "":
+ return "end_turn"
+ default:
return "end_turn"
}
-
- return "end_turn"
}
// ConvertAntigravityResponseToClaudeNonStream converts a non-streaming Antigravity response to a non-streaming Claude response.
@@ -618,9 +621,9 @@ func resolveStopReason(params *Params) string {
// - []byte: A Claude-compatible JSON response.
func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
toolNameMap := util.DisambiguatedToolNameMap(originalRequestRawJSON)
- modelName := gjson.GetBytes(requestRawJSON, "model").String()
+ modelName := ojson.GetBytesNoCopy(requestRawJSON, "model").String()
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
promptTokens := root.Get("response.usageMetadata.promptTokenCount").Int()
candidateTokens := root.Get("response.usageMetadata.candidatesTokenCount").Int()
thoughtTokens := root.Get("response.usageMetadata.thoughtsTokenCount").Int()
@@ -634,27 +637,26 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
}
}
- responseJSON := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
- responseJSON, _ = sjson.SetBytes(responseJSON, "id", root.Get("response.responseId").String())
- responseJSON, _ = sjson.SetBytes(responseJSON, "model", root.Get("response.modelVersion").String())
- responseJSON, _ = sjson.SetBytes(responseJSON, "usage.input_tokens", promptTokens)
- responseJSON, _ = sjson.SetBytes(responseJSON, "usage.output_tokens", outputTokens)
+ responseDoc := ojson.NewDoc([]byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`))
+ _ = responseDoc.Set("id", root.Get("response.responseId").String())
+ _ = responseDoc.Set("model", root.Get("response.modelVersion").String())
+ _ = responseDoc.Set("usage.input_tokens", promptTokens)
+ _ = responseDoc.Set("usage.output_tokens", outputTokens)
// Add cache_read_input_tokens if cached tokens are present (indicates prompt caching is working)
if cachedTokens > 0 {
- var err error
- responseJSON, err = sjson.SetBytes(responseJSON, "usage.cache_read_input_tokens", cachedTokens)
- if err != nil {
- log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", err)
+ if errSet := responseDoc.Set("usage.cache_read_input_tokens", cachedTokens); errSet != nil {
+ log.Warnf("antigravity claude response: failed to set cache_read_input_tokens: %v", errSet)
}
}
if shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON) {
if groundingMetadata := antigravityGroundingMetadata(root); groundingMetadata.Exists() {
toolUseID := newClaudeWebSearchToolUseID()
- responseJSON, _ = sjson.SetRawBytes(responseJSON, "content", buildClaudeWebSearchContent(toolUseID, antigravityTextContent(root), groundingMetadata))
- responseJSON, _ = sjson.SetBytes(responseJSON, "stop_reason", "end_turn")
- responseJSON, _ = sjson.SetBytes(responseJSON, "usage.server_tool_use.web_search_requests", 1)
- return responseJSON
+ _ = responseDoc.SetRawBytes("content", buildClaudeWebSearchContent(toolUseID, antigravityTextContent(root), groundingMetadata))
+ stopReason := resolveAntigravityClaudeStopReason(root.Get("response.candidates.0.finishReason").String(), false)
+ _ = responseDoc.Set("stop_reason", stopReason)
+ _ = responseDoc.Set("usage.server_tool_use.web_search_requests", 1)
+ return responseDoc.Bytes()
}
}
@@ -676,7 +678,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
return
}
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", textBuilder.String())
+ block, _ = ojson.SetBytes(block, "text", textBuilder.String())
blocks = append(blocks, block)
textBuilder.Reset()
}
@@ -685,13 +687,13 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
return
}
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
+ blockDoc := ojson.NewDoc([]byte(`{"type":"thinking","thinking":""}`))
+ _ = blockDoc.Set("thinking", thinkingBuilder.String())
if thinkingSignature != "" {
sigValue := formatGeminiClaudeCarrierValue(modelName, thinkingSignature, thinkingSignatureDirection, thinkingSignatureTargetKind)
- block, _ = sjson.SetBytes(block, "signature", sigValue)
+ _ = blockDoc.Set("signature", sigValue)
}
- blocks = append(blocks, block)
+ blocks = append(blocks, blockDoc.Bytes())
thinkingBuilder.Reset()
thinkingSignature = ""
thinkingSignatureDirection = geminiClaudeCarrierStandalone
@@ -703,7 +705,7 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
return
}
carrier := []byte(`{"type":"thinking","thinking":"","signature":""}`)
- carrier, _ = sjson.SetBytes(carrier, "signature", formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind))
+ carrier, _ = ojson.SetBytes(carrier, "signature", formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind))
blocks = append(blocks, carrier)
}
@@ -736,19 +738,19 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
if !isClaudeTarget && signature != "" && !signatureAttachedToThought {
appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction)
}
- toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolBlock, _ = sjson.SetBytes(toolBlock, "id", antigravityClaudeToolUseID(modelName, functionCall, fmt.Sprintf("tool_%d", toolIDCounter)))
- toolBlock, _ = sjson.SetBytes(toolBlock, "name", name)
+ toolBlockDoc := ojson.NewDoc([]byte(`{"type":"tool_use","id":"","name":"","input":{}}`))
+ _ = toolBlockDoc.Set("id", antigravityClaudeToolUseID(modelName, functionCall, fmt.Sprintf("tool_%d", toolIDCounter)))
+ _ = toolBlockDoc.Set("name", name)
if isClaudeTarget && signature != "" {
- toolBlock, _ = sjson.SetBytes(toolBlock, "signature", formatClaudeSignatureValue(modelName, signature))
+ _ = toolBlockDoc.Set("signature", formatClaudeSignatureValue(modelName, signature))
}
- if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) && args.IsObject() {
+ if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && ojson.Valid(args.Raw) && args.IsObject() {
cleanArgs := util.CleanClaudeToolArgumentsWithTranslated(args.Raw, name, originalRequestRawJSON, requestRawJSON)
- toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(cleanArgs))
+ _ = toolBlockDoc.SetRaw("input", cleanArgs)
}
- blocks = append(blocks, toolBlock)
+ blocks = append(blocks, toolBlockDoc.Bytes())
hasSemanticContent = true
lastSemanticKind = geminiClaudeCarrierFunction
continue
@@ -821,33 +823,23 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or
flushText()
if len(blocks) > 0 {
- responseJSON, _ = sjson.SetRawBytes(responseJSON, "content", translatorcommon.JoinRawArray(blocks))
- }
-
- stopReason := "end_turn"
- if hasToolCall {
- stopReason = "tool_use"
- } else {
- if finish := root.Get("response.candidates.0.finishReason"); finish.Exists() {
- switch finish.String() {
- case "MAX_TOKENS":
- stopReason = "max_tokens"
- case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
- stopReason = "end_turn"
- default:
- stopReason = "end_turn"
- }
- }
+ _ = responseDoc.SetRawBytes("content", translatorcommon.JoinRawArray(blocks))
+ }
+
+ var finishReason string
+ if finish := root.Get("response.candidates.0.finishReason"); finish.Exists() {
+ finishReason = finish.String()
}
- responseJSON, _ = sjson.SetBytes(responseJSON, "stop_reason", stopReason)
+ stopReason := resolveAntigravityClaudeStopReason(finishReason, hasToolCall)
+ _ = responseDoc.Set("stop_reason", stopReason)
if promptTokens == 0 && outputTokens == 0 {
if usageMeta := root.Get("response.usageMetadata"); !usageMeta.Exists() {
- responseJSON, _ = sjson.DeleteBytes(responseJSON, "usage")
+ _ = responseDoc.Delete("usage")
}
}
- return responseJSON
+ return responseDoc.Bytes()
}
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go
index 7126e233869..3f61868bcfc 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go
@@ -3,16 +3,15 @@ package claude
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ============================================================================
@@ -29,19 +28,19 @@ func TestConvertAntigravityResponseToClaudeNonStream_WebSearchGrounding(t *testi
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.1-flash-lite", requestJSON, translatedRequestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.0.type").String(); got != "server_tool_use" {
+ if got := ojson.GetBytesNoCopy(output, "content.0.type").String(); got != "server_tool_use" {
t.Fatalf("first content block = %q, want server_tool_use: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.1.type").String(); got != "web_search_tool_result" {
+ if got := ojson.GetBytesNoCopy(output, "content.1.type").String(); got != "web_search_tool_result" {
t.Fatalf("second content block = %q, want web_search_tool_result: %s", got, output)
}
- if got := gjson.GetBytes(output, "usage.server_tool_use.web_search_requests").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(output, "usage.server_tool_use.web_search_requests").Int(); got != 1 {
t.Fatalf("web_search_requests = %d, want 1: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.1.content.0.url").String(); got != "https://example.com/weather" {
+ if got := ojson.GetBytesNoCopy(output, "content.1.content.0.url").String(); got != "https://example.com/weather" {
t.Fatalf("search result url = %q: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.2.citations.0.url").String(); got != "https://example.com/weather" {
+ if got := ojson.GetBytesNoCopy(output, "content.2.citations.0.url").String(); got != "https://example.com/weather" {
t.Fatalf("citation url = %q: %s", got, output)
}
}
@@ -56,10 +55,10 @@ func TestConvertAntigravityResponseToClaudeNonStream_WebSearchGroundingRequiresN
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3-flash-agent", requestJSON, translatedRequestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.0.type").String(); got == "server_tool_use" {
+ if got := ojson.GetBytesNoCopy(output, "content.0.type").String(); got == "server_tool_use" {
t.Fatalf("non-native translated request should not synthesize server_tool_use: %s", output)
}
- if got := gjson.GetBytes(output, "usage.server_tool_use.web_search_requests").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(output, "usage.server_tool_use.web_search_requests").Int(); got != 0 {
t.Fatalf("web_search_requests = %d, want 0: %s", got, output)
}
}
@@ -179,7 +178,7 @@ func TestConvertAntigravityResponseToClaudeStream_WebSearchMessageStartOutputTok
output := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.1-flash-lite", requestJSON, translatedRequestJSON, responseJSON, ¶m), nil)
messageStart := sseDataForEvent(t, string(output), "message_start")
- if got := gjson.Get(messageStart, "message.usage.output_tokens").Int(); got != 0 {
+ if got := ojson.Get(messageStart, "message.usage.output_tokens").Int(); got != 0 {
t.Fatalf("message_start output_tokens = %d, want 0: %s", got, messageStart)
}
}
@@ -188,11 +187,11 @@ func TestConvertAntigravityResponseToClaudeNonStream_EmptyCandidateReturnsConten
requestJSON := []byte(`{"model":"gemini-3-flash-agent"}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3-flash-agent", requestJSON, requestJSON, testEmptyAntigravityResponse(), nil)
- content := gjson.GetBytes(output, "content")
+ content := ojson.GetBytesNoCopy(output, "content")
if !content.IsArray() || len(content.Array()) != 0 {
t.Fatalf("content = %s, want empty array: %s", content.Raw, output)
}
- if got := gjson.GetBytes(output, "stop_reason").String(); got != "end_turn" {
+ if got := ojson.GetBytesNoCopy(output, "stop_reason").String(); got != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn: %s", got, output)
}
}
@@ -219,21 +218,21 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyCandidateClosesMessage(t
}
contentBlockStart := sseDataForEvent(t, outputText, "content_block_start")
- if got := gjson.Get(contentBlockStart, "content_block.type").String(); got != "text" {
+ if got := ojson.Get(contentBlockStart, "content_block.type").String(); got != "text" {
t.Fatalf("empty content block type = %q, want text: %s", got, contentBlockStart)
}
- if text := gjson.Get(contentBlockStart, "content_block.text"); !text.Exists() || text.String() != "" {
+ if text := ojson.Get(contentBlockStart, "content_block.text"); !text.Exists() || text.String() != "" {
t.Fatalf("empty content block text = %s, want empty string: %s", text.Raw, contentBlockStart)
}
messageDelta := sseDataForEvent(t, outputText, "message_delta")
- if got := gjson.Get(messageDelta, "delta.stop_reason").String(); got != "end_turn" {
+ if got := ojson.Get(messageDelta, "delta.stop_reason").String(); got != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn: %s", got, messageDelta)
}
- if got := gjson.Get(messageDelta, "usage.input_tokens").Int(); got != 64214 {
+ if got := ojson.Get(messageDelta, "usage.input_tokens").Int(); got != 64214 {
t.Fatalf("input_tokens = %d, want 64214: %s", got, messageDelta)
}
- if got := gjson.Get(messageDelta, "usage.output_tokens").Int(); got != 0 {
+ if got := ojson.Get(messageDelta, "usage.output_tokens").Int(); got != 0 {
t.Fatalf("output_tokens = %d, want 0: %s", got, messageDelta)
}
}
@@ -257,13 +256,13 @@ func TestConvertAntigravityResponseToClaudeStream_ZeroCandidatesDoesNotCountCach
output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.8-flash-high", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
messageDelta := sseDataForEvent(t, string(output), "message_delta")
- if got := gjson.Get(messageDelta, "usage.input_tokens").Int(); got != 2041 {
+ if got := ojson.Get(messageDelta, "usage.input_tokens").Int(); got != 2041 {
t.Fatalf("input_tokens = %d, want 2041: %s", got, messageDelta)
}
- if outputTokens := gjson.Get(messageDelta, "usage.output_tokens"); !outputTokens.Exists() || outputTokens.Int() != 0 {
+ if outputTokens := ojson.Get(messageDelta, "usage.output_tokens"); !outputTokens.Exists() || outputTokens.Int() != 0 {
t.Fatalf("output_tokens = %s, want 0: %s", outputTokens.Raw, messageDelta)
}
- if got := gjson.Get(messageDelta, "usage.cache_read_input_tokens").Int(); got != 140830 {
+ if got := ojson.Get(messageDelta, "usage.cache_read_input_tokens").Int(); got != 140830 {
t.Fatalf("cache_read_input_tokens = %d, want 140830: %s", got, messageDelta)
}
}
@@ -282,10 +281,10 @@ func TestConvertAntigravityResponseToClaudeStream_AppendFinalEventsFallbackUsesR
appendFinalEvents(params, &output, true)
messageDelta := sseDataForEvent(t, string(output), "message_delta")
- if outputTokens := gjson.Get(messageDelta, "usage.output_tokens"); !outputTokens.Exists() || outputTokens.Int() != 0 {
+ if outputTokens := ojson.Get(messageDelta, "usage.output_tokens"); !outputTokens.Exists() || outputTokens.Int() != 0 {
t.Fatalf("output_tokens = %s, want 0: %s", outputTokens.Raw, messageDelta)
}
- if got := gjson.Get(messageDelta, "usage.cache_read_input_tokens").Int(); got != 140830 {
+ if got := ojson.Get(messageDelta, "usage.cache_read_input_tokens").Int(); got != 140830 {
t.Fatalf("cache_read_input_tokens = %d, want 140830: %s", got, messageDelta)
}
}
@@ -305,7 +304,7 @@ func testEmptyAntigravityResponse() []byte {
}
func TestWebSearchResultsFromGrounding_DeduplicatesAndSkipsEmptyURLs(t *testing.T) {
- groundingMetadata := gjson.Parse(`{
+ groundingMetadata := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/a", "title": "A"}},
{"web": {"uri": "https://example.com/b", "title": "B"}},
@@ -316,13 +315,13 @@ func TestWebSearchResultsFromGrounding_DeduplicatesAndSkipsEmptyURLs(t *testing.
results := webSearchResultsFromGrounding(groundingMetadata)
- if got := gjson.GetBytes(results, "#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(results, "#").Int(); got != 2 {
t.Fatalf("result count = %d, want 2: %s", got, string(results))
}
- if got := gjson.GetBytes(results, "0.url").String(); got != "https://example.com/a" {
+ if got := ojson.GetBytesNoCopy(results, "0.url").String(); got != "https://example.com/a" {
t.Fatalf("first url = %q: %s", got, string(results))
}
- if got := gjson.GetBytes(results, "1.url").String(); got != "https://example.com/b" {
+ if got := ojson.GetBytesNoCopy(results, "1.url").String(); got != "https://example.com/b" {
t.Fatalf("second url = %q: %s", got, string(results))
}
}
@@ -943,7 +942,7 @@ func TestConvertAntigravityResponseToClaude_PreservesClaudeThoughtAndToolSignatu
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true,"thoughtSignature":"` + upstreamSig1 + `"},{"functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}},"thoughtSignature":"` + upstreamSig2 + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3},"modelVersion":"claude-sonnet-4-6-thinking","responseId":"resp-claude-thought-tool"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(nonStream, "content").Array()
+ content := ojson.GetBytesNoCopy(nonStream, "content").Array()
if len(content) != 2 {
t.Fatalf("content blocks = %d, want thinking + tool; output=%s", len(content), nonStream)
}
@@ -954,10 +953,10 @@ func TestConvertAntigravityResponseToClaude_PreservesClaudeThoughtAndToolSignatu
}
replayRequest := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(nonStream, "content").Raw))
replayRequest = StripEmptySignatureThinkingBlocks(replayRequest)
translated, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", replayRequest, false)
- parts := gjson.GetBytes(translated, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "request.contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != upstreamSig1 || parts[1].Get("thoughtSignature").String() != upstreamSig2 {
t.Fatalf("Claude thought/tool signatures did not round-trip: %s", translated)
}
@@ -980,10 +979,10 @@ func TestConvertAntigravityResponseToClaudeNonStream_SignedThoughtBeforeUnsigned
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(output, "content").Raw))
replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest)
translated, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false)
- parts := gjson.GetBytes(translated, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "request.contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "hidden" || !parts[0].Get("thought").Bool() || parts[0].Get("thoughtSignature").String() != signature || parts[1].Get("text").String() != "visible" || parts[1].Get("thoughtSignature").String() != "" {
t.Fatalf("signed thought target changed: output=%s translated=%s", output, translated)
}
@@ -996,13 +995,13 @@ func TestConvertAntigravityResponseToClaudeNonStream_PreviousCarrierDoesNotCross
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"A","thoughtSignature":"` + signature1 + `"},{"text":"","thoughtSignature":"` + signature2 + `"},{"text":"B"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"previous-carrier-boundary"}}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(output, "content.#").Int(); got != 3 {
t.Fatalf("content blocks = %d, want leading carrier + 2 text blocks; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "content.1.text").String(); got != "A" {
+ if got := ojson.GetBytesNoCopy(output, "content.1.text").String(); got != "A" {
t.Fatalf("first text block = %q; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "content.2.text").String(); got != "B" {
+ if got := ojson.GetBytesNoCopy(output, "content.2.text").String(); got != "B" {
t.Fatalf("second text block = %q; output=%s", got, output)
}
}
@@ -1014,7 +1013,7 @@ func TestConvertAntigravityResponseToClaudeNonStream_PreservesDistinctThoughtAnd
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"visible","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp-distinct-signatures"}}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(output, "content").Array()
+ content := ojson.GetBytesNoCopy(output, "content").Array()
if len(content) != 3 {
t.Fatalf("content blocks = %d, want signed thought + carrier + text; output=%s", len(content), output)
}
@@ -1034,10 +1033,10 @@ func TestConvertAntigravityResponseToClaudeNonStream_PreservesDistinctThoughtAnd
}
replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(output, "content").Raw))
replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest)
translated, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false)
- parts := gjson.GetBytes(translated, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("replayed parts = %d, want thought + text; translated=%s", len(parts), translated)
}
@@ -1080,7 +1079,7 @@ func TestConvertAntigravityResponseToClaude_PreservesConsecutiveDetachedCarriers
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"visible"},{"text":"","thoughtSignature":"` + sig1 + `"},{"text":"","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"gemini-3.6-flash","responseId":"resp-consecutive-carriers"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(nonStream, "content").Array()
+ content := ojson.GetBytesNoCopy(nonStream, "content").Array()
if len(content) != 1 || content[0].Get("text").String() != "visible" {
t.Fatalf("want 1 visible text block, got: %s", nonStream)
}
@@ -1099,28 +1098,28 @@ func TestConvertAntigravityResponseToClaudeNonStream_ThoughtBeforeSignedToolRoun
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden analysis","thought":true},{"thoughtSignature":"` + validSignature + `","functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp-thought-tool"}}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(output, "content.#").Int(); got != 2 {
t.Fatalf("content blocks = %d, want thinking + tool_use; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "hidden analysis" {
+ if got := ojson.GetBytesNoCopy(output, "content.0.thinking").String(); got != "hidden analysis" {
t.Fatalf("thinking text = %q; output=%s", got, output)
}
wantCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction)
- if got := gjson.GetBytes(output, "content.0.signature").String(); got != wantCarrier {
+ if got := ojson.GetBytesNoCopy(output, "content.0.signature").String(); got != wantCarrier {
t.Fatalf("thinking carrier signature = %q, want %q; output=%s", got, wantCarrier, output)
}
replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(output, "content").Raw))
replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest)
translated, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false)
- if got := gjson.GetBytes(translated, "request.contents.0.parts.0.text").String(); got != "hidden analysis" {
+ if got := ojson.GetBytesNoCopy(translated, "request.contents.0.parts.0.text").String(); got != "hidden analysis" {
t.Fatalf("replayed thought text = %q; translated=%s", got, translated)
}
- if gjson.GetBytes(translated, "request.contents.0.parts.0.thoughtSignature").Exists() {
+ if ojson.GetBytesNoCopy(translated, "request.contents.0.parts.0.thoughtSignature").Exists() {
t.Fatalf("thought part must remain unsigned; translated=%s", translated)
}
- if got := gjson.GetBytes(translated, "request.contents.0.parts.1.thoughtSignature").String(); got != validSignature {
+ if got := ojson.GetBytesNoCopy(translated, "request.contents.0.parts.1.thoughtSignature").String(); got != validSignature {
t.Fatalf("tool signature = %q, want %q; translated=%s", got, validSignature, translated)
}
}
@@ -1130,10 +1129,10 @@ func TestConvertAntigravityResponseToClaudeNonStream_DetachedGeminiSignatureAfte
requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`)
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"visible answer"},{"text":"","thoughtSignature":"` + validSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp-detached"}}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(output, "content.#").Int(); got != 1 {
t.Fatalf("content blocks = %d, want 1 text block; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.text").String(); got != "visible answer" {
+ if got := ojson.GetBytesNoCopy(output, "content.0.text").String(); got != "visible answer" {
t.Fatalf("visible text = %q; output=%s", got, output)
}
}
@@ -1144,7 +1143,7 @@ func TestConvertAntigravityResponseToClaude_TrailingFunctionCarrierRoundTrip(t *
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"native-call-1","name":"run_command","args":{"command":"true"}}},{"text":"","thoughtSignature":"` + nativeSignature + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"tool-trailing-carrier"}}`)
claudeResponse := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(claudeResponse, "content").Array()
+ content := ojson.GetBytesNoCopy(claudeResponse, "content").Array()
if len(content) != 2 || content[0].Get("type").String() != "tool_use" || content[1].Get("type").String() != "thinking" {
t.Fatalf("Claude response did not emit tool_use followed by carrier: %s", claudeResponse)
}
@@ -1153,11 +1152,12 @@ func TestConvertAntigravityResponseToClaude_TrailingFunctionCarrierRoundTrip(t *
t.Fatalf("trailing function carrier malformed: %q", content[1].Get("signature").String())
}
- replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":"ok"}]}],"tools":[{"name":"run_command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(claudeResponse, "content").Raw))
- replayRequest, _ = sjson.SetBytes(replayRequest, "messages.1.content.0.tool_use_id", content[0].Get("id").String())
+ replayRequestDoc := ojson.NewDocString(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":"ok"}]}],"tools":[{"name":"run_command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}]}`)
+ _ = replayRequestDoc.SetRawBytes("messages.0.content", []byte(ojson.GetBytesNoCopy(claudeResponse, "content").Raw))
+ _ = replayRequestDoc.Set("messages.1.content.0.tool_use_id", content[0].Get("id").String())
+ replayRequest := replayRequestDoc.Bytes()
translated, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, true)
- parts := gjson.GetBytes(translated, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "request.contents.0.parts").Array()
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() {
t.Fatalf("trailing carrier was not rebound to the function call: %s", translated)
}
@@ -1206,7 +1206,7 @@ func TestConvertAntigravityResponseToClaude_DirectionalTextCarriersRoundTrip(t *
t.Run(testCase.name, func(t *testing.T) {
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":` + testCase.parts + `},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"directional-text"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(nonStream, "content").Array()
+ content := ojson.GetBytesNoCopy(nonStream, "content").Array()
if len(content) != 3 {
t.Fatalf("Claude content count = %d, want carrier + two text blocks; output=%s", len(content), nonStream)
}
@@ -1217,10 +1217,10 @@ func TestConvertAntigravityResponseToClaude_DirectionalTextCarriersRoundTrip(t *
}
replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(nonStream, "content").Raw))
replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest)
translated, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false)
- parts := gjson.GetBytes(translated, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "request.contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "A" || parts[1].Get("text").String() != "B" {
t.Fatalf("text boundaries changed: %s", translated)
}
@@ -1251,18 +1251,18 @@ func TestConvertAntigravityResponseToClaude_LeadingCarrierTargetsFollowingThough
requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`)
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature + `"},{"text":"reason","thought":true}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"leading-thought"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(nonStream, "content").Array()
+ content := ojson.GetBytesNoCopy(nonStream, "content").Array()
if len(content) != 2 || content[0].Get("thinking").String() != "" || content[1].Get("thinking").String() != "reason" {
t.Fatalf("leading thought carrier response malformed: %s", nonStream)
}
replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(nonStream, "content").Raw))
replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest)
- if got := gjson.GetBytes(replayRequest, "messages.0.content.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(replayRequest, "messages.0.content.#").Int(); got != 2 {
t.Fatalf("prevalidation dropped unsigned target thought: %s", replayRequest)
}
translated, _ := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false)
- part := gjson.GetBytes(translated, "request.contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(translated, "request.contents.0.parts.0")
if part.Get("text").String() != "reason" || !part.Get("thought").Bool() || part.Get("thoughtSignature").String() != signature {
t.Fatalf("leading thought carrier did not round-trip: %s", translated)
}
@@ -1298,16 +1298,16 @@ func TestConvertAntigravityResponseToClaudeNonStream_SignatureOnlyPartWithoutTho
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-5-thinking", requestJSON, requestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(output, "content.#").Int(); got != 1 {
t.Fatalf("expected exactly one content block, got %d: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.type").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(output, "content.0.type").String(); got != "thinking" {
t.Fatalf("expected thinking content block, got %q: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "Full thinking text." {
+ if got := ojson.GetBytesNoCopy(output, "content.0.thinking").String(); got != "Full thinking text." {
t.Fatalf("unexpected thinking text %q: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.signature").String(); got != validSignature {
+ if got := ojson.GetBytesNoCopy(output, "content.0.signature").String(); got != validSignature {
t.Fatalf("expected signature %q, got %q: %s", validSignature, got, output)
}
}
@@ -1342,23 +1342,23 @@ func TestConvertAntigravityResponseToClaudeNonStream_TextWithThoughtSignatureSta
}`)
output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, responseJSON, nil)
- if got := gjson.GetBytes(output, "content.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(output, "content.#").Int(); got != 2 {
t.Fatalf("content block count = %d, want 2. Output: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.type").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(output, "content.0.type").String(); got != "thinking" {
t.Fatalf("content.0.type = %q, want thinking. Output: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "I need to multiply 17 by 24." {
+ if got := ojson.GetBytesNoCopy(output, "content.0.thinking").String(); got != "I need to multiply 17 by 24." {
t.Fatalf("thinking = %q, want thought text. Output: %s", got, output)
}
wantCarrier := encodeGeminiClaudeCarrierSignature("sig-final-answer", geminiClaudeCarrierNext, geminiClaudeCarrierText)
- if got := gjson.GetBytes(output, "content.0.signature").String(); got != wantCarrier {
+ if got := ojson.GetBytesNoCopy(output, "content.0.signature").String(); got != wantCarrier {
t.Fatalf("signature = %q, want %q. Output: %s", got, wantCarrier, output)
}
- if got := gjson.GetBytes(output, "content.1.type").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(output, "content.1.type").String(); got != "text" {
t.Fatalf("content.1.type = %q, want text. Output: %s", got, output)
}
- if got := gjson.GetBytes(output, "content.1.text").String(); got != "408" {
+ if got := ojson.GetBytesNoCopy(output, "content.1.text").String(); got != "408" {
t.Fatalf("text = %q, want final answer. Output: %s", got, output)
}
}
@@ -1425,7 +1425,7 @@ func TestConvertAntigravityResponseToClaudeUsesStableGeminiToolProvenanceID(t *t
}
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil)
- if got := gjson.GetBytes(nonStream, "content.#(type==\"tool_use\").id").String(); got != wantID {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.#(type==\"tool_use\").id").String(); got != wantID {
t.Fatalf("non-stream tool_use.id = %q, want %q; output=%s", got, wantID, nonStream)
}
@@ -1453,7 +1453,7 @@ func TestConvertAntigravityResponseToClaude_EmitsNativeSignaturesWithoutProvider
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"thought content","thought":true,"thoughtSignature":"` + upstreamSig1 + `"},{"functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}},"thoughtSignature":"` + upstreamSig2 + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3},"modelVersion":"claude-sonnet-4-6-thinking","responseId":"resp-claude-native-sigs"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", requestJSON, requestJSON, responseJSON, nil)
- content := gjson.GetBytes(nonStream, "content").Array()
+ content := ojson.GetBytesNoCopy(nonStream, "content").Array()
if len(content) != 2 {
t.Fatalf("content blocks = %d, want thinking + tool; output=%s", len(content), nonStream)
}
@@ -1485,10 +1485,10 @@ func TestConvertAntigravityResponseToClaude_EmitsNativeSignaturesWithoutProvider
// Verify multi-turn replay with native signatures in cache mode
replayRequest := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`)
- replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw))
+ replayRequest, _ = ojson.SetRawBytes(replayRequest, "messages.0.content", []byte(ojson.GetBytesNoCopy(nonStream, "content").Raw))
replayRequest = StripEmptySignatureThinkingBlocks(replayRequest)
translated, _ := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", replayRequest, false)
- parts := gjson.GetBytes(translated, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "request.contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != upstreamSig1 || parts[1].Get("thoughtSignature").String() != upstreamSig2 {
t.Fatalf("Claude native thought/tool signatures did not round-trip in cache mode: %s", translated)
}
@@ -1526,13 +1526,13 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyTextPartKeepsThinkingBloc
continue
}
data := strings.TrimPrefix(trimmed, "data: ")
- eventType := gjson.Get(data, "type").String()
+ eventType := ojson.Get(data, "type").String()
switch eventType {
case "content_block_start":
- idx := gjson.Get(data, "index").Int()
+ idx := ojson.Get(data, "index").Int()
started[idx] = true
case "content_block_delta":
- idx := gjson.Get(data, "index").Int()
+ idx := ojson.Get(data, "index").Int()
if !started[idx] {
t.Fatalf("event %s for index %d before content_block_start; full stream:\n%s", eventType, idx, fullOutput)
}
@@ -1540,7 +1540,7 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyTextPartKeepsThinkingBloc
t.Fatalf("event %s for index %d after content_block_stop; full stream:\n%s", eventType, idx, fullOutput)
}
case "content_block_stop":
- idx := gjson.Get(data, "index").Int()
+ idx := ojson.Get(data, "index").Int()
if !started[idx] {
t.Fatalf("event %s for index %d before content_block_start; full stream:\n%s", eventType, idx, fullOutput)
}
@@ -1585,13 +1585,13 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyTextPartFollowedByThinkin
continue
}
data := strings.TrimPrefix(trimmed, "data: ")
- eventType := gjson.Get(data, "type").String()
+ eventType := ojson.Get(data, "type").String()
switch eventType {
case "content_block_start":
- idx := gjson.Get(data, "index").Int()
+ idx := ojson.Get(data, "index").Int()
started[idx] = true
case "content_block_delta":
- idx := gjson.Get(data, "index").Int()
+ idx := ojson.Get(data, "index").Int()
if !started[idx] {
t.Fatalf("event %s for index %d before content_block_start; full stream:\n%s", eventType, idx, fullOutput)
}
@@ -1599,7 +1599,7 @@ func TestConvertAntigravityResponseToClaudeStream_EmptyTextPartFollowedByThinkin
t.Fatalf("event %s for index %d after content_block_stop; full stream:\n%s", eventType, idx, fullOutput)
}
case "content_block_stop":
- idx := gjson.Get(data, "index").Int()
+ idx := ojson.Get(data, "index").Int()
if !started[idx] {
t.Fatalf("event %s for index %d before content_block_start; full stream:\n%s", eventType, idx, fullOutput)
}
@@ -1624,7 +1624,7 @@ func TestConvertAntigravityResponseToClaude_SanitizesParameterlessToolArguments(
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"EnterPlanMode","args":{"reason":"Switching to plan mode to inspect files"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp-1"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, responseJSON, nil)
- if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != "{}" {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.input").Raw; got != "{}" {
t.Fatalf("expected non-streaming input {}, got %s", got)
}
@@ -1635,8 +1635,8 @@ func TestConvertAntigravityResponseToClaude_SanitizesParameterlessToolArguments(
for _, line := range strings.Split(streamText, "\n") {
if strings.HasPrefix(line, "data: ") {
payload := strings.TrimPrefix(line, "data: ")
- if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- deltaPartial.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ if ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ deltaPartial.WriteString(ojson.Get(payload, "delta.partial_json").String())
}
}
}
@@ -1654,7 +1654,7 @@ func TestConvertAntigravityResponseToClaude_StripsExecutorInjectedUnderscore(t *
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"WrappedPlaceholder","args":{"flag":"x","_":true}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp-wrapped"}}`)
nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", origRequest, translatedRequest, responseJSON, nil)
- if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"flag":"x"}` {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.input").Raw; got != `{"flag":"x"}` {
t.Fatalf("expected executor-injected underscore stripped, got %s (translated: %s)", got, string(translatedRequest))
}
}
@@ -1674,11 +1674,11 @@ func TestConvertAntigravityResponseToClaude_ParameterlessContinuationDoesNotDupl
for _, line := range strings.Split(streamText, "\n") {
if strings.HasPrefix(line, "data: ") {
payload := strings.TrimPrefix(line, "data: ")
- if gjson.Get(payload, "type").String() == "content_block_start" && gjson.Get(payload, "content_block.type").String() == "tool_use" {
+ if ojson.Get(payload, "type").String() == "content_block_start" && ojson.Get(payload, "content_block.type").String() == "tool_use" {
toolUseStarts++
}
- if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- deltaPartial.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ if ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ deltaPartial.WriteString(ojson.Get(payload, "delta.partial_json").String())
}
}
}
@@ -1720,8 +1720,8 @@ func TestConvertAntigravityResponseToClaude_ImplicitOpenToolContinuationOrdering
for _, line := range strings.Split(streamText, "\n") {
if strings.HasPrefix(line, "data: ") {
payload := strings.TrimPrefix(line, "data: ")
- if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- deltaPartial.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ if ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ deltaPartial.WriteString(ojson.Get(payload, "delta.partial_json").String())
}
}
}
@@ -1731,3 +1731,226 @@ func TestConvertAntigravityResponseToClaude_ImplicitOpenToolContinuationOrdering
})
}
}
+
+func TestConvertAntigravityResponseToClaudeNonStream_RefusalFinishReasons(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"user","content":"hi"}]}`)
+ testCases := []struct {
+ finishReason string
+ wantReason string
+ }{
+ {finishReason: "SAFETY", wantReason: "refusal"},
+ {finishReason: "RECITATION", wantReason: "refusal"},
+ {finishReason: "PROHIBITED_CONTENT", wantReason: "refusal"},
+ {finishReason: "SPII", wantReason: "refusal"},
+ {finishReason: "BLOCKLIST", wantReason: "refusal"},
+ {finishReason: "IMAGE_SAFETY", wantReason: "refusal"},
+ {finishReason: "MALFORMED_RESPONSE", wantReason: "refusal"},
+ {finishReason: "MALFORMED_FUNCTION_CALL", wantReason: "refusal"},
+ {finishReason: "MAX_TOKENS", wantReason: "max_tokens"},
+ {finishReason: "STOP", wantReason: "end_turn"},
+ }
+
+ ctx := context.Background()
+ for _, tc := range testCases {
+ t.Run(tc.finishReason, func(t *testing.T) {
+ rawJSON := []byte(`{
+ "response": {
+ "candidates": [{
+ "content": {"role": "model", "parts": []},
+ "finishReason": "` + tc.finishReason + `"
+ }],
+ "usageMetadata": {
+ "promptTokenCount": 8149,
+ "totalTokenCount": 8149
+ },
+ "modelVersion": "claude-opus-5-5-high",
+ "responseId": "req_vrtx_test"
+ }
+ }`)
+ output := ConvertAntigravityResponseToClaudeNonStream(ctx, "claude-opus-5-5-high", requestJSON, requestJSON, rawJSON, nil)
+ if got := ojson.GetBytesNoCopy(output, "stop_reason").String(); got != tc.wantReason {
+ t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tc.wantReason, string(output))
+ }
+ })
+ }
+
+ t.Run("tool_use_takes_precedence_over_safety", func(t *testing.T) {
+ origReq := []byte(`{"model":"claude-opus-5-5","tools":[{"name":"echo","input_schema":{"type":"object","properties":{"x":{"type":"string"}}}}]}`)
+ rawJSON := []byte(`{
+ "response": {
+ "candidates": [{
+ "content": {"role": "model", "parts": [{"functionCall": {"name": "echo", "args": {"x": "1"}}}]},
+ "finishReason": "SAFETY"
+ }],
+ "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 5, "totalTokenCount": 15},
+ "modelVersion": "claude-opus-5-5-high",
+ "responseId": "req_vrtx_tool"
+ }
+ }`)
+ output := ConvertAntigravityResponseToClaudeNonStream(ctx, "claude-opus-5-5-high", origReq, origReq, rawJSON, nil)
+ if got := ojson.GetBytesNoCopy(output, "stop_reason").String(); got != "tool_use" {
+ t.Fatalf("stop_reason = %q, want tool_use. Output: %s", got, string(output))
+ }
+ })
+}
+
+func TestConvertAntigravityResponseToClaudeStream_EmptySafetyRefusal(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5"}`)
+ translatedRequestJSON := []byte(`{"model":"claude-opus-5-5-high"}`)
+
+ firstChunk := []byte(`{
+ "response": {
+ "candidates": [{"content": {"role": "model", "parts": [{"text": ""}]}}],
+ "modelVersion": "claude-opus-5-5-high",
+ "responseId": "req_vrtx_011CgdVmG8o2DSoFQtSfzktZ",
+ "cpaUsageMetadata": {"promptTokenCount": 8149, "totalTokenCount": 8149}
+ }
+ }`)
+ terminalChunk := []byte(`{
+ "response": {
+ "candidates": [{"content": {"role": "model", "parts": [{"text": ""}]}, "finishReason": "SAFETY"}],
+ "usageMetadata": {"promptTokenCount": 8149, "totalTokenCount": 8149},
+ "modelVersion": "claude-opus-5-5-high",
+ "responseId": "req_vrtx_011CgdVmG8o2DSoFQtSfzktZ"
+ }
+ }`)
+
+ var param any
+ ctx := context.Background()
+ output := bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-opus-5-5-high", requestJSON, translatedRequestJSON, firstChunk, ¶m), nil)
+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-opus-5-5-high", requestJSON, translatedRequestJSON, terminalChunk, ¶m), nil)...)
+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-opus-5-5-high", requestJSON, translatedRequestJSON, []byte("[DONE]"), ¶m), nil)...)
+ outputText := string(output)
+
+ lastIndex := -1
+ for _, eventName := range []string{"message_start", "content_block_start", "content_block_stop", "message_delta", "message_stop"} {
+ index := strings.Index(outputText, "event: "+eventName+"\n")
+ if index < 0 {
+ t.Fatalf("event %q not found in:\n%s", eventName, outputText)
+ }
+ if index <= lastIndex {
+ t.Fatalf("event %q is out of order in:\n%s", eventName, outputText)
+ }
+ lastIndex = index
+ }
+
+ messageDelta := sseDataForEvent(t, outputText, "message_delta")
+ if got := ojson.Get(messageDelta, "delta.stop_reason").String(); got != "refusal" {
+ t.Fatalf("stop_reason = %q, want refusal: %s", got, messageDelta)
+ }
+ if got := ojson.Get(messageDelta, "usage.input_tokens").Int(); got != 8149 {
+ t.Fatalf("input_tokens = %d, want 8149: %s", got, messageDelta)
+ }
+ if got := ojson.Get(messageDelta, "usage.output_tokens").Int(); got != 0 {
+ t.Fatalf("output_tokens = %d, want 0: %s", got, messageDelta)
+ }
+}
+
+func TestConvertAntigravityResponseToClaudeStream_RefusalPreservedAcrossTrailingEmptyFinishReason(t *testing.T) {
+ requestJSON := []byte(`{"model":"claude-opus-5-5"}`)
+ for _, reason := range []string{
+ "SAFETY",
+ "RECITATION",
+ "PROHIBITED_CONTENT",
+ "SPII",
+ "BLOCKLIST",
+ "IMAGE_SAFETY",
+ "MALFORMED_RESPONSE",
+ "MALFORMED_FUNCTION_CALL",
+ } {
+ t.Run(reason, func(t *testing.T) {
+ for _, textPart := range []string{"partial", ""} {
+ name := "with_text"
+ if textPart == "" {
+ name = "empty_text"
+ }
+ t.Run(name, func(t *testing.T) {
+ finishChunk := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"` + textPart + `"}]},"finishReason":"` + reason + `"}],"modelVersion":"claude-opus-5-5-high","responseId":"req_vrtx_split"}}`)
+ usageChunk := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":""}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":1,"totalTokenCount":101},"modelVersion":"claude-opus-5-5-high","responseId":"req_vrtx_split"}}`)
+
+ var param any
+ ctx := context.Background()
+ output := bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-opus-5-5-high", requestJSON, requestJSON, finishChunk, ¶m), nil)
+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-opus-5-5-high", requestJSON, requestJSON, usageChunk, ¶m), nil)...)
+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "claude-opus-5-5-high", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
+
+ messageDelta := sseDataForEvent(t, string(output), "message_delta")
+ if got := ojson.Get(messageDelta, "delta.stop_reason").String(); got != "refusal" {
+ t.Fatalf("stop_reason = %q, want refusal: %s", got, messageDelta)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestConvertAntigravityResponseToClaudeStream_WebSearchBufferedTextDrainedOnEmptyFinishReason(t *testing.T) {
+ requestJSON := []byte(`{
+ "model": "gemini-3.1-flash-lite",
+ "tools": [{"type": "web_search_20250305", "name": "web_search"}]
+ }`)
+ translatedRequestJSON := []byte(`{"model":"gemini-3.1-flash-lite","request":{"tools":[{"googleSearch":{}}]}}`)
+ chunk := []byte(`{
+ "response": {
+ "modelVersion": "gemini-3.1-flash-lite",
+ "responseId": "resp-web-search-empty-finish",
+ "candidates": [{
+ "content": {"parts": [{"text": "Direct answer without grounding."}]},
+ "finishReason": ""
+ }],
+ "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 4, "totalTokenCount": 14}
+ }
+ }`)
+
+ var param any
+ ctx := context.Background()
+ output := bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-flash-lite", requestJSON, translatedRequestJSON, chunk, ¶m), nil)
+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-flash-lite", requestJSON, translatedRequestJSON, []byte("[DONE]"), ¶m), nil)...)
+ outputText := string(output)
+
+ if !strings.Contains(outputText, "Direct answer without grounding.") {
+ t.Fatalf("buffered web-search text was lost on empty finishReason:\n%s", outputText)
+ }
+ messageDelta := sseDataForEvent(t, outputText, "message_delta")
+ if got := ojson.Get(messageDelta, "delta.stop_reason").String(); got != "end_turn" {
+ t.Fatalf("stop_reason = %q, want end_turn: %s", got, messageDelta)
+ }
+}
+
+func TestConvertAntigravityResponseToClaudeNonStream_WebSearchGroundingRespectsFinishReason(t *testing.T) {
+ requestJSON := []byte(`{
+ "model": "gemini-3.1-flash-lite",
+ "tools": [{"type": "web_search_20250305", "name": "web_search"}]
+ }`)
+ translatedRequestJSON := []byte(`{"model":"gemini-3.1-flash-lite","request":{"tools":[{"googleSearch":{}}]}}`)
+ for _, tc := range []struct {
+ finishReason string
+ wantReason string
+ }{
+ {finishReason: "SAFETY", wantReason: "refusal"},
+ {finishReason: "MAX_TOKENS", wantReason: "max_tokens"},
+ {finishReason: "STOP", wantReason: "end_turn"},
+ } {
+ t.Run(tc.finishReason, func(t *testing.T) {
+ rawJSON := []byte(`{
+ "response": {
+ "modelVersion": "gemini-3.1-flash-lite",
+ "responseId": "resp-web-search-grounding-finish",
+ "candidates": [{
+ "content": {"parts": [{"text": "Partial grounded text."}]},
+ "groundingMetadata": {
+ "webSearchQueries": ["query"],
+ "groundingChunks": [{"web": {"uri": "https://example.com", "title": "Example"}}]
+ },
+ "finishReason": "` + tc.finishReason + `"
+ }],
+ "usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 4, "totalTokenCount": 14}
+ }
+ }`)
+ output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.1-flash-lite", requestJSON, translatedRequestJSON, rawJSON, nil)
+ if got := ojson.GetBytesNoCopy(output, "stop_reason").String(); got != tc.wantReason {
+ t.Fatalf("stop_reason = %q, want %q: %s", got, tc.wantReason, string(output))
+ }
+ })
+ }
+}
diff --git a/internal/translator/antigravity/claude/antigravity_claude_user_turn_test.go b/internal/translator/antigravity/claude/antigravity_claude_user_turn_test.go
index 187f61f108b..2c572b66c2e 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_user_turn_test.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const userTurnUpload = `{"type":"container_upload","file_id":"file-1"}`
@@ -49,7 +49,7 @@ func TestConvertClaudeRequestToAntigravity_RefusesAnyEmptiedUserTurn(t *testing.
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -62,7 +62,7 @@ func TestConvertClaudeRequestToAntigravity_KeepsTurnWithTextBesideAttachment(t *
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "request.contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "request.contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -73,7 +73,7 @@ func TestConvertClaudeRequestToAntigravity_Base64DocumentBecomesInlineData(t *te
if err != nil {
t.Fatalf("err = %v", err)
}
- inlineData := gjson.GetBytes(body, "request.contents.2.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(body, "request.contents.2.parts.0.inlineData")
if inlineData.Get("mimeType").String() != "application/pdf" || inlineData.Get("data").String() != "JVBERi0xLjQK" {
t.Fatalf("inlineData = %s, body = %s", inlineData.Raw, body)
}
@@ -81,7 +81,7 @@ func TestConvertClaudeRequestToAntigravity_Base64DocumentBecomesInlineData(t *te
func TestConvertClaudeRequestToAntigravity_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","messages":[{"role":"user","content":[` + userTurnUpload + `]}]}`)
- if body, _ := ConvertClaudeRequestToAntigravity("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertClaudeRequestToAntigravity("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
@@ -139,7 +139,7 @@ func TestClaudeToAntigravityUnrepresentableImageBesideTextStillSucceeds(t *testi
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- parts := gjson.GetBytes(envelope.Body, "request.contents.2.parts").Array()
+ parts := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "keep me" {
t.Fatalf("text was lost or an image leaked: %s", envelope.Body)
}
@@ -155,7 +155,7 @@ func TestClaudeToAntigravityBase64ImageOnlyTurnStaysInlineData(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- inlineData := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.inlineData")
if inlineData.Get("mimeType").String() != "image/png" || inlineData.Get("data").String() != "aGVsbG8=" {
t.Fatalf("inlineData = %s, body = %s", inlineData.Raw, envelope.Body)
}
@@ -174,7 +174,7 @@ func TestClaudeToAntigravityRealTextBesideWhitespaceAndAnImageStillSucceeds(t *t
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
found := false
- for _, part := range gjson.GetBytes(envelope.Body, "request.contents.2.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts").Array() {
if part.Get("text").String() == "keep me" {
found = true
}
diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go
index 035c1654ec1..db4ae326b08 100644
--- a/internal/translator/antigravity/claude/signature_validation.go
+++ b/internal/translator/antigravity/claude/signature_validation.go
@@ -2,14 +2,13 @@
package claude
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -75,7 +74,7 @@ func decodeGeminiClaudeCarrierSignature(rawSignature string) (signatureValue, di
return normalized, direction, targetKind, true, true
}
-func geminiClaudeSemanticTargetKind(block gjson.Result) string {
+func geminiClaudeSemanticTargetKind(block ojson.Result) string {
switch block.Get("type").String() {
case "text":
return geminiClaudeCarrierText
@@ -89,7 +88,7 @@ func geminiClaudeSemanticTargetKind(block gjson.Result) string {
return ""
}
-func geminiClaudeCarrierMatchesAdjacent(blocks []gjson.Result, index int, direction, targetKind string) bool {
+func geminiClaudeCarrierMatchesAdjacent(blocks []ojson.Result, index int, direction, targetKind string) bool {
step := 1
if direction == geminiClaudeCarrierPrevious {
step = -1
@@ -142,7 +141,7 @@ type geminiClaudeCarrierContext struct {
hasTrailingPreviousCarrier []bool
}
-func geminiClaudePrecomputeCarrierContext(blocks []gjson.Result) geminiClaudeCarrierContext {
+func geminiClaudePrecomputeCarrierContext(blocks []ojson.Result) geminiClaudeCarrierContext {
n := len(blocks)
ctx := geminiClaudeCarrierContext{
nextSemanticKind: make([]string, n),
@@ -235,7 +234,7 @@ func StripEmptySignatureThinkingBlocks(payload []byte) []byte {
// whose signatures can be replayed to Gemini. Claude Code uses these carriers
// to return provider-native signatures from prior translated responses.
func StripInvalidGeminiSignatureThinkingBlocks(payload []byte) []byte {
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() {
return payload
}
@@ -326,7 +325,7 @@ func StripInvalidGeminiSignatureThinkingBlocks(payload []byte) []byte {
contentItems = append(contentItems, []byte(block.Raw))
}
if contentChanged {
- messageJSON, _ = sjson.SetRawBytes(messageJSON, "content", translatorcommon.JoinRawArray(contentItems))
+ messageJSON, _ = ojson.SetRawBytes(messageJSON, "content", translatorcommon.JoinRawArray(contentItems))
changed = true
}
messageItems = append(messageItems, messageJSON)
@@ -334,7 +333,7 @@ func StripInvalidGeminiSignatureThinkingBlocks(payload []byte) []byte {
if !changed {
return payload
}
- updated, errSet := sjson.SetRawBytes(payload, "messages", translatorcommon.JoinRawArray(messageItems))
+ updated, errSet := ojson.SetRawBytes(payload, "messages", translatorcommon.JoinRawArray(messageItems))
if errSet != nil {
return payload
}
diff --git a/internal/translator/antigravity/claude/signature_validation_test.go b/internal/translator/antigravity/claude/signature_validation_test.go
index e2c73388569..98b916bd677 100644
--- a/internal/translator/antigravity/claude/signature_validation_test.go
+++ b/internal/translator/antigravity/claude/signature_validation_test.go
@@ -3,7 +3,7 @@ package claude
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestGeminiClaudeCarrierSignatureRoundTrip(t *testing.T) {
@@ -31,7 +31,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksPreservesMarkedNonEmptyThinkin
invalidPrevious := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"signed thought","signature":"` + standalone + `"},{"type":"thinking","thinking":"tool preface","signature":"` + nextFunction + `"},{"type":"tool_use","id":"tool-1","name":"run","input":{}},{"type":"thinking","thinking":"invalid backward","signature":"` + invalidPrevious + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 3 || content[0].Get("signature").String() != standalone || content[1].Get("signature").String() != nextFunction || content[2].Get("type").String() != "tool_use" {
t.Fatalf("marked non-empty thinking validation changed carriers: %s", out)
}
@@ -43,7 +43,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksDropsMismatchedDirectionalThin
standaloneFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierStandalone, geminiClaudeCarrierFunction)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"wrong next target","signature":"` + nextFunction + `"},{"type":"text","text":"visible"},{"type":"thinking","thinking":"wrong standalone target","signature":"` + standaloneFunction + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 1 || content[0].Get("type").String() != "text" {
t.Fatalf("mismatched directional thinking was preserved: %s", out)
}
@@ -53,8 +53,8 @@ func TestStripInvalidGeminiSignatureThinkingBlocksDropsLegacyRawCarrierFromUserM
validSignature := testGeminiEPrefixSignature(t)
input := []byte(`{"messages":[{"role":"user","content":[{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"text","text":"user text"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"text","text":"assistant text"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- userContent := gjson.GetBytes(out, "messages.0.content").Array()
- assistantContent := gjson.GetBytes(out, "messages.1.content").Array()
+ userContent := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
+ assistantContent := ojson.GetBytesNoCopy(out, "messages.1.content").Array()
if len(userContent) != 1 || userContent[0].Get("type").String() != "text" {
t.Fatalf("legacy raw carrier survived user message: %s", out)
}
@@ -68,7 +68,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocks(t *testing.T) {
validCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"text","text":"first"},{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"thinking","thinking":"","signature":"` + validCarrier + `"},{"type":"thinking","thinking":"","signature":"cpa-gemini-carrier-v1:previous:text:invalid"},{"type":"thinking","thinking":"","signature":"invalid"},{"type":"text","text":"last"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 4 {
t.Fatalf("content count = %d, want 4; output=%s", len(content), out)
}
@@ -88,7 +88,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksPreservesUnsignedThoughtWithFo
validCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"let me think","signature":""},{"type":"text","text":"answer text"},{"type":"thinking","thinking":"","signature":"` + validCarrier + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 3 {
t.Fatalf("content count = %d, want 3; output=%s", len(content), out)
}
@@ -108,7 +108,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksUnrelatedSegmentDoesNotPreserv
validCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"unrelated early thought","signature":""},{"type":"text","text":"text A"},{"type":"thinking","thinking":"second thought","signature":""},{"type":"text","text":"text B"},{"type":"thinking","thinking":"","signature":"` + validCarrier + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 4 {
t.Fatalf("content count = %d, want 4 (early thought dropped, second thought + text + carrier kept); output=%s", len(content), out)
}
@@ -129,7 +129,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksUnrelatedSegmentDoesNotPreserv
func TestStripInvalidGeminiSignatureThinkingBlocksDropsUnsignedThinkingWithoutCarriers(t *testing.T) {
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"thought 1","signature":""},{"type":"thinking","thinking":"thought 2","signature":""},{"type":"thinking","thinking":"thought 3","signature":""},{"type":"text","text":"visible answer"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 1 || content[0].Get("text").String() != "visible answer" {
t.Fatalf("unsigned thoughts without carrier were not stripped: %s", out)
}
@@ -143,7 +143,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksDropsUnsignedThoughtWithMalfor
// Trailing next carrier does not bind backward to previous text/thinking
inputNext := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"thought text","signature":""},{"type":"text","text":"visible text"},{"type":"thinking","thinking":"","signature":"` + nextCarrier + `"}]}]}`)
outNext := StripInvalidGeminiSignatureThinkingBlocks(inputNext)
- contentNext := gjson.GetBytes(outNext, "messages.0.content").Array()
+ contentNext := ojson.GetBytesNoCopy(outNext, "messages.0.content").Array()
if len(contentNext) != 1 || contentNext[0].Get("text").String() != "visible text" {
t.Fatalf("wrong direction trailing carrier preserved unsigned thought: %s", outNext)
}
@@ -151,7 +151,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksDropsUnsignedThoughtWithMalfor
// Trailing malformed carrier does not preserve unsigned thought
inputMalformed := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"thought text","signature":""},{"type":"text","text":"visible text"},{"type":"thinking","thinking":"","signature":"` + malformedCarrier + `"}]}]}`)
outMalformed := StripInvalidGeminiSignatureThinkingBlocks(inputMalformed)
- contentMalformed := gjson.GetBytes(outMalformed, "messages.0.content").Array()
+ contentMalformed := ojson.GetBytesNoCopy(outMalformed, "messages.0.content").Array()
if len(contentMalformed) != 1 || contentMalformed[0].Get("text").String() != "visible text" {
t.Fatalf("malformed trailing carrier preserved unsigned thought: %s", outMalformed)
}
@@ -162,7 +162,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksDroppedNonEmptyThinkingInvalid
functionCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierFunction)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"tool-1","name":"run","input":{}},{"type":"thinking","thinking":"intervening unsigned thought","signature":""},{"type":"thinking","thinking":"","signature":"` + functionCarrier + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 1 || content[0].Get("type").String() != "tool_use" {
t.Fatalf("dropped non-empty thinking did not invalidate following previous carrier: %s", out)
}
@@ -173,7 +173,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksInvalidMiddleThinkingDoesNotPr
validCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"thought A","signature":""},{"type":"thinking","thinking":"invalid boundary","signature":"invalid"},{"type":"thinking","thinking":"","signature":"` + validCarrier + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 0 {
t.Fatalf("invalid middle thinking propagated carrier; got: %s", out)
}
@@ -185,7 +185,7 @@ func TestStripInvalidGeminiSignatureThinkingBlocksInvalidPlacementMiddleThinking
validTrailingCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText)
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"thought A","signature":""},{"type":"thinking","thinking":"invalid middle thinking","signature":"` + invalidPlacementCarrier + `"},{"type":"text","text":"text answer"},{"type":"thinking","thinking":"","signature":"` + validTrailingCarrier + `"}]}]}`)
out := StripInvalidGeminiSignatureThinkingBlocks(input)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 2 || content[0].Get("text").String() != "text answer" || content[1].Get("signature").String() != validTrailingCarrier {
t.Fatalf("invalid placement middle thinking propagated carrier; got: %s", out)
}
diff --git a/internal/translator/antigravity/claude/web_search.go b/internal/translator/antigravity/claude/web_search.go
index dc96a9f5026..23498150fc3 100644
--- a/internal/translator/antigravity/claude/web_search.go
+++ b/internal/translator/antigravity/claude/web_search.go
@@ -6,9 +6,8 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type webSearchGroundingSupport struct {
@@ -35,7 +34,7 @@ func isClaudeTypedWebSearchToolType(toolType string) bool {
}
func hasClaudeTypedWebSearchTool(payload []byte) bool {
- tools := gjson.GetBytes(payload, "tools")
+ tools := ojson.GetBytesNoCopy(payload, "tools")
if !tools.IsArray() {
return false
}
@@ -48,7 +47,7 @@ func hasClaudeTypedWebSearchTool(payload []byte) bool {
}
func hasOnlyClaudeTypedWebSearchTools(payload []byte) bool {
- tools := gjson.GetBytes(payload, "tools")
+ tools := ojson.GetBytesNoCopy(payload, "tools")
if !tools.IsArray() {
return false
}
@@ -64,11 +63,11 @@ func hasOnlyClaudeTypedWebSearchTools(payload []byte) bool {
}
func allowsClaudeWebSearchToolChoice(payload []byte) bool {
- toolChoice := gjson.GetBytes(payload, "tool_choice")
+ toolChoice := ojson.GetBytesNoCopy(payload, "tool_choice")
if !toolChoice.Exists() {
return true
}
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
switch toolChoice.String() {
case "", "auto", "any":
return true
@@ -101,23 +100,23 @@ func buildAntigravityWebSearchRequest(model string, payload []byte) []byte {
query := extractClaudeWebSearchQuery(payload)
maxResultCount := extractClaudeWebSearchMaxUses(payload)
includedDomains := extractClaudeWebSearchAllowedDomains(payload)
- out := []byte(`{"model":"","requestType":"web_search","request":{"contents":[{"role":"user","parts":[{"text":""}]}],"systemInstruction":{"role":"user","parts":[{"text":""}]},"tools":[{"googleSearch":{"enhancedContent":{"imageSearch":{"maxResultCount":5}}}}],"generationConfig":{"candidateCount":1}}}`)
- out, _ = sjson.SetBytes(out, "model", model)
- out, _ = sjson.SetBytes(out, "request.contents.0.parts.0.text", query)
- out, _ = sjson.SetBytes(out, "request.systemInstruction.parts.0.text", antigravityWebSearchSystemInstruction)
- out, _ = sjson.SetBytes(out, "request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount", maxResultCount)
+ outDoc := ojson.NewDocString(`{"model":"","requestType":"web_search","request":{"contents":[{"role":"user","parts":[{"text":""}]}],"systemInstruction":{"role":"user","parts":[{"text":""}]},"tools":[{"googleSearch":{"enhancedContent":{"imageSearch":{"maxResultCount":5}}}}],"generationConfig":{"candidateCount":1}}}`)
+ _ = outDoc.Set("model", model)
+ _ = outDoc.Set("request.contents.0.parts.0.text", query)
+ _ = outDoc.Set("request.systemInstruction.parts.0.text", antigravityWebSearchSystemInstruction)
+ _ = outDoc.Set("request.tools.0.googleSearch.enhancedContent.imageSearch.maxResultCount", maxResultCount)
if len(includedDomains) > 0 {
if domainsJSON, err := json.Marshal(includedDomains); err == nil {
- out, _ = sjson.SetRawBytes(out, "request.tools.0.googleSearch.includedDomains", domainsJSON)
+ _ = outDoc.SetRawBytes("request.tools.0.googleSearch.includedDomains", domainsJSON)
}
}
- return out
+ return outDoc.Bytes()
}
func extractClaudeWebSearchMaxUses(payload []byte) int64 {
const defaultMaxResultCount int64 = 5
- tools := gjson.GetBytes(payload, "tools")
+ tools := ojson.GetBytesNoCopy(payload, "tools")
if !tools.IsArray() {
return defaultMaxResultCount
}
@@ -134,7 +133,7 @@ func extractClaudeWebSearchMaxUses(payload []byte) int64 {
}
func extractClaudeWebSearchAllowedDomains(payload []byte) []string {
- tools := gjson.GetBytes(payload, "tools")
+ tools := ojson.GetBytesNoCopy(payload, "tools")
if !tools.IsArray() {
return nil
}
@@ -148,7 +147,7 @@ func extractClaudeWebSearchAllowedDomains(payload []byte) []string {
}
domains := make([]string, 0, len(allowedDomains.Array()))
for _, domain := range allowedDomains.Array() {
- if domain.Type != gjson.String {
+ if domain.Type != ojson.String {
continue
}
if trimmed := strings.TrimSpace(domain.String()); trimmed != "" {
@@ -161,7 +160,7 @@ func extractClaudeWebSearchAllowedDomains(payload []byte) []string {
}
func extractClaudeWebSearchQuery(payload []byte) string {
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if !messages.IsArray() {
return ""
}
@@ -178,8 +177,8 @@ func extractClaudeWebSearchQuery(payload []byte) string {
return ""
}
-func extractClaudeTextContent(content gjson.Result) string {
- if content.Type == gjson.String {
+func extractClaudeTextContent(content ojson.Result) string {
+ if content.Type == ojson.String {
return strings.TrimSpace(content.String())
}
if !content.IsArray() {
@@ -198,7 +197,7 @@ func extractClaudeTextContent(content gjson.Result) string {
}
func hasAntigravityGoogleSearchTool(payload []byte) bool {
- tools := gjson.GetBytes(payload, "request.tools")
+ tools := ojson.GetBytesNoCopy(payload, "request.tools")
if !tools.IsArray() {
return false
}
@@ -214,7 +213,7 @@ func shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON []
return hasClaudeTypedWebSearchTool(originalRequestRawJSON) && hasAntigravityGoogleSearchTool(requestRawJSON)
}
-func antigravityGroundingMetadata(root gjson.Result) gjson.Result {
+func antigravityGroundingMetadata(root ojson.Result) ojson.Result {
groundingMetadata := root.Get("response.candidates.0.groundingMetadata")
if groundingMetadata.Exists() {
return groundingMetadata
@@ -222,7 +221,7 @@ func antigravityGroundingMetadata(root gjson.Result) gjson.Result {
return root.Get("candidates.0.groundingMetadata")
}
-func antigravityTextContent(root gjson.Result) string {
+func antigravityTextContent(root ojson.Result) string {
var textBuilder strings.Builder
parts := root.Get("response.candidates.0.content.parts")
if !parts.IsArray() {
@@ -238,7 +237,7 @@ func antigravityTextContent(root gjson.Result) string {
return textBuilder.String()
}
-func antigravityUsageTokens(root gjson.Result) (int64, int64) {
+func antigravityUsageTokens(root ojson.Result) (int64, int64) {
usage := root.Get("response.usageMetadata")
if !usage.Exists() {
usage = root.Get("usageMetadata")
@@ -257,19 +256,19 @@ func antigravityUsageTokens(root gjson.Result) (int64, int64) {
return inputTokens, outputTokens
}
-func webSearchQueryFromGrounding(groundingMetadata gjson.Result) string {
+func webSearchQueryFromGrounding(groundingMetadata ojson.Result) string {
if queries := groundingMetadata.Get("webSearchQueries"); queries.IsArray() && len(queries.Array()) > 0 {
return queries.Array()[0].String()
}
return ""
}
-func webSearchResultsFromGrounding(groundingMetadata gjson.Result) []byte {
- results := []byte(`[]`)
+func webSearchResultsFromGrounding(groundingMetadata ojson.Result) []byte {
groundingChunks := groundingMetadata.Get("groundingChunks")
if !groundingChunks.IsArray() {
- return results
+ return []byte(`[]`)
}
+ resultsDoc := ojson.NewDocString(`[]`)
seenURLs := make(map[string]struct{})
for _, chunk := range groundingChunks.Array() {
web := chunk.Get("web")
@@ -285,17 +284,17 @@ func webSearchResultsFromGrounding(groundingMetadata gjson.Result) []byte {
}
seenURLs[uri] = struct{}{}
- result := []byte(`{"type":"web_search_result","page_age":null}`)
+ resultDoc := ojson.NewDocString(`{"type":"web_search_result","page_age":null}`)
if title := web.Get("title"); title.Exists() {
- result, _ = sjson.SetBytes(result, "title", title.String())
+ _ = resultDoc.Set("title", title.String())
}
- result, _ = sjson.SetBytes(result, "url", uri)
- results, _ = sjson.SetRawBytes(results, "-1", result)
+ _ = resultDoc.Set("url", uri)
+ _ = resultsDoc.SetRawBytes("-1", resultDoc.Bytes())
}
- return results
+ return resultsDoc.Bytes()
}
-func parseWebSearchGroundingSupports(groundingMetadata gjson.Result) []webSearchGroundingSupport {
+func parseWebSearchGroundingSupports(groundingMetadata ojson.Result) []webSearchGroundingSupport {
groundingChunks := groundingMetadata.Get("groundingChunks")
if !groundingChunks.IsArray() {
return nil
@@ -402,47 +401,47 @@ func buildWebSearchCitedTextBlocks(textContent string, supports []webSearchGroun
return blocks
}
-func buildClaudeWebSearchContent(toolUseID string, textContent string, groundingMetadata gjson.Result) []byte {
- content := []byte(`[]`)
+func buildClaudeWebSearchContent(toolUseID string, textContent string, groundingMetadata ojson.Result) []byte {
+ contentDoc := ojson.NewDocString(`[]`)
- serverToolUse := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
- serverToolUse, _ = sjson.SetBytes(serverToolUse, "id", toolUseID)
+ serverToolUseDoc := ojson.NewDocString(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
+ _ = serverToolUseDoc.Set("id", toolUseID)
if query := webSearchQueryFromGrounding(groundingMetadata); query != "" {
- serverToolUse, _ = sjson.SetBytes(serverToolUse, "input.query", query)
+ _ = serverToolUseDoc.Set("input.query", query)
}
- content, _ = sjson.SetRawBytes(content, "-1", serverToolUse)
+ _ = contentDoc.SetRawBytes("-1", serverToolUseDoc.Bytes())
- webSearchToolResult := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
- webSearchToolResult, _ = sjson.SetBytes(webSearchToolResult, "tool_use_id", toolUseID)
- webSearchToolResult, _ = sjson.SetRawBytes(webSearchToolResult, "content", webSearchResultsFromGrounding(groundingMetadata))
- content, _ = sjson.SetRawBytes(content, "-1", webSearchToolResult)
+ webSearchToolResultDoc := ojson.NewDocString(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
+ _ = webSearchToolResultDoc.Set("tool_use_id", toolUseID)
+ _ = webSearchToolResultDoc.SetRawBytes("content", webSearchResultsFromGrounding(groundingMetadata))
+ _ = contentDoc.SetRawBytes("-1", webSearchToolResultDoc.Bytes())
for _, block := range buildWebSearchCitedTextBlocks(textContent, parseWebSearchGroundingSupports(groundingMetadata)) {
if block.Text == "" {
continue
}
- textBlock := []byte(`{"type":"text","text":""}`)
- textBlock, _ = sjson.SetBytes(textBlock, "text", block.Text)
+ textBlockDoc := ojson.NewDocString(`{"type":"text","text":""}`)
+ _ = textBlockDoc.Set("text", block.Text)
if len(block.Citations) > 0 {
citationsJSON, _ := json.Marshal(block.Citations)
- textBlock, _ = sjson.SetRawBytes(textBlock, "citations", citationsJSON)
+ _ = textBlockDoc.SetRawBytes("citations", citationsJSON)
}
- content, _ = sjson.SetRawBytes(content, "-1", textBlock)
+ _ = contentDoc.SetRawBytes("-1", textBlockDoc.Bytes())
}
- return content
+ return contentDoc.Bytes()
}
-func appendClaudeWebSearchStreamBlocks(appendEvent func(string, string), startIndex int, toolUseID string, textContent string, groundingMetadata gjson.Result) int {
+func appendClaudeWebSearchStreamBlocks(appendEvent func(string, string), startIndex int, toolUseID string, textContent string, groundingMetadata ojson.Result) int {
contentIndex := startIndex
serverToolUseStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"server_tool_use","id":"%s","name":"web_search","input":{}}}`,
contentIndex, toolUseID)
appendEvent("content_block_start", serverToolUseStart)
if query := webSearchQueryFromGrounding(groundingMetadata); query != "" {
- queryJSON, _ := sjson.Set(`{}`, "query", query)
+ queryJSON, _ := ojson.Set(`{}`, "query", query)
inputDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, contentIndex)
- inputDelta, _ = sjson.Set(inputDelta, "delta.partial_json", queryJSON)
+ inputDelta, _ = ojson.Set(inputDelta, "delta.partial_json", queryJSON)
appendEvent("content_block_delta", inputDelta)
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
@@ -450,7 +449,7 @@ func appendClaudeWebSearchStreamBlocks(appendEvent func(string, string), startIn
webSearchToolResultStart := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"web_search_tool_result","tool_use_id":"%s","content":[]}}`,
contentIndex, toolUseID)
- webSearchToolResultStart, _ = sjson.SetRaw(webSearchToolResultStart, "content_block.content", string(webSearchResultsFromGrounding(groundingMetadata)))
+ webSearchToolResultStart, _ = ojson.SetRaw(webSearchToolResultStart, "content_block.content", string(webSearchResultsFromGrounding(groundingMetadata)))
appendEvent("content_block_start", webSearchToolResultStart)
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
contentIndex++
@@ -471,7 +470,7 @@ func appendClaudeWebSearchStreamBlocks(appendEvent func(string, string), startIn
}
for _, chunk := range splitRunesForWebSearch(block.Text, 50) {
textDelta := fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, contentIndex)
- textDelta, _ = sjson.Set(textDelta, "delta.text", chunk)
+ textDelta, _ = ojson.Set(textDelta, "delta.text", chunk)
appendEvent("content_block_delta", textDelta)
}
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, contentIndex))
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request.go b/internal/translator/antigravity/gemini/antigravity_gemini_request.go
index 9f902a6be3b..174755481de 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_request.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_request.go
@@ -10,13 +10,12 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ConvertGeminiRequestToAntigravity parses and transforms a Antigravity API request into Gemini API format.
@@ -36,38 +35,35 @@ import (
// Returns:
// - []byte: The transformed request data in Gemini API format
func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) {
- rawJSON := inputRawJSON
+ if len(inputRawJSON) == 0 {
+ return []byte{}, nil
+ }
functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON)
- // Keep the envelope in []byte form. Round-tripping through string copies the
- // entire request, which dominates allocations for large inline data. Fill the
- // small envelope fields first so the payload is only spliced in once.
- envelope, _ := sjson.SetBytes([]byte(`{"project":"","request":{},"model":""}`), "model", modelName)
- rawJSON, _ = sjson.SetRawBytes(envelope, "request", rawJSON)
- if util.GetGJSONBytesNoCopy(rawJSON, "request.model").Exists() {
- rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.model")
+ reqDoc := ojson.NewDoc(inputRawJSON)
+ if reqDoc.Get("model").Exists() {
+ _ = reqDoc.Delete("model")
}
- fixedJSON, errFixCLIToolResponse := fixCLIToolResponse(rawJSON)
- if errFixCLIToolResponse != nil {
+ if _, errFixCLIToolResponse := fixCLIToolResponseDocAt(reqDoc, ""); errFixCLIToolResponse != nil {
return []byte{}, nil
}
- rawJSON = fixedJSON
- if systemInstructionResult := util.GetGJSONBytesNoCopy(rawJSON, "request.system_instruction"); systemInstructionResult.Exists() {
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.systemInstruction", []byte(systemInstructionResult.Raw))
- rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.system_instruction")
+ if systemInstructionResult := reqDoc.Get("system_instruction"); systemInstructionResult.Exists() {
+ _ = reqDoc.SetRaw("systemInstruction", systemInstructionResult.Raw)
+ _ = reqDoc.Delete("system_instruction")
}
+ signature.SanitizeGeminiSystemInstructionDoc(reqDoc, "contents")
- rawJSON = normalizeGeminiGenerationConfigResponseSchema(rawJSON)
+ normalizeGeminiGenerationConfigResponseSchemaDocAt(reqDoc, "")
// Normalize roles in request.contents: default to valid values if missing/invalid.
// The contents array is only materialized when a role actually changes; copying
// every content up front duplicates the whole payload for large inline data.
- contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
+ contents := reqDoc.Get("contents")
if contents.IsArray() && geminiContentRolesNeedNormalization(contents) {
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
previousRole := ""
- contents.ForEach(func(_, value gjson.Result) bool {
+ contents.ForEach(func(_, value ojson.Result) bool {
role := value.Get("role").String()
content := []byte(value.Raw)
if role != "user" && role != "model" {
@@ -78,21 +74,21 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _
} else {
role = "model"
}
- content, _ = sjson.SetBytes(content, "role", role)
+ content, _ = ojson.SetBytes(content, "role", role)
}
previousRole = role
contentItems = append(contentItems, content)
return true
})
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents", translatorcommon.JoinRawArray(contentItems))
+ _ = reqDoc.SetRawBytes("contents", translatorcommon.JoinRawArray(contentItems))
}
- toolsResult := util.GetGJSONBytesNoCopy(rawJSON, "request.tools")
+ toolsResult := reqDoc.Get("tools")
if toolsResult.IsArray() {
seenFunctionNames := make(map[string]struct{})
toolsChanged := false
var toolItems [][]byte
- toolsResult.ForEach(func(toolIndex, tool gjson.Result) bool {
+ toolsResult.ForEach(func(toolIndex, tool ojson.Result) bool {
toolJSON := []byte(tool.Raw)
toolChanged := false
for _, key := range []string{"functionDeclarations", "function_declarations"} {
@@ -103,7 +99,7 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _
declarationsChanged := false
var declarationItems [][]byte
- declarations.ForEach(func(_, declaration gjson.Result) bool {
+ declarations.ForEach(func(_, declaration ojson.Result) bool {
nameResult := declaration.Get("name")
originalName := nameResult.String()
mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName)
@@ -115,22 +111,38 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _
seenFunctionNames[mappedName] = struct{}{}
}
- declarationJSON := []byte(declaration.Raw)
- if nameResult.Type != gjson.String || mappedName != originalName {
- declarationJSON, _ = sjson.SetBytes(declarationJSON, "name", mappedName)
+ var declarationDoc *ojson.Doc
+ ensureDeclDoc := func() *ojson.Doc {
+ if declarationDoc == nil {
+ declarationDoc = ojson.NewDocString(declaration.Raw)
+ }
+ return declarationDoc
+ }
+ if nameResult.Type != ojson.String || mappedName != originalName {
+ _ = ensureDeclDoc().Set("name", mappedName)
declarationsChanged = true
}
if parameters := declaration.Get("parameters"); parameters.Exists() {
- declarationJSON, _ = sjson.SetRawBytes(declarationJSON, "parametersJsonSchema", []byte(parameters.Raw))
- declarationJSON, _ = sjson.DeleteBytes(declarationJSON, "parameters")
+ normalizedParams := util.NormalizeJSONSchemaNumericConstraints(parameters.Raw)
+ _ = ensureDeclDoc().SetRaw("parametersJsonSchema", normalizedParams)
+ _ = ensureDeclDoc().Delete("parameters")
declarationsChanged = true
+ } else if parametersJSONSchema := declaration.Get("parametersJsonSchema"); parametersJSONSchema.Exists() {
+ if normalizedParams := util.NormalizeJSONSchemaNumericConstraints(parametersJSONSchema.Raw); normalizedParams != parametersJSONSchema.Raw {
+ _ = ensureDeclDoc().SetRaw("parametersJsonSchema", normalizedParams)
+ declarationsChanged = true
+ }
+ }
+ if declarationDoc != nil {
+ declarationItems = append(declarationItems, declarationDoc.Bytes())
+ } else {
+ declarationItems = append(declarationItems, []byte(declaration.Raw))
}
- declarationItems = append(declarationItems, declarationJSON)
return true
})
if declarationsChanged {
var errSet error
- toolJSON, errSet = sjson.SetRawBytes(toolJSON, key, translatorcommon.JoinRawArray(declarationItems))
+ toolJSON, errSet = ojson.SetRawBytes(toolJSON, key, translatorcommon.JoinRawArray(declarationItems))
if errSet != nil {
log.Warnf("failed to normalize function declarations in tool %d: %v", toolIndex.Int(), errSet)
} else {
@@ -143,47 +155,73 @@ func ConvertGeminiRequestToAntigravity(modelName string, inputRawJSON []byte, _
return true
})
if toolsChanged {
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", translatorcommon.JoinRawArray(toolItems))
+ _ = reqDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- rawJSON = removeEmptyGeminiFunctionTools(rawJSON)
+ removeEmptyGeminiFunctionToolsDocAt(reqDoc, "")
}
- rawJSON = rewriteGeminiFunctionNames(rawJSON, functionNameMap)
+ rewriteGeminiFunctionNamesDocAt(reqDoc, "", functionNameMap)
if strings.Contains(strings.ToLower(modelName), "claude") {
- rawJSON = SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, rawJSON)
+ SanitizeAntigravityClaudeGeminiRequestSignaturesDocAt(modelName, reqDoc, "")
} else {
- rawJSON = signature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents")
+ signature.SanitizeGeminiRequestThoughtSignaturesDoc(reqDoc, "contents")
}
- return common.AttachDefaultSafetySettings(rawJSON, "request.safetySettings"), nil
+ common.AttachDefaultSafetySettingsDoc(reqDoc, "safetySettings")
+
+ modelJSON, _ := json.Marshal(modelName)
+ out := make([]byte, 0, 34+len(modelJSON)+reqDoc.Len())
+ out = append(out, `{"project":"","request":`...)
+ out = reqDoc.AppendTo(out)
+ out = append(out, `,"model":`...)
+ out = append(out, modelJSON...)
+ out = append(out, '}')
+ return out, nil
}
// normalizeGeminiGenerationConfigResponseSchema converts generationConfig.responseJsonSchema
// (and snake_case response_json_schema) to generationConfig.responseSchema for Antigravity compatibility.
func normalizeGeminiGenerationConfigResponseSchema(rawJSON []byte) []byte {
- for _, container := range []string{"request.generationConfig", "request.generation_config"} {
- if !util.GetGJSONBytesNoCopy(rawJSON, container).Exists() {
+ doc := ojson.NewDoc(rawJSON)
+ if normalizeGeminiGenerationConfigResponseSchemaDoc(doc) {
+ return doc.Bytes()
+ }
+ return rawJSON
+}
+
+func normalizeGeminiGenerationConfigResponseSchemaDoc(doc *ojson.Doc) bool {
+ return normalizeGeminiGenerationConfigResponseSchemaDocAt(doc, "request.")
+}
+
+func normalizeGeminiGenerationConfigResponseSchemaDocAt(doc *ojson.Doc, prefix string) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
+ for _, container := range []string{prefix + "generationConfig", prefix + "generation_config"} {
+ if !doc.Get(container).Exists() {
continue
}
for _, schemaKey := range []string{"responseJsonSchema", "response_json_schema"} {
oldPath := container + "." + schemaKey
- if schema := util.GetGJSONBytesNoCopy(rawJSON, oldPath); schema.Exists() {
+ if schema := doc.Get(oldPath); schema.Exists() {
targetPath := container + ".responseSchema"
- if !util.GetGJSONBytesNoCopy(rawJSON, targetPath).Exists() {
- rawJSON, _ = sjson.SetRawBytes(rawJSON, targetPath, []byte(schema.Raw))
+ if !doc.Get(targetPath).Exists() {
+ _ = doc.SetRaw(targetPath, schema.Raw)
}
- rawJSON, _ = sjson.DeleteBytes(rawJSON, oldPath)
+ _ = doc.Delete(oldPath)
+ changed = true
}
}
}
- return rawJSON
+ return changed
}
// geminiContentRolesNeedNormalization reports whether any content role is missing
// or invalid and therefore requires rebuilding the contents array.
-func geminiContentRolesNeedNormalization(contents gjson.Result) bool {
+func geminiContentRolesNeedNormalization(contents ojson.Result) bool {
needsNormalization := false
- contents.ForEach(func(_, value gjson.Result) bool {
+ contents.ForEach(func(_, value ojson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
needsNormalization = true
@@ -195,22 +233,45 @@ func geminiContentRolesNeedNormalization(contents gjson.Result) bool {
}
func removeEmptyGeminiFunctionTools(rawJSON []byte) []byte {
- tools := util.GetGJSONBytesNoCopy(rawJSON, "request.tools")
+ doc := ojson.NewDoc(rawJSON)
+ if removeEmptyGeminiFunctionToolsDoc(doc) {
+ return doc.Bytes()
+ }
+ return rawJSON
+}
+
+func removeEmptyGeminiFunctionToolsDoc(doc *ojson.Doc) bool {
+ return removeEmptyGeminiFunctionToolsDocAt(doc, "request.")
+}
+
+func removeEmptyGeminiFunctionToolsDocAt(doc *ojson.Doc, prefix string) bool {
+ if doc == nil {
+ return false
+ }
+ toolsPath := prefix + "tools"
+ tools := doc.Get(toolsPath)
if tools.IsArray() && len(tools.Array()) == 0 {
- rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools")
- return rawJSON
+ _ = doc.Delete(toolsPath)
+ return true
}
changed := false
var cleanedTools [][]byte
for _, tool := range tools.Array() {
toolJSON := []byte(tool.Raw)
if tool.IsObject() {
+ var toolDoc *ojson.Doc
for _, key := range []string{"functionDeclarations", "function_declarations"} {
if declarations := tool.Get(key); declarations.IsArray() && len(declarations.Array()) == 0 {
- toolJSON, _ = sjson.DeleteBytes(toolJSON, key)
+ if toolDoc == nil {
+ toolDoc = ojson.NewDoc(toolJSON)
+ }
+ _ = toolDoc.Delete(key)
changed = true
}
}
+ if toolDoc != nil {
+ toolJSON = toolDoc.Bytes()
+ }
if len(util.ParseGJSONBytesNoCopy(toolJSON).Map()) == 0 {
changed = true
continue
@@ -219,14 +280,22 @@ func removeEmptyGeminiFunctionTools(rawJSON []byte) []byte {
cleanedTools = append(cleanedTools, toolJSON)
}
if !changed {
- return rawJSON
+ return false
}
if len(cleanedTools) == 0 {
- rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.tools")
- return rawJSON
+ _ = doc.Delete(toolsPath)
+ return true
}
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.tools", translatorcommon.JoinRawArray(cleanedTools))
- return rawJSON
+ _ = doc.SetRawBytes(toolsPath, translatorcommon.JoinRawArray(cleanedTools))
+ return true
+}
+
+func removeEmptyGeminiSystemInstructionParts(rawJSON []byte) []byte {
+ return signature.SanitizeGeminiSystemInstruction(rawJSON, "request.contents")
+}
+
+func removeEmptyGeminiSystemInstructionPartsDoc(doc *ojson.Doc) {
+ signature.SanitizeGeminiSystemInstructionDoc(doc, "request.contents")
}
// geminiFunctionNameFields lists the part fields that can carry a function name.
@@ -234,17 +303,17 @@ var geminiFunctionNameFields = []string{"functionCall", "functionResponse", "fun
// geminiFunctionNamesNeedRewrite reports whether any part carries a function name
// that must be remapped or coerced to a string.
-func geminiFunctionNamesNeedRewrite(contents gjson.Result, functionNameMap map[string]string) bool {
+func geminiFunctionNamesNeedRewrite(contents ojson.Result, functionNameMap map[string]string) bool {
needsRewrite := false
- contents.ForEach(func(_, content gjson.Result) bool {
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
for _, field := range geminiFunctionNameFields {
nameResult := part.Get(field + ".name")
name := nameResult.String()
if name == "" {
continue
}
- if nameResult.Type == gjson.String && util.MapSanitizedFunctionName(functionNameMap, name) == name {
+ if nameResult.Type == ojson.String && util.MapSanitizedFunctionName(functionNameMap, name) == name {
continue
}
needsRewrite = true
@@ -258,10 +327,27 @@ func geminiFunctionNamesNeedRewrite(contents gjson.Result, functionNameMap map[s
}
func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]string) []byte {
- contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
+ doc := ojson.NewDoc(rawJSON)
+ if rewriteGeminiFunctionNamesDoc(doc, functionNameMap) {
+ return doc.Bytes()
+ }
+ return rawJSON
+}
+
+func rewriteGeminiFunctionNamesDoc(doc *ojson.Doc, functionNameMap map[string]string) bool {
+ return rewriteGeminiFunctionNamesDocAt(doc, "request.", functionNameMap)
+}
+
+func rewriteGeminiFunctionNamesDocAt(doc *ojson.Doc, prefix string, functionNameMap map[string]string) bool {
+ if doc == nil {
+ return false
+ }
+ changed := false
+ contentsPath := prefix + "contents"
+ contents := doc.Get(contentsPath)
canBatchContents := contents.IsArray()
if canBatchContents {
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
if parts.Exists() && !parts.IsArray() {
canBatchContents = false
@@ -274,12 +360,12 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin
// it once a name actually needs rewriting.
if canBatchContents && geminiFunctionNamesNeedRewrite(contents, functionNameMap) {
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
contentJSON := []byte(content.Raw)
partsChanged := false
partItems := make([][]byte, 0, 4)
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
- partJSON := []byte(part.Raw)
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
+ var partDoc *ojson.Doc
for _, field := range geminiFunctionNameFields {
nameResult := part.Get(field + ".name")
name := nameResult.String()
@@ -287,22 +373,30 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin
continue
}
mappedName := util.MapSanitizedFunctionName(functionNameMap, name)
- if nameResult.Type == gjson.String && mappedName == name {
+ if nameResult.Type == ojson.String && mappedName == name {
continue
}
- partJSON, _ = sjson.SetBytes(partJSON, field+".name", mappedName)
+ if partDoc == nil {
+ partDoc = ojson.NewDocString(part.Raw)
+ }
+ _ = partDoc.Set(field+".name", mappedName)
partsChanged = true
}
- partItems = append(partItems, partJSON)
+ if partDoc != nil {
+ partItems = append(partItems, partDoc.Bytes())
+ } else {
+ partItems = append(partItems, []byte(part.Raw))
+ }
return true
})
if partsChanged {
- contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts", translatorcommon.JoinRawArray(partItems))
+ contentJSON, _ = ojson.SetRawBytes(contentJSON, "parts", translatorcommon.JoinRawArray(partItems))
}
contentItems = append(contentItems, contentJSON)
return true
})
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents", translatorcommon.JoinRawArray(contentItems))
+ _ = doc.SetRawBytes(contentsPath, translatorcommon.JoinRawArray(contentItems))
+ changed = true
} else if !canBatchContents {
for contentIndex, content := range contents.Array() {
for partIndex, part := range content.Get("parts").Array() {
@@ -313,52 +407,73 @@ func rewriteGeminiFunctionNames(rawJSON []byte, functionNameMap map[string]strin
continue
}
mappedName := util.MapSanitizedFunctionName(functionNameMap, name)
- if nameResult.Type == gjson.String && mappedName == name {
+ if nameResult.Type == ojson.String && mappedName == name {
continue
}
- path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field)
- rawJSON, _ = sjson.SetBytes(rawJSON, path, mappedName)
+ path := fmt.Sprintf("%s.%d.parts.%d.%s.name", contentsPath, contentIndex, partIndex, field)
+ _ = doc.Set(path, mappedName)
+ changed = true
}
}
}
}
for _, allowedPath := range []string{
- "request.toolConfig.functionCallingConfig.allowedFunctionNames",
- "request.tool_config.function_calling_config.allowed_function_names",
+ prefix + "toolConfig.functionCallingConfig.allowedFunctionNames",
+ prefix + "tool_config.function_calling_config.allowed_function_names",
} {
- allowedNames := util.GetGJSONBytesNoCopy(rawJSON, allowedPath)
+ allowedNames := doc.Get(allowedPath)
if allowedNames.IsArray() {
namesChanged := false
nameItems := make([][]byte, 0, 4)
- allowedNames.ForEach(func(_, name gjson.Result) bool {
+ allowedNames.ForEach(func(_, name ojson.Result) bool {
mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String())
- namesChanged = namesChanged || name.Type != gjson.String || mappedName != name.String()
+ namesChanged = namesChanged || name.Type != ojson.String || mappedName != name.String()
mappedNameJSON, _ := json.Marshal(mappedName)
nameItems = append(nameItems, mappedNameJSON)
return true
})
if namesChanged {
- rawJSON, _ = sjson.SetRawBytes(rawJSON, allowedPath, translatorcommon.JoinRawArray(nameItems))
+ _ = doc.SetRawBytes(allowedPath, translatorcommon.JoinRawArray(nameItems))
+ changed = true
}
} else {
for index, name := range allowedNames.Array() {
mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String())
- if name.Type == gjson.String && mappedName == name.String() {
+ if name.Type == ojson.String && mappedName == name.String() {
continue
}
path := fmt.Sprintf("%s.%d", allowedPath, index)
- rawJSON, _ = sjson.SetBytes(rawJSON, path, mappedName)
+ _ = doc.Set(path, mappedName)
+ changed = true
}
}
}
- return rawJSON
+ return changed
}
func SanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON []byte) []byte {
- contents := util.GetGJSONBytesNoCopy(rawJSON, "request.contents")
+ rawJSON = signature.SanitizeGeminiSystemInstruction(rawJSON, "request.contents")
+ doc := ojson.NewDoc(rawJSON)
+ if SanitizeAntigravityClaudeGeminiRequestSignaturesDoc(modelName, doc) {
+ return doc.Bytes()
+ }
+ return rawJSON
+}
+
+func SanitizeAntigravityClaudeGeminiRequestSignaturesDoc(modelName string, doc *ojson.Doc) bool {
+ return SanitizeAntigravityClaudeGeminiRequestSignaturesDocAt(modelName, doc, "request.")
+}
+
+func SanitizeAntigravityClaudeGeminiRequestSignaturesDocAt(modelName string, doc *ojson.Doc, prefix string) bool {
+ if doc == nil {
+ return false
+ }
+ contentsPath := prefix + "contents"
+ signature.SanitizeGeminiSystemInstructionDoc(doc, contentsPath)
+ contents := doc.Get(contentsPath)
if !contents.IsArray() {
- return rawJSON
+ return false
}
contentsArray := contents.Array()
@@ -462,7 +577,7 @@ func SanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON
}
if contentChanged || len(rewrittenParts) != len(partsArray) {
contentBytes := []byte(content.Raw)
- contentBytes, _ = sjson.SetRawBytes(contentBytes, "parts", translatorcommon.JoinRawArray(rewrittenParts))
+ contentBytes, _ = ojson.SetRawBytes(contentBytes, "parts", translatorcommon.JoinRawArray(rewrittenParts))
rewrittenContents = append(rewrittenContents, contentBytes)
} else {
rewrittenContents = append(rewrittenContents, []byte(content.Raw))
@@ -470,13 +585,9 @@ func SanitizeAntigravityClaudeGeminiRequestSignatures(modelName string, rawJSON
}
if !changed {
- return rawJSON
+ return false
}
- out, errSet := sjson.SetRawBytes(rawJSON, "request.contents", translatorcommon.JoinRawArray(rewrittenContents))
- if errSet != nil {
- return rawJSON
- }
- return out
+ return doc.SetRawBytes(contentsPath, translatorcommon.JoinRawArray(rewrittenContents)) == nil
}
func antigravityClaudeGeminiPartHasThoughtSignatureKeyInRaw(raw string) bool {
@@ -661,10 +772,121 @@ func logAntigravityClaudeGeminiSignatureSanitize(modelName, action, reason strin
// FunctionCallGroup represents a group of function calls and their responses
type FunctionCallGroup struct {
ResponsesNeeded int
+ CallIDs []string // ordered function call IDs for matching responses when extras exist
CallNames []string // ordered function call names for backfilling empty response names
}
-func normalizeAntigravityInlineDataPart(part gjson.Result) ([]byte, bool) {
+func selectGroupResponses(collected []ojson.Result, group *FunctionCallGroup) ([]ojson.Result, []ojson.Result) {
+ needed := group.ResponsesNeeded
+ if len(collected) <= needed {
+ return collected, nil
+ }
+
+ hasCallID := false
+ for _, id := range group.CallIDs {
+ if id != "" {
+ hasCallID = true
+ break
+ }
+ }
+ if !hasCallID {
+ return collected[:needed], collected[needed:]
+ }
+
+ // Single O(M) pass over collected responses to index id and name once per part.
+ byID := make(map[string][]int, len(collected))
+ byName := make(map[string][]int, len(collected))
+ var emptyNameIdxs []int
+ for idx, resp := range collected {
+ funcResp := resp.Get("functionResponse")
+ if id := funcResp.Get("id").String(); id != "" {
+ byID[id] = append(byID[id], idx)
+ }
+ if name := funcResp.Get("name").String(); name != "" {
+ byName[name] = append(byName[name], idx)
+ } else {
+ emptyNameIdxs = append(emptyNameIdxs, idx)
+ }
+ }
+
+ used := make([]bool, len(collected))
+ selected := make([]ojson.Result, needed)
+ matchedSlot := make([]bool, needed)
+ popUnused := func(queue []int) (int, []int, bool) {
+ for len(queue) > 0 {
+ idx := queue[0]
+ queue = queue[1:]
+ if !used[idx] {
+ return idx, queue, true
+ }
+ }
+ return 0, queue, false
+ }
+
+ for slot := 0; slot < needed; slot++ {
+ if slot >= len(group.CallIDs) || group.CallIDs[slot] == "" {
+ continue
+ }
+ callID := group.CallIDs[slot]
+ if idx, rest, ok := popUnused(byID[callID]); ok {
+ byID[callID] = rest
+ used[idx] = true
+ selected[slot] = collected[idx]
+ matchedSlot[slot] = true
+ } else {
+ delete(byID, callID)
+ }
+ }
+
+ for slot := 0; slot < needed; slot++ {
+ if matchedSlot[slot] || slot >= len(group.CallNames) || group.CallNames[slot] == "" {
+ continue
+ }
+ callName := group.CallNames[slot]
+ if idx, rest, ok := popUnused(byName[callName]); ok {
+ byName[callName] = rest
+ used[idx] = true
+ selected[slot] = collected[idx]
+ matchedSlot[slot] = true
+ continue
+ }
+ delete(byName, callName)
+ if idx, rest, ok := popUnused(emptyNameIdxs); ok {
+ emptyNameIdxs = rest
+ used[idx] = true
+ selected[slot] = collected[idx]
+ matchedSlot[slot] = true
+ } else {
+ emptyNameIdxs = nil
+ }
+ }
+
+ freeCursor := 0
+ for slot := 0; slot < needed; slot++ {
+ if matchedSlot[slot] {
+ continue
+ }
+ for freeCursor < len(collected) && used[freeCursor] {
+ freeCursor++
+ }
+ if freeCursor < len(collected) {
+ used[freeCursor] = true
+ selected[slot] = collected[freeCursor]
+ matchedSlot[slot] = true
+ freeCursor++
+ }
+ }
+
+ remaining := make([]ojson.Result, 0, len(collected)-needed)
+ for idx, resp := range collected {
+ if !used[idx] {
+ remaining = append(remaining, resp)
+ }
+ }
+ return selected, remaining
+}
+
+func normalizeAntigravityInlineDataPart(part ojson.Result) ([]byte, bool) {
inline := part.Get("inlineData")
if !inline.Exists() {
inline = part.Get("inline_data")
@@ -684,31 +906,31 @@ func normalizeAntigravityInlineDataPart(part gjson.Result) ([]byte, bool) {
// Cloud Code Assist ignores inlineData without mimeType.
mimeType = "image/png"
}
- out := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
- out, _ = sjson.SetBytes(out, "inlineData.mimeType", mimeType)
- out, _ = sjson.SetBytes(out, "inlineData.data", data)
- return out, true
+ outDoc := ojson.NewDoc([]byte(`{"inlineData":{"mimeType":"","data":""}}`))
+ _ = outDoc.Set("inlineData.mimeType", mimeType)
+ _ = outDoc.Set("inlineData.data", data)
+ return outDoc.Bytes(), true
}
-func attachInlineDataToFunctionResponse(response gjson.Result, images [][]byte) gjson.Result {
+func attachInlineDataToFunctionResponse(response ojson.Result, images [][]byte) ojson.Result {
if len(images) == 0 {
return response
}
- target := []byte(response.Raw)
+ targetDoc := ojson.NewDocString(response.Raw)
for _, img := range images {
- target, _ = sjson.SetRawBytes(target, "functionResponse.parts.-1", img)
+ _ = targetDoc.SetRawBytes("functionResponse.parts.-1", img)
}
- return gjson.ParseBytes(target)
+ return util.ParseGJSONBytesNoCopy(targetDoc.Bytes())
}
// collectFunctionResponsesWithSiblingInlineData keeps functionResponse parts and
// moves sibling inline_data/inlineData onto the nearest preceding functionResponse.
// Leading images before the first functionResponse attach to that first response.
-func collectFunctionResponsesWithSiblingInlineData(parts gjson.Result) []gjson.Result {
- responses := make([]gjson.Result, 0)
+func collectFunctionResponsesWithSiblingInlineData(parts ojson.Result) []ojson.Result {
+ responses := make([]ojson.Result, 0)
leadingImages := make([][]byte, 0)
current := -1
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if part.Get("functionResponse").Exists() {
responses = append(responses, part)
current = len(responses) - 1
@@ -735,12 +957,12 @@ func collectFunctionResponsesWithSiblingInlineData(parts gjson.Result) []gjson.R
// parseFunctionResponseRaw attempts to normalize a function response part into a JSON object string.
// Falls back to a minimal "functionResponse" object when parsing fails.
// fallbackName is used when the response's own name is empty.
-func parseFunctionResponseRaw(response gjson.Result, fallbackName string) string {
- if response.IsObject() && gjson.Valid(response.Raw) {
+func parseFunctionResponseRaw(response ojson.Result, fallbackName string) string {
+ if response.IsObject() && ojson.Valid(response.Raw) {
raw := response.Raw
name := response.Get("functionResponse.name").String()
if strings.TrimSpace(name) == "" && fallbackName != "" {
- updated, _ := sjson.SetBytes([]byte(raw), "functionResponse.name", fallbackName)
+ updated, _ := ojson.SetBytes([]byte(raw), "functionResponse.name", fallbackName)
raw = string(updated)
}
return raw
@@ -749,27 +971,27 @@ func parseFunctionResponseRaw(response gjson.Result, fallbackName string) string
log.Debugf("parse function response failed, using fallback")
funcResp := response.Get("functionResponse")
if funcResp.Exists() {
- fr := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
+ frDoc := ojson.NewDoc([]byte(`{"functionResponse":{"name":"","response":{"result":""}}}`))
name := funcResp.Get("name").String()
if strings.TrimSpace(name) == "" {
name = fallbackName
}
- fr, _ = sjson.SetBytes(fr, "functionResponse.name", name)
- fr, _ = sjson.SetBytes(fr, "functionResponse.response.result", funcResp.Get("response").String())
+ _ = frDoc.Set("functionResponse.name", name)
+ _ = frDoc.Set("functionResponse.response.result", funcResp.Get("response").String())
if id := funcResp.Get("id").String(); id != "" {
- fr, _ = sjson.SetBytes(fr, "functionResponse.id", id)
+ _ = frDoc.Set("functionResponse.id", id)
}
- return string(fr)
+ return frDoc.String()
}
useName := fallbackName
if useName == "" {
useName = "unknown"
}
- fr := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
- fr, _ = sjson.SetBytes(fr, "functionResponse.name", useName)
- fr, _ = sjson.SetBytes(fr, "functionResponse.response.result", response.String())
- return string(fr)
+ frDoc := ojson.NewDoc([]byte(`{"functionResponse":{"name":"","response":{"result":""}}}`))
+ _ = frDoc.Set("functionResponse.name", useName)
+ _ = frDoc.Set("functionResponse.response.result", response.String())
+ return frDoc.String()
}
// fixCLIToolResponse performs sophisticated tool response format conversion and grouping.
@@ -785,26 +1007,38 @@ func parseFunctionResponseRaw(response gjson.Result, fallbackName string) string
// - string: The processed JSON string with grouped function calls and responses
// - error: An error if the processing fails
func fixCLIToolResponse(input []byte) ([]byte, error) {
- // Parse the input JSON to extract the conversation structure.
- // The parsed result references input directly; input must not be mutated
- // while the result and its raw slices are still in use.
- parsed := util.ParseGJSONBytesNoCopy(input)
+ doc := ojson.NewDoc(input)
+ changed, err := fixCLIToolResponseDoc(doc)
+ if err != nil || !changed {
+ return input, err
+ }
+ return doc.Bytes(), nil
+}
+
+func fixCLIToolResponseDoc(doc *ojson.Doc) (bool, error) {
+ return fixCLIToolResponseDocAt(doc, "request.")
+}
+
+func fixCLIToolResponseDocAt(doc *ojson.Doc, prefix string) (bool, error) {
+ if doc == nil {
+ return false, fmt.Errorf("contents not found in input")
+ }
// Extract the contents array which contains the conversation messages
- contents := parsed.Get("request.contents")
+ contentsPath := prefix + "contents"
+ contents := doc.Get(contentsPath)
if !contents.Exists() {
- // log.Debugf(input)
- return input, fmt.Errorf("contents not found in input")
+ return false, fmt.Errorf("contents not found in input")
}
needsGrouping := false
allContentsAreObjects := true
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
if !content.IsObject() {
allContentsAreObjects = false
return true
}
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionResponse").Exists() {
needsGrouping = true
return false
@@ -814,14 +1048,14 @@ func fixCLIToolResponse(input []byte) ([]byte, error) {
return !needsGrouping
})
if contents.IsArray() && allContentsAreObjects && !needsGrouping {
- return input, nil
+ return false, nil
}
// Initialize data structures for processing and grouping
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
var pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses
- var collectedResponses []gjson.Result // Standalone responses to be matched
- appendFunctionResponses := func(responses []gjson.Result, callNames []string) {
+ var collectedResponses []ojson.Result // Standalone responses to be matched
+ appendFunctionResponses := func(responses []ojson.Result, callNames []string) {
partItems := make([][]byte, 0, len(responses))
for responseIndex, response := range responses {
partRaw := parseFunctionResponseRaw(response, callNames[responseIndex])
@@ -831,14 +1065,14 @@ func fixCLIToolResponse(input []byte) ([]byte, error) {
}
if len(partItems) > 0 {
functionResponseContent := []byte(`{"parts":[],"role":"function"}`)
- functionResponseContent, _ = sjson.SetRawBytes(functionResponseContent, "parts", translatorcommon.JoinRawArray(partItems))
+ functionResponseContent, _ = ojson.SetRawBytes(functionResponseContent, "parts", translatorcommon.JoinRawArray(partItems))
contentItems = append(contentItems, functionResponseContent)
}
}
// Process each content object in the conversation
// This iterates through messages and groups function calls with their responses
- contents.ForEach(func(key, value gjson.Result) bool {
+ contents.ForEach(func(key, value ojson.Result) bool {
role := value.Get("role").String()
parts := value.Get("parts")
@@ -855,20 +1089,27 @@ func fixCLIToolResponse(input []byte) ([]byte, error) {
pendingGroups = pendingGroups[1:]
// Take the needed responses for this group
- groupResponses := collectedResponses[:group.ResponsesNeeded]
- collectedResponses = collectedResponses[group.ResponsesNeeded:]
+ var groupResponses []ojson.Result
+ groupResponses, collectedResponses = selectGroupResponses(collectedResponses, group)
appendFunctionResponses(groupResponses, group.CallNames)
}
+ if len(pendingGroups) == 0 {
+ // Any remaining responses in this turn have no preceding functionCall group
+ // to match and must not leak into subsequent model turns.
+ collectedResponses = nil
+ }
return true // Skip adding this content, responses are merged
}
// If this is a model with function calls, create a new group
if role == "model" {
+ var callIDs []string
var callNames []string
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
+ callIDs = append(callIDs, part.Get("functionCall.id").String())
callNames = append(callNames, part.Get("functionCall.name").String())
}
return true
@@ -885,6 +1126,7 @@ func fixCLIToolResponse(input []byte) ([]byte, error) {
// Create a new group for tracking responses
group := &FunctionCallGroup{
ResponsesNeeded: len(callNames),
+ CallIDs: callIDs,
CallNames: callNames,
}
pendingGroups = append(pendingGroups, group)
@@ -918,8 +1160,7 @@ func fixCLIToolResponse(input []byte) ([]byte, error) {
}
}
- // Update the original JSON with the new contents
- result, _ := sjson.SetRawBytes(input, "request.contents", translatorcommon.JoinRawArray(contentItems))
-
- return result, nil
+ // Update the document with the new contents
+ _ = doc.SetRawBytes(contentsPath, translatorcommon.JoinRawArray(contentItems))
+ return true, nil
}
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go
index 2dfa995f765..5684ef3ed6c 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go
@@ -1,12 +1,12 @@
package gemini
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -28,7 +28,7 @@ func TestConvertGeminiRequestToAntigravity_ReplacesClientSignatureOnFunctionCall
output, _ := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
outputStr := string(output)
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part, got %d", len(parts))
}
@@ -55,7 +55,7 @@ func TestConvertGeminiRequestToAntigravity_DropsIncompatibleClientSignatureOnTex
}`, validSignature))
output, _ := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
- if signature := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.thoughtSignature"); signature.Exists() {
t.Fatalf("incompatible text signature should be dropped, got %s", signature.Raw)
}
}
@@ -74,7 +74,7 @@ func TestConvertGeminiRequestToAntigravity_LeavesUnsignedThoughtPartUnsigned(t *
}`)
output, _ := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
- if signature := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature"); signature.Exists() {
+ if signature := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.thoughtSignature"); signature.Exists() {
t.Fatalf("unsigned thought should remain unsigned, got %s", signature.Raw)
}
}
@@ -95,7 +95,7 @@ func TestConvertGeminiRequestToAntigravity_SkipsUppercaseClaudeModel(t *testing.
output, _ := ConvertGeminiRequestToAntigravity("Claude-Test", inputJSON, false)
outputStr := string(output)
- if sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature"); sig.Exists() {
+ if sig := ojson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature"); sig.Exists() {
t.Fatalf("Expected no thoughtSignature for Claude model, got %s", sig.Raw)
}
}
@@ -126,7 +126,7 @@ func TestConvertGeminiRequestToAntigravity_ClaudeModelNormalizesStrictClaudeThou
output, _ := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
- part := gjson.GetBytes(output, "request.contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0")
if !part.Get("thought").Bool() {
t.Fatalf("first part should remain thought. Output: %s", output)
}
@@ -160,10 +160,10 @@ func TestConvertGeminiRequestToAntigravity_ClaudeModelDropsNonStrictEPrefixThoug
output, _ := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
- if gjson.GetBytes(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ if ojson.GetBytesNoCopy(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
t.Fatalf("non-strict E-prefix thought block should be dropped. Output: %s", output)
}
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.text").String(); got != "visible answer" {
t.Fatalf("visible text = %q, want visible answer. Output: %s", got, output)
}
}
@@ -189,10 +189,10 @@ func TestConvertGeminiRequestToAntigravity_ClaudeModelDropsEmptyThoughtText(t *t
output, _ := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
- if gjson.GetBytes(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ if ojson.GetBytesNoCopy(output, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
t.Fatalf("empty-text thought block should be dropped for Antigravity Claude. Output: %s", output)
}
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.text").String(); got != "visible answer" {
t.Fatalf("visible text = %q, want visible answer. Output: %s", got, output)
}
}
@@ -213,7 +213,7 @@ func TestConvertGeminiRequestToAntigravity_ClaudeModelStripsUnneededFunctionCall
output, _ := ConvertGeminiRequestToAntigravity("claude-opus-4-6-thinking", inputJSON, false)
- part := gjson.GetBytes(output, "request.contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0")
if !part.Get("functionCall").Exists() {
t.Fatalf("functionCall should be preserved. Output: %s", output)
}
@@ -240,7 +240,7 @@ func TestConvertGeminiRequestToAntigravity_AddSkipSentinelToFunctionCall(t *test
outputStr := string(output)
// Check that skip_thought_signature_validator is added to functionCall
- sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String()
+ sig := ojson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String()
expectedSig := "skip_thought_signature_validator"
if sig != expectedSig {
t.Errorf("Expected skip sentinel '%s', got '%s'", expectedSig, sig)
@@ -284,7 +284,7 @@ func TestConvertGeminiRequestToAntigravity_ParallelFunctionCallsOnlyFirstGetsSen
}`)
output, _ := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts))
}
@@ -338,8 +338,8 @@ func TestFixCLIToolResponse_PreservesFunctionResponseParts(t *testing.T) {
}
// Find the function response content (role=function)
- contents := gjson.GetBytes(result, "request.contents").Array()
- var funcContent gjson.Result
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
+ var funcContent ojson.Result
for _, c := range contents {
if c.Get("role").String() == "function" {
funcContent = c
@@ -401,8 +401,8 @@ func TestFixCLIToolResponse_BackfillsEmptyFunctionResponseName(t *testing.T) {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
- contents := gjson.GetBytes(result, "request.contents").Array()
- var funcContent gjson.Result
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
+ var funcContent ojson.Result
for _, c := range contents {
if c.Get("role").String() == "function" {
funcContent = c
@@ -448,8 +448,8 @@ func TestFixCLIToolResponse_BackfillsMultipleEmptyNames(t *testing.T) {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
- contents := gjson.GetBytes(result, "request.contents").Array()
- var funcContent gjson.Result
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
+ var funcContent ojson.Result
for _, c := range contents {
if c.Get("role").String() == "function" {
funcContent = c
@@ -502,8 +502,8 @@ func TestFixCLIToolResponse_PreservesExistingName(t *testing.T) {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
- contents := gjson.GetBytes(result, "request.contents").Array()
- var funcContent gjson.Result
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
+ var funcContent ojson.Result
for _, c := range contents {
if c.Get("role").String() == "function" {
funcContent = c
@@ -548,8 +548,8 @@ func TestFixCLIToolResponse_MoreResponsesThanCalls(t *testing.T) {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
- contents := gjson.GetBytes(result, "request.contents").Array()
- var funcContent gjson.Result
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
+ var funcContent ojson.Result
for _, c := range contents {
if c.Get("role").String() == "function" {
funcContent = c
@@ -567,6 +567,66 @@ func TestFixCLIToolResponse_MoreResponsesThanCalls(t *testing.T) {
}
}
+func TestFixCLIToolResponse_MoreResponsesThanCallsDoesNotCorruptNextTurn(t *testing.T) {
+ input := `{
+ "model": "gemini-3.8-flash",
+ "request": {
+ "contents": [
+ {
+ "role": "user",
+ "parts": [{"text": "Inspect the project structure"}]
+ },
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call_210083", "name": "glob", "args": {"pattern": "src/**"}}},
+ {"functionCall": {"id": "call_210092", "name": "grep_search", "args": {"query": "header"}}},
+ {"functionCall": {"id": "call_210101", "name": "web_fetch", "args": {"url": "https://example.com"}}}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [
+ {"functionResponse": {"id": "call_210083", "name": "glob", "response": {"output": "a"}}},
+ {"functionResponse": {"id": "call_210092", "name": "grep_search", "response": {"output": "b"}}},
+ {"functionResponse": {"id": "call_210101", "name": "web_fetch", "response": {"output": "c"}}},
+ {"functionResponse": {"id": "call_203720", "name": "glob", "response": {"output": "orphan"}}}
+ ]
+ },
+ {
+ "role": "model",
+ "parts": [
+ {"functionCall": {"id": "call_153280", "name": "glob", "args": {"pattern": "src/components/**"}}},
+ {"functionCall": {"id": "call_153283", "name": "glob", "args": {"pattern": "src/i18n/**"}}}
+ ]
+ },
+ {
+ "role": "user",
+ "parts": [
+ {"functionResponse": {"id": "call_153280", "name": "glob", "response": {"output": "d"}}},
+ {"functionResponse": {"id": "call_153283", "name": "glob", "response": {"output": "e"}}}
+ ]
+ }
+ ]
+ }
+ }`
+
+ result, err := fixCLIToolResponse([]byte(input))
+ if err != nil {
+ t.Fatalf("fixCLIToolResponse failed: %v", err)
+ }
+
+ if errValidate := signature.ValidateGeminiFunctionCallPairing(result); errValidate != nil {
+ t.Fatalf("expected valid function call pairing after fixCLIToolResponse, got error: %v\noutput: %s", errValidate, result)
+ }
+
+ gotID0 := ojson.GetBytesNoCopy(result, "request.contents.4.parts.0.functionResponse.id").String()
+ gotID1 := ojson.GetBytesNoCopy(result, "request.contents.4.parts.1.functionResponse.id").String()
+ if gotID0 != "call_153280" || gotID1 != "call_153283" {
+ t.Fatalf("contents[4] response IDs = [%q, %q], want [call_153280, call_153283]", gotID0, gotID1)
+ }
+}
+
func TestFixCLIToolResponse_MultipleGroupsFIFO(t *testing.T) {
// Two sequential function call groups should be matched FIFO.
input := `{
@@ -606,8 +666,8 @@ func TestFixCLIToolResponse_MultipleGroupsFIFO(t *testing.T) {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
- contents := gjson.GetBytes(result, "request.contents").Array()
- var funcContents []gjson.Result
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
+ var funcContents []ojson.Result
for _, c := range contents {
if c.Get("role").String() == "function" {
funcContents = append(funcContents, c)
@@ -650,11 +710,11 @@ func TestConvertGeminiRequestToAntigravityDeduplicatesRequestWideAndDisambiguate
}`)
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false)
- if got := len(gjson.GetBytes(out, "request.tools").Array()); got != 2 {
+ if got := len(ojson.GetBytesNoCopy(out, "request.tools").Array()); got != 2 {
t.Fatalf("tool count = %d, want 2 after removing the empty duplicate node. Output: %s", got, out)
}
- camel := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
- snake := gjson.GetBytes(out, "request.tools.1.function_declarations").Array()
+ camel := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations").Array()
+ snake := ojson.GetBytesNoCopy(out, "request.tools.1.function_declarations").Array()
if len(camel)+len(snake) != 3 {
t.Fatalf("declaration count = %d, want 3. Output: %s", len(camel)+len(snake), out)
}
@@ -669,13 +729,13 @@ func TestConvertGeminiRequestToAntigravityDeduplicatesRequestWideAndDisambiguate
if !camel[0].Get("parametersJsonSchema").Exists() || !snake[0].Get("parametersJsonSchema").Exists() {
t.Fatalf("parameters were not normalized. Output: %s", out)
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
}
}
@@ -691,7 +751,7 @@ func TestConvertGeminiRequestToAntigravityMapsSnakeCaseFunctionReferences(t *tes
}`)
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false)
- mapped := gjson.GetBytes(out, "request.tools.0.function_declarations.1.name").String()
+ mapped := ojson.GetBytesNoCopy(out, "request.tools.0.function_declarations.1.name").String()
if mapped == "" {
t.Fatalf("mapped declaration name is empty. Output: %s", out)
}
@@ -700,7 +760,7 @@ func TestConvertGeminiRequestToAntigravityMapsSnakeCaseFunctionReferences(t *tes
"request.contents.1.parts.0.function_response.name",
"request.tool_config.function_calling_config.allowed_function_names.0",
} {
- if got := gjson.GetBytes(out, path).String(); got != mapped {
+ if got := ojson.GetBytesNoCopy(out, path).String(); got != mapped {
t.Fatalf("%s = %q, want %q. Output: %s", path, got, mapped, out)
}
}
@@ -738,8 +798,8 @@ func TestSanitizeAntigravityClaudeGeminiRequestSignatures_PreservesNumberPrecisi
output := SanitizeAntigravityClaudeGeminiRequestSignatures("claude-sonnet-4-6", inputJSON)
outputStr := string(output)
- bigVal := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall.args.big").Raw
- nVal := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall.args.n").Raw
+ bigVal := ojson.Get(outputStr, "request.contents.0.parts.0.functionCall.args.big").Raw
+ nVal := ojson.Get(outputStr, "request.contents.0.parts.0.functionCall.args.n").Raw
if bigVal != "9007199254740993" {
t.Errorf("Precision lost for big: got %s, want 9007199254740993", bigVal)
@@ -774,7 +834,7 @@ func TestSanitizeAntigravityClaudeGeminiRequestSignatures_StripsFunctionCallSign
output := SanitizeAntigravityClaudeGeminiRequestSignatures("claude-sonnet-4-6", inputJSON)
outputStr := string(output)
- sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature")
+ sig := ojson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature")
if sig.Exists() {
t.Fatalf("expected functionCall thoughtSignature to be stripped for Claude target model, got %s", sig.Raw)
}
@@ -812,7 +872,7 @@ func TestSanitizeAntigravityClaudeGeminiRequestSignatures_StrictTypeChecks(t *te
output := SanitizeAntigravityClaudeGeminiRequestSignatures("claude-sonnet-4-6", inputJSON)
outputStr := string(output)
- parts := gjson.Get(outputStr, "request.contents.0.parts").Array()
+ parts := ojson.Get(outputStr, "request.contents.0.parts").Array()
for i, part := range parts {
if sig := part.Get("thoughtSignature"); sig.Exists() {
t.Fatalf("part %d should not retain thoughtSignature, got %s", i, sig.Raw)
@@ -883,11 +943,11 @@ func TestSanitizeAntigravityClaudeGeminiRequestSignatures_StripsDuplicateSignatu
outputStr := string(output)
for i := 0; i < 8; i++ {
- sig := gjson.Get(outputStr, fmt.Sprintf("request.contents.0.parts.%d.thoughtSignature", i))
+ sig := ojson.Get(outputStr, fmt.Sprintf("request.contents.0.parts.%d.thoughtSignature", i))
if sig.Exists() {
t.Fatalf("part %d: expected all duplicate thoughtSignature fields to be stripped, got %s", i, sig.Raw)
}
- fcSig := gjson.Get(outputStr, fmt.Sprintf("request.contents.0.parts.%d.functionCall.thoughtSignature", i))
+ fcSig := ojson.Get(outputStr, fmt.Sprintf("request.contents.0.parts.%d.functionCall.thoughtSignature", i))
if fcSig.Exists() {
t.Fatalf("part %d: expected functionCall thoughtSignature to be stripped, got %s", i, fcSig.Raw)
}
@@ -949,7 +1009,7 @@ func TestSanitizeAntigravityClaudeGeminiRequestSignatures_LargeNumberDoesNotHalt
output := SanitizeAntigravityClaudeGeminiRequestSignatures("claude-sonnet-4-6", inputJSON)
outputStr := string(output)
- fcSig := gjson.Get(outputStr, "request.contents.0.parts.0.functionCall.thoughtSignature")
+ fcSig := ojson.Get(outputStr, "request.contents.0.parts.0.functionCall.thoughtSignature")
if fcSig.Exists() {
t.Fatalf("expected hidden thoughtSignature to be stripped despite large number, got %s", fcSig.Raw)
}
@@ -1051,12 +1111,12 @@ func TestFixCLIToolResponse_AttachesSiblingInlineDataToNearestFunctionResponse(t
if err != nil {
t.Fatalf("fixCLIToolResponse failed: %v", err)
}
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("contents = %d, want 2. Output: %s", len(contents), result)
}
funcParts := contents[1].Get("parts").Array()
- gotByID := map[string][]gjson.Result{}
+ gotByID := map[string][]ojson.Result{}
for _, part := range funcParts {
fr := part.Get("functionResponse")
gotByID[fr.Get("id").String()] = fr.Get("parts").Array()
@@ -1109,7 +1169,7 @@ func TestConvertGeminiRequestToAntigravity_PreservesSiblingToolImageOnUserRole(t
]
}`)
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", input, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents = %d, want 3. Output: %s", len(contents), out)
}
@@ -1150,7 +1210,7 @@ func TestNormalizeRoles_InvalidRoleWithoutFunctionResponseAlternates(t *testing.
]
}`)
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("expected 2 contents, got %d", len(contents))
}
@@ -1169,7 +1229,7 @@ func TestNormalizeRoles_InvalidRoleWithFunctionResponseNormalizesToUser(t *testi
}`)
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false)
// Role functionResponse should NEVER be normalized to model
- for _, content := range gjson.GetBytes(out, "request.contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(out, "request.contents").Array() {
if content.Get("parts.0.functionResponse").Exists() {
if got := content.Get("role").String(); got != "user" && got != "function" {
t.Fatalf("functionResponse role should be user or function, got %q", got)
@@ -1217,17 +1277,17 @@ func TestConvertGeminiRequestToAntigravity_TranslatesResponseJsonSchemaToRespons
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", []byte(tt.inputJSON), false)
- schema := gjson.GetBytes(out, "request.generationConfig.responseSchema")
+ schema := ojson.GetBytesNoCopy(out, "request.generationConfig.responseSchema")
if !schema.Exists() {
t.Fatalf("request.generationConfig.responseSchema missing. Output: %s", out)
}
if got := schema.Get("properties.message.type").String(); got != "STRING" {
t.Fatalf("responseSchema.properties.message.type = %q, want STRING. Output: %s", got, out)
}
- if gjson.GetBytes(out, "request.generationConfig.responseJsonSchema").Exists() {
+ if ojson.GetBytesNoCopy(out, "request.generationConfig.responseJsonSchema").Exists() {
t.Fatalf("request.generationConfig.responseJsonSchema should have been removed. Output: %s", out)
}
- if gjson.GetBytes(out, "request.generationConfig.response_json_schema").Exists() {
+ if ojson.GetBytesNoCopy(out, "request.generationConfig.response_json_schema").Exists() {
t.Fatalf("request.generationConfig.response_json_schema should have been removed. Output: %s", out)
}
})
@@ -1252,7 +1312,7 @@ func TestConvertGeminiRequestToAntigravity_PreservesExistingResponseSchema(t *te
}`)
out, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false)
- schema := gjson.GetBytes(out, "request.generationConfig.responseSchema")
+ schema := ojson.GetBytesNoCopy(out, "request.generationConfig.responseSchema")
if !schema.Exists() {
t.Fatalf("request.generationConfig.responseSchema missing. Output: %s", out)
}
@@ -1262,7 +1322,143 @@ func TestConvertGeminiRequestToAntigravity_PreservesExistingResponseSchema(t *te
if schema.Get("properties.stale").Exists() {
t.Fatalf("stale properties survived. Output: %s", out)
}
- if gjson.GetBytes(out, "request.generationConfig.responseJsonSchema").Exists() {
+ if ojson.GetBytesNoCopy(out, "request.generationConfig.responseJsonSchema").Exists() {
t.Fatalf("request.generationConfig.responseJsonSchema should have been removed. Output: %s", out)
}
}
+
+func TestConvertGeminiRequestToAntigravity_RemovesEmptySystemInstructionParts(t *testing.T) {
+ tests := []struct {
+ name string
+ inputJSON string
+ wantParts []string
+ wantRole string
+ }{
+ {
+ name: "camelCase systemInstruction with single empty part removes systemInstruction",
+ inputJSON: `{
+ "contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
+ "systemInstruction": {"parts": [{}]},
+ "generationConfig": {"temperature": 1}
+ }`,
+ },
+ {
+ name: "snake_case system_instruction with single empty part removes systemInstruction",
+ inputJSON: `{
+ "contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
+ "system_instruction": {"parts": [{}]},
+ "generationConfig": {"temperature": 1}
+ }`,
+ },
+ {
+ name: "mixed empty and non-empty parts preserves valid parts and role",
+ inputJSON: `{
+ "contents": [{"role": "user", "parts": [{"text": "Hello"}]}],
+ "systemInstruction": {
+ "role": "user",
+ "parts": [{}, {"text": "Be concise"}, {}]
+ }
+ }`,
+ wantParts: []string{"Be concise"},
+ wantRole: "user",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, err := ConvertGeminiRequestToAntigravity("gemini-3.8-flash", []byte(tt.inputJSON), true)
+ if err != nil {
+ t.Fatalf("ConvertGeminiRequestToAntigravity returned error: %v", err)
+ }
+ if ojson.GetBytesNoCopy(out, "request.system_instruction").Exists() {
+ t.Fatalf("request.system_instruction should not exist. Output: %s", out)
+ }
+ sys := ojson.GetBytesNoCopy(out, "request.systemInstruction")
+ if len(tt.wantParts) == 0 {
+ if sys.Exists() {
+ t.Fatalf("request.systemInstruction should be removed when all parts are empty, got: %s", sys.Raw)
+ }
+ return
+ }
+ if !sys.Exists() {
+ t.Fatalf("request.systemInstruction should exist. Output: %s", out)
+ }
+ if gotRole := sys.Get("role").String(); gotRole != tt.wantRole {
+ t.Fatalf("request.systemInstruction.role = %q, want %q. Output: %s", gotRole, tt.wantRole, out)
+ }
+ parts := sys.Get("parts").Array()
+ if len(parts) != len(tt.wantParts) {
+ t.Fatalf("len(request.systemInstruction.parts) = %d, want %d. Output: %s", len(parts), len(tt.wantParts), out)
+ }
+ for i, wantText := range tt.wantParts {
+ if gotText := parts[i].Get("text").String(); gotText != wantText {
+ t.Fatalf("request.systemInstruction.parts[%d].text = %q, want %q. Output: %s", i, gotText, wantText, out)
+ }
+ }
+ })
+ }
+}
+
+func TestConvertGeminiRequestToAntigravity_NormalizesParametersNumericConstraints_Issue7149(t *testing.T) {
+ inputJSON := []byte(`{
+ "contents": [{"role": "user", "parts": [{"text": "hello"}]}],
+ "tools": [{
+ "functionDeclarations": [{
+ "name": "run_command",
+ "parameters": {
+ "type": "OBJECT",
+ "minProperties": "1",
+ "maxProperties": "5",
+ "properties": {
+ "Message": {
+ "type": "STRING",
+ "minLength": "1",
+ "maxLength": "100",
+ "enum": ["1", "2"],
+ "default": "1"
+ },
+ "Items": {
+ "type": "ARRAY",
+ "minItems": "1",
+ "maxItems": "3",
+ "items": {"type": "STRING", "minLength": "2"}
+ }
+ }
+ }
+ }]
+ }]
+ }`)
+
+ out, err := ConvertGeminiRequestToAntigravity("gemini-3-flash", inputJSON, false)
+ if err != nil {
+ t.Fatalf("ConvertGeminiRequestToAntigravity returned error: %v", err)
+ }
+
+ schema := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations.0.parametersJsonSchema")
+ if !schema.Exists() {
+ t.Fatalf("expected parametersJsonSchema to exist, got: %s", out)
+ }
+ for _, tc := range []struct {
+ path string
+ want int64
+ }{
+ {"minProperties", 1},
+ {"maxProperties", 5},
+ {"properties.Message.minLength", 1},
+ {"properties.Message.maxLength", 100},
+ {"properties.Items.minItems", 1},
+ {"properties.Items.maxItems", 3},
+ {"properties.Items.items.minLength", 2},
+ } {
+ got := schema.Get(tc.path)
+ if got.Type != ojson.Number || got.Int() != tc.want {
+ t.Errorf("parametersJsonSchema.%s = %s (type %v), want number %d; schema=%s", tc.path, got.Raw, got.Type, tc.want, schema.Raw)
+ }
+ }
+ if got := schema.Get("properties.Message.enum.0"); got.Type != ojson.String || got.String() != "1" {
+ t.Errorf("enum.0 = %s (type %v), want string \"1\"", got.Raw, got.Type)
+ }
+ if got := schema.Get("properties.Message.default"); got.Type != ojson.String || got.String() != "1" {
+ t.Errorf("default = %s (type %v), want string \"1\"", got.Raw, got.Type)
+ }
+}
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response.go b/internal/translator/antigravity/gemini/antigravity_gemini_response.go
index c0f1550a6d1..656c91b6bfb 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_response.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_response.go
@@ -9,11 +9,11 @@ import (
"bytes"
"context"
"fmt"
+ "strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type convertAntigravityGeminiParams struct {
@@ -32,7 +32,7 @@ func (p *convertAntigravityGeminiParams) observe(rawJSON []byte) {
}
if !p.SawFinishReason {
for _, path := range []string{"response.candidates", "candidates"} {
- for _, candidate := range gjson.GetBytes(rawJSON, path).Array() {
+ for _, candidate := range ojson.GetBytesNoCopy(rawJSON, path).Array() {
if candidate.Get("finishReason").String() != "" {
p.SawFinishReason = true
break
@@ -52,7 +52,7 @@ func (p *convertAntigravityGeminiParams) observe(rawJSON []byte) {
// Keep the latest usage snapshot. FilterSSEUsageMetadata renames non-terminal
// usage to cpaUsageMetadata, so both spellings must be tracked.
for _, path := range []string{"response.usageMetadata", "response.cpaUsageMetadata", "usageMetadata", "cpaUsageMetadata"} {
- if usage := gjson.GetBytes(rawJSON, path); usage.Exists() {
+ if usage := ojson.GetBytesNoCopy(rawJSON, path); usage.Exists() {
p.UsageMetadata = append(p.UsageMetadata[:0], usage.Raw...)
break
}
@@ -64,17 +64,17 @@ func (p *convertAntigravityGeminiParams) observe(rawJSON []byte) {
// known usage metadata. Fields are set in upstream key order
// (candidates, usageMetadata, modelVersion, responseId).
func (p *convertAntigravityGeminiParams) syntheticTerminalChunk() []byte {
- chunk := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}]}`)
+ chunkDoc := ojson.NewDoc([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}]}`))
if len(p.UsageMetadata) > 0 {
- chunk, _ = sjson.SetRawBytes(chunk, "usageMetadata", p.UsageMetadata)
+ _ = chunkDoc.SetRawBytes("usageMetadata", p.UsageMetadata)
}
if p.ModelVersion != "" {
- chunk, _ = sjson.SetBytes(chunk, "modelVersion", p.ModelVersion)
+ _ = chunkDoc.Set("modelVersion", p.ModelVersion)
}
if p.ResponseID != "" {
- chunk, _ = sjson.SetBytes(chunk, "responseId", p.ResponseID)
+ _ = chunkDoc.Set("responseId", p.ResponseID)
}
- return chunk
+ return chunkDoc.Bytes()
}
// hasAntigravityResponsePayload reports whether a chunk carries actual generated
@@ -84,7 +84,7 @@ func (p *convertAntigravityGeminiParams) syntheticTerminalChunk() []byte {
// finalize a stream that produced nothing.
func hasAntigravityResponsePayload(rawJSON []byte) bool {
for _, path := range []string{"response.candidates", "candidates"} {
- if candidates := gjson.GetBytes(rawJSON, path); candidates.IsArray() && len(candidates.Array()) > 0 {
+ if candidates := ojson.GetBytesNoCopy(rawJSON, path); candidates.IsArray() && len(candidates.Array()) > 0 {
return true
}
}
@@ -94,7 +94,7 @@ func hasAntigravityResponsePayload(rawJSON []byte) bool {
"response.usageMetadata", "response.cpaUsageMetadata",
"usageMetadata", "cpaUsageMetadata",
} {
- if usage := gjson.GetBytes(rawJSON, path); usage.IsObject() && len(usage.Map()) > 0 {
+ if usage := ojson.GetBytesNoCopy(rawJSON, path); usage.IsObject() && len(usage.Map()) > 0 {
return true
}
}
@@ -103,8 +103,8 @@ func hasAntigravityResponsePayload(rawJSON []byte) bool {
func firstStringValue(rawJSON []byte, paths ...string) string {
for _, path := range paths {
- if value := gjson.GetBytes(rawJSON, path).String(); value != "" {
- return value
+ if value := ojson.GetBytesNoCopy(rawJSON, path).String(); value != "" {
+ return strings.Clone(value)
}
}
return ""
@@ -152,7 +152,7 @@ func ConvertAntigravityResponseToGemini(ctx context.Context, _ string, originalR
state.SawFinishReason = true
finalChunk := state.syntheticTerminalChunk()
if alt, ok := ctx.Value("alt").(string); ok && alt != "" {
- finalChunk, _ = sjson.SetRawBytes([]byte("[]"), "-1", finalChunk)
+ finalChunk, _ = ojson.SetRawBytes([]byte("[]"), "-1", finalChunk)
}
return [][]byte{finalChunk}
}
@@ -175,18 +175,18 @@ func ConvertAntigravityResponseToGemini(ctx context.Context, _ string, originalR
chunk = restoreGeminiFunctionNames(chunk, originalRequestRawJSON)
}
} else {
- chunkTemplate := []byte("[]")
- responseResult := gjson.ParseBytes(rawJSON)
+ chunkDoc := ojson.NewDoc([]byte("[]"))
+ responseResult := util.ParseGJSONBytesNoCopy(rawJSON)
if responseResult.IsArray() {
responseResultItems := responseResult.Array()
for i := 0; i < len(responseResultItems); i++ {
responseResultItem := responseResultItems[i]
if responseResultItem.Get("response").Exists() {
- chunkTemplate, _ = sjson.SetRawBytes(chunkTemplate, "-1", []byte(responseResultItem.Get("response").Raw))
+ _ = chunkDoc.SetRaw("-1", responseResultItem.Get("response").Raw)
}
}
}
- chunk = chunkTemplate
+ chunk = chunkDoc.Bytes()
}
return [][]byte{chunk}
}
@@ -225,14 +225,21 @@ func ConvertAntigravityResponseToGeminiNonStream(_ context.Context, _ string, or
if candidates.IsArray() {
// Default every candidate, not just the first one, so a multi-candidate
// response cannot leave some of them unterminated.
+ var chunkDoc *ojson.Doc
idx := 0
- candidates.ForEach(func(_, candidate gjson.Result) bool {
+ candidates.ForEach(func(_, candidate ojson.Result) bool {
if candidate.Get("finishReason").String() == "" {
- chunk, _ = sjson.SetBytes(chunk, fmt.Sprintf("candidates.%d.finishReason", idx), "STOP")
+ if chunkDoc == nil {
+ chunkDoc = ojson.NewDoc(chunk)
+ }
+ _ = chunkDoc.Set(fmt.Sprintf("candidates.%d.finishReason", idx), "STOP")
}
idx++
return true
})
+ if chunkDoc != nil {
+ chunk = chunkDoc.Bytes()
+ }
}
return chunk
}
@@ -242,7 +249,8 @@ func restoreGeminiFunctionNames(chunk, originalRequestRawJSON []byte) []byte {
if len(nameMap) == 0 {
return chunk
}
- candidates := gjson.GetBytes(chunk, "candidates")
+ var chunkDoc *ojson.Doc
+ candidates := ojson.GetBytesNoCopy(chunk, "candidates")
for candidateIndex, candidate := range candidates.Array() {
for partIndex, part := range candidate.Get("content.parts").Array() {
for _, field := range []string{"functionCall", "functionResponse", "function_call", "function_response"} {
@@ -252,14 +260,20 @@ func restoreGeminiFunctionNames(chunk, originalRequestRawJSON []byte) []byte {
continue
}
restoredName := util.RestoreSanitizedToolName(nameMap, name)
- if nameResult.Type == gjson.String && restoredName == name {
+ if nameResult.Type == ojson.String && restoredName == name {
continue
}
+ if chunkDoc == nil {
+ chunkDoc = ojson.NewDoc(chunk)
+ }
path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field)
- chunk, _ = sjson.SetBytes(chunk, path, restoredName)
+ _ = chunkDoc.Set(path, restoredName)
}
}
}
+ if chunkDoc != nil {
+ return chunkDoc.Bytes()
+ }
return chunk
}
@@ -290,8 +304,10 @@ func restoreUsageMetadata(chunk []byte) []byte {
return out
}
}
- chunk, _ = sjson.SetRawBytes(chunk, "usageMetadata", []byte(cpaUsage.Raw))
- chunk, _ = sjson.DeleteBytes(chunk, "cpaUsageMetadata")
+ chunkDoc := ojson.NewDoc(chunk)
+ _ = chunkDoc.SetRaw("usageMetadata", cpaUsage.Raw)
+ _ = chunkDoc.Delete("cpaUsageMetadata")
+ chunk = chunkDoc.Bytes()
}
return chunk
}
diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go
index be40fc778b1..dab89af8362 100644
--- a/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go
+++ b/internal/translator/antigravity/gemini/antigravity_gemini_response_test.go
@@ -5,8 +5,8 @@ import (
"fmt"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
func TestRestoreUsageMetadata(t *testing.T) {
@@ -78,7 +78,7 @@ func TestConvertAntigravityResponseToGeminiNonStreamRestoresDisambiguatedName(t
raw := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
out := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", original, nil, raw, nil)
- if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != second {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.name").String(); got != second {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, second, out)
}
}
@@ -93,7 +93,7 @@ func TestConvertAntigravityResponseToGeminiStream_SynthesizesFinishReasonOnDoneW
if len(results1) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(results1))
}
- if fr := gjson.GetBytes(results1[0], "candidates.0.finishReason").String(); fr != "" {
+ if fr := ojson.GetBytesNoCopy(results1[0], "candidates.0.finishReason").String(); fr != "" {
t.Fatalf("chunk1 should not have finishReason yet, got %q", fr)
}
@@ -103,28 +103,28 @@ func TestConvertAntigravityResponseToGeminiStream_SynthesizesFinishReasonOnDoneW
t.Fatalf("expected synthetic terminal chunk on [DONE], got %d results", len(doneResults))
}
synthetic := doneResults[0]
- if fr := gjson.GetBytes(synthetic, "candidates.0.finishReason").String(); fr != "STOP" {
+ if fr := ojson.GetBytesNoCopy(synthetic, "candidates.0.finishReason").String(); fr != "STOP" {
t.Fatalf("synthetic chunk finishReason = %q, want STOP", fr)
}
// Upstream terminal chunks always carry a model-role candidate with an empty
// text part, so the synthetic chunk mirrors that shape for client compatibility.
- if role := gjson.GetBytes(synthetic, "candidates.0.content.role").String(); role != "model" {
+ if role := ojson.GetBytesNoCopy(synthetic, "candidates.0.content.role").String(); role != "model" {
t.Fatalf("synthetic chunk content role = %q, want model", role)
}
- if parts := gjson.GetBytes(synthetic, "candidates.0.content.parts"); parts.String() != `[{"text":""}]` {
+ if parts := ojson.GetBytesNoCopy(synthetic, "candidates.0.content.parts"); parts.String() != `[{"text":""}]` {
t.Fatalf("synthetic chunk parts = %s, want [{\"text\":\"\"}]", parts.Raw)
}
// The last real chunk carries usage, so the synthetic terminal chunk must keep it.
- if got := gjson.GetBytes(synthetic, "usageMetadata.promptTokenCount").Int(); got != 100 {
+ if got := ojson.GetBytesNoCopy(synthetic, "usageMetadata.promptTokenCount").Int(); got != 100 {
t.Fatalf("synthetic chunk promptTokenCount = %d, want 100", got)
}
- if got := gjson.GetBytes(synthetic, "usageMetadata.totalTokenCount").Int(); got != 150 {
+ if got := ojson.GetBytesNoCopy(synthetic, "usageMetadata.totalTokenCount").Int(); got != 150 {
t.Fatalf("synthetic chunk totalTokenCount = %d, want 150", got)
}
- if mv := gjson.GetBytes(synthetic, "modelVersion").String(); mv != "gemini-3.7-flash" {
+ if mv := ojson.GetBytesNoCopy(synthetic, "modelVersion").String(); mv != "gemini-3.7-flash" {
t.Fatalf("synthetic chunk modelVersion = %q, want gemini-3.7-flash", mv)
}
- if rid := gjson.GetBytes(synthetic, "responseId").String(); rid != "resp-123" {
+ if rid := ojson.GetBytesNoCopy(synthetic, "responseId").String(); rid != "resp-123" {
t.Fatalf("synthetic chunk responseId = %q, want resp-123", rid)
}
@@ -145,7 +145,7 @@ func TestConvertAntigravityResponseToGeminiStream_DoesNotDuplicateFinishReasonWh
if len(results1) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(results1))
}
- if fr := gjson.GetBytes(results1[0], "candidates.0.finishReason").String(); fr != "STOP" {
+ if fr := ojson.GetBytesNoCopy(results1[0], "candidates.0.finishReason").String(); fr != "STOP" {
t.Fatalf("chunk1 finishReason = %q, want STOP", fr)
}
@@ -169,7 +169,7 @@ func TestConvertAntigravityResponseToGeminiStream_CarriesFilteredUsageIntoSynthe
if len(results) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(results))
}
- if got := gjson.GetBytes(results[0], "usageMetadata.totalTokenCount").Int(); got != 49 {
+ if got := ojson.GetBytesNoCopy(results[0], "usageMetadata.totalTokenCount").Int(); got != 49 {
t.Fatalf("restored chunk totalTokenCount = %d, want 49", got)
}
@@ -177,13 +177,13 @@ func TestConvertAntigravityResponseToGeminiStream_CarriesFilteredUsageIntoSynthe
if len(doneResults) != 1 {
t.Fatalf("expected synthetic terminal chunk, got %d results", len(doneResults))
}
- if fr := gjson.GetBytes(doneResults[0], "candidates.0.finishReason").String(); fr != "STOP" {
+ if fr := ojson.GetBytesNoCopy(doneResults[0], "candidates.0.finishReason").String(); fr != "STOP" {
t.Fatalf("synthetic finishReason = %q, want STOP", fr)
}
- if got := gjson.GetBytes(doneResults[0], "usageMetadata.totalTokenCount").Int(); got != 49 {
+ if got := ojson.GetBytesNoCopy(doneResults[0], "usageMetadata.totalTokenCount").Int(); got != 49 {
t.Fatalf("synthetic totalTokenCount = %d, want 49", got)
}
- if got := gjson.GetBytes(doneResults[0], "usageMetadata.candidatesTokenCount").Int(); got != 7 {
+ if got := ojson.GetBytesNoCopy(doneResults[0], "usageMetadata.candidatesTokenCount").Int(); got != 7 {
t.Fatalf("synthetic candidatesTokenCount = %d, want 7", got)
}
}
@@ -225,7 +225,7 @@ func TestConvertAntigravityResponseToGeminiNonStream_DefaultsEveryCandidate(t *t
out := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", nil, nil, input, nil)
want := []string{"STOP", "MAX_TOKENS", "STOP"}
for i, expected := range want {
- got := gjson.GetBytes(out, fmt.Sprintf("candidates.%d.finishReason", i)).String()
+ got := ojson.GetBytesNoCopy(out, fmt.Sprintf("candidates.%d.finishReason", i)).String()
if got != expected {
t.Fatalf("candidates.%d.finishReason = %q, want %q", i, got, expected)
}
@@ -235,7 +235,7 @@ func TestConvertAntigravityResponseToGeminiNonStream_DefaultsEveryCandidate(t *t
func TestConvertAntigravityResponseToGeminiNonStream_DefaultsMissingFinishReason(t *testing.T) {
input := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"}}]}}`)
out := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", nil, nil, input, nil)
- if fr := gjson.GetBytes(out, "candidates.0.finishReason").String(); fr != "STOP" {
+ if fr := ojson.GetBytesNoCopy(out, "candidates.0.finishReason").String(); fr != "STOP" {
t.Fatalf("ConvertAntigravityResponseToGeminiNonStream finishReason = %q, want STOP", fr)
}
}
@@ -246,7 +246,7 @@ func TestAntigravityGeminiResponseAllowsWhitespace(t *testing.T) {
t.Run(fmt.Sprintf("separator=%q", separator), func(t *testing.T) {
raw := []byte(`{"response":` + separator + body + `}`)
out := ConvertAntigravityResponseToGeminiNonStream(context.Background(), "", nil, nil, raw, nil)
- if !gjson.ValidBytes(out) || string(out) != body {
+ if !ojson.ValidBytes(out) || string(out) != body {
t.Fatalf("incorrect response slice: %s", out)
}
})
diff --git a/internal/translator/antigravity/gemini/noop_optimization_test.go b/internal/translator/antigravity/gemini/noop_optimization_test.go
index c2d9ea9f894..9fc8acff0b5 100644
--- a/internal/translator/antigravity/gemini/noop_optimization_test.go
+++ b/internal/translator/antigravity/gemini/noop_optimization_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestRewriteGeminiFunctionNamesReusesNormalizedPayload(t *testing.T) {
@@ -33,20 +33,30 @@ func TestRemoveEmptyGeminiFunctionToolsDeletesEmptyArray(t *testing.T) {
output := removeEmptyGeminiFunctionTools(input)
- if gjson.GetBytes(output, "request.tools").Exists() {
+ if ojson.GetBytesNoCopy(output, "request.tools").Exists() {
t.Fatalf("empty tools should be removed: %s", output)
}
}
+func TestRemoveEmptyGeminiSystemInstructionPartsReusesNormalizedPayload(t *testing.T) {
+ input := []byte(`{"request":{"systemInstruction":{"parts":[{"text":"be concise"}]}}}`)
+
+ output := removeEmptyGeminiSystemInstructionParts(input)
+
+ if &output[0] != &input[0] {
+ t.Fatal("non-empty systemInstruction parts caused a payload copy")
+ }
+}
+
func TestRewriteGeminiFunctionNamesNormalizesNonStringNames(t *testing.T) {
input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":true,"args":{}}}]}],"toolConfig":{"functionCallingConfig":{"allowedFunctionNames":[true]}}}}`)
output := rewriteGeminiFunctionNames(input, nil)
- if name := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.name"); name.Type != gjson.String || name.String() != "true" {
+ if name := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.functionCall.name"); name.Type != ojson.String || name.String() != "true" {
t.Fatalf("functionCall.name = %s, want string true", name.Raw)
}
- if name := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != gjson.String || name.String() != "true" {
+ if name := ojson.GetBytesNoCopy(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != ojson.String || name.String() != "true" {
t.Fatalf("allowedFunctionNames.0 = %s, want string true", name.Raw)
}
}
@@ -73,7 +83,7 @@ func TestFixCLIToolResponsePreservesObjectNormalization(t *testing.T) {
if errFix != nil {
t.Fatalf("fixCLIToolResponse returned an error: %v", errFix)
}
- if !gjson.GetBytes(output, "request.contents").IsArray() {
+ if !ojson.GetBytesNoCopy(output, "request.contents").IsArray() {
t.Fatalf("contents should be normalized to an array: %s", output)
}
}
@@ -93,19 +103,19 @@ func TestConvertGeminiRequestToAntigravityBoundsLargePayloadCopies(t *testing.T)
output, _ := ConvertGeminiRequestToAntigravity("gemini-3-flash", input, false)
runtime.ReadMemStats(&after)
- if got := gjson.GetBytes(output, "request.contents.0.parts.0.inlineData.data").String(); len(got) != inlineDataSize {
+ if got := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.inlineData.data").String(); len(got) != inlineDataSize {
t.Fatalf("inline data length = %d, want %d", len(got), inlineDataSize)
}
- if got := gjson.GetBytes(output, "model").String(); got != "gemini-3-flash" {
+ if got := ojson.GetBytesNoCopy(output, "model").String(); got != "gemini-3-flash" {
t.Fatalf("model = %q, want gemini-3-flash", got)
}
- if got := gjson.GetBytes(output, "request.safetySettings"); !got.IsArray() {
+ if got := ojson.GetBytesNoCopy(output, "request.safetySettings"); !got.IsArray() {
t.Fatalf("request.safetySettings = %s, want array", got.Raw)
}
// Wrapping the request in the Antigravity envelope and setting the model each
// allocate one payload-sized buffer; everything beyond that is a regression.
- const allowedCopies = 3
+ const allowedCopies = 2
if allocated := after.TotalAlloc - before.TotalAlloc; allocated > allowedCopies*inlineDataSize {
t.Fatalf("conversion allocated %d bytes for a %d byte payload, want at most %d",
allocated, inlineDataSize, allowedCopies*inlineDataSize)
diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go
index beeb0b63327..0ed2c24106a 100644
--- a/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go
+++ b/internal/translator/antigravity/interactions/interactions_antigravity_file_data_test.go
@@ -3,14 +3,14 @@ package interactions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertInteractionsRequestToAntigravityNormalizesOpenAIFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.5-flash", input, false)
- inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_request.go b/internal/translator/antigravity/interactions/interactions_antigravity_request.go
index a1f1049e335..ab9c40c2ce0 100644
--- a/internal/translator/antigravity/interactions/interactions_antigravity_request.go
+++ b/internal/translator/antigravity/interactions/interactions_antigravity_request.go
@@ -5,10 +5,9 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -19,32 +18,39 @@ func ConvertInteractionsRequestToAntigravity(modelName string, inputRawJSON []by
// convertInteractionsRequestToAntigravity also reports a user turn that was left
// empty because its only attachment has no Antigravity equivalent.
func convertInteractionsRequestToAntigravity(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- root := gjson.ParseBytes(inputRawJSON)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
functionNameMap := util.SanitizedFunctionNameMap(inputRawJSON)
- out := []byte(`{"project":"","request":{"contents":[]},"model":""}`)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ outDoc := ojson.NewDoc([]byte(`{"project":"","request":{"contents":[]},"model":""}`))
+ _ = outDoc.Set("model", modelName)
if stream || root.Get("stream").Bool() {
- out, _ = sjson.SetBytes(out, "request.stream", true)
+ _ = outDoc.Set("request.stream", true)
}
- out = copyInteractionsSystemToAntigravity(out, root)
- out = copyInteractionsGenerationConfigToAntigravity(out, root)
+ copyInteractionsSystemToAntigravityDoc(outDoc, root)
+ copyInteractionsGenerationConfigToAntigravityDoc(outDoc, root)
contentItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int())
errInput := appendInteractionsInputToAntigravity(&contentItems, root.Get("input"))
- out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems)
- out = copyInteractionsToolsToAntigravity(out, root, functionNameMap)
- if gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String() == "NONE" {
- out, _ = sjson.DeleteBytes(out, "request.tools")
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "request.contents", contentItems)
+ copyInteractionsToolsToAntigravityDoc(outDoc, root, functionNameMap)
+ if outDoc.Get("request.toolConfig.functionCallingConfig.mode").String() == "NONE" {
+ _ = outDoc.Delete("request.tools")
}
- out = rewriteInteractionsFunctionNames(out, functionNameMap)
- out = attachDefaultAntigravitySafetySettings(out)
+ attachDefaultAntigravitySafetySettingsDoc(outDoc)
+ out := rewriteInteractionsFunctionNames(outDoc.Bytes(), functionNameMap)
return out, errInput
}
func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]string) []byte {
- contents := gjson.GetBytes(out, "request.contents")
+ var outDoc *ojson.Doc
+ ensureOutDoc := func() *ojson.Doc {
+ if outDoc == nil {
+ outDoc = ojson.NewDoc(out)
+ }
+ return outDoc
+ }
+ contents := ojson.GetBytesNoCopy(out, "request.contents")
canBatchContents := contents.IsArray()
if canBatchContents {
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
if parts.Exists() && !parts.IsArray() {
canBatchContents = false
@@ -56,12 +62,12 @@ func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]str
if canBatchContents {
contentsChanged := false
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
contentJSON := []byte(content.Raw)
partsChanged := false
partItems := make([][]byte, 0, 4)
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
- partJSON := []byte(part.Raw)
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
+ var partDoc *ojson.Doc
for _, field := range []string{"functionCall", "functionResponse"} {
nameResult := part.Get(field + ".name")
name := nameResult.String()
@@ -69,24 +75,31 @@ func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]str
continue
}
mappedName := util.MapSanitizedFunctionName(functionNameMap, name)
- if nameResult.Type == gjson.String && mappedName == name {
+ if nameResult.Type == ojson.String && mappedName == name {
continue
}
- partJSON, _ = sjson.SetBytes(partJSON, field+".name", mappedName)
+ if partDoc == nil {
+ partDoc = ojson.NewDocString(part.Raw)
+ }
+ _ = partDoc.Set(field+".name", mappedName)
partsChanged = true
}
- partItems = append(partItems, partJSON)
+ if partDoc != nil {
+ partItems = append(partItems, partDoc.Bytes())
+ } else {
+ partItems = append(partItems, []byte(part.Raw))
+ }
return true
})
if partsChanged {
- contentJSON, _ = sjson.SetRawBytes(contentJSON, "parts", translatorcommon.JoinRawArray(partItems))
+ contentJSON, _ = ojson.SetRawBytes(contentJSON, "parts", translatorcommon.JoinRawArray(partItems))
contentsChanged = true
}
contentItems = append(contentItems, contentJSON)
return true
})
if contentsChanged {
- out, _ = sjson.SetRawBytes(out, "request.contents", translatorcommon.JoinRawArray(contentItems))
+ _ = ensureOutDoc().SetRawBytes("request.contents", translatorcommon.JoinRawArray(contentItems))
}
} else {
for contentIndex, content := range contents.Array() {
@@ -98,107 +111,105 @@ func rewriteInteractionsFunctionNames(out []byte, functionNameMap map[string]str
continue
}
mappedName := util.MapSanitizedFunctionName(functionNameMap, name)
- if nameResult.Type == gjson.String && mappedName == name {
+ if nameResult.Type == ojson.String && mappedName == name {
continue
}
path := fmt.Sprintf("request.contents.%d.parts.%d.%s.name", contentIndex, partIndex, field)
- out, _ = sjson.SetBytes(out, path, mappedName)
+ _ = ensureOutDoc().Set(path, mappedName)
}
}
}
}
allowedPath := "request.toolConfig.functionCallingConfig.allowedFunctionNames"
- allowedNames := gjson.GetBytes(out, allowedPath)
+ allowedNames := ojson.GetBytesNoCopy(out, allowedPath)
if allowedNames.IsArray() {
namesChanged := false
nameItems := make([][]byte, 0, 4)
- allowedNames.ForEach(func(_, name gjson.Result) bool {
+ allowedNames.ForEach(func(_, name ojson.Result) bool {
mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String())
- namesChanged = namesChanged || name.Type != gjson.String || mappedName != name.String()
+ namesChanged = namesChanged || name.Type != ojson.String || mappedName != name.String()
mappedNameJSON, _ := json.Marshal(mappedName)
nameItems = append(nameItems, mappedNameJSON)
return true
})
if namesChanged {
- out, _ = sjson.SetRawBytes(out, allowedPath, translatorcommon.JoinRawArray(nameItems))
+ _ = ensureOutDoc().SetRawBytes(allowedPath, translatorcommon.JoinRawArray(nameItems))
}
} else {
for index, name := range allowedNames.Array() {
mappedName := util.MapSanitizedFunctionName(functionNameMap, name.String())
- if name.Type == gjson.String && mappedName == name.String() {
+ if name.Type == ojson.String && mappedName == name.String() {
continue
}
path := fmt.Sprintf("%s.%d", allowedPath, index)
- out, _ = sjson.SetBytes(out, path, mappedName)
+ _ = ensureOutDoc().Set(path, mappedName)
}
}
+ if outDoc != nil {
+ return outDoc.Bytes()
+ }
return out
}
-func copyInteractionsSystemToAntigravity(out []byte, root gjson.Result) []byte {
+func copyInteractionsSystemToAntigravityDoc(outDoc *ojson.Doc, root ojson.Result) {
sys := root.Get("system_instruction")
if !sys.Exists() {
- return out
+ return
}
- if sys.Type == gjson.String {
- instr := []byte(`{"parts":[{"text":""}]}`)
- instr, _ = sjson.SetBytes(instr, "parts.0.text", sys.String())
- out, _ = sjson.SetRawBytes(out, "request.systemInstruction", instr)
- return out
+ if sys.Type == ojson.String {
+ instr, _ := ojson.SetBytes([]byte(`{"parts":[{"text":""}]}`), "parts.0.text", sys.String())
+ _ = outDoc.SetRawBytes("request.systemInstruction", instr)
+ return
}
if text := sys.Get("text"); text.Exists() && !sys.Get("parts").Exists() {
- instr := []byte(`{"parts":[{"text":""}]}`)
- instr, _ = sjson.SetBytes(instr, "parts.0.text", text.String())
- out, _ = sjson.SetRawBytes(out, "request.systemInstruction", instr)
- return out
+ instr, _ := ojson.SetBytes([]byte(`{"parts":[{"text":""}]}`), "parts.0.text", text.String())
+ _ = outDoc.SetRawBytes("request.systemInstruction", instr)
+ return
}
- out, _ = sjson.SetRawBytes(out, "request.systemInstruction", []byte(sys.Raw))
- return out
+ _ = outDoc.SetRaw("request.systemInstruction", sys.Raw)
}
-func copyInteractionsGenerationConfigToAntigravity(out []byte, root gjson.Result) []byte {
+func copyInteractionsGenerationConfigToAntigravityDoc(outDoc *ojson.Doc, root ojson.Result) {
if cfg := root.Get("generation_config"); cfg.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig", convertSnakeCaseKeysToCamelCaseForAntigravity([]byte(cfg.Raw)))
+ _ = outDoc.SetRawBytes("request.generationConfig", convertSnakeCaseKeysToCamelCaseForAntigravity([]byte(cfg.Raw)))
} else if cfg := root.Get("generationConfig"); cfg.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(cfg.Raw))
+ _ = outDoc.SetRaw("request.generationConfig", cfg.Raw)
}
- out = normalizeInteractionsGenerationConfigForAntigravity(out)
- out = copyInteractionsReasoningToAntigravity(out, root)
- out = copyInteractionsResponseModalitiesToAntigravity(out, root)
- out = copyInteractionsToolChoiceToAntigravity(out, root)
- return out
+ normalizeInteractionsGenerationConfigForAntigravityDoc(outDoc)
+ copyInteractionsReasoningToAntigravityDoc(outDoc, root)
+ copyInteractionsResponseModalitiesToAntigravityDoc(outDoc, root)
+ copyInteractionsToolChoiceToAntigravityDoc(outDoc, root)
}
-func normalizeInteractionsGenerationConfigForAntigravity(out []byte) []byte {
- if thinkingLevel := gjson.GetBytes(out, "request.generationConfig.thinkingLevel"); thinkingLevel.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw))
- out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingLevel")
+func normalizeInteractionsGenerationConfigForAntigravityDoc(outDoc *ojson.Doc) {
+ if thinkingLevel := outDoc.Get("request.generationConfig.thinkingLevel"); thinkingLevel.Exists() {
+ _ = outDoc.SetRaw("request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel.Raw)
+ _ = outDoc.Delete("request.generationConfig.thinkingLevel")
}
- if thinkingBudget := gjson.GetBytes(out, "request.generationConfig.thinkingBudget"); thinkingBudget.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw))
- out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingBudget")
+ if thinkingBudget := outDoc.Get("request.generationConfig.thinkingBudget"); thinkingBudget.Exists() {
+ _ = outDoc.SetRaw("request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget.Raw)
+ _ = outDoc.Delete("request.generationConfig.thinkingBudget")
}
- if includeThoughts := gjson.GetBytes(out, "request.generationConfig.includeThoughts"); includeThoughts.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", []byte(includeThoughts.Raw))
- out, _ = sjson.DeleteBytes(out, "request.generationConfig.includeThoughts")
+ if includeThoughts := outDoc.Get("request.generationConfig.includeThoughts"); includeThoughts.Exists() {
+ _ = outDoc.SetRaw("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts.Raw)
+ _ = outDoc.Delete("request.generationConfig.includeThoughts")
}
- if summaries := gjson.GetBytes(out, "request.generationConfig.thinkingSummaries"); summaries.Exists() {
+ if summaries := outDoc.Get("request.generationConfig.thinkingSummaries"); summaries.Exists() {
if includeThoughts, ok := antigravityThinkingSummariesIncludeThoughts(summaries); ok {
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
- out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingSummaries")
+ _ = outDoc.Delete("request.generationConfig.thinkingSummaries")
}
- if toolChoice := gjson.GetBytes(out, "request.generationConfig.toolChoice"); toolChoice.Exists() {
- out, _ = sjson.DeleteBytes(out, "request.generationConfig.toolChoice")
+ if toolChoice := outDoc.Get("request.generationConfig.toolChoice"); toolChoice.Exists() {
+ _ = outDoc.Delete("request.generationConfig.toolChoice")
}
- return out
}
-func copyInteractionsReasoningToAntigravity(out []byte, root gjson.Result) []byte {
+func copyInteractionsReasoningToAntigravityDoc(outDoc *ojson.Doc, root ojson.Result) {
reasoning := root.Get("reasoning")
if !reasoning.Exists() {
- return out
+ return
}
effort := strings.ToLower(strings.TrimSpace(reasoning.Get("effort").String()))
if effort == "" {
@@ -209,29 +220,28 @@ func copyInteractionsReasoningToAntigravity(out []byte, root gjson.Result) []byt
// compatibility alias controls only the amount; includeThoughts is written
// below only for an explicit Interactions summary selector.
if effort == "auto" {
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.thinkingBudget", -1)
} else {
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel", effort)
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.thinkingLevel", effort)
}
}
if summary := reasoning.Get("summary"); summary.Exists() {
if includeThoughts, ok := antigravityThinkingSummariesIncludeThoughts(summary); ok {
- out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ _ = outDoc.Set("request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
}
- return out
}
-func copyInteractionsResponseModalitiesToAntigravity(out []byte, root gjson.Result) []byte {
+func copyInteractionsResponseModalitiesToAntigravityDoc(outDoc *ojson.Doc, root ojson.Result) {
mods := root.Get("response_modalities")
if !mods.Exists() {
mods = root.Get("responseModalities")
}
if !mods.Exists() || !mods.IsArray() {
- return out
+ return
}
var responseMods []string
- mods.ForEach(func(_, mod gjson.Result) bool {
+ mods.ForEach(func(_, mod ojson.Result) bool {
switch strings.ToLower(strings.TrimSpace(mod.String())) {
case "text":
responseMods = append(responseMods, "TEXT")
@@ -243,12 +253,11 @@ func copyInteractionsResponseModalitiesToAntigravity(out []byte, root gjson.Resu
return true
})
if len(responseMods) > 0 {
- out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods)
+ _ = outDoc.Set("request.generationConfig.responseModalities", responseMods)
}
- return out
}
-func copyInteractionsToolChoiceToAntigravity(out []byte, root gjson.Result) []byte {
+func copyInteractionsToolChoiceToAntigravityDoc(outDoc *ojson.Doc, root ojson.Result) {
toolChoice := root.Get("tool_choice")
if !toolChoice.Exists() {
toolChoice = root.Get("generation_config.tool_choice")
@@ -257,11 +266,11 @@ func copyInteractionsToolChoiceToAntigravity(out []byte, root gjson.Result) []by
toolChoice = root.Get("generationConfig.toolChoice")
}
if !toolChoice.Exists() {
- return out
+ return
}
mode := ""
var allowedNames []string
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
mode = "NONE"
@@ -291,13 +300,12 @@ func copyInteractionsToolChoiceToAntigravity(out []byte, root gjson.Result) []by
}
}
if mode == "" {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode)
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.mode", mode)
if len(allowedNames) > 0 {
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
}
- return out
}
type antigravityInteractionsInputContext struct {
@@ -332,21 +340,21 @@ func (ctx *antigravityInteractionsInputContext) appendText(role, text string) {
}
}
-func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) error {
+func appendInteractionsInputToAntigravity(items *[][]byte, input ojson.Result) error {
if !input.Exists() {
return nil
}
ctx := &antigravityInteractionsInputContext{
items: *items,
}
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
appendAntigravityTextContent(&ctx.items, "user", input.String())
ctx.lastStepType = "text"
*items = ctx.items
return nil
}
if input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if !appendInteractionsContentBlockItem(ctx, item) {
appendInteractionsStepToAntigravity(ctx, item, "user")
}
@@ -358,7 +366,7 @@ func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) e
defaultRole = "model"
}
ctx.instruction = translatorcommon.IsInteractionsInstructionStep(input, false)
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
appendInteractionsStepToAntigravity(ctx, step, defaultRole)
return true
})
@@ -375,7 +383,7 @@ func appendInteractionsInputToAntigravity(items *[][]byte, input gjson.Result) e
// appendInteractionsContentBlockItem accepts the shorthand where input is a bare
// list of content blocks instead of steps. Those items have no step envelope and
// would otherwise be dropped, losing images and files sent that way.
-func appendInteractionsContentBlockItem(ctx *antigravityInteractionsInputContext, item gjson.Result) bool {
+func appendInteractionsContentBlockItem(ctx *antigravityInteractionsInputContext, item ojson.Result) bool {
switch strings.ToLower(strings.TrimSpace(item.Get("type").String())) {
case "text", "image", "audio", "video", "document":
default:
@@ -399,11 +407,11 @@ func appendInteractionsContentBlockItem(ctx *antigravityInteractionsInputContext
return true
}
-func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContext, step gjson.Result, defaultRole string) {
+func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContext, step ojson.Result, defaultRole string) {
inheritedInstruction := ctx.instruction
ctx.instruction = translatorcommon.IsInteractionsInstructionStep(step, inheritedInstruction)
defer func() { ctx.instruction = inheritedInstruction }()
- if step.Type == gjson.String {
+ if step.Type == ojson.String {
if ctx.inModelTurn {
flushPendingAntigravitySignature(ctx)
ctx.inModelTurn = false
@@ -419,7 +427,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
} else if itemRole == "user" {
role = "user"
}
- steps.ForEach(func(_, child gjson.Result) bool {
+ steps.ForEach(func(_, child ojson.Result) bool {
appendInteractionsStepToAntigravity(ctx, child, role)
return true
})
@@ -430,9 +438,9 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
ctx.run.End()
if ctx.pendingSignature != "" {
carrier := antigravityTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
ctx.pendingSignature = ""
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, antigravityContent("model", [][]byte{carrier}))
@@ -440,7 +448,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
}
partItems := extractInteractionsStepContentPartsToAntigravity(step, false)
if len(partItems) > 0 {
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentParts(ctx.items[len(ctx.items)-1], partItems)
} else {
ctx.items = append(ctx.items, antigravityContent("model", partItems))
@@ -453,8 +461,8 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
if sig := firstNonEmptyString(step.Get("signature").String(), step.Get("thought_signature").String(), step.Get("thoughtSignature").String()); sig != "" {
if ctx.pendingSignature != "" && ctx.pendingSignature != sig {
carrier := antigravityTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, antigravityContent("model", [][]byte{carrier}))
@@ -464,7 +472,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
}
partItems := extractInteractionsThoughtPartsToAntigravity(step)
if len(partItems) > 0 {
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentParts(ctx.items[len(ctx.items)-1], partItems)
} else {
ctx.items = append(ctx.items, antigravityContent("model", partItems))
@@ -484,9 +492,9 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
ctx.pendingSignature = ""
} else {
carrier := antigravityTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
ctx.pendingSignature = ""
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, antigravityContent("model", [][]byte{carrier}))
@@ -494,9 +502,9 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
}
}
if sig != "" {
- part, _ = sjson.SetBytes(part, "thoughtSignature", sig)
+ part, _ = ojson.SetBytes(part, "thoughtSignature", sig)
}
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], part)
} else {
ctx.items = append(ctx.items, antigravityContent("model", [][]byte{part}))
@@ -511,7 +519,7 @@ func appendInteractionsStepToAntigravity(ctx *antigravityInteractionsInputContex
part := buildAntigravityFunctionResultPart(step)
// A tool result is content the model reads, so it keeps the surrounding user turn.
ctx.run.Add()
- if ctx.lastStepType == "function_result" && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "user" {
+ if ctx.lastStepType == "function_result" && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "user" {
ctx.items[len(ctx.items)-1] = appendGeminiUserContentPart(ctx.items[len(ctx.items)-1], part)
} else {
ctx.items = append(ctx.items, antigravityContent("user", [][]byte{part}))
@@ -549,16 +557,16 @@ func flushPendingAntigravitySignature(ctx *antigravityInteractionsInputContext)
return
}
carrier := antigravityTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
ctx.pendingSignature = ""
- if len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, antigravityContent("model", [][]byte{carrier}))
}
}
-func extractInteractionsThoughtPartsToAntigravity(step gjson.Result) [][]byte {
+func extractInteractionsThoughtPartsToAntigravity(step ojson.Result) [][]byte {
content := step.Get("content")
if !content.Exists() {
content = step.Get("summary")
@@ -571,7 +579,7 @@ func extractInteractionsThoughtPartsToAntigravity(step gjson.Result) [][]byte {
}
var partItems [][]byte
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if partJSON := appendInteractionsContentToAntigravityPart(nil, part, true); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
@@ -581,13 +589,13 @@ func extractInteractionsThoughtPartsToAntigravity(step gjson.Result) [][]byte {
if partJSON := appendInteractionsContentToAntigravityPart(nil, content, true); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
partItems = append(partItems, antigravityTextPartJSON(content.String(), true))
}
return partItems
}
-func extractInteractionsStepContentPartsToAntigravity(step gjson.Result, thought bool) [][]byte {
+func extractInteractionsStepContentPartsToAntigravity(step ojson.Result, thought bool) [][]byte {
content := step.Get("content")
if !content.Exists() {
content = step.Get("text")
@@ -597,7 +605,7 @@ func extractInteractionsStepContentPartsToAntigravity(step gjson.Result, thought
}
var partItems [][]byte
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if partJSON := appendInteractionsContentToAntigravityPart(nil, part, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
@@ -607,20 +615,20 @@ func extractInteractionsStepContentPartsToAntigravity(step gjson.Result, thought
if partJSON := appendInteractionsContentToAntigravityPart(nil, content, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
partItems = append(partItems, antigravityTextPartJSON(content.String(), thought))
}
return partItems
}
func appendGeminiContentPart(content []byte, part []byte) []byte {
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
rawParts := make([][]byte, 0, len(parts)+1)
for _, p := range parts {
rawParts = append(rawParts, []byte(p.Raw))
}
rawParts = append(rawParts, part)
- updated, err := sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
+ updated, err := ojson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
if err != nil {
return content
}
@@ -631,13 +639,13 @@ func appendGeminiContentParts(content []byte, newParts [][]byte) []byte {
if len(newParts) == 0 {
return content
}
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
rawParts := make([][]byte, 0, len(parts)+len(newParts))
for _, p := range parts {
rawParts = append(rawParts, []byte(p.Raw))
}
rawParts = append(rawParts, newParts...)
- updated, err := sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
+ updated, err := ojson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
if err != nil {
return content
}
@@ -645,14 +653,14 @@ func appendGeminiContentParts(content []byte, newParts [][]byte) []byte {
}
func appendGeminiUserContentPart(content []byte, part []byte) []byte {
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
rawParts := make([][]byte, 0, len(parts)+1)
for _, p := range parts {
rawParts = append(rawParts, []byte(p.Raw))
}
rawParts = append(rawParts, part)
rawParts = translatorcommon.ReorderGeminiUserParts(rawParts)
- updated, err := sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
+ updated, err := ojson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
if err != nil {
return content
}
@@ -668,35 +676,35 @@ func firstNonEmptyString(values ...string) string {
return ""
}
-func buildAntigravityFunctionCallPart(step gjson.Result) []byte {
- part := []byte(`{"functionCall":{"name":"","args":{}}}`)
- part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String())
+func buildAntigravityFunctionCallPart(step ojson.Result) []byte {
+ partDoc := ojson.NewDoc([]byte(`{"functionCall":{"name":"","args":{}}}`))
+ _ = partDoc.Set("functionCall.name", step.Get("name").String())
if callID := step.Get("call_id"); callID.Exists() {
- part, _ = sjson.SetBytes(part, "functionCall.id", callID.String())
+ _ = partDoc.Set("functionCall.id", callID.String())
} else if id := step.Get("id"); id.Exists() {
- part, _ = sjson.SetBytes(part, "functionCall.id", id.String())
+ _ = partDoc.Set("functionCall.id", id.String())
}
if args := step.Get("arguments"); args.Exists() {
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(args.Raw))
+ _ = partDoc.SetRaw("functionCall.args", args.Raw)
}
- return part
+ return partDoc.Bytes()
}
-func buildAntigravityFunctionResultPart(step gjson.Result) []byte {
- part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
- part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String())
+func buildAntigravityFunctionResultPart(step ojson.Result) []byte {
+ partDoc := ojson.NewDoc([]byte(`{"functionResponse":{"name":"","response":{}}}`))
+ _ = partDoc.Set("functionResponse.name", step.Get("name").String())
if callID := step.Get("call_id"); callID.Exists() {
- part, _ = sjson.SetBytes(part, "functionResponse.id", callID.String())
+ _ = partDoc.Set("functionResponse.id", callID.String())
} else if id := step.Get("id"); id.Exists() {
- part, _ = sjson.SetBytes(part, "functionResponse.id", id.String())
+ _ = partDoc.Set("functionResponse.id", id.String())
}
if result := step.Get("result"); result.Exists() {
- part = translatorcommon.SetGeminiFunctionResponseResult(part, "functionResponse.response", result)
+ translatorcommon.SetGeminiFunctionResponseResultDoc(partDoc, "functionResponse.response", result)
}
- return part
+ return partDoc.Bytes()
}
-func appendInteractionsNativeContentToAntigravity(ctx *antigravityInteractionsInputContext, step gjson.Result, defaultRole string) {
+func appendInteractionsNativeContentToAntigravity(ctx *antigravityInteractionsInputContext, step ojson.Result, defaultRole string) {
parts := step.Get("parts")
if !parts.Exists() || !parts.IsArray() {
return
@@ -704,7 +712,7 @@ func appendInteractionsNativeContentToAntigravity(ctx *antigravityInteractionsIn
role := antigravityContentRole(step.Get("role").String(), defaultRole)
run := ctx.userRun(role)
partItems := make([][]byte, 0, 4)
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
partJSON := interactionsNativeAntigravityPart(part)
if len(partJSON) == 0 {
if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" {
@@ -723,14 +731,14 @@ func appendInteractionsNativeContentToAntigravity(ctx *antigravityInteractionsIn
}
}
-func appendInteractionsStepContentToAntigravity(items *[][]byte, role string, step gjson.Result, thought bool) {
+func appendInteractionsStepContentToAntigravity(items *[][]byte, role string, step ojson.Result, thought bool) {
content := step.Get("content")
if !content.Exists() {
return
}
partItems := make([][]byte, 0, 4)
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if partJSON := appendInteractionsContentToAntigravityPart(nil, part, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
@@ -740,7 +748,7 @@ func appendInteractionsStepContentToAntigravity(items *[][]byte, role string, st
if partJSON := appendInteractionsContentToAntigravityPart(nil, content, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
partItems = append(partItems, antigravityTextPartJSON(content.String(), thought))
}
if len(partItems) > 0 {
@@ -748,13 +756,13 @@ func appendInteractionsStepContentToAntigravity(items *[][]byte, role string, st
}
}
-func appendInteractionsContentListToAntigravity(ctx *antigravityInteractionsInputContext, role string, content gjson.Result) {
+func appendInteractionsContentListToAntigravity(ctx *antigravityInteractionsInputContext, role string, content ojson.Result) {
if !content.Exists() {
return
}
run := ctx.userRun(role)
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
appendInteractionsContentPartToAntigravity(&ctx.items, role, part, run)
return true
})
@@ -762,14 +770,14 @@ func appendInteractionsContentListToAntigravity(ctx *antigravityInteractionsInpu
}
if content.IsObject() {
appendInteractionsContentPartToAntigravity(&ctx.items, role, content, run)
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
ctx.appendText(role, content.String())
}
}
// appendInteractionsContentPartToAntigravity adds one content part. run is nil for
// model content; for user content it records what is sent and what cannot be.
-func appendInteractionsContentPartToAntigravity(items *[][]byte, role string, part gjson.Result, run *translatorcommon.UserRun) {
+func appendInteractionsContentPartToAntigravity(items *[][]byte, role string, part ojson.Result, run *translatorcommon.UserRun) {
partJSON := appendInteractionsContentToAntigravityPart(nil, part, false)
if len(partJSON) == 0 {
if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" {
@@ -783,7 +791,7 @@ func appendInteractionsContentPartToAntigravity(items *[][]byte, role string, pa
}
}
-func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result, thought bool) []byte {
+func appendInteractionsContentToAntigravityPart(_ []byte, content ojson.Result, thought bool) []byte {
if text := content.Get("text"); text.Exists() {
return antigravityTextPartJSON(text.String(), thought)
}
@@ -805,7 +813,7 @@ func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result,
mimeType = content.Get("mimeType").String()
}
if data := content.Get("data").String(); data != "" {
- return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return antigravityInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
// uri is the Interactions spelling of file_uri.
@@ -814,7 +822,7 @@ func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result,
if mimeType == "" {
mimeType = content.Get("mimeType").String()
}
- return antigravityFileDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI)))
+ return antigravityFileDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI)))
}
if url := content.Get("url"); url.Exists() {
return antigravityInlineDataPartFromDataURL(url.String())
@@ -823,45 +831,45 @@ func appendInteractionsContentToAntigravityPart(_ []byte, content gjson.Result,
return antigravityInlineDataPartFromDataURL(content.Get("image_url.url").String())
case "input_audio":
mimeType := antigravityInputAudioMimeType(content.Get("input_audio.format").String())
- return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, content.Get("input_audio.data").String())))
+ return antigravityInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, content.Get("input_audio.data").String())))
case "file":
filename := content.Get("file.filename").String()
fileData := content.Get("file.file_data").String()
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
- return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return antigravityInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
return nil
}
-func appendInteractionsFunctionCallToAntigravity(items *[][]byte, step gjson.Result) {
+func appendInteractionsFunctionCallToAntigravity(items *[][]byte, step ojson.Result) {
part := buildAntigravityFunctionCallPart(step)
*items = append(*items, antigravityContent("model", [][]byte{part}))
}
-func appendInteractionsFunctionResultToAntigravity(items *[][]byte, step gjson.Result) {
+func appendInteractionsFunctionResultToAntigravity(items *[][]byte, step ojson.Result) {
part := buildAntigravityFunctionResultPart(step)
*items = append(*items, antigravityContent("user", [][]byte{part}))
}
-func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result, functionNameMap map[string]string) []byte {
- if gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String() == "NONE" {
- out, _ = sjson.DeleteBytes(out, "request.tools")
- return out
+func copyInteractionsToolsToAntigravityDoc(outDoc *ojson.Doc, root ojson.Result, functionNameMap map[string]string) {
+ if outDoc.Get("request.toolConfig.functionCallingConfig.mode").String() == "NONE" {
+ _ = outDoc.Delete("request.tools")
+ return
}
tools := root.Get("tools")
if !tools.Exists() {
- return out
+ return
}
if !tools.IsArray() {
- out, _ = sjson.SetRawBytes(out, "request.tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("request.tools", tools.Raw)
+ return
}
var functionDeclarations [][]byte
var otherTools [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if decls := tool.Get("functionDeclarations"); decls.Exists() && decls.IsArray() {
- decls.ForEach(func(_, decl gjson.Result) bool {
+ decls.ForEach(func(_, decl ojson.Result) bool {
if converted := antigravityFunctionDeclarationJSON(decl, functionNameMap); len(converted) > 0 {
functionDeclarations = append(functionDeclarations, converted)
}
@@ -870,7 +878,7 @@ func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result, functionN
return true
}
if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() {
- decls.ForEach(func(_, decl gjson.Result) bool {
+ decls.ForEach(func(_, decl ojson.Result) bool {
if converted := antigravityFunctionDeclarationJSON(decl, functionNameMap); len(converted) > 0 {
functionDeclarations = append(functionDeclarations, converted)
}
@@ -889,51 +897,53 @@ func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result, functionN
case "url_context":
node := []byte(`{"urlContext":{}}`)
if uc := tool.Get("url_context"); uc.Exists() && uc.IsObject() {
- node, _ = sjson.SetRawBytes(node, "urlContext", []byte(uc.Raw))
+ node, _ = ojson.SetRawBytes(node, "urlContext", []byte(uc.Raw))
} else if uc := tool.Get("urlContext"); uc.Exists() && uc.IsObject() {
- node, _ = sjson.SetRawBytes(node, "urlContext", []byte(uc.Raw))
+ node, _ = ojson.SetRawBytes(node, "urlContext", []byte(uc.Raw))
}
otherTools = append(otherTools, node)
return true
case "code_execution":
node := []byte(`{"codeExecution":{}}`)
if ce := tool.Get("code_execution"); ce.Exists() && ce.IsObject() {
- node, _ = sjson.SetRawBytes(node, "codeExecution", []byte(ce.Raw))
+ node, _ = ojson.SetRawBytes(node, "codeExecution", []byte(ce.Raw))
} else if ce := tool.Get("codeExecution"); ce.Exists() && ce.IsObject() {
- node, _ = sjson.SetRawBytes(node, "codeExecution", []byte(ce.Raw))
+ node, _ = ojson.SetRawBytes(node, "codeExecution", []byte(ce.Raw))
}
otherTools = append(otherTools, node)
return true
case "google_search", "web_search":
node := []byte(`{"googleSearch":{}}`)
if gs := tool.Get("google_search"); gs.Exists() && gs.IsObject() {
- node, _ = sjson.SetRawBytes(node, "googleSearch", []byte(gs.Raw))
+ node, _ = ojson.SetRawBytes(node, "googleSearch", []byte(gs.Raw))
} else if gs := tool.Get("googleSearch"); gs.Exists() && gs.IsObject() {
- node, _ = sjson.SetRawBytes(node, "googleSearch", []byte(gs.Raw))
+ node, _ = ojson.SetRawBytes(node, "googleSearch", []byte(gs.Raw))
}
otherTools = append(otherTools, node)
return true
}
- rawBytes := []byte(tool.Raw)
if toolType == "" {
+ rawDoc := ojson.NewDocString(tool.Raw)
if uc := tool.Get("url_context"); uc.Exists() {
- rawBytes, _ = sjson.SetRawBytes(rawBytes, "urlContext", []byte(uc.Raw))
- rawBytes, _ = sjson.DeleteBytes(rawBytes, "url_context")
+ _ = rawDoc.SetRaw("urlContext", uc.Raw)
+ _ = rawDoc.Delete("url_context")
}
if ce := tool.Get("code_execution"); ce.Exists() {
- rawBytes, _ = sjson.SetRawBytes(rawBytes, "codeExecution", []byte(ce.Raw))
- rawBytes, _ = sjson.DeleteBytes(rawBytes, "code_execution")
+ _ = rawDoc.SetRaw("codeExecution", ce.Raw)
+ _ = rawDoc.Delete("code_execution")
}
if gs := tool.Get("google_search"); gs.Exists() {
- rawBytes, _ = sjson.SetRawBytes(rawBytes, "googleSearch", []byte(gs.Raw))
- rawBytes, _ = sjson.DeleteBytes(rawBytes, "google_search")
+ _ = rawDoc.SetRaw("googleSearch", gs.Raw)
+ _ = rawDoc.Delete("google_search")
}
if ws := tool.Get("web_search"); ws.Exists() {
- rawBytes, _ = sjson.SetRawBytes(rawBytes, "googleSearch", []byte(ws.Raw))
- rawBytes, _ = sjson.DeleteBytes(rawBytes, "web_search")
+ _ = rawDoc.SetRaw("googleSearch", ws.Raw)
+ _ = rawDoc.Delete("web_search")
}
+ otherTools = append(otherTools, rawDoc.Bytes())
+ return true
}
- otherTools = append(otherTools, rawBytes)
+ otherTools = append(otherTools, []byte(tool.Raw))
return true
})
deduplicated := util.DeduplicateFunctionDeclarations(translatorcommon.JoinRawArray(functionDeclarations))
@@ -942,16 +952,15 @@ func copyInteractionsToolsToAntigravity(out []byte, root gjson.Result, functionN
toolItems := make([][]byte, 0, 1+len(otherTools))
if hasFunction {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
- functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
+ functionToolNode, _ = ojson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
toolItems = append(toolItems, functionToolNode)
}
toolItems = append(toolItems, otherTools...)
- out, _ = sjson.SetRawBytes(out, "request.tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("request.tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func antigravityFunctionDeclarationJSON(decl gjson.Result, functionNameMap map[string]string) []byte {
+func antigravityFunctionDeclarationJSON(decl ojson.Result, functionNameMap map[string]string) []byte {
fn := decl
if nested := decl.Get("function"); nested.Exists() && nested.IsObject() {
fn = nested
@@ -960,26 +969,26 @@ func antigravityFunctionDeclarationJSON(decl gjson.Result, functionNameMap map[s
if strings.TrimSpace(name) == "" {
return nil
}
- out := []byte(`{"name":"","parametersJsonSchema":{"type":"object","properties":{}}}`)
- out, _ = sjson.SetBytes(out, "name", util.MapSanitizedFunctionName(functionNameMap, name))
+ outDoc := ojson.NewDoc([]byte(`{"name":"","parametersJsonSchema":{"type":"object","properties":{}}}`))
+ _ = outDoc.Set("name", util.MapSanitizedFunctionName(functionNameMap, name))
if desc := fn.Get("description"); desc.Exists() {
- out, _ = sjson.SetBytes(out, "description", desc.String())
+ _ = outDoc.Set("description", desc.String())
}
if params := fn.Get("parametersJsonSchema"); params.Exists() {
- out, _ = sjson.SetRawBytes(out, "parametersJsonSchema", []byte(params.Raw))
+ _ = outDoc.SetRaw("parametersJsonSchema", params.Raw)
} else if params := fn.Get("parameters"); params.Exists() {
- out, _ = sjson.SetRawBytes(out, "parametersJsonSchema", []byte(params.Raw))
+ _ = outDoc.SetRaw("parametersJsonSchema", params.Raw)
}
if response := fn.Get("response"); response.Exists() {
- out, _ = sjson.SetRawBytes(out, "response", []byte(response.Raw))
+ _ = outDoc.SetRaw("response", response.Raw)
}
if responseSchema := fn.Get("responseJsonSchema"); responseSchema.Exists() {
- out, _ = sjson.SetRawBytes(out, "responseJsonSchema", []byte(responseSchema.Raw))
+ _ = outDoc.SetRaw("responseJsonSchema", responseSchema.Raw)
}
- return out
+ return outDoc.Bytes()
}
-func interactionsNativeAntigravityPart(part gjson.Result) []byte {
+func interactionsNativeAntigravityPart(part ojson.Result) []byte {
switch {
case part.Get("text").Exists(), part.Get("functionCall").Exists(), part.Get("functionResponse").Exists():
return []byte(part.Raw)
@@ -996,15 +1005,15 @@ func interactionsNativeAntigravityPart(part gjson.Result) []byte {
}
func antigravityTextPartJSON(text string, thought bool) []byte {
- partJSON := []byte(`{"text":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", text)
+ partDoc := ojson.NewDoc([]byte(`{"text":""}`))
+ _ = partDoc.Set("text", text)
if thought {
- partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
+ _ = partDoc.Set("thought", true)
}
- return partJSON
+ return partDoc.Bytes()
}
-func antigravityInlineDataPartJSON(inline gjson.Result) []byte {
+func antigravityInlineDataPartJSON(inline ojson.Result) []byte {
mimeType := inline.Get("mimeType").String()
if mimeType == "" {
mimeType = inline.Get("mime_type").String()
@@ -1013,13 +1022,13 @@ func antigravityInlineDataPartJSON(inline gjson.Result) []byte {
if mimeType == "" || data == "" {
return nil
}
- partJSON := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
- partJSON, _ = sjson.SetBytes(partJSON, "inlineData.mimeType", mimeType)
- partJSON, _ = sjson.SetBytes(partJSON, "inlineData.data", data)
- return partJSON
+ partDoc := ojson.NewDoc([]byte(`{"inlineData":{"mimeType":"","data":""}}`))
+ _ = partDoc.Set("inlineData.mimeType", mimeType)
+ _ = partDoc.Set("inlineData.data", data)
+ return partDoc.Bytes()
}
-func antigravityFileDataPartJSON(fileData gjson.Result) []byte {
+func antigravityFileDataPartJSON(fileData ojson.Result) []byte {
mimeType := fileData.Get("mimeType").String()
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
@@ -1031,10 +1040,10 @@ func antigravityFileDataPartJSON(fileData gjson.Result) []byte {
if mimeType == "" || fileURI == "" {
return nil
}
- partJSON := []byte(`{"fileData":{"mimeType":"","fileUri":""}}`)
- partJSON, _ = sjson.SetBytes(partJSON, "fileData.mimeType", mimeType)
- partJSON, _ = sjson.SetBytes(partJSON, "fileData.fileUri", fileURI)
- return partJSON
+ partDoc := ojson.NewDoc([]byte(`{"fileData":{"mimeType":"","fileUri":""}}`))
+ _ = partDoc.Set("fileData.mimeType", mimeType)
+ _ = partDoc.Set("fileData.fileUri", fileURI)
+ return partDoc.Bytes()
}
func antigravityInlineDataPartFromDataURL(dataURL string) []byte {
@@ -1046,7 +1055,7 @@ func antigravityInlineDataPartFromDataURL(dataURL string) []byte {
if len(pieces) != 2 || !strings.HasPrefix(pieces[1], "base64,") {
return nil
}
- return antigravityInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, pieces[0], pieces[1][7:])))
+ return antigravityInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, pieces[0], pieces[1][7:])))
}
func appendAntigravityTextContent(items *[][]byte, role, text string) {
@@ -1055,10 +1064,10 @@ func appendAntigravityTextContent(items *[][]byte, role, text string) {
}
func antigravityContent(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDoc([]byte(`{"role":"","parts":[]}`))
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
func antigravityContentRole(role, defaultRole string) string {
@@ -1091,8 +1100,8 @@ func antigravityInputAudioMimeType(format string) string {
}
}
-func antigravityThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) {
- if summary.Type != gjson.String {
+func antigravityThinkingSummariesIncludeThoughts(summary ojson.Result) (bool, bool) {
+ if summary.Type != ojson.String {
return false, false
}
switch strings.ToLower(strings.TrimSpace(summary.String())) {
@@ -1106,33 +1115,32 @@ func antigravityThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bo
}
func convertSnakeCaseKeysToCamelCaseForAntigravity(raw []byte) []byte {
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
if !root.Exists() {
return raw
}
- out := []byte(`{}`)
- out = copySnakeCaseValueToCamelCaseForAntigravity(out, "", root)
- return out
+ outDoc := ojson.NewDoc([]byte(`{}`))
+ copySnakeCaseValueToCamelCaseForAntigravityDoc(outDoc, "", root)
+ return outDoc.Bytes()
}
-func copySnakeCaseValueToCamelCaseForAntigravity(out []byte, path string, node gjson.Result) []byte {
+func copySnakeCaseValueToCamelCaseForAntigravityDoc(outDoc *ojson.Doc, path string, node ojson.Result) {
if node.IsObject() {
- node.ForEach(func(key, value gjson.Result) bool {
+ node.ForEach(func(key, value ojson.Result) bool {
childPath := joinAntigravityJSONPath(path, toAntigravityCamelCase(key.String()))
- out = copySnakeCaseValueToCamelCaseForAntigravity(out, childPath, value)
+ copySnakeCaseValueToCamelCaseForAntigravityDoc(outDoc, childPath, value)
return true
})
- return out
+ return
}
if node.IsArray() {
- node.ForEach(func(_, value gjson.Result) bool {
- out = copySnakeCaseValueToCamelCaseForAntigravity(out, path+".-1", value)
+ node.ForEach(func(_, value ojson.Result) bool {
+ copySnakeCaseValueToCamelCaseForAntigravityDoc(outDoc, path+".-1", value)
return true
})
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw))
- return out
+ _ = outDoc.SetRaw(path, node.Raw)
}
func joinAntigravityJSONPath(path, key string) string {
@@ -1157,9 +1165,9 @@ func toAntigravityCamelCase(s string) string {
return out
}
-func attachDefaultAntigravitySafetySettings(out []byte) []byte {
- if gjson.GetBytes(out, "request.safetySettings").Exists() {
- return out
+func attachDefaultAntigravitySafetySettingsDoc(outDoc *ojson.Doc) {
+ if outDoc.Get("request.safetySettings").Exists() {
+ return
}
settings := []map[string]string{
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
@@ -1170,8 +1178,7 @@ func attachDefaultAntigravitySafetySettings(out []byte) []byte {
}
raw, errMarshal := json.Marshal(settings)
if errMarshal != nil {
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, "request.safetySettings", raw)
- return out
+ _ = outDoc.SetRawBytes("request.safetySettings", raw)
}
diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_response.go b/internal/translator/antigravity/interactions/interactions_antigravity_response.go
index aefdb64b77e..4b29d54fbbc 100644
--- a/internal/translator/antigravity/interactions/interactions_antigravity_response.go
+++ b/internal/translator/antigravity/interactions/interactions_antigravity_response.go
@@ -7,10 +7,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type antigravityToInteractionsStreamState struct {
@@ -19,7 +18,7 @@ type antigravityToInteractionsStreamState struct {
Completed bool
Done bool
ActiveStepOpen bool
- PendingUsage gjson.Result
+ PendingUsage ojson.Result
HasPendingUsage bool
UsageAfterSeen bool
ID string
@@ -51,7 +50,7 @@ func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName str
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
if !st.Completed {
out = appendAntigravityInteractionsStepStop(out, st)
- completedRoot := gjson.Result{}
+ completedRoot := ojson.Result{}
if st.HasPendingUsage {
completedRoot = st.PendingUsage
}
@@ -60,7 +59,7 @@ func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName str
out = appendAntigravityInteractionsDone(out, st)
continue
}
- root := unwrapAntigravityResponse(gjson.ParseBytes(payload))
+ root := unwrapAntigravityResponse(util.ParseGJSONBytesNoCopy(payload))
root = restoreInteractionsFunctionNames(root, st.ToolNameMap)
if !root.Exists() {
continue
@@ -70,7 +69,7 @@ func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName str
out = appendAntigravityInteractionsStatusUpdate(out, st)
st.Started = true
}
- root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool {
+ root.Get("candidates.0.content.parts").ForEach(func(_, part ojson.Result) bool {
out = appendAntigravityPartToInteractionsStream(out, st, part)
return true
})
@@ -78,7 +77,7 @@ func ConvertAntigravityResponseToInteractions(ctx context.Context, modelName str
hasFinish := finishReason.Exists() && strings.TrimSpace(finishReason.String()) != ""
frameHasUsage := hasAntigravityStreamUsage(root)
if frameHasUsage {
- st.PendingUsage = root
+ st.PendingUsage = ojson.Parse(strings.Clone(root.Raw))
st.HasPendingUsage = true
if st.Finished {
st.UsageAfterSeen = true
@@ -107,17 +106,17 @@ func ConvertAntigravityResponseToInteractionsNonStream(ctx context.Context, mode
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := unwrapAntigravityResponse(gjson.ParseBytes(rawJSON))
+ root := unwrapAntigravityResponse(util.ParseGJSONBytesNoCopy(rawJSON))
root = restoreInteractionsFunctionNames(root, util.DisambiguatedToolNameMap(originalRequestRawJSON))
- out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
+ outDoc := ojson.NewDoc([]byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`))
id := root.Get("responseId").String()
if id == "" {
id = fmt.Sprintf("interaction_%d", time.Now().UnixNano())
}
- out, _ = sjson.SetBytes(out, "id", id)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("id", id)
+ _ = outDoc.Set("model", modelName)
var steps [][]byte
- root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool {
+ root.Get("candidates.0.content.parts").ForEach(func(_, part ojson.Result) bool {
for _, step := range antigravityPartToInteractionsSteps(part) {
if len(step) > 0 {
steps = append(steps, step)
@@ -126,10 +125,10 @@ func ConvertAntigravityResponseToInteractionsNonStream(ctx context.Context, mode
return true
})
if len(steps) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "steps", steps)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "steps", steps)
}
- out = setInteractionsUsageFromAntigravity(out, "usage", root)
- return out
+ setInteractionsUsageFromAntigravityDoc(outDoc, "usage", root)
+ return outDoc.Bytes()
}
func antigravityStreamPayloads(rawJSON []byte) [][]byte {
@@ -142,10 +141,10 @@ func antigravityStreamPayloads(rawJSON []byte) [][]byte {
if bytes.HasPrefix(trimmed, []byte("data:")) {
return [][]byte{bytes.TrimSpace(trimmed[5:])}
}
- root := gjson.ParseBytes(trimmed)
+ root := util.ParseGJSONBytesNoCopy(trimmed)
if root.IsArray() {
payloads := make([][]byte, 0)
- root.ForEach(func(_, item gjson.Result) bool {
+ root.ForEach(func(_, item ojson.Result) bool {
if response := item.Get("response"); response.Exists() {
payloads = append(payloads, []byte(response.Raw))
} else if item.Exists() {
@@ -160,7 +159,7 @@ func antigravityStreamPayloads(rawJSON []byte) [][]byte {
return [][]byte{trimmed}
}
-func unwrapAntigravityResponse(root gjson.Result) gjson.Result {
+func unwrapAntigravityResponse(root ojson.Result) ojson.Result {
if response := root.Get("response"); response.Exists() {
response = restoreAntigravityUsageMetadata(response)
return response
@@ -168,11 +167,11 @@ func unwrapAntigravityResponse(root gjson.Result) gjson.Result {
return restoreAntigravityUsageMetadata(root)
}
-func restoreInteractionsFunctionNames(root gjson.Result, nameMap map[string]string) gjson.Result {
+func restoreInteractionsFunctionNames(root ojson.Result, nameMap map[string]string) ojson.Result {
if !root.Exists() || len(nameMap) == 0 {
return root
}
- raw := []byte(root.Raw)
+ var rawDoc *ojson.Doc
candidates := root.Get("candidates")
for candidateIndex, candidate := range candidates.Array() {
for partIndex, part := range candidate.Get("content.parts").Array() {
@@ -183,52 +182,59 @@ func restoreInteractionsFunctionNames(root gjson.Result, nameMap map[string]stri
continue
}
restoredName := util.RestoreSanitizedToolName(nameMap, name)
- if nameResult.Type == gjson.String && restoredName == name {
+ if nameResult.Type == ojson.String && restoredName == name {
continue
}
+ if rawDoc == nil {
+ rawDoc = ojson.NewDocString(root.Raw)
+ }
path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field)
- raw, _ = sjson.SetBytes(raw, path, restoredName)
+ _ = rawDoc.Set(path, restoredName)
}
}
}
- return gjson.ParseBytes(raw)
+ if rawDoc != nil {
+ return util.ParseGJSONBytesNoCopy(rawDoc.Bytes())
+ }
+ return root
}
-func restoreAntigravityUsageMetadata(root gjson.Result) gjson.Result {
+func restoreAntigravityUsageMetadata(root ojson.Result) ojson.Result {
if !root.Get("usageMetadata").Exists() {
if cpaUsage := root.Get("cpaUsageMetadata"); cpaUsage.Exists() {
- raw, _ := sjson.SetRawBytes([]byte(root.Raw), "usageMetadata", []byte(cpaUsage.Raw))
- raw, _ = sjson.DeleteBytes(raw, "cpaUsageMetadata")
- return gjson.ParseBytes(raw)
+ rawDoc := ojson.NewDocString(root.Raw)
+ _ = rawDoc.SetRaw("usageMetadata", cpaUsage.Raw)
+ _ = rawDoc.Delete("cpaUsageMetadata")
+ return util.ParseGJSONBytesNoCopy(rawDoc.Bytes())
}
}
return root
}
func appendAntigravityInteractionsCreated(out [][]byte, st *antigravityToInteractionsStreamState, modelName string) [][]byte {
- created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
- created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
- created, _ = sjson.SetBytes(created, "interaction.model", modelName)
- return append(out, translatorcommon.SSEEventData("interaction.created", created))
+ createdDoc := ojson.NewDoc([]byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`))
+ _ = createdDoc.Set("interaction.id", st.ID)
+ _ = createdDoc.Set("interaction.model", modelName)
+ return append(out, translatorcommon.SSEEventData("interaction.created", createdDoc.Bytes()))
}
func appendAntigravityInteractionsStatusUpdate(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte {
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
- statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
+ statusUpdate, _ = ojson.SetBytes(statusUpdate, "interaction_id", st.ID)
return append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
}
-func appendAntigravityInteractionsCompleted(out [][]byte, st *antigravityToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
+func appendAntigravityInteractionsCompleted(out [][]byte, st *antigravityToInteractionsStreamState, modelName string, root ojson.Result) [][]byte {
now := time.Now().UTC().Format(time.RFC3339)
- completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
- completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
- completed, _ = sjson.SetBytes(completed, "interaction.created", now)
- completed, _ = sjson.SetBytes(completed, "interaction.updated", now)
- completed, _ = sjson.SetBytes(completed, "interaction.model", modelName)
+ completedDoc := ojson.NewDoc([]byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`))
+ _ = completedDoc.Set("interaction.id", st.ID)
+ _ = completedDoc.Set("interaction.created", now)
+ _ = completedDoc.Set("interaction.updated", now)
+ _ = completedDoc.Set("interaction.model", modelName)
if root.Exists() {
- completed = setInteractionsStreamUsageFromAntigravity(completed, "interaction.usage", root)
+ setInteractionsStreamUsageFromAntigravityDoc(completedDoc, "interaction.usage", root)
}
- out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
+ out = append(out, translatorcommon.SSEEventData("interaction.completed", completedDoc.Bytes()))
st.Completed = true
return out
}
@@ -242,26 +248,26 @@ func appendAntigravityInteractionsDone(out [][]byte, st *antigravityToInteractio
return out
}
-func appendAntigravityInteractionsStepStart(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part gjson.Result) [][]byte {
+func appendAntigravityInteractionsStepStart(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part ojson.Result) [][]byte {
st.StepID = fmt.Sprintf("step_%d", time.Now().UnixNano())
st.ActiveStepIndex = st.StepIndex
st.StepIndex++
- st.ActiveStepType = stepType
+ st.ActiveStepType = strings.Clone(stepType)
st.ActiveStepOpen = true
- stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
- stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex)
- stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType)
+ stepStartDoc := ojson.NewDoc([]byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`))
+ _ = stepStartDoc.Set("index", st.ActiveStepIndex)
+ _ = stepStartDoc.Set("step.type", stepType)
if stepType == "function_call" {
id := antigravityFunctionPartID(part)
if id == "" {
id = st.StepID
}
- stepStart, _ = sjson.SetBytes(stepStart, "step.id", id)
- stepStart, _ = sjson.SetBytes(stepStart, "step.call_id", id)
- stepStart, _ = sjson.SetBytes(stepStart, "step.name", part.Get("name").String())
- stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`))
+ _ = stepStartDoc.Set("step.id", id)
+ _ = stepStartDoc.Set("step.call_id", id)
+ _ = stepStartDoc.Set("step.name", part.Get("name").String())
+ _ = stepStartDoc.SetRaw("step.arguments", `{}`)
}
- return append(out, translatorcommon.SSEEventData("step.start", stepStart))
+ return append(out, translatorcommon.SSEEventData("step.start", stepStartDoc.Bytes()))
}
func appendAntigravityInteractionsStepStop(out [][]byte, st *antigravityToInteractionsStreamState) [][]byte {
@@ -269,14 +275,14 @@ func appendAntigravityInteractionsStepStop(out [][]byte, st *antigravityToIntera
return out
}
stepStop := []byte(`{"index":0,"event_type":"step.stop"}`)
- stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex)
+ stepStop, _ = ojson.SetBytes(stepStop, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", stepStop))
st.ActiveStepOpen = false
st.ActiveStepType = ""
return out
}
-func ensureAntigravityInteractionsStep(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part gjson.Result) [][]byte {
+func ensureAntigravityInteractionsStep(out [][]byte, st *antigravityToInteractionsStreamState, stepType string, part ojson.Result) [][]byte {
if st.ActiveStepOpen && st.ActiveStepType == stepType {
return out
}
@@ -284,45 +290,45 @@ func ensureAntigravityInteractionsStep(out [][]byte, st *antigravityToInteractio
return appendAntigravityInteractionsStepStart(out, st, stepType, part)
}
-func appendAntigravityPartToInteractionsStream(out [][]byte, st *antigravityToInteractionsStreamState, part gjson.Result) [][]byte {
+func appendAntigravityPartToInteractionsStream(out [][]byte, st *antigravityToInteractionsStreamState, part ojson.Result) [][]byte {
if text := part.Get("text"); text.Exists() && text.String() != "" {
if part.Get("thought").Bool() {
- out = ensureAntigravityInteractionsStep(out, st, "thought", gjson.Result{})
- delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.content.text", text.String())
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ out = ensureAntigravityInteractionsStep(out, st, "thought", ojson.Result{})
+ deltaDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`))
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.content.text", text.String())
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendAntigravityThoughtSignature(out, st, part)
}
- out = ensureAntigravityInteractionsStep(out, st, "model_output", gjson.Result{})
- delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.text", text.String())
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ out = ensureAntigravityInteractionsStep(out, st, "model_output", ojson.Result{})
+ deltaDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`))
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.text", text.String())
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendAntigravityThoughtSignature(out, st, part)
}
if fc := part.Get("functionCall"); fc.Exists() {
out = appendAntigravityThoughtSignature(out, st, part)
out = ensureAntigravityInteractionsStep(out, st, "function_call", fc)
- delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
+ deltaDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`))
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
arguments := `{}`
if args := fc.Get("args"); args.Exists() {
arguments = args.Raw
}
- delta, _ = sjson.SetBytes(delta, "delta.arguments", arguments)
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ _ = deltaDoc.Set("delta.arguments", arguments)
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendAntigravityInteractionsStepStop(out, st)
}
if fr := part.Get("functionResponse"); fr.Exists() {
out = ensureAntigravityInteractionsStep(out, st, "function_result", fr)
- delta := []byte(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.name", fr.Get("name").String())
+ deltaDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`))
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.name", fr.Get("name").String())
if response := fr.Get("response"); response.Exists() {
- delta, _ = sjson.SetRawBytes(delta, "delta.result", []byte(response.Raw))
+ _ = deltaDoc.SetRaw("delta.result", response.Raw)
}
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendAntigravityInteractionsStepStop(out, st)
}
if sig := antigravityThoughtSignature(part); sig != "" {
@@ -331,49 +337,49 @@ func appendAntigravityPartToInteractionsStream(out [][]byte, st *antigravityToIn
return out
}
-func appendAntigravityThoughtSignature(out [][]byte, st *antigravityToInteractionsStreamState, part gjson.Result) [][]byte {
+func appendAntigravityThoughtSignature(out [][]byte, st *antigravityToInteractionsStreamState, part ojson.Result) [][]byte {
if signature := antigravityThoughtSignature(part); signature != "" {
- out = ensureAntigravityInteractionsStep(out, st, "thought", gjson.Result{})
- signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signature)
- return append(out, translatorcommon.SSEEventData("step.delta", signatureDelta))
+ out = ensureAntigravityInteractionsStep(out, st, "thought", ojson.Result{})
+ signatureDeltaDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`))
+ _ = signatureDeltaDoc.Set("index", st.ActiveStepIndex)
+ _ = signatureDeltaDoc.Set("delta.signature", signature)
+ return append(out, translatorcommon.SSEEventData("step.delta", signatureDeltaDoc.Bytes()))
}
return out
}
-func antigravityPartToInteractionsSteps(part gjson.Result) [][]byte {
+func antigravityPartToInteractionsSteps(part ojson.Result) [][]byte {
sig := antigravityThoughtSignature(part)
if fc := part.Get("functionCall"); fc.Exists() {
var steps [][]byte
if sig != "" {
steps = append(steps, antigravityThoughtStepJSON(sig, ""))
}
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
- step, _ = sjson.SetBytes(step, "name", fc.Get("name").String())
+ stepDoc := ojson.NewDoc([]byte(`{"type":"function_call","name":"","arguments":{}}`))
+ _ = stepDoc.Set("name", fc.Get("name").String())
if id := fc.Get("id"); id.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", id.String())
+ _ = stepDoc.Set("call_id", id.String())
} else if callID := fc.Get("call_id"); callID.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", callID.String())
+ _ = stepDoc.Set("call_id", callID.String())
}
if args := fc.Get("args"); args.Exists() {
- step, _ = sjson.SetRawBytes(step, "arguments", []byte(args.Raw))
+ _ = stepDoc.SetRaw("arguments", args.Raw)
}
- steps = append(steps, step)
+ steps = append(steps, stepDoc.Bytes())
return steps
}
if fr := part.Get("functionResponse"); fr.Exists() {
- step := []byte(`{"type":"function_result","name":"","result":{}}`)
- step, _ = sjson.SetBytes(step, "name", fr.Get("name").String())
+ stepDoc := ojson.NewDoc([]byte(`{"type":"function_result","name":"","result":{}}`))
+ _ = stepDoc.Set("name", fr.Get("name").String())
if id := fr.Get("id"); id.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", id.String())
+ _ = stepDoc.Set("call_id", id.String())
} else if callID := fr.Get("call_id"); callID.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", callID.String())
+ _ = stepDoc.Set("call_id", callID.String())
}
if response := fr.Get("response"); response.Exists() {
- step, _ = sjson.SetRawBytes(step, "result", []byte(response.Raw))
+ _ = stepDoc.SetRaw("result", response.Raw)
}
- return [][]byte{step}
+ return [][]byte{stepDoc.Bytes()}
}
if text := part.Get("text"); text.Exists() {
if part.Get("thought").Bool() {
@@ -387,7 +393,7 @@ func antigravityPartToInteractionsSteps(part gjson.Result) [][]byte {
}
step := []byte(`{"type":"model_output","content":[]}`)
item := []byte(`{"type":"text","text":""}`)
- item, _ = sjson.SetBytes(item, "text", text.String())
+ item, _ = ojson.SetBytes(item, "text", text.String())
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item})
var steps [][]byte
steps = append(steps, step)
@@ -423,32 +429,32 @@ func antigravityPartToInteractionsSteps(part gjson.Result) [][]byte {
}
func antigravityThoughtStepJSON(sig, text string) []byte {
- step := []byte(`{"type":"thought"}`)
+ stepDoc := ojson.NewDoc([]byte(`{"type":"thought"}`))
if sig != "" {
- step, _ = sjson.SetBytes(step, "signature", sig)
+ _ = stepDoc.Set("signature", sig)
}
if text != "" {
item := []byte(`{"type":"text","text":""}`)
- item, _ = sjson.SetBytes(item, "text", text)
- step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item})
+ item, _ = ojson.SetBytes(item, "text", text)
+ translatorcommon.SetRawArrayItemsDoc(stepDoc, "content", [][]byte{item})
}
- return step
+ return stepDoc.Bytes()
}
-func antigravityPartToInteractionsStep(part gjson.Result) []byte {
+func antigravityPartToInteractionsStep(part ojson.Result) []byte {
steps := antigravityPartToInteractionsSteps(part)
if len(steps) == 0 {
return nil
}
for _, s := range steps {
- if gjson.GetBytes(s, "type").String() == "function_call" {
+ if ojson.GetBytesNoCopy(s, "type").String() == "function_call" {
return s
}
}
return steps[0]
}
-func antigravityInlineDataToInteractionsStep(inline gjson.Result) []byte {
+func antigravityInlineDataToInteractionsStep(inline ojson.Result) []byte {
mimeType := inline.Get("mimeType").String()
if mimeType == "" {
mimeType = inline.Get("mime_type").String()
@@ -467,16 +473,15 @@ func antigravityInlineDataToInteractionsStep(inline gjson.Result) []byte {
case strings.HasPrefix(lower, "video/"):
contentType = "video"
}
- item := []byte(`{"type":"","mime_type":"","data":""}`)
- item, _ = sjson.SetBytes(item, "type", contentType)
- item, _ = sjson.SetBytes(item, "mime_type", mimeType)
- item, _ = sjson.SetBytes(item, "data", data)
- step := []byte(`{"type":"model_output","content":[]}`)
- step, _ = sjson.SetRawBytes(step, "content.-1", item)
+ itemDoc := ojson.NewDoc([]byte(`{"type":"","mime_type":"","data":""}`))
+ _ = itemDoc.Set("type", contentType)
+ _ = itemDoc.Set("mime_type", mimeType)
+ _ = itemDoc.Set("data", data)
+ step, _ := ojson.SetRawBytes([]byte(`{"type":"model_output","content":[]}`), "content.-1", itemDoc.Bytes())
return step
}
-func hasAntigravityStreamUsage(root gjson.Result) bool {
+func hasAntigravityStreamUsage(root ojson.Result) bool {
usage := antigravityUsageNode(root)
if !usage.Exists() {
return false
@@ -500,44 +505,42 @@ func hasAntigravityStreamUsage(root gjson.Result) bool {
return false
}
-func setInteractionsUsageFromAntigravity(out []byte, path string, root gjson.Result) []byte {
+func setInteractionsUsageFromAntigravityDoc(outDoc *ojson.Doc, path string, root ojson.Result) {
usage := antigravityUsageNode(root)
if !usage.Exists() {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, path+".input_tokens", firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count"))
- out, _ = sjson.SetBytes(out, path+".output_tokens", firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count"))
+ _ = outDoc.Set(path+".input_tokens", firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count"))
+ _ = outDoc.Set(path+".output_tokens", firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count"))
if antigravityUsagePathExists(usage, "thoughtsTokenCount", "thoughts_token_count") {
- out, _ = sjson.SetBytes(out, path+".reasoning_tokens", firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count"))
+ _ = outDoc.Set(path+".reasoning_tokens", firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count"))
}
- out, _ = sjson.SetBytes(out, path+".total_tokens", firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count"))
+ _ = outDoc.Set(path+".total_tokens", firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count"))
if antigravityUsagePathExists(usage, "cachedContentTokenCount", "cached_content_token_count") {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count"))
+ _ = outDoc.Set(path+".cached_tokens", firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count"))
}
- return out
}
-func setInteractionsStreamUsageFromAntigravity(out []byte, path string, root gjson.Result) []byte {
+func setInteractionsStreamUsageFromAntigravityDoc(outDoc *ojson.Doc, path string, root ojson.Result) {
usage := antigravityUsageNode(root)
if !usage.Exists() {
- return out
+ return
}
inputTokens := firstAntigravityUsageInt(usage, "promptTokenCount", "prompt_token_count")
outputTokens := firstAntigravityUsageInt(usage, "candidatesTokenCount", "candidates_token_count")
totalTokens := firstAntigravityUsageInt(usage, "totalTokenCount", "total_token_count")
thoughtTokens := firstAntigravityUsageInt(usage, "thoughtsTokenCount", "thoughts_token_count")
cachedTokens := firstAntigravityUsageInt(usage, "cachedContentTokenCount", "cached_content_token_count")
- out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
- out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
- out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens)))
- out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens)
- out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
- out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0)
- out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thoughtTokens)
- return out
+ _ = outDoc.Set(path+".total_tokens", totalTokens)
+ _ = outDoc.Set(path+".total_input_tokens", inputTokens)
+ _ = outDoc.SetRaw(path+".input_tokens_by_modality", fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens))
+ _ = outDoc.Set(path+".total_cached_tokens", cachedTokens)
+ _ = outDoc.Set(path+".total_output_tokens", outputTokens)
+ _ = outDoc.Set(path+".total_tool_use_tokens", 0)
+ _ = outDoc.Set(path+".total_thought_tokens", thoughtTokens)
}
-func antigravityUsageNode(root gjson.Result) gjson.Result {
+func antigravityUsageNode(root ojson.Result) ojson.Result {
if usage := root.Get("usageMetadata"); usage.Exists() {
return usage
}
@@ -547,10 +550,10 @@ func antigravityUsageNode(root gjson.Result) gjson.Result {
if usage := root.Get("cpaUsageMetadata"); usage.Exists() {
return usage
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func firstAntigravityUsageInt(usage gjson.Result, paths ...string) int64 {
+func firstAntigravityUsageInt(usage ojson.Result, paths ...string) int64 {
for _, path := range paths {
if value := usage.Get(path); value.Exists() {
return value.Int()
@@ -559,7 +562,7 @@ func firstAntigravityUsageInt(usage gjson.Result, paths ...string) int64 {
return 0
}
-func antigravityUsagePathExists(usage gjson.Result, paths ...string) bool {
+func antigravityUsagePathExists(usage ojson.Result, paths ...string) bool {
for _, path := range paths {
if usage.Get(path).Exists() {
return true
@@ -568,7 +571,7 @@ func antigravityUsagePathExists(usage gjson.Result, paths ...string) bool {
return false
}
-func antigravityFunctionPartID(part gjson.Result) string {
+func antigravityFunctionPartID(part ojson.Result) string {
if id := part.Get("id"); id.Exists() {
return id.String()
}
@@ -578,7 +581,7 @@ func antigravityFunctionPartID(part gjson.Result) string {
return ""
}
-func antigravityThoughtSignature(part gjson.Result) string {
+func antigravityThoughtSignature(part ojson.Result) string {
for _, path := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
if signature := strings.TrimSpace(part.Get(path).String()); signature != "" {
return signature
diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_test.go
index bbc9b3b4f54..134d2c165f1 100644
--- a/internal/translator/antigravity/interactions/interactions_antigravity_test.go
+++ b/internal/translator/antigravity/interactions/interactions_antigravity_test.go
@@ -6,36 +6,36 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToAntigravityWithToolMessagesDirect(t *testing.T) {
out, _ := ConvertInteractionsRequestToAntigravity("antigravity-test", []byte(`{"model":"antigravity-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}`), false)
- if got := gjson.GetBytes(out, "request.systemInstruction.parts.0.text").String(); got != "be brief" {
+ if got := ojson.GetBytesNoCopy(out, "request.systemInstruction.parts.0.text").String(); got != "be brief" {
t.Fatalf("request.systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionCall.name").String(); got != "lookup" {
t.Fatalf("functionCall.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.name").String(); got != "lookup" {
t.Fatalf("functionResponse.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations.0.name").String(); got != "lookup" {
t.Fatalf("request.tools.0.functionDeclarations.0.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.q.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations.0.parametersJsonSchema.properties.q.type").String(); got != "string" {
t.Fatalf("tool parameters schema was not preserved. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToAntigravityPreservesGenerationConfig(t *testing.T) {
out, _ := ConvertInteractionsRequestToAntigravity("antigravity-test", []byte(`{"model":"antigravity-test","input":"hi","generation_config":{"max_output_tokens":16,"top_p":0.8,"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"reasoning":{"summary":"auto"},"stream":true}`), true)
- if gjson.GetBytes(out, "input").Exists() {
+ if ojson.GetBytesNoCopy(out, "input").Exists() {
t.Fatalf("raw interactions input exists in translated request. Output: %s", string(out))
}
for _, path := range []string{
@@ -43,29 +43,29 @@ func TestConvertInteractionsRequestToAntigravityPreservesGenerationConfig(t *tes
"request.generationConfig.thinkingLevel",
"request.generationConfig.thinkingSummaries",
} {
- if gjson.GetBytes(out, path).Exists() {
+ if ojson.GetBytesNoCopy(out, path).Exists() {
t.Fatalf("%s exists, want omitted. Output: %s", path, string(out))
}
}
- if got := gjson.GetBytes(out, "request.stream").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "request.stream").Bool(); !got {
t.Fatalf("request.stream = false, want true. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("request.contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.generationConfig.maxOutputTokens").Int(); got != 16 {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.maxOutputTokens").Int(); got != 16 {
t.Fatalf("request.generationConfig.maxOutputTokens = %d, want 16. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.generationConfig.topP").Float(); got != 0.8 {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.topP").Float(); got != 0.8 {
t.Fatalf("request.generationConfig.topP = %v, want 0.8. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("request.generationConfig.thinkingConfig.thinkingLevel = %q, want high. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("request.generationConfig.thinkingConfig.includeThoughts = false, want true. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
t.Fatalf("request.toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out))
}
}
@@ -85,10 +85,10 @@ func TestConvertInteractionsReasoningToAntigravityKeepsSummaryIndependent(t *tes
t.Run(test.name, func(t *testing.T) {
body := []byte(`{"model":"antigravity-test","input":"hi","reasoning":` + test.reasoning + `}`)
out, _ := ConvertInteractionsRequestToAntigravity("antigravity-test", body, false)
- if got := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, out)
}
- includeThoughts := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts")
+ includeThoughts := ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.includeThoughts")
if includeThoughts.Exists() != test.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), test.wantExists, out)
}
@@ -102,13 +102,13 @@ func TestConvertInteractionsReasoningToAntigravityKeepsSummaryIndependent(t *tes
func TestConvertAntigravityResponseToInteractionsNonStream(t *testing.T) {
raw := []byte(`{"response":{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"},{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":2,"totalTokenCount":5}}}`)
out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "steps.1.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "steps.1.type").String(); got != "function_call" {
t.Fatalf("steps.1.type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
}
@@ -121,7 +121,7 @@ func TestConvertAntigravityResponseToInteractionsStream(t *testing.T) {
if len(payload) == 0 {
t.Fatalf("step.delta event not found: %q", events)
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "ok" {
t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload))
}
}
@@ -154,7 +154,7 @@ func TestConvertAntigravityResponseToInteractionsStreamEmitsDoneAfterUsage(t *te
t.Fatalf("done count = %d, want 1. Events: %q", got, joined)
}
completed := findAntigravityInteractionsEventPayload(events, "interaction.completed")
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); got != 5 {
t.Fatalf("completed total_tokens = %d, want 5. Payload: %s", got, string(completed))
}
}
@@ -177,7 +177,7 @@ func TestConvertAntigravityResponseToInteractionsStreamSplitFinishAndUsage(t *te
t.Fatalf("usage done count = %d, want 1", got)
}
completed := findAntigravityInteractionsEventPayload(usageOut, "interaction.completed")
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 33 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); got != 33 {
t.Fatalf("completed total_tokens = %d, want 33. Payload: %s", got, string(completed))
}
@@ -205,7 +205,7 @@ func TestConvertAntigravityResponseToInteractionsStreamWaitsForPostFinishUsage(t
t.Fatalf("usage done count = %d, want 1", got)
}
completed := findAntigravityInteractionsEventPayload(usageOut, "interaction.completed")
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 33 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); got != 33 {
t.Fatalf("completed total_tokens = %d, want 33 (final usage, not the intermediate frame). Payload: %s", got, string(completed))
}
}
@@ -238,7 +238,7 @@ func TestConvertAntigravityResponseToInteractionsStreamFunctionCallStartHasCallI
var param any
events := ConvertAntigravityResponseToInteractions(context.Background(), "antigravity-test", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]}}]}}`), ¶m)
payload := findAntigravityInteractionsEventPayload(events, "step.start")
- if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(payload, "step.call_id").String(); got != "call_1" {
t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload))
}
}
@@ -259,7 +259,7 @@ func TestConvertInteractionsRequestToAntigravityDeduplicatesAndDisambiguatesTool
}`)
out, _ := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false)
- declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
+ declarations := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations").Array()
if len(declarations) != 3 {
t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out)
}
@@ -268,13 +268,13 @@ func TestConvertInteractionsRequestToAntigravityDeduplicatesAndDisambiguatesTool
if firstMapped == secondMapped || len(secondMapped) > 64 {
t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped)
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
}
}
@@ -287,9 +287,9 @@ func TestConvertInteractionsRequestToAntigravityPreservesNameMappingWhitespace(t
}`)
out, _ := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false)
- declarationName := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String()
- callName := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String()
- allowedName := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
+ declarationName := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations.0.name").String()
+ callName := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionCall.name").String()
+ allowedName := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
if declarationName == "" || callName != declarationName || allowedName != declarationName {
t.Fatalf("mapped names declaration=%q call=%q allowed=%q. Output: %s", declarationName, callName, allowedName, out)
}
@@ -303,7 +303,7 @@ func TestConvertAntigravityResponseToInteractionsRestoresDisambiguatedName(t *te
raw := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "antigravity-test", original, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.name").String(); got != second {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.name").String(); got != second {
t.Fatalf("function call name = %q, want %q. Output: %s", got, second, out)
}
}
@@ -317,7 +317,7 @@ func findAntigravityInteractionsEventPayload(events [][]byte, eventType string)
continue
}
payload := bytes.TrimSpace(line[len(prefix):])
- if gjson.GetBytes(payload, "type").String() == eventType || gjson.GetBytes(payload, "event_type").String() == eventType {
+ if ojson.GetBytesNoCopy(payload, "type").String() == eventType || ojson.GetBytesNoCopy(payload, "event_type").String() == eventType {
return payload
}
}
@@ -341,11 +341,11 @@ func TestConvertInteractionsRequestToAntigravityToolChoiceNoneOmitsTools(t *test
"tool_choice":` + tc.toolChoice + `
}`)
out, _ := ConvertInteractionsRequestToAntigravity("antigravity-test", inputJSON, false)
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
t.Fatalf("expected mode NONE, got %q", got)
}
- if gjson.GetBytes(out, "request.tools").Exists() {
- t.Fatalf("expected request.tools to be omitted, got %s", gjson.GetBytes(out, "request.tools").Raw)
+ if ojson.GetBytesNoCopy(out, "request.tools").Exists() {
+ t.Fatalf("expected request.tools to be omitted, got %s", ojson.GetBytesNoCopy(out, "request.tools").Raw)
}
})
}
@@ -359,7 +359,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
"tools":[{"type":"url_context"}]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d. Output: %s", len(tools), string(out))
}
@@ -378,7 +378,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
"tools":[{"type":"code_execution"}]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d. Output: %s", len(tools), string(out))
}
@@ -397,7 +397,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
"tools":[{"type":"google_search"}]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d. Output: %s", len(tools), string(out))
}
@@ -420,7 +420,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 3 {
t.Fatalf("expected 3 tools, got %d. Output: %s", len(tools), string(out))
}
@@ -457,7 +457,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 3 {
t.Fatalf("expected 3 tools, got %d. Output: %s", len(tools), string(out))
}
@@ -479,7 +479,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
"tools":[{"googleSearch":{},"urlContext":{}}]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool node, got %d. Output: %s", len(tools), string(out))
}
@@ -498,7 +498,7 @@ func TestConvertInteractionsRequestToAntigravityBuiltinTools(t *testing.T) {
"tools":[{"type":"file_search","file_search":{"max_results":5}}]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- tools := gjson.GetBytes(out, "request.tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "request.tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool retained, got %d. Output: %s", len(tools), string(out))
}
@@ -525,8 +525,8 @@ func TestConvertInteractionsRequestToAntigravity_FunctionResponseJSONRef(t *test
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- val := gjson.GetBytes(out, "request.contents.0.parts.0.functionResponse.response.result")
- if val.Type != gjson.String {
+ val := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionResponse.response.result")
+ if val.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got %s (raw: %s)", val.Type, val.Raw)
}
if !strings.Contains(val.String(), "#/components/schemas/ErrorModel") {
@@ -549,7 +549,7 @@ func TestConvertInteractionsRequestToAntigravity_ParallelToolCallsHistory(t *tes
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents (user, model, user), got %d: %s", len(contents), string(out))
}
@@ -578,7 +578,7 @@ func TestConvertInteractionsRequestToAntigravity_ThoughtSummaryAndSignature(t *t
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(out))
}
@@ -616,7 +616,7 @@ func TestConvertAntigravityResponseToInteractionsNonStream_ThoughtSignature(t *t
}
}`)
out := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "gemini-3.8-flash-high", nil, nil, raw, nil)
- steps := gjson.GetBytes(out, "steps").Array()
+ steps := ojson.GetBytesNoCopy(out, "steps").Array()
if len(steps) != 3 {
t.Fatalf("expected 3 steps (thought, function_call, function_call), got %d: %s", len(steps), string(out))
}
@@ -659,7 +659,7 @@ func TestConvertInteractionsRequestToAntigravity_InterleavedModelTurnSteps(t *te
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("expected 2 contents (model, user), got %d: %s", len(contents), string(out))
}
@@ -694,17 +694,17 @@ func TestConvertInteractionsRequestToAntigravity_ResponseToRequestRoundTrip(t *t
}
}`)
respOut := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "gemini-3.8-flash-high", nil, nil, upstreamResponse, nil)
- steps := gjson.GetBytes(respOut, "steps").Raw
+ steps := ojson.GetBytesNoCopy(respOut, "steps").Raw
// Build Turn 2 input: user question + Turn 1 steps + tool results
turn2Input := `{"model":"gemini-3.8-flash-high","input":[{"type":"user_input","content":[{"type":"text","text":"hello"}]}`
- for _, s := range gjson.Get(steps, "@this").Array() {
+ for _, s := range ojson.Get(steps, "@this").Array() {
turn2Input += "," + s.Raw
}
turn2Input += `,{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}},{"type":"function_result","name":"search","call_id":"call_2","result":{"found":true}}]}`
reqOut, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", []byte(turn2Input), false)
- contents := gjson.GetBytes(reqOut, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(reqOut, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents (user, model, user), got %d: %s", len(contents), string(reqOut))
}
@@ -730,7 +730,7 @@ func TestConvertInteractionsRequestToAntigravity_MultipleSignaturesPreserved(t *
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(out))
}
@@ -764,11 +764,11 @@ func TestConvertInteractionsRequestToAntigravity_TrailingSignatureRoundTrip(t *t
}
}`)
respOut := ConvertAntigravityResponseToInteractionsNonStream(context.Background(), "gemini-3.8-flash-high", nil, nil, upstreamResponse, nil)
- steps := gjson.GetBytes(respOut, "steps").Raw
+ steps := ojson.GetBytesNoCopy(respOut, "steps").Raw
turn2Input := `{"model":"gemini-3.8-flash-high","input":` + steps + `}`
reqOut, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", []byte(turn2Input), false)
- contents := gjson.GetBytes(reqOut, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(reqOut, "request.contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(reqOut))
}
@@ -797,7 +797,7 @@ func TestConvertInteractionsRequestToAntigravity_ExplicitSignatureClearsPending(
]
}`)
out, _ := ConvertInteractionsRequestToAntigravity("gemini-3.8-flash-high", inputJSON, false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(out))
}
@@ -824,11 +824,11 @@ func TestConvertInteractionsRequestToAntigravityAcceptsBareContentBlockList(t *t
if errStep != nil {
t.Fatalf("ConvertInteractionsRequestToAntigravity step form error: %v", errStep)
}
- if got, want := gjson.GetBytes(out, "request.contents").Raw, gjson.GetBytes(stepOut, "request.contents").Raw; got != want {
+ if got, want := ojson.GetBytesNoCopy(out, "request.contents").Raw, ojson.GetBytesNoCopy(stepOut, "request.contents").Raw; got != want {
t.Fatalf("bare content block contents = %s, want step form %s", got, want)
}
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) == 0 {
t.Fatalf("no contents in output: %s", string(out))
}
@@ -837,7 +837,7 @@ func TestConvertInteractionsRequestToAntigravityAcceptsBareContentBlockList(t *t
if role := content.Get("role").String(); role != "user" {
t.Fatalf("content role = %q, want user. Output: %s", role, string(out))
}
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if data := part.Get("inlineData.data").String(); data != "" {
inlineData = data
mimeType = part.Get("inlineData.mimeType").String()
diff --git a/internal/translator/antigravity/interactions/interactions_antigravity_uri_test.go b/internal/translator/antigravity/interactions/interactions_antigravity_uri_test.go
index 9270be587b5..056e4dadb73 100644
--- a/internal/translator/antigravity/interactions/interactions_antigravity_uri_test.go
+++ b/internal/translator/antigravity/interactions/interactions_antigravity_uri_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const interactionsAntigravityHistory = `{"type":"user_input","content":[{"type":"text","text":"a"}]},{"type":"model_output","content":[{"type":"text","text":"b"}]}`
@@ -35,7 +35,7 @@ func requireInteractionsToAntigravityRefusal(t *testing.T, envelope sdktranslato
if got, want := envelope.Err.Error(), "unsupported content part: "+wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
}
@@ -47,10 +47,10 @@ func TestInteractionsToAntigravityKeepsTheMediaGeminiToInteractionsWrites(t *tes
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "request.contents.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "request.contents.#").Int(); got != 3 {
t.Fatalf("contents = %d, want 3: %s", got, envelope.Body)
}
- fileData := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.fileData")
+ fileData := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.fileData")
if fileData.Get("fileUri").String() != "gs://b/a.png" || fileData.Get("mimeType").String() != "image/png" {
t.Fatalf("last turn lost the file: %s", envelope.Body)
}
@@ -63,7 +63,7 @@ func TestInteractionsToAntigravityReadsURIAsFileURI(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- fileData := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.fileData")
+ fileData := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.fileData")
if fileData.Get("fileUri").String() != "gs://b/a.pdf" || fileData.Get("mimeType").String() != "application/pdf" {
t.Fatalf("fileData = %s, body = %s", fileData.Raw, envelope.Body)
}
@@ -76,7 +76,7 @@ func TestInteractionsToAntigravityInlineDataStillConverts(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- inlineData := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.inlineData")
if inlineData.Get("mimeType").String() != "image/png" || inlineData.Get("data").String() != "aGVsbG8=" {
t.Fatalf("inlineData = %s, body = %s", inlineData.Raw, envelope.Body)
}
@@ -117,10 +117,10 @@ func TestInteractionsToAntigravityUnrepresentableAttachmentBesideTextStillSuccee
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "request.contents.2.parts.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.#").Int(); got != 1 {
t.Fatalf("last turn has %d parts, want 1: %s", got, envelope.Body)
}
}
@@ -145,7 +145,7 @@ func TestInteractionsToAntigravityNeighbouringStepKeepsTheTurnAlive(t *testing.T
func TestInteractionsToAntigravityExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","input":[` + interactionsAntigravityHistory + `,{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav"}]}]}`)
- if body, _ := ConvertInteractionsRequestToAntigravity("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertInteractionsRequestToAntigravity("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/antigravity/interactions/noop_optimization_test.go b/internal/translator/antigravity/interactions/noop_optimization_test.go
index 976f1d17c6a..05148afc287 100644
--- a/internal/translator/antigravity/interactions/noop_optimization_test.go
+++ b/internal/translator/antigravity/interactions/noop_optimization_test.go
@@ -3,7 +3,7 @@ package interactions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestRewriteInteractionsFunctionNamesReusesNormalizedPayload(t *testing.T) {
@@ -21,10 +21,10 @@ func TestRewriteInteractionsFunctionNamesNormalizesNonStringNames(t *testing.T)
output := rewriteInteractionsFunctionNames(input, nil)
- if name := gjson.GetBytes(output, "request.contents.0.parts.0.functionCall.name"); name.Type != gjson.String || name.String() != "true" {
+ if name := ojson.GetBytesNoCopy(output, "request.contents.0.parts.0.functionCall.name"); name.Type != ojson.String || name.String() != "true" {
t.Fatalf("functionCall.name = %s, want string true", name.Raw)
}
- if name := gjson.GetBytes(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != gjson.String || name.String() != "true" {
+ if name := ojson.GetBytesNoCopy(output, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0"); name.Type != ojson.String || name.String() != "true" {
t.Fatalf("allowedFunctionNames.0 = %s, want string true", name.Raw)
}
}
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go
index 02222725b05..ad259c9c330 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_file_data_test.go
@@ -3,14 +3,14 @@ package chat_completions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIRequestToAntigravityNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out, _ := ConvertOpenAIRequestToAntigravity("gemini-2.5-pro", input, false)
- inlineData := gjson.GetBytes(out, "request.contents.0.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_media_url_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_media_url_test.go
index a499a4a1fc8..3eaec52dd41 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_media_url_test.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_media_url_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -57,7 +57,7 @@ func TestConvertOpenAIRequestToAntigravity_RealTextBesideAnUnsendableAttachmentS
t.Fatalf("parts %s: err = %v, body = %s", parts, err, body)
}
found := false
- for _, part := range gjson.GetBytes(body, "request.contents.2.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(body, "request.contents.2.parts").Array() {
if part.Get("text").String() == "keep me" {
found = true
}
@@ -73,7 +73,7 @@ func TestConvertOpenAIRequestToAntigravity_DataURLImageStillConverts(t *testing.
if err != nil {
t.Fatalf("err = %v, body = %s", err, body)
}
- part := gjson.GetBytes(body, "request.contents.2.parts.0.inlineData")
+ part := ojson.GetBytesNoCopy(body, "request.contents.2.parts.0.inlineData")
if part.Get("mimeType").String() != "image/png" || part.Get("data").String() != "iVBORw0KGgo=" {
t.Fatalf("data URL image was not inlined: %s", body)
}
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go
index 7a65a95d615..549d57aae42 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request.go
@@ -6,20 +6,19 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/gemini"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const antigravityFunctionThoughtSignature = "skip_thought_signature_validator"
// ConvertOpenAIRequestToAntigravity converts an OpenAI Chat Completions request (raw JSON)
-// into a complete Antigravity request JSON. All JSON construction uses sjson and lookups use gjson.
+// into a complete Antigravity request JSON. All JSON construction uses sjson and lookups use ojson.
//
// Parameters:
// - modelName: The name of the model to use for the request
@@ -40,76 +39,76 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
var drops translatorcommon.UserTurnDrops
functionNameMap := util.SanitizedFunctionNameMap(rawJSON)
// Base envelope (no default thinkingConfig)
- out := []byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`)
+ outDoc := ojson.NewDoc([]byte(`{"project":"","request":{"contents":[]},"model":"gemini-2.5-pro"}`))
// Model
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Let user-provided generationConfig pass through
- if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw))
- } else if genConfig := gjson.GetBytes(rawJSON, "generation_config"); genConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig", []byte(genConfig.Raw))
+ if genConfig := ojson.GetBytesNoCopy(rawJSON, "generationConfig"); genConfig.Exists() {
+ _ = outDoc.SetRaw("request.generationConfig", genConfig.Raw)
+ } else if genConfig := ojson.GetBytesNoCopy(rawJSON, "generation_config"); genConfig.Exists() {
+ _ = outDoc.SetRaw("request.generationConfig", genConfig.Raw)
}
// Apply thinking configuration: convert OpenAI reasoning_effort to Antigravity thinkingConfig.
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
- re := gjson.GetBytes(rawJSON, "reasoning_effort")
+ re := ojson.GetBytesNoCopy(rawJSON, "reasoning_effort")
if re.Exists() {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if effort != "" {
thinkingPath := "request.generationConfig.thinkingConfig"
if effort == "auto" {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
+ _ = outDoc.Set(thinkingPath+".thinkingBudget", -1)
} else {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
+ _ = outDoc.Set(thinkingPath+".thinkingLevel", effort)
}
}
}
- out = applyOpenAIThinkingCompatibilityToAntigravity(out, rawJSON)
+ normalizeAntigravityOpenAIThinkingConfigDoc(outDoc)
// Temperature/top_p/top_k/max_tokens/max_completion_tokens
- if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.temperature", tr.Num)
+ if tr := ojson.GetBytesNoCopy(rawJSON, "temperature"); tr.Exists() && tr.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.temperature", tr.Num)
}
- if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.topP", tpr.Num)
+ if tpr := ojson.GetBytesNoCopy(rawJSON, "top_p"); tpr.Exists() && tpr.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.topP", tpr.Num)
}
- if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.topK", tkr.Num)
+ if tkr := ojson.GetBytesNoCopy(rawJSON, "top_k"); tkr.Exists() && tkr.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.topK", tkr.Num)
}
- if maxTok := gjson.GetBytes(rawJSON, "max_tokens"); maxTok.Exists() && maxTok.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", maxTok.Num)
- } else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "request.generationConfig.maxOutputTokens", mct.Num)
+ if maxTok := ojson.GetBytesNoCopy(rawJSON, "max_tokens"); maxTok.Exists() && maxTok.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.maxOutputTokens", maxTok.Num)
+ } else if mct := ojson.GetBytesNoCopy(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == ojson.Number {
+ _ = outDoc.Set("request.generationConfig.maxOutputTokens", mct.Num)
}
// Map OpenAI response_format to Antigravity structured output settings.
- if responseFormat := gjson.GetBytes(rawJSON, "response_format"); responseFormat.Exists() {
+ if responseFormat := ojson.GetBytesNoCopy(rawJSON, "response_format"); responseFormat.Exists() {
switch responseFormatType := strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())); responseFormatType {
case "json_object", "json_schema":
for _, schemaKey := range []string{"responseSchema", "responseJsonSchema", "response_schema", "response_json_schema"} {
- out, _ = sjson.DeleteBytes(out, "request.generationConfig."+schemaKey)
+ _ = util.DeleteJSONDocIfExists(outDoc, "request.generationConfig."+schemaKey)
}
- out, _ = sjson.SetBytes(out, "request.generationConfig.responseMimeType", "application/json")
+ _ = outDoc.Set("request.generationConfig.responseMimeType", "application/json")
if responseFormatType == "json_schema" {
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
- out, _ = sjson.SetRawBytes(out, "request.generationConfig.responseSchema", []byte(schema.Raw))
+ _ = outDoc.SetRaw("request.generationConfig.responseSchema", schema.Raw)
}
}
}
}
// Candidate count (OpenAI 'n' parameter)
- if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number {
+ if n := ojson.GetBytesNoCopy(rawJSON, "n"); n.Exists() && n.Type == ojson.Number {
if val := n.Int(); val > 1 {
- out, _ = sjson.SetBytes(out, "request.generationConfig.candidateCount", val)
+ _ = outDoc.Set("request.generationConfig.candidateCount", val)
}
}
// Map OpenAI modalities -> Antigravity request.generationConfig.responseModalities
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
- if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
+ if mods := ojson.GetBytesNoCopy(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
var responseMods []string
for _, m := range mods.Array() {
switch strings.ToLower(m.String()) {
@@ -120,23 +119,23 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
}
if len(responseMods) > 0 {
- out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods)
+ _ = outDoc.Set("request.generationConfig.responseModalities", responseMods)
}
}
// OpenRouter-style image_config support
// If the input uses top-level image_config.aspect_ratio, map it into request.generationConfig.imageConfig.aspectRatio.
- if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
- if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.aspectRatio", ar.Str)
+ if imgCfg := ojson.GetBytesNoCopy(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
+ if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == ojson.String {
+ _ = outDoc.Set("request.generationConfig.imageConfig.aspectRatio", ar.Str)
}
- if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.imageSize", size.Str)
+ if size := imgCfg.Get("image_size"); size.Exists() && size.Type == ojson.String {
+ _ = outDoc.Set("request.generationConfig.imageConfig.imageSize", size.Str)
}
}
// messages -> systemInstruction + contents
- messages := gjson.GetBytes(rawJSON, "messages")
+ messages := ojson.GetBytesNoCopy(rawJSON, "messages")
if messages.IsArray() {
arr := messages.Array()
systemParts := make([][]byte, 0, 2)
@@ -150,7 +149,7 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
if (role == "system" || role == "developer") && len(arr) > 1 && !hasEncounteredConversation {
// system -> request.systemInstruction as a user message style
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
systemParts = append(systemParts, antigravityOpenAITextPart(content.String()))
} else if content.IsObject() && content.Get("type").String() == "text" {
systemParts = append(systemParts, antigravityOpenAITextPart(content.Get("text").String()))
@@ -163,7 +162,7 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
hasEncounteredConversation = true
isDemotedSystem := role == "system" || role == "developer"
partItems := make([][]byte, 0, 4)
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
partItems = append(partItems, antigravityOpenAITextPart(antigravityDemotedSystemText(content.String(), isDemotedSystem)))
} else if content.IsObject() && content.Get("type").String() == "text" {
partItems = append(partItems, antigravityOpenAITextPart(antigravityDemotedSystemText(content.Get("text").String(), isDemotedSystem)))
@@ -215,12 +214,12 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
} else if role == "assistant" {
hasEncounteredConversation = true
partItems := make([][]byte, 0, 4)
- if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
+ if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == ojson.String && reasoningContent.String() != "" {
part := antigravityOpenAITextPart(reasoningContent.String())
- part, _ = sjson.SetBytes(part, "thought", true)
+ part, _ = ojson.SetBytes(part, "thought", true)
partItems = append(partItems, part)
}
- if content.Type == gjson.String && content.String() != "" {
+ if content.Type == ojson.String && content.String() != "" {
partItems = append(partItems, antigravityOpenAITextPart(content.String()))
} else if content.IsArray() {
for _, item := range content.Array() {
@@ -272,16 +271,16 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
continue
}
functionArgs := tc.Get("function.arguments").String()
- part := []byte(`{"functionCall":{"id":"","name":""}}`)
- part, _ = sjson.SetBytes(part, "functionCall.id", functionID)
- part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
- if gjson.Valid(functionArgs) {
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
+ partDoc := ojson.NewDoc([]byte(`{"functionCall":{"id":"","name":""}}`))
+ _ = partDoc.Set("functionCall.id", functionID)
+ _ = partDoc.Set("functionCall.name", functionName)
+ if ojson.Valid(functionArgs) {
+ _ = partDoc.SetRaw("functionCall.args", functionArgs)
} else {
- part, _ = sjson.SetBytes(part, "functionCall.args.params", []byte(functionArgs))
+ _ = partDoc.Set("functionCall.args.params", []byte(functionArgs))
}
- part, _ = sjson.SetBytes(part, "thoughtSignature", antigravityFunctionThoughtSignature)
- partItems = append(partItems, part)
+ _ = partDoc.Set("thoughtSignature", antigravityFunctionThoughtSignature)
+ partItems = append(partItems, partDoc.Bytes())
toolCalls = append(toolCalls, assistantToolCall{
rawID: rawID,
id: functionID,
@@ -309,17 +308,17 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
responseParts := make([][]byte, 0, len(toolCalls))
for _, call := range toolCalls {
- part := []byte(`{"functionResponse":{"id":"","name":""}}`)
- part, _ = sjson.SetBytes(part, "functionResponse.id", call.id)
- part, _ = sjson.SetBytes(part, "functionResponse.name", call.name)
+ partDoc := ojson.NewDoc([]byte(`{"functionResponse":{"id":"","name":""}}`))
+ _ = partDoc.Set("functionResponse.id", call.id)
+ _ = partDoc.Set("functionResponse.name", call.name)
response := turnToolResponses[call.rawID]
if response == "" {
response = "{}"
}
// Keep it as a string instead of parsing it into JSON.
// Parsing it as JSON, similar to reading a JSON file with readFile, may trigger an upstream 400 error.
- part, _ = sjson.SetBytes(part, "functionResponse.response.result", response)
- responseParts = append(responseParts, part)
+ _ = partDoc.Set("functionResponse.response.result", response)
+ responseParts = append(responseParts, partDoc.Bytes())
}
if len(responseParts) > 0 {
contentItems = append(contentItems, antigravityOpenAIContent("user", responseParts))
@@ -330,13 +329,13 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
}
}
if len(systemParts) > 0 {
- out, _ = sjson.SetRawBytes(out, "request.systemInstruction", antigravityOpenAIContent("user", systemParts))
+ _ = outDoc.SetRawBytes("request.systemInstruction", antigravityOpenAIContent("user", systemParts))
}
- out = translatorcommon.SetRawArrayItems(out, "request.contents", contentItems)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "request.contents", contentItems)
}
// tools -> request.tools[].functionDeclarations + request.tools[].googleSearch/codeExecution/urlContext passthrough
- tools := gjson.GetBytes(rawJSON, "tools")
+ tools := ojson.GetBytesNoCopy(rawJSON, "tools")
toolResults := tools.Array()
if tools.IsArray() && len(toolResults) > 0 {
functionDeclarations := make([][]byte, 0, len(toolResults))
@@ -348,58 +347,45 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
fn := t.Get("function")
if fn.Exists() && fn.IsObject() {
fnRaw := fn.Raw
+ needDefaultSchema := false
if fn.Get("parameters").Exists() {
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
if errRename != nil {
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
- var errSet error
- fnRawBytes, errSet := sjson.SetBytes([]byte(fnRaw), "parametersJsonSchema.type", "object")
- if errSet != nil {
- log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
- continue
- }
- fnRaw = string(fnRawBytes)
- fnRawBytes, errSet = sjson.SetRawBytes([]byte(fnRaw), "parametersJsonSchema.properties", []byte(`{}`))
- if errSet != nil {
- log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
- continue
- }
- fnRaw = string(fnRawBytes)
+ needDefaultSchema = true
} else {
fnRaw = renamed
}
} else {
- var errSet error
- fnRawBytes, errSet := sjson.SetBytes([]byte(fnRaw), "parametersJsonSchema.type", "object")
- if errSet != nil {
+ needDefaultSchema = true
+ }
+ fnDoc := ojson.NewDocString(fnRaw)
+ if needDefaultSchema {
+ if errSet := fnDoc.Set("parametersJsonSchema.type", "object"); errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
- fnRaw = string(fnRawBytes)
- fnRawBytes, errSet = sjson.SetRawBytes([]byte(fnRaw), "parametersJsonSchema.properties", []byte(`{}`))
- if errSet != nil {
+ if errSet := fnDoc.SetRaw("parametersJsonSchema.properties", `{}`); errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
- fnRaw = string(fnRawBytes)
}
- fnRawBytes := []byte(fnRaw)
nameResult := fn.Get("name")
originalName := nameResult.String()
mappedName := util.MapSanitizedFunctionName(functionNameMap, originalName)
- if nameResult.Type != gjson.String || mappedName != originalName {
- fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", mappedName)
+ if nameResult.Type != ojson.String || mappedName != originalName {
+ _ = fnDoc.Set("name", mappedName)
}
- if gjson.GetBytes(fnRawBytes, "strict").Exists() {
- fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict")
+ if fn.Get("strict").Exists() {
+ _ = fnDoc.Delete("strict")
}
- functionDeclarations = append(functionDeclarations, fnRawBytes)
+ functionDeclarations = append(functionDeclarations, fnDoc.Bytes())
}
}
if gs := t.Get("google_search"); gs.Exists() {
googleToolNode := []byte(`{}`)
var errSet error
- googleToolNode, errSet = sjson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
+ googleToolNode, errSet = ojson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
if errSet != nil {
log.Warnf("Failed to set googleSearch tool: %v", errSet)
continue
@@ -409,7 +395,7 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
if ce := t.Get("code_execution"); ce.Exists() {
codeToolNode := []byte(`{}`)
var errSet error
- codeToolNode, errSet = sjson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
+ codeToolNode, errSet = ojson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
if errSet != nil {
log.Warnf("Failed to set codeExecution tool: %v", errSet)
continue
@@ -419,7 +405,7 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
if uc := t.Get("url_context"); uc.Exists() {
urlToolNode := []byte(`{}`)
var errSet error
- urlToolNode, errSet = sjson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
+ urlToolNode, errSet = ojson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
if errSet != nil {
log.Warnf("Failed to set urlContext tool: %v", errSet)
continue
@@ -433,46 +419,50 @@ func convertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
if hasFunction {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
- functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
+ functionToolNode, _ = ojson.SetRawBytes(functionToolNode, "functionDeclarations", deduplicated)
toolItems = append(toolItems, functionToolNode)
}
toolItems = append(toolItems, googleSearchNodes...)
toolItems = append(toolItems, codeExecutionNodes...)
toolItems = append(toolItems, urlContextNodes...)
- out, _ = sjson.SetRawBytes(out, "request.tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("request.tools", translatorcommon.JoinRawArray(toolItems))
}
}
- out = applyOpenAIToolChoiceToAntigravity(out, rawJSON, functionNameMap)
+ applyOpenAIToolChoiceToAntigravityDoc(outDoc, rawJSON, functionNameMap)
+ common.AttachDefaultSafetySettingsDoc(outDoc, "request.safetySettings")
+ summaryCfg := thinking.ExtractSummaryConfig(rawJSON, "openai")
+ out := thinking.ApplySummaryConfig(outDoc.Bytes(), "antigravity", summaryCfg)
if strings.Contains(strings.ToLower(modelName), "claude") {
out = gemini.SanitizeAntigravityClaudeGeminiRequestSignatures(modelName, out)
}
- return common.AttachDefaultSafetySettings(out, "request.safetySettings"), drops.Err()
+ return out, drops.Err()
}
func antigravityOpenAITextPart(text string) []byte {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
return part
}
func antigravityOpenAIInlineDataPart(mimeType, data string, snakeCase bool) []byte {
- part := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
if snakeCase {
- part = []byte(`{"inlineData":{"mime_type":"","data":""}}`)
- part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
- } else {
- part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
+ partDoc := ojson.NewDoc([]byte(`{"inlineData":{"mime_type":"","data":""}}`))
+ _ = partDoc.Set("inlineData.mime_type", mimeType)
+ _ = partDoc.Set("inlineData.data", data)
+ return partDoc.Bytes()
}
- part, _ = sjson.SetBytes(part, "inlineData.data", data)
- return part
+ partDoc := ojson.NewDoc([]byte(`{"inlineData":{"mimeType":"","data":""}}`))
+ _ = partDoc.Set("inlineData.mimeType", mimeType)
+ _ = partDoc.Set("inlineData.data", data)
+ return partDoc.Bytes()
}
func antigravityOpenAIContent(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDoc([]byte(`{"role":"","parts":[]}`))
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
func antigravityOpenAIAudioMIMEType(format string) string {
@@ -498,15 +488,15 @@ func antigravityOpenAIAudioMIMEType(format string) string {
}
}
-func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map[string]string) []byte {
- toolChoice := gjson.GetBytes(rawJSON, "tool_choice")
+func applyOpenAIToolChoiceToAntigravityDoc(outDoc *ojson.Doc, rawJSON []byte, functionNameMap map[string]string) {
+ toolChoice := ojson.GetBytesNoCopy(rawJSON, "tool_choice")
if !toolChoice.Exists() {
- return out
+ return
}
mode := ""
allowedName := ""
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
mode = "NONE"
@@ -525,53 +515,69 @@ func applyOpenAIToolChoiceToAntigravity(out, rawJSON []byte, functionNameMap map
}
}
if mode == "" {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.mode", mode)
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.mode", mode)
if strings.TrimSpace(allowedName) != "" {
mappedName := util.MapSanitizedFunctionName(functionNameMap, allowedName)
- out, _ = sjson.SetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{mappedName})
+ _ = outDoc.Set("request.toolConfig.functionCallingConfig.allowedFunctionNames", []string{mappedName})
}
- return out
}
-func applyOpenAIThinkingCompatibilityToAntigravity(out []byte, rawJSON []byte) []byte {
- out = normalizeAntigravityOpenAIThinkingConfig(out)
- config := thinking.ExtractSummaryConfig(rawJSON, "openai")
- return thinking.ApplySummaryConfig(out, "antigravity", config)
+func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte {
+ outDoc := ojson.NewDoc(out)
+ if normalizeAntigravityOpenAIThinkingConfigDoc(outDoc) {
+ return outDoc.Bytes()
+ }
+ return out
}
-func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte {
+func normalizeAntigravityOpenAIThinkingConfigDoc(outDoc *ojson.Doc) bool {
+ changed := false
for _, prefix := range []string{
"request.generationConfig.thinking_config",
"request.generationConfig.thinkingConfig",
} {
- if sourcePath := prefix + ".includeThoughts"; gjson.GetBytes(out, sourcePath).Exists() {
- includeThoughts := gjson.GetBytes(out, sourcePath)
- out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
- if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False {
- out, _ = sjson.DeleteBytes(out, sourcePath)
+ if sourcePath := prefix + ".includeThoughts"; outDoc.Get(sourcePath).Exists() {
+ includeThoughts := outDoc.Get(sourcePath)
+ if setAntigravityOpenAIBoolResultIfValidDoc(outDoc, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) {
+ changed = true
+ }
+ if includeThoughts.Type != ojson.True && includeThoughts.Type != ojson.False {
+ _ = outDoc.Delete(sourcePath)
+ changed = true
}
}
- if sourcePath := prefix + ".include_thoughts"; gjson.GetBytes(out, sourcePath).Exists() {
- includeThoughts := gjson.GetBytes(out, sourcePath)
- out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
- if includeThoughts.Type != gjson.True && includeThoughts.Type != gjson.False {
- out, _ = sjson.DeleteBytes(out, sourcePath)
+ if sourcePath := prefix + ".include_thoughts"; outDoc.Get(sourcePath).Exists() {
+ includeThoughts := outDoc.Get(sourcePath)
+ if setAntigravityOpenAIBoolResultIfValidDoc(outDoc, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) {
+ changed = true
+ }
+ if includeThoughts.Type != ojson.True && includeThoughts.Type != ojson.False {
+ _ = outDoc.Delete(sourcePath)
+ changed = true
}
}
- if thinkingLevel := gjson.GetBytes(out, prefix+".thinkingLevel"); thinkingLevel.Exists() {
- out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel)
+ if thinkingLevel := outDoc.Get(prefix + ".thinkingLevel"); thinkingLevel.Exists() {
+ if setAntigravityOpenAIRawIfDifferentDoc(outDoc, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel) {
+ changed = true
+ }
}
- if thinkingLevel := gjson.GetBytes(out, prefix+".thinking_level"); thinkingLevel.Exists() {
- out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel)
+ if thinkingLevel := outDoc.Get(prefix + ".thinking_level"); thinkingLevel.Exists() {
+ if setAntigravityOpenAIRawIfDifferentDoc(outDoc, "request.generationConfig.thinkingConfig.thinkingLevel", thinkingLevel) {
+ changed = true
+ }
}
- if thinkingBudget := gjson.GetBytes(out, prefix+".thinkingBudget"); thinkingBudget.Exists() {
- out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget)
+ if thinkingBudget := outDoc.Get(prefix + ".thinkingBudget"); thinkingBudget.Exists() {
+ if setAntigravityOpenAIRawIfDifferentDoc(outDoc, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget) {
+ changed = true
+ }
}
- if thinkingBudget := gjson.GetBytes(out, prefix+".thinking_budget"); thinkingBudget.Exists() {
- out = setAntigravityOpenAIRawIfDifferent(out, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget)
+ if thinkingBudget := outDoc.Get(prefix + ".thinking_budget"); thinkingBudget.Exists() {
+ if setAntigravityOpenAIRawIfDifferentDoc(outDoc, "request.generationConfig.thinkingConfig.thinkingBudget", thinkingBudget) {
+ changed = true
+ }
}
}
@@ -579,8 +585,10 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte {
"request.generationConfig.includeThoughts",
"request.generationConfig.include_thoughts",
} {
- if includeThoughts := gjson.GetBytes(out, path); includeThoughts.Exists() {
- out = setAntigravityOpenAIBoolResultIfValid(out, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ if includeThoughts := outDoc.Get(path); includeThoughts.Exists() {
+ if setAntigravityOpenAIBoolResultIfValidDoc(outDoc, "request.generationConfig.thinkingConfig.includeThoughts", includeThoughts) {
+ changed = true
+ }
}
}
@@ -592,47 +600,40 @@ func normalizeAntigravityOpenAIThinkingConfig(out []byte) []byte {
"request.generationConfig.includeThoughts",
"request.generationConfig.include_thoughts",
} {
- if gjson.GetBytes(out, path).Exists() {
- out, _ = sjson.DeleteBytes(out, path)
+ if outDoc.Get(path).Exists() {
+ _ = outDoc.Delete(path)
+ changed = true
}
}
- return out
+ return changed
}
-func setAntigravityOpenAIBoolResultIfValid(out []byte, path string, value gjson.Result) []byte {
+func setAntigravityOpenAIBoolResultIfValidDoc(outDoc *ojson.Doc, path string, value ojson.Result) bool {
switch value.Type {
- case gjson.True:
- return setAntigravityOpenAIBoolIfDifferent(out, path, true)
- case gjson.False:
- return setAntigravityOpenAIBoolIfDifferent(out, path, false)
+ case ojson.True:
+ return setAntigravityOpenAIBoolIfDifferentDoc(outDoc, path, true)
+ case ojson.False:
+ return setAntigravityOpenAIBoolIfDifferentDoc(outDoc, path, false)
default:
- return out
+ return false
}
}
-func setAntigravityOpenAIBoolIfDifferent(out []byte, path string, value bool) []byte {
- current := gjson.GetBytes(out, path)
- if value && current.Type == gjson.True || !value && current.Type == gjson.False {
- return out
+func setAntigravityOpenAIBoolIfDifferentDoc(outDoc *ojson.Doc, path string, value bool) bool {
+ current := outDoc.Get(path)
+ if value && current.Type == ojson.True || !value && current.Type == ojson.False {
+ return false
}
- updated, errSet := sjson.SetBytes(out, path, value)
- if errSet != nil {
- return out
- }
- return updated
+ return outDoc.Set(path, value) == nil
}
-func setAntigravityOpenAIRawIfDifferent(out []byte, path string, value gjson.Result) []byte {
- current := gjson.GetBytes(out, path)
+func setAntigravityOpenAIRawIfDifferentDoc(outDoc *ojson.Doc, path string, value ojson.Result) bool {
+ current := outDoc.Get(path)
if current.Exists() && current.Raw == value.Raw {
- return out
- }
- updated, errSet := sjson.SetRawBytes(out, path, []byte(value.Raw))
- if errSet != nil {
- return out
+ return false
}
- return updated
+ return outDoc.SetRaw(path, value.Raw) == nil
}
// antigravityDemotedSystemText wraps a demoted mid-session system or developer
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go
index ac64e6b8386..d364f9d4675 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_request_test.go
@@ -4,8 +4,9 @@ import (
"regexp"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *testing.T) {
@@ -34,22 +35,22 @@ func TestConvertOpenAIRequestToAntigravitySkipsEmptyTextPartsWithoutNulls(t *tes
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- userParts := gjson.GetBytes(result, "request.contents.0.parts").Array()
+ userParts := ojson.GetBytesNoCopy(result, "request.contents.0.parts").Array()
if len(userParts) != 1 {
t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result)
}
- if userParts[0].Type == gjson.Null {
+ if userParts[0].Type == ojson.Null {
t.Fatalf("user parts.0 is null. Output: %s", result)
}
if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result)
}
- assistantParts := gjson.GetBytes(result, "request.contents.1.parts").Array()
+ assistantParts := ojson.GetBytesNoCopy(result, "request.contents.1.parts").Array()
if len(assistantParts) != 1 {
t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result)
}
- if assistantParts[0].Type == gjson.Null {
+ if assistantParts[0].Type == ojson.Null {
t.Fatalf("assistant parts.0 is null. Output: %s", result)
}
if !assistantParts[0].Get("functionCall").Exists() {
@@ -68,7 +69,7 @@ func TestConvertOpenAIRequestToAntigravity_ClaudeModelSanitizesUnsignedReasoning
}`
result, _ := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false)
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
}
@@ -95,7 +96,7 @@ func TestConvertOpenAIRequestToAntigravity_ClaudeModelDropsEmptyAssistantTurnAft
}`
result, _ := ConvertOpenAIRequestToAntigravity("claude-sonnet-4-6", []byte(inputJSON), false)
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2 (empty model turn dropped). Output: %s", len(contents), result)
}
@@ -118,7 +119,7 @@ func TestConvertOpenAIRequestToAntigravityPreservesReasoningContent(t *testing.T
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
}
@@ -149,7 +150,7 @@ func TestConvertOpenAIRequestToAntigravityPreservesReasoningBeforeVisibleContent
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) != 4 {
t.Fatalf("contents length = %d, want 4. Output: %s", len(contents), result)
}
@@ -188,7 +189,7 @@ func TestConvertOpenAIRequestToAntigravitySkipsEmptyAssistantMessages(t *testing
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), true)
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
}
@@ -207,7 +208,7 @@ func TestConvertOpenAIRequestToAntigravity_MidSessionDeveloperMessageDoesNotMuta
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- output := gjson.ParseBytes(result)
+ output := util.ParseGJSONBytesNoCopy(result)
// request.systemInstruction must contain only original system prompt
sysParts := output.Get("request.systemInstruction.parts").Array()
@@ -251,7 +252,7 @@ func TestConvertOpenAIRequestToAntigravity_MidSessionSystemReminderEnvelope(t *t
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- output := gjson.ParseBytes(result)
+ output := util.ParseGJSONBytesNoCopy(result)
contents := output.Get("request.contents").Array()
if len(contents) != 4 {
@@ -289,8 +290,8 @@ func TestConvertOpenAIRequestToAntigravity_MidSessionTransientSystemInstructionP
outWith, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(turnWithTransient), false)
outWithout, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(turnWithoutTransient), false)
- contentsWith := gjson.GetBytes(outWith, "request.contents").Array()
- contentsWithout := gjson.GetBytes(outWithout, "request.contents").Array()
+ contentsWith := ojson.GetBytesNoCopy(outWith, "request.contents").Array()
+ contentsWithout := ojson.GetBytesNoCopy(outWithout, "request.contents").Array()
// Ensure demoted system instruction is standalone and not merged into adjacent user turn
if len(contentsWith) != 4 {
@@ -329,7 +330,7 @@ func TestConvertOpenAIRequestToAntigravity_MidSessionSystemReminderObjectAndArra
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- output := gjson.ParseBytes(result)
+ output := util.ParseGJSONBytesNoCopy(result)
contents := output.Get("request.contents").Array()
if len(contents) != 4 {
@@ -433,7 +434,7 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3.1-pro-low", []byte(tt.body), false)
- includeThoughts := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
+ includeThoughts := ojson.GetBytesNoCopy(result, "request.generationConfig.thinkingConfig.includeThoughts")
if includeThoughts.Exists() != tt.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v. Output: %s", includeThoughts.Exists(), tt.wantExists, result)
}
@@ -442,7 +443,7 @@ func TestConvertOpenAIRequestToAntigravityThinkingAliases(t *testing.T) {
t.Fatalf("includeThoughts = %v, want %v. Output: %s", got, tt.want, result)
}
}
- if snake := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() {
+ if snake := ojson.GetBytesNoCopy(result, "request.generationConfig.thinkingConfig.include_thoughts"); snake.Exists() {
t.Fatalf("include_thoughts should be normalized away. Output: %s", result)
}
})
@@ -467,7 +468,7 @@ func TestConvertOpenAIRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *t
}`
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- declarations := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
+ declarations := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations").Array()
if len(declarations) != 3 {
t.Fatalf("declaration count = %d, want 3. Output: %s", len(declarations), out)
}
@@ -476,13 +477,13 @@ func TestConvertOpenAIRequestToAntigravityDeduplicatesAndDisambiguatesTools(t *t
if firstMapped == secondMapped || len(secondMapped) > 64 {
t.Fatalf("collision names = %q and %q, want distinct names <= 64 chars", firstMapped, secondMapped)
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.functionCall.name").String(); got != secondMapped {
t.Fatalf("functionCall.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionResponse.name").String(); got != secondMapped {
t.Fatalf("functionResponse.name = %q, want %q. Output: %s", got, secondMapped, out)
}
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != secondMapped {
t.Fatalf("allowedFunctionNames.0 = %q, want %q. Output: %s", got, secondMapped, out)
}
}
@@ -499,7 +500,7 @@ func TestConvertOpenAIRequestToAntigravityMapsToolChoiceModes(t *testing.T) {
t.Run(tt.mode+tt.choice, func(t *testing.T) {
inputJSON := []byte(`{"messages":[{"role":"user","content":"hi"}],"tool_choice":` + tt.choice + `}`)
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", inputJSON, false)
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != tt.mode {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String(); got != tt.mode {
t.Fatalf("tool choice mode = %q, want %q. Output: %s", got, tt.mode, out)
}
})
@@ -520,10 +521,10 @@ func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONObject(t *testin
}`)
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false)
- if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out)
}
- if gjson.GetBytes(out, "request.generationConfig.responseSchema").Exists() {
+ if ojson.GetBytesNoCopy(out, "request.generationConfig.responseSchema").Exists() {
t.Fatalf("responseSchema should not be set for json_object. Output: %s", out)
}
assertNoResponseSchemaAliases(t, out)
@@ -553,10 +554,10 @@ func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONSchema(t *testin
}`)
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3.6-flash-high", inputJSON, false)
- if got := gjson.GetBytes(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
+ if got := ojson.GetBytesNoCopy(out, "request.generationConfig.responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, out)
}
- schema := gjson.GetBytes(out, "request.generationConfig.responseSchema")
+ schema := ojson.GetBytesNoCopy(out, "request.generationConfig.responseSchema")
if !schema.Exists() {
t.Fatalf("responseSchema missing. Output: %s", out)
}
@@ -572,7 +573,7 @@ func TestConvertOpenAIRequestToAntigravityMapsResponseFormatJSONSchema(t *testin
func assertNoResponseSchemaAliases(t *testing.T, out []byte) {
t.Helper()
for _, schemaKey := range []string{"responseJsonSchema", "response_schema", "response_json_schema"} {
- if gjson.GetBytes(out, "request.generationConfig."+schemaKey).Exists() {
+ if ojson.GetBytesNoCopy(out, "request.generationConfig."+schemaKey).Exists() {
t.Errorf("stale %s survived response_format mapping. Output: %s", schemaKey, out)
}
}
@@ -591,7 +592,7 @@ func TestConvertOpenAIRequestToAntigravityTranslatesVideoURL(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3.7-flash-high", inputJSON, false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts length = %d, want 2. Output: %s", len(parts), out)
}
@@ -638,7 +639,7 @@ func TestConvertOpenAIRequestToAntigravity_MaxCompletionTokens(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToAntigravity("gemini-2.5-flash", []byte(tt.body), false)
- got := gjson.GetBytes(out, "request.generationConfig.maxOutputTokens")
+ got := ojson.GetBytesNoCopy(out, "request.generationConfig.maxOutputTokens")
if !got.Exists() {
t.Fatalf("request.generationConfig.maxOutputTokens missing. Output: %s", out)
}
@@ -674,12 +675,12 @@ func TestConvertOpenAIRequestToAntigravityPreservesToolResponseAsString(t *testi
}`
result, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(result, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "request.contents").Array()
if len(contents) < 3 {
t.Fatalf("expected at least 3 contents, got %d. Output: %s", len(contents), result)
}
frResult := contents[2].Get("parts.0.functionResponse.response.result")
- if frResult.Type != gjson.String {
+ if frResult.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got type %s (raw: %s)", frResult.Type, frResult.Raw)
}
expected := `{"key":"value","items":[1,2,3]}`
@@ -704,10 +705,10 @@ func TestConvertOpenAIRequestToAntigravityToolChoiceNoneKeepsTools(t *testing.T)
"tool_choice":` + tc.toolChoice + `
}`)
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", inputJSON, false)
- if got := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
+ if got := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String(); got != "NONE" {
t.Fatalf("expected mode NONE, got %q", got)
}
- if got := gjson.GetBytes(out, "request.tools.0.functionDeclarations.0.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations.0.name").String(); got != "get_weather" {
t.Fatalf("expected tool declaration get_weather with mode NONE, got %q", got)
}
})
@@ -745,9 +746,9 @@ func TestConvertOpenAIRequestToAntigravity_MultiTurnRepeatedToolCallID_Issue5933
// In Turn 1 (contents[1] = model functionCall, contents[2] = user functionResponse):
// functionCall.name must be "glob", and functionResponse.name must be "glob".
- call1Name := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String()
- resp1Name := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
- resp1Result := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.response.result").String()
+ call1Name := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionCall.name").String()
+ resp1Name := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.name").String()
+ resp1Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.response.result").String()
if call1Name != "glob" {
t.Fatalf("turn 1 functionCall.name = %q, want glob", call1Name)
@@ -761,9 +762,9 @@ func TestConvertOpenAIRequestToAntigravity_MultiTurnRepeatedToolCallID_Issue5933
// In Turn 2 (contents[4] = model functionCall, contents[5] = user functionResponse):
// functionCall.name must be "read", and functionResponse.name must be "read".
- call2Name := gjson.GetBytes(out, "request.contents.4.parts.0.functionCall.name").String()
- resp2Name := gjson.GetBytes(out, "request.contents.5.parts.0.functionResponse.name").String()
- resp2Result := gjson.GetBytes(out, "request.contents.5.parts.0.functionResponse.response.result").String()
+ call2Name := ojson.GetBytesNoCopy(out, "request.contents.4.parts.0.functionCall.name").String()
+ resp2Name := ojson.GetBytesNoCopy(out, "request.contents.5.parts.0.functionResponse.name").String()
+ resp2Result := ojson.GetBytesNoCopy(out, "request.contents.5.parts.0.functionResponse.response.result").String()
if call2Name != "read" {
t.Fatalf("turn 2 functionCall.name = %q, want read", call2Name)
@@ -800,10 +801,10 @@ func TestConvertOpenAIRequestToAntigravity_ParallelAndOutOfOrderToolResponses(t
out, _ := ConvertOpenAIRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- resp0Name := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String()
- resp0Result := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.response.result").String()
- resp1Name := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.name").String()
- resp1Result := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.response.result").String()
+ resp0Name := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.name").String()
+ resp0Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.response.result").String()
+ resp1Name := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.name").String()
+ resp1Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.response.result").String()
if resp0Name != "tool_a" || resp0Result != "res_a" {
t.Fatalf("part 0 want tool_a / res_a, got %s / %s", resp0Name, resp0Result)
@@ -846,10 +847,10 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs(t *testing.T)
claudePattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
- fc0ID := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String()
- fc1ID := gjson.GetBytes(out, "request.contents.1.parts.1.functionCall.id").String()
- fr0ID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
- fr1ID := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.id").String()
+ fc0ID := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionCall.id").String()
+ fc1ID := ojson.GetBytesNoCopy(out, "request.contents.1.parts.1.functionCall.id").String()
+ fr0ID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.id").String()
+ fr1ID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.id").String()
if !claudePattern.MatchString(fc0ID) {
t.Fatalf("functionCall 0 id %q does not match Claude pattern ^[a-zA-Z0-9_-]+$", fc0ID)
@@ -861,7 +862,7 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs(t *testing.T)
t.Fatalf("functionResponse 0 id = %q, want matching functionCall id %q", fr0ID, fc0ID)
}
- fr0Result := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.response.result").String()
+ fr0Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.response.result").String()
if want := `{"result":"ok"}`; fr0Result != want {
t.Fatalf("functionResponse 0 result = %q, want %q", fr0Result, want)
}
@@ -876,7 +877,7 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs(t *testing.T)
t.Fatalf("functionResponse 1 id = %q, want matching functionCall id %q", fr1ID, fc1ID)
}
- fr1Result := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.response.result").String()
+ fr1Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.response.result").String()
if want := `{"result":"valid_ok"}`; fr1Result != want {
t.Fatalf("functionResponse 1 result = %q, want %q", fr1Result, want)
}
@@ -911,10 +912,10 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs_DisambiguatesC
claudePattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
- fc0ID := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String()
- fc1ID := gjson.GetBytes(out, "request.contents.1.parts.1.functionCall.id").String()
- fr0ID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
- fr1ID := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.id").String()
+ fc0ID := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionCall.id").String()
+ fc1ID := ojson.GetBytesNoCopy(out, "request.contents.1.parts.1.functionCall.id").String()
+ fr0ID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.id").String()
+ fr1ID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.id").String()
if !claudePattern.MatchString(fc0ID) {
t.Fatalf("functionCall 0 id %q does not match Claude pattern", fc0ID)
@@ -934,12 +935,12 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs_DisambiguatesC
}
// Verify that results did not get swapped or overwritten despite reverse response order and raw ID collision
- fr0Result := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.response.result").String()
+ fr0Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.response.result").String()
if want := "result_for_a_pipe_b"; fr0Result != want {
t.Fatalf("functionResponse 0 result = %q, want %q", fr0Result, want)
}
- fr1Result := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.response.result").String()
+ fr1Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.response.result").String()
if want := "result_for_a_b"; fr1Result != want {
t.Fatalf("functionResponse 1 result = %q, want %q", fr1Result, want)
}
@@ -977,10 +978,10 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs_MissingRespons
claudePattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
- fc0ID := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String()
- fc1ID := gjson.GetBytes(out, "request.contents.1.parts.1.functionCall.id").String()
- fr0ID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
- fr1ID := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.id").String()
+ fc0ID := ojson.GetBytesNoCopy(out, "request.contents.1.parts.0.functionCall.id").String()
+ fc1ID := ojson.GetBytesNoCopy(out, "request.contents.1.parts.1.functionCall.id").String()
+ fr0ID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.id").String()
+ fr1ID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.id").String()
if !claudePattern.MatchString(fc0ID) || !claudePattern.MatchString(fc1ID) {
t.Fatalf("functionCall ids do not match Claude pattern: fc0=%q, fc1=%q", fc0ID, fc1ID)
@@ -993,12 +994,12 @@ func TestConvertOpenAIRequestToAntigravity_SanitizesClaudeToolIDs_MissingRespons
}
// First call has missing response, should fallback to "{}" and NOT steal second call's result
- fr0Result := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.response.result").String()
+ fr0Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.response.result").String()
if want := "{}"; fr0Result != want {
t.Fatalf("functionResponse 0 result = %q, want %q (stole colliding call's result)", fr0Result, want)
}
- fr1Result := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.response.result").String()
+ fr1Result := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse.response.result").String()
if want := "result_for_a_b"; fr1Result != want {
t.Fatalf("functionResponse 1 result = %q, want %q", fr1Result, want)
}
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go
index 1b8c029eab1..bd4ff2d6f2c 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response.go
@@ -16,9 +16,8 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
+ "github.com/MoeForge/moego/ojson"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/openai/chat-completions"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// convertCliResponseToOpenAIChatParams holds parameters for response conversion.
@@ -72,20 +71,20 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
if params.SawResponse && !params.SawFinishReason {
params.SawFinishReason = true
finishReason, nativeFinishReason := resolveOpenAIFinishReason(params)
- template := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":"stop","native_finish_reason":"stop"}]}`)
- template, _ = sjson.SetBytes(template, "created", params.UnixTimestamp)
+ templateDoc := ojson.NewDoc([]byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":"stop","native_finish_reason":"stop"}]}`))
+ _ = templateDoc.Set("created", params.UnixTimestamp)
if params.ModelVersion != "" {
- template, _ = sjson.SetBytes(template, "model", params.ModelVersion)
+ _ = templateDoc.Set("model", params.ModelVersion)
}
if params.ResponseID != "" {
- template, _ = sjson.SetBytes(template, "id", params.ResponseID)
+ _ = templateDoc.Set("id", params.ResponseID)
}
if len(params.PendingUsageMetadata) > 0 {
- template = setOpenAIUsageMetadata(template, gjson.ParseBytes(params.PendingUsageMetadata))
+ setOpenAIUsageMetadataDoc(templateDoc, util.ParseGJSONBytesNoCopy(params.PendingUsageMetadata))
}
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
- template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
- return [][]byte{template}
+ _ = templateDoc.Set("choices.0.finish_reason", finishReason)
+ _ = templateDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
+ return [][]byte{templateDoc.Bytes()}
}
return [][]byte{}
}
@@ -95,52 +94,54 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
}
// Initialize the OpenAI SSE template.
- template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null},"finish_reason":null,"native_finish_reason":null}]}`)
+ templateDoc := ojson.NewDoc([]byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null},"finish_reason":null,"native_finish_reason":null}]}`))
// Extract and set the model version.
- if modelVersionResult := gjson.GetBytes(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
- params.ModelVersion = modelVersionResult.String()
- template, _ = sjson.SetBytes(template, "model", params.ModelVersion)
+ if modelVersionResult := ojson.GetBytesNoCopy(rawJSON, "response.modelVersion"); modelVersionResult.Exists() {
+ params.ModelVersion = strings.Clone(modelVersionResult.String())
+ _ = templateDoc.Set("model", params.ModelVersion)
}
// Extract and set the creation timestamp.
- if createTimeResult := gjson.GetBytes(rawJSON, "response.createTime"); createTimeResult.Exists() {
+ if createTimeResult := ojson.GetBytesNoCopy(rawJSON, "response.createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
params.UnixTimestamp = t.Unix()
}
- template, _ = sjson.SetBytes(template, "created", params.UnixTimestamp)
+ _ = templateDoc.Set("created", params.UnixTimestamp)
} else {
- template, _ = sjson.SetBytes(template, "created", params.UnixTimestamp)
+ _ = templateDoc.Set("created", params.UnixTimestamp)
}
// Extract and set the response ID.
- if responseIDResult := gjson.GetBytes(rawJSON, "response.responseId"); responseIDResult.Exists() {
- params.ResponseID = responseIDResult.String()
- template, _ = sjson.SetBytes(template, "id", params.ResponseID)
+ if responseIDResult := ojson.GetBytesNoCopy(rawJSON, "response.responseId"); responseIDResult.Exists() {
+ params.ResponseID = strings.Clone(responseIDResult.String())
+ _ = templateDoc.Set("id", params.ResponseID)
}
// Cache the finish reason - do NOT set it in output yet (will be set on final chunk)
- if finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
- params.UpstreamFinishReason = strings.ToUpper(finishReasonResult.String())
+ if finishReasonResult := ojson.GetBytesNoCopy(rawJSON, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
+ params.UpstreamFinishReason = strings.Clone(strings.ToUpper(finishReasonResult.String()))
}
// Extract and set usage metadata (token counts). FilterSSEUsageMetadata renames
// non-terminal usage to cpaUsageMetadata, so retain the latest copy for [DONE]
// instead of treating an intermediate chunk as terminal.
- usageResult := gjson.GetBytes(rawJSON, "response.usageMetadata")
+ usageResult := ojson.GetBytesNoCopy(rawJSON, "response.usageMetadata")
if !usageResult.Exists() {
- if pendingUsage := gjson.GetBytes(rawJSON, "response.cpaUsageMetadata"); pendingUsage.Exists() {
+ if pendingUsage := ojson.GetBytesNoCopy(rawJSON, "response.cpaUsageMetadata"); pendingUsage.Exists() {
params.PendingUsageMetadata = append(params.PendingUsageMetadata[:0], pendingUsage.Raw...)
}
} else {
- template = setOpenAIUsageMetadata(template, usageResult)
+ setOpenAIUsageMetadataDoc(templateDoc, usageResult)
}
// Process the main content part of the response.
- partsResult := gjson.GetBytes(rawJSON, "response.candidates.0.content.parts")
+ partsResult := ojson.GetBytesNoCopy(rawJSON, "response.candidates.0.content.parts")
if partsResult.IsArray() {
partResults := partsResult.Array()
+ hasToolCallsArray := false
+ imageCount := 0
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
partTextResult := partResult.Get("text")
@@ -167,35 +168,35 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
// Handle text content, distinguishing between regular content and reasoning/thoughts.
if partResult.Get("thought").Bool() {
- template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", textContent)
+ _ = templateDoc.Set("choices.0.delta.reasoning_content", textContent)
} else {
- template, _ = sjson.SetBytes(template, "choices.0.delta.content", textContent)
+ _ = templateDoc.Set("choices.0.delta.content", textContent)
}
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
} else if functionCallResult.Exists() {
// Handle function call content.
(*param).(*convertCliResponseToOpenAIChatParams).SawToolCall = true // Persist across chunks
- toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex
(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++
- if !toolCallsResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
+ if !hasToolCallsArray {
+ _ = templateDoc.SetRaw("choices.0.delta.tool_calls", `[]`)
+ hasToolCallsArray = true
}
- functionCallTemplate := []byte(`{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`)
+ fcDoc := ojson.NewDoc([]byte(`{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`))
fcName := util.RestoreSanitizedToolName((*param).(*convertCliResponseToOpenAIChatParams).SanitizedNameMap, functionCallResult.Get("name").String())
fcID := strings.TrimSpace(functionCallResult.Get("id").String())
if fcID == "" {
fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))
}
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fcID)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
+ _ = fcDoc.Set("id", fcID)
+ _ = fcDoc.Set("index", functionCallIndex)
+ _ = fcDoc.Set("function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
+ _ = fcDoc.Set("function.arguments", fcArgsResult.Raw)
}
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls.-1", fcDoc.Bytes())
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data == "" {
@@ -209,16 +210,16 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
- imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
- if !imagesResult.Exists() || !imagesResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
+ if imageCount == 0 {
+ _ = templateDoc.SetRaw("choices.0.delta.images", `[]`)
}
- imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
- imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
- imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
+ imageIndex := imageCount
+ imageCount++
+ imageDoc := ojson.NewDoc([]byte(`{"type":"image_url","image_url":{"url":""}}`))
+ _ = imageDoc.Set("index", imageIndex)
+ _ = imageDoc.Set("image_url.url", imageURL)
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.images.-1", imageDoc.Bytes())
}
}
}
@@ -229,17 +230,16 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
// across chunks and keep streaming afterwards, and finalizing early would make
// strict clients drop the remaining content. When upstream never sends
// finishReason at all, the [DONE] branch synthesizes the terminal chunk.
- usageMetadata := gjson.GetBytes(rawJSON, "response.usageMetadata")
- isFinalChunk := params.UpstreamFinishReason != "" && usageMetadata.Exists()
+ isFinalChunk := params.UpstreamFinishReason != "" && usageResult.Exists()
if isFinalChunk {
finishReason, nativeFinishReason := resolveOpenAIFinishReason(params)
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
- template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
+ _ = templateDoc.Set("choices.0.finish_reason", finishReason)
+ _ = templateDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
params.SawFinishReason = true
}
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
}
// hasAntigravityResponsePayload reports whether a chunk carries actual generated
@@ -249,7 +249,7 @@ func ConvertAntigravityResponseToOpenAI(_ context.Context, _ string, originalReq
// finalize a stream that produced nothing.
func hasAntigravityResponsePayload(rawJSON []byte) bool {
for _, path := range []string{"response.candidates", "candidates"} {
- if candidates := gjson.GetBytes(rawJSON, path); candidates.IsArray() && len(candidates.Array()) > 0 {
+ if candidates := ojson.GetBytesNoCopy(rawJSON, path); candidates.IsArray() && len(candidates.Array()) > 0 {
return true
}
}
@@ -259,7 +259,7 @@ func hasAntigravityResponsePayload(rawJSON []byte) bool {
"response.usageMetadata", "response.cpaUsageMetadata",
"usageMetadata", "cpaUsageMetadata",
} {
- if usage := gjson.GetBytes(rawJSON, path); usage.IsObject() && len(usage.Map()) > 0 {
+ if usage := ojson.GetBytesNoCopy(rawJSON, path); usage.IsObject() && len(usage.Map()) > 0 {
return true
}
}
@@ -296,26 +296,23 @@ func isAntigravityContentFilterFinishReason(reason string) bool {
}
}
-func setOpenAIUsageMetadata(template []byte, usageResult gjson.Result) []byte {
+func setOpenAIUsageMetadataDoc(templateDoc *ojson.Doc, usageResult ojson.Result) {
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
- template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int())
+ _ = templateDoc.Set("usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int())
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
+ _ = templateDoc.Set("usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
+ _ = templateDoc.Set("usage.prompt_tokens", promptTokenCount)
if thoughtsTokenCount > 0 {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
+ _ = templateDoc.Set("usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
if cachedTokenCount > 0 {
- var err error
- template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
- if err != nil {
- log.Warnf("antigravity openai response: failed to set cached_tokens: %v", err)
+ if errSet := templateDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokenCount); errSet != nil {
+ log.Warnf("antigravity openai response: failed to set cached_tokens: %v", errSet)
}
}
- return template
}
// ConvertAntigravityResponseToOpenAINonStream converts a non-streaming Antigravity response to a non-streaming OpenAI response.
@@ -332,7 +329,7 @@ func setOpenAIUsageMetadata(template []byte, usageResult gjson.Result) []byte {
// Returns:
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
func ConvertAntigravityResponseToOpenAINonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
- responseResult := gjson.GetBytes(rawJSON, "response")
+ responseResult := ojson.GetBytesNoCopy(rawJSON, "response")
if responseResult.Exists() {
responseJSON := restoreAntigravityOpenAIFunctionNames([]byte(responseResult.Raw), originalRequestRawJSON)
return ConvertGeminiResponseToOpenAINonStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, responseJSON, param)
@@ -345,7 +342,8 @@ func restoreAntigravityOpenAIFunctionNames(rawJSON, originalRequestRawJSON []byt
if len(nameMap) == 0 {
return rawJSON
}
- candidates := gjson.GetBytes(rawJSON, "candidates")
+ var rawDoc *ojson.Doc
+ candidates := ojson.GetBytesNoCopy(rawJSON, "candidates")
for candidateIndex, candidate := range candidates.Array() {
for partIndex, part := range candidate.Get("content.parts").Array() {
for _, field := range []string{"functionCall", "functionResponse"} {
@@ -355,13 +353,19 @@ func restoreAntigravityOpenAIFunctionNames(rawJSON, originalRequestRawJSON []byt
continue
}
restoredName := util.RestoreSanitizedToolName(nameMap, name)
- if nameResult.Type == gjson.String && restoredName == name {
+ if nameResult.Type == ojson.String && restoredName == name {
continue
}
+ if rawDoc == nil {
+ rawDoc = ojson.NewDoc(rawJSON)
+ }
path := fmt.Sprintf("candidates.%d.content.parts.%d.%s.name", candidateIndex, partIndex, field)
- rawJSON, _ = sjson.SetBytes(rawJSON, path, restoredName)
+ _ = rawDoc.Set(path, restoredName)
}
}
}
+ if rawDoc != nil {
+ return rawDoc.Bytes()
+ }
return rawJSON
}
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go
index baf6adb46d3..9faa86dc309 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_response_test.go
@@ -4,8 +4,8 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
func TestFinishReasonToolCallsNotOverwritten(t *testing.T) {
@@ -20,7 +20,7 @@ func TestFinishReasonToolCallsNotOverwritten(t *testing.T) {
if len(result1) != 1 {
t.Fatalf("Expected 1 result from chunk1, got %d", len(result1))
}
- fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
+ fr1 := ojson.GetBytesNoCopy(result1[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Errorf("Expected finish_reason to be null in chunk1, got: %v", fr1.String())
}
@@ -34,13 +34,13 @@ func TestFinishReasonToolCallsNotOverwritten(t *testing.T) {
if len(result2) != 1 {
t.Fatalf("Expected 1 result from chunk2, got %d", len(result2))
}
- fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
+ fr2 := ojson.GetBytesNoCopy(result2[0], "choices.0.finish_reason").String()
if fr2 != "tool_calls" {
t.Errorf("Expected finish_reason 'tool_calls', got: %s", fr2)
}
// Verify native_finish_reason is lowercase upstream value
- nfr2 := gjson.GetBytes(result2[0], "choices.0.native_finish_reason").String()
+ nfr2 := ojson.GetBytesNoCopy(result2[0], "choices.0.native_finish_reason").String()
if nfr2 != "stop" {
t.Errorf("Expected native_finish_reason 'stop', got: %s", nfr2)
}
@@ -59,7 +59,7 @@ func TestFinishReasonStopForNormalText(t *testing.T) {
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
// Verify finish_reason is "stop" (no tool calls were made)
- fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
+ fr := ojson.GetBytesNoCopy(result2[0], "choices.0.finish_reason").String()
if fr != "stop" {
t.Errorf("Expected finish_reason 'stop', got: %s", fr)
}
@@ -78,7 +78,7 @@ func TestFinishReasonMaxTokens(t *testing.T) {
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
// Verify finish_reason is "max_tokens"
- fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
+ fr := ojson.GetBytesNoCopy(result2[0], "choices.0.finish_reason").String()
if fr != "max_tokens" {
t.Errorf("Expected finish_reason 'max_tokens', got: %s", fr)
}
@@ -97,7 +97,7 @@ func TestToolCallTakesPriorityOverMaxTokens(t *testing.T) {
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
// Verify finish_reason is "tool_calls" (takes priority over max_tokens)
- fr := gjson.GetBytes(result2[0], "choices.0.finish_reason").String()
+ fr := ojson.GetBytesNoCopy(result2[0], "choices.0.finish_reason").String()
if fr != "tool_calls" {
t.Errorf("Expected finish_reason 'tool_calls', got: %s", fr)
}
@@ -112,7 +112,7 @@ func TestNoFinishReasonOnIntermediateChunks(t *testing.T) {
result1 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
// Verify no finish_reason on intermediate chunk
- fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
+ fr1 := ojson.GetBytesNoCopy(result1[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr1)
}
@@ -122,7 +122,7 @@ func TestNoFinishReasonOnIntermediateChunks(t *testing.T) {
result2 := ConvertAntigravityResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
// Verify no finish_reason on intermediate chunk
- fr2 := gjson.GetBytes(result2[0], "choices.0.finish_reason")
+ fr2 := ojson.GetBytesNoCopy(result2[0], "choices.0.finish_reason")
if fr2.Exists() && fr2.String() != "" && fr2.Type.String() != "Null" {
t.Errorf("Expected no finish_reason on intermediate chunk, got: %v", fr2)
}
@@ -153,15 +153,15 @@ func TestConvertAntigravityResponseToOpenAICompletionTokensIncludeThoughts(t *te
if len(result) != 1 {
t.Fatalf("expected 1 result, got %d", len(result))
}
- completionTokens := gjson.GetBytes(result[0], "usage.completion_tokens")
+ completionTokens := ojson.GetBytesNoCopy(result[0], "usage.completion_tokens")
if !completionTokens.Exists() || completionTokens.Int() != tt.wantOutput {
t.Fatalf("completion_tokens = %s, want present with value %d. Output: %s", completionTokens.Raw, tt.wantOutput, result[0])
}
- if got := gjson.GetBytes(result[0], "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 {
+ if got := ojson.GetBytesNoCopy(result[0], "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 {
t.Fatalf("reasoning_tokens = %d, want 42. Output: %s", got, result[0])
}
- promptTokens := gjson.GetBytes(result[0], "usage.prompt_tokens").Int()
- totalTokens := gjson.GetBytes(result[0], "usage.total_tokens").Int()
+ promptTokens := ojson.GetBytesNoCopy(result[0], "usage.prompt_tokens").Int()
+ totalTokens := ojson.GetBytesNoCopy(result[0], "usage.total_tokens").Int()
if promptTokens+completionTokens.Int() != totalTokens {
t.Fatalf("prompt_tokens + completion_tokens = %d, total_tokens = %d", promptTokens+completionTokens.Int(), totalTokens)
}
@@ -180,7 +180,7 @@ func TestConvertAntigravityResponseToOpenAI_SynthesizesStopOnUsageChunkWhenFinis
if len(result) != 1 {
t.Fatalf("expected 1 result from chunk, got %d", len(result))
}
- if fr := gjson.GetBytes(result[0], "choices.0.finish_reason").String(); fr != "" {
+ if fr := ojson.GetBytesNoCopy(result[0], "choices.0.finish_reason").String(); fr != "" {
t.Fatalf("usage chunk without finishReason must not be terminal, got %q", fr)
}
@@ -189,7 +189,7 @@ func TestConvertAntigravityResponseToOpenAI_SynthesizesStopOnUsageChunkWhenFinis
if len(done) != 1 {
t.Fatalf("expected 1 synthesized terminal chunk on [DONE], got %d", len(done))
}
- if fr := gjson.GetBytes(done[0], "choices.0.finish_reason").String(); fr != "stop" {
+ if fr := ojson.GetBytesNoCopy(done[0], "choices.0.finish_reason").String(); fr != "stop" {
t.Fatalf("terminal finish_reason = %q, want stop", fr)
}
}
@@ -206,10 +206,10 @@ func TestConvertAntigravityResponseToOpenAI_PreservesFilteredUsageUntilDone(t *t
if len(result) != 1 {
t.Fatalf("expected 1 intermediate result, got %d", len(result))
}
- if usage := gjson.GetBytes(result[0], "usage"); usage.Exists() {
+ if usage := ojson.GetBytesNoCopy(result[0], "usage"); usage.Exists() {
t.Fatalf("intermediate filtered usage should not be emitted: %s", usage.Raw)
}
- if finishReason := gjson.GetBytes(result[0], "choices.0.finish_reason"); finishReason.String() != "" {
+ if finishReason := ojson.GetBytesNoCopy(result[0], "choices.0.finish_reason"); finishReason.String() != "" {
t.Fatalf("intermediate finish_reason = %q, want null", finishReason.String())
}
@@ -217,13 +217,13 @@ func TestConvertAntigravityResponseToOpenAI_PreservesFilteredUsageUntilDone(t *t
if len(done) != 1 {
t.Fatalf("expected 1 synthetic terminal result, got %d", len(done))
}
- if finishReason := gjson.GetBytes(done[0], "choices.0.finish_reason").String(); finishReason != "stop" {
+ if finishReason := ojson.GetBytesNoCopy(done[0], "choices.0.finish_reason").String(); finishReason != "stop" {
t.Fatalf("terminal finish_reason = %q, want stop", finishReason)
}
- if got := gjson.GetBytes(done[0], "usage.prompt_tokens").Int(); got != 100 {
+ if got := ojson.GetBytesNoCopy(done[0], "usage.prompt_tokens").Int(); got != 100 {
t.Fatalf("terminal prompt_tokens = %d, want 100", got)
}
- if got := gjson.GetBytes(done[0], "usage.total_tokens").Int(); got != 150 {
+ if got := ojson.GetBytesNoCopy(done[0], "usage.total_tokens").Int(); got != 150 {
t.Fatalf("terminal total_tokens = %d, want 150", got)
}
}
@@ -238,7 +238,7 @@ func TestConvertAntigravityResponseToOpenAI_SynthesizesStopOnDoneWhenFinishReaso
if len(result) != 1 {
t.Fatalf("expected 1 result from chunk, got %d", len(result))
}
- fr1 := gjson.GetBytes(result[0], "choices.0.finish_reason")
+ fr1 := ojson.GetBytesNoCopy(result[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Fatalf("expected finish_reason to be null on intermediate chunk, got %v", fr1)
}
@@ -248,14 +248,14 @@ func TestConvertAntigravityResponseToOpenAI_SynthesizesStopOnDoneWhenFinishReaso
if len(done) != 1 {
t.Fatalf("expected 1 result on [DONE], got %d", len(done))
}
- frDone := gjson.GetBytes(done[0], "choices.0.finish_reason").String()
+ frDone := ojson.GetBytesNoCopy(done[0], "choices.0.finish_reason").String()
if frDone != "stop" {
t.Fatalf("expected finish_reason 'stop' on [DONE], got %q", frDone)
}
- if mv := gjson.GetBytes(done[0], "model").String(); mv != "gemini-3.7-flash" {
+ if mv := ojson.GetBytesNoCopy(done[0], "model").String(); mv != "gemini-3.7-flash" {
t.Fatalf("expected model 'gemini-3.7-flash', got %q", mv)
}
- if rid := gjson.GetBytes(done[0], "id").String(); rid != "resp-888" {
+ if rid := ojson.GetBytesNoCopy(done[0], "id").String(); rid != "resp-888" {
t.Fatalf("expected id 'resp-888', got %q", rid)
}
}
@@ -303,7 +303,7 @@ func TestConvertAntigravityResponseToOpenAINonStreamRestoresDisambiguatedName(t
responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + mapped + `","args":{}}}]}}]}}`)
output := ConvertAntigravityResponseToOpenAINonStream(context.Background(), "gemini-3-flash", original, nil, responseJSON, nil)
- if got := gjson.GetBytes(output, "choices.0.message.tool_calls.0.function.name").String(); got != second {
+ if got := ojson.GetBytesNoCopy(output, "choices.0.message.tool_calls.0.function.name").String(); got != second {
t.Fatalf("function.name = %q, want %q. Output: %s", got, second, output)
}
}
@@ -334,13 +334,13 @@ func TestConvertAntigravityResponseToOpenAINonStreamIncludesReasoningContent(t *
}`)
output := ConvertAntigravityResponseToOpenAINonStream(ctx, "gemini-3.1-pro-low", nil, nil, responseJSON, nil)
- if got := gjson.GetBytes(output, "choices.0.message.reasoning_content").String(); got != "I need to multiply 17 by 24." {
+ if got := ojson.GetBytesNoCopy(output, "choices.0.message.reasoning_content").String(); got != "I need to multiply 17 by 24." {
t.Fatalf("reasoning_content = %q, want thought text. Output: %s", got, output)
}
- if got := gjson.GetBytes(output, "choices.0.message.content").String(); got != "408" {
+ if got := ojson.GetBytesNoCopy(output, "choices.0.message.content").String(); got != "408" {
t.Fatalf("content = %q, want final answer. Output: %s", got, output)
}
- if got := gjson.GetBytes(output, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 {
+ if got := ojson.GetBytesNoCopy(output, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 42 {
t.Fatalf("reasoning_tokens = %d, want 42. Output: %s", got, output)
}
}
@@ -363,10 +363,10 @@ func TestConvertAntigravityResponseToOpenAI_NonStopFinishReasonsMapToContentFilt
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.finish_reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.finish_reason").String(); got != "content_filter" {
t.Fatalf("finish_reason = %q, want content_filter; payload=%s", got, out[0])
}
- if got := gjson.GetBytes(out[0], "choices.0.native_finish_reason").String(); got != tc.wantNative {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.native_finish_reason").String(); got != tc.wantNative {
t.Fatalf("native_finish_reason = %q, want %q; payload=%s", got, tc.wantNative, out[0])
}
})
@@ -383,7 +383,7 @@ func TestConvertAntigravityResponseToOpenAI_OmitToolCallsWhenAbsentInStreamingDe
if len(textResults) != 1 {
t.Fatalf("expected 1 result, got %d", len(textResults))
}
- if tc := gjson.GetBytes(textResults[0], "choices.0.delta.tool_calls"); tc.Exists() {
+ if tc := ojson.GetBytesNoCopy(textResults[0], "choices.0.delta.tool_calls"); tc.Exists() {
t.Fatalf("choices.0.delta.tool_calls must not exist in text-only chunk, got: %s (type: %s)", tc.Raw, tc.Type)
}
@@ -393,7 +393,7 @@ func TestConvertAntigravityResponseToOpenAI_OmitToolCallsWhenAbsentInStreamingDe
if len(thoughtResults) != 1 {
t.Fatalf("expected 1 result, got %d", len(thoughtResults))
}
- if tc := gjson.GetBytes(thoughtResults[0], "choices.0.delta.tool_calls"); tc.Exists() {
+ if tc := ojson.GetBytesNoCopy(thoughtResults[0], "choices.0.delta.tool_calls"); tc.Exists() {
t.Fatalf("choices.0.delta.tool_calls must not exist in reasoning chunk, got: %s (type: %s)", tc.Raw, tc.Type)
}
@@ -403,7 +403,7 @@ func TestConvertAntigravityResponseToOpenAI_OmitToolCallsWhenAbsentInStreamingDe
if len(toolResults) != 1 {
t.Fatalf("expected 1 result, got %d", len(toolResults))
}
- tc := gjson.GetBytes(toolResults[0], "choices.0.delta.tool_calls")
+ tc := ojson.GetBytesNoCopy(toolResults[0], "choices.0.delta.tool_calls")
if !tc.Exists() || !tc.IsArray() || len(tc.Array()) != 1 {
t.Fatalf("choices.0.delta.tool_calls must be an array with 1 call, got: %s", tc.Raw)
}
@@ -420,7 +420,7 @@ func TestConvertAntigravityResponseToOpenAI_OmitToolCallsWhenAbsentInStreamingDe
if len(finalResults) != 1 {
t.Fatalf("expected 1 result, got %d", len(finalResults))
}
- if tcFinal := gjson.GetBytes(finalResults[0], "choices.0.delta.tool_calls"); tcFinal.Exists() {
+ if tcFinal := ojson.GetBytesNoCopy(finalResults[0], "choices.0.delta.tool_calls"); tcFinal.Exists() {
t.Fatalf("choices.0.delta.tool_calls must not exist in final chunk, got: %s (type: %s)", tcFinal.Raw, tcFinal.Type)
}
@@ -429,7 +429,7 @@ func TestConvertAntigravityResponseToOpenAI_OmitToolCallsWhenAbsentInStreamingDe
ConvertAntigravityResponseToOpenAI(ctx, "gemini-3.5-flash-lite", nil, nil, textChunk, &doneParam)
doneResults := ConvertAntigravityResponseToOpenAI(ctx, "gemini-3.5-flash-lite", nil, nil, []byte("[DONE]"), &doneParam)
if len(doneResults) == 1 {
- if tcDone := gjson.GetBytes(doneResults[0], "choices.0.delta.tool_calls"); tcDone.Exists() {
+ if tcDone := ojson.GetBytesNoCopy(doneResults[0], "choices.0.delta.tool_calls"); tcDone.Exists() {
t.Fatalf("choices.0.delta.tool_calls must not exist in synthesized DONE chunk, got: %s (type: %s)", tcDone.Raw, tcDone.Type)
}
}
diff --git a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_user_turn_test.go b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_user_turn_test.go
index 5ad91377bb2..fc94987db63 100644
--- a/internal/translator/antigravity/openai/chat-completions/antigravity_openai_user_turn_test.go
+++ b/internal/translator/antigravity/openai/chat-completions/antigravity_openai_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -48,7 +48,7 @@ func TestConvertOpenAIRequestToAntigravity_RefusesAnyEmptiedUserTurn(t *testing.
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -61,7 +61,7 @@ func TestConvertOpenAIRequestToAntigravity_KeepsTurnWithTextBesideAttachment(t *
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "request.contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "request.contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -72,7 +72,7 @@ func TestConvertOpenAIRequestToAntigravity_InlineFileAfterHistoryStaysInlineData
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "request.contents.2.parts.0.inlineData.mimeType").String(); got != "application/pdf" {
+ if got := ojson.GetBytesNoCopy(body, "request.contents.2.parts.0.inlineData.mimeType").String(); got != "application/pdf" {
t.Fatalf("inline file was not kept: %s", body)
}
}
@@ -92,7 +92,7 @@ func TestOpenAIToAntigravityRegistryCarriesTheRefusal(t *testing.T) {
func TestConvertOpenAIRequestToAntigravity_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","messages":[{"role":"user","content":[` + userTurnFileID + `]}]}`)
- if body, _ := ConvertOpenAIRequestToAntigravity("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIRequestToAntigravity("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
index 32963c8e0fb..ba3a8d95f4a 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request.go
@@ -5,16 +5,16 @@ import (
"encoding/json"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/gemini"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/openai/responses"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const antigravityWebSearchSystemInstruction = "You are a search engine bot. You will be given a query from a user. Your task is to search the web for relevant information that will help the user. You MUST perform a web search. Do not respond or interact with the user, please respond as if they typed the query into a search bar."
@@ -45,36 +45,37 @@ func antigravitySupportsNativeResponsesWebSearch(model string, modelInfo *regist
}
func shouldBuildAntigravityResponsesWebSearchRequest(model string, payload []byte, modelInfo *registry.ModelInfo) bool {
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
return HasOnlyResponsesWebSearchTools(root) &&
antigravitySupportsNativeResponsesWebSearch(model, modelInfo) &&
AllowsResponsesWebSearchToolChoice(root)
}
func buildAntigravityResponsesWebSearchRequest(model string, payload []byte, stream bool) ([]byte, error) {
- includedDomains := ExtractResponsesWebSearchAllowedDomains(gjson.ParseBytes(payload))
+ includedDomains := ExtractResponsesWebSearchAllowedDomains(util.ParseGJSONBytesNoCopy(payload))
rawJSON, errConvert := ConvertOpenAIResponsesRequestToGemini(model, payload, stream)
rawJSON = stripAntigravityServerSideToolInvocations(rawJSON)
rawJSON = rewriteOpenAIResponsesReasoningForAntigravityClaude(model, payload, rawJSON)
out, _ := ConvertGeminiRequestToAntigravity(model, rawJSON, stream)
- out, _ = sjson.SetBytes(out, "requestType", "web_search")
- out = ensureAntigravityResponsesWebSearchTool(out, includedDomains)
- out = ensureAntigravityResponsesWebSearchSystemInstruction(out)
- return enableAntigravityResponsesThinkingSummary(payload, out), errConvert
+ outDoc := ojson.NewDoc(out)
+ _ = outDoc.Set("requestType", "web_search")
+ ensureAntigravityResponsesWebSearchToolDoc(outDoc, out, includedDomains)
+ ensureAntigravityResponsesWebSearchSystemInstructionDoc(outDoc, out)
+ return enableAntigravityResponsesThinkingSummary(payload, outDoc.Bytes()), errConvert
}
-func ensureAntigravityResponsesWebSearchTool(payload []byte, includedDomains []string) []byte {
+func ensureAntigravityResponsesWebSearchToolDoc(doc *ojson.Doc, payload []byte, includedDomains []string) {
googleSearchTool := []byte(`{"googleSearch":{"enhancedContent":{"imageSearch":{"maxResultCount":5}}}}`)
if len(includedDomains) > 0 {
if domainsJSON, errMarshal := json.Marshal(includedDomains); errMarshal == nil {
- googleSearchTool, _ = sjson.SetRawBytes(googleSearchTool, "googleSearch.includedDomains", domainsJSON)
+ googleSearchTool, _ = ojson.SetRawBytes(googleSearchTool, "googleSearch.includedDomains", domainsJSON)
}
}
- tools := gjson.GetBytes(payload, "request.tools")
+ tools := ojson.GetBytesNoCopy(payload, "request.tools")
if !tools.IsArray() {
- payload, _ = sjson.SetRawBytes(payload, "request.tools", translatorcommon.JoinRawArray([][]byte{googleSearchTool}))
- return payload
+ _ = doc.SetRawBytes("request.tools", translatorcommon.JoinRawArray([][]byte{googleSearchTool}))
+ return
}
replaced := false
@@ -92,20 +93,19 @@ func ensureAntigravityResponsesWebSearchTool(payload []byte, includedDomains []s
if !replaced {
filtered = append([][]byte{googleSearchTool}, filtered...)
}
- payload, _ = sjson.SetRawBytes(payload, "request.tools", translatorcommon.JoinRawArray(filtered))
- return payload
+ _ = doc.SetRawBytes("request.tools", translatorcommon.JoinRawArray(filtered))
}
-func ensureAntigravityResponsesWebSearchSystemInstruction(payload []byte) []byte {
+func ensureAntigravityResponsesWebSearchSystemInstructionDoc(doc *ojson.Doc, payload []byte) {
searchPart := []byte(`{"text":""}`)
- searchPart, _ = sjson.SetBytes(searchPart, "text", antigravityWebSearchSystemInstruction)
+ searchPart, _ = ojson.SetBytes(searchPart, "text", antigravityWebSearchSystemInstruction)
- sys := gjson.GetBytes(payload, "request.systemInstruction")
+ sys := ojson.GetBytesNoCopy(payload, "request.systemInstruction")
if !sys.Exists() {
instr := []byte(`{"role":"user","parts":[]}`)
- instr, _ = sjson.SetRawBytes(instr, "parts", translatorcommon.JoinRawArray([][]byte{searchPart}))
- payload, _ = sjson.SetRawBytes(payload, "request.systemInstruction", instr)
- return payload
+ instr, _ = ojson.SetRawBytes(instr, "parts", translatorcommon.JoinRawArray([][]byte{searchPart}))
+ _ = doc.SetRawBytes("request.systemInstruction", instr)
+ return
}
var parts [][]byte
@@ -121,8 +121,7 @@ func ensureAntigravityResponsesWebSearchSystemInstruction(payload []byte) []byte
if !alreadyPresent {
parts = append(parts, searchPart)
}
- payload, _ = sjson.SetRawBytes(payload, "request.systemInstruction.parts", translatorcommon.JoinRawArray(parts))
- return payload
+ _ = doc.SetRawBytes("request.systemInstruction.parts", translatorcommon.JoinRawArray(parts))
}
// ConvertOpenAIResponsesRequestToAntigravity translates an OpenAI Responses request
@@ -158,8 +157,9 @@ func ConvertOpenAIResponsesRequestEnvelopeToAntigravity(_ context.Context, req s
// Antigravity only supports native search in dedicated web_search request envelopes
// and rejects requests with mixed googleSearch and function declarations.
func stripAntigravityResponsesGoogleSearch(payload []byte) []byte {
+ var doc *ojson.Doc
for _, path := range []string{"tools", "request.tools"} {
- tools := gjson.GetBytes(payload, path)
+ tools := ojson.GetBytesNoCopy(payload, path)
if !tools.IsArray() {
continue
}
@@ -173,13 +173,19 @@ func stripAntigravityResponsesGoogleSearch(payload []byte) []byte {
filtered = append(filtered, []byte(tool.Raw))
}
if hasGoogleSearch {
+ if doc == nil {
+ doc = ojson.NewDoc(payload)
+ }
if len(filtered) == 0 {
- payload, _ = sjson.DeleteBytes(payload, path)
+ _ = doc.Delete(path)
} else {
- payload, _ = sjson.SetRawBytes(payload, path, translatorcommon.JoinRawArray(filtered))
+ _ = doc.SetRawBytes(path, translatorcommon.JoinRawArray(filtered))
}
}
}
+ if doc != nil {
+ payload = doc.Bytes()
+ }
return stripAntigravityServerSideToolInvocations(payload)
}
@@ -187,16 +193,23 @@ func stripAntigravityResponsesGoogleSearch(payload []byte) []byte {
// injected by the shared Gemini Responses translator because Antigravity does not
// support the includeServerSideToolInvocations parameter.
func stripAntigravityServerSideToolInvocations(payload []byte) []byte {
+ var doc *ojson.Doc
for _, toolConfigPath := range []string{"toolConfig", "request.toolConfig"} {
flagPath := toolConfigPath + ".includeServerSideToolInvocations"
- if !gjson.GetBytes(payload, flagPath).Exists() {
+ if !ojson.GetBytesNoCopy(payload, flagPath).Exists() {
continue
}
- payload, _ = sjson.DeleteBytes(payload, flagPath)
- if tc := gjson.GetBytes(payload, toolConfigPath); tc.IsObject() && len(tc.Map()) == 0 {
- payload, _ = sjson.DeleteBytes(payload, toolConfigPath)
+ if doc == nil {
+ doc = ojson.NewDoc(payload)
+ }
+ _ = doc.Delete(flagPath)
+ if tc := doc.Get(toolConfigPath); tc.IsObject() && len(tc.Map()) == 0 {
+ _ = doc.Delete(toolConfigPath)
}
}
+ if doc != nil {
+ return doc.Bytes()
+ }
return payload
}
@@ -204,8 +217,8 @@ func stripAntigravityServerSideToolInvocations(payload []byte) []byte {
// Antigravity needs includeThoughts to emit thought parts, so effort alone would
// otherwise spend thinking tokens without returning a visible summary.
func enableAntigravityResponsesThinkingSummary(inputRawJSON, translated []byte) []byte {
- effort := gjson.GetBytes(inputRawJSON, "reasoning.effort")
- if effort.Type != gjson.String {
+ effort := ojson.GetBytesNoCopy(inputRawJSON, "reasoning.effort")
+ if effort.Type != ojson.String {
return translated
}
effortVal := strings.ToLower(strings.TrimSpace(effort.String()))
@@ -323,13 +336,13 @@ func rewriteOpenAIResponsesReasoningForAntigravityClaude(modelName string, input
}
func antigravityClaudeReasoningSignatures(inputRawJSON []byte) []antigravityClaudeReasoningSignature {
- input := gjson.GetBytes(inputRawJSON, "input")
+ input := ojson.GetBytesNoCopy(inputRawJSON, "input")
if !input.IsArray() {
return nil
}
signatures := make([]antigravityClaudeReasoningSignature, 0)
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
itemType := item.Get("type").String()
if itemType == "" && item.Get("role").Exists() {
itemType = "message"
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go
index 9a10981fd67..52a2c01df02 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go
@@ -2,14 +2,15 @@ package responses
import (
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -57,7 +58,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningKeepsClaudeSi
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
- part := gjson.GetBytes(out, "request.contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0")
if !part.Get("thought").Bool() {
t.Fatalf("first part should remain a thought block. Output: %s", out)
}
@@ -97,13 +98,13 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsIncompat
if strings.Contains(string(out), sigcompat.GeminiSkipThoughtSignatureValidator) {
t.Fatalf("Claude target must not receive Gemini bypass signature. Output: %s", out)
}
- if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ if ojson.GetBytesNoCopy(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
t.Fatalf("incompatible reasoning block should be dropped. Output: %s", out)
}
if strings.Contains(string(out), "must not reach Claude") {
t.Fatalf("incompatible reasoning text should be dropped. Output: %s", out)
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
}
}
@@ -131,10 +132,10 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThi
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
- if gjson.GetBytes(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
+ if ojson.GetBytesNoCopy(out, `request.contents.#.parts.#(thought=true)#`).Int() != 0 {
t.Fatalf("empty-text reasoning block should be dropped for Antigravity Claude. Output: %s", out)
}
- if got := gjson.GetBytes(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
+ if got := ojson.GetBytesNoCopy(out, "request.contents.0.parts.0.text").String(); got != "visible answer" {
t.Fatalf("visible assistant text = %q, want visible answer. Output: %s", got, out)
}
}
@@ -200,8 +201,8 @@ func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningDoesNotS
]
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
- var thoughts []gjson.Result
- for _, content := range gjson.GetBytes(out, "request.contents").Array() {
+ var thoughts []ojson.Result
+ for _, content := range ojson.GetBytesNoCopy(out, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("thought").Bool() {
thoughts = append(thoughts, part)
@@ -237,8 +238,8 @@ func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningBeforeFu
]
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false)
- var thoughts []gjson.Result
- for _, content := range gjson.GetBytes(out, "request.contents").Array() {
+ var thoughts []ojson.Result
+ for _, content := range ojson.GetBytesNoCopy(out, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("thought").Bool() {
thoughts = append(thoughts, part)
@@ -257,7 +258,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeTho
sig := "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
raw := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"reasoning","encrypted_content":"gemini#` + sig + `","summary":[{"type":"summary_text","text":"reasoning summary"}]}]}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash-agent", raw, false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), out)
}
@@ -286,7 +287,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_PreservesToolResultImage(t *
]
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d. Output: %s", len(contents), out)
}
@@ -342,7 +343,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_AttachesParallelToolImagesTo
]
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(out, "request.contents.2.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.2.parts").Array()
if len(parts) != 2 {
t.Fatalf("function parts = %d, want 2. Output: %s", len(parts), out)
}
@@ -386,20 +387,20 @@ func TestConvertOpenAIResponsesRequestToAntigravity_PreservesAdditionalToolsAndT
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("invalid JSON output: %s", out)
}
- decls := gjson.GetBytes(out, "request.tools.0.functionDeclarations").Array()
+ decls := ojson.GetBytesNoCopy(out, "request.tools.0.functionDeclarations").Array()
if len(decls) != 2 {
t.Fatalf("expected 2 functionDeclarations in request.tools, got %d; raw: %s", len(decls), out)
}
- mode := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.mode").String()
if mode != "ANY" {
t.Fatalf("mode = %q, want ANY", mode)
}
- allowed := gjson.GetBytes(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
+ allowed := ojson.GetBytesNoCopy(out, "request.toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
if allowed != "functions__continuity_probe" {
t.Fatalf("allowedFunctionNames.0 = %q, want functions__continuity_probe", allowed)
}
@@ -440,12 +441,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_MidSessionDeveloperMessageDo
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("invalid JSON output: %s", out)
}
// In Antigravity envelope, systemInstruction is at request.systemInstruction
- sysParts := gjson.GetBytes(out, "request.systemInstruction.parts").Array()
+ sysParts := ojson.GetBytesNoCopy(out, "request.systemInstruction.parts").Array()
if len(sysParts) != 1 {
t.Fatalf("request.systemInstruction parts count = %d, want 1; output=%s", len(sysParts), out)
}
@@ -453,7 +454,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_MidSessionDeveloperMessageDo
t.Fatalf("systemInstruction part = %q, want %q; output=%s", got, "Be a helpful assistant", out)
}
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("request.contents count = %d, want 3; output=%s", len(contents), out)
}
@@ -501,7 +502,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_MidSessionSystemReminderEnve
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(out)
+ result := util.ParseGJSONBytesNoCopy(out)
contents := result.Get("request.contents").Array()
if len(contents) != 3 {
@@ -545,11 +546,11 @@ func TestConvertOpenAIResponsesRequestToAntigravity_InterveningDeveloperMessageP
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("invalid JSON output: %s", out)
}
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out)
}
@@ -617,7 +618,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ReasoningSummaries(t *testin
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(tc.inputJSON), false)
- res := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts")
+ res := ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.includeThoughts")
if res.Exists() != tc.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v; out=%s", res.Exists(), tc.wantExists, out)
}
@@ -639,12 +640,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_FunctionCallOutputAlternateI
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.7-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out)
}
- responseID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String()
+ responseID := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse.id").String()
if responseID != "call_bash_1" {
t.Fatalf("functionResponse.id = %q, want call_bash_1", responseID)
}
@@ -670,16 +671,16 @@ func TestConvertOpenAIResponsesRequestToAntigravity_InterruptedFunctionCallPrese
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out)
}
- c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(out, "request.contents.5.parts.0.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(out, "request.contents.5.parts.0.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
}
@@ -707,16 +708,16 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ParallelInterruptedFunctionC
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity request: %v; output=%s", errPair, out)
}
- c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
}
@@ -741,16 +742,16 @@ func TestConvertOpenAIResponsesRequestToAntigravity_TrailingPartialParallelCalls
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity trailing partial parallel request: %v; output=%s", errPair, out)
}
- c1Resp := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(out, "request.contents.2.parts.1.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
}
@@ -776,21 +777,21 @@ func TestConvertOpenAIResponsesRequestToAntigravity_InterruptedMessageBeforeReal
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.8-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity interrupted message before real output request: %v; output=%s", errPair, out)
}
// The user message precedes the completed tool response turn
- stopText := gjson.GetBytes(out, "request.contents.2.parts.0.text").String()
+ stopText := ojson.GetBytesNoCopy(out, "request.contents.2.parts.0.text").String()
if stopText != "Stop, do something else instead." {
t.Fatalf("unexpected text in request.contents[2]: %q", stopText)
}
- c1Resp := gjson.GetBytes(out, "request.contents.3.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(out, "request.contents.3.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(out, "request.contents.3.parts.1.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(out, "request.contents.3.parts.1.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
}
@@ -814,12 +815,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_FunctionCallOutputWithFCOIte
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.7-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity fco item ID request: %v; output=%s", errPair, out)
}
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d; output=%s", len(contents), string(out))
}
@@ -856,7 +857,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_OrphanFunctionCallOutputBeco
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.7-flash-high", []byte(inputJSON), false)
- rawRequest := gjson.GetBytes(out, "request").Raw
+ rawRequest := ojson.GetBytesNoCopy(out, "request").Raw
if errPair := sigcompat.ValidateGeminiFunctionCallPairing([]byte(rawRequest)); errPair != nil {
t.Fatalf("ValidateGeminiFunctionCallPairing failed on Antigravity orphan output request: %v; output=%s", errPair, out)
}
@@ -864,7 +865,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_OrphanFunctionCallOutputBeco
delegationFound := false
bashCallID := ""
bashResponseID := ""
- for _, content := range gjson.GetBytes(out, "request.contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(out, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if fr := part.Get("functionResponse"); fr.Exists() {
if fr.Get("id").String() == "" {
@@ -918,7 +919,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_WebSearch(t *testing.T) {
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(capableModel, input, false)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if parsed.Get("requestType").String() != "web_search" {
t.Fatalf("expected requestType web_search, got %q. Output: %s", parsed.Get("requestType").String(), out)
@@ -941,7 +942,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_WebSearch(t *testing.T) {
"tools": [{"type": "web_search"}]
}`)
incapableOut, _ := ConvertOpenAIResponsesRequestToAntigravity(incapableModel, incapableInput, false)
- if gjson.GetBytes(incapableOut, "requestType").String() == "web_search" {
+ if ojson.GetBytesNoCopy(incapableOut, "requestType").String() == "web_search" {
t.Fatalf("incapable model should not build web_search requestType envelope, got: %s", incapableOut)
}
}
@@ -967,7 +968,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_MixedToolsSuppressesGoogleSe
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
// 1. Must not build independent web_search requestType envelope
if parsed.Get("requestType").String() == "web_search" {
@@ -1013,7 +1014,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_MixedToolsSuppressesGoogleSe
"tool_choice": "auto"
}`)
outWithAuto, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, inputWithAutoChoice, false)
- parsedAuto := gjson.ParseBytes(outWithAuto)
+ parsedAuto := util.ParseGJSONBytesNoCopy(outWithAuto)
if parsedAuto.Get("request.toolConfig.includeServerSideToolInvocations").Exists() || parsedAuto.Get("toolConfig.includeServerSideToolInvocations").Exists() {
t.Fatalf("Antigravity must not carry includeServerSideToolInvocations with tool_choice=auto: %s", outWithAuto)
}
@@ -1033,10 +1034,10 @@ func TestConvertOpenAIResponsesRequestToAntigravity_MixedToolsSuppressesGoogleSe
if errWebSearchAdditional != nil {
t.Fatalf("convert error: %v", errWebSearchAdditional)
}
- if !gjson.ValidBytes(outWebSearchAdditional) {
+ if !ojson.ValidBytes(outWebSearchAdditional) {
t.Fatalf("expected valid JSON output, got: %s", outWebSearchAdditional)
}
- parsedWebSearchAdditional := gjson.ParseBytes(outWebSearchAdditional)
+ parsedWebSearchAdditional := util.ParseGJSONBytesNoCopy(outWebSearchAdditional)
if got := parsedWebSearchAdditional.Get("requestType").String(); got != "web_search" {
t.Fatalf("requestType = %q, want web_search: %s", got, outWebSearchAdditional)
}
@@ -1078,7 +1079,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_CrossProviderCapabilityIsola
}
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- if gjson.GetBytes(out, "requestType").String() == "web_search" {
+ if ojson.GetBytesNoCopy(out, "requestType").String() == "web_search" {
t.Fatalf("ConvertOpenAIResponsesRequestToAntigravity should not build web_search requestType envelope when Antigravity route lacks capability: %s", out)
}
}
@@ -1099,7 +1100,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_DoesNotBorrowNativeCapabilit
"tools": [{"type": "web_search"}]
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- if gjson.GetBytes(out, "requestType").String() == "web_search" {
+ if ojson.GetBytesNoCopy(out, "requestType").String() == "web_search" {
t.Fatalf("borrowed Gemini capability for Antigravity route: %s", out)
}
}
@@ -1146,10 +1147,10 @@ func TestConvertOpenAIResponsesRequestToAntigravity_LocalWebSearchCapability(t *
Model: modelID + "(high)", Body: input, Stream: true, ModelInfo: unknownInfo,
}).Body,
} {
- if got := gjson.GetBytes(out, "requestType").String() == "web_search"; got != tc.wantSearch {
+ if got := ojson.GetBytesNoCopy(out, "requestType").String() == "web_search"; got != tc.wantSearch {
t.Fatalf("%s: web_search = %v, want %v; output=%s", name, got, tc.wantSearch, out)
}
- if got := gjson.GetBytes(out, "request.tools.0.googleSearch").Exists(); got != tc.wantSearch {
+ if got := ojson.GetBytesNoCopy(out, "request.tools.0.googleSearch").Exists(); got != tc.wantSearch {
t.Fatalf("%s: googleSearch present = %v, want %v; output=%s", name, got, tc.wantSearch, out)
}
}
@@ -1174,10 +1175,10 @@ func TestConvertOpenAIResponsesRequestToAntigravity_IgnoresOtherProviderSearchVe
input := []byte(`{"model":"` + modelID + `","input":"Search weather","tools":[{"type":"web_search"}]}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- if gjson.GetBytes(out, "requestType").String() != "web_search" {
+ if ojson.GetBytesNoCopy(out, "requestType").String() != "web_search" {
t.Fatalf("another provider disabled Antigravity search: %s", out)
}
- if !gjson.GetBytes(out, "request.tools.0.googleSearch").Exists() {
+ if !ojson.GetBytesNoCopy(out, "request.tools.0.googleSearch").Exists() {
t.Fatalf("missing Antigravity googleSearch tool: %s", out)
}
}
@@ -1218,7 +1219,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_WebSearchPreservesMultiTurnC
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if parsed.Get("requestType").String() != "web_search" {
t.Fatalf("expected requestType web_search, got %q. Output: %s", parsed.Get("requestType").String(), out)
}
@@ -1261,7 +1262,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_WebSearchPreservesInstructio
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if parsed.Get("requestType").String() != "web_search" {
t.Fatalf("expected requestType web_search, got %q. Output: %s", parsed.Get("requestType").String(), out)
}
@@ -1307,7 +1308,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_WebSearchToolChoiceAutoPrese
}`)
out, _ := ConvertOpenAIResponsesRequestToAntigravity(modelID, input, false)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if parsed.Get("requestType").String() != "web_search" {
t.Fatalf("expected requestType web_search with tool_choice auto, got %q. Output: %s", parsed.Get("requestType").String(), out)
}
@@ -1369,10 +1370,10 @@ func TestConvertOpenAIResponsesRequestEnvelopeToAntigravity(t *testing.T) {
out := ConvertOpenAIResponsesRequestEnvelopeToAntigravity(context.Background(), sdktranslator.RequestEnvelope{
Model: modelID, Body: input, ModelInfo: info,
})
- if got := gjson.GetBytes(out.Body, "requestType").String() == "web_search"; got != tc.enabled {
+ if got := ojson.GetBytesNoCopy(out.Body, "requestType").String() == "web_search"; got != tc.enabled {
t.Fatalf("web_search = %v, want %v; output=%s", got, tc.enabled, out.Body)
}
- if got := gjson.GetBytes(out.Body, "request.tools.0.googleSearch").Exists(); got != tc.enabled {
+ if got := ojson.GetBytesNoCopy(out.Body, "request.tools.0.googleSearch").Exists(); got != tc.enabled {
t.Fatalf("googleSearch present = %v, want %v; output=%s", got, tc.enabled, out.Body)
}
})
@@ -1398,7 +1399,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_AudioAndVideoInput(t *testin
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) == 0 {
t.Fatalf("expected at least 1 content, got 0. Output: %s", out)
}
@@ -1475,7 +1476,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_InvalidDataURLsRejected(t *t
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
for _, content := range contents {
for _, part := range content.Get("parts").Array() {
if part.Get("inline_data").Exists() || part.Get("inlineData").Exists() {
@@ -1504,12 +1505,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_FallbackMIME(t *testing.T) {
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) < 6 {
t.Fatalf("expected at least 6 parts, got %d. Output: %s", len(parts), out)
}
- getMime := func(part gjson.Result) string {
+ getMime := func(part ojson.Result) string {
if inline := part.Get("inline_data"); inline.Exists() {
if m := inline.Get("mime_type").String(); m != "" {
return m
@@ -1563,7 +1564,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_RemoteAudioAndVideo(t *testi
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) == 0 {
t.Fatalf("expected at least 1 content, got 0. Output: %s", out)
}
@@ -1639,12 +1640,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_InlineExplicitFormatNotOverr
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) < 2 {
t.Fatalf("expected at least 2 parts, got %d. Output: %s", len(parts), out)
}
- getMime := func(part gjson.Result) string {
+ getMime := func(part ojson.Result) string {
if inline := part.Get("inline_data"); inline.Exists() {
if m := inline.Get("mime_type").String(); m != "" {
return m
@@ -1686,12 +1687,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_RemoteNestedFormatNotOverrid
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) < 5 {
t.Fatalf("expected at least 5 parts, got %d. Output: %s", len(parts), out)
}
- getFileMime := func(part gjson.Result) string {
+ getFileMime := func(part ojson.Result) string {
if fileData := part.Get("file_data"); fileData.Exists() {
if m := fileData.Get("mime_type").String(); m != "" {
return m
@@ -1740,12 +1741,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_GenericMIMEWithNestedFormatN
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) < 3 {
t.Fatalf("expected at least 3 parts, got %d. Output: %s", len(parts), out)
}
- getFileMime := func(part gjson.Result) string {
+ getFileMime := func(part ojson.Result) string {
if fileData := part.Get("file_data"); fileData.Exists() {
if m := fileData.Get("mime_type").String(); m != "" {
return m
@@ -1787,12 +1788,12 @@ func TestConvertOpenAIResponsesRequestToAntigravity_UnknownExtensionFallsBackToD
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(out, "request.contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(out, "request.contents.0.parts").Array()
if len(parts) < 2 {
t.Fatalf("expected at least 2 parts, got %d. Output: %s", len(parts), out)
}
- getFileMime := func(part gjson.Result) string {
+ getFileMime := func(part ojson.Result) string {
if fileData := part.Get("file_data"); fileData.Exists() {
if m := fileData.Get("mime_type").String(); m != "" {
return m
@@ -1827,7 +1828,7 @@ func TestConvertOpenAIResponsesRequestToAntigravity_TopLevelMedia(t *testing.T)
}`
out, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(out, "request.contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "request.contents").Array()
if len(contents) == 0 {
t.Fatalf("expected at least 1 content, got 0. Output: %s", out)
}
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go
index f475e853e46..6559d337e0a 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response.go
@@ -3,12 +3,12 @@ package responses
import (
"context"
+ "github.com/MoeForge/moego/ojson"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/openai/responses"
- "github.com/tidwall/gjson"
)
func ConvertAntigravityResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
- responseResult := gjson.GetBytes(rawJSON, "response")
+ responseResult := ojson.GetBytesNoCopy(rawJSON, "response")
if responseResult.Exists() {
rawJSON = []byte(responseResult.Raw)
}
@@ -16,17 +16,17 @@ func ConvertAntigravityResponseToOpenAIResponses(ctx context.Context, modelName
}
func ConvertAntigravityResponseToOpenAIResponsesNonStream(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
- responseResult := gjson.GetBytes(rawJSON, "response")
+ responseResult := ojson.GetBytesNoCopy(rawJSON, "response")
if responseResult.Exists() {
rawJSON = []byte(responseResult.Raw)
}
- requestResult := gjson.GetBytes(originalRequestRawJSON, "request")
+ requestResult := ojson.GetBytesNoCopy(originalRequestRawJSON, "request")
if requestResult.Exists() {
originalRequestRawJSON = []byte(requestResult.Raw)
}
- requestResult = gjson.GetBytes(requestRawJSON, "request")
+ requestResult = ojson.GetBytesNoCopy(requestRawJSON, "request")
if requestResult.Exists() {
requestRawJSON = []byte(requestResult.Raw)
}
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go
index 8cb4ef87ffe..71755c53dd3 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_response_test.go
@@ -5,7 +5,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertAntigravityResponseToOpenAIResponsesNonStream_PreservesOpenAITools(t *testing.T) {
@@ -66,23 +67,23 @@ func TestConvertAntigravityResponseToOpenAIResponsesNonStream_PreservesOpenAIToo
nil,
)
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
t.Fatalf("converter returned invalid JSON: %s", output)
}
- if got := gjson.GetBytes(output, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(output, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function; output=%s", got, output)
}
- if gjson.GetBytes(output, "tools.0.functionDeclarations").Exists() {
+ if ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations").Exists() {
t.Fatalf("OpenAI response contains Gemini-native functionDeclarations: %s", output)
}
- if got := gjson.GetBytes(output, "output.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(output, "output.0.type").String(); got != "function_call" {
t.Fatalf("output.0.type = %q, want function_call; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "output.0.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(output, "output.0.name").String(); got != "get_weather" {
t.Fatalf("output.0.name = %q, want get_weather; output=%s", got, output)
}
- arguments := gjson.GetBytes(output, "output.0.arguments").String()
- if !gjson.Valid(arguments) || gjson.Get(arguments, "city").String() != "Tokyo" {
+ arguments := ojson.GetBytesNoCopy(output, "output.0.arguments").String()
+ if !ojson.Valid(arguments) || ojson.Get(arguments, "city").String() != "Tokyo" {
t.Fatalf("output.0.arguments = %q, want JSON arguments with city Tokyo; output=%s", arguments, output)
}
}
@@ -125,19 +126,19 @@ func TestConvertAntigravityResponseToOpenAIResponses_RestoresAdditionalNamespace
nil,
)
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
t.Fatalf("invalid JSON output: %s", output)
}
- if got := gjson.GetBytes(output, "output.0.type").String(); got != "custom_tool_call" {
+ if got := ojson.GetBytesNoCopy(output, "output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("output.0.type = %q, want custom_tool_call; output=%s", got, output)
}
- if got := gjson.GetBytes(output, "output.0.name").String(); got != "exec" {
+ if got := ojson.GetBytesNoCopy(output, "output.0.name").String(); got != "exec" {
t.Fatalf("output.0.name = %q, want exec", got)
}
- if got := gjson.GetBytes(output, "output.0.namespace").String(); got != "functions" {
+ if got := ojson.GetBytesNoCopy(output, "output.0.namespace").String(); got != "functions" {
t.Fatalf("output.0.namespace = %q, want functions", got)
}
- if got := gjson.GetBytes(output, "output.0.input").String(); got != "pwd" {
+ if got := ojson.GetBytesNoCopy(output, "output.0.input").String(); got != "pwd" {
t.Fatalf("output.0.input = %q, want pwd", got)
}
}
@@ -187,7 +188,7 @@ func TestConvertAntigravityResponseToOpenAIResponsesNonStream_WebSearch(t *testi
nil,
)
- parsed := gjson.ParseBytes(output)
+ parsed := util.ParseGJSONBytesNoCopy(output)
outputs := parsed.Get("output").Array()
if len(outputs) != 2 {
t.Fatalf("expected 2 outputs, got %d: %s", len(outputs), output)
@@ -227,14 +228,14 @@ func TestConvertAntigravityResponseToOpenAIResponsesNonStream_WebSearch(t *testi
func TestAntigravityApplyPatchReuse(t *testing.T) {
request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}],"input":"patch"}`)
translated, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3.1-pro-preview", request, false)
- declaration := gjson.GetBytes(translated, "request.tools.0.functionDeclarations.0")
+ declaration := ojson.GetBytesNoCopy(translated, "request.tools.0.functionDeclarations.0")
if declaration.Get("name").String() != "functions__apply_patch" || !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || declaration.Get("parametersJsonSchema.properties.input.type").String() != "string" || !declaration.Get("parametersJsonSchema.additionalProperties").Exists() || declaration.Get("parametersJsonSchema.additionalProperties").Bool() {
t.Fatalf("missing declaration: %s", translated)
}
raw := []byte(`{"response":{"responseId":"patch","candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":{"input":" *** Begin Patch\n*** End Patch\n "}}}]},"finishReason":"STOP"}]}}`)
want := " *** Begin Patch\n*** End Patch\n "
nonStream := ConvertAntigravityResponseToOpenAIResponsesNonStream(context.Background(), "gemini", request, translated, raw, nil)
- if gjson.GetBytes(nonStream, "output.0.input").String() != want || gjson.GetBytes(nonStream, "output.0.namespace").String() != "functions" {
+ if ojson.GetBytesNoCopy(nonStream, "output.0.input").String() != want || ojson.GetBytesNoCopy(nonStream, "output.0.namespace").String() != "functions" {
t.Fatalf("wrong input: %s", nonStream)
}
var param any
@@ -247,7 +248,7 @@ func TestAntigravityApplyPatchReuse(t *testing.T) {
if !strings.HasPrefix(line, "data:") {
continue
}
- ev := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ ev := ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
switch ev.Get("type").String() {
case "response.custom_tool_call_input.delta":
delta.WriteString(ev.Get("delta").String())
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go
index c698e95ef16..f08aa3ab7c9 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_tool_search_test.go
@@ -5,7 +5,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertOpenAIResponsesRequestToAntigravity_ExposesClientToolSearch(t *testing.T) {
@@ -26,10 +27,10 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ExposesClientToolSearch(t *t
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
names := make([]string, 0)
- root.Get("request.tools.0.functionDeclarations").ForEach(func(_, declaration gjson.Result) bool {
+ root.Get("request.tools.0.functionDeclarations").ForEach(func(_, declaration ojson.Result) bool {
names = append(names, declaration.Get("name").String())
return true
})
@@ -53,12 +54,12 @@ func TestConvertAntigravityResponseToOpenAIResponses_ToolSearchCall(t *testing.T
chunk := []byte(`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"fixture"}}}]},"finishReason":"STOP"}],"responseId":"resp_ag_tool_search"}}`)
var param any
- var added, done, completed gjson.Result
+ var added, done, completed ojson.Result
for _, frame := range [][]byte{chunk, []byte("data: [DONE]")} {
for _, output := range ConvertAntigravityResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", originalRequest, nil, frame, ¶m) {
payload := string(output)
if idx := strings.Index(payload, "data:"); idx >= 0 {
- data := gjson.Parse(payload[idx+len("data:"):])
+ data := ojson.Parse(payload[idx+len("data:"):])
switch data.Get("type").String() {
case "response.output_item.added":
added = data
@@ -89,7 +90,7 @@ func TestConvertAntigravityResponseToOpenAIResponses_ToolSearchCall(t *testing.T
wantItemID := "tsc_" + callID
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
}{
{label: "added", item: added.Get("item")},
{label: "done", item: done.Get("item")},
@@ -102,7 +103,7 @@ func TestConvertAntigravityResponseToOpenAIResponses_ToolSearchCall(t *testing.T
nonStreamRaw := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"fixture"}}}]},"finishReason":"STOP"}],"responseId":"resp_ag_tool_search_nonstream"}}`)
nonStreamOut := ConvertAntigravityResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.8-flash-high", originalRequest, nil, nonStreamRaw, nil)
- nonStreamItem := gjson.GetBytes(nonStreamOut, "output.0")
+ nonStreamItem := ojson.GetBytesNoCopy(nonStreamOut, "output.0")
if got := nonStreamItem.Get("type").String(); got != "tool_search_call" {
t.Fatalf("non-stream item type = %q, want tool_search_call; raw: %s", got, nonStreamOut)
}
diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_user_turn_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_user_turn_test.go
index dcc3495fdef..affa58d350d 100644
--- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_user_turn_test.go
+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_user_turn_test.go
@@ -6,10 +6,10 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -65,7 +65,7 @@ func TestOpenAIResponsesToAntigravityRegistryCarriesTheRefusal(t *testing.T) {
if !errors.As(envelope.Err, &unsupported) || unsupported.Type != "input_file" || unsupported.StatusCode() != 400 {
t.Fatalf("envelope.Err = %v, want unsupported content part: input_file; body = %s", envelope.Err, envelope.Body)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
@@ -85,7 +85,7 @@ func TestOpenAIResponsesToAntigravityKeepsTurnWithTextBesideFileID(t *testing.T)
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v", envelope.Err)
}
- if got := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", envelope.Body)
}
}
@@ -97,14 +97,14 @@ func TestOpenAIResponsesToAntigravityInlineFileStaysInlineData(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v", envelope.Err)
}
- if got := gjson.GetBytes(envelope.Body, "request.contents.2.parts.0.inline_data.mime_type").String(); got != "application/pdf" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "request.contents.2.parts.0.inline_data.mime_type").String(); got != "application/pdf" {
t.Fatalf("inline file was not kept: %s", envelope.Body)
}
}
func TestConvertOpenAIResponsesRequestToAntigravity_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := antigravityTurnPayload("", antigravityUserTurn(antigravityTurnFileID))
- if body, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/claude/gemini/claude_gemini_issue7279_test.go b/internal/translator/claude/gemini/claude_gemini_issue7279_test.go
new file mode 100644
index 00000000000..e3c68e76711
--- /dev/null
+++ b/internal/translator/claude/gemini/claude_gemini_issue7279_test.go
@@ -0,0 +1,188 @@
+package gemini
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
+)
+
+func TestIssue7279GeminiToolResultsUseClaudeUserMessages(t *testing.T) {
+ tests := []struct {
+ name string
+ contents string
+ wantIDs []string
+ }{
+ {
+ name: "model role with parallel responses",
+ contents: `[
+ {"role":"user","parts":[{"text":"read files"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","id":"call-a","args":{}}},{"functionCall":{"name":"read","id":"call-b","args":{}}}]},
+ {"role":"model","parts":[{"functionResponse":{"name":"read","id":"call-a","response":{"result":"one"}}},{"functionResponse":{"name":"read","id":"call-b","response":{"result":"two"}}}]}
+ ]`,
+ wantIDs: []string{"call-a", "call-b"},
+ },
+ {
+ name: "assistant role with snake case response",
+ contents: `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"function_call":{"name":"read","id":"call-a","args":{}}}]},
+ {"role":"assistant","parts":[{"function_response":{"name":"read","id":"call-a","response":{"result":"one"}}}]}
+ ]`,
+ wantIDs: []string{"call-a"},
+ },
+ {
+ name: "call and response in one model turn",
+ contents: `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","id":"call-a","args":{}}},{"functionResponse":{"name":"read","id":"call-a","response":{"result":"one"}}}]}
+ ]`,
+ wantIDs: []string{"call-a"},
+ },
+ {
+ name: "ordinary user tool result",
+ contents: `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","id":"call-a","args":{}}}]},
+ {"role":"user","parts":[{"functionResponse":{"name":"read","id":"call-a","response":{"result":"one"}}}]}
+ ]`,
+ wantIDs: []string{"call-a"},
+ },
+ {
+ name: "model result without explicit tool id",
+ contents: `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","args":{}}}]},
+ {"role":"model","parts":[{"functionResponse":{"name":"read","response":{"result":"one"}}}]}
+ ]`,
+ wantIDs: []string{"toolu_gemini_0000000000000001"},
+ },
+ {
+ name: "missing result role",
+ contents: `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","id":"call-a","args":{}}}]},
+ {"parts":[{"functionResponse":{"name":"read","id":"call-a","response":{"result":"one"}}}]}
+ ]`,
+ wantIDs: []string{"call-a"},
+ },
+ }
+
+ for _, tt := range tests {
+ for _, stream := range []bool{false, true} {
+ t.Run(tt.name+map[bool]string{false: "/nonstream", true: "/stream"}[stream], func(t *testing.T) {
+ out, errConvert := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":`+tt.contents+`}`), stream)
+ if errConvert != nil {
+ t.Fatalf("conversion failed: %v", errConvert)
+ }
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
+ if len(messages) != 3 {
+ t.Fatalf("messages = %s; want user, assistant tool calls, user tool results", ojson.GetBytesNoCopy(out, "messages").Raw)
+ }
+ if got := messages[1].Get("role").String(); got != "assistant" {
+ t.Fatalf("tool call role = %q, want assistant; body=%s", got, out)
+ }
+ if got := messages[2].Get("role").String(); got != "user" {
+ t.Fatalf("tool result role = %q, want user; body=%s", got, out)
+ }
+ calls := messages[1].Get("content").Array()
+ results := messages[2].Get("content").Array()
+ if len(calls) != len(tt.wantIDs) || len(results) != len(tt.wantIDs) {
+ t.Fatalf("calls=%s results=%s, want %d pairs", messages[1].Get("content").Raw, messages[2].Get("content").Raw, len(tt.wantIDs))
+ }
+ for i, wantID := range tt.wantIDs {
+ if calls[i].Get("type").String() != "tool_use" || calls[i].Get("id").String() != wantID {
+ t.Fatalf("call[%d] = %s, want tool_use id %q", i, calls[i].Raw, wantID)
+ }
+ if results[i].Get("type").String() != "tool_result" || results[i].Get("tool_use_id").String() != wantID {
+ t.Fatalf("result[%d] = %s, want tool_result id %q", i, results[i].Raw, wantID)
+ }
+ }
+ })
+ }
+ }
+}
+
+func TestIssue7279ToolResultsPrecedeUserText(t *testing.T) {
+ tests := map[string]string{
+ "text and result in one turn": `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","id":"call-a","args":{}}}]},
+ {"role":"model","parts":[{"text":"continue"},{"functionResponse":{"name":"read","id":"call-a","response":{"result":"one"}}}]}
+ ]`,
+ "text before a separate result turn": `[
+ {"role":"user","parts":[{"text":"read file"}]},
+ {"role":"model","parts":[{"functionCall":{"name":"read","id":"call-a","args":{}}}]},
+ {"role":"user","parts":[{"text":"continue"}]},
+ {"role":"model","parts":[{"functionResponse":{"name":"read","id":"call-a","response":{"result":"one"}}}]}
+ ]`,
+ }
+ for name, contents := range tests {
+ t.Run(name, func(t *testing.T) {
+ out, errConvert := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":`+contents+`}`), true)
+ if errConvert != nil {
+ t.Fatalf("conversion failed: %v", errConvert)
+ }
+ message := ojson.GetBytesNoCopy(out, "messages.2")
+ if message.Get("role").String() != "user" || message.Get("content.0.type").String() != "tool_result" || message.Get("content.0.tool_use_id").String() != "call-a" || message.Get("content.1.text").String() != "continue" {
+ t.Fatalf("user content = %s; want tool result before text", message.Raw)
+ }
+ })
+ }
+}
+
+func TestIssue7279ClaudeThinkingSignaturesAreValidGeminiBytes(t *testing.T) {
+ const validGeminiSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
+ tests := []struct {
+ name string
+ signature string
+ want string
+ }{
+ {name: "foreign Claude signature", signature: "foreign_claude_sig_123"},
+ {name: "native Gemini signature", signature: "gemini#" + validGeminiSignature, want: validGeminiSignature},
+ {name: "unpadded native Gemini signature", signature: "gemini#EggKBgEMOdbHNA", want: "EggKBgEMOdbHNA=="},
+ }
+ for _, tt := range tests {
+ for _, stream := range []bool{false, true} {
+ t.Run(tt.name+map[bool]string{false: "/nonstream", true: "/stream"}[stream], func(t *testing.T) {
+ events := []string{
+ `data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","signature":"` + tt.signature + `"}}`,
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"reasoning"}}`,
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"` + tt.signature + `"}}`,
+ }
+ var outputs [][]byte
+ if stream {
+ var param any
+ for _, event := range events {
+ outputs = append(outputs, ConvertClaudeResponseToGemini(context.Background(), "claude-sonnet-4", nil, nil, []byte(event), ¶m)...)
+ }
+ } else {
+ outputs = append(outputs, ConvertClaudeResponseToGeminiNonStream(context.Background(), "claude-sonnet-4", nil, nil, []byte(strings.Join(events, "\n")), nil))
+ }
+ var thoughtText strings.Builder
+ var foundSignature string
+ for _, out := range outputs {
+ for _, part := range ojson.GetBytesNoCopy(out, "candidates.0.content.parts").Array() {
+ if part.Get("thought").Bool() {
+ thoughtText.WriteString(part.Get("text").String())
+ }
+ if signature := part.Get("thoughtSignature"); signature.Exists() {
+ foundSignature = signature.String()
+ if _, errDecode := base64.StdEncoding.DecodeString(foundSignature); errDecode != nil {
+ t.Fatalf("thoughtSignature %q is not valid base64: %v; response=%s", foundSignature, errDecode, out)
+ }
+ }
+ }
+ }
+ if thoughtText.String() != "reasoning" {
+ t.Fatalf("thought text = %q, want reasoning", thoughtText.String())
+ }
+ if tt.want != "" && foundSignature != tt.want {
+ t.Fatalf("native thought signature = %q, want %q", foundSignature, tt.want)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/translator/claude/gemini/claude_gemini_request.go b/internal/translator/claude/gemini/claude_gemini_request.go
index 616e8645b79..9d6913591ec 100644
--- a/internal/translator/claude/gemini/claude_gemini_request.go
+++ b/internal/translator/claude/gemini/claude_gemini_request.go
@@ -9,12 +9,11 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ConvertGeminiRequestToClaude parses and transforms a Gemini API request into Claude Code API format.
@@ -49,13 +48,13 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
userID := translatorcommon.DeriveClaudeUserID(rawJSON)
// Base Claude message payload
- out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
- out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
+ outDoc := ojson.NewDoc([]byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`))
+ _ = outDoc.Set("metadata.user_id", userID)
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("contents.#").Int()) + 1)
- getGeminiToolID := func(value gjson.Result) string {
+ getGeminiToolID := func(value ojson.Result) string {
if toolID := strings.TrimSpace(value.Get("id").String()); toolID != "" {
return toolID
}
@@ -82,30 +81,30 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
toolCallCounter := 0
// Model mapping to specify which Claude Code model to use
- out, _ = sjson.SetBytes(out, "model", modelName)
- if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
+ _ = outDoc.Set("model", modelName)
+ if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == ojson.String {
+ _ = outDoc.Set("service_tier", serviceTier.String())
}
// Generation config extraction from Gemini format
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
// Max output tokens configuration
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
- out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
+ _ = outDoc.Set("max_tokens", maxTokens.Int())
}
// Top P setting for nucleus sampling.
if topP := genConfig.Get("topP"); topP.Exists() {
- out, _ = sjson.SetBytes(out, "top_p", topP.Float())
+ _ = outDoc.Set("top_p", topP.Float())
}
// Stop sequences configuration for custom termination conditions
if stopSeqs := genConfig.Get("stopSequences"); stopSeqs.Exists() && stopSeqs.IsArray() {
var stopSequences []string
- stopSeqs.ForEach(func(_, value gjson.Result) bool {
+ stopSeqs.ForEach(func(_, value ojson.Result) bool {
stopSequences = append(stopSequences, value.String())
return true
})
if len(stopSequences) > 0 {
- out, _ = sjson.SetBytes(out, "stop_sequences", stopSequences)
+ _ = outDoc.Set("stop_sequences", stopSequences)
}
}
// Include thoughts configuration for reasoning process visibility
@@ -128,36 +127,36 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
switch {
case level == "":
case level == "none":
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
case level == "auto" && supportsDynamic:
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
default:
if !thinking.HasLevel(mi.Thinking.Levels, level) {
if mapped, ok := thinking.MapToClaudeEffort(level, supportsMax); ok {
level = mapped
}
}
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.SetBytes(out, "output_config.effort", level)
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Set("output_config.effort", level)
}
} else {
switch level {
case "":
case "none":
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
case "auto":
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
default:
if budget, ok := thinking.ConvertLevelToBudget(level); ok {
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Set("thinking.budget_tokens", budget)
}
}
}
@@ -171,13 +170,13 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
if supportsAdaptive {
switch {
case budget == 0:
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
case budget == -1 && supportsDynamic:
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
default:
level, ok := thinking.ConvertBudgetToLevel(budget)
if ok {
@@ -186,22 +185,22 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
level = mapped
}
}
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.SetBytes(out, "output_config.effort", level)
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Set("output_config.effort", level)
}
}
} else {
switch budget {
case 0:
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
case -1:
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
default:
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Set("thinking.budget_tokens", budget)
}
}
}
@@ -217,7 +216,7 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
if sysInstr.Exists() {
if parts := sysInstr.Get("parts"); parts.Exists() && parts.IsArray() {
var systemText strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if translatorcommon.IsGeminiThoughtPart(part) {
return true
}
@@ -232,7 +231,7 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
if systemText.Len() > 0 {
// Create system message in Claude Code format.
systemMessage := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
- systemMessage, _ = sjson.SetBytes(systemMessage, "content.0.text", systemText.String())
+ systemMessage, _ = ojson.SetBytes(systemMessage, "content.0.text", systemText.String())
messageAccumulator.Append(systemMessage)
messageAccumulator.Flush()
}
@@ -241,26 +240,41 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
// Contents conversion to messages with proper role mapping
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
- contents.ForEach(func(_, content gjson.Result) bool {
- role := content.Get("role").String()
- // Map Gemini roles to Claude Code roles
- if role == "model" {
- role = "assistant"
- }
-
- if role == "function" {
- role = "user"
- }
-
- if role == "tool" {
+ previousRole := ""
+ contents.ForEach(func(_, content ojson.Result) bool {
+ parts := content.Get("parts")
+ role := translatorcommon.NormalizeGeminiTurnRole(content.Get("role").String(), parts, previousRole)
+ // Some Gemini clients label tool results as model turns; Claude requires user turns.
+ hasFunctionResponse := translatorcommon.GeminiPartsHaveFunctionResponse(parts)
+ if hasFunctionResponse && !translatorcommon.GeminiPartsHaveFunctionCall(parts) {
role = "user"
}
contentItems := make([][]byte, 0, 4)
// Counts what this turn sends; whitespace-only text is forwarded but never sendable.
sendable := 0
- if parts := content.Get("parts"); parts.Exists() && parts.IsArray() {
- parts.ForEach(func(_, part gjson.Result) bool {
+ flushContent := func() {
+ if role != "assistant" {
+ drops.EndTurn(sendable)
+ }
+ if len(contentItems) > 0 {
+ msgDoc := ojson.NewDoc([]byte(`{"role":"","content":[]}`))
+ _ = msgDoc.Set("role", role)
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
+ messageAccumulator.Append(msgDoc.Bytes())
+ }
+ contentItems = contentItems[:0]
+ sendable = 0
+ }
+ // Split mixed function calls and results without losing their order or IDs.
+ switchRole := func(nextRole string) {
+ if role != nextRole {
+ flushContent()
+ role = nextRole
+ }
+ }
+ if parts.Exists() && parts.IsArray() {
+ parts.ForEach(func(_, part ojson.Result) bool {
if translatorcommon.IsGeminiThoughtPart(part) {
return true
}
@@ -268,7 +282,7 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
// Text content conversion
if text := part.Get("text"); text.Exists() {
textContent := []byte(`{"type":"text","text":""}`)
- textContent, _ = sjson.SetBytes(textContent, "text", text.String())
+ textContent, _ = ojson.SetBytes(textContent, "text", text.String())
contentItems = append(contentItems, textContent)
if strings.TrimSpace(text.String()) != "" {
sendable++
@@ -277,8 +291,9 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
}
// Function call (from model/assistant) conversion to tool use
- if fc := part.Get("functionCall"); fc.Exists() && role == "assistant" {
- toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ if fc := translatorcommon.GeminiPartFunctionCall(part); fc.Exists() && (role == "assistant" || hasFunctionResponse) {
+ switchRole("assistant")
+ toolUseDoc := ojson.NewDoc([]byte(`{"type":"tool_use","id":"","name":"","input":{}}`))
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
toolID := getGeminiToolID(fc)
@@ -287,22 +302,23 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
toolID = fmt.Sprintf("toolu_gemini_%016d", toolCallCounter)
}
pendingToolIDs = append(pendingToolIDs, toolID)
- toolUse, _ = sjson.SetBytes(toolUse, "id", toolID)
+ _ = toolUseDoc.Set("id", toolID)
if name := fc.Get("name"); name.Exists() {
- toolUse, _ = sjson.SetBytes(toolUse, "name", util.SanitizeClaudeFunctionName(name.String()))
+ _ = toolUseDoc.Set("name", util.SanitizeClaudeFunctionName(name.String()))
}
if args := fc.Get("args"); args.Exists() && args.IsObject() {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw))
+ _ = toolUseDoc.SetRaw("input", args.Raw)
}
- contentItems = append(contentItems, toolUse)
+ contentItems = append(contentItems, toolUseDoc.Bytes())
sendable++
return true
}
// Function response (from user) conversion to tool result
- if fr := part.Get("functionResponse"); fr.Exists() {
- toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
+ if fr := translatorcommon.GeminiPartFunctionResponse(part); fr.Exists() {
+ switchRole("user")
+ toolResultDoc := ojson.NewDoc([]byte(`{"type":"tool_result","tool_use_id":"","content":""}`))
// Attach the oldest queued tool_id to pair the response
// with its call. If the queue is empty, generate a new id.
@@ -319,15 +335,15 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
toolCallCounter++
toolID = fmt.Sprintf("toolu_gemini_%016d", toolCallCounter)
}
- toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", toolID)
+ _ = toolResultDoc.Set("tool_use_id", toolID)
// Extract result content from the function response
if result := fr.Get("response.result"); result.Exists() {
- toolResult, _ = sjson.SetBytes(toolResult, "content", result.String())
+ _ = toolResultDoc.Set("content", result.String())
} else if response := fr.Get("response"); response.Exists() {
- toolResult, _ = sjson.SetBytes(toolResult, "content", response.Raw)
+ _ = toolResultDoc.Set("content", response.Raw)
}
- contentItems = append(contentItems, toolResult)
+ contentItems = append(contentItems, toolResultDoc.Bytes())
sendable++
return true
}
@@ -359,50 +375,43 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
return true
})
}
- if role != "assistant" {
- drops.EndTurn(sendable)
- }
-
- // Only add message if it has content.
- if len(contentItems) > 0 {
- msg := []byte(`{"role":"","content":[]}`)
- msg, _ = sjson.SetBytes(msg, "role", role)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
- messageAccumulator.Append(msg)
- }
+ flushContent()
+ previousRole = role
return true
})
}
- out = translatorcommon.SetRawArrayItems(out, "messages", messageAccumulator.Messages())
+ messages := messageAccumulator.Messages()
+ orderGeminiClaudeToolResults(messages)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "messages", messages)
// Tools mapping: Gemini functionDeclarations -> Claude Code tools
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
var anthropicTools []interface{}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if funcDecls := tool.Get("functionDeclarations"); funcDecls.Exists() && funcDecls.IsArray() {
- funcDecls.ForEach(func(_, funcDecl gjson.Result) bool {
- anthropicTool := []byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{}}}`)
+ funcDecls.ForEach(func(_, funcDecl ojson.Result) bool {
+ toolDoc := ojson.NewDoc([]byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{}}}`))
if name := funcDecl.Get("name"); name.Exists() {
- anthropicTool, _ = sjson.SetBytes(anthropicTool, "name", util.SanitizeClaudeFunctionName(name.String()))
+ _ = toolDoc.Set("name", util.SanitizeClaudeFunctionName(name.String()))
}
if desc := funcDecl.Get("description"); desc.Exists() {
- anthropicTool, _ = sjson.SetBytes(anthropicTool, "description", desc.String())
+ _ = toolDoc.Set("description", desc.String())
}
if params := funcDecl.Get("parameters"); params.Exists() {
cleaned := normalizeClaudeToolSchema(params)
- anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned)
+ _ = toolDoc.SetRawBytes("input_schema", cleaned)
} else if params = funcDecl.Get("parametersJsonSchema"); params.Exists() {
cleaned := normalizeClaudeToolSchema(params)
- anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", cleaned)
+ _ = toolDoc.SetRawBytes("input_schema", cleaned)
} else {
- anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", []byte(`{"type":"object","properties":{}}`))
+ _ = toolDoc.SetRaw("input_schema", `{"type":"object","properties":{}}`)
}
- anthropicTool = lowercaseClaudeToolSchemaTypes(anthropicTool)
- anthropicTools = append(anthropicTools, gjson.ParseBytes(anthropicTool).Value())
+ anthropicTool := lowercaseClaudeToolSchemaTypes(toolDoc.Bytes())
+ anthropicTools = append(anthropicTools, util.ParseGJSONBytesNoCopy(anthropicTool).Value())
return true
})
}
@@ -410,63 +419,98 @@ func convertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
})
if len(anthropicTools) > 0 {
- out, _ = sjson.SetBytes(out, "tools", anthropicTools)
+ _ = outDoc.Set("tools", anthropicTools)
}
}
// Tool config mapping from Gemini format to Claude Code format
if toolConfig := root.Get("tool_config"); toolConfig.Exists() {
- out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("function_calling_config"))
+ setClaudeToolChoiceFromGeminiToolConfigDoc(outDoc, toolConfig.Get("function_calling_config"))
} else if toolConfig := root.Get("toolConfig"); toolConfig.Exists() {
- out = setClaudeToolChoiceFromGeminiToolConfig(out, toolConfig.Get("functionCallingConfig"))
+ setClaudeToolChoiceFromGeminiToolConfigDoc(outDoc, toolConfig.Get("functionCallingConfig"))
}
// Stream setting configuration
- out, _ = sjson.SetBytes(out, "stream", stream)
+ _ = outDoc.Set("stream", stream)
- return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "gemini", modelName), drops.Err()
+ return thinking.ApplyTranslatedSummaryToClaude(outDoc.Bytes(), rawJSON, "gemini", modelName), drops.Err()
}
-func normalizeClaudeToolSchema(parameters gjson.Result) []byte {
- cleaned := []byte(parameters.Raw)
- if parameters.Get("additionalProperties").Type != gjson.False {
- cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
+// orderGeminiClaudeToolResults keeps tool results before other user content,
+// including when adjacent Gemini user turns were merged into one Claude turn.
+func orderGeminiClaudeToolResults(messages [][]byte) {
+ for i, message := range messages {
+ root := util.ParseGJSONBytesNoCopy(message)
+ if root.Get("role").String() != "user" {
+ continue
+ }
+ var results, other [][]byte
+ hasOther, needsReorder := false, false
+ root.Get("content").ForEach(func(_, part ojson.Result) bool {
+ if part.Get("type").String() == "tool_result" {
+ results = append(results, []byte(part.Raw))
+ needsReorder = needsReorder || hasOther
+ } else {
+ other = append(other, []byte(part.Raw))
+ hasOther = true
+ }
+ return true
+ })
+ if needsReorder {
+ messageDoc := ojson.NewDoc(message)
+ _ = messageDoc.SetRawBytes("content", translatorcommon.JoinRawArray(append(results, other...)))
+ messages[i] = messageDoc.Bytes()
+ }
+ }
+}
+
+func normalizeClaudeToolSchema(parameters ojson.Result) []byte {
+ cleanedDoc := ojson.NewDocString(util.NormalizeJSONSchemaNumericConstraints(parameters.Raw))
+ if parameters.Get("additionalProperties").Type != ojson.False {
+ _ = cleanedDoc.Set("additionalProperties", false)
}
const schema = "http://json-schema.org/draft-07/schema#"
currentSchema := parameters.Get("$schema")
- if currentSchema.Type != gjson.String || currentSchema.String() != schema {
- cleaned, _ = sjson.SetBytes(cleaned, "$schema", schema)
+ if currentSchema.Type != ojson.String || currentSchema.String() != schema {
+ _ = cleanedDoc.Set("$schema", schema)
}
- return cleaned
+ return cleanedDoc.Bytes()
}
func lowercaseClaudeToolSchemaTypes(tool []byte) []byte {
var pathsToLower []string
- util.Walk(gjson.ParseBytes(tool), "", "type", &pathsToLower)
+ util.Walk(util.ParseGJSONBytesNoCopy(tool), "", "type", &pathsToLower)
+ var toolDoc *ojson.Doc
for _, path := range pathsToLower {
- typeValue := gjson.GetBytes(tool, path)
+ typeValue := ojson.GetBytesNoCopy(tool, path)
normalizedType := strings.ToLower(typeValue.String())
- if typeValue.Type == gjson.String && normalizedType == typeValue.String() {
+ if typeValue.Type == ojson.String && normalizedType == typeValue.String() {
continue
}
- tool, _ = sjson.SetBytes(tool, path, normalizedType)
+ if toolDoc == nil {
+ toolDoc = ojson.NewDoc(tool)
+ }
+ _ = toolDoc.Set(path, normalizedType)
+ }
+ if toolDoc != nil {
+ return toolDoc.Bytes()
}
return tool
}
-func setClaudeToolChoiceFromGeminiToolConfig(out []byte, funcCalling gjson.Result) []byte {
+func setClaudeToolChoiceFromGeminiToolConfigDoc(outDoc *ojson.Doc, funcCalling ojson.Result) {
if !funcCalling.Exists() {
- return out
+ return
}
mode := funcCalling.Get("mode")
if !mode.Exists() {
- return out
+ return
}
switch mode.String() {
case "AUTO":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
case "NONE":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"none"}`)
case "ANY":
allowedNames := funcCalling.Get("allowedFunctionNames")
if !allowedNames.Exists() {
@@ -475,16 +519,15 @@ func setClaudeToolChoiceFromGeminiToolConfig(out []byte, funcCalling gjson.Resul
allowedNameItems := allowedNames.Array()
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
choice := []byte(`{"type":"tool","name":""}`)
- choice, _ = sjson.SetBytes(choice, "name", util.SanitizeClaudeFunctionName(allowedNameItems[0].String()))
- out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
+ choice, _ = ojson.SetBytes(choice, "name", util.SanitizeClaudeFunctionName(allowedNameItems[0].String()))
+ _ = outDoc.SetRawBytes("tool_choice", choice)
} else {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
}
}
- return out
}
-func geminiClaudeInlineData(part gjson.Result) gjson.Result {
+func geminiClaudeInlineData(part ojson.Result) ojson.Result {
inlineData := part.Get("inlineData")
if inlineData.Exists() {
return inlineData
@@ -492,7 +535,7 @@ func geminiClaudeInlineData(part gjson.Result) gjson.Result {
return part.Get("inline_data")
}
-func geminiClaudeFileData(part gjson.Result) gjson.Result {
+func geminiClaudeFileData(part ojson.Result) ojson.Result {
fileData := part.Get("fileData")
if fileData.Exists() {
return fileData
@@ -503,7 +546,7 @@ func geminiClaudeFileData(part gjson.Result) gjson.Result {
// claudeContentPartFromGeminiInlineData maps inline bytes onto a Claude block.
// Media that Claude cannot read becomes placeholder text only when
// keepPlaceholder is set; otherwise it reports false so the caller can refuse it.
-func claudeContentPartFromGeminiInlineData(inlineData gjson.Result, keepPlaceholder bool) ([]byte, bool) {
+func claudeContentPartFromGeminiInlineData(inlineData ojson.Result, keepPlaceholder bool) ([]byte, bool) {
mimeType := inlineData.Get("mimeType").String()
if mimeType == "" {
mimeType = inlineData.Get("mime_type").String()
@@ -515,15 +558,15 @@ func claudeContentPartFromGeminiInlineData(inlineData gjson.Result, keepPlacehol
lowerMimeType := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
- imageContent := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
- imageContent, _ = sjson.SetBytes(imageContent, "source.media_type", mimeType)
- imageContent, _ = sjson.SetBytes(imageContent, "source.data", data)
- return imageContent, true
+ imageDoc := ojson.NewDoc([]byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = imageDoc.Set("source.media_type", mimeType)
+ _ = imageDoc.Set("source.data", data)
+ return imageDoc.Bytes(), true
case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"):
- documentContent := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
- documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType)
- documentContent, _ = sjson.SetBytes(documentContent, "source.data", data)
- return documentContent, true
+ docDoc := ojson.NewDoc([]byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = docDoc.Set("source.media_type", mimeType)
+ _ = docDoc.Set("source.data", data)
+ return docDoc.Bytes(), true
default:
if !keepPlaceholder {
return nil, false
@@ -532,7 +575,7 @@ func claudeContentPartFromGeminiInlineData(inlineData gjson.Result, keepPlacehol
}
}
-func claudeContentPartFromGeminiFileData(fileData gjson.Result) ([]byte, bool) {
+func claudeContentPartFromGeminiFileData(fileData ojson.Result) ([]byte, bool) {
fileURI := fileData.Get("fileUri").String()
if fileURI == "" {
fileURI = fileData.Get("file_uri").String()
@@ -548,15 +591,15 @@ func claudeContentPartFromGeminiFileData(fileData gjson.Result) ([]byte, bool) {
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
imageContent := []byte(`{"type":"image","source":{"type":"url","url":""}}`)
- imageContent, _ = sjson.SetBytes(imageContent, "source.url", fileURI)
+ imageContent, _ = ojson.SetBytes(imageContent, "source.url", fileURI)
return imageContent, true
case strings.HasPrefix(lowerMimeType, "application/"), strings.HasPrefix(lowerMimeType, "text/"):
- documentContent := []byte(`{"type":"document","source":{"type":"url","url":""}}`)
- documentContent, _ = sjson.SetBytes(documentContent, "source.url", fileURI)
+ docDoc := ojson.NewDoc([]byte(`{"type":"document","source":{"type":"url","url":""}}`))
+ _ = docDoc.Set("source.url", fileURI)
if mimeType != "" {
- documentContent, _ = sjson.SetBytes(documentContent, "source.media_type", mimeType)
+ _ = docDoc.Set("source.media_type", mimeType)
}
- return documentContent, true
+ return docDoc.Bytes(), true
default:
fileInfo := "File: " + fileURI
if mimeType != "" {
@@ -568,6 +611,6 @@ func claudeContentPartFromGeminiFileData(fileData gjson.Result) ([]byte, bool) {
func claudeTextContentPart(text string) []byte {
textContent := []byte(`{"type":"text","text":""}`)
- textContent, _ = sjson.SetBytes(textContent, "text", text)
+ textContent, _ = ojson.SetBytes(textContent, "text", text)
return textContent
}
diff --git a/internal/translator/claude/gemini/claude_gemini_request_test.go b/internal/translator/claude/gemini/claude_gemini_request_test.go
index e67fdaf3286..c1832e4da2a 100644
--- a/internal/translator/claude/gemini/claude_gemini_request_test.go
+++ b/internal/translator/claude/gemini/claude_gemini_request_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertGeminiRequestToClaude_ThinkingSummaryVisibility(t *testing.T) {
@@ -20,7 +20,7 @@ func TestConvertGeminiRequestToClaude_ThinkingSummaryVisibility(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out, _ := ConvertGeminiRequestToClaude("claude-opus-5-5", []byte(test.input), false)
- if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wanted {
+ if got := ojson.GetBytesNoCopy(out, "thinking.display").String(); got != test.wanted {
t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wanted, out)
}
})
@@ -69,12 +69,12 @@ func TestConvertGeminiRequestToClaude_PreservesCustomToolIDs(t *testing.T) {
out, _ := ConvertGeminiRequestToClaude("claude-sonnet-4", raw, false)
- gotCallID := gjson.GetBytes(out, "messages.0.content.0.id").String()
+ gotCallID := ojson.GetBytesNoCopy(out, "messages.0.content.0.id").String()
if gotCallID != tt.want {
t.Fatalf("expected tool_use id %q, got %q; output=%s", tt.want, gotCallID, string(out))
}
- gotResultID := gjson.GetBytes(out, "messages.1.content.0.tool_use_id").String()
+ gotResultID := ojson.GetBytesNoCopy(out, "messages.1.content.0.tool_use_id").String()
if gotResultID != tt.want {
t.Fatalf("expected tool_result tool_use_id %q, got %q; output=%s", tt.want, gotResultID, string(out))
}
@@ -94,7 +94,7 @@ func TestConvertGeminiRequestToClaude_GroupsConsecutiveRoleTurns(t *testing.T) {
}`)
out, _ := ConvertGeminiRequestToClaude("claude-test", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
@@ -128,7 +128,7 @@ func TestConvertGeminiRequestToClaude_KeepsSystemInstructionUserSeparate(t *test
"contents":[{"role":"user","parts":[{"text":"question"}]}]
}`)
out, _ := ConvertGeminiRequestToClaude("claude-test", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
@@ -146,7 +146,7 @@ func TestConvertGeminiRequestToClaude_SupportsCamelCaseSystemInstruction(t *test
"contents":[{"role":"user","parts":[{"text":"question"}]}]
}`)
out, _ := ConvertGeminiRequestToClaude("claude-test", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
@@ -174,20 +174,20 @@ func TestConvertGeminiRequestToClaude_DropsTemperature(t *testing.T) {
out, _ := ConvertGeminiRequestToClaude("claude-sonnet-5", raw, false)
- if gjson.GetBytes(out, "temperature").Exists() {
+ if ojson.GetBytesNoCopy(out, "temperature").Exists() {
t.Fatalf("temperature should be removed")
}
- if got := gjson.GetBytes(out, "top_p").Float(); got != 0.8 {
+ if got := ojson.GetBytesNoCopy(out, "top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want 0.8", got)
}
}
func TestConvertGeminiRequestToClaude_AcceptsCamelInlineData(t *testing.T) {
out, _ := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "image" {
t.Fatalf("content type = %q, want image. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.source.media_type").String(); got != "image/png" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.source.media_type").String(); got != "image/png" {
t.Fatalf("media_type = %q, want image/png. Output: %s", got, string(out))
}
}
@@ -196,13 +196,13 @@ func TestConvertGeminiRequestToClaude_SplitsNonImageInlineDataByMIME(t *testing.
out, _ := ConvertGeminiRequestToClaude("claude-sonnet-4", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
// A user attachment Claude cannot read is dropped rather than replaced by placeholder text.
- if got := gjson.GetBytes(out, "messages.0.content.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.#").Int(); got != 1 {
t.Fatalf("user content has %d blocks, want only the document. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "document" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "document" {
t.Fatalf("document content type = %q, want document. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "messages.0.content.#(type==\"image\")").Exists() {
+ if ojson.GetBytesNoCopy(out, "messages.0.content.#(type==\"image\")").Exists() {
t.Fatalf("non-image inlineData must not be converted to image. Output: %s", string(out))
}
if strings.Contains(string(out), "Media content") {
@@ -219,7 +219,7 @@ func TestConvertGeminiRequestToClaude_DropsHiddenThoughtParts(t *testing.T) {
]
}`), false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "user" || messages[0].Get("content.0.text").String() != "continue" {
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
}
@@ -233,7 +233,7 @@ func TestConvertGeminiRequestToClaude_DropsHiddenThoughtParts(t *testing.T) {
]}]
}`), false)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 1 || content[0].Get("type").String() != "text" || content[0].Get("text").String() != "visible answer" {
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
}
@@ -280,10 +280,10 @@ func TestConvertGeminiRequestToClaude_DeterministicToolIDs(t *testing.T) {
wantID1 := "toolu_gemini_0000000000000001"
wantID2 := "toolu_gemini_0000000000000002"
- gotCall1 := gjson.GetBytes(out1, "messages.0.content.0.id").String()
- gotResp1 := gjson.GetBytes(out1, "messages.1.content.0.tool_use_id").String()
- gotCall2 := gjson.GetBytes(out1, "messages.2.content.0.id").String()
- gotResp2 := gjson.GetBytes(out1, "messages.3.content.0.tool_use_id").String()
+ gotCall1 := ojson.GetBytesNoCopy(out1, "messages.0.content.0.id").String()
+ gotResp1 := ojson.GetBytesNoCopy(out1, "messages.1.content.0.tool_use_id").String()
+ gotCall2 := ojson.GetBytesNoCopy(out1, "messages.2.content.0.id").String()
+ gotResp2 := ojson.GetBytesNoCopy(out1, "messages.3.content.0.tool_use_id").String()
if gotCall1 != wantID1 || gotResp1 != wantID1 {
t.Fatalf("expected first tool pair to have id %q, got call=%q, resp=%q", wantID1, gotCall1, gotResp1)
@@ -319,10 +319,10 @@ func TestConvertGeminiRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *t
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out, _ := ConvertGeminiRequestToClaude("claude-test", []byte(tc.rawJSON), false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
- got := gjson.GetBytes(out, "metadata.user_id").String()
+ got := ojson.GetBytesNoCopy(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
@@ -335,8 +335,8 @@ func TestConvertGeminiRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t
b := []byte(`{"model":"claude-test","prompt_cache_key":"gemini-session-b","contents":[{"role":"user","parts":[{"text":"hello"}]}]}`)
outA, _ := ConvertGeminiRequestToClaude("claude-test", a, false)
outB, _ := ConvertGeminiRequestToClaude("claude-test", b, false)
- idA := gjson.GetBytes(outA, "metadata.user_id").String()
- idB := gjson.GetBytes(outB, "metadata.user_id").String()
+ idA := ojson.GetBytesNoCopy(outA, "metadata.user_id").String()
+ idB := ojson.GetBytesNoCopy(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
@@ -347,14 +347,17 @@ func TestConvertGeminiRequestToClaude_DefaultRoleDifferentContentProducesDiffere
b := []byte(`{"contents":[{"parts":[{"text":"second prompt"}]}]}`)
outA, _ := ConvertGeminiRequestToClaude("claude-test", a, false)
outB, _ := ConvertGeminiRequestToClaude("claude-test", b, false)
- idA := gjson.GetBytes(outA, "metadata.user_id").String()
- idB := gjson.GetBytes(outB, "metadata.user_id").String()
+ idA := ojson.GetBytesNoCopy(outA, "metadata.user_id").String()
+ idB := ojson.GetBytesNoCopy(outB, "metadata.user_id").String()
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id without role, got idA=%q idB=%q", idA, idB)
}
if idA == idB {
t.Fatalf("different prompt texts without role produced identical metadata.user_id: %q", idA)
}
+ if got := ojson.GetBytesNoCopy(outA, "messages.0.role").String(); got != "user" {
+ t.Fatalf("messages.0.role = %q, want user; out=%s", got, string(outA))
+ }
}
func TestConvertGeminiRequestToClaude_SanitizesToolNamesAndProvidesFallbackSchema(t *testing.T) {
@@ -404,25 +407,25 @@ func TestConvertGeminiRequestToClaude_SanitizesToolNamesAndProvidesFallbackSchem
result, _ := ConvertGeminiRequestToClaude("claude-test", []byte(inputJSON), false)
// 1. Tool declaration name sanitized
- toolName := gjson.GetBytes(result, "tools.0.name").String()
+ toolName := ojson.GetBytesNoCopy(result, "tools.0.name").String()
if toolName != "mcp_server_get_data" {
t.Fatalf("tools.0.name = %q, want mcp_server_get_data. Output: %s", toolName, result)
}
// 2. Fallback input_schema
- schema := gjson.GetBytes(result, "tools.0.input_schema")
+ schema := ojson.GetBytesNoCopy(result, "tools.0.input_schema")
if !schema.Exists() || schema.Get("type").String() != "object" {
t.Fatalf("tools.0.input_schema = %s, want object schema. Output: %s", schema, result)
}
// 3. Historical tool_use in model/assistant turn sanitized
- toolUseName := gjson.GetBytes(result, "messages.0.content.0.name").String()
+ toolUseName := ojson.GetBytesNoCopy(result, "messages.0.content.0.name").String()
if toolUseName != "mcp_server_get_data" {
t.Fatalf("messages.0.content.0.name = %q, want mcp_server_get_data. Output: %s", toolUseName, result)
}
// 4. Tool choice name sanitized
- toolChoiceName := gjson.GetBytes(result, "tool_choice.name").String()
+ toolChoiceName := ojson.GetBytesNoCopy(result, "tool_choice.name").String()
if toolChoiceName != "mcp_server_get_data" {
t.Fatalf("tool_choice.name = %q, want mcp_server_get_data. Output: %s", toolChoiceName, result)
}
diff --git a/internal/translator/claude/gemini/claude_gemini_response.go b/internal/translator/claude/gemini/claude_gemini_response.go
index d030eaf7d0d..111df9d448f 100644
--- a/internal/translator/claude/gemini/claude_gemini_response.go
+++ b/internal/translator/claude/gemini/claude_gemini_response.go
@@ -11,10 +11,11 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var (
@@ -68,35 +69,35 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
eventType := root.Get("type").String()
// Base Gemini response template with default values
- template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
+ templateDoc := ojson.NewDoc([]byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`))
// Set model version
if (*param).(*ConvertAnthropicResponseToGeminiParams).Model != "" {
// Map Claude model names back to Gemini model names
- template, _ = sjson.SetBytes(template, "modelVersion", (*param).(*ConvertAnthropicResponseToGeminiParams).Model)
+ _ = templateDoc.Set("modelVersion", (*param).(*ConvertAnthropicResponseToGeminiParams).Model)
}
// Set response ID and creation time
if (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID != "" {
- template, _ = sjson.SetBytes(template, "responseId", (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID)
+ _ = templateDoc.Set("responseId", (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID)
}
// Set creation time to current time if not provided
if (*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt == 0 {
(*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt = time.Now().Unix()
}
- template, _ = sjson.SetBytes(template, "createTime", time.Unix((*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
+ _ = templateDoc.Set("createTime", time.Unix((*param).(*ConvertAnthropicResponseToGeminiParams).CreatedAt, 0).Format(time.RFC3339Nano))
switch eventType {
case "message_start":
// Initialize response with message metadata when a new message begins
if message := root.Get("message"); message.Exists() {
- (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID = message.Get("id").String()
- (*param).(*ConvertAnthropicResponseToGeminiParams).Model = message.Get("model").String()
+ (*param).(*ConvertAnthropicResponseToGeminiParams).ResponseID = strings.Clone(message.Get("id").String())
+ (*param).(*ConvertAnthropicResponseToGeminiParams).Model = strings.Clone(message.Get("model").String())
}
return [][]byte{}
@@ -109,20 +110,20 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames = map[int]string{}
}
if name := cb.Get("name"); name.Exists() {
- (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = name.String()
+ (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseNames[idx] = strings.Clone(name.String())
}
if toolID := cb.Get("id").String(); toolID != "" {
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs == nil {
(*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs = map[int]string{}
}
- (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = toolID
+ (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx] = strings.Clone(toolID)
}
} else if cb.Get("type").String() == "thinking" {
if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" {
- thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`)
- thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
- return [][]byte{template}
+ if thinkingPart := geminiCompatibleThoughtSignaturePart(sig.String()); len(thinkingPart) > 0 {
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts.-1", thinkingPart)
+ return [][]byte{templateDoc.Bytes()}
+ }
}
}
}
@@ -138,21 +139,23 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
// Regular text content delta for normal response text
if text := delta.Get("text"); text.Exists() && text.String() != "" {
textPart := []byte(`{"text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", text.String())
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", textPart)
+ textPart, _ = ojson.SetBytes(textPart, "text", text.String())
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts.-1", textPart)
}
case "thinking_delta":
// Thinking/reasoning content delta for models with reasoning capabilities
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
thinkingPart := []byte(`{"thought":true,"text":""}`)
- thinkingPart, _ = sjson.SetBytes(thinkingPart, "text", text.String())
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
+ thinkingPart, _ = ojson.SetBytes(thinkingPart, "text", text.String())
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts.-1", thinkingPart)
}
case "signature_delta":
if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" {
- thinkingPart := []byte(`{"thought":true,"thoughtSignature":""}`)
- thinkingPart, _ = sjson.SetBytes(thinkingPart, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", thinkingPart)
+ if thinkingPart := geminiCompatibleThoughtSignaturePart(sig.String()); len(thinkingPart) > 0 {
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts.-1", thinkingPart)
+ } else {
+ return [][]byte{}
+ }
}
case "input_json_delta":
// Tool use input delta - accumulate partial_json by index for later assembly at content_block_stop
@@ -172,7 +175,7 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
return [][]byte{}
}
}
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
case "content_block_stop":
// End of content block - finalize tool calls if any
@@ -194,19 +197,20 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
toolID = (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs[idx]
}
if name != "" || argsTrim != "" {
- functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
+ fcDoc := ojson.NewDoc([]byte(`{"functionCall":{"name":"","args":{}}}`))
if name != "" {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name)
+ _ = fcDoc.Set("functionCall.name", name)
}
if argsTrim != "" {
- functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsTrim))
+ _ = fcDoc.SetRaw("functionCall.args", argsTrim)
}
if toolID != "" {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", toolID)
+ _ = fcDoc.Set("functionCall.id", toolID)
}
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", functionCall)
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
- (*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = append([]byte(nil), template...)
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts.-1", fcDoc.Bytes())
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
+ outBytes := templateDoc.Bytes()
+ (*param).(*ConvertAnthropicResponseToGeminiParams).LastStorageOutput = append([]byte(nil), outBytes...)
// cleanup used state for this index
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs != nil {
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseArgs, idx)
@@ -217,7 +221,7 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
if (*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs != nil {
delete((*param).(*ConvertAnthropicResponseToGeminiParams).ToolUseIDs, idx)
}
- return [][]byte{template}
+ return [][]byte{outBytes}
}
return [][]byte{}
@@ -227,15 +231,15 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
switch stopReason.String() {
case "end_turn":
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
case "tool_use":
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
case "max_tokens":
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "MAX_TOKENS")
+ _ = templateDoc.Set("candidates.0.finishReason", "MAX_TOKENS")
case "stop_sequence":
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
default:
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
}
}
}
@@ -246,32 +250,32 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
outputTokens := usage.Get("output_tokens").Int()
// Set basic usage metadata according to Gemini API specification
- template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", inputTokens)
- template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", outputTokens)
- template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
+ _ = templateDoc.Set("usageMetadata.promptTokenCount", inputTokens)
+ _ = templateDoc.Set("usageMetadata.candidatesTokenCount", outputTokens)
+ _ = templateDoc.Set("usageMetadata.totalTokenCount", inputTokens+outputTokens)
// Add cache-related token counts if present (Claude Code API cache fields)
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
- template, _ = sjson.SetBytes(template, "usageMetadata.cachedContentTokenCount", cacheCreationTokens.Int())
+ _ = templateDoc.Set("usageMetadata.cachedContentTokenCount", cacheCreationTokens.Int())
}
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
// Add cache read tokens to cached content count
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
- template, _ = sjson.SetBytes(template, "usageMetadata.cachedContentTokenCount", totalCacheTokens)
+ _ = templateDoc.Set("usageMetadata.cachedContentTokenCount", totalCacheTokens)
}
// Add thinking tokens if present (for models with reasoning capabilities)
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
- template, _ = sjson.SetBytes(template, "usageMetadata.thoughtsTokenCount", thinkingTokens.Int())
+ _ = templateDoc.Set("usageMetadata.thoughtsTokenCount", thinkingTokens.Int())
}
// Set traffic type (required by Gemini API)
- template, _ = sjson.SetBytes(template, "usageMetadata.trafficType", "PROVISIONED_THROUGHPUT")
+ _ = templateDoc.Set("usageMetadata.trafficType", "PROVISIONED_THROUGHPUT")
}
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
case "message_stop":
// Final message with usage information - no additional output needed
return [][]byte{}
@@ -284,7 +288,7 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
// Create error response in Gemini format
errorResponse := []byte(`{"error":{"code":400,"message":"","status":"INVALID_ARGUMENT"}}`)
- errorResponse, _ = sjson.SetBytes(errorResponse, "error.message", errorMsg)
+ errorResponse, _ = ojson.SetBytes(errorResponse, "error.message", errorMsg)
return [][]byte{errorResponse}
default:
@@ -308,10 +312,10 @@ func ConvertClaudeResponseToGemini(_ context.Context, modelName string, original
// - []byte: A Gemini-compatible JSON response containing all message content and metadata
func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
// Base Gemini response template for non-streaming with default values
- template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
+ templateDoc := ojson.NewDoc([]byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`))
// Set model version
- template, _ = sjson.SetBytes(template, "modelVersion", modelName)
+ _ = templateDoc.Set("modelVersion", modelName)
streamingEvents := make([][]byte, 0)
remaining := rawJSON
@@ -357,7 +361,7 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
continue
}
- root := gjson.ParseBytes(eventData)
+ root := util.ParseGJSONBytesNoCopy(eventData)
eventType := root.Get("type").String()
switch eventType {
@@ -392,9 +396,9 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
}
} else if cb.Get("type").String() == "thinking" {
if sig := cb.Get("signature"); sig.Exists() && sig.String() != "" {
- partJSON := []byte(`{"thought":true,"thoughtSignature":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
- allParts = append(allParts, partJSON)
+ if partJSON := geminiCompatibleThoughtSignaturePart(sig.String()); len(partJSON) > 0 {
+ allParts = append(allParts, partJSON)
+ }
}
}
}
@@ -409,21 +413,21 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
// Process regular text content
if text := delta.Get("text"); text.Exists() && text.String() != "" {
partJSON := []byte(`{"text":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
+ partJSON, _ = ojson.SetBytes(partJSON, "text", text.String())
allParts = append(allParts, partJSON)
}
case "thinking_delta":
// Process reasoning/thinking content
if text := delta.Get("thinking"); text.Exists() && text.String() != "" {
partJSON := []byte(`{"thought":true,"text":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
+ partJSON, _ = ojson.SetBytes(partJSON, "text", text.String())
allParts = append(allParts, partJSON)
}
case "signature_delta":
if sig := delta.Get("signature"); sig.Exists() && sig.String() != "" {
- partJSON := []byte(`{"thought":true,"thoughtSignature":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", sigcompat.GeminiReplaySignatureOrBypass(sig.String(), sigcompat.SignatureBlockKindGeminiModelPart))
- allParts = append(allParts, partJSON)
+ if partJSON := geminiCompatibleThoughtSignaturePart(sig.String()); len(partJSON) > 0 {
+ allParts = append(allParts, partJSON)
+ }
}
case "input_json_delta":
// accumulate args partial_json for this index
@@ -460,17 +464,17 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
toolID = newParam.ToolUseIDs[idx]
}
if name != "" || argsTrim != "" {
- functionCallJSON := []byte(`{"functionCall":{"name":"","args":{}}}`)
+ fcDoc := ojson.NewDoc([]byte(`{"functionCall":{"name":"","args":{}}}`))
if name != "" {
- functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.name", name)
+ _ = fcDoc.Set("functionCall.name", name)
}
if argsTrim != "" {
- functionCallJSON, _ = sjson.SetRawBytes(functionCallJSON, "functionCall.args", []byte(argsTrim))
+ _ = fcDoc.SetRaw("functionCall.args", argsTrim)
}
if toolID != "" {
- functionCallJSON, _ = sjson.SetBytes(functionCallJSON, "functionCall.id", toolID)
+ _ = fcDoc.Set("functionCall.id", toolID)
}
- allParts = append(allParts, functionCallJSON)
+ allParts = append(allParts, fcDoc.Bytes())
// cleanup used state for this index
if newParam.ToolUseArgs != nil {
delete(newParam.ToolUseArgs, idx)
@@ -486,47 +490,47 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
case "message_delta":
// Extract final usage information using sjson for token counts and metadata
if usage := root.Get("usage"); usage.Exists() {
- usageJSON := []byte(`{}`)
+ usageDoc := ojson.NewDoc([]byte(`{}`))
// Basic token counts for prompt and completion
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
// Set basic usage metadata according to Gemini API specification
- usageJSON, _ = sjson.SetBytes(usageJSON, "promptTokenCount", inputTokens)
- usageJSON, _ = sjson.SetBytes(usageJSON, "candidatesTokenCount", outputTokens)
- usageJSON, _ = sjson.SetBytes(usageJSON, "totalTokenCount", inputTokens+outputTokens)
+ _ = usageDoc.Set("promptTokenCount", inputTokens)
+ _ = usageDoc.Set("candidatesTokenCount", outputTokens)
+ _ = usageDoc.Set("totalTokenCount", inputTokens+outputTokens)
// Add cache-related token counts if present (Claude Code API cache fields)
if cacheCreationTokens := usage.Get("cache_creation_input_tokens"); cacheCreationTokens.Exists() {
- usageJSON, _ = sjson.SetBytes(usageJSON, "cachedContentTokenCount", cacheCreationTokens.Int())
+ _ = usageDoc.Set("cachedContentTokenCount", cacheCreationTokens.Int())
}
if cacheReadTokens := usage.Get("cache_read_input_tokens"); cacheReadTokens.Exists() {
// Add cache read tokens to cached content count
existingCacheTokens := usage.Get("cache_creation_input_tokens").Int()
totalCacheTokens := existingCacheTokens + cacheReadTokens.Int()
- usageJSON, _ = sjson.SetBytes(usageJSON, "cachedContentTokenCount", totalCacheTokens)
+ _ = usageDoc.Set("cachedContentTokenCount", totalCacheTokens)
}
// Add thinking tokens if present (for models with reasoning capabilities)
if thinkingTokens := usage.Get("thinking_tokens"); thinkingTokens.Exists() {
- usageJSON, _ = sjson.SetBytes(usageJSON, "thoughtsTokenCount", thinkingTokens.Int())
+ _ = usageDoc.Set("thoughtsTokenCount", thinkingTokens.Int())
}
// Set traffic type (required by Gemini API)
- usageJSON, _ = sjson.SetBytes(usageJSON, "trafficType", "PROVISIONED_THROUGHPUT")
+ _ = usageDoc.Set("trafficType", "PROVISIONED_THROUGHPUT")
- finalUsageJSON = usageJSON
+ finalUsageJSON = usageDoc.Bytes()
}
}
}
// Set response metadata
if responseID != "" {
- template, _ = sjson.SetBytes(template, "responseId", responseID)
+ _ = templateDoc.Set("responseId", responseID)
}
if createdAt > 0 {
- template, _ = sjson.SetBytes(template, "createTime", time.Unix(createdAt, 0).Format(time.RFC3339Nano))
+ _ = templateDoc.Set("createTime", time.Unix(createdAt, 0).Format(time.RFC3339Nano))
}
// Consolidate consecutive text parts and thinking parts for cleaner output
@@ -534,21 +538,40 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
// Set the consolidated parts array
if len(consolidatedParts) > 0 {
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts", translatorcommon.JoinRawArray(consolidatedParts))
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts", translatorcommon.JoinRawArray(consolidatedParts))
}
// Set usage metadata
if len(finalUsageJSON) > 0 {
- template, _ = sjson.SetRawBytes(template, "usageMetadata", finalUsageJSON)
+ _ = templateDoc.SetRawBytes("usageMetadata", finalUsageJSON)
}
- return template
+ return templateDoc.Bytes()
}
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}
+// geminiCompatibleThoughtSignaturePart omits incompatible signatures rather
+// than emitting a function-call bypass sentinel in a thought part's byte field.
+func geminiCompatibleThoughtSignaturePart(rawSignature string) []byte {
+ signature := sigcompat.GeminiReplaySignatureOrBypass(rawSignature, sigcompat.SignatureBlockKindGeminiModelPart)
+ if sigcompat.IsGeminiThoughtSignatureBypass(signature) {
+ return nil
+ }
+ if _, errDecode := base64.StdEncoding.DecodeString(signature); errDecode != nil {
+ decoded, errRawDecode := base64.RawStdEncoding.DecodeString(signature)
+ if errRawDecode != nil {
+ return nil
+ }
+ signature = base64.StdEncoding.EncodeToString(decoded)
+ }
+ partDoc := ojson.NewDocString(`{"thought":true,"thoughtSignature":""}`)
+ _ = partDoc.Set("thoughtSignature", signature)
+ return partDoc.Bytes()
+}
+
// consolidateParts merges consecutive text parts and thinking parts to create a cleaner response.
// This function processes the parts array to combine adjacent text elements and thinking elements
// into single consolidated parts, which results in a more readable and efficient response structure.
@@ -568,7 +591,7 @@ func consolidateParts(parts [][]byte) [][]byte {
// Flush accumulated text content to the consolidated parts array
if hasText && currentTextPart.Len() > 0 {
textPartJSON := []byte(`{"text":""}`)
- textPartJSON, _ = sjson.SetBytes(textPartJSON, "text", currentTextPart.String())
+ textPartJSON, _ = ojson.SetBytes(textPartJSON, "text", currentTextPart.String())
consolidated = append(consolidated, textPartJSON)
currentTextPart.Reset()
hasText = false
@@ -578,12 +601,12 @@ func consolidateParts(parts [][]byte) [][]byte {
flushThought := func() {
// Flush accumulated thinking content to the consolidated parts array
if hasThought && (currentThoughtPart.Len() > 0 || currentThoughtSignature != "") {
- thoughtPartJSON := []byte(`{"thought":true,"text":""}`)
- thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "text", currentThoughtPart.String())
+ thoughtPartDoc := ojson.NewDoc([]byte(`{"thought":true,"text":""}`))
+ _ = thoughtPartDoc.Set("text", currentThoughtPart.String())
if currentThoughtSignature != "" {
- thoughtPartJSON, _ = sjson.SetBytes(thoughtPartJSON, "thoughtSignature", currentThoughtSignature)
+ _ = thoughtPartDoc.Set("thoughtSignature", currentThoughtSignature)
}
- consolidated = append(consolidated, thoughtPartJSON)
+ consolidated = append(consolidated, thoughtPartDoc.Bytes())
currentThoughtPart.Reset()
currentThoughtSignature = ""
hasThought = false
@@ -591,7 +614,7 @@ func consolidateParts(parts [][]byte) [][]byte {
}
for _, partJSON := range parts {
- part := gjson.ParseBytes(partJSON)
+ part := util.ParseGJSONBytesNoCopy(partJSON)
if !part.Exists() || !part.IsObject() {
// Flush any pending parts and add this non-text part
flushText()
@@ -601,19 +624,19 @@ func consolidateParts(parts [][]byte) [][]byte {
}
thought := part.Get("thought")
- if thought.Exists() && thought.Type == gjson.True {
+ if thought.Exists() && thought.Type == ojson.True {
// This is a thinking part - flush any pending text first
flushText() // Flush any pending text first
- if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
+ if text := part.Get("text"); text.Exists() && text.Type == ojson.String {
currentThoughtPart.WriteString(text.String())
hasThought = true
}
- if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == gjson.String && sig.String() != "" {
+ if sig := part.Get("thoughtSignature"); sig.Exists() && sig.Type == ojson.String && sig.String() != "" {
currentThoughtSignature = sig.String()
hasThought = true
}
- } else if text := part.Get("text"); text.Exists() && text.Type == gjson.String {
+ } else if text := part.Get("text"); text.Exists() && text.Type == ojson.String {
// This is a regular text part - flush any pending thought first
flushThought() // Flush any pending thought first
diff --git a/internal/translator/claude/gemini/claude_gemini_response_test.go b/internal/translator/claude/gemini/claude_gemini_response_test.go
index 3e2a623fe56..e2b77a52f5b 100644
--- a/internal/translator/claude/gemini/claude_gemini_response_test.go
+++ b/internal/translator/claude/gemini/claude_gemini_response_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeResponseToGemini_StreamPreservesToolUseID(t *testing.T) {
@@ -30,7 +30,7 @@ func TestConvertClaudeResponseToGemini_StreamPreservesToolUseID(t *testing.T) {
t.Fatalf("expected content_block_stop to emit 1 chunk, got %d", len(out))
}
- got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.functionCall.id").String()
+ got := ojson.GetBytesNoCopy(out[0], "candidates.0.content.parts.0.functionCall.id").String()
if got != "toolu_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out[0]))
}
@@ -46,7 +46,7 @@ func TestConvertClaudeResponseToGeminiNonStreamPreservesToolUseID(t *testing.T)
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
- got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
+ got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.id").String()
if got != "toolu_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "toolu_gateway", got, string(out))
}
@@ -61,9 +61,9 @@ func TestConvertClaudeResponseToGemini_StreamThinkingSignature(t *testing.T) {
wantSignature string
}{
{
- name: "foreign claude signature maps to bypass sentinel",
+ name: "foreign claude signature is omitted",
signature: "foreign_claude_sig_123",
- wantSignature: "skip_thought_signature_validator",
+ wantSignature: "",
},
{
name: "preserves valid gemini signature",
@@ -89,11 +89,11 @@ func TestConvertClaudeResponseToGemini_StreamThinkingSignature(t *testing.T) {
[]byte(`data: {"type":"message_stop"}`),
}
- var emittedParts []gjson.Result
+ var emittedParts []ojson.Result
for _, chunk := range chunks {
out := ConvertClaudeResponseToGemini(ctx, "gemini-2.5-pro", nil, nil, chunk, ¶m)
for _, c := range out {
- parts := gjson.GetBytes(c, "candidates.0.content.parts").Array()
+ parts := ojson.GetBytesNoCopy(c, "candidates.0.content.parts").Array()
emittedParts = append(emittedParts, parts...)
}
}
@@ -121,9 +121,9 @@ func TestConvertClaudeResponseToGeminiNonStream_ThinkingSignature(t *testing.T)
wantSignature string
}{
{
- name: "foreign claude signature maps to bypass sentinel",
+ name: "foreign claude signature is omitted",
signature: "foreign_claude_sig_123",
- wantSignature: "skip_thought_signature_validator",
+ wantSignature: "",
},
{
name: "preserves valid gemini signature",
@@ -149,7 +149,7 @@ func TestConvertClaudeResponseToGeminiNonStream_ThinkingSignature(t *testing.T)
out := ConvertClaudeResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, raw, nil)
- thoughtPart := gjson.GetBytes(out, "candidates.0.content.parts.0")
+ thoughtPart := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0")
if !thoughtPart.Get("thought").Bool() || thoughtPart.Get("text").String() != "thinking text" {
t.Fatalf("expected thought part with text 'thinking text', got %s", thoughtPart.Raw)
}
@@ -157,7 +157,7 @@ func TestConvertClaudeResponseToGeminiNonStream_ThinkingSignature(t *testing.T)
t.Fatalf("expected thoughtSignature %q, got %q", tt.wantSignature, got)
}
- textPart := gjson.GetBytes(out, "candidates.0.content.parts.1")
+ textPart := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.1")
if textPart.Get("text").String() != "final answer" {
t.Fatalf("expected text part 'final answer', got %s", textPart.Raw)
}
diff --git a/internal/translator/claude/gemini/claude_gemini_user_turn_test.go b/internal/translator/claude/gemini/claude_gemini_user_turn_test.go
index e260033e4b8..df770274b96 100644
--- a/internal/translator/claude/gemini/claude_gemini_user_turn_test.go
+++ b/internal/translator/claude/gemini/claude_gemini_user_turn_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -36,7 +36,7 @@ func requireInlineDataRefusal(t *testing.T, envelope sdktranslator.RequestEnvelo
if got, want := envelope.Err.Error(), "unsupported content part: inlineData"; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
}
@@ -81,7 +81,7 @@ func TestGeminiToClaudeRealTextBesideAudioIsSentWithoutAPlaceholder(t *testing.T
t.Fatalf("placeholder text was synthesized: %s", envelope.Body)
}
found := false
- for _, block := range gjson.GetBytes(envelope.Body, "messages.2.content").Array() {
+ for _, block := range ojson.GetBytesNoCopy(envelope.Body, "messages.2.content").Array() {
if block.Get("text").String() == "keep me" {
found = true
}
@@ -116,14 +116,14 @@ func TestGeminiToClaudeModelAudioKeepsItsPlaceholder(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "messages.1.content.0.text").String(); got != "Media content: inline data (Type: audio/wav)" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "messages.1.content.0.text").String(); got != "Media content: inline data (Type: audio/wav)" {
t.Fatalf("assistant placeholder = %q, body = %s", got, envelope.Body)
}
}
func TestGeminiToClaudeExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"contents":[{"role":"user","parts":[` + geminiAudioPart + `]}]}`)
- if body, _ := ConvertGeminiRequestToClaude("claude-sonnet-4", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertGeminiRequestToClaude("claude-sonnet-4", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/claude/gemini/noop_optimization_test.go b/internal/translator/claude/gemini/noop_optimization_test.go
index f6e2e8bd943..734358cf17a 100644
--- a/internal/translator/claude/gemini/noop_optimization_test.go
+++ b/internal/translator/claude/gemini/noop_optimization_test.go
@@ -3,13 +3,14 @@ package gemini
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestNormalizeClaudeToolSchemaPreservesCanonicalSchema(t *testing.T) {
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false,"$schema":"http://json-schema.org/draft-07/schema#"}`)
- output := normalizeClaudeToolSchema(gjson.ParseBytes(input))
+ output := normalizeClaudeToolSchema(util.ParseGJSONBytesNoCopy(input))
if string(output) != string(input) {
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
@@ -19,12 +20,12 @@ func TestNormalizeClaudeToolSchemaPreservesCanonicalSchema(t *testing.T) {
func TestNormalizeClaudeToolSchemaCorrectsWrongTypes(t *testing.T) {
input := []byte(`{"type":"object","additionalProperties":"false","$schema":123}`)
- output := normalizeClaudeToolSchema(gjson.ParseBytes(input))
+ output := normalizeClaudeToolSchema(util.ParseGJSONBytesNoCopy(input))
- if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False {
+ if additionalProperties := ojson.GetBytesNoCopy(output, "additionalProperties"); additionalProperties.Type != ojson.False {
t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw)
}
- if schema := gjson.GetBytes(output, "$schema"); schema.Type != gjson.String || schema.String() != "http://json-schema.org/draft-07/schema#" {
+ if schema := ojson.GetBytesNoCopy(output, "$schema"); schema.Type != ojson.String || schema.String() != "http://json-schema.org/draft-07/schema#" {
t.Fatalf("$schema = %s, want canonical string", schema.Raw)
}
}
@@ -44,7 +45,7 @@ func TestLowercaseClaudeToolSchemaTypesNormalizesNonStringType(t *testing.T) {
output := lowercaseClaudeToolSchemaTypes(input)
- if got := gjson.GetBytes(output, "input_schema.type"); got.Type != gjson.String || got.String() != "123" {
+ if got := ojson.GetBytesNoCopy(output, "input_schema.type"); got.Type != ojson.String || got.String() != "123" {
t.Fatalf("input_schema.type = %s, want string 123", got.Raw)
}
}
@@ -54,10 +55,10 @@ func TestLowercaseClaudeToolSchemaTypesNormalizesUppercaseTypes(t *testing.T) {
output := lowercaseClaudeToolSchemaTypes(input)
- if got := gjson.GetBytes(output, "input_schema.type").String(); got != "object" {
+ if got := ojson.GetBytesNoCopy(output, "input_schema.type").String(); got != "object" {
t.Fatalf("input_schema.type = %q, want object", got)
}
- if got := gjson.GetBytes(output, "input_schema.properties.value.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(output, "input_schema.properties.value.type").String(); got != "string" {
t.Fatalf("nested type = %q, want string", got)
}
}
diff --git a/internal/translator/claude/interactions/interactions_claude_request.go b/internal/translator/claude/interactions/interactions_claude_request.go
index 556b9a7690d..0c45aa61398 100644
--- a/internal/translator/claude/interactions/interactions_claude_request.go
+++ b/internal/translator/claude/interactions/interactions_claude_request.go
@@ -4,11 +4,10 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -20,20 +19,20 @@ func ConvertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, s
// because its only media part has no Claude equivalent.
func convertInteractionsRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
var run interactionsClaudeUserRun
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","max_tokens":32000,"messages":[]}`)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDoc([]byte(`{"model":"","max_tokens":32000,"messages":[]}`))
+ _ = outDoc.Set("model", modelName)
if stream || root.Get("stream").Bool() {
- out, _ = sjson.SetBytes(out, "stream", true)
+ _ = outDoc.Set("stream", true)
}
- out = copyInteractionsSystemToClaude(out, root)
- out = copyInteractionsGenerationConfigToClaude(out, root)
+ copyInteractionsSystemToClaudeDoc(outDoc, root)
+ copyInteractionsGenerationConfigToClaudeDoc(outDoc, root)
messageAccumulator := translatorcommon.NewClaudeMessageAccumulator(int(root.Get("input.#").Int()))
appendInteractionsInputToClaudeMessages(messageAccumulator, root.Get("input"), &run)
run.end()
- out = translatorcommon.SetRawArrayItems(out, "messages", messageAccumulator.Messages())
- out = copyInteractionsToolsToClaude(out, root)
- return out, run.drops.Err()
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "messages", messageAccumulator.Messages())
+ copyInteractionsToolsToClaudeDoc(outDoc, root)
+ return outDoc.Bytes(), run.drops.Err()
}
// interactionsClaudeUserRun follows the consecutive user content that the message
@@ -59,144 +58,140 @@ func (r *interactionsClaudeUserRun) end() {
r.sendable = 0
}
-func copyInteractionsSystemToClaude(out []byte, root gjson.Result) []byte {
+func copyInteractionsSystemToClaudeDoc(outDoc *ojson.Doc, root ojson.Result) {
sys := root.Get("system_instruction")
if !sys.Exists() {
sys = root.Get("systemInstruction")
}
text := interactionsClaudeText(sys)
if text == "" {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "system", text)
- return out
+ _ = outDoc.Set("system", text)
}
-func copyInteractionsGenerationConfigToClaude(out []byte, root gjson.Result) []byte {
+func copyInteractionsGenerationConfigToClaudeDoc(outDoc *ojson.Doc, root ojson.Result) {
cfg := root.Get("generation_config")
if !cfg.Exists() {
cfg = root.Get("generationConfig")
}
if cfg.Exists() {
- out = copyJSONField(out, cfg, "max_output_tokens", "max_tokens")
- out = copyJSONField(out, cfg, "maxOutputTokens", "max_tokens")
- out = copyJSONField(out, cfg, "top_p", "top_p")
- out = copyJSONField(out, cfg, "topP", "top_p")
- out = copyJSONField(out, cfg, "temperature", "temperature")
- out = copyJSONField(out, cfg, "stop_sequences", "stop_sequences")
- out = copyJSONField(out, cfg, "stopSequences", "stop_sequences")
- out = copyInteractionsThinkingConfigToClaude(out, cfg)
- out = copyInteractionsToolChoiceToClaude(out, cfg.Get("tool_choice"))
- out = copyInteractionsToolChoiceToClaude(out, cfg.Get("toolChoice"))
- }
- out = copyInteractionsReasoningToClaude(out, root.Get("reasoning"))
- out = copyInteractionsToolChoiceToClaude(out, root.Get("tool_choice"))
- out = copyInteractionsToolChoiceToClaude(out, root.Get("toolChoice"))
- return out
+ copyJSONFieldDoc(outDoc, cfg, "max_output_tokens", "max_tokens")
+ copyJSONFieldDoc(outDoc, cfg, "maxOutputTokens", "max_tokens")
+ copyJSONFieldDoc(outDoc, cfg, "top_p", "top_p")
+ copyJSONFieldDoc(outDoc, cfg, "topP", "top_p")
+ copyJSONFieldDoc(outDoc, cfg, "temperature", "temperature")
+ copyJSONFieldDoc(outDoc, cfg, "stop_sequences", "stop_sequences")
+ copyJSONFieldDoc(outDoc, cfg, "stopSequences", "stop_sequences")
+ copyInteractionsThinkingConfigToClaudeDoc(outDoc, cfg)
+ copyInteractionsToolChoiceToClaudeDoc(outDoc, cfg.Get("tool_choice"))
+ copyInteractionsToolChoiceToClaudeDoc(outDoc, cfg.Get("toolChoice"))
+ }
+ copyInteractionsReasoningToClaudeDoc(outDoc, root.Get("reasoning"))
+ copyInteractionsToolChoiceToClaudeDoc(outDoc, root.Get("tool_choice"))
+ copyInteractionsToolChoiceToClaudeDoc(outDoc, root.Get("toolChoice"))
}
-func copyJSONField(out []byte, root gjson.Result, from, to string) []byte {
+func copyJSONFieldDoc(outDoc *ojson.Doc, root ojson.Result, from, to string) {
value := root.Get(from)
if !value.Exists() {
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, to, []byte(value.Raw))
- return out
+ _ = outDoc.SetRaw(to, value.Raw)
}
-func copyInteractionsThinkingConfigToClaude(out []byte, cfg gjson.Result) []byte {
+func copyInteractionsThinkingConfigToClaudeDoc(outDoc *ojson.Doc, cfg ojson.Result) {
level := firstClaudeInteractionsExisting(cfg, "thinking_level", "thinkingLevel", "reasoning.effort")
if !level.Exists() {
- return out
+ return
}
- return setClaudeThinkingFromLevel(out, level.String())
+ setClaudeThinkingFromLevelDoc(outDoc, level.String())
}
-func copyInteractionsReasoningToClaude(out []byte, reasoning gjson.Result) []byte {
+func copyInteractionsReasoningToClaudeDoc(outDoc *ojson.Doc, reasoning ojson.Result) {
if !reasoning.Exists() {
- return out
+ return
}
if effort := reasoning.Get("effort"); effort.Exists() {
- return setClaudeThinkingFromLevel(out, effort.String())
+ setClaudeThinkingFromLevelDoc(outDoc, effort.String())
+ return
}
if level := reasoning.Get("thinking_level"); level.Exists() {
- return setClaudeThinkingFromLevel(out, level.String())
+ setClaudeThinkingFromLevelDoc(outDoc, level.String())
}
- return out
}
-func setClaudeThinkingFromLevel(out []byte, level string) []byte {
+func setClaudeThinkingFromLevelDoc(outDoc *ojson.Doc, level string) {
normalized := strings.ToLower(strings.TrimSpace(level))
if normalized == "" {
- return out
+ return
}
switch normalized {
case "none", "disabled", "off", "false":
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- return out
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ return
case "auto", "adaptive":
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- return out
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ return
}
if budget, ok := thinking.ConvertLevelToBudget(normalized); ok {
switch {
case budget == 0:
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
+ _ = outDoc.Set("thinking.type", "disabled")
case budget < 0:
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
+ _ = outDoc.Set("thinking.type", "enabled")
default:
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Set("thinking.budget_tokens", budget)
}
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.SetBytes(out, "output_config.effort", normalized)
- return out
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Set("output_config.effort", normalized)
}
-func appendInteractionsInputToClaudeMessages(accumulator *translatorcommon.ClaudeMessageAccumulator, input gjson.Result, run *interactionsClaudeUserRun) {
+func appendInteractionsInputToClaudeMessages(accumulator *translatorcommon.ClaudeMessageAccumulator, input ojson.Result, run *interactionsClaudeUserRun) {
if !input.Exists() {
return
}
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
step := []byte(`{"type":"user_input","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "content.0.text", input.String())
- appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(step), "user", false, run)
+ step, _ = ojson.SetBytes(step, "content.0.text", input.String())
+ appendInteractionsStepToClaude(accumulator, util.ParseGJSONBytesNoCopy(step), "user", false, run)
return
}
if input.IsObject() {
appendInteractionsInputItemToClaude(accumulator, input, run)
return
}
- input.ForEach(func(_, step gjson.Result) bool {
+ input.ForEach(func(_, step ojson.Result) bool {
appendInteractionsInputItemToClaude(accumulator, step, run)
return true
})
}
-func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, run *interactionsClaudeUserRun) {
+func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step ojson.Result, run *interactionsClaudeUserRun) {
if step.Get("steps").IsArray() {
defaultRole := "user"
if role := step.Get("role").String(); role == "model" || role == "assistant" {
defaultRole = "assistant"
}
instruction := translatorcommon.IsInteractionsInstructionStep(step, false)
- step.Get("steps").ForEach(func(_, nestedStep gjson.Result) bool {
+ step.Get("steps").ForEach(func(_, nestedStep ojson.Result) bool {
appendInteractionsStepToClaude(accumulator, nestedStep, defaultRole, instruction, run)
return true
})
return
}
if step.Get("parts").Exists() {
- wrapped := []byte(`{"type":"user_input","content":[]}`)
+ wrappedDoc := ojson.NewDoc([]byte(`{"type":"user_input","content":[]}`))
if role := step.Get("role").String(); role == "model" || role == "assistant" {
- wrapped, _ = sjson.SetBytes(wrapped, "type", "model_output")
+ _ = wrappedDoc.Set("type", "model_output")
}
- wrapped, _ = sjson.SetRawBytes(wrapped, "content", []byte(step.Get("parts").Raw))
- appendInteractionsStepToClaude(accumulator, gjson.ParseBytes(wrapped), "user", translatorcommon.IsInteractionsInstructionStep(step, false), run)
+ _ = wrappedDoc.SetRaw("content", step.Get("parts").Raw)
+ appendInteractionsStepToClaude(accumulator, util.ParseGJSONBytesNoCopy(wrappedDoc.Bytes()), "user", translatorcommon.IsInteractionsInstructionStep(step, false), run)
return
}
stepType := step.Get("type").String()
@@ -216,14 +211,14 @@ func appendInteractionsInputItemToClaude(accumulator *translatorcommon.ClaudeMes
// a developer or system wrapper. Developer and system content is sent as user
// content, but it is not the user's own turn: it closes the open user turn and
// never counts toward keeping an emptied one alive.
-func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, defaultRole string, instruction bool, run *interactionsClaudeUserRun) {
+func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step ojson.Result, defaultRole string, instruction bool, run *interactionsClaudeUserRun) {
role := defaultRole
if stepRole := step.Get("role").String(); stepRole == "user" || stepRole == "assistant" {
role = stepRole
}
userContent := role == "user" && !translatorcommon.IsInteractionsInstructionStep(step, instruction)
contentItems := make([][]byte, 0, 4)
- appendPart := func(part gjson.Result) {
+ appendPart := func(part ojson.Result) {
converted := interactionsContentToClaude(part, role)
if len(converted) == 0 {
if userContent {
@@ -239,19 +234,19 @@ func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageA
}
}
stepContent := step.Get("content")
- if stepContent.Type == gjson.String {
+ if stepContent.Type == ojson.String {
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", stepContent.String())
- appendPart(gjson.ParseBytes(part))
+ part, _ = ojson.SetBytes(part, "text", stepContent.String())
+ appendPart(util.ParseGJSONBytesNoCopy(part))
} else if stepContent.IsArray() {
- stepContent.ForEach(func(_, part gjson.Result) bool {
+ stepContent.ForEach(func(_, part ojson.Result) bool {
appendPart(part)
return true
})
} else if text := step.Get("text"); text.Exists() {
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", text.String())
- appendPart(gjson.ParseBytes(part))
+ part, _ = ojson.SetBytes(part, "text", text.String())
+ appendPart(util.ParseGJSONBytesNoCopy(part))
} else if interactionsClaudeDroppedMedia(step) != "" {
// A bare media content object stands for a step of its own.
appendPart(step)
@@ -262,13 +257,13 @@ func appendInteractionsStepToClaude(accumulator *translatorcommon.ClaudeMessageA
if !userContent {
run.end()
}
- msg := []byte(`{"role":"","content":[]}`)
- msg, _ = sjson.SetBytes(msg, "role", role)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
- accumulator.Append(msg)
+ msgDoc := ojson.NewDoc([]byte(`{"role":"","content":[]}`))
+ _ = msgDoc.Set("role", role)
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
+ accumulator.Append(msgDoc.Bytes())
}
-func interactionsContentToClaude(part gjson.Result, role string) []byte {
+func interactionsContentToClaude(part ojson.Result, role string) []byte {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
@@ -276,14 +271,14 @@ func interactionsContentToClaude(part gjson.Result, role string) []byte {
switch partType {
case "text":
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
+ textPart, _ = ojson.SetBytes(textPart, "text", part.Get("text").String())
return textPart
case "thinking", "reasoning":
if role != "assistant" {
return nil
}
thinkingPart := []byte(`{"type":"thinking","thinking":""}`)
- thinkingPart, _ = sjson.SetBytes(thinkingPart, "thinking", interactionsClaudeText(part))
+ thinkingPart, _ = ojson.SetBytes(thinkingPart, "thinking", interactionsClaudeText(part))
return thinkingPart
case "image":
imagePart, _ := interactionsClaudeMediaPart(part, "image")
@@ -294,7 +289,7 @@ func interactionsContentToClaude(part gjson.Result, role string) []byte {
default:
if text := interactionsClaudeText(part); text != "" {
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", text)
+ textPart, _ = ojson.SetBytes(textPart, "text", text)
return textPart
}
// A user attachment Claude cannot carry is never replaced by placeholder text;
@@ -302,35 +297,35 @@ func interactionsContentToClaude(part gjson.Result, role string) []byte {
// echoes earlier output, so it keeps the placeholder.
if role != "user" && (part.Get("data").String() != "" || part.Get("file_data").String() != "") {
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", fmt.Sprintf("[%s content omitted]", partType))
+ textPart, _ = ojson.SetBytes(textPart, "text", fmt.Sprintf("[%s content omitted]", partType))
return textPart
}
}
return nil
}
-func appendInteractionsFunctionCallToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, run *interactionsClaudeUserRun) {
- toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUse, _ = sjson.SetBytes(toolUse, "id", interactionsClaudeToolID(step))
- toolUse, _ = sjson.SetBytes(toolUse, "name", util.SanitizeClaudeFunctionName(step.Get("name").String()))
+func appendInteractionsFunctionCallToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step ojson.Result, run *interactionsClaudeUserRun) {
+ toolUseDoc := ojson.NewDoc([]byte(`{"type":"tool_use","id":"","name":"","input":{}}`))
+ _ = toolUseDoc.Set("id", interactionsClaudeToolID(step))
+ _ = toolUseDoc.Set("name", util.SanitizeClaudeFunctionName(step.Get("name").String()))
args := step.Get("arguments")
if !args.Exists() {
args = step.Get("args")
}
if args.Exists() && args.IsObject() {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(args.Raw))
+ _ = toolUseDoc.SetRaw("input", args.Raw)
}
msg := []byte(`{"role":"assistant","content":[]}`)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolUse}))
+ msg, _ = ojson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolUseDoc.Bytes()}))
run.end()
accumulator.Append(msg)
}
-func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step gjson.Result, run *interactionsClaudeUserRun) {
- toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
- toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", interactionsClaudeToolID(step))
+func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.ClaudeMessageAccumulator, step ojson.Result, run *interactionsClaudeUserRun) {
+ toolResultDoc := ojson.NewDoc([]byte(`{"type":"tool_result","tool_use_id":"","content":""}`))
+ _ = toolResultDoc.Set("tool_use_id", interactionsClaudeToolID(step))
if isError := step.Get("is_error"); isError.Exists() && isError.Bool() {
- toolResult, _ = sjson.SetBytes(toolResult, "is_error", true)
+ _ = toolResultDoc.Set("is_error", true)
}
result := step.Get("result")
if !result.Exists() {
@@ -339,34 +334,34 @@ func appendInteractionsFunctionResultToClaude(accumulator *translatorcommon.Clau
switch {
case result.IsArray():
contentItems := make([][]byte, 0, 4)
- result.ForEach(func(_, part gjson.Result) bool {
+ result.ForEach(func(_, part ojson.Result) bool {
if converted := interactionsContentToClaude(part, "tool_result"); len(converted) > 0 {
contentItems = append(contentItems, converted)
}
return true
})
- toolResult, _ = sjson.SetRawBytes(toolResult, "content", translatorcommon.JoinRawArray(contentItems))
+ _ = toolResultDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
case result.Exists() && result.Raw != "":
- toolResult, _ = sjson.SetBytes(toolResult, "content", result.Raw)
+ _ = toolResultDoc.Set("content", result.Raw)
default:
- toolResult, _ = sjson.SetBytes(toolResult, "content", "")
+ _ = toolResultDoc.Set("content", "")
}
msg := []byte(`{"role":"user","content":[]}`)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolResult}))
+ msg, _ = ojson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{toolResultDoc.Bytes()}))
// A tool result is content the model reads, so it keeps the surrounding user turn.
run.add()
accumulator.Append(msg)
}
-func copyInteractionsToolsToClaude(out []byte, root gjson.Result) []byte {
+func copyInteractionsToolsToClaudeDoc(outDoc *ojson.Doc, root ojson.Result) {
tools := root.Get("tools")
if !tools.Exists() || !tools.IsArray() {
- return out
+ return
}
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if tool.Get("function_declarations").IsArray() {
- tool.Get("function_declarations").ForEach(func(_, decl gjson.Result) bool {
+ tool.Get("function_declarations").ForEach(func(_, decl ojson.Result) bool {
if converted := interactionsClaudeTool(decl); len(converted) > 0 {
toolItems = append(toolItems, converted)
}
@@ -375,7 +370,7 @@ func copyInteractionsToolsToClaude(out []byte, root gjson.Result) []byte {
return true
}
if tool.Get("functionDeclarations").IsArray() {
- tool.Get("functionDeclarations").ForEach(func(_, decl gjson.Result) bool {
+ tool.Get("functionDeclarations").ForEach(func(_, decl ojson.Result) bool {
if converted := interactionsClaudeTool(decl); len(converted) > 0 {
toolItems = append(toolItems, converted)
}
@@ -389,12 +384,11 @@ func copyInteractionsToolsToClaude(out []byte, root gjson.Result) []byte {
return true
})
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func interactionsClaudeTool(tool gjson.Result) []byte {
+func interactionsClaudeTool(tool ojson.Result) []byte {
name := tool.Get("name").String()
if name == "" {
name = tool.Get("function.name").String()
@@ -402,39 +396,39 @@ func interactionsClaudeTool(tool gjson.Result) []byte {
if name == "" {
return nil
}
- converted := []byte(`{"name":"","input_schema":{"type":"object","properties":{}}}`)
- converted, _ = sjson.SetBytes(converted, "name", util.SanitizeClaudeFunctionName(name))
+ convertedDoc := ojson.NewDoc([]byte(`{"name":"","input_schema":{"type":"object","properties":{}}}`))
+ _ = convertedDoc.Set("name", util.SanitizeClaudeFunctionName(name))
if desc := tool.Get("description"); desc.Exists() {
- converted, _ = sjson.SetBytes(converted, "description", desc.String())
+ _ = convertedDoc.Set("description", desc.String())
} else if desc := tool.Get("function.description"); desc.Exists() {
- converted, _ = sjson.SetBytes(converted, "description", desc.String())
+ _ = convertedDoc.Set("description", desc.String())
}
params := firstClaudeInteractionsExisting(tool, "parameters", "parametersJsonSchema", "parameters_json_schema", "input_schema")
if params.Exists() && params.IsObject() {
- converted, _ = sjson.SetRawBytes(converted, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(params.Raw)))
+ _ = convertedDoc.SetRawBytes("input_schema", util.NormalizeClaudeToolInputSchema([]byte(params.Raw)))
}
- return converted
+ return convertedDoc.Bytes()
}
-func copyInteractionsToolChoiceToClaude(out []byte, toolChoice gjson.Result) []byte {
+func copyInteractionsToolChoiceToClaudeDoc(outDoc *ojson.Doc, toolChoice ojson.Result) {
if !toolChoice.Exists() {
- return out
+ return
}
switch toolChoice.Type {
- case gjson.String:
+ case ojson.String:
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "auto":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
case "required", "any":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
}
- case gjson.JSON:
+ case ojson.JSON:
toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String()))
switch toolType {
case "auto":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
case "required", "any":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
case "function", "tool":
name := toolChoice.Get("name").String()
if name == "" {
@@ -442,15 +436,14 @@ func copyInteractionsToolChoiceToClaude(out []byte, toolChoice gjson.Result) []b
}
if name != "" {
choice := []byte(`{"type":"tool","name":""}`)
- choice, _ = sjson.SetBytes(choice, "name", util.SanitizeClaudeFunctionName(name))
- out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
+ choice, _ = ojson.SetBytes(choice, "name", util.SanitizeClaudeFunctionName(name))
+ _ = outDoc.SetRawBytes("tool_choice", choice)
}
}
}
- return out
}
-func interactionsClaudeToolID(step gjson.Result) string {
+func interactionsClaudeToolID(step ojson.Result) string {
for _, path := range []string{"call_id", "id", "tool_use_id"} {
if value := step.Get(path).String(); value != "" {
return util.SanitizeClaudeToolID(value)
@@ -462,11 +455,11 @@ func interactionsClaudeToolID(step gjson.Result) string {
return "toolu_interactions"
}
-func interactionsClaudeText(value gjson.Result) string {
+func interactionsClaudeText(value ojson.Result) string {
if !value.Exists() {
return ""
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
return value.String()
}
if text := value.Get("text"); text.Exists() {
@@ -480,7 +473,7 @@ func interactionsClaudeText(value gjson.Result) string {
}
if parts := value.Get("parts"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
text := interactionsClaudeText(part)
if text == "" {
return true
@@ -499,7 +492,7 @@ func interactionsClaudeText(value gjson.Result) string {
// interactionsClaudeMediaPart maps an Interactions image or document onto a Claude
// block. Inline bytes become a base64 source and an http(s) uri becomes a url
// source. It reports false when the part carries neither.
-func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte, bool) {
+func interactionsClaudeMediaPart(part ojson.Result, claudeType string) ([]byte, bool) {
mimeType := firstClaudeInteractionsExisting(part, "mime_type", "mimeType", "media_type", "mediaType").String()
data := firstClaudeInteractionsExisting(part, "data", "file_data", "fileData").String()
if source := part.Get("source"); source.Exists() {
@@ -511,18 +504,18 @@ func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte,
}
}
if mimeType != "" && data != "" {
- out := []byte(`{"type":"","source":{"type":"base64","media_type":"","data":""}}`)
- out, _ = sjson.SetBytes(out, "type", claudeType)
- out, _ = sjson.SetBytes(out, "source.media_type", mimeType)
- out, _ = sjson.SetBytes(out, "source.data", data)
- return out, true
+ outDoc := ojson.NewDoc([]byte(`{"type":"","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = outDoc.Set("type", claudeType)
+ _ = outDoc.Set("source.media_type", mimeType)
+ _ = outDoc.Set("source.data", data)
+ return outDoc.Bytes(), true
}
for _, path := range []string{"uri", "file_uri", "fileUri", "url"} {
if uri := strings.TrimSpace(part.Get(path).String()); translatorcommon.IsHTTPURL(uri) {
- out := []byte(`{"type":"","source":{"type":"url","url":""}}`)
- out, _ = sjson.SetBytes(out, "type", claudeType)
- out, _ = sjson.SetBytes(out, "source.url", uri)
- return out, true
+ outDoc := ojson.NewDoc([]byte(`{"type":"","source":{"type":"url","url":""}}`))
+ _ = outDoc.Set("type", claudeType)
+ _ = outDoc.Set("source.url", uri)
+ return outDoc.Bytes(), true
}
}
return nil, false
@@ -530,7 +523,7 @@ func interactionsClaudeMediaPart(part gjson.Result, claudeType string) ([]byte,
// interactionsClaudeDroppedMedia names the media type of a part that
// interactionsContentToClaude left out, or returns "" when the part is not media.
-func interactionsClaudeDroppedMedia(part gjson.Result) string {
+func interactionsClaudeDroppedMedia(part ojson.Result) string {
switch partType := part.Get("type").String(); partType {
case "image", "audio", "video", "document":
return partType
@@ -543,7 +536,7 @@ func interactionsClaudeDroppedMedia(part gjson.Result) string {
// interactionsClaudeDroppedPart names the type of a user part that
// interactionsContentToClaude left out: media, or any other part that carries
// bytes. It returns "" when the part holds nothing to report.
-func interactionsClaudeDroppedPart(part gjson.Result) string {
+func interactionsClaudeDroppedPart(part ojson.Result) string {
if mediaType := interactionsClaudeDroppedMedia(part); mediaType != "" {
return mediaType
}
@@ -556,15 +549,15 @@ func interactionsClaudeDroppedPart(part gjson.Result) string {
// interactionsClaudePartIsSendable reports whether a converted Claude block gives
// the model something to read. Blank text does not.
func interactionsClaudePartIsSendable(converted []byte) bool {
- block := gjson.ParseBytes(converted)
+ block := util.ParseGJSONBytesNoCopy(converted)
return block.Get("type").String() != "text" || strings.TrimSpace(block.Get("text").String()) != ""
}
-func firstClaudeInteractionsExisting(root gjson.Result, paths ...string) gjson.Result {
+func firstClaudeInteractionsExisting(root ojson.Result, paths ...string) ojson.Result {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
diff --git a/internal/translator/claude/interactions/interactions_claude_response.go b/internal/translator/claude/interactions/interactions_claude_response.go
index fe0e17f2e99..13df10f390d 100644
--- a/internal/translator/claude/interactions/interactions_claude_response.go
+++ b/internal/translator/claude/interactions/interactions_claude_response.go
@@ -7,9 +7,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var claudeInteractionsDataTag = []byte("data:")
@@ -53,35 +53,35 @@ func ConvertClaudeResponseToInteractionsNonStream(ctx context.Context, modelName
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
if root.Exists() && root.Get("content").Exists() {
return convertClaudeMessageToInteractions(modelName, root)
}
return convertClaudeSSEToInteractionsNonStream(modelName, rawJSON)
}
-func convertClaudeMessageToInteractions(modelName string, root gjson.Result) []byte {
- out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
- out, _ = sjson.SetBytes(out, "id", firstNonEmptyString(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
- out, _ = sjson.SetBytes(out, "model", firstNonEmptyString(root.Get("model").String(), modelName))
+func convertClaudeMessageToInteractions(modelName string, root ojson.Result) []byte {
+ outDoc := ojson.NewDoc([]byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`))
+ _ = outDoc.Set("id", firstNonEmptyString(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
+ _ = outDoc.Set("model", firstNonEmptyString(root.Get("model").String(), modelName))
steps := make([][]byte, 0, 4)
- root.Get("content").ForEach(func(_, part gjson.Result) bool {
+ root.Get("content").ForEach(func(_, part ojson.Result) bool {
if step := claudeContentBlockToInteractionsStep(part); len(step) > 0 {
steps = append(steps, step)
}
return true
})
if len(steps) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps", translatorcommon.JoinRawArray(steps))
+ _ = outDoc.SetRawBytes("steps", translatorcommon.JoinRawArray(steps))
}
- out = setInteractionsUsageFromClaude(out, "usage", root.Get("usage"))
- return out
+ setInteractionsUsageFromClaudeDoc(outDoc, "usage", root.Get("usage"))
+ return outDoc.Bytes()
}
func convertClaudeSSEToInteractionsNonStream(modelName string, rawJSON []byte) []byte {
- out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
- out, _ = sjson.SetBytes(out, "id", fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
- out, _ = sjson.SetBytes(out, "model", modelName)
+ outDoc := ojson.NewDoc([]byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`))
+ _ = outDoc.Set("id", fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
+ _ = outDoc.Set("model", modelName)
st := &claudeToInteractionsStreamState{Model: modelName}
st.ensureMaps()
steps := make([][]byte, 0, 8)
@@ -104,15 +104,15 @@ func convertClaudeSSEToInteractionsNonStream(modelName string, rawJSON []byte) [
if bytes.Equal(payload, []byte("[DONE]")) {
continue
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "message_start":
msg := root.Get("message")
if id := msg.Get("id").String(); id != "" {
- out, _ = sjson.SetBytes(out, "id", id)
+ _ = outDoc.Set("id", id)
}
if model := msg.Get("model").String(); model != "" {
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
}
mergeClaudeUsage(st, msg.Get("usage"))
case "content_block_start":
@@ -128,10 +128,10 @@ func convertClaudeSSEToInteractionsNonStream(modelName string, rawJSON []byte) [
}
}
if len(steps) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps", translatorcommon.JoinRawArray(steps))
+ _ = outDoc.SetRawBytes("steps", translatorcommon.JoinRawArray(steps))
}
- out = setInteractionsUsageFromClaude(out, "usage", claudeMergedUsage(st))
- return out
+ setInteractionsUsageFromClaudeDoc(outDoc, "usage", claudeMergedUsage(st))
+ return outDoc.Bytes()
}
func convertClaudeEventToInteractions(modelName string, rawJSON []byte, st *claudeToInteractionsStreamState) [][]byte {
@@ -142,12 +142,12 @@ func convertClaudeEventToInteractions(modelName string, rawJSON []byte, st *clau
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return appendClaudeInteractionsDone(nil, st)
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "message_start":
msg := root.Get("message")
- st.ID = firstNonEmptyString(msg.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
- st.Model = firstNonEmptyString(msg.Get("model").String(), st.Model, modelName)
+ st.ID = strings.Clone(firstNonEmptyString(msg.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
+ st.Model = strings.Clone(firstNonEmptyString(msg.Get("model").String(), st.Model, modelName))
mergeClaudeUsage(st, msg.Get("usage"))
return appendClaudeInteractionsCreated(nil, st, st.Model)
case "content_block_start":
@@ -173,7 +173,7 @@ func convertClaudeEventToInteractions(modelName string, rawJSON []byte, st *clau
return nil
}
-func claudeContentBlockStartToInteractions(modelName string, root gjson.Result, st *claudeToInteractionsStreamState) [][]byte {
+func claudeContentBlockStartToInteractions(modelName string, root ojson.Result, st *claudeToInteractionsStreamState) [][]byte {
out := appendClaudeInteractionsCreated(nil, st, modelName)
out = appendClaudeInteractionsStepStop(out, st)
index := int(root.Get("index").Int())
@@ -182,10 +182,10 @@ func claudeContentBlockStartToInteractions(modelName string, root gjson.Result,
st.CurrentStepByIndex[index] = stepType
if stepType == "function_call" {
if name := block.Get("name").String(); name != "" {
- st.ToolNames[index] = name
+ st.ToolNames[index] = strings.Clone(name)
}
if id := block.Get("id").String(); id != "" {
- st.ToolIDs[index] = id
+ st.ToolIDs[index] = strings.Clone(id)
}
if input := block.Get("input"); input.Exists() && input.IsObject() && input.Raw != "{}" {
builder := &strings.Builder{}
@@ -197,7 +197,7 @@ func claudeContentBlockStartToInteractions(modelName string, root gjson.Result,
return appendClaudeInteractionsStepStart(out, st, stepType, step)
}
-func claudeContentBlockDeltaToInteractions(modelName string, root gjson.Result, st *claudeToInteractionsStreamState) [][]byte {
+func claudeContentBlockDeltaToInteractions(modelName string, root ojson.Result, st *claudeToInteractionsStreamState) [][]byte {
index := int(root.Get("index").Int())
stepType := st.CurrentStepByIndex[index]
if stepType == "" {
@@ -218,7 +218,7 @@ func claudeContentBlockDeltaToInteractions(modelName string, root gjson.Result,
return appendClaudeDeltaToInteractions(nil, st, root.Get("delta"), index)
}
-func claudeContentBlockStopToInteractions(root gjson.Result, st *claudeToInteractionsStreamState) [][]byte {
+func claudeContentBlockStopToInteractions(root ojson.Result, st *claudeToInteractionsStreamState) [][]byte {
index := int(root.Get("index").Int())
out := appendClaudeInteractionsStepStop(nil, st)
delete(st.CurrentStepByIndex, index)
@@ -228,7 +228,7 @@ func claudeContentBlockStopToInteractions(root gjson.Result, st *claudeToInterac
return out
}
-func appendClaudeDeltaToInteractions(out [][]byte, st *claudeToInteractionsStreamState, delta gjson.Result, index int) [][]byte {
+func appendClaudeDeltaToInteractions(out [][]byte, st *claudeToInteractionsStreamState, delta ojson.Result, index int) [][]byte {
switch delta.Get("type").String() {
case "text_delta":
return appendClaudeInteractionsTextDelta(out, st, delta.Get("text").String(), false)
@@ -245,17 +245,17 @@ func appendClaudeDeltaToInteractions(out [][]byte, st *claudeToInteractionsStrea
return out
}
-func claudeContentBlockToInteractionsStep(part gjson.Result) []byte {
+func claudeContentBlockToInteractionsStep(part ojson.Result) []byte {
switch part.Get("type").String() {
case "text":
step := []byte(`{"type":"model_output","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
- content, _ = sjson.SetBytes(content, "text", part.Get("text").String())
+ content, _ = ojson.SetBytes(content, "text", part.Get("text").String())
return translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
case "thinking":
step := []byte(`{"type":"thought","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
- content, _ = sjson.SetBytes(content, "text", part.Get("thinking").String())
+ content, _ = ojson.SetBytes(content, "text", part.Get("thinking").String())
return translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
case "tool_use":
return claudeToolUseToInteractionsStep(part, strings.TrimSpace(part.Get("input").Raw))
@@ -263,48 +263,48 @@ func claudeContentBlockToInteractionsStep(part gjson.Result) []byte {
return nil
}
-func claudeToolUseToInteractionsStep(part gjson.Result, argsRaw string) []byte {
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
- step, _ = sjson.SetBytes(step, "name", part.Get("name").String())
+func claudeToolUseToInteractionsStep(part ojson.Result, argsRaw string) []byte {
+ stepDoc := ojson.NewDoc([]byte(`{"type":"function_call","name":"","arguments":{}}`))
+ _ = stepDoc.Set("name", part.Get("name").String())
if id := part.Get("id").String(); id != "" {
- step, _ = sjson.SetBytes(step, "id", id)
- step, _ = sjson.SetBytes(step, "call_id", id)
+ _ = stepDoc.Set("id", id)
+ _ = stepDoc.Set("call_id", id)
}
- if argsRaw != "" && gjson.Valid(argsRaw) {
- step, _ = sjson.SetRawBytes(step, "arguments", []byte(argsRaw))
+ if argsRaw != "" && ojson.Valid(argsRaw) {
+ _ = stepDoc.SetRaw("arguments", argsRaw)
}
- return step
+ return stepDoc.Bytes()
}
-func claudeBlockToInteractionsStep(block gjson.Result, stepType string) []byte {
- step := []byte(`{"type":""}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
+func claudeBlockToInteractionsStep(block ojson.Result, stepType string) []byte {
+ stepDoc := ojson.NewDoc([]byte(`{"type":""}`))
+ _ = stepDoc.Set("type", stepType)
if stepType == "function_call" {
- step, _ = sjson.SetBytes(step, "name", block.Get("name").String())
+ _ = stepDoc.Set("name", block.Get("name").String())
if id := block.Get("id").String(); id != "" {
- step, _ = sjson.SetBytes(step, "id", id)
- step, _ = sjson.SetBytes(step, "call_id", id)
+ _ = stepDoc.Set("id", id)
+ _ = stepDoc.Set("call_id", id)
}
- step, _ = sjson.SetRawBytes(step, "arguments", []byte(`{}`))
+ _ = stepDoc.SetRaw("arguments", `{}`)
}
- return step
+ return stepDoc.Bytes()
}
func claudeStepForKnownIndex(stepType string, index int, st *claudeToInteractionsStreamState) []byte {
- step := []byte(`{"type":""}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
+ stepDoc := ojson.NewDoc([]byte(`{"type":""}`))
+ _ = stepDoc.Set("type", stepType)
if stepType == "function_call" {
- step, _ = sjson.SetBytes(step, "name", st.ToolNames[index])
+ _ = stepDoc.Set("name", st.ToolNames[index])
if id := st.ToolIDs[index]; id != "" {
- step, _ = sjson.SetBytes(step, "id", id)
- step, _ = sjson.SetBytes(step, "call_id", id)
+ _ = stepDoc.Set("id", id)
+ _ = stepDoc.Set("call_id", id)
}
- step, _ = sjson.SetRawBytes(step, "arguments", []byte(`{}`))
+ _ = stepDoc.SetRaw("arguments", `{}`)
}
- return step
+ return stepDoc.Bytes()
}
-func claudeNonStreamContentBlockStart(root gjson.Result, st *claudeToInteractionsStreamState) {
+func claudeNonStreamContentBlockStart(root ojson.Result, st *claudeToInteractionsStreamState) {
index := int(root.Get("index").Int())
block := root.Get("content_block")
st.CurrentStepByIndex[index] = claudeBlockInteractionsStepType(block.Get("type").String())
@@ -320,7 +320,7 @@ func claudeNonStreamContentBlockStart(root gjson.Result, st *claudeToInteraction
}
}
-func claudeNonStreamContentBlockDelta(root gjson.Result, st *claudeToInteractionsStreamState) {
+func claudeNonStreamContentBlockDelta(root ojson.Result, st *claudeToInteractionsStreamState) {
index := int(root.Get("index").Int())
delta := root.Get("delta")
switch delta.Get("type").String() {
@@ -341,7 +341,7 @@ func claudeNonStreamContentBlockDelta(root gjson.Result, st *claudeToInteraction
}
}
-func claudeNonStreamContentBlockStop(root gjson.Result, st *claudeToInteractionsStreamState) []byte {
+func claudeNonStreamContentBlockStop(root ojson.Result, st *claudeToInteractionsStreamState) []byte {
index := int(root.Get("index").Int())
stepType := st.CurrentStepByIndex[index]
builder := st.ToolArgs[index]
@@ -354,17 +354,17 @@ func claudeNonStreamContentBlockStop(root gjson.Result, st *claudeToInteractions
case "thought":
step = []byte(`{"type":"thought","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
- content, _ = sjson.SetBytes(content, "text", text)
+ content, _ = ojson.SetBytes(content, "text", text)
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
case "function_call":
- part := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- part, _ = sjson.SetBytes(part, "id", st.ToolIDs[index])
- part, _ = sjson.SetBytes(part, "name", st.ToolNames[index])
- step = claudeToolUseToInteractionsStep(gjson.ParseBytes(part), strings.TrimSpace(text))
+ partDoc := ojson.NewDoc([]byte(`{"type":"tool_use","id":"","name":"","input":{}}`))
+ _ = partDoc.Set("id", st.ToolIDs[index])
+ _ = partDoc.Set("name", st.ToolNames[index])
+ step = claudeToolUseToInteractionsStep(util.ParseGJSONBytesNoCopy(partDoc.Bytes()), strings.TrimSpace(text))
default:
step = []byte(`{"type":"model_output","content":[]}`)
content := []byte(`{"type":"text","text":""}`)
- content, _ = sjson.SetBytes(content, "text", text)
+ content, _ = ojson.SetBytes(content, "text", text)
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{content})
}
delete(st.CurrentStepByIndex, index)
@@ -374,13 +374,14 @@ func claudeNonStreamContentBlockStop(root gjson.Result, st *claudeToInteractions
return step
}
-func mergeClaudeUsage(st *claudeToInteractionsStreamState, usage gjson.Result) {
+func mergeClaudeUsage(st *claudeToInteractionsStreamState, usage ojson.Result) {
if !usage.Exists() {
return
}
if len(st.UsageRaw) == 0 {
st.UsageRaw = []byte(`{}`)
}
+ usageDoc := ojson.NewDoc(st.UsageRaw)
for _, key := range []string{
"input_tokens",
"output_tokens",
@@ -392,20 +393,21 @@ func mergeClaudeUsage(st *claudeToInteractionsStreamState, usage gjson.Result) {
if !value.Exists() {
continue
}
- st.UsageRaw, _ = sjson.SetRawBytes(st.UsageRaw, key, []byte(value.Raw))
+ _ = usageDoc.SetRaw(key, value.Raw)
}
+ st.UsageRaw = usageDoc.Bytes()
}
-func claudeMergedUsage(st *claudeToInteractionsStreamState) gjson.Result {
+func claudeMergedUsage(st *claudeToInteractionsStreamState) ojson.Result {
if len(st.UsageRaw) == 0 {
- return gjson.Result{}
+ return ojson.Result{}
}
- return gjson.ParseBytes(st.UsageRaw)
+ return util.ParseGJSONBytesNoCopy(st.UsageRaw)
}
-func setInteractionsUsageFromClaude(out []byte, path string, usage gjson.Result) []byte {
+func setInteractionsUsageFromClaudeDoc(outDoc *ojson.Doc, path string, usage ojson.Result) {
if !usage.Exists() {
- return out
+ return
}
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
@@ -413,26 +415,25 @@ func setInteractionsUsageFromClaude(out []byte, path string, usage gjson.Result)
cacheCreation := usage.Get("cache_creation_input_tokens").Int()
thinkingTokens := usage.Get("thinking_tokens").Int()
if usage.Get("input_tokens").Exists() {
- out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
+ _ = outDoc.Set(path+".input_tokens", inputTokens)
+ _ = outDoc.Set(path+".total_input_tokens", inputTokens)
}
if usage.Get("output_tokens").Exists() {
- out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens)
- out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
+ _ = outDoc.Set(path+".output_tokens", outputTokens)
+ _ = outDoc.Set(path+".total_output_tokens", outputTokens)
}
total := inputTokens + outputTokens
if usage.Get("input_tokens").Exists() || usage.Get("output_tokens").Exists() {
- out, _ = sjson.SetBytes(out, path+".total_tokens", total)
+ _ = outDoc.Set(path+".total_tokens", total)
}
if cacheRead != 0 || cacheCreation != 0 {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", cacheRead+cacheCreation)
- out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cacheRead+cacheCreation)
+ _ = outDoc.Set(path+".cached_tokens", cacheRead+cacheCreation)
+ _ = outDoc.Set(path+".total_cached_tokens", cacheRead+cacheCreation)
}
if thinkingTokens != 0 {
- out, _ = sjson.SetBytes(out, path+".reasoning_tokens", thinkingTokens)
- out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thinkingTokens)
+ _ = outDoc.Set(path+".reasoning_tokens", thinkingTokens)
+ _ = outDoc.Set(path+".total_thought_tokens", thinkingTokens)
}
- return out
}
func appendClaudeInteractionsCreated(out [][]byte, st *claudeToInteractionsStreamState, modelName string) [][]byte {
@@ -440,10 +441,10 @@ func appendClaudeInteractionsCreated(out [][]byte, st *claudeToInteractionsStrea
return out
}
st.ID = firstNonEmptyString(st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
- created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
- created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
- created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmptyString(st.Model, modelName))
- out = append(out, translatorcommon.SSEEventData("interaction.created", created))
+ createdDoc := ojson.NewDoc([]byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`))
+ _ = createdDoc.Set("interaction.id", st.ID)
+ _ = createdDoc.Set("interaction.model", firstNonEmptyString(st.Model, modelName))
+ out = append(out, translatorcommon.SSEEventData("interaction.created", createdDoc.Bytes()))
st.Created = true
return appendClaudeInteractionsStatusUpdate(out, st)
}
@@ -453,7 +454,7 @@ func appendClaudeInteractionsStatusUpdate(out [][]byte, st *claudeToInteractions
return out
}
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
- statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
+ statusUpdate, _ = ojson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.StatusUpdated = true
return out
@@ -463,35 +464,35 @@ func appendClaudeInteractionsStepStart(out [][]byte, st *claudeToInteractionsStr
st.ActiveStepIndex = st.StepIndex
st.ActiveStepType = stepType
st.ActiveStepOpen = true
- payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- if len(step) > 0 && gjson.ValidBytes(step) {
- payload, _ = sjson.SetRawBytes(payload, "step", step)
+ payloadDoc := ojson.NewDoc([]byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`))
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ if len(step) > 0 && ojson.ValidBytes(step) {
+ _ = payloadDoc.SetRawBytes("step", step)
} else {
- payload, _ = sjson.SetBytes(payload, "step.type", stepType)
+ _ = payloadDoc.Set("step.type", stepType)
}
- return append(out, translatorcommon.SSEEventData("step.start", payload))
+ return append(out, translatorcommon.SSEEventData("step.start", payloadDoc.Bytes()))
}
func appendClaudeInteractionsTextDelta(out [][]byte, st *claudeToInteractionsStreamState, text string, thought bool) [][]byte {
- payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
+ payloadDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`))
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
if thought {
- payload, _ = sjson.SetBytes(payload, "delta.type", "thought_summary")
- payload, _ = sjson.SetBytes(payload, "delta.content.type", "text")
- payload, _ = sjson.SetBytes(payload, "delta.content.text", text)
- payload, _ = sjson.DeleteBytes(payload, "delta.text")
+ _ = payloadDoc.Set("delta.type", "thought_summary")
+ _ = payloadDoc.Set("delta.content.type", "text")
+ _ = payloadDoc.Set("delta.content.text", text)
+ _ = payloadDoc.Delete("delta.text")
} else {
- payload, _ = sjson.SetBytes(payload, "delta.text", text)
+ _ = payloadDoc.Set("delta.text", text)
}
- return append(out, translatorcommon.SSEEventData("step.delta", payload))
+ return append(out, translatorcommon.SSEEventData("step.delta", payloadDoc.Bytes()))
}
func appendClaudeInteractionsArgumentsDelta(out [][]byte, st *claudeToInteractionsStreamState, arguments string) [][]byte {
- payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments)
- return append(out, translatorcommon.SSEEventData("step.delta", payload))
+ payloadDoc := ojson.NewDoc([]byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`))
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = payloadDoc.Set("delta.arguments", arguments)
+ return append(out, translatorcommon.SSEEventData("step.delta", payloadDoc.Bytes()))
}
func appendClaudeInteractionsStepStop(out [][]byte, st *claudeToInteractionsStreamState) [][]byte {
@@ -499,7 +500,7 @@ func appendClaudeInteractionsStepStop(out [][]byte, st *claudeToInteractionsStre
return out
}
payload := []byte(`{"index":0,"event_type":"step.stop"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
+ payload, _ = ojson.SetBytes(payload, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", payload))
st.ActiveStepOpen = false
st.ActiveStepType = ""
@@ -507,23 +508,23 @@ func appendClaudeInteractionsStepStop(out [][]byte, st *claudeToInteractionsStre
return out
}
-func appendClaudeInteractionsCompleted(out [][]byte, st *claudeToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
+func appendClaudeInteractionsCompleted(out [][]byte, st *claudeToInteractionsStreamState, modelName string, root ojson.Result) [][]byte {
if st.Completed {
return out
}
out = appendClaudeInteractionsCreated(out, st, modelName)
now := time.Now().UTC().Format(time.RFC3339)
- completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
- completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
- completed, _ = sjson.SetBytes(completed, "interaction.created", now)
- completed, _ = sjson.SetBytes(completed, "interaction.updated", now)
- completed, _ = sjson.SetBytes(completed, "interaction.model", firstNonEmptyString(st.Model, modelName))
+ completedDoc := ojson.NewDoc([]byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`))
+ _ = completedDoc.Set("interaction.id", st.ID)
+ _ = completedDoc.Set("interaction.created", now)
+ _ = completedDoc.Set("interaction.updated", now)
+ _ = completedDoc.Set("interaction.model", firstNonEmptyString(st.Model, modelName))
usage := claudeMergedUsage(st)
if !usage.Exists() {
usage = root.Get("usage")
}
- completed = setInteractionsUsageFromClaude(completed, "interaction.usage", usage)
- out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
+ setInteractionsUsageFromClaudeDoc(completedDoc, "interaction.usage", usage)
+ out = append(out, translatorcommon.SSEEventData("interaction.completed", completedDoc.Bytes()))
st.Completed = true
return out
}
diff --git a/internal/translator/claude/interactions/interactions_claude_test.go b/internal/translator/claude/interactions/interactions_claude_test.go
index 07684dd6734..13eeaf21d1b 100644
--- a/internal/translator/claude/interactions/interactions_claude_test.go
+++ b/internal/translator/claude/interactions/interactions_claude_test.go
@@ -5,34 +5,34 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertInteractionsRequestToClaudeWithToolMessagesDirect(t *testing.T) {
out, _ := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"toolu_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"toolu_1","result":{"ok":true}}]}`), false)
- if got := gjson.GetBytes(out, "system").String(); got != "be brief" {
+ if got := ojson.GetBytesNoCopy(out, "system").String(); got != "be brief" {
t.Fatalf("system = %q, want be brief. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.text").String(); got != "hi" {
t.Fatalf("messages.0.content.0.text = %q, want hi. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.1.content.0.type").String(); got != "tool_use" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.content.0.type").String(); got != "tool_use" {
t.Fatalf("messages.1.content.0.type = %q, want tool_use. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.2.content.0.type").String(); got != "tool_result" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.content.0.type").String(); got != "tool_result" {
t.Fatalf("messages.2.content.0.type = %q, want tool_result. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.2.content.0.tool_use_id").String(); got != "toolu_1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.content.0.tool_use_id").String(); got != "toolu_1" {
t.Fatalf("tool_use_id = %q, want toolu_1. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToClaudePropagatesIsError(t *testing.T) {
out, _ := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"function_result","name":"lookup","call_id":"toolu_err","result":"command failed","is_error":true}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "tool_result" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "tool_result" {
t.Fatalf("content type = %q, want tool_result. Output: %s", got, string(out))
}
- if !gjson.GetBytes(out, "messages.0.content.0.is_error").Bool() {
+ if !ojson.GetBytesNoCopy(out, "messages.0.content.0.is_error").Bool() {
t.Fatalf("expected is_error = true. Output: %s", string(out))
}
}
@@ -49,7 +49,7 @@ func TestConvertInteractionsRequestToClaudeGroupsConsecutiveRoleTurns(t *testing
]
}`)
out, _ := ConvertInteractionsRequestToClaude("claude-test", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
@@ -83,7 +83,7 @@ func TestConvertInteractionsRequestToClaudeDoesNotMergeAcrossRoleChanges(t *test
]
}`)
out, _ := ConvertInteractionsRequestToClaude("claude-test", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", len(messages), string(out))
}
@@ -96,42 +96,42 @@ func TestConvertInteractionsRequestToClaudeDoesNotMergeAcrossRoleChanges(t *test
func TestConvertInteractionsRequestToClaudeStringInputDirect(t *testing.T) {
out, _ := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":"hello"}`), false)
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "user" {
t.Fatalf("messages.0.role = %q, want user. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "hello" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.text").String(); got != "hello" {
t.Fatalf("messages.0.content.0.text = %q, want hello. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToClaudeMapsGenerationConfigToolsAndStreamDirect(t *testing.T) {
out, _ := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","stream":true,"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"type":"function","name":"lookup","description":"Lookup data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}],"generation_config":{"max_output_tokens":99,"top_p":0.7,"stop_sequences":["END"],"tool_choice":{"type":"function","name":"lookup"},"thinking_level":"high"}}`), false)
- if !gjson.GetBytes(out, "stream").Bool() {
+ if !ojson.GetBytesNoCopy(out, "stream").Bool() {
t.Fatalf("stream should be true when request body asks for stream. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 99 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 99 {
t.Fatalf("max_tokens = %d, want 99. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.input_schema.properties.q.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.input_schema.properties.q.type").String(); got != "string" {
t.Fatalf("tool schema type = %q, want string. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.name").String(); got != "lookup" {
t.Fatalf("tool_choice.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "thinking.type").String(); got == "" {
+ if got := ojson.GetBytesNoCopy(out, "thinking.type").String(); got == "" {
t.Fatalf("thinking config was not mapped. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToClaudeAcceptsImageContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "image" {
t.Fatalf("content type = %q, want image. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.source.media_type").String(); got != "image/png" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.source.media_type").String(); got != "image/png" {
t.Fatalf("media_type = %q, want image/png. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.source.data").String(); got != "aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.source.data").String(); got != "aGVsbG8=" {
t.Fatalf("data = %q, want aGVsbG8=. Output: %s", got, string(out))
}
}
@@ -139,19 +139,19 @@ func TestConvertInteractionsRequestToClaudeAcceptsImageContent(t *testing.T) {
func TestConvertInteractionsRequestToClaudePreservesNonImageMediaContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToClaude("claude-test", []byte(`{"model":"claude-test","input":[{"type":"thought","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want assistant. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "text" {
t.Fatalf("audio fallback type = %q, want text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.1.type").String(); got != "text" {
t.Fatalf("video fallback type = %q, want text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "document" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.2.type").String(); got != "document" {
t.Fatalf("document content type = %q, want document. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "messages.0.content.#(type==\"image\")").Exists() {
+ if ojson.GetBytesNoCopy(out, "messages.0.content.#(type==\"image\")").Exists() {
t.Fatalf("non-image media must not be converted to image. Output: %s", string(out))
}
}
@@ -159,19 +159,19 @@ func TestConvertInteractionsRequestToClaudePreservesNonImageMediaContent(t *test
func TestConvertClaudeResponseToInteractionsNonStream(t *testing.T) {
raw := []byte(`{"id":"msg_1","model":"claude-test","content":[{"type":"thinking","thinking":"reasoning"},{"type":"text","text":"ok"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}],"usage":{"input_tokens":3,"output_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":4,"thinking_tokens":5}}`)
out := ConvertClaudeResponseToInteractionsNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.type").String(); got != "thought" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.type").String(); got != "thought" {
t.Fatalf("steps.0.type = %q, want thought. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "steps.1.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "steps.1.content.0.text").String(); got != "ok" {
t.Fatalf("steps.1.content.0.text = %q, want ok. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "steps.2.call_id").String(); got != "toolu_1" {
+ if got := ojson.GetBytesNoCopy(out, "steps.2.call_id").String(); got != "toolu_1" {
t.Fatalf("steps.2.call_id = %q, want toolu_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_cached_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_cached_tokens").Int(); got != 5 {
t.Fatalf("usage.total_cached_tokens = %d, want 5. Output: %s", got, string(out))
}
}
@@ -183,10 +183,10 @@ data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text
data: {"type":"content_block_stop","index":0}
data: {"type":"message_delta","usage":{"output_tokens":2}}`)
out := ConvertClaudeResponseToInteractionsNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
}
@@ -207,13 +207,13 @@ func TestConvertClaudeResponseToInteractionsStreamMergesUsageAndStatus(t *testin
t.Fatalf("interaction.status_update event not found: %q", events)
}
payload := findClaudeInteractionsEventPayload(events, "interaction.completed")
- if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_output_tokens").Int(); got != 2 {
t.Fatalf("total_output_tokens = %d, want 2. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_tokens").Int(); got != 5 {
t.Fatalf("total_tokens = %d, want 5. Payload: %s", got, string(payload))
}
}
@@ -225,7 +225,7 @@ func TestConvertClaudeResponseToInteractionsStream(t *testing.T) {
if len(payload) == 0 {
t.Fatalf("step.delta event not found: %q", events)
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "ok" {
t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload))
}
}
@@ -239,7 +239,7 @@ func findClaudeInteractionsEventPayload(events [][]byte, eventType string) []byt
continue
}
payload := bytes.TrimSpace(line[len(prefix):])
- if gjson.GetBytes(payload, "event_type").String() == eventType || gjson.GetBytes(payload, "type").String() == eventType {
+ if ojson.GetBytesNoCopy(payload, "event_type").String() == eventType || ojson.GetBytesNoCopy(payload, "type").String() == eventType {
return payload
}
}
diff --git a/internal/translator/claude/interactions/interactions_claude_user_turn_test.go b/internal/translator/claude/interactions/interactions_claude_user_turn_test.go
index 7408f033a1a..0da66e3535b 100644
--- a/internal/translator/claude/interactions/interactions_claude_user_turn_test.go
+++ b/internal/translator/claude/interactions/interactions_claude_user_turn_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -40,7 +40,7 @@ func requireMediaRefusal(t *testing.T, envelope sdktranslator.RequestEnvelope, w
if got, want := envelope.Err.Error(), "unsupported content part: "+wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
}
@@ -52,10 +52,10 @@ func TestInteractionsToClaudeURIOnlyDocumentBecomesAURLSource(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "messages.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "messages.#").Int(); got != 3 {
t.Fatalf("messages = %d, want 3: %s", got, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content.0")
if part.Get("type").String() != "document" || part.Get("source.type").String() != "url" || part.Get("source.url").String() != "https://example.test/a.pdf" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -68,7 +68,7 @@ func TestInteractionsToClaudeURIOnlyImageBecomesAURLSource(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content.0")
if part.Get("type").String() != "image" || part.Get("source.type").String() != "url" || part.Get("source.url").String() != "https://example.test/a.png" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -79,7 +79,7 @@ func TestInteractionsToClaudeInlineDocumentStaysBase64(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content.0")
if part.Get("source.type").String() != "base64" || part.Get("source.media_type").String() != "application/pdf" || part.Get("source.data").String() != "JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -124,7 +124,7 @@ func TestInteractionsToClaudeUnrepresentableMediaBesideTextStillSucceeds(t *test
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- content := gjson.GetBytes(envelope.Body, "messages.2.content").Array()
+ content := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content").Array()
if len(content) != 1 || content[0].Get("text").String() != "keep me" {
t.Fatalf("text was lost or media leaked: %s", envelope.Body)
}
@@ -135,7 +135,7 @@ func TestInteractionsToClaudeFlatContentInputKeepsTextBesideMedia(t *testing.T)
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "messages.0.content.0.text").String(); got != "Describe this" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "messages.0.content.0.text").String(); got != "Describe this" {
t.Fatalf("text was lost: %s", envelope.Body)
}
}
@@ -145,7 +145,7 @@ func TestInteractionsToClaudeFlatContentInputConvertsAndRefusesBareMedia(t *test
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "messages.2.content.0.source.url").String(); got != "https://example.test/a.pdf" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content.0.source.url").String(); got != "https://example.test/a.pdf" {
t.Fatalf("bare document was lost: %s", envelope.Body)
}
requireMediaRefusal(t, interactionsToClaudeEnvelope(`{"model":"m","input":[`+interactionsHistory+`,`+videoByURI+`]}`), "video")
@@ -178,7 +178,7 @@ func TestInteractionsToClaudeAssistantStepEndsTheUserTurn(t *testing.T) {
func TestInteractionsToClaudeExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","input":[{"type":"user_input","content":[` + videoByURI + `]}]}`)
- if body, _ := ConvertInteractionsRequestToClaude("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertInteractionsRequestToClaude("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
@@ -215,7 +215,7 @@ func TestInteractionsToClaudeInlineAudioBesideTextSendsTheTextWithoutAPlaceholde
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- content := gjson.GetBytes(envelope.Body, "messages.2.content").Array()
+ content := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content").Array()
if len(content) != 1 || content[0].Get("text").String() != "keep me" {
t.Fatalf("text was lost or a placeholder leaked: %s", envelope.Body)
}
@@ -226,7 +226,7 @@ func TestInteractionsToClaudeAssistantMediaKeepsThePlaceholder(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "messages.1.content.0.text").String(); got != "[audio content omitted]" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "messages.1.content.0.text").String(); got != "[audio content omitted]" {
t.Fatalf("assistant placeholder = %q, body = %s", got, envelope.Body)
}
}
@@ -236,14 +236,14 @@ func TestInteractionsToClaudeInlineAndURLDocumentsStillConvert(t *testing.T) {
if inline.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", inline.Err, inline.Body)
}
- if got := gjson.GetBytes(inline.Body, "messages.2.content.0.source.type").String(); got != "base64" {
+ if got := ojson.GetBytesNoCopy(inline.Body, "messages.2.content.0.source.type").String(); got != "base64" {
t.Fatalf("inline document = %s", inline.Body)
}
byURL := interactionsFinalTurnToClaude(pdfByURI)
if byURL.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", byURL.Err, byURL.Body)
}
- if got := gjson.GetBytes(byURL.Body, "messages.2.content.0.source.url").String(); got != "https://example.test/a.pdf" {
+ if got := ojson.GetBytesNoCopy(byURL.Body, "messages.2.content.0.source.url").String(); got != "https://example.test/a.pdf" {
t.Fatalf("url document = %s", byURL.Body)
}
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go
index b40bbc9f3c8..bc21bc2ab97 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_compat_test.go
@@ -3,19 +3,19 @@ package chat_completions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIRequestToClaudeWithCompatPreservesReasoningContent(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":"answer","reasoning_content":"reason"}]}`)
withoutCompat, _ := ConvertOpenAIRequestToClaude("deepseek-v4", payload, false)
- if gjson.GetBytes(withoutCompat, "messages.0.content.#(type=thinking)").Exists() {
+ if ojson.GetBytesNoCopy(withoutCompat, "messages.0.content.#(type=thinking)").Exists() {
t.Fatalf("default translation preserved reasoning_content: %s", withoutCompat)
}
withCompat, _ := ConvertOpenAIRequestToClaudeWithCompat("deepseek-v4", payload, false)
- part := gjson.GetBytes(withCompat, "messages.0.content.#(type=thinking)")
+ part := ojson.GetBytesNoCopy(withCompat, "messages.0.content.#(type=thinking)")
if part.Get("thinking").String() != "reason" || part.Get("signature").String() != "" {
t.Fatalf("compat translation missing unsigned thinking block: %s", withCompat)
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_file_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_file_test.go
index 0b27b6288aa..3f80f97a9d1 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_file_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_file_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToClaude_FileParts(t *testing.T) {
@@ -46,7 +46,7 @@ func TestConvertOpenAIRequestToClaude_FileParts(t *testing.T) {
if got.Err != nil {
t.Fatalf("err = %v", got.Err)
}
- if types := gjson.GetBytes(got.Body, "messages.0.content.#.type").Raw; types != tc.wantTypes {
+ if types := ojson.GetBytesNoCopy(got.Body, "messages.0.content.#.type").Raw; types != tc.wantTypes {
t.Fatalf("content types = %s, want %s. Output: %s", types, tc.wantTypes, got.Body)
}
})
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_native_response_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_native_response_test.go
index a9269d76678..135869d0b8a 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_native_response_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_native_response_test.go
@@ -5,7 +5,8 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertClaudeResponseToOpenAINonStream_NativeMessagesJSON(t *testing.T) {
@@ -24,10 +25,10 @@ func TestConvertClaudeResponseToOpenAINonStream_NativeMessagesJSON(t *testing.T)
raw := []byte(fmt.Sprintf(`{"id":"msg_native","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":%s,"stop_reason":%q,"stop_sequence":null,"usage":{"input_tokens":13,"cache_read_input_tokens":7,"cache_creation_input_tokens":3,"output_tokens":5}}`, tt.content, tt.stopReason))
request := []byte(`{"model":"claude-sonnet-4-6"}`)
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "claude-sonnet-4-6", request, request, raw, nil)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("invalid response JSON: %s", out)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
for path, want := range map[string]string{
"id": "msg_native",
"object": "chat.completion",
@@ -70,11 +71,11 @@ func TestConvertClaudeResponseToOpenAINonStream_NativeMessagesJSON(t *testing.T)
t.Errorf("tool_calls.%s = %q, want %q", path, got, want)
}
}
- args := gjson.Parse(calls.Get("0.function.arguments").String())
+ args := ojson.Parse(calls.Get("0.function.arguments").String())
if !args.IsObject() || len(args.Map()) != 2 || args.Get("city").String() != "Paris" || args.Get("days").Int() != 2 {
t.Errorf("weather arguments not preserved: %s", calls.Get("0.function.arguments").Raw)
}
- emptyArgs := gjson.Parse(calls.Get("1.function.arguments").String())
+ emptyArgs := ojson.Parse(calls.Get("1.function.arguments").String())
if !emptyArgs.IsObject() || len(emptyArgs.Map()) != 0 {
t.Errorf("empty arguments not preserved: %s", calls.Get("1.function.arguments").Raw)
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
index b9d876642a1..45ac9dc49aa 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
@@ -8,12 +8,11 @@ package chat_completions
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -56,11 +55,11 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
userID := common.DeriveClaudeUserID(rawJSON)
// Base Claude Code API template with default max_tokens value
- out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
- out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
- out, _ = sjson.SetBytes(out, "max_tokens", defaultClaudeChatMaxTokensForModel(modelName))
+ outDoc := ojson.NewDoc([]byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`))
+ _ = outDoc.Set("metadata.user_id", userID)
+ _ = outDoc.Set("max_tokens", defaultClaudeChatMaxTokensForModel(modelName))
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Convert OpenAI reasoning_effort to Claude thinking config.
if v := root.Get("reasoning_effort"); v.Exists() {
@@ -76,22 +75,22 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
if supportsAdaptive {
switch effort {
case "none":
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
case "auto":
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
default:
if !thinking.HasLevel(mi.Thinking.Levels, effort) {
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
effort = mapped
}
}
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.SetBytes(out, "output_config.effort", effort)
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Set("output_config.effort", effort)
}
} else {
// Legacy/manual thinking (budget_tokens).
@@ -99,13 +98,13 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
if ok {
switch budget {
case 0:
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
+ _ = outDoc.Set("thinking.type", "disabled")
case -1:
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
+ _ = outDoc.Set("thinking.type", "enabled")
default:
if budget > 0 {
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Set("thinking.budget_tokens", budget)
}
}
}
@@ -114,46 +113,46 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
}
// Model mapping to specify which Claude Code model to use
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Max tokens configuration with fallback to default value.
// OpenAI Chat Completions deprecated max_tokens in favor of
// max_completion_tokens, so accept either spelling.
if maxTokens := firstExisting(root.Get("max_tokens"), root.Get("max_completion_tokens")); maxTokens.Exists() {
- out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
+ _ = outDoc.Set("max_tokens", maxTokens.Int())
}
// Top P setting for nucleus sampling.
if topP := root.Get("top_p"); topP.Exists() {
- out, _ = sjson.SetBytes(out, "top_p", topP.Float())
+ _ = outDoc.Set("top_p", topP.Float())
}
// Stop sequences configuration for custom termination conditions
if stop := root.Get("stop"); stop.Exists() {
if stop.IsArray() {
var stopSequences []string
- stop.ForEach(func(_, value gjson.Result) bool {
+ stop.ForEach(func(_, value ojson.Result) bool {
stopSequences = append(stopSequences, value.String())
return true
})
if len(stopSequences) > 0 {
- out, _ = sjson.SetBytes(out, "stop_sequences", stopSequences)
+ _ = outDoc.Set("stop_sequences", stopSequences)
}
} else {
- out, _ = sjson.SetBytes(out, "stop_sequences", []string{stop.String()})
+ _ = outDoc.Set("stop_sequences", []string{stop.String()})
}
}
// Stream configuration to enable or disable streaming responses
- out, _ = sjson.SetBytes(out, "stream", stream)
+ _ = outDoc.Set("stream", stream)
systemBlocks := make([][]byte, 0)
messageBlocks := make([][]byte, 0)
// Process messages and transform them to Claude Code format
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
- lastToolMessage := map[string]gjson.Result{}
- messages.ForEach(func(_, message gjson.Result) bool {
+ lastToolMessage := map[string]ojson.Result{}
+ messages.ForEach(func(_, message ojson.Result) bool {
if message.Get("role").String() == "tool" {
rawID := message.Get("tool_call_id").String()
if rawID != "" {
@@ -165,7 +164,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
emittedToolResults := map[string]struct{}{}
messageAccumulator := common.NewClaudeMessageAccumulator(int(root.Get("messages.#").Int()))
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
role := message.Get("role").String()
contentResult := message.Get("content")
@@ -176,26 +175,26 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
// instructions from the upstream request.
case "system", "developer":
systemStart := len(systemBlocks)
- if contentResult.Exists() && contentResult.Type == gjson.String && strings.TrimSpace(contentResult.String()) != "" {
+ if contentResult.Exists() && contentResult.Type == ojson.String && strings.TrimSpace(contentResult.String()) != "" {
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
+ textPart, _ = ojson.SetBytes(textPart, "text", contentResult.String())
textPart = common.AttachCacheControl(textPart, message)
systemBlocks = append(systemBlocks, textPart)
} else if contentResult.Exists() && contentResult.IsArray() {
- contentResult.ForEach(func(_, part gjson.Result) bool {
+ contentResult.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "text" {
txt := part.Get("text").String()
if strings.TrimSpace(txt) == "" {
if part.Get("cache_control").Exists() && len(systemBlocks) > systemStart {
prevIdx := len(systemBlocks) - 1
- if !gjson.GetBytes(systemBlocks[prevIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(systemBlocks[prevIdx], "cache_control").Exists() {
systemBlocks[prevIdx] = common.AttachCacheControl(systemBlocks[prevIdx], part)
}
}
return true
}
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", txt)
+ textPart, _ = ojson.SetBytes(textPart, "text", txt)
textPart = common.AttachCacheControl(textPart, part)
systemBlocks = append(systemBlocks, textPart)
}
@@ -204,7 +203,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
// Message-level cache_control applies to the last system block from this message.
if message.Get("cache_control").Exists() && len(systemBlocks) > systemStart {
lastIdx := len(systemBlocks) - 1
- if !gjson.GetBytes(systemBlocks[lastIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(systemBlocks[lastIdx], "cache_control").Exists() {
systemBlocks[lastIdx] = common.AttachCacheControl(systemBlocks[lastIdx], message)
}
}
@@ -212,26 +211,26 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
case "user", "assistant":
contentBlocks := make([][]byte, 0, 4)
if preserveEmptyThinkingBlocks && role == "assistant" {
- if reasoningContent := message.Get("reasoning_content"); reasoningContent.Type == gjson.String && strings.TrimSpace(reasoningContent.String()) != "" {
+ if reasoningContent := message.Get("reasoning_content"); reasoningContent.Type == ojson.String && strings.TrimSpace(reasoningContent.String()) != "" {
part := []byte(`{"type":"thinking","thinking":"","signature":""}`)
- part, _ = sjson.SetBytes(part, "thinking", reasoningContent.String())
+ part, _ = ojson.SetBytes(part, "thinking", reasoningContent.String())
contentBlocks = append(contentBlocks, part)
}
}
// Handle content based on its type
- if contentResult.Exists() && contentResult.Type == gjson.String && strings.TrimSpace(contentResult.String()) != "" {
+ if contentResult.Exists() && contentResult.Type == ojson.String && strings.TrimSpace(contentResult.String()) != "" {
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", contentResult.String())
+ part, _ = ojson.SetBytes(part, "text", contentResult.String())
contentBlocks = append(contentBlocks, part)
} else if contentResult.Exists() && contentResult.IsArray() {
- contentResult.ForEach(func(_, part gjson.Result) bool {
+ contentResult.ForEach(func(_, part ojson.Result) bool {
if claudePart := convertOpenAIContentPartToClaudePart(part); claudePart != "" {
contentBlocks = append(contentBlocks, []byte(claudePart))
} else if partType := part.Get("type").String(); partType == "text" {
if part.Get("cache_control").Exists() && len(contentBlocks) > 0 {
prevIdx := len(contentBlocks) - 1
- if !gjson.GetBytes(contentBlocks[prevIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(contentBlocks[prevIdx], "cache_control").Exists() {
contentBlocks[prevIdx] = common.AttachCacheControl(contentBlocks[prevIdx], part)
}
}
@@ -248,7 +247,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
// Handle tool calls (for assistant messages)
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() && role == "assistant" {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
if toolCall.Get("type").String() == "function" {
toolCallID := toolCall.Get("id").String()
if toolCallID == "" {
@@ -257,37 +256,37 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
toolCallID = util.SanitizeClaudeToolID(toolCallID)
function := toolCall.Get("function")
- toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUse, _ = sjson.SetBytes(toolUse, "id", toolCallID)
- toolUse, _ = sjson.SetBytes(toolUse, "name", util.SanitizeClaudeFunctionName(function.Get("name").String()))
+ toolUseDoc := ojson.NewDoc([]byte(`{"type":"tool_use","id":"","name":"","input":{}}`))
+ _ = toolUseDoc.Set("id", toolCallID)
+ _ = toolUseDoc.Set("name", util.SanitizeClaudeFunctionName(function.Get("name").String()))
// Parse arguments for the tool call
if args := function.Get("arguments"); args.Exists() {
argsStr := args.String()
- if argsStr != "" && gjson.Valid(argsStr) {
- argsJSON := gjson.Parse(argsStr)
+ if argsStr != "" && ojson.Valid(argsStr) {
+ argsJSON := ojson.Parse(argsStr)
if argsJSON.IsObject() {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
+ _ = toolUseDoc.SetRaw("input", argsJSON.Raw)
} else {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte("{}"))
+ _ = toolUseDoc.SetRaw("input", "{}")
}
} else {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte("{}"))
+ _ = toolUseDoc.SetRaw("input", "{}")
}
} else {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte("{}"))
+ _ = toolUseDoc.SetRaw("input", "{}")
}
- contentBlocks = append(contentBlocks, toolUse)
+ contentBlocks = append(contentBlocks, toolUseDoc.Bytes())
}
return true
})
}
- msg := []byte(`{"role":"","content":[]}`)
- msg, _ = sjson.SetBytes(msg, "role", role)
- msg, _ = sjson.SetRawBytes(msg, "content", common.JoinRawArray(contentBlocks))
- msg = common.AttachMessageCacheControl(msg, message)
+ msgDoc := ojson.NewDoc([]byte(`{"role":"","content":[]}`))
+ _ = msgDoc.Set("role", role)
+ _ = msgDoc.SetRawBytes("content", common.JoinRawArray(contentBlocks))
+ msg := common.AttachMessageCacheControl(msgDoc.Bytes(), message)
messageAccumulator.Append(msg)
case "tool":
@@ -309,18 +308,18 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
}
toolContentResult := targetMsg.Get("content")
- msg := []byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":""}]}`)
- msg, _ = sjson.SetBytes(msg, "content.0.tool_use_id", toolCallID)
+ msgDoc := ojson.NewDoc([]byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"","content":""}]}`))
+ _ = msgDoc.Set("content.0.tool_use_id", toolCallID)
toolResultContent, toolResultContentRaw := convertOpenAIToolResultContent(toolContentResult)
if toolResultContentRaw {
- msg, _ = sjson.SetRawBytes(msg, "content.0.content", []byte(toolResultContent))
+ _ = msgDoc.SetRaw("content.0.content", toolResultContent)
} else {
- msg, _ = sjson.SetBytes(msg, "content.0.content", toolResultContent)
+ _ = msgDoc.Set("content.0.content", toolResultContent)
}
// Anthropic rejects cache_control inside tool_result.content, so
// part-level or message-level cache_control is hoisted onto the
// tool_result block itself.
- msg = common.AttachToolMessageCacheControl(msg, targetMsg)
+ msg := common.AttachToolMessageCacheControl(msgDoc.Bytes(), targetMsg)
messageAccumulator.Append(msg)
}
return true
@@ -331,7 +330,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
if formatInstruction := common.BuildClaudeStructuredOutputInstruction(root.Get("response_format")); formatInstruction != "" {
systemBlock := []byte(`{"type":"text","text":""}`)
- systemBlock, _ = sjson.SetBytes(systemBlock, "text", formatInstruction)
+ systemBlock, _ = ojson.SetBytes(systemBlock, "text", formatInstruction)
systemBlocks = append(systemBlocks, systemBlock)
}
@@ -345,10 +344,10 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
}
if len(systemBlocks) > 0 {
- out, _ = sjson.SetRawBytes(out, "system", common.JoinRawArray(systemBlocks))
+ _ = outDoc.SetRawBytes("system", common.JoinRawArray(systemBlocks))
}
if len(messageBlocks) > 0 {
- out = common.SetRawArrayItems(out, "messages", messageBlocks)
+ common.SetRawArrayItemsDoc(outDoc, "messages", messageBlocks)
}
// Tools mapping: OpenAI tools -> Claude Code tools
@@ -382,7 +381,7 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
var anthropicTools [][]byte
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if tool.Get("type").String() == "function" {
function := tool.Get("function")
fnName := function.Get("name").String()
@@ -394,33 +393,33 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
}
}
}
- anthropicTool := []byte(`{"name":"","description":""}`)
- anthropicTool, _ = sjson.SetBytes(anthropicTool, "name", sanitizedFnName)
- anthropicTool, _ = sjson.SetBytes(anthropicTool, "description", function.Get("description").String())
+ toolDoc := ojson.NewDoc([]byte(`{"name":"","description":""}`))
+ _ = toolDoc.Set("name", sanitizedFnName)
+ _ = toolDoc.Set("description", function.Get("description").String())
// Convert parameters schema for the tool
if parameters := function.Get("parameters"); parameters.Exists() {
- anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)))
+ _ = toolDoc.SetRawBytes("input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)))
} else if parameters := function.Get("parametersJsonSchema"); parameters.Exists() {
- anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)))
+ _ = toolDoc.SetRawBytes("input_schema", util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw)))
} else {
- anthropicTool, _ = sjson.SetRawBytes(anthropicTool, "input_schema", util.NormalizeClaudeToolInputSchema(nil))
- }
- anthropicTool = common.AttachCacheControl(anthropicTool, tool)
- if !gjson.GetBytes(anthropicTool, "cache_control").Exists() {
- anthropicTool = common.AttachCacheControl(anthropicTool, function)
+ _ = toolDoc.SetRawBytes("input_schema", util.NormalizeClaudeToolInputSchema(nil))
}
strict := function.Get("strict")
if !strict.Exists() {
strict = tool.Get("strict")
}
if strict.Exists() {
- if strict.Type == gjson.True {
- anthropicTool, _ = sjson.SetBytes(anthropicTool, "strict", true)
- } else if strict.Type == gjson.False {
- anthropicTool, _ = sjson.SetBytes(anthropicTool, "strict", false)
+ if strict.Type == ojson.True {
+ _ = toolDoc.Set("strict", true)
+ } else if strict.Type == ojson.False {
+ _ = toolDoc.Set("strict", false)
}
}
+ anthropicTool := common.AttachCacheControl(toolDoc.Bytes(), tool)
+ if !ojson.GetBytesNoCopy(anthropicTool, "cache_control").Exists() {
+ anthropicTool = common.AttachCacheControl(anthropicTool, function)
+ }
anthropicTools = append(anthropicTools, anthropicTool)
}
@@ -428,42 +427,42 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
})
if len(anthropicTools) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(anthropicTools))
+ _ = outDoc.SetRawBytes("tools", common.JoinRawArray(anthropicTools))
} else {
- out, _ = sjson.DeleteBytes(out, "tools")
+ _ = outDoc.Delete("tools")
}
}
// Tool choice mapping from OpenAI format to Claude Code format
if isAllowedTools {
if len(anthropicTools) == 0 {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"none"}`)
} else if allowedMode == "required" {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
} else {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
}
- } else if toolChoice := root.Get("tool_choice"); toolChoice.Exists() && toolChoice.Type != gjson.Null {
+ } else if toolChoice := root.Get("tool_choice"); toolChoice.Exists() && toolChoice.Type != ojson.Null {
switch toolChoice.Type {
- case gjson.String:
+ case ojson.String:
choice := toolChoice.String()
switch choice {
case "none":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"none"}`)
case "auto":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
case "required":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
}
- case gjson.JSON:
+ case ojson.JSON:
choiceType := toolChoice.Get("type").String()
switch choiceType {
case "none":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"none"}`)
case "auto":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
case "required", "any":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
case "function":
functionName := toolChoice.Get("function.name").String()
if functionName == "" {
@@ -471,30 +470,30 @@ func convertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream,
}
if functionName != "" {
toolChoiceJSON := []byte(`{"type":"tool","name":""}`)
- toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", util.SanitizeClaudeFunctionName(functionName))
- out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
+ toolChoiceJSON, _ = ojson.SetBytes(toolChoiceJSON, "name", util.SanitizeClaudeFunctionName(functionName))
+ _ = outDoc.SetRawBytes("tool_choice", toolChoiceJSON)
} else {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"none"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"none"}`)
}
}
default:
}
}
- if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Type == gjson.False {
- if gjson.GetBytes(out, "tool_choice").Exists() {
- if gjson.GetBytes(out, "tool_choice.type").String() != "none" {
- out, _ = sjson.SetBytes(out, "tool_choice.disable_parallel_tool_use", true)
+ if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Type == ojson.False {
+ if outDoc.Get("tool_choice").Exists() {
+ if outDoc.Get("tool_choice.type").String() != "none" {
+ _ = outDoc.Set("tool_choice.disable_parallel_tool_use", true)
}
- } else if gjson.GetBytes(out, "tools").Exists() {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto","disable_parallel_tool_use":true}`))
+ } else if outDoc.Get("tools").Exists() {
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto","disable_parallel_tool_use":true}`)
}
}
- return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "openai", modelName), nothingLeftErr
+ return thinking.ApplyTranslatedSummaryToClaude(outDoc.Bytes(), rawJSON, "openai", modelName), nothingLeftErr
}
-func convertOpenAIContentPartToClaudePartRaw(part gjson.Result) []byte {
+func convertOpenAIContentPartToClaudePartRaw(part ojson.Result) []byte {
switch part.Get("type").String() {
case "text":
txt := part.Get("text").String()
@@ -502,7 +501,7 @@ func convertOpenAIContentPartToClaudePartRaw(part gjson.Result) []byte {
return nil
}
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", txt)
+ textPart, _ = ojson.SetBytes(textPart, "text", txt)
return textPart
case "image_url":
@@ -516,17 +515,17 @@ func convertOpenAIContentPartToClaudePartRaw(part gjson.Result) []byte {
if semicolonIdx != -1 && commaIdx != -1 && commaIdx > semicolonIdx {
mediaType := strings.TrimPrefix(fileData[:semicolonIdx], "data:")
data := fileData[commaIdx+1:]
- docPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
- docPart, _ = sjson.SetBytes(docPart, "source.media_type", mediaType)
- docPart, _ = sjson.SetBytes(docPart, "source.data", data)
- return docPart
+ docPartDoc := ojson.NewDoc([]byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = docPartDoc.Set("source.media_type", mediaType)
+ _ = docPartDoc.Set("source.data", data)
+ return docPartDoc.Bytes()
}
}
}
return nil
}
-func convertOpenAIContentPartToClaudePart(part gjson.Result) string {
+func convertOpenAIContentPartToClaudePart(part ojson.Result) string {
claudePart := convertOpenAIContentPartToClaudePartRaw(part)
if len(claudePart) == 0 {
return ""
@@ -551,37 +550,37 @@ func convertOpenAIImageURLToClaudePart(imageURL string) string {
mediaType = "application/octet-stream"
}
- imagePart := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
- imagePart, _ = sjson.SetBytes(imagePart, "source.media_type", mediaType)
- imagePart, _ = sjson.SetBytes(imagePart, "source.data", parts[1])
- return string(imagePart)
+ imagePartDoc := ojson.NewDoc([]byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = imagePartDoc.Set("source.media_type", mediaType)
+ _ = imagePartDoc.Set("source.data", parts[1])
+ return imagePartDoc.String()
}
imagePart := []byte(`{"type":"image","source":{"type":"url","url":""}}`)
- imagePart, _ = sjson.SetBytes(imagePart, "source.url", imageURL)
+ imagePart, _ = ojson.SetBytes(imagePart, "source.url", imageURL)
return string(imagePart)
}
-func convertOpenAIToolResultContent(content gjson.Result) (string, bool) {
+func convertOpenAIToolResultContent(content ojson.Result) (string, bool) {
if !content.Exists() {
return "", false
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String(), false
}
if content.IsArray() {
claudeParts := make([][]byte, 0, 4)
droppedEmptyText := false
- content.ForEach(func(_, part gjson.Result) bool {
- if part.Type == gjson.String {
+ content.ForEach(func(_, part ojson.Result) bool {
+ if part.Type == ojson.String {
if strings.TrimSpace(part.String()) == "" {
droppedEmptyText = true
return true
}
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", part.String())
+ textPart, _ = ojson.SetBytes(textPart, "text", part.String())
claudeParts = append(claudeParts, textPart)
return true
}
@@ -634,11 +633,11 @@ func defaultClaudeChatMaxTokensForModel(modelName string) int {
}
// firstExisting returns the first non-null result that exists, or an empty result.
-func firstExisting(values ...gjson.Result) gjson.Result {
+func firstExisting(values ...ojson.Result) ojson.Result {
for _, value := range values {
- if value.Exists() && value.Type != gjson.Null {
+ if value.Exists() && value.Type != ojson.Null {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
index e84d51e5be2..ee3922f2d3d 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
@@ -4,7 +4,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertOpenAIRequestToClaude_ThinkingSummaryVisibility(t *testing.T) {
@@ -20,7 +21,7 @@ func TestConvertOpenAIRequestToClaude_ThinkingSummaryVisibility(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToClaude("claude-opus-5-5", []byte(test.input), false)
- if got := gjson.GetBytes(out, "thinking.display").String(); got != test.wanted {
+ if got := ojson.GetBytesNoCopy(out, "thinking.display").String(); got != test.wanted {
t.Fatalf("thinking.display = %q, want %q; body=%s", got, test.wanted, out)
}
})
@@ -42,7 +43,7 @@ func TestConvertOpenAIRequestToClaudeWithCompat_GroupsAssistantThinkingTextAndTo
]
}`)
out, _ := ConvertOpenAIRequestToClaudeWithCompat("claude-test", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1. Output: %s", len(messages), string(out))
}
@@ -67,7 +68,7 @@ func TestConvertOpenAIRequestToClaude_MergesToolResultWithAdjacentUserContent(t
]
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", len(messages), string(out))
}
@@ -92,7 +93,7 @@ func TestConvertOpenAIRequestToClaude_SystemDoesNotBreakUserTurnAndCacheBoundary
]
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1. Output: %s", len(messages), string(out))
}
@@ -109,7 +110,7 @@ func TestConvertOpenAIRequestToClaude_SystemDoesNotBreakUserTurnAndCacheBoundary
if got := content[1].Get("text").String(); got != "second" {
t.Fatalf("content[1].text = %q, want second", got)
}
- if got := gjson.GetBytes(out, "system.0.text").String(); got != "system rule" {
+ if got := ojson.GetBytesNoCopy(out, "system.0.text").String(); got != "system rule" {
t.Fatalf("system text = %q, want system rule", got)
}
}
@@ -140,7 +141,7 @@ func TestConvertOpenAIRequestToClaude_SanitizesToolCallIDsForClaude(t *testing.T
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
toolUseID := resultJSON.Get("messages.0.content.0.id").String()
toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String()
@@ -177,7 +178,7 @@ func TestConvertOpenAIRequestToClaude_GroupsConsecutiveParallelToolResults(t *te
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 4 {
@@ -227,7 +228,7 @@ func TestConvertOpenAIRequestToClaude_DropsTemperature(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if resultJSON.Get("temperature").Exists() {
t.Fatalf("temperature should be removed")
@@ -272,7 +273,7 @@ func TestConvertOpenAIRequestToClaude_ToolResultTextAndBase64Image(t *testing.T)
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
@@ -345,7 +346,7 @@ func TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 2 {
@@ -377,7 +378,7 @@ func TestConvertOpenAIRequestToClaude_SystemRoleBecomesTopLevelSystem(t *testing
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
system := resultJSON.Get("system")
if !system.IsArray() {
@@ -416,7 +417,7 @@ func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSy
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
system := resultJSON.Get("system").Array()
if len(system) != 2 {
@@ -450,7 +451,7 @@ func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
system := resultJSON.Get("system").Array()
if len(system) != 1 {
@@ -490,7 +491,7 @@ func TestConvertOpenAIRequestToClaude_PreservesContentPartCacheControl(t *testin
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
@@ -516,7 +517,7 @@ func TestConvertOpenAIRequestToClaude_PreservesMessageLevelCacheControl(t *testi
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
@@ -544,7 +545,7 @@ func TestConvertOpenAIRequestToClaude_PreservesToolCacheControl(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("tools.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("tools.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
@@ -586,7 +587,7 @@ func TestConvertOpenAIRequestToClaude_NormalizesRootToolSchemaUnions(t *testing.
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- root := gjson.ParseBytes(result)
+ root := util.ParseGJSONBytesNoCopy(result)
for _, toolName := range []string{"without_type", "constraint_union"} {
schema := root.Get(`tools.#(name=="` + toolName + `").input_schema`)
@@ -620,7 +621,7 @@ func TestConvertOpenAIRequestToClaude_PartCacheControlWinsOverMessageLevel(t *te
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("messages.0.content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral. Output: %s", got, result)
@@ -641,7 +642,7 @@ func TestConvertOpenAIRequestToClaude_DeveloperRoleBecomesTopLevelSystem(t *test
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
system := resultJSON.Get("system").Array()
if len(system) != 3 {
@@ -675,7 +676,7 @@ func TestConvertOpenAIRequestToClaude_DeveloperMessageCacheControlAppliesToLastB
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- system := gjson.ParseBytes(result).Get("system").Array()
+ system := util.ParseGJSONBytesNoCopy(result).Get("system").Array()
if len(system) != 2 {
t.Fatalf("system blocks = %d, want 2", len(system))
}
@@ -704,7 +705,7 @@ func TestConvertOpenAIRequestToClaude_DeduplicatesToolResults(t *testing.T) {
]
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
messages := root.Get("messages").Array()
if len(messages) < 5 {
@@ -782,7 +783,7 @@ func TestConvertOpenAIRequestToClaude_MaxTokensAndMaxCompletionTokens(t *testing
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToClaude("claude-opus-4-1-20250805", []byte(tc.rawJSON), false)
- got := gjson.GetBytes(out, "max_tokens").Int()
+ got := ojson.GetBytesNoCopy(out, "max_tokens").Int()
if got != tc.wantLimit {
t.Fatalf("max_tokens = %d, want %d. Output: %s", got, tc.wantLimit, string(out))
}
@@ -853,7 +854,7 @@ func TestConvertOpenAIRequestToClaude_OmittedMaxTokensUsesRegistryLimit(t *testi
if errConvert != nil {
t.Fatalf("convert: %v", errConvert)
}
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != tc.wantLimit {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != tc.wantLimit {
t.Fatalf("max_tokens = %d, want %d. Output: %s", got, tc.wantLimit, string(out))
}
})
@@ -886,10 +887,10 @@ func TestConvertOpenAIRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *t
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToClaude("claude-test", []byte(tc.rawJSON), false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
- got := gjson.GetBytes(out, "metadata.user_id").String()
+ got := ojson.GetBytesNoCopy(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
@@ -900,10 +901,10 @@ func TestConvertOpenAIRequestToClaude_PreservesCallerSuppliedMetadataUserID(t *t
func TestConvertOpenAIRequestToClaude_PreservesOpenAIUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","user":"openai-user-456","messages":[{"role":"user","content":"hello"}]}`)
out, _ := ConvertOpenAIRequestToClaude("claude-test", raw, false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
- got := gjson.GetBytes(out, "metadata.user_id").String()
+ got := ojson.GetBytesNoCopy(out, "metadata.user_id").String()
if got != "openai-user-456" {
t.Fatalf("metadata.user_id = %q, want %q", got, "openai-user-456")
}
@@ -914,8 +915,8 @@ func TestConvertOpenAIRequestToClaude_DifferentSessionsProduceDifferentUserIDs(t
b := []byte(`{"model":"claude-test","prompt_cache_key":"session-b","messages":[{"role":"user","content":"hello"}]}`)
outA, _ := ConvertOpenAIRequestToClaude("claude-test", a, false)
outB, _ := ConvertOpenAIRequestToClaude("claude-test", b, false)
- idA := gjson.GetBytes(outA, "metadata.user_id").String()
- idB := gjson.GetBytes(outB, "metadata.user_id").String()
+ idA := ojson.GetBytesNoCopy(outA, "metadata.user_id").String()
+ idB := ojson.GetBytesNoCopy(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
@@ -926,8 +927,8 @@ func TestConvertOpenAIRequestToClaude_DeterministicWithoutSessionKey(t *testing.
second := []byte(`{"model":"claude-test","messages":[{"role":"user","content":"stable first message"},{"role":"assistant","content":"hi"},{"role":"user","content":"second message"}]}`)
outFirst, _ := ConvertOpenAIRequestToClaude("claude-test", first, false)
outSecond, _ := ConvertOpenAIRequestToClaude("claude-test", second, false)
- idFirst := gjson.GetBytes(outFirst, "metadata.user_id").String()
- idSecond := gjson.GetBytes(outSecond, "metadata.user_id").String()
+ idFirst := ojson.GetBytesNoCopy(outFirst, "metadata.user_id").String()
+ idSecond := ojson.GetBytesNoCopy(outSecond, "metadata.user_id").String()
if idFirst == "" || idFirst == "unknown" {
t.Fatalf("expected non-empty derived user_id, got %q", idFirst)
}
@@ -960,7 +961,7 @@ func TestConvertOpenAIRequestToClaude_ResponseFormatJSONSchema(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytesNoCopy(out, "system")
if !system.Exists() || !system.IsArray() || len(system.Array()) == 0 {
t.Fatalf("system blocks missing or empty. Output: %s", string(out))
}
@@ -988,7 +989,7 @@ func TestConvertOpenAIRequestToClaude_ResponseFormatJSONObject(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytesNoCopy(out, "system")
if !system.Exists() || !system.IsArray() || len(system.Array()) == 0 {
t.Fatalf("system blocks missing or empty. Output: %s", string(out))
}
@@ -1019,7 +1020,7 @@ func TestConvertOpenAIRequestToClaude_ResponseFormatPreservesExistingSystem(t *t
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytesNoCopy(out, "system")
if !system.Exists() || !system.IsArray() || len(system.Array()) < 2 {
t.Fatalf("expected at least 2 system blocks (original + response_format). Output: %s", string(out))
}
@@ -1058,7 +1059,7 @@ func TestConvertOpenAIRequestToClaude_ResponseFormatAbsentOrTextNoOp(t *testing.
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(tt.body), false)
- if gjson.GetBytes(out, "system").Exists() {
+ if ojson.GetBytesNoCopy(out, "system").Exists() {
t.Fatalf("system blocks should not be created when response_format is absent or text. Output: %s", string(out))
}
})
@@ -1079,7 +1080,7 @@ func TestConvertOpenAIRequestToClaude_ToolResultPartCacheControlHoisted(t *testi
}`)
out, _ := ConvertOpenAIRequestToClaude("claude-test", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", len(messages), string(out))
}
@@ -1120,7 +1121,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
if gotType != "none" {
t.Fatalf("expected tool_choice.type to be 'none', got %q. Output: %s", gotType, result)
}
@@ -1136,7 +1137,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
if gotType != "none" {
t.Fatalf("expected tool_choice.type to be 'none', got %q. Output: %s", gotType, result)
}
@@ -1159,8 +1160,8 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
- tools := gjson.GetBytes(result, "tools").Array()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
+ tools := ojson.GetBytesNoCopy(result, "tools").Array()
if gotType != "auto" {
t.Fatalf("expected tool_choice type='auto', got %q. Output: %s", gotType, result)
}
@@ -1190,8 +1191,8 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
- tools := gjson.GetBytes(result, "tools").Array()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
+ tools := ojson.GetBytesNoCopy(result, "tools").Array()
if gotType != "any" {
t.Fatalf("expected tool_choice type='any', got %q. Output: %s", gotType, result)
}
@@ -1211,8 +1212,8 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
- gotDisable := gjson.GetBytes(result, "tool_choice.disable_parallel_tool_use").Bool()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
+ gotDisable := ojson.GetBytesNoCopy(result, "tool_choice.disable_parallel_tool_use").Bool()
if gotType != "any" || !gotDisable {
t.Fatalf("expected type='any' with disable_parallel_tool_use=true, got type=%q disable=%v. Output: %s", gotType, gotDisable, result)
}
@@ -1229,7 +1230,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotDisable := gjson.GetBytes(result, "tool_choice.disable_parallel_tool_use")
+ gotDisable := ojson.GetBytesNoCopy(result, "tool_choice.disable_parallel_tool_use")
if gotDisable.Exists() && gotDisable.Bool() {
t.Fatalf("expected disable_parallel_tool_use to not be true for parallel_tool_calls=null. Output: %s", result)
}
@@ -1246,7 +1247,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotDisable := gjson.GetBytes(result, "tool_choice.disable_parallel_tool_use")
+ gotDisable := ojson.GetBytesNoCopy(result, "tool_choice.disable_parallel_tool_use")
if gotDisable.Exists() && gotDisable.Bool() {
t.Fatalf("expected disable_parallel_tool_use to not be true for parallel_tool_calls=true. Output: %s", result)
}
@@ -1262,8 +1263,8 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
- gotDisable := gjson.GetBytes(result, "tool_choice.disable_parallel_tool_use").Bool()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
+ gotDisable := ojson.GetBytesNoCopy(result, "tool_choice.disable_parallel_tool_use").Bool()
if gotType != "auto" || !gotDisable {
t.Fatalf("expected type='auto' with disable_parallel_tool_use=true, got type=%q disable=%v. Output: %s", gotType, gotDisable, result)
}
@@ -1284,7 +1285,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
if gotType != "none" {
t.Fatalf("expected tool_choice type='none', got %q. Output: %s", gotType, result)
}
@@ -1300,7 +1301,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- gotType := gjson.GetBytes(result, "tool_choice.type").String()
+ gotType := ojson.GetBytesNoCopy(result, "tool_choice.type").String()
if gotType != "none" {
t.Fatalf("expected tool_choice type='none', got %q. Output: %s", gotType, result)
}
@@ -1316,7 +1317,7 @@ func TestConvertOpenAIRequestToClaude_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- if gjson.GetBytes(result, "tool_choice").Exists() {
+ if ojson.GetBytesNoCopy(result, "tool_choice").Exists() {
t.Fatalf("expected tool_choice not to be set when tool_choice is null, got: %s", result)
}
})
@@ -1340,7 +1341,7 @@ func TestConvertOpenAIRequestToClaude_ToolStrict(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- toolStrict := gjson.GetBytes(result, "tools.0.strict")
+ toolStrict := ojson.GetBytesNoCopy(result, "tools.0.strict")
if !toolStrict.Exists() {
t.Fatalf("expected tools.0.strict to exist in Claude output: %s", result)
}
@@ -1366,7 +1367,7 @@ func TestConvertOpenAIRequestToClaude_ToolStrict(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- toolStrict := gjson.GetBytes(result, "tools.0.strict")
+ toolStrict := ojson.GetBytesNoCopy(result, "tools.0.strict")
if !toolStrict.Exists() || !toolStrict.Bool() {
t.Fatalf("expected tools.0.strict to be true, got %s", result)
}
@@ -1389,7 +1390,7 @@ func TestConvertOpenAIRequestToClaude_ToolStrict(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- toolStrict := gjson.GetBytes(result, "tools.0.strict")
+ toolStrict := ojson.GetBytesNoCopy(result, "tools.0.strict")
if !toolStrict.Exists() {
t.Fatalf("expected tools.0.strict to exist in Claude output: %s", result)
}
@@ -1414,7 +1415,7 @@ func TestConvertOpenAIRequestToClaude_ToolStrict(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
- if gjson.GetBytes(result, "tools.0.strict").Exists() {
+ if ojson.GetBytesNoCopy(result, "tools.0.strict").Exists() {
t.Fatalf("expected tools.0.strict to be omitted when not provided, got %s", result)
}
})
@@ -1475,29 +1476,29 @@ func TestConvertOpenAIRequestToClaude_SanitizesToolNamesAndProvidesFallbackSchem
result, _ := ConvertOpenAIRequestToClaude("claude-sonnet-4-6", []byte(inputJSON), false)
// 1. Tool name in declarations must be sanitized
- tool0Name := gjson.GetBytes(result, "tools.0.name").String()
+ tool0Name := ojson.GetBytesNoCopy(result, "tools.0.name").String()
if tool0Name != "mcp_server_special_get_time" {
t.Fatalf("tools.0.name = %q, want mcp_server_special_get_time. Output: %s", tool0Name, result)
}
// 2. Parameterless tool must have a fallback input_schema object
- tool0Schema := gjson.GetBytes(result, "tools.0.input_schema")
+ tool0Schema := ojson.GetBytesNoCopy(result, "tools.0.input_schema")
if !tool0Schema.Exists() || tool0Schema.Get("type").String() != "object" {
t.Fatalf("tools.0.input_schema = %s, want object schema. Output: %s", tool0Schema, result)
}
- tool1Schema := gjson.GetBytes(result, "tools.1.input_schema")
+ tool1Schema := ojson.GetBytesNoCopy(result, "tools.1.input_schema")
if !tool1Schema.Exists() || tool1Schema.Get("type").String() != "object" {
t.Fatalf("tools.1.input_schema = %s, want object schema. Output: %s", tool1Schema, result)
}
// 3. Historical tool_use name in assistant turn must be sanitized
- toolUseName := gjson.GetBytes(result, "messages.0.content.1.name").String()
+ toolUseName := ojson.GetBytesNoCopy(result, "messages.0.content.1.name").String()
if toolUseName != "mcp_server_special_get_time" {
t.Fatalf("messages.0.content.1.name = %q, want mcp_server_special_get_time. Output: %s", toolUseName, result)
}
// 4. Tool choice function name must be sanitized
- toolChoiceName := gjson.GetBytes(result, "tool_choice.name").String()
+ toolChoiceName := ojson.GetBytesNoCopy(result, "tool_choice.name").String()
if toolChoiceName != "mcp_server_special_get_time" {
t.Fatalf("tool_choice.name = %q, want mcp_server_special_get_time. Output: %s", toolChoiceName, result)
}
@@ -1532,7 +1533,7 @@ func TestConvertOpenAIRequestToClaude_DropsEmptyAndWhitespaceTextBlocks(t *testi
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(result)
+ root := util.ParseGJSONBytesNoCopy(result)
system := root.Get("system").Array()
if len(system) != 1 {
@@ -1583,7 +1584,7 @@ func TestConvertOpenAIRequestToClaude_DropsEmptyAndWhitespaceTextBlocks(t *testi
if errMixed != nil {
t.Fatalf("unexpected error: %v", errMixed)
}
- mixedRoot := gjson.ParseBytes(outMixed)
+ mixedRoot := util.ParseGJSONBytesNoCopy(outMixed)
if got := mixedRoot.Get("system.0.cache_control.ttl").String(); got != "1h" {
t.Fatalf("system.0.cache_control.ttl = %q, want 1h", got)
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response.go b/internal/translator/claude/openai/chat-completions/claude_openai_response.go
index bec2e46c374..f9a6afb4718 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_response.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_response.go
@@ -12,9 +12,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var (
@@ -51,7 +51,7 @@ type ToolCallAccumulator struct {
Arguments strings.Builder
}
-func (u *claudeUsageTokens) Merge(usage gjson.Result) {
+func (u *claudeUsageTokens) Merge(usage ojson.Result) {
if !usage.Exists() {
return
}
@@ -74,15 +74,14 @@ func (u *claudeUsageTokens) Merge(usage gjson.Result) {
}
}
-// applyReasoningTokens maps Claude's output_tokens_details.thinking_tokens to OpenAI's
+// applyReasoningTokensDoc maps Claude's output_tokens_details.thinking_tokens to OpenAI's
// completion_tokens_details.reasoning_tokens. Both are a subset of the output token count.
// The field is left out when upstream did not report it, so clients never see a false 0.
-func (u claudeUsageTokens) applyReasoningTokens(out []byte) []byte {
+func (u claudeUsageTokens) applyReasoningTokensDoc(outDoc *ojson.Doc) {
if !u.HasThinkingTokens {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "usage.completion_tokens_details.reasoning_tokens", u.ThinkingTokens)
- return out
+ _ = outDoc.Set("usage.completion_tokens_details.reasoning_tokens", u.ThinkingTokens)
}
func (u claudeUsageTokens) OpenAIUsage() (promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens int64) {
@@ -121,38 +120,38 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
eventType := root.Get("type").String()
// Base OpenAI streaming response template
- template := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`)
+ templateDoc := ojson.NewDoc([]byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`))
// Set model
if modelName != "" {
- template, _ = sjson.SetBytes(template, "model", modelName)
+ _ = templateDoc.Set("model", modelName)
}
// Set response ID and creation time
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID != "" {
- template, _ = sjson.SetBytes(template, "id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
+ _ = templateDoc.Set("id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
}
if (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt > 0 {
- template, _ = sjson.SetBytes(template, "created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
+ _ = templateDoc.Set("created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
}
switch eventType {
case "message_start":
// Initialize response with message metadata when a new message begins
if message := root.Get("message"); message.Exists() {
- (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID = message.Get("id").String()
+ (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID = strings.Clone(message.Get("id").String())
(*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt = time.Now().Unix()
- template, _ = sjson.SetBytes(template, "id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
- template, _ = sjson.SetBytes(template, "model", modelName)
- template, _ = sjson.SetBytes(template, "created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
+ _ = templateDoc.Set("id", (*param).(*ConvertAnthropicResponseToOpenAIParams).ResponseID)
+ _ = templateDoc.Set("model", modelName)
+ _ = templateDoc.Set("created", (*param).(*ConvertAnthropicResponseToOpenAIParams).CreatedAt)
// Set initial role to assistant for the response
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
// Initialize tool calls accumulator for tracking tool call progress
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
@@ -161,7 +160,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
(*param).(*ConvertAnthropicResponseToOpenAIParams).NextToolCallIndex = 0
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(message.Get("usage"))
}
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
case "content_block_start":
// Start of a content block (text, tool use, or reasoning)
@@ -170,8 +169,8 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if blockType == "tool_use" {
// Start of tool call - initialize accumulator to track arguments
- toolCallID := contentBlock.Get("id").String()
- toolName := contentBlock.Get("name").String()
+ toolCallID := strings.Clone(contentBlock.Get("id").String())
+ toolName := strings.Clone(contentBlock.Get("name").String())
index := int(root.Get("index").Int())
if (*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator == nil {
@@ -203,13 +202,13 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
case "text_delta":
// Text content delta - send incremental text updates
if text := delta.Get("text"); text.Exists() {
- template, _ = sjson.SetBytes(template, "choices.0.delta.content", text.String())
+ _ = templateDoc.Set("choices.0.delta.content", text.String())
hasContent = true
}
case "thinking_delta":
// Accumulate reasoning/thinking content
if thinking := delta.Get("thinking"); thinking.Exists() {
- template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", thinking.String())
+ _ = templateDoc.Set("choices.0.delta.reasoning_content", thinking.String())
hasContent = true
}
case "input_json_delta":
@@ -227,7 +226,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
}
}
if hasContent {
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
} else {
return [][]byte{}
}
@@ -242,16 +241,16 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if arguments == "" {
arguments = "{}"
}
- template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.index", accumulator.Index)
- template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.id", accumulator.ID)
- template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.type", "function")
- template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.function.name", accumulator.Name)
- template, _ = sjson.SetBytes(template, "choices.0.delta.tool_calls.0.function.arguments", arguments)
+ _ = templateDoc.Set("choices.0.delta.tool_calls.0.index", accumulator.Index)
+ _ = templateDoc.Set("choices.0.delta.tool_calls.0.id", accumulator.ID)
+ _ = templateDoc.Set("choices.0.delta.tool_calls.0.type", "function")
+ _ = templateDoc.Set("choices.0.delta.tool_calls.0.function.name", accumulator.Name)
+ _ = templateDoc.Set("choices.0.delta.tool_calls.0.function.arguments", arguments)
// Clean up the accumulator for this index
delete((*param).(*ConvertAnthropicResponseToOpenAIParams).ToolCallsAccumulator, index)
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
}
}
return [][]byte{}
@@ -261,7 +260,7 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if delta := root.Get("delta"); delta.Exists() {
if stopReason := delta.Get("stop_reason"); stopReason.Exists() {
(*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason = mapAnthropicStopReasonToOpenAI(stopReason.String())
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", (*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason)
+ _ = templateDoc.Set("choices.0.finish_reason", (*param).(*ConvertAnthropicResponseToOpenAIParams).FinishReason)
}
}
@@ -269,40 +268,40 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
if usage := root.Get("usage"); usage.Exists() {
(*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.Merge(usage)
promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.OpenAIUsage()
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokens)
- template, _ = sjson.SetBytes(template, "usage.completion_tokens", completionTokens)
- template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokens)
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens)
- template = (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.applyReasoningTokens(template)
+ _ = templateDoc.Set("usage.prompt_tokens", promptTokens)
+ _ = templateDoc.Set("usage.completion_tokens", completionTokens)
+ _ = templateDoc.Set("usage.total_tokens", totalTokens)
+ _ = templateDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokens)
+ _ = templateDoc.Set("usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
+ _ = templateDoc.Set("usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens)
+ (*param).(*ConvertAnthropicResponseToOpenAIParams).Usage.applyReasoningTokensDoc(templateDoc)
}
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
case "message_stop":
// Final message event - emit standard OpenAI trailing usage chunk with empty choices array if usage was tracked.
p := (*param).(*ConvertAnthropicResponseToOpenAIParams)
if p.Usage.HasUsage && !p.TrailingUsageSent {
p.TrailingUsageSent = true
- usageTemplate := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[]}`)
+ usageDoc := ojson.NewDoc([]byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[]}`))
if p.ResponseID != "" {
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "id", p.ResponseID)
+ _ = usageDoc.Set("id", p.ResponseID)
}
if modelName != "" {
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "model", modelName)
+ _ = usageDoc.Set("model", modelName)
}
if p.CreatedAt > 0 {
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "created", p.CreatedAt)
+ _ = usageDoc.Set("created", p.CreatedAt)
}
promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := p.Usage.OpenAIUsage()
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens", promptTokens)
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.completion_tokens", completionTokens)
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.total_tokens", totalTokens)
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
- usageTemplate, _ = sjson.SetBytes(usageTemplate, "usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens)
- usageTemplate = p.Usage.applyReasoningTokens(usageTemplate)
- return [][]byte{usageTemplate}
+ _ = usageDoc.Set("usage.prompt_tokens", promptTokens)
+ _ = usageDoc.Set("usage.completion_tokens", completionTokens)
+ _ = usageDoc.Set("usage.total_tokens", totalTokens)
+ _ = usageDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokens)
+ _ = usageDoc.Set("usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
+ _ = usageDoc.Set("usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens)
+ p.Usage.applyReasoningTokensDoc(usageDoc)
+ return [][]byte{usageDoc.Bytes()}
}
return [][]byte{}
@@ -313,10 +312,10 @@ func ConvertClaudeResponseToOpenAI(_ context.Context, modelName string, original
case "error":
// Error event - format and return error response
if errorData := root.Get("error"); errorData.Exists() {
- errorJSON := []byte(`{"error":{"message":"","type":""}}`)
- errorJSON, _ = sjson.SetBytes(errorJSON, "error.message", errorData.Get("message").String())
- errorJSON, _ = sjson.SetBytes(errorJSON, "error.type", errorData.Get("type").String())
- return [][]byte{errorJSON}
+ errorDoc := ojson.NewDoc([]byte(`{"error":{"message":"","type":""}}`))
+ _ = errorDoc.Set("error.message", errorData.Get("message").String())
+ _ = errorDoc.Set("error.type", errorData.Get("type").String())
+ return [][]byte{errorDoc.Bytes()}
}
return [][]byte{}
@@ -370,7 +369,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
}
// Base OpenAI non-streaming response template
- out := []byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`)
+ outDoc := ojson.NewDoc([]byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}`))
var messageID string
var model string
@@ -382,7 +381,7 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
toolCallsAccumulator := make(map[int]*ToolCallAccumulator)
for _, chunk := range chunks {
- root := gjson.ParseBytes(chunk)
+ root := util.ParseGJSONBytesNoCopy(chunk)
eventType := root.Get("type").String()
switch eventType {
@@ -462,29 +461,29 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
if usageTokens.HasUsage {
promptTokens, completionTokens, totalTokens, cachedTokens, cachedCreationTokens := usageTokens.OpenAIUsage()
- out, _ = sjson.SetBytes(out, "usage.prompt_tokens", promptTokens)
- out, _ = sjson.SetBytes(out, "usage.completion_tokens", completionTokens)
- out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
- out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_tokens", cachedTokens)
- out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
- out, _ = sjson.SetBytes(out, "usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens)
- out = usageTokens.applyReasoningTokens(out)
+ _ = outDoc.Set("usage.prompt_tokens", promptTokens)
+ _ = outDoc.Set("usage.completion_tokens", completionTokens)
+ _ = outDoc.Set("usage.total_tokens", totalTokens)
+ _ = outDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokens)
+ _ = outDoc.Set("usage.prompt_tokens_details.cached_creation_tokens", cachedCreationTokens)
+ _ = outDoc.Set("usage.prompt_tokens_details.cache_write_tokens", cachedCreationTokens)
+ usageTokens.applyReasoningTokensDoc(outDoc)
}
// Set basic response fields including message ID, creation time, and model
- out, _ = sjson.SetBytes(out, "id", messageID)
- out, _ = sjson.SetBytes(out, "created", createdAt)
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("id", messageID)
+ _ = outDoc.Set("created", createdAt)
+ _ = outDoc.Set("model", model)
// Set message content by combining all text parts
messageContent := strings.Join(contentParts, "")
- out, _ = sjson.SetBytes(out, "choices.0.message.content", messageContent)
+ _ = outDoc.Set("choices.0.message.content", messageContent)
// Add reasoning content if available (following OpenAI reasoning format)
if len(reasoningParts) > 0 {
reasoningContent := strings.Join(reasoningParts, "")
// Add reasoning as a separate field in the message
- out, _ = sjson.SetBytes(out, "choices.0.message.reasoning_content", reasoningContent)
+ _ = outDoc.Set("choices.0.message.reasoning_content", reasoningContent)
}
// Set tool calls if any were accumulated during processing
@@ -510,20 +509,20 @@ func ConvertClaudeResponseToOpenAINonStream(_ context.Context, _ string, origina
namePath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.name", toolCallsCount)
argumentsPath := fmt.Sprintf("choices.0.message.tool_calls.%d.function.arguments", toolCallsCount)
- out, _ = sjson.SetBytes(out, idPath, accumulator.ID)
- out, _ = sjson.SetBytes(out, typePath, "function")
- out, _ = sjson.SetBytes(out, namePath, accumulator.Name)
- out, _ = sjson.SetBytes(out, argumentsPath, arguments)
+ _ = outDoc.Set(idPath, accumulator.ID)
+ _ = outDoc.Set(typePath, "function")
+ _ = outDoc.Set(namePath, accumulator.Name)
+ _ = outDoc.Set(argumentsPath, arguments)
toolCallsCount++
}
if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); toolCallsCount > 0 && finishReason != "length" {
- out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls")
+ _ = outDoc.Set("choices.0.finish_reason", "tool_calls")
} else if finishReason != "stop" {
- out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason)
+ _ = outDoc.Set("choices.0.finish_reason", finishReason)
}
} else if finishReason := mapAnthropicStopReasonToOpenAI(stopReason); finishReason != "stop" {
- out, _ = sjson.SetBytes(out, "choices.0.finish_reason", finishReason)
+ _ = outDoc.Set("choices.0.finish_reason", finishReason)
}
- return out
+ return outDoc.Bytes()
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go
index 4c12e2b0641..1c1bcd3def7 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_response_test.go
@@ -5,13 +5,13 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func assertCachedCreationTokens(t *testing.T, payload []byte, want int64) {
t.Helper()
- got := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_creation_tokens")
+ got := ojson.GetBytesNoCopy(payload, "usage.prompt_tokens_details.cached_creation_tokens")
if !got.Exists() {
t.Fatalf("expected cached_creation_tokens to exist, payload=%s", string(payload))
}
@@ -36,16 +36,16 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesCachedTokens(t *testin
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
+ if gotPromptTokens := ojson.GetBytesNoCopy(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
- if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
- if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
+ if gotTotalTokens := ojson.GetBytesNoCopy(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
- if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
+ if gotCachedTokens := ojson.GetBytesNoCopy(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out[0], 31)
@@ -75,16 +75,16 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageMergesMessageStartUsage(t *tes
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if gotPromptTokens := gjson.GetBytes(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
+ if gotPromptTokens := ojson.GetBytesNoCopy(out[0], "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
- if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
- if gotTotalTokens := gjson.GetBytes(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
+ if gotTotalTokens := ojson.GetBytesNoCopy(out[0], "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
- if gotCachedTokens := gjson.GetBytes(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
+ if gotCachedTokens := ojson.GetBytesNoCopy(out[0], "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out[0], 31)
@@ -96,16 +96,16 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesCachedTokens(t *tes
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
+ if gotPromptTokens := ojson.GetBytesNoCopy(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
- if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
- if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
+ if gotTotalTokens := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
- if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
+ if gotCachedTokens := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out, 31)
@@ -117,16 +117,16 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageMergesMessageStartUsage(t *
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- if gotPromptTokens := gjson.GetBytes(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
+ if gotPromptTokens := ojson.GetBytesNoCopy(out, "usage.prompt_tokens").Int(); gotPromptTokens != 22044 {
t.Fatalf("expected prompt_tokens %d, got %d", 22044, gotPromptTokens)
}
- if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(out, "usage.completion_tokens").Int(); gotCompletionTokens != 4 {
t.Fatalf("expected completion_tokens %d, got %d", 4, gotCompletionTokens)
}
- if gotTotalTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
+ if gotTotalTokens := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); gotTotalTokens != 22048 {
t.Fatalf("expected total_tokens %d, got %d", 22048, gotTotalTokens)
}
- if gotCachedTokens := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
+ if gotCachedTokens := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 22000 {
t.Fatalf("expected cached_tokens %d, got %d", 22000, gotCachedTokens)
}
assertCachedCreationTokens(t, out, 31)
@@ -167,7 +167,7 @@ func TestConvertClaudeResponseToOpenAI_RefusalStopReason(t *testing.T) {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- gotFinishReason := gjson.GetBytes(out[0], "choices.0.finish_reason").String()
+ gotFinishReason := ojson.GetBytesNoCopy(out[0], "choices.0.finish_reason").String()
if gotFinishReason != tc.wantFinishReason {
t.Fatalf("expected finish_reason %q, got %q, payload=%s", tc.wantFinishReason, gotFinishReason, string(out[0]))
}
@@ -200,7 +200,7 @@ func TestConvertClaudeResponseToOpenAINonStream_RefusalStopReason(t *testing.T)
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- gotFinishReason := gjson.GetBytes(out, "choices.0.finish_reason").String()
+ gotFinishReason := ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String()
if gotFinishReason != tc.wantFinishReason {
t.Fatalf("expected finish_reason %q, got %q, payload=%s", tc.wantFinishReason, gotFinishReason, string(out))
}
@@ -221,7 +221,7 @@ func TestConvertClaudeResponseToOpenAINonStream_ReasoningContent(t *testing.T) {
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- gotRC := gjson.GetBytes(out, "choices.0.message.reasoning_content")
+ gotRC := ojson.GetBytesNoCopy(out, "choices.0.message.reasoning_content")
if !gotRC.Exists() {
t.Fatalf("expected choices.0.message.reasoning_content to exist, payload=%s", string(out))
}
@@ -230,11 +230,11 @@ func TestConvertClaudeResponseToOpenAINonStream_ReasoningContent(t *testing.T) {
t.Fatalf("reasoning_content = %q, want %q", gotRC.String(), wantRC)
}
- if gotOldReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotOldReasoning.Exists() {
+ if gotOldReasoning := ojson.GetBytesNoCopy(out, "choices.0.message.reasoning"); gotOldReasoning.Exists() {
t.Fatalf("choices.0.message.reasoning should not exist, got %q", gotOldReasoning.String())
}
- gotContent := gjson.GetBytes(out, "choices.0.message.content").String()
+ gotContent := ojson.GetBytesNoCopy(out, "choices.0.message.content").String()
wantContent := "Here is the solution."
if gotContent != wantContent {
t.Fatalf("content = %q, want %q", gotContent, wantContent)
@@ -250,13 +250,13 @@ func TestConvertClaudeResponseToOpenAINonStream_OmitsReasoningContentWhenAbsent(
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- if gotRC := gjson.GetBytes(out, "choices.0.message.reasoning_content"); gotRC.Exists() {
+ if gotRC := ojson.GetBytesNoCopy(out, "choices.0.message.reasoning_content"); gotRC.Exists() {
t.Fatalf("choices.0.message.reasoning_content should be omitted when absent, got %q", gotRC.String())
}
- if gotReasoning := gjson.GetBytes(out, "choices.0.message.reasoning"); gotReasoning.Exists() {
+ if gotReasoning := ojson.GetBytesNoCopy(out, "choices.0.message.reasoning"); gotReasoning.Exists() {
t.Fatalf("choices.0.message.reasoning should not exist, got %q", gotReasoning.String())
}
- if gotContent := gjson.GetBytes(out, "choices.0.message.content").String(); gotContent != "Just plain text." {
+ if gotContent := ojson.GetBytesNoCopy(out, "choices.0.message.content").String(); gotContent != "Just plain text." {
t.Fatalf("content = %q, want %q", gotContent, "Just plain text.")
}
}
@@ -286,13 +286,13 @@ func TestConvertClaudeResponseToOpenAI_StreamAndNonStreamParity(t *testing.T) {
for _, ev := range events {
chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, ev, ¶m)
for _, chunk := range chunks {
- if rc := gjson.GetBytes(chunk, "choices.0.delta.reasoning_content"); rc.Exists() {
+ if rc := ojson.GetBytesNoCopy(chunk, "choices.0.delta.reasoning_content"); rc.Exists() {
streamReasoning += rc.String()
}
- if c := gjson.GetBytes(chunk, "choices.0.delta.content"); c.Exists() {
+ if c := ojson.GetBytesNoCopy(chunk, "choices.0.delta.content"); c.Exists() {
streamContent += c.String()
}
- if fr := gjson.GetBytes(chunk, "choices.0.finish_reason"); fr.Exists() && fr.String() != "" {
+ if fr := ojson.GetBytesNoCopy(chunk, "choices.0.finish_reason"); fr.Exists() && fr.String() != "" {
streamFinishReason = fr.String()
}
}
@@ -306,9 +306,9 @@ func TestConvertClaudeResponseToOpenAI_StreamAndNonStreamParity(t *testing.T) {
}
nonStreamOut := ConvertClaudeResponseToOpenAINonStream(ctx, "", nil, nil, rawBuffer, nil)
- nonStreamRC := gjson.GetBytes(nonStreamOut, "choices.0.message.reasoning_content").String()
- nonStreamContent := gjson.GetBytes(nonStreamOut, "choices.0.message.content").String()
- nonStreamFinishReason := gjson.GetBytes(nonStreamOut, "choices.0.finish_reason").String()
+ nonStreamRC := ojson.GetBytesNoCopy(nonStreamOut, "choices.0.message.reasoning_content").String()
+ nonStreamContent := ojson.GetBytesNoCopy(nonStreamOut, "choices.0.message.content").String()
+ nonStreamFinishReason := ojson.GetBytesNoCopy(nonStreamOut, "choices.0.finish_reason").String()
if streamReasoning != "First thought. Second thought." {
t.Fatalf("streamReasoning = %q, want %q", streamReasoning, "First thought. Second thought.")
@@ -349,13 +349,13 @@ func TestConvertClaudeResponseToOpenAI_RedactedThinkingIgnored(t *testing.T) {
}
outNonStream := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- if gotRC := gjson.GetBytes(outNonStream, "choices.0.message.reasoning_content"); gotRC.Exists() {
+ if gotRC := ojson.GetBytesNoCopy(outNonStream, "choices.0.message.reasoning_content"); gotRC.Exists() {
t.Fatalf("redacted_thinking must never map to reasoning_content in non-stream, got %q", gotRC.String())
}
- if gotReasoning := gjson.GetBytes(outNonStream, "choices.0.message.reasoning"); gotReasoning.Exists() {
+ if gotReasoning := ojson.GetBytesNoCopy(outNonStream, "choices.0.message.reasoning"); gotReasoning.Exists() {
t.Fatalf("redacted_thinking must not produce reasoning field in non-stream, got %q", gotReasoning.String())
}
- if gotContent := gjson.GetBytes(outNonStream, "choices.0.message.content").String(); gotContent != "Visible reply." {
+ if gotContent := ojson.GetBytesNoCopy(outNonStream, "choices.0.message.content").String(); gotContent != "Visible reply." {
t.Fatalf("content = %q, want %q", gotContent, "Visible reply.")
}
@@ -366,13 +366,13 @@ func TestConvertClaudeResponseToOpenAI_RedactedThinkingIgnored(t *testing.T) {
for _, line := range events {
chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, line, ¶m)
for _, chunk := range chunks {
- if gotRC := gjson.GetBytes(chunk, "choices.0.delta.reasoning_content"); gotRC.Exists() {
+ if gotRC := ojson.GetBytesNoCopy(chunk, "choices.0.delta.reasoning_content"); gotRC.Exists() {
t.Fatalf("redacted_thinking must never map to reasoning_content in stream, got %q", gotRC.String())
}
- if gotReasoning := gjson.GetBytes(chunk, "choices.0.delta.reasoning"); gotReasoning.Exists() {
+ if gotReasoning := ojson.GetBytesNoCopy(chunk, "choices.0.delta.reasoning"); gotReasoning.Exists() {
t.Fatalf("redacted_thinking must not produce delta.reasoning field in stream, got %q", gotReasoning.String())
}
- if c := gjson.GetBytes(chunk, "choices.0.delta.content"); c.Exists() {
+ if c := ojson.GetBytesNoCopy(chunk, "choices.0.delta.content"); c.Exists() {
streamContent += c.String()
}
}
@@ -411,7 +411,7 @@ func TestConvertClaudeResponseToOpenAI_StreamToolCallIndexIsZeroBased(t *testing
for _, ev := range events {
chunks := ConvertClaudeResponseToOpenAI(ctx, "claude-opus-4-6", nil, nil, ev, ¶m)
for _, chunk := range chunks {
- tc := gjson.GetBytes(chunk, "choices.0.delta.tool_calls.0")
+ tc := ojson.GetBytesNoCopy(chunk, "choices.0.delta.tool_calls.0")
if tc.Exists() {
streamedCalls = append(streamedCalls, toolCallInfo{
Index: tc.Get("index").Int(),
@@ -469,11 +469,11 @@ func TestConvertClaudeResponseToOpenAI_StreamEmitsTrailingUsageChunkWithCacheDet
trailingUsageChunkCount := 0
for i, chunk := range allChunks {
- if gjson.GetBytes(chunk, "choices.0.finish_reason").Exists() && gjson.GetBytes(chunk, "choices.0.finish_reason").String() != "" {
+ if ojson.GetBytesNoCopy(chunk, "choices.0.finish_reason").Exists() && ojson.GetBytesNoCopy(chunk, "choices.0.finish_reason").String() != "" {
finishReasonChunkIndex = i
}
- choices := gjson.GetBytes(chunk, "choices")
- if choices.Exists() && len(choices.Array()) == 0 && gjson.GetBytes(chunk, "usage").Exists() {
+ choices := ojson.GetBytesNoCopy(chunk, "choices")
+ if choices.Exists() && len(choices.Array()) == 0 && ojson.GetBytesNoCopy(chunk, "usage").Exists() {
trailingUsageChunk = chunk
trailingUsageChunkIndex = i
trailingUsageChunkCount++
@@ -488,22 +488,22 @@ func TestConvertClaudeResponseToOpenAI_StreamEmitsTrailingUsageChunkWithCacheDet
t.Fatalf("expected finish_reason chunk (index %d) before trailing usage chunk (index %d)", finishReasonChunkIndex, trailingUsageChunkIndex)
}
- if gotPromptTokens := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens").Int(); gotPromptTokens != 170 {
+ if gotPromptTokens := ojson.GetBytesNoCopy(trailingUsageChunk, "usage.prompt_tokens").Int(); gotPromptTokens != 170 {
t.Errorf("expected prompt_tokens 170, got %d", gotPromptTokens)
}
- if gotCompletionTokens := gjson.GetBytes(trailingUsageChunk, "usage.completion_tokens").Int(); gotCompletionTokens != 15 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(trailingUsageChunk, "usage.completion_tokens").Int(); gotCompletionTokens != 15 {
t.Errorf("expected completion_tokens 15, got %d", gotCompletionTokens)
}
- if gotTotalTokens := gjson.GetBytes(trailingUsageChunk, "usage.total_tokens").Int(); gotTotalTokens != 185 {
+ if gotTotalTokens := ojson.GetBytesNoCopy(trailingUsageChunk, "usage.total_tokens").Int(); gotTotalTokens != 185 {
t.Errorf("expected total_tokens 185, got %d", gotTotalTokens)
}
- if gotCachedTokens := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 50 {
+ if gotCachedTokens := ojson.GetBytesNoCopy(trailingUsageChunk, "usage.prompt_tokens_details.cached_tokens").Int(); gotCachedTokens != 50 {
t.Errorf("expected cached_tokens 50, got %d", gotCachedTokens)
}
- if gotCacheWriteTokens := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cache_write_tokens").Int(); gotCacheWriteTokens != 20 {
+ if gotCacheWriteTokens := ojson.GetBytesNoCopy(trailingUsageChunk, "usage.prompt_tokens_details.cache_write_tokens").Int(); gotCacheWriteTokens != 20 {
t.Errorf("expected cache_write_tokens 20, got %d", gotCacheWriteTokens)
}
- if gotCachedCreationTokens := gjson.GetBytes(trailingUsageChunk, "usage.prompt_tokens_details.cached_creation_tokens").Int(); gotCachedCreationTokens != 20 {
+ if gotCachedCreationTokens := ojson.GetBytesNoCopy(trailingUsageChunk, "usage.prompt_tokens_details.cached_creation_tokens").Int(); gotCachedCreationTokens != 20 {
t.Errorf("expected cached_creation_tokens 20, got %d", gotCachedCreationTokens)
}
}
@@ -511,7 +511,7 @@ func TestConvertClaudeResponseToOpenAI_StreamEmitsTrailingUsageChunkWithCacheDet
func assertReasoningTokens(t *testing.T, payload []byte, want int64) {
t.Helper()
- got := gjson.GetBytes(payload, "usage.completion_tokens_details.reasoning_tokens")
+ got := ojson.GetBytesNoCopy(payload, "usage.completion_tokens_details.reasoning_tokens")
if !got.Exists() {
t.Fatalf("expected reasoning_tokens to exist, payload=%s", string(payload))
}
@@ -535,7 +535,7 @@ func TestConvertClaudeResponseToOpenAI_StreamUsageIncludesReasoningTokens(t *tes
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if gotCompletionTokens := gjson.GetBytes(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 38 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(out[0], "usage.completion_tokens").Int(); gotCompletionTokens != 38 {
t.Fatalf("expected completion_tokens %d, got %d", 38, gotCompletionTokens)
}
assertReasoningTokens(t, out[0], 23)
@@ -554,8 +554,8 @@ func TestConvertClaudeResponseToOpenAI_StreamTrailingUsageIncludesReasoningToken
var trailing [][]byte
for _, ev := range events {
for _, chunk := range ConvertClaudeResponseToOpenAI(ctx, "claude-sonnet-5", nil, nil, ev, ¶m) {
- choices := gjson.GetBytes(chunk, "choices")
- if choices.Exists() && len(choices.Array()) == 0 && gjson.GetBytes(chunk, "usage").Exists() {
+ choices := ojson.GetBytesNoCopy(chunk, "choices")
+ if choices.Exists() && len(choices.Array()) == 0 && ojson.GetBytesNoCopy(chunk, "usage").Exists() {
trailing = append(trailing, chunk)
}
}
@@ -571,7 +571,7 @@ func TestConvertClaudeResponseToOpenAINonStream_UsageIncludesReasoningTokens(t *
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, rawJSON, nil)
- if gotCompletionTokens := gjson.GetBytes(out, "usage.completion_tokens").Int(); gotCompletionTokens != 52 {
+ if gotCompletionTokens := ojson.GetBytesNoCopy(out, "usage.completion_tokens").Int(); gotCompletionTokens != 52 {
t.Fatalf("expected completion_tokens %d, got %d", 52, gotCompletionTokens)
}
assertReasoningTokens(t, out, 32)
@@ -592,12 +592,12 @@ func TestConvertClaudeResponseToOpenAI_OmitsReasoningTokensWhenNotReported(t *te
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "usage.completion_tokens_details.reasoning_tokens"); got.Exists() {
+ if got := ojson.GetBytesNoCopy(out[0], "usage.completion_tokens_details.reasoning_tokens"); got.Exists() {
t.Fatalf("expected reasoning_tokens to be omitted, got %s; payload=%s", got.Raw, string(out[0]))
}
nonStream := ConvertClaudeResponseToOpenAINonStream(ctx, "", nil, nil, []byte("data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"model\":\"claude-sonnet-5\"}}\n"+"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":10,\"output_tokens\":38}}\n"), nil)
- if got := gjson.GetBytes(nonStream, "usage.completion_tokens_details.reasoning_tokens"); got.Exists() {
+ if got := ojson.GetBytesNoCopy(nonStream, "usage.completion_tokens_details.reasoning_tokens"); got.Exists() {
t.Fatalf("expected reasoning_tokens to be omitted, got %s; payload=%s", got.Raw, string(nonStream))
}
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_stop_reasons_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_stop_reasons_test.go
index 0d5dad0722e..ece91824754 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_stop_reasons_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_stop_reasons_test.go
@@ -6,7 +6,8 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func chatStopReasonChunks(reason string) [][]byte {
@@ -32,7 +33,7 @@ func TestChatContextWindowExceededStreamMapsToLengthAndKeepsTextAndUsage(t *test
var text string
var finish string
for _, output := range outputs {
- chunk := gjson.ParseBytes(output)
+ chunk := util.ParseGJSONBytesNoCopy(output)
if chunk.Get("error").Exists() {
t.Fatalf("unexpected error chunk: %s", chunk.Raw)
}
@@ -47,7 +48,7 @@ func TestChatContextWindowExceededStreamMapsToLengthAndKeepsTextAndUsage(t *test
if finish != "length" {
t.Fatalf("stream finish_reason = %q, want length", finish)
}
- last := gjson.ParseBytes(outputs[len(outputs)-1])
+ last := util.ParseGJSONBytesNoCopy(outputs[len(outputs)-1])
if last.Get("usage.completion_tokens").Int() != 3 {
t.Fatalf("trailing usage chunk = %s", last.Raw)
}
@@ -55,16 +56,16 @@ func TestChatContextWindowExceededStreamMapsToLengthAndKeepsTextAndUsage(t *test
func TestChatContextWindowExceededNonStreamMapsToLengthAndKeepsTextAndUsage(t *testing.T) {
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "claude-test", nil, nil, bytes.Join(chatStopReasonChunks("model_context_window_exceeded"), []byte("\n")), nil)
- if gjson.GetBytes(out, "error").Exists() {
+ if ojson.GetBytesNoCopy(out, "error").Exists() {
t.Fatalf("unexpected error body = %s", out)
}
- if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "length" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String(); got != "length" {
t.Fatalf("finish_reason = %q, want length: %s", got, out)
}
- if got := gjson.GetBytes(out, "choices.0.message.content").String(); got != "partial" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.content").String(); got != "partial" {
t.Fatalf("content = %q, want %q: %s", got, "partial", out)
}
- if got := gjson.GetBytes(out, "usage.completion_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(out, "usage.completion_tokens").Int(); got != 3 {
t.Fatalf("completion_tokens = %d, want 3: %s", got, out)
}
}
@@ -87,7 +88,7 @@ func TestChatTruncatedToolCallKeepsLengthFinishReason(t *testing.T) {
var streamCompletionTokens int64
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAI(context.Background(), "claude-test", nil, nil, chunk, ¶m) {
- parsed := gjson.ParseBytes(output)
+ parsed := util.ParseGJSONBytesNoCopy(output)
if args := parsed.Get("choices.0.delta.tool_calls.0.function.arguments"); args.Exists() {
streamArgs = args.String()
}
@@ -110,13 +111,13 @@ func TestChatTruncatedToolCallKeepsLengthFinishReason(t *testing.T) {
}
nonStream := ConvertClaudeResponseToOpenAINonStream(context.Background(), "claude-test", nil, nil, bytes.Join(chunks, []byte("\n")), nil)
- if got := gjson.GetBytes(nonStream, "choices.0.finish_reason").String(); got != "length" {
+ if got := ojson.GetBytesNoCopy(nonStream, "choices.0.finish_reason").String(); got != "length" {
t.Fatalf("non-stream finish_reason = %q, want length: %s", got, nonStream)
}
- if got := gjson.GetBytes(nonStream, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"command":"ls -l` {
+ if got := ojson.GetBytesNoCopy(nonStream, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"command":"ls -l` {
t.Fatalf("non-stream tool call arguments = %q: %s", got, nonStream)
}
- if got := gjson.GetBytes(nonStream, "usage.completion_tokens").Int(); got != 4 {
+ if got := ojson.GetBytesNoCopy(nonStream, "usage.completion_tokens").Int(); got != 4 {
t.Fatalf("non-stream completion_tokens = %d, want 4: %s", got, nonStream)
}
})
@@ -128,7 +129,7 @@ func TestChatRefusalKeepsContentFilterAndUsage(t *testing.T) {
var finish string
for _, chunk := range chatStopReasonChunks("refusal") {
for _, output := range ConvertClaudeResponseToOpenAI(context.Background(), "claude-test", nil, nil, chunk, ¶m) {
- if reason := gjson.GetBytes(output, "choices.0.finish_reason").String(); reason != "" {
+ if reason := ojson.GetBytesNoCopy(output, "choices.0.finish_reason").String(); reason != "" {
finish = reason
}
}
@@ -137,14 +138,14 @@ func TestChatRefusalKeepsContentFilterAndUsage(t *testing.T) {
t.Fatalf("stream finish_reason = %q", finish)
}
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "claude-test", nil, nil, bytes.Join(chatStopReasonChunks("refusal"), []byte("\n")), nil)
- if gjson.GetBytes(out, "choices.0.finish_reason").String() != "content_filter" || gjson.GetBytes(out, "usage.completion_tokens").Int() != 3 {
+ if ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String() != "content_filter" || ojson.GetBytesNoCopy(out, "usage.completion_tokens").Int() != 3 {
t.Fatalf("non-stream = %s", out)
}
}
func TestChatMaxTokensUnchanged(t *testing.T) {
out := ConvertClaudeResponseToOpenAINonStream(context.Background(), "claude-test", nil, nil, bytes.Join(chatStopReasonChunks("max_tokens"), []byte("\n")), nil)
- if gjson.GetBytes(out, "choices.0.finish_reason").String() != "length" {
+ if ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String() != "length" {
t.Fatalf("non-stream = %s", out)
}
}
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_user_turn_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_user_turn_test.go
index 76cd9dcbc22..cc1988a588d 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_user_turn_test.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_user_turn_test.go
@@ -4,8 +4,8 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
)
const (
@@ -51,7 +51,7 @@ func TestConvertOpenAIRequestToClaude_RefusesAnyEmptiedUserTurn(t *testing.T) {
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -64,7 +64,7 @@ func TestConvertOpenAIRequestToClaude_KeepsTurnWithTextBesideAttachment(t *testi
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "messages.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "messages.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -75,7 +75,7 @@ func TestConvertOpenAIRequestToClaude_InlineFileAfterHistoryStaysADocument(t *te
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "messages.2.content.0.type").String(); got != "document" {
+ if got := ojson.GetBytesNoCopy(body, "messages.2.content.0.type").String(); got != "document" {
t.Fatalf("inline file was not kept: %s", body)
}
}
diff --git a/internal/translator/claude/openai/chat-completions/noop_optimization_test.go b/internal/translator/claude/openai/chat-completions/noop_optimization_test.go
index d397fcb0f75..019b388c65d 100644
--- a/internal/translator/claude/openai/chat-completions/noop_optimization_test.go
+++ b/internal/translator/claude/openai/chat-completions/noop_optimization_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeResponseToOpenAINonStreamFinishReasons(t *testing.T) {
@@ -25,7 +25,7 @@ func TestConvertClaudeResponseToOpenAINonStreamFinishReasons(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
raw := []byte(`data: {"type":"message_delta","delta":{"stop_reason":"` + testCase.stopReason + `"}}`)
output := ConvertClaudeResponseToOpenAINonStream(context.Background(), "", nil, nil, raw, nil)
- if got := gjson.GetBytes(output, "choices.0.finish_reason").String(); got != testCase.want {
+ if got := ojson.GetBytesNoCopy(output, "choices.0.finish_reason").String(); got != testCase.want {
t.Fatalf("finish_reason = %q, want %q", got, testCase.want)
}
})
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_citations_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_citations_test.go
index 09d834f6141..6a9e395ea6d 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_citations_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_citations_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestClaudeResponsesAdjacentCitedTextBlocksShareMessage(t *testing.T) {
@@ -29,7 +29,7 @@ func TestClaudeResponsesAdjacentCitedTextBlocksShareMessage(t *testing.T) {
[]byte(`data: {"type":"message_stop"}`),
}
wantText := "The store reopened in 2024, and Olga was named for Ohlert."
- checkOutput := func(t *testing.T, items []gjson.Result) {
+ checkOutput := func(t *testing.T, items []ojson.Result) {
t.Helper()
if len(items) != 2 {
t.Fatalf("output items = %d, want 2 (web search and one message)", len(items))
@@ -55,7 +55,7 @@ func TestClaudeResponsesAdjacentCitedTextBlocksShareMessage(t *testing.T) {
t.Run("stream", func(t *testing.T) {
counts := make(map[string]int)
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range translateClaudeResponsesStreamThroughRegistry(chunks) {
event, data := parseClaudeResponsesSSEEvent(t, output)
counts[event]++
@@ -80,6 +80,25 @@ func TestClaudeResponsesAdjacentCitedTextBlocksShareMessage(t *testing.T) {
lines = append(lines, string(chunk))
}
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, []byte(strings.Join(lines, "\n")), nil)
- checkOutput(t, gjson.GetBytes(out, "output").Array())
+ checkOutput(t, ojson.GetBytesNoCopy(out, "output").Array())
+ })
+
+ t.Run("stream_buffer_reuse", func(t *testing.T) {
+ var param any
+ var buf []byte
+ var completed ojson.Result
+ for _, chunk := range chunks {
+ buf = append(buf[:0], chunk...)
+ for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", nil, nil, buf, ¶m) {
+ event, data := parseClaudeResponsesSSEEvent(t, output)
+ if event == "response.completed" {
+ completed = data
+ }
+ }
+ for i := range buf {
+ buf[i] = 'X'
+ }
+ }
+ checkOutput(t, completed.Get("response.output").Array())
})
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_interleaved_search_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_interleaved_search_test.go
index 807b03c911a..1c96045be1a 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_interleaved_search_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_interleaved_search_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// Regression for a production incident: a Claude turn that interleaved thinking
@@ -53,14 +53,14 @@ func TestInterleavedThinkingAndSearchSurvivesRoundTrip(t *testing.T) {
[]byte(`data: {"type":"message_stop"}`))
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range outputs {
if ev, data := parseClaudeResponsesSSEEvent(t, o); ev == "response.completed" {
completed = data
}
}
var items []json.RawMessage
- completed.Get("response.output").ForEach(func(_, v gjson.Result) bool {
+ completed.Get("response.output").ForEach(func(_, v ojson.Result) bool {
items = append(items, json.RawMessage(v.Raw))
return true
})
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_pause_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_pause_test.go
index d8b5c0ff66f..e0b681f8aa0 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_pause_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_pause_test.go
@@ -6,7 +6,8 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestClaudeResponsesServerToolStopReason(t *testing.T) {
@@ -34,7 +35,7 @@ func TestClaudeResponsesServerToolStopReason(t *testing.T) {
stop := chunks[len(chunks)-1]
chunks = append(chunks[:len(chunks)-1], []byte(fmt.Sprintf(
`data: {"type":"message_delta","delta":{"stop_reason":%q},"usage":{"output_tokens":12}}`, tc.reason)), stop)
- check := func(t *testing.T, response gjson.Result) {
+ check := func(t *testing.T, response ojson.Result) {
t.Helper()
if got := response.Get("status").String(); got != tc.status {
t.Fatalf("status = %q, want %q", got, tc.status)
@@ -82,7 +83,7 @@ func TestClaudeResponsesServerToolStopReason(t *testing.T) {
t.Run("buffered", func(t *testing.T) {
output := ConvertClaudeResponseToOpenAIResponsesNonStream(
context.Background(), "claude-test", nil, nil, bytes.Join(chunks, []byte("\n")), nil)
- check(t, gjson.ParseBytes(output))
+ check(t, util.ParseGJSONBytesNoCopy(output))
})
})
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go
index 5b7a1d1b49d..b68e3cc76c1 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_reasoning_order_test.go
@@ -4,7 +4,7 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponsesRequestToClaude_KeepsLatestConsecutiveReasoning(t *testing.T) {
@@ -22,7 +22,7 @@ func TestConvertOpenAIResponsesRequestToClaude_KeepsLatestConsecutiveReasoning(t
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
wantTypes := []string{"text", "thinking", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
@@ -57,7 +57,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolCallsSeparateReasoningBlocks(
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
wantTypes := []string{"thinking", "tool_use", "thinking", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
@@ -97,7 +97,7 @@ func TestConvertOpenAIResponsesRequestToClaude_NonThinkingBlocksSeparateReasonin
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
wantTypes := []string{"thinking", "text", "thinking", "redacted_thinking", "thinking", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
@@ -154,7 +154,7 @@ func TestConvertOpenAIResponsesRequestToClaude_WebSearchSeparatesToolUseWithThin
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.1.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.1.content").Array()
wantTypes := []string{"thinking", "text", "server_tool_use", "web_search_tool_result", "thinking", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
@@ -189,7 +189,7 @@ func TestConvertOpenAIResponsesRequestToClaude_WebSearchSeparatesToolUseWithThin
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.1.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.1.content").Array()
wantTypes := []string{"thinking", "text", "server_tool_use", "web_search_tool_result", "thinking", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
@@ -217,7 +217,7 @@ func TestConvertOpenAIResponsesRequestToClaude_WebSearchSeparatesToolUseWithThin
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.1.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.1.content").Array()
wantTypes := []string{"text", "server_tool_use", "web_search_tool_result", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
@@ -244,7 +244,7 @@ func TestConvertOpenAIResponsesRequestToClaude_WebSearchSeparatesToolUseWithThin
)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- content := gjson.GetBytes(out, "messages.1.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.1.content").Array()
wantTypes := []string{"thinking", "text", "thinking", "server_tool_use", "web_search_tool_result", "thinking", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("assistant content count = %d, want %d. Output: %s", len(content), len(wantTypes), out)
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
index 9d2c43b7fae..22c94b73292 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
@@ -8,6 +8,7 @@ import (
log "github.com/sirupsen/logrus"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
@@ -15,8 +16,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -36,20 +35,20 @@ const (
// - max_output_tokens -> max_tokens
// - stream passthrough via parameter
func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, false, ClientToolSearchEnabled())
+ return convertOpenAIResponsesRequestToClaude(context.Background(), modelName, inputRawJSON, stream, false, ClientToolSearchEnabled())
}
// ConvertOpenAIResponsesRequestToClaudeWithCompat preserves reasoning items
// whose encrypted content is empty for configured compatibility endpoints.
func ConvertOpenAIResponsesRequestToClaudeWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, true, ClientToolSearchEnabled())
+ return convertOpenAIResponsesRequestToClaude(context.Background(), modelName, inputRawJSON, stream, true, ClientToolSearchEnabled())
}
// ConvertOpenAIResponsesRequestEnvelopeToClaude translates with the
// upstream.claude.tool-search value pinned in ctx, if any.
func ConvertOpenAIResponsesRequestEnvelopeToClaude(ctx context.Context, req sdktranslator.RequestEnvelope) sdktranslator.RequestEnvelope {
- req.Body, req.Err = convertOpenAIResponsesRequestToClaude(req.Model, req.Body, req.Stream, false, ClientToolSearchEnabledFor(ctx))
+ req.Body, req.Err = convertOpenAIResponsesRequestToClaude(ctx, req.Model, req.Body, req.Stream, false, ClientToolSearchEnabledFor(ctx))
return req
}
@@ -57,23 +56,23 @@ func ConvertOpenAIResponsesRequestEnvelopeToClaude(ctx context.Context, req sdkt
// ConvertOpenAIResponsesRequestToClaudeWithCompat with the
// upstream.claude.tool-search value pinned in ctx, if any.
func ConvertOpenAIResponsesRequestToClaudeWithCompatContext(ctx context.Context, modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- return convertOpenAIResponsesRequestToClaude(modelName, inputRawJSON, stream, true, ClientToolSearchEnabledFor(ctx))
+ return convertOpenAIResponsesRequestToClaude(ctx, modelName, inputRawJSON, stream, true, ClientToolSearchEnabledFor(ctx))
}
// toolSearch carries upstream.claude.tool-search for the whole translation so
// a config reload cannot mix the two behaviors within one request.
-func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks, toolSearch bool) ([]byte, error) {
+func convertOpenAIResponsesRequestToClaude(ctx context.Context, modelName string, inputRawJSON []byte, stream, preserveEmptyThinkingBlocks, toolSearch bool) ([]byte, error) {
rawJSON := normalizeCodexAgentMessages(inputRawJSON)
var drops common.UserTurnDrops
userID := common.DeriveClaudeUserID(rawJSON)
// Base Claude message payload
- out := []byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`)
- out, _ = sjson.SetBytes(out, "metadata.user_id", userID)
- out, _ = sjson.SetBytes(out, "max_tokens", defaultClaudeResponsesMaxTokensForModel(modelName))
+ outDoc := ojson.NewDoc([]byte(`{"model":"","max_tokens":32000,"messages":[],"metadata":{}}`))
+ _ = outDoc.Set("metadata.user_id", userID)
+ _ = outDoc.Set("max_tokens", defaultClaudeResponsesMaxTokensForModel(modelName))
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Convert OpenAI Responses reasoning.effort to Claude thinking config.
if v := root.Get("reasoning.effort"); v.Exists() {
@@ -89,22 +88,22 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
if supportsAdaptive {
switch effort {
case "none":
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "disabled")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
case "auto":
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.DeleteBytes(out, "output_config.effort")
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Delete("output_config.effort")
default:
if !thinking.HasLevel(mi.Thinking.Levels, effort) {
if mapped, ok := thinking.MapToClaudeEffort(effort, supportsMax); ok {
effort = mapped
}
}
- out, _ = sjson.SetBytes(out, "thinking.type", "adaptive")
- out, _ = sjson.DeleteBytes(out, "thinking.budget_tokens")
- out, _ = sjson.SetBytes(out, "output_config.effort", effort)
+ _ = outDoc.Set("thinking.type", "adaptive")
+ _ = outDoc.Delete("thinking.budget_tokens")
+ _ = outDoc.Set("output_config.effort", effort)
}
} else {
// Legacy/manual thinking (budget_tokens).
@@ -112,13 +111,13 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
if ok {
switch budget {
case 0:
- out, _ = sjson.SetBytes(out, "thinking.type", "disabled")
+ _ = outDoc.Set("thinking.type", "disabled")
case -1:
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
+ _ = outDoc.Set("thinking.type", "enabled")
default:
if budget > 0 {
- out, _ = sjson.SetBytes(out, "thinking.type", "enabled")
- out, _ = sjson.SetBytes(out, "thinking.budget_tokens", budget)
+ _ = outDoc.Set("thinking.type", "enabled")
+ _ = outDoc.Set("thinking.budget_tokens", budget)
}
}
}
@@ -127,23 +126,23 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
// Model
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Max tokens
- if mot := root.Get("max_output_tokens"); mot.Exists() && mot.Type != gjson.Null {
+ if mot := root.Get("max_output_tokens"); mot.Exists() && mot.Type != ojson.Null {
val := mot.Int()
if info := registry.LookupModelInfo(modelName, "claude"); info != nil && info.MaxCompletionTokens > 0 && val > int64(info.MaxCompletionTokens) {
val = int64(info.MaxCompletionTokens)
}
- out, _ = sjson.SetBytes(out, "max_tokens", val)
+ _ = outDoc.Set("max_tokens", val)
}
// Stream
- out, _ = sjson.SetBytes(out, "stream", stream)
+ _ = outDoc.Set("stream", stream)
// Service Tier -> Speed
- if st := root.Get("service_tier"); st.Type == gjson.String && st.String() == "priority" {
- out, _ = sjson.SetBytes(out, "speed", "fast")
+ if st := root.Get("service_tier"); st.Type == ojson.String && st.String() == "priority" {
+ _ = outDoc.Set("speed", "fast")
}
// System-level inputs become canonical top-level Claude system blocks in
@@ -153,43 +152,43 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
// (mid-conversation role=system messages, or system reminders on legacy
// models), so this layer must not merge, trim or downgrade them to user text.
messageCapacity := root.Get("input.#").Int()
- if messageCapacity == 0 && root.Get("input").Type == gjson.String {
+ if messageCapacity == 0 && root.Get("input").Type == ojson.String {
messageCapacity = 1
}
messageBlocks := common.NewRawArrayItems(messageCapacity)
systemBlocks := make([][]byte, 0, 4)
- appendSystemText := func(text string, cacheSource gjson.Result) {
+ appendSystemText := func(text string, cacheSource ojson.Result) {
if strings.TrimSpace(text) == "" {
return
}
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", text)
+ block, _ = ojson.SetBytes(block, "text", text)
if cacheSource.Exists() {
block = common.AttachCacheControl(block, cacheSource)
}
systemBlocks = append(systemBlocks, block)
}
- if instr := root.Get("instructions"); instr.Type == gjson.String {
- appendSystemText(instr.String(), gjson.Result{})
+ if instr := root.Get("instructions"); instr.Type == ojson.String {
+ appendSystemText(instr.String(), ojson.Result{})
}
if input := root.Get("input"); input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if !isResponsesSystemLevelRole(item.Get("role").String()) {
return true
}
startIdx := len(systemBlocks)
content := item.Get("content")
- if content.Type == gjson.String {
- appendSystemText(content.String(), gjson.Result{})
+ if content.Type == ojson.String {
+ appendSystemText(content.String(), ojson.Result{})
} else if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
switch part.Get("type").String() {
case "input_text", "output_text", "text":
txt := part.Get("text").String()
if strings.TrimSpace(txt) == "" {
if part.Get("cache_control").Exists() && len(systemBlocks) > startIdx {
prevIdx := len(systemBlocks) - 1
- if !gjson.GetBytes(systemBlocks[prevIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(systemBlocks[prevIdx], "cache_control").Exists() {
systemBlocks[prevIdx] = common.AttachCacheControl(systemBlocks[prevIdx], part)
}
}
@@ -207,7 +206,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
// Item-level cache_control applies to the last block this item produced.
if item.Get("cache_control").Exists() && len(systemBlocks) > startIdx {
lastIdx := len(systemBlocks) - 1
- if !gjson.GetBytes(systemBlocks[lastIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(systemBlocks[lastIdx], "cache_control").Exists() {
systemBlocks[lastIdx] = common.AttachCacheControl(systemBlocks[lastIdx], item)
}
}
@@ -220,7 +219,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
formatResult = root.Get("response_format")
}
if formatInstruction := common.BuildClaudeStructuredOutputInstruction(formatResult); formatInstruction != "" {
- appendSystemText(formatInstruction, gjson.Result{})
+ appendSystemText(formatInstruction, ojson.Result{})
}
names := buildClaudeToolNames(root, toolSearch)
@@ -233,6 +232,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
var pendingRole string
var pendingParts [][]byte
var pendingToolUseParts [][]byte
+ var turnHasEmptyWebSearchResult bool
appendMessage := func(msg []byte) {
messageBlocks = append(messageBlocks, msg)
}
@@ -251,20 +251,27 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
combined = append(combined, pendingToolUseParts...)
parts = combined
}
+ if pendingRole == "assistant" && (turnHasEmptyWebSearchResult || claudeBlocksHaveEmptyWebSearchResult(parts)) {
+ var droppedCitations int
+ parts, droppedCitations = stripClaudeWebSearchCitationsFromParts(parts)
+ if droppedCitations > 0 {
+ log.Debugf("responses->claude: dropped %d web_search_result_location citation(s) from assistant turn without search results", droppedCitations)
+ }
+ }
if len(parts) > 0 {
- msg := []byte(`{"role":"","content":[]}`)
- msg, _ = sjson.SetBytes(msg, "role", pendingRole)
+ msgDoc := ojson.NewDoc([]byte(`{"role":"","content":[]}`))
+ _ = msgDoc.Set("role", pendingRole)
if len(parts) == 1 {
- part := gjson.ParseBytes(parts[0])
+ part := util.ParseGJSONBytesNoCopy(parts[0])
if part.Get("type").String() == "text" && !part.Get("cache_control").Exists() && !part.Get("citations").Exists() {
- msg, _ = sjson.SetBytes(msg, "content", part.Get("text").String())
+ _ = msgDoc.Set("content", part.Get("text").String())
} else {
- msg, _ = sjson.SetRawBytes(msg, "content", common.JoinRawArray(parts))
+ _ = msgDoc.SetRawBytes("content", common.JoinRawArray(parts))
}
} else {
- msg, _ = sjson.SetRawBytes(msg, "content", common.JoinRawArray(parts))
+ _ = msgDoc.SetRawBytes("content", common.JoinRawArray(parts))
}
- appendMessage(msg)
+ appendMessage(msgDoc.Bytes())
}
pendingRole = ""
@@ -307,10 +314,10 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
pendingToolUseParts = nil
}
- currentType := gjson.GetBytes(reasoningPart, "type").String()
+ currentType := ojson.GetBytesNoCopy(reasoningPart, "type").String()
if currentType == "thinking" && len(pendingParts) > 0 {
lastIdx := len(pendingParts) - 1
- if gjson.GetBytes(pendingParts[lastIdx], "type").String() == "thinking" {
+ if ojson.GetBytesNoCopy(pendingParts[lastIdx], "type").String() == "thinking" {
pendingParts[lastIdx] = reasoningPart
return
}
@@ -318,18 +325,18 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
pendingParts = append(pendingParts, reasoningPart)
}
- var inputItems []gjson.Result
+ var inputItems []ojson.Result
if input := root.Get("input"); input.Exists() {
if input.IsArray() {
inputItems = common.NormalizeResponsesToolCallOutputs(input.Array())
- } else if input.Type == gjson.String && strings.TrimSpace(input.String()) != "" {
+ } else if input.Type == ojson.String && strings.TrimSpace(input.String()) != "" {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", input.String())
+ contentPart, _ = ojson.SetBytes(contentPart, "text", input.String())
appendParts("user", contentPart)
}
}
- lastToolResult := map[string]gjson.Result{}
+ lastToolResult := map[string]ojson.Result{}
for _, item := range inputItems {
switch item.Get("type").String() {
case "function_call_output", "custom_tool_call_output":
@@ -359,17 +366,17 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
var role string
var partsJSON [][]byte
var droppedPartType string
- attachCacheControlToPrecedingPart := func(src gjson.Result) {
+ attachCacheControlToPrecedingPart := func(src ojson.Result) {
if !src.Get("cache_control").Exists() || len(partsJSON) == 0 {
return
}
prevIdx := len(partsJSON) - 1
- if !gjson.GetBytes(partsJSON[prevIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(partsJSON[prevIdx], "cache_control").Exists() {
partsJSON[prevIdx] = common.AttachCacheControl(partsJSON[prevIdx], src)
}
}
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
ptype := part.Get("type").String()
switch ptype {
case "input_text", "output_text":
@@ -377,7 +384,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
txt := t.String()
if strings.TrimSpace(txt) != "" {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", txt)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", txt)
contentPart = attachClaudeCitations(contentPart, part.Get("annotations"))
contentPart = common.AttachCacheControl(contentPart, part)
partsJSON = append(partsJSON, contentPart)
@@ -396,7 +403,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
txt := t.String()
if strings.TrimSpace(txt) != "" {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", txt)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", txt)
contentPart = common.AttachCacheControl(contentPart, part)
partsJSON = append(partsJSON, contentPart)
} else {
@@ -423,13 +430,14 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
data = mediaAndData[1]
}
if data != "" {
- contentPart = []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
- contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
+ cpDoc := ojson.NewDoc([]byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = cpDoc.Set("source.media_type", mediaType)
+ _ = cpDoc.Set("source.data", data)
+ contentPart = cpDoc.Bytes()
}
} else {
contentPart = []byte(`{"type":"image","source":{"type":"url","url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "source.url", url)
+ contentPart, _ = ojson.SetBytes(contentPart, "source.url", url)
}
if len(contentPart) > 0 {
contentPart = common.AttachCacheControl(contentPart, part)
@@ -454,10 +462,10 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
data = mediaAndData[1]
}
}
- contentPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
- contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
- contentPart = common.AttachCacheControl(contentPart, part)
+ cpDoc := ojson.NewDoc([]byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = cpDoc.Set("source.media_type", mediaType)
+ _ = cpDoc.Set("source.data", data)
+ contentPart := common.AttachCacheControl(cpDoc.Bytes(), part)
partsJSON = append(partsJSON, contentPart)
if role == "" {
role = "user"
@@ -473,9 +481,9 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
return true
})
- } else if parts.Type == gjson.String && strings.TrimSpace(parts.String()) != "" {
+ } else if parts.Type == ojson.String && strings.TrimSpace(parts.String()) != "" {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", parts.String())
+ contentPart, _ = ojson.SetBytes(contentPart, "text", parts.String())
partsJSON = append(partsJSON, contentPart)
}
@@ -499,16 +507,22 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
if len(partsJSON) > 0 {
lastIdx := len(partsJSON) - 1
- if !gjson.GetBytes(partsJSON[lastIdx], "cache_control").Exists() {
+ if !ojson.GetBytesNoCopy(partsJSON[lastIdx], "cache_control").Exists() {
partsJSON[lastIdx] = common.AttachCacheControl(partsJSON[lastIdx], item)
}
appendParts(role, partsJSON...)
+ if role == "user" {
+ turnHasEmptyWebSearchResult = false
+ }
}
case "web_search_call":
// Rebuild the Claude server-side search pair so the replayed turn
// still shows the search and its hits.
- if blocks := webSearchHistory.claudeBlocks(itemIndex, item); len(blocks) > 0 {
+ if blocks := webSearchHistory.claudeBlocksWithContext(ctx, itemIndex, item); len(blocks) > 0 {
+ if claudeBlocksHaveEmptyWebSearchResult(blocks) {
+ turnHasEmptyWebSearchResult = true
+ }
appendParts("assistant", blocks...)
}
@@ -534,22 +548,22 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
isCustomToolCall := typ == "custom_tool_call"
- toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUse, _ = sjson.SetBytes(toolUse, "id", callID)
- toolUse, _ = sjson.SetBytes(toolUse, "name", names.claudeName(name))
+ toolUseDoc := ojson.NewDoc([]byte(`{"type":"tool_use","id":"","name":"","input":{}}`))
+ _ = toolUseDoc.Set("id", callID)
+ _ = toolUseDoc.Set("name", names.claudeName(name))
if isCustomToolCall {
- toolUse, _ = sjson.SetBytes(toolUse, "input.input", item.Get("input").String())
+ _ = toolUseDoc.Set("input.input", item.Get("input").String())
} else {
argsStr := item.Get("arguments").String()
- if argsStr != "" && gjson.Valid(argsStr) {
- argsJSON := gjson.Parse(argsStr)
+ if argsStr != "" && ojson.Valid(argsStr) {
+ argsJSON := ojson.Parse(argsStr)
if argsJSON.IsObject() {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
+ _ = toolUseDoc.SetRaw("input", argsJSON.Raw)
}
}
}
- appendToolUse(toolUse)
+ appendToolUse(toolUseDoc.Bytes())
case "function_call_output", "custom_tool_call_output":
// Map to user tool_result
@@ -575,20 +589,21 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
// same Claude id are not mistaken for the same call.
if _, paired := emittedRawToolUses[rawID]; rawID == "" || !paired {
appendParts("user", convertResponsesStandaloneToolOutputToClaudeText(output)...)
+ turnHasEmptyWebSearchResult = false
continue
}
callID := util.SanitizeClaudeToolID(rawID)
- toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
- toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", callID)
+ toolResultDoc := ojson.NewDoc([]byte(`{"type":"tool_result","tool_use_id":"","content":""}`))
+ _ = toolResultDoc.Set("tool_use_id", callID)
// Responses reports an unfinished tool output as status
// "incomplete"; Claude's only in-band equivalent is is_error
// on tool_result, so carry the signal over instead of dropping it.
if strings.EqualFold(strings.TrimSpace(outputItem.Get("status").String()), "incomplete") {
- toolResult, _ = sjson.SetBytes(toolResult, "is_error", true)
+ _ = toolResultDoc.Set("is_error", true)
}
- toolResult = applyResponsesToolResultContent(toolResult, output)
+ applyResponsesToolResultContentDoc(toolResultDoc, output)
- appendParts("user", toolResult)
+ appendParts("user", toolResultDoc.Bytes())
case "tool_search_call":
if !toolSearch {
@@ -617,14 +632,15 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
if _, paired := emittedRawToolUses[rawID]; rawID == "" || !paired {
text := []byte(`{"type":"text","text":""}`)
- text, _ = sjson.SetBytes(text, "text", responsesToolSearchOutputText(item, names))
+ text, _ = ojson.SetBytes(text, "text", responsesToolSearchOutputText(item, names))
appendParts("user", text)
+ turnHasEmptyWebSearchResult = false
continue
}
- toolResult := []byte(`{"type":"tool_result","tool_use_id":"","content":""}`)
- toolResult, _ = sjson.SetBytes(toolResult, "tool_use_id", util.SanitizeClaudeToolID(rawID))
- toolResult, _ = sjson.SetRawBytes(toolResult, "content", responsesToolSearchOutputContent(item, names, winners, nativeToolSearch))
- appendParts("user", toolResult)
+ toolResultDoc := ojson.NewDoc([]byte(`{"type":"tool_result","tool_use_id":"","content":""}`))
+ _ = toolResultDoc.Set("tool_use_id", util.SanitizeClaudeToolID(rawID))
+ _ = toolResultDoc.SetRawBytes("content", responsesToolSearchOutputContent(item, names, winners, nativeToolSearch))
+ appendParts("user", toolResultDoc.Bytes())
default:
// Reachability guard: Claude only ever receives the item types this
@@ -661,9 +677,9 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
if len(messageBlocks) == 0 && (len(systemBlocks) > 0 || hadMessages) {
messageBlocks = append(messageBlocks, []byte(`{"role":"user","content":[{"type":"text","text":""}]}`))
}
- out = common.SetRawArrayItems(out, "messages", messageBlocks)
+ common.SetRawArrayItemsDoc(outDoc, "messages", messageBlocks)
if len(systemBlocks) > 0 {
- out, _ = sjson.SetRawBytes(out, "system", common.JoinRawArray(systemBlocks))
+ _ = outDoc.SetRawBytes("system", common.JoinRawArray(systemBlocks))
}
includedToolNames := map[string]struct{}{}
@@ -691,10 +707,12 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
anchorIndex = len(toolItems)
}
// Anthropic rejects cache_control on deferred tools.
- tJSON, _ = sjson.DeleteBytes(tJSON, "cache_control")
- tJSON, _ = sjson.SetBytes(tJSON, "defer_loading", true)
+ tDoc := ojson.NewDoc(tJSON)
+ _ = tDoc.Delete("cache_control")
+ _ = tDoc.Set("defer_loading", true)
+ tJSON = tDoc.Bytes()
}
- toolName := gjson.GetBytes(tJSON, "name").String()
+ toolName := ojson.GetBytesNoCopy(tJSON, "name").String()
if toolName != "" {
includedToolNames[descriptor.name] = struct{}{}
includedToolNames[toolName] = struct{}{}
@@ -711,7 +729,7 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
toolNameMap = responsesToolNameMap(root, includedToolNames, toolSearch)
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", common.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", common.JoinRawArray(toolItems))
}
// Map tool_choice similar to Chat Completions translator (optional in docs, safe to handle)
@@ -719,21 +737,21 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
forcedToolFilteredOut := false
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
switch toolChoice.Type {
- case gjson.String:
+ case ojson.String:
switch toolChoice.String() {
case "auto":
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto"}`)
case "none":
// Leave unset; implies no tools
toolChoiceNone = true
case "required":
if len(includedToolNames) > 0 {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"any"}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"any"}`)
} else {
forcedToolFilteredOut = true
}
}
- case gjson.JSON:
+ case ojson.JSON:
choiceType := strings.TrimSpace(toolChoice.Get("type").String())
if choiceType == "none" {
toolChoiceNone = true
@@ -760,8 +778,8 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
}
if _, ok := includedToolNames[fn]; ok {
toolChoiceJSON := []byte(`{"name":"","type":"tool"}`)
- toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "name", names.claudeName(fn))
- out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
+ toolChoiceJSON, _ = ojson.SetBytes(toolChoiceJSON, "name", names.claudeName(fn))
+ _ = outDoc.SetRawBytes("tool_choice", toolChoiceJSON)
} else {
forcedToolFilteredOut = true
}
@@ -779,17 +797,17 @@ func convertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
// mirroring the Chat Completions translator. It is not added when the
// effective choice is none, when no tool survived conversion, or when a
// forced tool was filtered out (no tool_choice was produced for it).
- if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Type == gjson.False && !toolChoiceNone && !forcedToolFilteredOut && len(toolItems) > 0 {
- if choice := gjson.GetBytes(out, "tool_choice"); choice.Exists() {
+ if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Type == ojson.False && !toolChoiceNone && !forcedToolFilteredOut && len(toolItems) > 0 {
+ if choice := outDoc.Get("tool_choice"); choice.Exists() {
if choice.Get("type").String() != "none" {
- out, _ = sjson.SetBytes(out, "tool_choice.disable_parallel_tool_use", true)
+ _ = outDoc.Set("tool_choice.disable_parallel_tool_use", true)
}
} else {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(`{"type":"auto","disable_parallel_tool_use":true}`))
+ _ = outDoc.SetRaw("tool_choice", `{"type":"auto","disable_parallel_tool_use":true}`)
}
}
- return thinking.ApplyTranslatedSummaryToClaude(out, rawJSON, "openai-response", modelName), drops.Err()
+ return thinking.ApplyTranslatedSummaryToClaude(outDoc.Bytes(), rawJSON, "openai-response", modelName), drops.Err()
}
func defaultClaudeResponsesMaxTokensForModel(modelName string) int {
@@ -832,7 +850,7 @@ func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte)
if !claudeModelRejectsAssistantPrefill(modelName) || len(messages) == 0 {
return messages
}
- last := gjson.ParseBytes(messages[len(messages)-1])
+ last := util.ParseGJSONBytesNoCopy(messages[len(messages)-1])
if !strings.EqualFold(strings.TrimSpace(last.Get("role").String()), "assistant") {
return messages
}
@@ -842,7 +860,7 @@ func dropUnsupportedClaudeAssistantPrefill(modelName string, messages [][]byte)
return messages[:len(messages)-1]
}
-func claudeAssistantMessageEndsWithPausedServerTool(message gjson.Result) bool {
+func claudeAssistantMessageEndsWithPausedServerTool(message ojson.Result) bool {
content := message.Get("content")
if !content.IsArray() {
return false
@@ -868,7 +886,7 @@ func stripTrailingClaudeThinkingBlocks(messages [][]byte) [][]byte {
return messages
}
lastIdx := len(messages) - 1
- last := gjson.ParseBytes(messages[lastIdx])
+ last := util.ParseGJSONBytesNoCopy(messages[lastIdx])
if !strings.EqualFold(strings.TrimSpace(last.Get("role").String()), "assistant") {
return messages
}
@@ -900,12 +918,12 @@ func stripTrailingClaudeThinkingBlocks(messages [][]byte) [][]byte {
if end == 1 {
part := parts[0]
if part.Get("type").String() == "text" && !part.Get("cache_control").Exists() && !part.Get("citations").Exists() {
- updatedMsg, _ = sjson.SetBytes(messages[lastIdx], "content", part.Get("text").String())
+ updatedMsg, _ = ojson.SetBytes(messages[lastIdx], "content", part.Get("text").String())
} else {
- updatedMsg, _ = sjson.SetRawBytes(messages[lastIdx], "content", common.JoinRawArray(remainingParts))
+ updatedMsg, _ = ojson.SetRawBytes(messages[lastIdx], "content", common.JoinRawArray(remainingParts))
}
} else {
- updatedMsg, _ = sjson.SetRawBytes(messages[lastIdx], "content", common.JoinRawArray(remainingParts))
+ updatedMsg, _ = ojson.SetRawBytes(messages[lastIdx], "content", common.JoinRawArray(remainingParts))
}
messages[lastIdx] = updatedMsg
return messages
@@ -959,13 +977,13 @@ func claudeModelRejectsAssistantPrefill(modelName string) bool {
// rejected request, and the marker lets the Claude executor fail the request with
// the offending type named. The original payload is not copied because the
// request can never succeed.
-func responsesSystemUnsupportedBlock(part gjson.Result) []byte {
+func responsesSystemUnsupportedBlock(part ojson.Result) []byte {
partType := strings.TrimSpace(part.Get("type").String())
if partType == "" {
return nil
}
block := []byte(`{"type":""}`)
- block, _ = sjson.SetBytes(block, "type", partType)
+ block, _ = ojson.SetBytes(block, "type", partType)
return block
}
@@ -978,7 +996,7 @@ func responsesSystemUnsupportedBlock(part gjson.Result) []byte {
// signature for upstreams that use a provider-specific signature format.
// Anthropic does not verify the text against the signature, which is what makes
// the summarized text safe to restore alongside it.
-func convertResponsesReasoningToClaudeThinking(item gjson.Result, preserveEmptyThinkingBlocks ...bool) []byte {
+func convertResponsesReasoningToClaudeThinking(item ojson.Result, preserveEmptyThinkingBlocks ...bool) []byte {
encrypted := item.Get("encrypted_content").String()
preserveEmpty := len(preserveEmptyThinkingBlocks) > 0 && preserveEmptyThinkingBlocks[0]
if data, isRedacted := responsesRedactedThinkingData(encrypted); isRedacted {
@@ -986,7 +1004,7 @@ func convertResponsesReasoningToClaudeThinking(item gjson.Result, preserveEmptyT
return nil
}
redactedPart := []byte(`{"type":"redacted_thinking","data":""}`)
- redactedPart, _ = sjson.SetBytes(redactedPart, "data", data)
+ redactedPart, _ = ojson.SetBytes(redactedPart, "data", data)
return redactedPart
}
@@ -999,10 +1017,10 @@ func convertResponsesReasoningToClaudeThinking(item gjson.Result, preserveEmptyT
}
thinkingText := responsesReasoningText(item)
- thinkingPart := []byte(`{"type":"thinking","thinking":"","signature":""}`)
- thinkingPart, _ = sjson.SetBytes(thinkingPart, "thinking", thinkingText)
- thinkingPart, _ = sjson.SetBytes(thinkingPart, "signature", signature)
- return thinkingPart
+ thinkingPartDoc := ojson.NewDoc([]byte(`{"type":"thinking","thinking":"","signature":""}`))
+ _ = thinkingPartDoc.Set("thinking", thinkingText)
+ _ = thinkingPartDoc.Set("signature", signature)
+ return thinkingPartDoc.Bytes()
}
// responsesRedactedThinkingData reports whether encrypted_content carries an
@@ -1021,22 +1039,22 @@ func responsesRedactedThinkingData(encryptedContent string) (string, bool) {
// item back through whichever array their SDK models, so both are read. content[]
// is only consulted when summary[] carried nothing, otherwise a client that
// mirrors the text into both arrays would replay it twice.
-func responsesReasoningText(item gjson.Result) string {
+func responsesReasoningText(item ojson.Result) string {
if text := responsesReasoningPartsText(item.Get("summary")); text != "" {
return text
}
return responsesReasoningPartsText(item.Get("content"))
}
-func responsesReasoningPartsText(parts gjson.Result) string {
+func responsesReasoningPartsText(parts ojson.Result) string {
if !parts.Exists() || !parts.IsArray() {
return ""
}
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if text := part.Get("text"); text.Exists() {
builder.WriteString(text.String())
- } else if part.Type == gjson.String {
+ } else if part.Type == ojson.String {
builder.WriteString(part.String())
}
return true
@@ -1054,31 +1072,31 @@ func claudeThinkingSeparatorForToolUse(parts [][]byte) []byte {
if len(parts) == 0 {
return nil
}
- if gjson.GetBytes(parts[len(parts)-1], "type").String() != "web_search_tool_result" {
+ if ojson.GetBytesNoCopy(parts[len(parts)-1], "type").String() != "web_search_tool_result" {
return nil
}
for index := len(parts) - 1; index >= 0; index-- {
- if gjson.GetBytes(parts[index], "type").String() == "thinking" {
+ if ojson.GetBytesNoCopy(parts[index], "type").String() == "thinking" {
return parts[index]
}
}
return nil
}
-func applyResponsesToolResultContent(toolResult []byte, output gjson.Result) []byte {
+func applyResponsesToolResultContentDoc(toolResultDoc *ojson.Doc, output ojson.Result) {
if output.Exists() && output.IsArray() {
var partsJSON [][]byte
hasImage := false
hasFile := false
droppedEmptyPart := false
- output.ForEach(func(_, part gjson.Result) bool {
+ output.ForEach(func(_, part ojson.Result) bool {
if partJSON := convertResponsesContentPartToClaude(part); len(partJSON) > 0 {
if !contentPartHasVisibleContent(partJSON) {
droppedEmptyPart = true
return true
}
partsJSON = append(partsJSON, partJSON)
- partType := gjson.ParseBytes(partJSON).Get("type").String()
+ partType := util.ParseGJSONBytesNoCopy(partJSON).Get("type").String()
if partType == "image" {
hasImage = true
}
@@ -1090,25 +1108,23 @@ func applyResponsesToolResultContent(toolResult []byte, output gjson.Result) []b
})
if len(partsJSON) == 0 {
if droppedEmptyPart {
- toolResult, _ = sjson.SetBytes(toolResult, "content", "")
- return toolResult
+ _ = toolResultDoc.Set("content", "")
+ return
}
- toolResult, _ = sjson.SetBytes(toolResult, "content", output.Raw)
- return toolResult
+ _ = toolResultDoc.Set("content", output.Raw)
+ return
}
if len(partsJSON) == 1 && !hasImage && !hasFile {
- textPart := gjson.ParseBytes(partsJSON[0])
+ textPart := util.ParseGJSONBytesNoCopy(partsJSON[0])
if textPart.Get("type").String() == "text" {
- toolResult, _ = sjson.SetBytes(toolResult, "content", textPart.Get("text").String())
- return toolResult
+ _ = toolResultDoc.Set("content", textPart.Get("text").String())
+ return
}
}
- toolResult, _ = sjson.DeleteBytes(toolResult, "content")
- toolResult, _ = sjson.SetRawBytes(toolResult, "content", common.JoinRawArray(partsJSON))
- return toolResult
+ _ = toolResultDoc.SetRawBytes("content", common.JoinRawArray(partsJSON))
+ return
}
- toolResult, _ = sjson.SetBytes(toolResult, "content", output.String())
- return toolResult
+ _ = toolResultDoc.Set("content", output.String())
}
// claudeMessageInvariantProblems reports shapes Anthropic rejects. It only
@@ -1117,16 +1133,16 @@ func applyResponsesToolResultContent(toolResult []byte, output gjson.Result) []b
// the proxy log instead of from an upstream 400.
func claudeMessageInvariantProblems(messages [][]byte) []string {
var problems []string
- if len(messages) > 0 && gjson.GetBytes(messages[0], "role").String() != "user" {
+ if len(messages) > 0 && ojson.GetBytesNoCopy(messages[0], "role").String() != "user" {
problems = append(problems, "first message is not user")
}
for i, msg := range messages {
- role := gjson.GetBytes(msg, "role").String()
- content := gjson.GetBytes(msg, "content")
+ role := ojson.GetBytesNoCopy(msg, "role").String()
+ content := ojson.GetBytesNoCopy(msg, "content")
switch role {
case "assistant":
var toolUseIDs []string
- content.ForEach(func(_, block gjson.Result) bool {
+ content.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_use" {
toolUseIDs = append(toolUseIDs, block.Get("id").String())
}
@@ -1135,13 +1151,13 @@ func claudeMessageInvariantProblems(messages [][]byte) []string {
if len(toolUseIDs) == 0 {
continue
}
- if i+1 >= len(messages) || gjson.GetBytes(messages[i+1], "role").String() != "user" {
+ if i+1 >= len(messages) || ojson.GetBytesNoCopy(messages[i+1], "role").String() != "user" {
problems = append(problems, "messages["+strconv.Itoa(i)+"] tool_use has no following user message")
continue
}
- next := gjson.GetBytes(messages[i+1], "content")
+ next := ojson.GetBytesNoCopy(messages[i+1], "content")
answered := map[string]struct{}{}
- next.ForEach(func(_, block gjson.Result) bool {
+ next.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" {
answered[block.Get("tool_use_id").String()] = struct{}{}
}
@@ -1153,19 +1169,19 @@ func claudeMessageInvariantProblems(messages [][]byte) []string {
}
}
case "user":
- var previous gjson.Result
+ var previous ojson.Result
if i > 0 {
- previous = gjson.GetBytes(messages[i-1], "content")
+ previous = ojson.GetBytesNoCopy(messages[i-1], "content")
}
toolUses := map[string]struct{}{}
- previous.ForEach(func(_, block gjson.Result) bool {
+ previous.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_use" {
toolUses[block.Get("id").String()] = struct{}{}
}
return true
})
leading := true
- content.ForEach(func(_, block gjson.Result) bool {
+ content.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" {
if !leading {
problems = append(problems, "messages["+strconv.Itoa(i)+"] tool_result after non-tool_result block")
@@ -1200,8 +1216,8 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
out := make([][]byte, 0, len(messages)+1)
for i := 0; i < len(messages); i++ {
msg := messages[i]
- role := gjson.GetBytes(msg, "role").String()
- content := gjson.GetBytes(msg, "content")
+ role := ojson.GetBytesNoCopy(msg, "role").String()
+ content := ojson.GetBytesNoCopy(msg, "content")
if role == "user" {
// Fold tool_result blocks that do not answer a tool_use in the
@@ -1210,7 +1226,7 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
rebuilt, changed := normalizeClaudeToolResultMessage(msg, prevToolUseIDs)
if changed {
msg = rebuilt
- content = gjson.GetBytes(msg, "content")
+ content = ojson.GetBytesNoCopy(msg, "content")
if i < len(messages) {
messages[i] = msg
}
@@ -1222,7 +1238,7 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
prevToolUseIDs = map[string]struct{}{}
if role == "assistant" {
var toolUseIDs []string
- content.ForEach(func(_, block gjson.Result) bool {
+ content.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_use" {
if id := block.Get("id").String(); id != "" {
toolUseIDs = append(toolUseIDs, id)
@@ -1235,10 +1251,10 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
continue
}
- hasNextUser := i+1 < len(messages) && gjson.GetBytes(messages[i+1], "role").String() == "user"
+ hasNextUser := i+1 < len(messages) && ojson.GetBytesNoCopy(messages[i+1], "role").String() == "user"
answered := map[string]struct{}{}
if hasNextUser {
- gjson.GetBytes(messages[i+1], "content").ForEach(func(_, block gjson.Result) bool {
+ ojson.GetBytesNoCopy(messages[i+1], "content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" {
answered[block.Get("tool_use_id").String()] = struct{}{}
}
@@ -1252,7 +1268,7 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
continue
}
part := []byte(`{"type":"tool_result","tool_use_id":"","is_error":true,"content":"Tool call was interrupted before any output was recorded."}`)
- part, _ = sjson.SetBytes(part, "tool_use_id", id)
+ part, _ = ojson.SetBytes(part, "tool_use_id", id)
synthesized = append(synthesized, part)
}
if len(synthesized) == 0 {
@@ -1263,24 +1279,24 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
// Prepend the missing results so tool_result blocks still lead
// the existing user message.
next := messages[i+1]
- nextContent := gjson.GetBytes(next, "content")
+ nextContent := ojson.GetBytesNoCopy(next, "content")
parts := synthesized
if nextContent.IsArray() {
- nextContent.ForEach(func(_, block gjson.Result) bool {
+ nextContent.ForEach(func(_, block ojson.Result) bool {
parts = append(parts, []byte(block.Raw))
return true
})
- } else if nextContent.Type == gjson.String {
+ } else if nextContent.Type == ojson.String {
textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", nextContent.String())
+ textPart, _ = ojson.SetBytes(textPart, "text", nextContent.String())
parts = append(parts, textPart)
}
userMsg := []byte(`{"role":"user","content":[]}`)
- userMsg, _ = sjson.SetRawBytes(userMsg, "content", common.JoinRawArray(parts))
+ userMsg, _ = ojson.SetRawBytes(userMsg, "content", common.JoinRawArray(parts))
messages[i+1] = userMsg
} else {
userMsg := []byte(`{"role":"user","content":[]}`)
- userMsg, _ = sjson.SetRawBytes(userMsg, "content", common.JoinRawArray(synthesized))
+ userMsg, _ = ojson.SetRawBytes(userMsg, "content", common.JoinRawArray(synthesized))
out = append(out, userMsg)
}
}
@@ -1293,14 +1309,14 @@ func repairClaudeToolPairing(messages [][]byte) [][]byte {
// in the immediately preceding assistant message folds into plain text.
// answeredIDs lists the ids allowed to stay tool_result blocks.
func normalizeClaudeToolResultMessage(msg []byte, answeredIDs map[string]struct{}) ([]byte, bool) {
- content := gjson.GetBytes(msg, "content")
+ content := ojson.GetBytesNoCopy(msg, "content")
if !content.IsArray() {
return msg, false
}
var resultParts, otherParts [][]byte
seenOther := false
changed := false
- content.ForEach(func(_, block gjson.Result) bool {
+ content.ForEach(func(_, block ojson.Result) bool {
raw := []byte(block.Raw)
if block.Get("type").String() == "tool_result" {
if _, ok := answeredIDs[block.Get("tool_use_id").String()]; !ok {
@@ -1333,7 +1349,7 @@ func normalizeClaudeToolResultMessage(msg []byte, answeredIDs map[string]struct{
parts = append(parts, resultParts...)
parts = append(parts, otherParts...)
userMsg := []byte(`{"role":"user","content":[]}`)
- userMsg, _ = sjson.SetRawBytes(userMsg, "content", common.JoinRawArray(parts))
+ userMsg, _ = ojson.SetRawBytes(userMsg, "content", common.JoinRawArray(parts))
return userMsg, true
}
@@ -1342,14 +1358,14 @@ func normalizeClaudeToolResultMessage(msg []byte, answeredIDs map[string]struct{
// filtered out; when nothing visible remains it returns nil so the caller
// can substitute a marker instead of emitting empty text blocks that
// Anthropic rejects.
-func toolResultTextParts(block gjson.Result) [][]byte {
+func toolResultTextParts(block ojson.Result) [][]byte {
content := block.Get("content")
if content.IsArray() {
var parts [][]byte
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
raw := []byte(part.Raw)
if part.Get("type").String() == "" {
- raw, _ = sjson.SetBytes(raw, "type", "text")
+ raw, _ = ojson.SetBytes(raw, "type", "text")
}
if !contentPartHasVisibleContent(raw) {
return true
@@ -1367,16 +1383,16 @@ func toolResultTextParts(block gjson.Result) [][]byte {
return nil
}
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
return [][]byte{part}
}
// convertResponsesStandaloneToolOutputToClaudeText renders a tool output that
// has no matching tool_use as ordinary user content blocks.
-func convertResponsesStandaloneToolOutputToClaudeText(output gjson.Result) [][]byte {
+func convertResponsesStandaloneToolOutputToClaudeText(output ojson.Result) [][]byte {
if output.Exists() && output.IsArray() {
var partsJSON [][]byte
- output.ForEach(func(_, part gjson.Result) bool {
+ output.ForEach(func(_, part ojson.Result) bool {
if partJSON := convertResponsesContentPartToClaude(part); len(partJSON) > 0 {
// Drop empty text parts so a mixed array never emits blocks
// Anthropic rejects; images and documents always count.
@@ -1400,29 +1416,29 @@ func convertResponsesStandaloneToolOutputToClaudeText(output gjson.Result) [][]b
return [][]byte{[]byte(`{"type":"text","text":"Tool result was empty."}`)}
}
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", text)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", text)
return [][]byte{contentPart}
}
// contentPartHasVisibleContent reports whether a converted Claude content
// block carries user-visible payload (non-empty text, image, or document).
func contentPartHasVisibleContent(part []byte) bool {
- switch gjson.GetBytes(part, "type").String() {
+ switch ojson.GetBytesNoCopy(part, "type").String() {
case "text":
- return strings.TrimSpace(gjson.GetBytes(part, "text").String()) != ""
+ return strings.TrimSpace(ojson.GetBytesNoCopy(part, "text").String()) != ""
default:
// Non-text blocks (image, document, ...) always carry content.
return true
}
}
-func convertResponsesContentPartToClaude(part gjson.Result) []byte {
+func convertResponsesContentPartToClaude(part ojson.Result) []byte {
ptype := part.Get("type").String()
switch ptype {
case "input_text", "output_text":
if t := part.Get("text"); t.Exists() {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", t.String())
+ contentPart, _ = ojson.SetBytes(contentPart, "text", t.String())
return contentPart
}
case "input_image":
@@ -1447,13 +1463,13 @@ func convertResponsesContentPartToClaude(part gjson.Result) []byte {
if data == "" {
return nil
}
- contentPart := []byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
- contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
- return contentPart
+ cpDoc := ojson.NewDoc([]byte(`{"type":"image","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = cpDoc.Set("source.media_type", mediaType)
+ _ = cpDoc.Set("source.data", data)
+ return cpDoc.Bytes()
}
contentPart := []byte(`{"type":"image","source":{"type":"url","url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "source.url", url)
+ contentPart, _ = ojson.SetBytes(contentPart, "source.url", url)
return contentPart
case "input_file":
fileData := part.Get("file_data").String()
@@ -1472,10 +1488,10 @@ func convertResponsesContentPartToClaude(part gjson.Result) []byte {
data = mediaAndData[1]
}
}
- contentPart := []byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "source.media_type", mediaType)
- contentPart, _ = sjson.SetBytes(contentPart, "source.data", data)
- return contentPart
+ cpDoc := ojson.NewDoc([]byte(`{"type":"document","source":{"type":"base64","media_type":"","data":""}}`))
+ _ = cpDoc.Set("source.media_type", mediaType)
+ _ = cpDoc.Set("source.data", data)
+ return cpDoc.Bytes()
}
return nil
}
@@ -1511,13 +1527,13 @@ func convertResponsesToolDescriptorToClaude(descriptor responsesToolDescriptor,
const responsesDiscoveredToolPriority = 2
type responsesToolSource struct {
- tools gjson.Result
+ tools ojson.Result
priority int // Top-level tools use 0, additional_tools 1, tool_search_output results 2.
}
-func responsesToolSources(root gjson.Result, toolSearch bool) []responsesToolSource {
+func responsesToolSources(root ojson.Result, toolSearch bool) []responsesToolSource {
var sources []responsesToolSource
- appendSource := func(tools gjson.Result, priority int) {
+ appendSource := func(tools ojson.Result, priority int) {
if tools.Exists() && tools.IsArray() {
sources = append(sources, responsesToolSource{tools: tools, priority: priority})
}
@@ -1525,14 +1541,14 @@ func responsesToolSources(root gjson.Result, toolSearch bool) []responsesToolSou
appendSource(root.Get("tools"), 0)
if input := root.Get("input"); input.Exists() && input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "additional_tools" {
appendSource(item.Get("tools"), 1)
}
return true
})
if toolSearch {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "tool_search_output" {
appendSource(item.Get("tools"), responsesDiscoveredToolPriority)
}
@@ -1548,7 +1564,7 @@ type responsesToolDescriptor struct {
childName string
namespace string
toolType string
- tool gjson.Result
+ tool ojson.Result
sourcePriority int
direct bool
order int
@@ -1557,7 +1573,7 @@ type responsesToolDescriptor struct {
clientSearch bool
}
-func responsesToolDescriptors(root gjson.Result, toolSearch bool) []responsesToolDescriptor {
+func responsesToolDescriptors(root ojson.Result, toolSearch bool) []responsesToolDescriptor {
var descriptors []responsesToolDescriptor
for _, source := range responsesToolSources(root, toolSearch) {
descriptors = appendResponsesToolDescriptors(descriptors, source.tools, source.priority, toolSearch)
@@ -1591,8 +1607,8 @@ func responsesClientToolSearchWins(descriptors []responsesToolDescriptor) bool {
return winner != nil && winner.clientSearch
}
-func appendResponsesToolDescriptors(descriptors []responsesToolDescriptor, tools gjson.Result, sourcePriority int, toolSearch bool) []responsesToolDescriptor {
- appendDescriptor := func(tool gjson.Result, name, childName, namespaceName, toolType string, direct bool) {
+func appendResponsesToolDescriptors(descriptors []responsesToolDescriptor, tools ojson.Result, sourcePriority int, toolSearch bool) []responsesToolDescriptor {
+ appendDescriptor := func(tool ojson.Result, name, childName, namespaceName, toolType string, direct bool) {
if name == "" {
return
}
@@ -1607,13 +1623,13 @@ func appendResponsesToolDescriptors(descriptors []responsesToolDescriptor, tools
order: len(descriptors),
})
}
- appendNamespaceChildren := func(namespaceTool gjson.Result) {
+ appendNamespaceChildren := func(namespaceTool ojson.Result) {
namespaceName := strings.TrimSpace(namespaceTool.Get("name").String())
children := namespaceTool.Get("tools")
if !children.Exists() || !children.IsArray() {
return
}
- children.ForEach(func(_, child gjson.Result) bool {
+ children.ForEach(func(_, child ojson.Result) bool {
childName := responsesToolName(child)
if childName == "" {
return true
@@ -1628,7 +1644,7 @@ func appendResponsesToolDescriptors(descriptors []responsesToolDescriptor, tools
return true
})
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
case "", "function":
@@ -1682,7 +1698,7 @@ func responsesToolDescriptorPrecedes(left, right responsesToolDescriptor) bool {
return left.order < right.order
}
-func responsesToolWinners(root gjson.Result, toolSearch bool) map[string]responsesToolDescriptor {
+func responsesToolWinners(root ojson.Result, toolSearch bool) map[string]responsesToolDescriptor {
winners := map[string]responsesToolDescriptor{}
for _, descriptor := range responsesToolDescriptors(root, toolSearch) {
current, exists := winners[descriptor.name]
@@ -1693,7 +1709,7 @@ func responsesToolWinners(root gjson.Result, toolSearch bool) map[string]respons
return winners
}
-func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct{}, toolSearch bool) map[string]string {
+func responsesToolNameMap(root ojson.Result, acceptedToolNames map[string]struct{}, toolSearch bool) map[string]string {
toolNameMap := map[string]string{}
descriptors := responsesToolDescriptors(root, toolSearch)
winners := responsesToolWinners(root, toolSearch)
@@ -1731,7 +1747,7 @@ func responsesToolNameMap(root gjson.Result, acceptedToolNames map[string]struct
func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
names := make(map[string]struct{})
- root := gjson.ParseBytes(requestRawJSON)
+ root := util.ParseGJSONBytesNoCopy(requestRawJSON)
toolSearch := ClientToolSearchEnabled()
winners := responsesToolWinners(root, toolSearch)
toolNames := buildClaudeToolNamesWithWinners(root, winners, toolSearch)
@@ -1748,8 +1764,8 @@ func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
func unwrapCustomToolInput(arguments string) string {
trimmed := strings.TrimSpace(arguments)
- if v := gjson.Get(trimmed, "input"); v.Exists() {
- if v.Type == gjson.String {
+ if v := ojson.Get(trimmed, "input"); v.Exists() {
+ if v.Type == ojson.String {
return v.String()
}
return v.Raw
@@ -1821,7 +1837,7 @@ func unwrapCustomToolInput(arguments string) string {
return arguments
}
-func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
+func convertResponsesFunctionToolToClaude(tool ojson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = util.SanitizeClaudeFunctionName(responsesToolName(tool))
@@ -1830,20 +1846,20 @@ func convertResponsesFunctionToolToClaude(tool gjson.Result, overrideName string
return nil, false
}
- tJSON := []byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{}}}`)
- tJSON, _ = sjson.SetBytes(tJSON, "name", name)
+ tDoc := ojson.NewDoc([]byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{}}}`))
+ _ = tDoc.Set("name", name)
if d := responsesToolDescription(tool); d != "" {
- tJSON, _ = sjson.SetBytes(tJSON, "description", d)
+ _ = tDoc.Set("description", d)
}
- tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", util.NormalizeClaudeToolInputSchema([]byte(responsesToolParameters(tool).Raw)))
- tJSON = common.AttachCacheControl(tJSON, tool)
- if !gjson.GetBytes(tJSON, "cache_control").Exists() {
+ _ = tDoc.SetRawBytes("input_schema", util.NormalizeClaudeToolInputSchema([]byte(responsesToolParameters(tool).Raw)))
+ tJSON := common.AttachCacheControl(tDoc.Bytes(), tool)
+ if !ojson.GetBytesNoCopy(tJSON, "cache_control").Exists() {
tJSON = common.AttachCacheControl(tJSON, tool.Get("function"))
}
return tJSON, true
}
-func convertResponsesCustomToolToClaude(tool gjson.Result, overrideName string) ([]byte, bool) {
+func convertResponsesCustomToolToClaude(tool ojson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = util.SanitizeClaudeFunctionName(responsesToolName(tool))
@@ -1852,20 +1868,20 @@ func convertResponsesCustomToolToClaude(tool gjson.Result, overrideName string)
return nil, false
}
- tJSON := []byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}`)
- tJSON, _ = sjson.SetBytes(tJSON, "name", name)
+ tDoc := ojson.NewDoc([]byte(`{"name":"","description":"","input_schema":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}`))
+ _ = tDoc.Set("name", name)
if description := responsesToolDescription(tool); description != "" {
- tJSON, _ = sjson.SetBytes(tJSON, "description", description)
+ _ = tDoc.Set("description", description)
}
if applypatch.IsCustomTool(tool) {
- tJSON, _ = sjson.SetBytes(tJSON, "description", applypatch.Description(tool))
- tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", applypatch.Parameters())
+ _ = tDoc.Set("description", applypatch.Description(tool))
+ _ = tDoc.SetRawBytes("input_schema", applypatch.Parameters())
}
- tJSON = common.AttachCacheControl(tJSON, tool)
+ tJSON := common.AttachCacheControl(tDoc.Bytes(), tool)
return tJSON, true
}
-func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
+func convertResponsesWebSearchToolToClaude(tool ojson.Result) ([]byte, bool) {
if externalWebAccess := tool.Get("external_web_access"); externalWebAccess.Exists() && !externalWebAccess.Bool() {
return nil, false
}
@@ -1874,35 +1890,35 @@ func convertResponsesWebSearchToolToClaude(tool gjson.Result) ([]byte, bool) {
if name == "" {
name = "web_search"
}
- tJSON := []byte(`{"type":"web_search_20250305","name":""}`)
- tJSON, _ = sjson.SetBytes(tJSON, "name", name)
+ tDoc := ojson.NewDoc([]byte(`{"type":"web_search_20250305","name":""}`))
+ _ = tDoc.Set("name", name)
if maxUses := tool.Get("max_uses"); maxUses.Exists() {
- tJSON, _ = sjson.SetBytes(tJSON, "max_uses", maxUses.Int())
+ _ = tDoc.Set("max_uses", maxUses.Int())
}
if allowedDomains := tool.Get("filters.allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
- tJSON, _ = sjson.SetRawBytes(tJSON, "allowed_domains", []byte(allowedDomains.Raw))
+ _ = tDoc.SetRaw("allowed_domains", allowedDomains.Raw)
}
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
- tJSON, _ = sjson.SetRawBytes(tJSON, "user_location", []byte(userLocation.Raw))
+ _ = tDoc.SetRaw("user_location", userLocation.Raw)
}
- return tJSON, true
+ return tDoc.Bytes(), true
}
-func responsesToolName(tool gjson.Result) string {
+func responsesToolName(tool ojson.Result) string {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return name
}
return strings.TrimSpace(tool.Get("function.name").String())
}
-func responsesToolDescription(tool gjson.Result) string {
+func responsesToolDescription(tool ojson.Result) string {
if description := tool.Get("description").String(); description != "" {
return description
}
return tool.Get("function.description").String()
}
-func responsesToolParameters(tool gjson.Result) gjson.Result {
+func responsesToolParameters(tool ojson.Result) ojson.Result {
for _, path := range []string{
"parameters",
"parametersJsonSchema",
@@ -1914,7 +1930,7 @@ func responsesToolParameters(tool gjson.Result) gjson.Result {
return parameters
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
func qualifyResponsesNamespaceToolName(namespaceName, childName string) string {
@@ -1937,7 +1953,7 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali
return "", ""
}
- root := gjson.ParseBytes(requestRawJSON)
+ root := util.ParseGJSONBytesNoCopy(requestRawJSON)
winners := responsesToolWinners(root, toolSearch)
toolNames := buildClaudeToolNamesWithWinners(root, winners, toolSearch)
identity := toolNames.identity(qualifiedName)
@@ -1966,50 +1982,49 @@ func isUnsupportedOpenAIBuiltinToolType(toolType string) bool {
// not drop the delegated task text. Encrypted content parts are surfaced as
// input_text, mirroring the multi-agent v2 optimizer used for other upstreams.
func normalizeCodexAgentMessages(payload []byte) []byte {
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if !input.IsArray() {
return payload
}
- updated := payload
- changed := false
+ var updatedDoc *ojson.Doc
for itemIndex, item := range input.Array() {
if strings.TrimSpace(item.Get("type").String()) != "agent_message" {
continue
}
+ if updatedDoc == nil {
+ updatedDoc = ojson.NewDoc(payload)
+ }
itemPath := "input." + strconv.Itoa(itemIndex)
- var errSet error
if content := item.Get("content"); content.IsArray() {
for partIndex, part := range content.Array() {
if strings.TrimSpace(part.Get("type").String()) != "encrypted_content" {
continue
}
enc := part.Get("encrypted_content")
- if enc.Type != gjson.String {
+ if enc.Type != ojson.String {
continue
}
partPath := itemPath + ".content." + strconv.Itoa(partIndex)
- if updated, errSet = sjson.SetBytes(updated, partPath+".type", "input_text"); errSet != nil {
+ if errSet := updatedDoc.Set(partPath+".type", "input_text"); errSet != nil {
return payload
}
- if updated, errSet = sjson.SetBytes(updated, partPath+".text", enc.String()); errSet != nil {
+ if errSet := updatedDoc.Set(partPath+".text", enc.String()); errSet != nil {
return payload
}
- var errDelete error
- if updated, errDelete = sjson.DeleteBytes(updated, partPath+".encrypted_content"); errDelete != nil {
+ if errDelete := updatedDoc.Delete(partPath + ".encrypted_content"); errDelete != nil {
return payload
}
}
}
- if updated, errSet = sjson.SetBytes(updated, itemPath+".role", "user"); errSet != nil {
+ if errSet := updatedDoc.Set(itemPath+".role", "user"); errSet != nil {
return payload
}
- if updated, errSet = sjson.SetBytes(updated, itemPath+".type", "message"); errSet != nil {
+ if errSet := updatedDoc.Set(itemPath+".type", "message"); errSet != nil {
return payload
}
- changed = true
}
- if !changed {
+ if updatedDoc == nil {
return payload
}
- return updated
+ return updatedDoc.Bytes()
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
index 4d969c813e5..a64270fe81c 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request_test.go
@@ -2,13 +2,14 @@ package responses
import (
"context"
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -31,7 +32,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SanitizesToolCallIDsForClaude(t *
}`
result, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
toolUseID := resultJSON.Get("messages.0.content.0.id").String()
toolResultID := resultJSON.Get("messages.1.content.0.tool_use_id").String()
@@ -50,7 +51,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableMaxTokens(t *testing.T) {
[]byte(`{"model":"claude-fable-5-1","input":"hello"}`),
true,
)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 64000 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 64000 {
t.Fatalf("max_tokens = %d, want %d; output=%s", got, 64000, out)
}
})
@@ -61,7 +62,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableMaxTokens(t *testing.T) {
[]byte(`{"model":"claude-fable-5-1","max_output_tokens":128000,"input":"hello"}`),
true,
)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 128000 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 128000 {
t.Fatalf("max_tokens = %d, want 128000; output=%s", got, out)
}
})
@@ -72,7 +73,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableMaxTokens(t *testing.T) {
[]byte(`{"model":"claude-3-5-haiku-20241022","input":"hello"}`),
true,
)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 8192 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 8192 {
t.Fatalf("max_tokens = %d, want 8192; output=%s", got, out)
}
})
@@ -83,7 +84,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableMaxTokens(t *testing.T) {
[]byte(`{"model":"claude-3-5-haiku-20241022","max_output_tokens":128000,"input":"hello"}`),
true,
)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 8192 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 8192 {
t.Fatalf("max_tokens = %d, want 8192; output=%s", got, out)
}
})
@@ -94,7 +95,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableMaxTokens(t *testing.T) {
[]byte(`{"model":"claude-fable-5-1","max_output_tokens":null,"input":"hello"}`),
true,
)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 64000 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 64000 {
t.Fatalf("max_tokens = %d, want 64000; output=%s", got, out)
}
})
@@ -133,7 +134,7 @@ func TestConvertOpenAIResponsesRequestToClaude_OmittedMaxOutputTokensUsesRegistr
if errConvert != nil {
t.Fatalf("convert: %v", errConvert)
}
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != tc.wantLimit {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != tc.wantLimit {
t.Fatalf("max_tokens = %d, want %d; output=%s", got, tc.wantLimit, out)
}
})
@@ -164,7 +165,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ReasoningItemToThinkingBlock(t *t
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
assistant := root.Get("messages.0")
if got := assistant.Get("role").String(); got != "assistant" {
@@ -209,7 +210,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SignatureOnlyReasoningFlushesBefo
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
thinking := root.Get("messages.0.content.0")
if got := thinking.Get("type").String(); got != "thinking" {
@@ -250,7 +251,7 @@ func TestConvertOpenAIResponsesRequestToClaude_RedactedReasoningItemRestoresReda
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
block := root.Get("messages.0.content.0")
if got := block.Get("type").String(); got != "redacted_thinking" {
@@ -285,7 +286,7 @@ func TestConvertOpenAIResponsesRequestToClaude_EmptyRedactedReasoningItemIsDropp
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 1 {
t.Fatalf("message count = %d, want only the user turn. Output: %s", got, string(out))
@@ -315,7 +316,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ReasoningContentTextRebuildsThink
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
thinking := root.Get("messages.0.content.0")
if got := thinking.Get("thinking").String(); got != "restored from content" {
@@ -345,7 +346,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SummarySetsThinkingDisplay(t *tes
}
raw := []byte(`{"model":"claude-opus-5-5","reasoning":{` + reasoning + `},"input":"hi"}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", raw, false)
- if got := gjson.GetBytes(out, "thinking.display").String(); got != test.want {
+ if got := ojson.GetBytesNoCopy(out, "thinking.display").String(); got != test.want {
t.Fatalf("thinking.display = %q, want %q", got, test.want)
}
})
@@ -372,7 +373,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SummaryWinsOverDuplicatedReasonin
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- if got := gjson.ParseBytes(out).Get("messages.0.content.0.thinking").String(); got != "chain of thought" {
+ if got := util.ParseGJSONBytesNoCopy(out).Get("messages.0.content.0.thinking").String(); got != "chain of thought" {
t.Fatalf("thinking text = %q, want the summary text exactly once. Output: %s", got, string(out))
}
}
@@ -396,13 +397,13 @@ func TestConvertOpenAIResponsesRequestToClaude_DropsIncompatibleReasoningSignatu
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- if gjson.GetBytes(out, "messages.0.content.0.type").String() == "thinking" {
+ if ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String() == "thinking" {
t.Fatalf("GPT encrypted_content should not become Claude thinking. Output: %s", string(out))
}
- if gjson.GetBytes(out, "messages.0.content.0.signature").Exists() {
+ if ojson.GetBytesNoCopy(out, "messages.0.content.0.signature").Exists() {
t.Fatalf("incompatible signature should not be forwarded. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "user" {
t.Fatalf("first message role = %q, want user. Output: %s", got, string(out))
}
}
@@ -448,7 +449,7 @@ func TestConvertOpenAIResponsesRequestToClaude_GroupsAssistantAndToolResultTurns
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", got, string(out))
}
@@ -514,7 +515,7 @@ func TestConvertOpenAIResponsesRequestToClaude_MergesConsecutiveUserMessagesAndP
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 1 {
t.Fatalf("message count = %d, want 1. Output: %s", got, string(out))
}
@@ -547,7 +548,7 @@ func TestConvertOpenAIResponsesRequestToClaude_DoesNotMergeAcrossRoleChanges(t *
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
messages := root.Get("messages").Array()
if len(messages) != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", len(messages), string(out))
@@ -570,7 +571,7 @@ func TestConvertOpenAIResponsesRequestToClaude_EmptyStringContentDoesNotBreakAss
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
messages := root.Get("messages").Array()
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1. Output: %s", len(messages), string(out))
@@ -619,7 +620,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FunctionCallOutputPreservesInputI
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
toolResult := root.Get("messages.1.content.0")
if got := toolResult.Get("type").String(); got != "tool_result" {
@@ -661,10 +662,10 @@ func TestConvertOpenAIResponsesRequestToClaude_StandaloneToolOutputBecomesUserTe
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
- root.Get("messages").ForEach(func(_, msg gjson.Result) bool {
- msg.Get("content").ForEach(func(_, block gjson.Result) bool {
+ root.Get("messages").ForEach(func(_, msg ojson.Result) bool {
+ msg.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" {
t.Fatalf("unexpected tool_result for standalone output. Output: %s", string(out))
}
@@ -697,7 +698,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SynthesizesResultForDanglingToolU
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", got, string(out))
@@ -729,7 +730,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SynthesizesResultForTrailingToolU
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", got, string(out))
@@ -760,7 +761,7 @@ func TestConvertOpenAIResponsesRequestToClaude_MovesToolResultsAheadOfInjectedTe
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", got, string(out))
@@ -796,7 +797,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SynthesizesResultForTrailingToolU
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-fable-5-1", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 3 {
t.Fatalf("message count = %d, want 3. Output: %s", got, string(out))
@@ -832,7 +833,7 @@ func TestConvertOpenAIResponsesRequestToClaude_LateOrphanToolResultFoldsToText(t
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
// messages: user | assistant(tool_use call_a) | user | assistant(text) | user
if got := root.Get("messages.#").Int(); got != 5 {
@@ -855,7 +856,7 @@ func TestConvertOpenAIResponsesRequestToClaude_LateOrphanToolResultFoldsToText(t
if got := last.Get("role").String(); got != "user" {
t.Fatalf("messages.4.role = %q, want user. Output: %s", got, string(out))
}
- last.Get("content").ForEach(func(_, block gjson.Result) bool {
+ last.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" {
t.Fatalf("late output must not remain a tool_result. Output: %s", string(out))
}
@@ -880,7 +881,7 @@ func TestConvertOpenAIResponsesRequestToClaude_EmptyStandaloneToolOutputKeepsMar
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 1 {
t.Fatalf("message count = %d, want 1 for output %s. Output: %s", got, output, string(out))
@@ -919,7 +920,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SanitizedIDCollisionKeepsOrphanAs
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
blocks := root.Get("messages.2.content").Array()
if len(blocks) != 2 {
@@ -955,14 +956,14 @@ func TestConvertOpenAIResponsesRequestToClaude_EmptyLateOrphanToolResultKeepsNon
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-fable-5-1", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
- root.Get("messages").ForEach(func(_, msg gjson.Result) bool {
+ root.Get("messages").ForEach(func(_, msg ojson.Result) bool {
content := msg.Get("content")
if content.IsArray() && len(content.Array()) == 0 {
t.Fatalf("empty content array in message. Output: %s", string(out))
}
- content.ForEach(func(_, block gjson.Result) bool {
+ content.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" && block.Get("tool_use_id").String() == "call_a" && !block.Get("is_error").Bool() {
t.Fatalf("late empty orphan must not remain a tool_result. Output: %s", string(out))
}
@@ -994,10 +995,10 @@ func TestConvertOpenAIResponsesRequestToClaude_LateOrphanToolResultArrayOfEmptyT
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-fable-5-1", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
- root.Get("messages").ForEach(func(_, msg gjson.Result) bool {
- msg.Get("content").ForEach(func(_, block gjson.Result) bool {
+ root.Get("messages").ForEach(func(_, msg ojson.Result) bool {
+ msg.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "text" && strings.TrimSpace(block.Get("text").String()) == "" {
t.Fatalf("empty text block emitted. Output: %s", string(out))
}
@@ -1025,10 +1026,10 @@ func TestConvertOpenAIResponsesRequestToClaude_StandaloneToolOutputDropsEmptyTex
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
- root.Get("messages").ForEach(func(_, msg gjson.Result) bool {
- msg.Get("content").ForEach(func(_, block gjson.Result) bool {
+ root.Get("messages").ForEach(func(_, msg ojson.Result) bool {
+ msg.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "text" && strings.TrimSpace(block.Get("text").String()) == "" {
t.Fatalf("empty text block emitted. Output: %s", string(out))
}
@@ -1105,7 +1106,7 @@ func TestConvertOpenAIResponsesRequestToClaude_KeepsToolUseAdjacentToToolResult(
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("messages.#").Int(); got != 2 {
t.Fatalf("message count = %d, want 2. Output: %s", got, string(out))
@@ -1151,7 +1152,7 @@ func TestConvertOpenAIResponsesRequestToClaude_KeepsApplyPatchCustomTool(t *test
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("tools.#").Int(); got != 2 {
t.Fatalf("tools count = %d, want 2. Output: %s", got, out)
@@ -1183,7 +1184,7 @@ func TestConvertOpenAIResponsesRequestToClaude_NormalizesRootToolSchemaUnion(t *
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- schema := gjson.GetBytes(out, "tools.0.input_schema")
+ schema := ojson.GetBytesNoCopy(out, "tools.0.input_schema")
if got := schema.Get("type").String(); got != "object" {
t.Fatalf("input_schema.type = %q, want object. Output: %s", got, string(out))
@@ -1236,7 +1237,7 @@ func TestConvertOpenAIResponsesRequestToClaude_MergesAdditionalToolsAndPrefersTo
if errConvert1 != nil {
t.Fatal(errConvert1)
}
- root := gjson.ParseBytes(body1)
+ root := util.ParseGJSONBytesNoCopy(body1)
if got := root.Get("tools.#").Int(); got != 4 {
t.Fatalf("tools count = %d, want 4; output=%s", got, root.Raw)
}
@@ -1272,7 +1273,7 @@ func TestConvertOpenAIResponsesRequestToClaude_DeduplicatesExpandedToolNames(t *
if errConvert2 != nil {
t.Fatal(errConvert2)
}
- root := gjson.ParseBytes(body2)
+ root := util.ParseGJSONBytesNoCopy(body2)
if got := root.Get("tools.#").Int(); got != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", got, root.Raw)
}
@@ -1306,7 +1307,7 @@ func TestConvertOpenAIResponsesRequestToClaude_DirectToolWinsOverEarlierNamespac
if errConvert3 != nil {
t.Fatal(errConvert3)
}
- root := gjson.ParseBytes(body3)
+ root := util.ParseGJSONBytesNoCopy(body3)
if got := root.Get("tools.#").Int(); got != 1 {
t.Fatalf("tools count = %d, want 1; output=%s", got, root.Raw)
}
@@ -1337,7 +1338,7 @@ func TestConvertOpenAIResponsesRequestToClaude_PrefersDirectToolAcrossAdditional
if errConvert4 != nil {
t.Fatal(errConvert4)
}
- root := gjson.ParseBytes(body4)
+ root := util.ParseGJSONBytesNoCopy(body4)
if got := root.Get("tools.#").Int(); got != 1 {
t.Fatalf("tools count = %d, want 1; output=%s", got, root.Raw)
}
@@ -1370,7 +1371,7 @@ func TestConvertOpenAIResponsesRequestToClaude_PreservesToolDeclarationOrder(t *
if errConvert5 != nil {
t.Fatal(errConvert5)
}
- root := gjson.ParseBytes(body5)
+ root := util.ParseGJSONBytesNoCopy(body5)
want := []string{"first", "n__middle", "last"}
got := root.Get("tools.#.name").Array()
if len(got) != len(want) {
@@ -1396,7 +1397,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ReplaysCustomToolCallHistory(t *t
if errConvert6 != nil {
t.Fatal(errConvert6)
}
- root := gjson.ParseBytes(body6)
+ root := util.ParseGJSONBytesNoCopy(body6)
toolUse := root.Get("messages.0.content.0")
if got := toolUse.Get("type").String(); got != "tool_use" {
t.Fatalf("tool use type = %q, want tool_use; output=%s", got, root.Raw)
@@ -1433,7 +1434,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ReplaysNamespacedFunctionCallHist
if errConvert7 != nil {
t.Fatal(errConvert7)
}
- root := gjson.ParseBytes(body7)
+ root := util.ParseGJSONBytesNoCopy(body7)
if !root.Get(`tools.#(name=="mcp__node_repl__js")`).Exists() {
t.Fatal("missing qualified namespace tool declaration")
}
@@ -1488,7 +1489,7 @@ func TestConvertOpenAIResponsesRequestToClaude_MapsCustomAndNamespacedToolChoice
if errConvert8 != nil {
t.Fatal(errConvert8)
}
- root := gjson.ParseBytes(body8)
+ root := util.ParseGJSONBytesNoCopy(body8)
if got := root.Get("tool_choice.type").String(); got != "tool" {
t.Fatalf("tool_choice.type = %q, want tool; output=%s", got, root.Raw)
}
@@ -1525,7 +1526,7 @@ func TestQualifyResponsesNamespaceToolNameAvoidsPrefixCollision(t *testing.T) {
if errConvert9 != nil {
t.Fatal(errConvert9)
}
- root := gjson.ParseBytes(body9)
+ root := util.ParseGJSONBytesNoCopy(body9)
if got := root.Get("tools.0.name").String(); got != "collab__collaboration" {
t.Fatalf("qualified tool declaration = %q, want collab__collaboration", got)
}
@@ -1604,7 +1605,7 @@ func TestConvertOpenAIResponsesRequestToClaude_PreservesContentPartCacheControl(
}`
result, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
content := resultJSON.Get("messages.0.content")
if !content.IsArray() {
@@ -1632,7 +1633,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SystemLevelInputsBecomeSeparateSy
}`
result, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
- root := gjson.ParseBytes(result)
+ root := util.ParseGJSONBytesNoCopy(result)
system := root.Get("system").Array()
if len(system) != 4 {
@@ -1674,7 +1675,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SystemOnlyInputKeepsFallbackUserM
if errConvert10 != nil {
t.Fatal(errConvert10)
}
- root := gjson.ParseBytes(body10)
+ root := util.ParseGJSONBytesNoCopy(body10)
if got := len(root.Get("system").Array()); got != 1 {
t.Fatalf("system blocks = %d, want 1", got)
}
@@ -1703,7 +1704,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SystemNonTextPartKeptAsTypedMarke
if errConvert11 != nil {
t.Fatal(errConvert11)
}
- root := gjson.ParseBytes(body11)
+ root := util.ParseGJSONBytesNoCopy(body11)
system := root.Get("system").Array()
if len(system) != 2 {
t.Fatalf("system blocks = %d, want 2. system: %s", len(system), root.Get("system").Raw)
@@ -1735,7 +1736,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SystemItemCacheControlAppliesToLa
if errConvert12 != nil {
t.Fatal(errConvert12)
}
- system := gjson.ParseBytes(body12).Get("system").Array()
+ system := util.ParseGJSONBytesNoCopy(body12).Get("system").Array()
if len(system) != 2 {
t.Fatalf("system blocks = %d, want 2", len(system))
}
@@ -1810,7 +1811,7 @@ func TestConvertOpenAIResponsesRequestToClaude_DeduplicatesToolOutputs(t *testin
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
messages := root.Get("messages").Array()
if len(messages) < 5 {
@@ -1978,7 +1979,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ServiceTierToSpeed(t *testing.T)
raw += `}`
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-3-7-sonnet-20250219", []byte(raw), false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
speedResult := root.Get("speed")
if speedResult.Exists() != tt.wantSpeedExist {
@@ -2017,10 +2018,10 @@ func TestConvertOpenAIResponsesRequestToClaude_PreservesCallerSuppliedMetadataUs
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", []byte(tc.rawJSON), false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
- got := gjson.GetBytes(out, "metadata.user_id").String()
+ got := ojson.GetBytesNoCopy(out, "metadata.user_id").String()
if got != tc.expected {
t.Fatalf("metadata.user_id = %q, want %q", got, tc.expected)
}
@@ -2031,10 +2032,10 @@ func TestConvertOpenAIResponsesRequestToClaude_PreservesCallerSuppliedMetadataUs
func TestConvertOpenAIResponsesRequestToClaude_PreservesUserField(t *testing.T) {
raw := []byte(`{"model":"claude-test","user":"openai-resp-user-456","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("output is invalid json: %s", string(out))
}
- got := gjson.GetBytes(out, "metadata.user_id").String()
+ got := ojson.GetBytesNoCopy(out, "metadata.user_id").String()
if got != "openai-resp-user-456" {
t.Fatalf("metadata.user_id = %q, want %q", got, "openai-resp-user-456")
}
@@ -2045,8 +2046,8 @@ func TestConvertOpenAIResponsesRequestToClaude_DifferentSessionsProduceDifferent
b := []byte(`{"model":"claude-test","prompt_cache_key":"resp-session-b","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}]}`)
outA, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", a, false)
outB, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", b, false)
- idA := gjson.GetBytes(outA, "metadata.user_id").String()
- idB := gjson.GetBytes(outB, "metadata.user_id").String()
+ idA := ojson.GetBytesNoCopy(outA, "metadata.user_id").String()
+ idB := ojson.GetBytesNoCopy(outB, "metadata.user_id").String()
if idA == idB {
t.Fatalf("different prompt_cache_key produced identical metadata.user_id: %q", idA)
}
@@ -2071,8 +2072,8 @@ func TestConvertOpenAIResponsesRequestToClaude_DifferentUserContentWithSameSyste
}`)
outA, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", rawA, false)
outB, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", rawB, false)
- idA := gjson.GetBytes(outA, "metadata.user_id").String()
- idB := gjson.GetBytes(outB, "metadata.user_id").String()
+ idA := ojson.GetBytesNoCopy(outA, "metadata.user_id").String()
+ idB := ojson.GetBytesNoCopy(outB, "metadata.user_id").String()
if idA == "" || idB == "" || idA == "unknown" || idB == "unknown" {
t.Fatalf("expected valid derived user_id, got idA=%q idB=%q", idA, idB)
}
@@ -2090,7 +2091,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableStripsTrailingAssistantPrefi
]
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-fable-5", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("expected 1 message after stripping trailing assistant prefill, got %d: %s", len(messages), string(out))
}
@@ -2110,7 +2111,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FableOnlyAssistantMessageYieldsFa
]
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-fable-5", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("expected 1 fallback user message, got %d: %s", len(messages), string(out))
}
@@ -2152,7 +2153,7 @@ func TestConvertOpenAIResponsesRequestToClaude_UnsupportedPrefillModelsStripTrai
]
}`, model))
out, _ := ConvertOpenAIResponsesRequestToClaude(model, raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("expected 1 message after stripping trailing assistant prefill for model %s, got %d: %s", model, len(messages), string(out))
}
@@ -2197,7 +2198,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SupportedPrefillModelsPreserveAss
]
}`, model))
out, _ := ConvertOpenAIResponsesRequestToClaude(model, raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages preserving assistant prefill for model %s, got %d: %s", model, len(messages), string(out))
}
@@ -2220,7 +2221,7 @@ func TestConvertOpenAIResponsesRequestToClaudeWithCompat_FablePreservesAssistant
]
}`)
out, _ := ConvertOpenAIResponsesRequestToClaudeWithCompat("claude-fable-5", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages in compat mode, got %d: %s", len(messages), string(out))
}
@@ -2241,7 +2242,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StripsTrailingThinkingBlocksFromA
]
}`, rawSignature))
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("expected 1 message (user only) after stripping trailing thinking, got %d: %s", len(messages), string(out))
}
@@ -2263,7 +2264,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StripsTrailingThinkingBlocksFromA
]
}`, rawSignature))
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages (user + assistant text), got %d: %s", len(messages), string(out))
}
@@ -2286,7 +2287,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StripsTrailingThinkingBlocksFromA
]
}`, rawSignature, ClaudeResponsesRedactedThinkingPrefix+"redacted-data"))
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages (user + assistant text), got %d: %s", len(messages), string(out))
}
@@ -2303,7 +2304,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StripsTrailingThinkingBlocksFromA
]
}`, rawSignature))
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-haiku-4-5-20251001", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("expected 1 message (fallback user), got %d: %s", len(messages), string(out))
}
@@ -2321,7 +2322,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StripsTrailingThinkingBlocksFromA
]
}`, rawSignature))
out, _ := ConvertOpenAIResponsesRequestToClaudeWithCompat("claude-haiku-4-5-20251001", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages in compat mode, got %d: %s", len(messages), string(out))
}
@@ -2346,7 +2347,7 @@ func TestConvertOpenAIResponsesRequestToClaudeKeepsAgentMessageText(t *testing.T
if errConvert != nil {
t.Fatal(errConvert)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
messages := root.Get("messages").Array()
if len(messages) != 1 {
t.Fatalf("messages count = %d, want 1; output=%s", len(messages), string(out))
@@ -2375,7 +2376,7 @@ func TestConvertOpenAIResponsesRequestToClaudeKeepsAgentMessageText(t *testing.T
]}
]}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-fable-5-1", mixedIn, false)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
parts := root.Get("messages.0.content").Array()
if len(parts) != 2 {
t.Fatalf("parts count = %d, want 2; output=%s", len(parts), string(out))
@@ -2412,7 +2413,7 @@ func TestConvertOpenAIResponsesRequestToClaude_TextFormatStructuredOutput(t *tes
}
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", input, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytesNoCopy(out, "system")
if !system.Exists() || len(system.Array()) == 0 {
t.Fatalf("system blocks missing. Output: %s", string(out))
}
@@ -2439,7 +2440,7 @@ func TestConvertOpenAIResponsesRequestToClaude_TextFormatStructuredOutput(t *tes
}
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", input, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytesNoCopy(out, "system")
if !system.Exists() || len(system.Array()) == 0 {
t.Fatalf("system blocks missing. Output: %s", string(out))
}
@@ -2473,7 +2474,7 @@ func TestConvertOpenAIResponsesRequestToClaude_TextFormatStructuredOutput(t *tes
}
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", input, false)
- system := gjson.GetBytes(out, "system")
+ system := ojson.GetBytesNoCopy(out, "system")
if !system.Exists() || len(system.Array()) < 2 {
t.Fatalf("expected at least 2 system blocks. Output: %s", string(out))
}
@@ -2549,7 +2550,7 @@ func TestConvertOpenAIResponsesRequestToClaude_FunctionCallOutputAlternateIDsAnd
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages (user, assistant, user), got %d; output=%s", len(messages), string(out))
}
@@ -2595,7 +2596,7 @@ func TestConvertOpenAIResponsesRequestToClaude_MixedMissingAndExplicitParallelOu
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages, got %d; output=%s", len(messages), string(out))
}
@@ -2636,7 +2637,7 @@ func TestConvertOpenAIResponsesRequestToClaude_MixedMissingAndExplicitParallelOu
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
resultMap := make(map[string]string)
for _, m := range messages {
if m.Get("role").String() == "user" {
@@ -2665,7 +2666,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StringInput(t *testing.T) {
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 {
t.Fatalf("expected 1 message in translated Claude request, got %d. Output: %s", len(messages), string(out))
}
@@ -2703,11 +2704,11 @@ func TestConvertOpenAIResponsesRequestToClaude_StringInput(t *testing.T) {
"stream": false
}`)
outWithInstr, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", withInstructions, false)
- system := gjson.GetBytes(outWithInstr, "system").Array()
+ system := ojson.GetBytesNoCopy(outWithInstr, "system").Array()
if len(system) != 1 || system[0].Get("text").String() != "Be concise." {
t.Fatalf("expected system block with instructions, got %s", string(outWithInstr))
}
- messagesWithInstr := gjson.GetBytes(outWithInstr, "messages").Array()
+ messagesWithInstr := ojson.GetBytesNoCopy(outWithInstr, "messages").Array()
if len(messagesWithInstr) != 1 {
t.Fatalf("expected 1 message in translated Claude request with instructions, got %d. Output: %s", len(messagesWithInstr), string(outWithInstr))
}
@@ -2723,7 +2724,7 @@ func TestConvertOpenAIResponsesRequestToClaude_StringInput(t *testing.T) {
"stream": false
}`)
outComplex, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-6", complexInput, false)
- complexMsgs := gjson.GetBytes(outComplex, "messages").Array()
+ complexMsgs := ojson.GetBytesNoCopy(outComplex, "messages").Array()
if len(complexMsgs) != 1 {
t.Fatalf("expected 1 message in translated Claude request with complex input, got %d. Output: %s", len(complexMsgs), string(outComplex))
}
@@ -2748,7 +2749,7 @@ func TestConvertOpenAIResponsesRequestToClaude_LongToolNamesUniqueAndReversible(
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-5-5", reqJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d", len(tools))
}
@@ -2784,7 +2785,7 @@ func TestConvertOpenAIResponsesRequestToClaude_LongToolNamesUniqueAndReversible(
`data: {"type":"message_stop"}`,
}, "\n"))
translatedResp := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-sonnet-5-5", reqJSON, nil, claudeResp, nil)
- outputItem := gjson.GetBytes(translatedResp, "output.0")
+ outputItem := ojson.GetBytesNoCopy(translatedResp, "output.0")
if outputItem.Get("type").String() != "function_call" {
t.Fatalf("output.0.type = %q, want function_call", outputItem.Get("type").String())
}
@@ -2804,7 +2805,7 @@ func TestConvertOpenAIResponsesRequestToClaude_LongToolNamesUniqueAndReversible(
[]byte(`data: {"type":"message_stop"}`),
}
var param any
- var streamAdded, streamDone, streamCompleted gjson.Result
+ var streamAdded, streamDone, streamCompleted ojson.Result
for _, chunk := range streamChunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-sonnet-5-5", reqJSON, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -2845,7 +2846,7 @@ func TestConvertOpenAIResponsesRequestToClaude_SanitizationCollisions(t *testing
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-5-5", reqJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d", len(tools))
}
@@ -2901,16 +2902,16 @@ func TestConvertOpenAIResponsesRequestToClaude_LongToolChoiceAndHistory(t *testi
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-5-5", reqJSON, false)
// Tool definition name for prices
- declaredPricesName := gjson.GetBytes(out, "tools.0.name").String()
+ declaredPricesName := ojson.GetBytesNoCopy(out, "tools.0.name").String()
// tool_choice should have the exact same Claude name as the declared tool
- toolChoiceName := gjson.GetBytes(out, "tool_choice.name").String()
+ toolChoiceName := ojson.GetBytesNoCopy(out, "tool_choice.name").String()
if toolChoiceName != declaredPricesName {
t.Fatalf("tool_choice.name = %q, want %q", toolChoiceName, declaredPricesName)
}
// In messages history, the assistant tool_use block should have the exact same Claude name
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
var foundHistoryToolUseName string
for _, msg := range messages {
if msg.Get("role").String() == "assistant" {
@@ -2932,7 +2933,7 @@ func TestApplyPatchClaudeRequestContractAndHistory(t *testing.T) {
if errConvert13 != nil {
t.Fatal(errConvert13)
}
- result := gjson.ParseBytes(body13)
+ result := util.ParseGJSONBytesNoCopy(body13)
tool := result.Get("tools.0")
description := tool.Get("description").String()
schema := tool.Get("input_schema")
@@ -2974,11 +2975,11 @@ func TestConvertOpenAIResponsesRequestToClaude_IncompleteToolOutputIsError(t *te
if err != nil {
t.Fatalf("ConvertOpenAIResponsesRequestToClaude error: %v", err)
}
- resultJSON := gjson.ParseBytes(result)
- var toolResult gjson.Result
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
+ var toolResult ojson.Result
found := false
- resultJSON.Get("messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ resultJSON.Get("messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "tool_result" && block.Get("tool_use_id").String() == "call_err1" {
toolResult = block
found = true
@@ -3047,7 +3048,7 @@ func TestConvertOpenAIResponsesRequestToClaude_DropsEmptyAndWhitespaceTextBlocks
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
system := root.Get("system").Array()
if len(system) != 1 {
@@ -3113,7 +3114,7 @@ func TestConvertOpenAIResponsesRequestToClaude_DropsEmptyAndWhitespaceTextBlocks
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("system.#").Int(); got != 3 {
t.Fatalf("system count = %d, want 3. system=%s", got, root.Get("system").Raw)
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response.go b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
index e854417684b..19ff756f913 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_response.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response.go
@@ -9,10 +9,10 @@ import (
log "github.com/sirupsen/logrus"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type claudeToResponsesState struct {
@@ -86,7 +86,7 @@ type claudeResponsesWebSearchItem struct {
}
func (item *claudeResponsesWebSearchItem) render() []byte {
- return buildResponsesWebSearchCallItem(item.ToolUseID, claudeWebSearchQuery(item.InputBuf.String()), item.Results)
+ return buildResponsesWebSearchCallItemWithStatus(item.ToolUseID, claudeWebSearchQuery(item.InputBuf.String()), item.Status, item.Results)
}
type claudeResponsesMessageItem struct {
@@ -129,7 +129,7 @@ const ClaudeResponsesRedactedThinkingPrefix = "claude-redacted-thinking:"
// Streaming thinking blocks usually announce an empty signature and fill it in
// through signature_delta, so an empty result here is expected and later
// replaced.
-func claudeReasoningCarrier(contentBlock gjson.Result) string {
+func claudeReasoningCarrier(contentBlock ojson.Result) string {
if contentBlock.Get("type").String() == "redacted_thinking" {
if data := contentBlock.Get("data"); data.Exists() && data.String() != "" {
return ClaudeResponsesRedactedThinkingPrefix + data.String()
@@ -142,7 +142,7 @@ func claudeReasoningCarrier(contentBlock gjson.Result) string {
return ""
}
-func (u *claudeResponsesUsageTokens) Merge(usage gjson.Result) {
+func (u *claudeResponsesUsageTokens) Merge(usage ojson.Result) {
if !usage.Exists() {
return
}
@@ -217,10 +217,10 @@ func claudeResponsesTerminalState(stopReason string) (eventType, status string,
}
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
- if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
+ if len(originalRequestRawJSON) > 0 && ojson.ValidBytes(originalRequestRawJSON) {
return originalRequestRawJSON
}
- if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
+ if len(requestRawJSON) > 0 && ojson.ValidBytes(requestRawJSON) {
return requestRawJSON
}
return nil
@@ -274,9 +274,9 @@ type responsesRequestEcho struct {
hasMetadata bool
}
-func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
+func extractResponsesRequestEcho(root ojson.Result) responsesRequestEcho {
var e responsesRequestEcho
- root.ForEach(func(key, value gjson.Result) bool {
+ root.ForEach(func(key, value ojson.Result) bool {
switch key.String() {
case "instructions":
if !e.hasInstructions {
@@ -364,75 +364,74 @@ func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
return e
}
-func (e responsesRequestEcho) apply(dst []byte, prefix string) []byte {
+func (e responsesRequestEcho) applyDoc(doc *ojson.Doc, prefix string) {
if e.hasInstructions {
- dst, _ = sjson.SetBytes(dst, prefix+"instructions", e.instructions)
+ _ = doc.Set(prefix+"instructions", e.instructions)
}
if e.hasMaxOutputTokens {
- dst, _ = sjson.SetBytes(dst, prefix+"max_output_tokens", e.maxOutputTokens)
+ _ = doc.Set(prefix+"max_output_tokens", e.maxOutputTokens)
}
if e.hasMaxToolCalls {
- dst, _ = sjson.SetBytes(dst, prefix+"max_tool_calls", e.maxToolCalls)
+ _ = doc.Set(prefix+"max_tool_calls", e.maxToolCalls)
}
if e.hasModel {
- dst, _ = sjson.SetBytes(dst, prefix+"model", e.model)
+ _ = doc.Set(prefix+"model", e.model)
}
if e.hasParallelToolCalls {
- dst, _ = sjson.SetBytes(dst, prefix+"parallel_tool_calls", e.parallelToolCalls)
+ _ = doc.Set(prefix+"parallel_tool_calls", e.parallelToolCalls)
}
if e.hasPreviousResponseID {
- dst, _ = sjson.SetBytes(dst, prefix+"previous_response_id", e.previousResponseID)
+ _ = doc.Set(prefix+"previous_response_id", e.previousResponseID)
}
if e.hasPromptCacheKey {
- dst, _ = sjson.SetBytes(dst, prefix+"prompt_cache_key", e.promptCacheKey)
+ _ = doc.Set(prefix+"prompt_cache_key", e.promptCacheKey)
}
if e.hasReasoning {
- dst, _ = sjson.SetBytes(dst, prefix+"reasoning", e.reasoning)
+ _ = doc.Set(prefix+"reasoning", e.reasoning)
}
if e.hasSafetyIdentifier {
- dst, _ = sjson.SetBytes(dst, prefix+"safety_identifier", e.safetyIdentifier)
+ _ = doc.Set(prefix+"safety_identifier", e.safetyIdentifier)
}
if e.hasServiceTier {
- dst, _ = sjson.SetBytes(dst, prefix+"service_tier", e.serviceTier)
+ _ = doc.Set(prefix+"service_tier", e.serviceTier)
}
if e.hasStore {
- dst, _ = sjson.SetBytes(dst, prefix+"store", e.store)
+ _ = doc.Set(prefix+"store", e.store)
}
if e.hasTemperature {
- dst, _ = sjson.SetBytes(dst, prefix+"temperature", e.temperature)
+ _ = doc.Set(prefix+"temperature", e.temperature)
}
if e.hasText {
- dst, _ = sjson.SetBytes(dst, prefix+"text", e.text)
+ _ = doc.Set(prefix+"text", e.text)
}
if e.hasToolChoice {
- dst, _ = sjson.SetBytes(dst, prefix+"tool_choice", e.toolChoice)
+ _ = doc.Set(prefix+"tool_choice", e.toolChoice)
}
if e.hasTools {
- dst, _ = sjson.SetBytes(dst, prefix+"tools", e.tools)
+ _ = doc.Set(prefix+"tools", e.tools)
}
if e.hasTopLogprobs {
- dst, _ = sjson.SetBytes(dst, prefix+"top_logprobs", e.topLogprobs)
+ _ = doc.Set(prefix+"top_logprobs", e.topLogprobs)
}
if e.hasTopP {
- dst, _ = sjson.SetBytes(dst, prefix+"top_p", e.topP)
+ _ = doc.Set(prefix+"top_p", e.topP)
}
if e.hasTruncation {
- dst, _ = sjson.SetBytes(dst, prefix+"truncation", e.truncation)
+ _ = doc.Set(prefix+"truncation", e.truncation)
}
if e.hasUser {
- dst, _ = sjson.SetBytes(dst, prefix+"user", e.user)
+ _ = doc.Set(prefix+"user", e.user)
}
if e.hasMetadata {
- dst, _ = sjson.SetBytes(dst, prefix+"metadata", e.metadata)
+ _ = doc.Set(prefix+"metadata", e.metadata)
}
- return dst
}
-// applyFunctionCallNamespaceFields resolves a function call's (name,
+// applyFunctionCallNamespaceFieldsDoc resolves a function call's (name,
// namespace) from the winners and tool names built once for this stream,
// matching splitResponsesQualifiedFunctionCallFromRequest without
// re-parsing the request on every tool call.
-func (st *claudeToResponsesState) applyFunctionCallNamespaceFields(item []byte, qualifiedName string, itemPath string) []byte {
+func (st *claudeToResponsesState) applyFunctionCallNamespaceFieldsDoc(doc *ojson.Doc, qualifiedName string, itemPath string) {
qualifiedName = strings.TrimSpace(qualifiedName)
name, namespace := qualifiedName, ""
if qualifiedName != "" {
@@ -442,7 +441,7 @@ func (st *claudeToResponsesState) applyFunctionCallNamespaceFields(item []byte,
name, namespace = descriptor.childName, descriptor.namespace
}
}
- return translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, itemPath)
+ translatorcommon.SetResponsesToolCallIdentityDoc(doc, name, namespace, itemPath)
}
func emitEvent(event string, payload []byte) []byte {
@@ -503,13 +502,11 @@ func (st *claudeToResponsesState) finalizeWebSearchWithStatus(item *claudeRespon
}
item.Emitted = true
item.Status = status
- done := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
- done, _ = sjson.SetBytes(done, "output_index", item.OutputIndex)
- rendered := item.render()
- rendered, _ = sjson.SetBytes(rendered, "status", status)
- done, _ = sjson.SetRawBytes(done, "item", rendered)
- return [][]byte{emitEvent("response.output_item.done", done)}
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = doneDoc.Set("sequence_number", nextSeq())
+ _ = doneDoc.Set("output_index", item.OutputIndex)
+ _ = doneDoc.SetRawBytes("item", item.render())
+ return [][]byte{emitEvent("response.output_item.done", doneDoc.Bytes())}
}
// isApplyPatch consults the original request's winning declaration, not the
@@ -545,7 +542,7 @@ func finishClaudeApplyPatchArguments(call *translatorcommon.ApplyPatchCallState,
if snapshot == "" {
return call.FinishArguments(arguments)
}
- if gjson.Valid(arguments) {
+ if ojson.Valid(arguments) {
if _, _, errFinishArguments := call.FinishArguments(arguments); errFinishArguments != nil {
return "", "", errFinishArguments
}
@@ -596,10 +593,10 @@ func (st *claudeToResponsesState) emitFuncItem(idx int, force bool, nextSeq func
st.FuncItemAdded[idx] = true
return [][]byte{toolSearchCallEvent("response.output_item.added", nextSeq(), outputIndex, buildResponsesToolSearchCallItem(callID, "in_progress", ""))}
}
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
itemID := fmt.Sprintf("fc_%s", callID)
if st.FuncCustom[idx] {
- item = []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
+ itemDoc = ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
itemID = fmt.Sprintf("ctc_%s", callID)
if st.isApplyPatch(name) {
localName := d.childName
@@ -611,13 +608,13 @@ func (st *claudeToResponsesState) emitFuncItem(idx int, force bool, nextSeq func
}
}
}
- item, _ = sjson.SetBytes(item, "item.id", itemID)
- item, _ = sjson.SetBytes(item, "item.call_id", callID)
- item = st.applyFunctionCallNamespaceFields(item, name, "item")
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", outputIndex)
+ _ = itemDoc.Set("item.id", itemID)
+ _ = itemDoc.Set("item.call_id", callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoc, name, "item")
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", outputIndex)
st.FuncItemAdded[idx] = true
- return [][]byte{emitEvent("response.output_item.added", item)}
+ return [][]byte{emitEvent("response.output_item.added", itemDoc.Bytes())}
}
func (st *claudeToResponsesState) emitPendingFuncArgs(idx int, nextSeq func() int) [][]byte {
@@ -642,12 +639,12 @@ func (st *claudeToResponsesState) emitPendingFuncArgs(idx int, nextSeq func() in
}
return nil
}
- msg := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- msg, _ = sjson.SetBytes(msg, "output_index", st.functionOutputIndex(idx))
- msg, _ = translatorcommon.SetStringWithoutHTMLEscape(msg, "delta", fragment)
- return [][]byte{emitEvent("response.function_call_arguments.delta", msg)}
+ msgDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = msgDoc.Set("output_index", st.functionOutputIndex(idx))
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(msgDoc, "delta", fragment)
+ return [][]byte{emitEvent("response.function_call_arguments.delta", msgDoc.Bytes())}
}
func (st *claudeToResponsesState) finalizeFuncItem(idx int, status string, nextSeq func() int) [][]byte {
@@ -715,47 +712,47 @@ func (st *claudeToResponsesState) finalizeFuncItem(idx int, status string, nextS
if patchCall != nil {
out = append(out, emitEvent("response.custom_tool_call_input.done", translatorcommon.ApplyPatchInputDone(patchCall, input, nextSeq())))
} else {
- inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
- inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
- inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID))
- inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex)
- inputDone, _ = sjson.SetBytes(inputDone, "input", input)
- out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone))
+ inputDoneDoc := ojson.NewDocString(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
+ _ = inputDoneDoc.Set("sequence_number", nextSeq())
+ _ = inputDoneDoc.Set("item_id", fmt.Sprintf("ctc_%s", callID))
+ _ = inputDoneDoc.Set("output_index", outputIndex)
+ _ = inputDoneDoc.Set("input", input)
+ out = append(out, emitEvent("response.custom_tool_call_input.done", inputDoneDoc.Bytes()))
}
}
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", callID))
- itemDone, _ = sjson.SetBytes(itemDone, "item.status", status)
- itemDone, _ = sjson.SetBytes(itemDone, "item.input", input)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
- itemDone = st.applyFunctionCallNamespaceFields(itemDone, name, "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", outputIndex)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoneDoc.Set("item.status", status)
+ _ = itemDoneDoc.Set("item.input", input)
+ _ = itemDoneDoc.Set("item.call_id", callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoneDoc, name, "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
} else {
if !st.FuncArgsDone[idx] {
if st.FuncArgsDone == nil {
st.FuncArgsDone = make(map[int]bool)
}
st.FuncArgsDone[idx] = true
- fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
- fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
- fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))
- fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
- fcDone, _ = translatorcommon.SetStringWithoutHTMLEscape(fcDone, "arguments", args)
- out = append(out, emitEvent("response.function_call_arguments.done", fcDone))
+ fcDoneDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
+ _ = fcDoneDoc.Set("sequence_number", nextSeq())
+ _ = fcDoneDoc.Set("item_id", fmt.Sprintf("fc_%s", callID))
+ _ = fcDoneDoc.Set("output_index", outputIndex)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoneDoc, "arguments", args)
+ out = append(out, emitEvent("response.function_call_arguments.done", fcDoneDoc.Bytes()))
}
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
- itemDone, _ = sjson.SetBytes(itemDone, "item.status", status)
- itemDone, _ = translatorcommon.SetStringWithoutHTMLEscape(itemDone, "item.arguments", args)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
- itemDone = st.applyFunctionCallNamespaceFields(itemDone, name, "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", outputIndex)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoneDoc.Set("item.status", status)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoneDoc, "item.arguments", args)
+ _ = itemDoneDoc.Set("item.call_id", callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoneDoc, name, "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
}
st.InFuncBlock = false
return out
@@ -768,18 +765,18 @@ func (st *claudeToResponsesState) finalizeReasoningDeltas(nextSeq func() int) []
st.ReasoningDeltasDone = true
full := st.ReasoningBuf.String()
var out [][]byte
- textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
- textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq())
- textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningItemID)
- textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex)
- textDone, _ = sjson.SetBytes(textDone, "text", full)
- out = append(out, emitEvent("response.reasoning_summary_text.done", textDone))
- partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
- partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningItemID)
- partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
- partDone, _ = sjson.SetBytes(partDone, "part.text", full)
- out = append(out, emitEvent("response.reasoning_summary_part.done", partDone))
+ textDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
+ _ = textDoneDoc.Set("sequence_number", nextSeq())
+ _ = textDoneDoc.Set("item_id", st.ReasoningItemID)
+ _ = textDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = textDoneDoc.Set("text", full)
+ out = append(out, emitEvent("response.reasoning_summary_text.done", textDoneDoc.Bytes()))
+ partDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextSeq())
+ _ = partDoneDoc.Set("item_id", st.ReasoningItemID)
+ _ = partDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = partDoneDoc.Set("part.text", full)
+ out = append(out, emitEvent("response.reasoning_summary_part.done", partDoneDoc.Bytes()))
return out
}
@@ -791,16 +788,16 @@ func (st *claudeToResponsesState) finalizeReasoningItem(status string, nextSeq f
out = append(out, st.finalizeReasoningDeltas(nextSeq)...)
full := st.ReasoningBuf.String()
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID)
- itemDone, _ = sjson.SetBytes(itemDone, "item.status", status)
- itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningSignature)
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = itemDoneDoc.Set("item.id", st.ReasoningItemID)
+ _ = itemDoneDoc.Set("item.status", status)
+ _ = itemDoneDoc.Set("item.encrypted_content", st.ReasoningSignature)
summary := []byte(`{"type":"summary_text","text":""}`)
- summary, _ = sjson.SetBytes(summary, "text", full)
- itemDone = translatorcommon.SetRawArrayItems(itemDone, "item.summary", [][]byte{summary})
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ summary, _ = ojson.SetBytes(summary, "text", full)
+ translatorcommon.SetRawArrayItemsDoc(itemDoneDoc, "item.summary", [][]byte{summary})
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.ReasoningItems = append(st.ReasoningItems, claudeResponsesReasoningItem{
ID: st.ReasoningItemID,
OutputIndex: st.ReasoningIndex,
@@ -824,33 +821,33 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [
outputIndex := st.messageOutputIndex()
status := claudeResponsesOutputStatus(st.StopReason)
var out [][]byte
- done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
- done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID)
- done, _ = sjson.SetBytes(done, "output_index", outputIndex)
- done, _ = sjson.SetBytes(done, "text", fullText)
- out = append(out, emitEvent("response.output_text.done", done))
-
- partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
- partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID)
- partDone, _ = sjson.SetBytes(partDone, "output_index", outputIndex)
- partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
+ _ = doneDoc.Set("sequence_number", nextSeq())
+ _ = doneDoc.Set("item_id", st.CurrentMsgID)
+ _ = doneDoc.Set("output_index", outputIndex)
+ _ = doneDoc.Set("text", fullText)
+ out = append(out, emitEvent("response.output_text.done", doneDoc.Bytes()))
+
+ partDoneDoc := ojson.NewDocString(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextSeq())
+ _ = partDoneDoc.Set("item_id", st.CurrentMsgID)
+ _ = partDoneDoc.Set("output_index", outputIndex)
+ _ = partDoneDoc.Set("part.text", fullText)
if len(st.MessageAnnotations) > 0 {
- partDone, _ = sjson.SetBytes(partDone, "part.annotations", st.MessageAnnotations)
+ _ = partDoneDoc.Set("part.annotations", st.MessageAnnotations)
}
- out = append(out, emitEvent("response.content_part.done", partDone))
+ out = append(out, emitEvent("response.content_part.done", partDoneDoc.Bytes()))
- final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
- final, _ = sjson.SetBytes(final, "sequence_number", nextSeq())
- final, _ = sjson.SetBytes(final, "output_index", outputIndex)
- final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID)
- final, _ = sjson.SetBytes(final, "item.status", status)
- final, _ = sjson.SetBytes(final, "item.content.0.text", fullText)
+ finalDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
+ _ = finalDoc.Set("sequence_number", nextSeq())
+ _ = finalDoc.Set("output_index", outputIndex)
+ _ = finalDoc.Set("item.id", st.CurrentMsgID)
+ _ = finalDoc.Set("item.status", status)
+ _ = finalDoc.Set("item.content.0.text", fullText)
if len(st.MessageAnnotations) > 0 {
- final, _ = sjson.SetBytes(final, "item.content.0.annotations", st.MessageAnnotations)
+ _ = finalDoc.Set("item.content.0.annotations", st.MessageAnnotations)
}
- out = append(out, emitEvent("response.output_item.done", final))
+ out = append(out, emitEvent("response.output_item.done", finalDoc.Bytes()))
st.MessageItems = append(st.MessageItems, claudeResponsesMessageItem{
ID: st.CurrentMsgID,
@@ -871,7 +868,7 @@ func (st *claudeToResponsesState) finalizeAssistantMessage(nextSeq func() int) [
}
func newClaudeToResponsesState(requestJSON []byte, toolSearch bool) *claudeToResponsesState {
- root := gjson.ParseBytes(requestJSON)
+ root := util.ParseGJSONBytesNoCopy(bytes.Clone(requestJSON))
winners := responsesToolWinners(root, toolSearch)
return &claudeToResponsesState{
ToolSearch: toolSearch,
@@ -912,7 +909,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
return [][]byte{}
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
ev := root.Get("type").String()
var out [][]byte
@@ -921,7 +918,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
switch ev {
case "message_start":
if msg := root.Get("message"); msg.Exists() {
- st.ResponseID = msg.Get("id").String()
+ st.ResponseID = strings.Clone(msg.Get("id").String())
st.CreatedAt = time.Now().Unix()
// Reset per-message aggregation state
st.TextBuf.Reset()
@@ -962,27 +959,27 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.Usage = claudeResponsesUsageTokens{}
st.Usage.Merge(msg.Get("usage"))
// response.created
- created := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
- created, _ = sjson.SetBytes(created, "sequence_number", nextSeq())
- created, _ = sjson.SetBytes(created, "response.id", st.ResponseID)
- created, _ = sjson.SetBytes(created, "response.created_at", st.CreatedAt)
+ createdDoc := ojson.NewDocString(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
+ _ = createdDoc.Set("sequence_number", nextSeq())
+ _ = createdDoc.Set("response.id", st.ResponseID)
+ _ = createdDoc.Set("response.created_at", st.CreatedAt)
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName != "" {
- created, _ = sjson.SetBytes(created, "response.model", requestModelName)
+ _ = createdDoc.Set("response.model", requestModelName)
}
- out = append(out, emitEvent("response.created", created))
+ out = append(out, emitEvent("response.created", createdDoc.Bytes()))
// response.in_progress
- inprog := []byte(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
- inprog, _ = sjson.SetBytes(inprog, "sequence_number", nextSeq())
- inprog, _ = sjson.SetBytes(inprog, "response.id", st.ResponseID)
- inprog, _ = sjson.SetBytes(inprog, "response.created_at", st.CreatedAt)
+ inprogDoc := ojson.NewDocString(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
+ _ = inprogDoc.Set("sequence_number", nextSeq())
+ _ = inprogDoc.Set("response.id", st.ResponseID)
+ _ = inprogDoc.Set("response.created_at", st.CreatedAt)
if requestModelName != "" {
- inprog, _ = sjson.SetBytes(inprog, "response.model", requestModelName)
+ _ = inprogDoc.Set("response.model", requestModelName)
}
- out = append(out, emitEvent("response.in_progress", inprog))
+ out = append(out, emitEvent("response.in_progress", inprogDoc.Bytes()))
}
case "content_block_start":
cb := root.Get("content_block")
@@ -1028,24 +1025,24 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, len(st.MessageItems))
}
if !st.MessageOpen {
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", outputIndex)
- item, _ = sjson.SetBytes(item, "item.id", st.CurrentMsgID)
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", outputIndex)
+ _ = itemDoc.Set("item.id", st.CurrentMsgID)
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
st.MessageOpen = true
}
if !st.ContentPartOpen {
- part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
- part, _ = sjson.SetBytes(part, "item_id", st.CurrentMsgID)
- part, _ = sjson.SetBytes(part, "output_index", outputIndex)
- out = append(out, emitEvent("response.content_part.added", part))
+ partDoc := ojson.NewDocString(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partDoc.Set("sequence_number", nextSeq())
+ _ = partDoc.Set("item_id", st.CurrentMsgID)
+ _ = partDoc.Set("output_index", outputIndex)
+ out = append(out, emitEvent("response.content_part.added", partDoc.Bytes()))
st.ContentPartOpen = true
}
} else if typ == "tool_use" {
st.InFuncBlock = true
- callID, name := cb.Get("id").String(), cb.Get("name").String()
+ callID, name := strings.Clone(cb.Get("id").String()), strings.Clone(cb.Get("name").String())
oldID, oldName := st.FuncCallIDs[idx], st.FuncNames[idx]
// Pending identity evidence must survive later matching updates.
if callID != "" && oldID != "" && callID != oldID {
@@ -1088,7 +1085,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
}
}
}
- st.FuncInputSnapshot[idx] = input.Raw
+ st.FuncInputSnapshot[idx] = strings.Clone(input.Raw)
}
if st.isApplyPatch(st.FuncNames[idx]) {
if errSnapshot := st.FuncInputSnapshotErrors[idx]; errSnapshot != nil {
@@ -1103,18 +1100,18 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
// this translator, so anything else means a new upstream tool.
log.Debugf("claude->responses: unmapped server_tool_use %q at block %d", name, idx)
} else {
- item := st.startWebSearch(idx, cb.Get("id").String())
- added := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextSeq())
- added, _ = sjson.SetBytes(added, "output_index", item.OutputIndex)
- added, _ = sjson.SetBytes(added, "item.id", responsesWebSearchCallID(item.ToolUseID))
- out = append(out, emitEvent("response.output_item.added", added))
+ item := st.startWebSearch(idx, strings.Clone(cb.Get("id").String()))
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`)
+ _ = addedDoc.Set("sequence_number", nextSeq())
+ _ = addedDoc.Set("output_index", item.OutputIndex)
+ _ = addedDoc.Set("item.id", responsesWebSearchCallID(item.ToolUseID))
+ out = append(out, emitEvent("response.output_item.added", addedDoc.Bytes()))
}
} else if typ == "web_search_tool_result" {
// A result block carries its full content up front and has no deltas.
// Results are stored here and the item is closed when the next block
// starts or at message_stop so the final stop_reason is respected.
- toolUseID := cb.Get("tool_use_id").String()
+ toolUseID := strings.Clone(cb.Get("tool_use_id").String())
if item := st.WebSearchByToolID[toolUseID]; item != nil {
item.Results = claudeWebSearchResultsToResponses(cb.Get("content"))
} else if toolUseID != "" {
@@ -1123,11 +1120,11 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
// under the same id so the client keeps the result in its history.
item := st.startWebSearch(idx, toolUseID)
item.Results = claudeWebSearchResultsToResponses(cb.Get("content"))
- added := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextSeq())
- added, _ = sjson.SetBytes(added, "output_index", item.OutputIndex)
- added, _ = sjson.SetBytes(added, "item.id", responsesWebSearchCallID(item.ToolUseID))
- out = append(out, emitEvent("response.output_item.added", added))
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`)
+ _ = addedDoc.Set("sequence_number", nextSeq())
+ _ = addedDoc.Set("output_index", item.OutputIndex)
+ _ = addedDoc.Set("item.id", responsesWebSearchCallID(item.ToolUseID))
+ out = append(out, emitEvent("response.output_item.added", addedDoc.Bytes()))
} else {
log.Debugf("claude->responses: web_search_tool_result without matching server_tool_use at block %d", idx)
}
@@ -1137,20 +1134,20 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
st.ReasoningDeltasDone = false
st.ReasoningIndex = st.allocateOutputIndex()
st.ReasoningBuf.Reset()
- st.ReasoningSignature = claudeReasoningCarrier(cb)
+ st.ReasoningSignature = strings.Clone(claudeReasoningCarrier(cb))
st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
- item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID)
- item, _ = sjson.SetBytes(item, "item.encrypted_content", st.ReasoningSignature)
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", st.ReasoningIndex)
+ _ = itemDoc.Set("item.id", st.ReasoningItemID)
+ _ = itemDoc.Set("item.encrypted_content", st.ReasoningSignature)
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
// add a summary part placeholder
- part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
- part, _ = sjson.SetBytes(part, "item_id", st.ReasoningItemID)
- part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex)
- out = append(out, emitEvent("response.reasoning_summary_part.added", part))
+ partDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoc.Set("sequence_number", nextSeq())
+ _ = partDoc.Set("item_id", st.ReasoningItemID)
+ _ = partDoc.Set("output_index", st.ReasoningIndex)
+ out = append(out, emitEvent("response.reasoning_summary_part.added", partDoc.Bytes()))
}
case "content_block_delta":
d := root.Get("delta")
@@ -1160,12 +1157,12 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
dt := d.Get("type").String()
if dt == "text_delta" {
if t := d.Get("text"); t.Exists() {
- msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.CurrentMsgID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.messageOutputIndex())
- msg, _ = sjson.SetBytes(msg, "delta", t.String())
- out = append(out, emitEvent("response.output_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.CurrentMsgID)
+ _ = msgDoc.Set("output_index", st.messageOutputIndex())
+ _ = msgDoc.Set("delta", t.String())
+ out = append(out, emitEvent("response.output_text.delta", msgDoc.Bytes()))
// aggregate text for response.output
st.TextBuf.WriteString(t.String())
st.CurrentTextBuf.WriteString(t.String())
@@ -1189,24 +1186,24 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
if st.ReasoningActive {
if t := d.Get("thinking"); t.Exists() {
st.ReasoningBuf.WriteString(t.String())
- msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningItemID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
- msg, _ = sjson.SetBytes(msg, "delta", t.String())
- out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.ReasoningItemID)
+ _ = msgDoc.Set("output_index", st.ReasoningIndex)
+ _ = msgDoc.Set("delta", t.String())
+ out = append(out, emitEvent("response.reasoning_summary_text.delta", msgDoc.Bytes()))
}
}
} else if dt == "signature_delta" {
if st.ReasoningActive {
if signature := d.Get("signature"); signature.Exists() && signature.String() != "" {
- st.ReasoningSignature = signature.String()
+ st.ReasoningSignature = strings.Clone(signature.String())
}
}
return [][]byte{}
} else if dt == "citations_delta" {
if citation := d.Get("citation"); citation.Exists() {
- st.appendMessageAnnotation(citation.Value())
+ st.appendMessageAnnotation(ojson.Parse(strings.Clone(citation.Raw)).Value())
}
return [][]byte{}
}
@@ -1223,7 +1220,7 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
case "message_delta":
st.Usage.Merge(root.Get("usage"))
if stopReason := root.Get("delta.stop_reason"); stopReason.Exists() {
- st.StopReason = stopReason.String()
+ st.StopReason = strings.Clone(stopReason.String())
}
return [][]byte{}
case "message_stop":
@@ -1247,49 +1244,51 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
}
eventType, responseStatus, incompleteDetails := claudeResponsesTerminalState(st.StopReason)
- completed := []byte(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`)
- completed, _ = sjson.SetBytes(completed, "type", eventType)
- completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq())
- completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID)
- completed, _ = sjson.SetBytes(completed, "response.created_at", st.CreatedAt)
- completed, _ = sjson.SetBytes(completed, "response.status", responseStatus)
+ completedDoc := ojson.NewDocString(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`)
+ _ = completedDoc.Set("type", eventType)
+ _ = completedDoc.Set("sequence_number", nextSeq())
+ _ = completedDoc.Set("response.id", st.ResponseID)
+ _ = completedDoc.Set("response.created_at", st.CreatedAt)
+ _ = completedDoc.Set("response.status", responseStatus)
if len(incompleteDetails) > 0 {
- completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails)
+ _ = completedDoc.SetRawBytes("response.incomplete_details", incompleteDetails)
}
if claudeStopReasonPauseTurn(st.StopReason) {
- completed, _ = sjson.SetBytes(completed, "response.end_turn", false)
+ _ = completedDoc.Set("response.end_turn", false)
}
// Inject original request fields into response as per docs/response.completed.json
-
- completed = st.RequestEcho.apply(completed, "response.")
+ st.RequestEcho.applyDoc(completedDoc, "response.")
// Build response.output from aggregated state
- outputsWrapper := []byte(`{"arr":[]}`)
+ outputsWrapperDoc := ojson.NewDocString(`{"arr":[]}`)
+ hasOutputs := false
// reasoning items
for _, reasoning := range st.ReasoningItems {
status := reasoning.Status
if status == "" {
status = "completed"
}
- item := []byte(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`)
- item, _ = sjson.SetBytes(item, "id", reasoning.ID)
- item, _ = sjson.SetBytes(item, "status", status)
- item, _ = sjson.SetBytes(item, "encrypted_content", reasoning.Signature)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`)
+ _ = itemDoc.Set("id", reasoning.ID)
+ _ = itemDoc.Set("status", status)
+ _ = itemDoc.Set("encrypted_content", reasoning.Signature)
summary := []byte(`{"type":"summary_text","text":""}`)
- summary, _ = sjson.SetBytes(summary, "text", reasoning.Text)
- item = translatorcommon.SetRawArrayItems(item, "summary", [][]byte{summary})
- outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", reasoning.OutputIndex), item)
+ summary, _ = ojson.SetBytes(summary, "text", reasoning.Text)
+ translatorcommon.SetRawArrayItemsDoc(itemDoc, "summary", [][]byte{summary})
+ _ = outputsWrapperDoc.SetRawBytes(fmt.Sprintf("arr.%d", reasoning.OutputIndex), itemDoc.Bytes())
+ hasOutputs = true
}
// assistant message items
for _, message := range st.MessageItems {
- item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
- item, _ = sjson.SetBytes(item, "id", message.ID)
- item, _ = sjson.SetBytes(item, "status", message.Status)
- item, _ = sjson.SetBytes(item, "content.0.text", message.Text)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
+ _ = itemDoc.Set("id", message.ID)
+ _ = itemDoc.Set("status", message.Status)
+ _ = itemDoc.Set("content.0.text", message.Text)
if len(message.Annotations) > 0 {
- item, _ = sjson.SetBytes(item, "content.0.annotations", message.Annotations)
+ _ = itemDoc.Set("content.0.annotations", message.Annotations)
}
- outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", message.OutputIndex), item)
+ _ = outputsWrapperDoc.SetRawBytes(fmt.Sprintf("arr.%d", message.OutputIndex), itemDoc.Bytes())
+ hasOutputs = true
}
// web_search_call items
for _, item := range st.WebSearchItems {
@@ -1297,9 +1296,9 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
if status == "" {
status = "completed"
}
- rendered := item.render()
- rendered, _ = sjson.SetBytes(rendered, "status", status)
- outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", item.OutputIndex), rendered)
+ item.Status = status
+ _ = outputsWrapperDoc.SetRawBytes(fmt.Sprintf("arr.%d", item.OutputIndex), item.render())
+ hasOutputs = true
}
// function_call items (in ascending index order for determinism)
if len(st.FuncArgsBuf) > 0 {
@@ -1334,34 +1333,38 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
callID = st.CurrentFCID
}
if st.isToolSearch(name) {
- outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), buildResponsesToolSearchCallItem(callID, status, args))
+ _ = outputsWrapperDoc.SetRawBytes(fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), buildResponsesToolSearchCallItem(callID, status, args))
} else if st.FuncCustom[idx] {
- item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
- item, _ = sjson.SetBytes(item, "status", status)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoc.Set("status", status)
input := ""
if patchCall := st.ApplyPatchCalls[fmt.Sprint(idx)]; patchCall != nil {
input = patchCall.Decoder.Input()
} else {
input = unwrapCustomToolInput(args)
}
- item, _ = sjson.SetBytes(item, "input", input)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = st.applyFunctionCallNamespaceFields(item, name, "")
- outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item)
+ _ = itemDoc.Set("input", input)
+ _ = itemDoc.Set("call_id", callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoc, name, "")
+ _ = outputsWrapperDoc.SetRawBytes(fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), itemDoc.Bytes())
} else {
- item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
- item, _ = sjson.SetBytes(item, "status", status)
- item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = st.applyFunctionCallNamespaceFields(item, name, "")
- outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoc.Set("status", status)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", args)
+ _ = itemDoc.Set("call_id", callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoc, name, "")
+ _ = outputsWrapperDoc.SetRawBytes(fmt.Sprintf("arr.%d", st.FuncOutputIndices[idx]), itemDoc.Bytes())
}
+ hasOutputs = true
}
}
- if gjson.GetBytes(outputsWrapper, "arr.#").Int() > 0 {
- completed, _ = sjson.SetRawBytes(completed, "response.output", []byte(gjson.GetBytes(outputsWrapper, "arr").Raw))
+ if hasOutputs {
+ outputsWrapper := outputsWrapperDoc.Bytes()
+ if ojson.GetBytesNoCopy(outputsWrapper, "arr.#").Int() > 0 {
+ _ = completedDoc.SetRawBytes("response.output", []byte(ojson.GetBytesNoCopy(outputsWrapper, "arr").Raw))
+ }
}
reasoningLength := 0
@@ -1372,16 +1375,16 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
usagePresent := st.Usage.HasUsage || reasoningTokens > 0
if usagePresent {
inputTokens, outputTokens, totalTokens, cachedTokens := st.Usage.OpenAIResponsesUsage()
- completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens", inputTokens)
- completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens_details.cached_tokens", cachedTokens)
- completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens", outputTokens)
- completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens_details.reasoning_tokens", reasoningTokens)
+ _ = completedDoc.Set("response.usage.input_tokens", inputTokens)
+ _ = completedDoc.Set("response.usage.input_tokens_details.cached_tokens", cachedTokens)
+ _ = completedDoc.Set("response.usage.output_tokens", outputTokens)
+ _ = completedDoc.Set("response.usage.output_tokens_details.reasoning_tokens", reasoningTokens)
if totalTokens > 0 || st.Usage.HasUsage {
- completed, _ = sjson.SetBytes(completed, "response.usage.total_tokens", totalTokens)
+ _ = completedDoc.Set("response.usage.total_tokens", totalTokens)
}
}
st.CompletedEmitted = true
- out = append(out, emitEvent(eventType, completed))
+ out = append(out, emitEvent(eventType, completedDoc.Bytes()))
}
return noSSEOutput(out)
@@ -1421,7 +1424,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
}
// Base OpenAI Responses (non-stream) object
- out := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{},"total_tokens":0}}`)
+ outDoc := ojson.NewDocString(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{},"total_tokens":0}}`)
// Aggregation state
var (
@@ -1470,7 +1473,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
// Walk through SSE chunks to fill state
for _, ch := range chunks {
- root := gjson.ParseBytes(ch)
+ root := util.ParseGJSONBytesNoCopy(ch)
ev := root.Get("type").String()
// Match the streaming path: no input after the protocol terminator.
if ev == "message_stop" {
@@ -1528,7 +1531,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
if st.isApplyPatch(item.name) || st.isApplyPatch(name) {
if st.FuncIdentityConflicts[idx] || (name != "" && item.name != "" && st.ToolNames.identity(name) != st.ToolNames.identity(item.name)) {
st.SetToolInputError(fmt.Errorf("conflicting apply_patch call identity"))
- return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
+ return []byte(ojson.GetBytesNoCopy(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
}
}
if name != "" {
@@ -1547,7 +1550,7 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
if st.isApplyPatch(item.name) {
if errSnapshot := st.FuncInputSnapshotErrors[idx]; errSnapshot != nil {
st.SetToolInputError(errSnapshot)
- return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
+ return []byte(ojson.GetBytesNoCopy(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
}
}
switch item.itemType {
@@ -1630,12 +1633,13 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
}
case "citations_delta":
if citation := d.Get("citation"); citation.Exists() {
+ val := ojson.Parse(citation.Raw).Value()
if item != nil && item.itemType == "message" {
- item.annotations = append(item.annotations, citation.Value())
+ item.annotations = append(item.annotations, val)
} else if activeMessageItem != nil {
- activeMessageItem.annotations = append(activeMessageItem.annotations, citation.Value())
+ activeMessageItem.annotations = append(activeMessageItem.annotations, val)
} else {
- pendingAnnotations = append(pendingAnnotations, citation.Value())
+ pendingAnnotations = append(pendingAnnotations, val)
}
}
}
@@ -1653,23 +1657,23 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
// Populate base fields
_, responseStatus, incompleteDetails := claudeResponsesTerminalState(stopReason)
- out, _ = sjson.SetBytes(out, "id", responseID)
- out, _ = sjson.SetBytes(out, "created_at", createdAt)
- out, _ = sjson.SetBytes(out, "status", responseStatus)
+ _ = outDoc.Set("id", responseID)
+ _ = outDoc.Set("created_at", createdAt)
+ _ = outDoc.Set("status", responseStatus)
if len(incompleteDetails) > 0 {
- out, _ = sjson.SetRawBytes(out, "incomplete_details", incompleteDetails)
+ _ = outDoc.SetRawBytes("incomplete_details", incompleteDetails)
}
if claudeStopReasonPauseTurn(stopReason) {
- out, _ = sjson.SetBytes(out, "end_turn", false)
+ _ = outDoc.Set("end_turn", false)
}
// Inject request echo fields as top-level (similar to streaming variant)
- out = st.RequestEcho.apply(out, "")
+ st.RequestEcho.applyDoc(outDoc, "")
// A native response's model is authoritative; otherwise retain request echo
// behavior, including the existing SSE path.
if nativeModel != "" {
- out, _ = sjson.SetBytes(out, "model", nativeModel)
+ _ = outDoc.Set("model", nativeModel)
}
// Build output array in the order of the original content blocks.
@@ -1682,85 +1686,88 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
var item []byte
switch outputItem.itemType {
case "reasoning":
- item = []byte(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`)
- item, _ = sjson.SetBytes(item, "id", outputItem.id)
- item, _ = sjson.SetBytes(item, "status", itemStatus)
- item, _ = sjson.SetBytes(item, "encrypted_content", outputItem.signature)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`)
+ _ = itemDoc.Set("id", outputItem.id)
+ _ = itemDoc.Set("status", itemStatus)
+ _ = itemDoc.Set("encrypted_content", outputItem.signature)
summary := []byte(`{"type":"summary_text","text":""}`)
- summary, _ = sjson.SetBytes(summary, "text", outputItem.text.String())
- item, _ = sjson.SetRawBytes(item, "summary", translatorcommon.JoinRawArray([][]byte{summary}))
+ summary, _ = ojson.SetBytes(summary, "text", outputItem.text.String())
+ _ = itemDoc.SetRawBytes("summary", translatorcommon.JoinRawArray([][]byte{summary}))
+ item = itemDoc.Bytes()
case "web_search_call":
- item = buildResponsesWebSearchCallItem(outputItem.callID, claudeWebSearchQuery(outputItem.args.String()), outputItem.results)
- item, _ = sjson.SetBytes(item, "status", claudeResponsesWebSearchStatus(stopReason, itemStatus, len(outputItem.results) > 0))
+ item = buildResponsesWebSearchCallItemWithStatus(outputItem.callID, claudeWebSearchQuery(outputItem.args.String()), claudeResponsesWebSearchStatus(stopReason, itemStatus, len(outputItem.results) > 0), outputItem.results)
case "message":
- item = []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
- item, _ = sjson.SetBytes(item, "id", outputItem.id)
- item, _ = sjson.SetBytes(item, "status", itemStatus)
- item, _ = sjson.SetBytes(item, "content.0.text", outputItem.text.String())
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
+ _ = itemDoc.Set("id", outputItem.id)
+ _ = itemDoc.Set("status", itemStatus)
+ _ = itemDoc.Set("content.0.text", outputItem.text.String())
if len(outputItem.annotations) > 0 {
- item, _ = sjson.SetBytes(item, "content.0.annotations", outputItem.annotations)
+ _ = itemDoc.Set("content.0.annotations", outputItem.annotations)
}
+ item = itemDoc.Bytes()
case "tool_search_call":
args := outputItem.args.String()
item = buildResponsesToolSearchCallItem(outputItem.callID, toolSearchCallStatus(itemStatus, args), args)
case "function_call", "custom_tool_call":
if outputItem.itemType == "custom_tool_call" {
- item = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", outputItem.id)
- item, _ = sjson.SetBytes(item, "status", itemStatus)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", outputItem.id)
+ _ = itemDoc.Set("status", itemStatus)
input := ""
if st.isApplyPatch(outputItem.name) {
patchCall := &translatorcommon.ApplyPatchCallState{}
if _, errPushArguments := patchCall.PushArguments(outputItem.args.String()); errPushArguments != nil {
st.SetToolInputError(errPushArguments)
- return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
+ return []byte(ojson.GetBytesNoCopy(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
}
_, fullInput, errFinishArguments := finishClaudeApplyPatchArguments(patchCall, outputItem.args.String(), outputItem.inputSnapshot)
if errFinishArguments != nil {
st.SetToolInputError(errFinishArguments)
- return []byte(gjson.GetBytes(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
+ return []byte(ojson.GetBytesNoCopy(translatorcommon.ApplyPatchFailure(responseID, 0), "response").Raw)
}
input = fullInput
} else {
input = unwrapCustomToolInput(outputItem.args.String())
}
- item, _ = sjson.SetBytes(item, "input", input)
- item, _ = sjson.SetBytes(item, "call_id", outputItem.callID)
- item = st.applyFunctionCallNamespaceFields(item, outputItem.name, "")
+ _ = itemDoc.Set("input", input)
+ _ = itemDoc.Set("call_id", outputItem.callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoc, outputItem.name, "")
+ item = itemDoc.Bytes()
break
}
args := outputItem.args.String()
if args == "" && itemStatus == "completed" {
args = "{}"
}
- item = []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", outputItem.id)
- item, _ = sjson.SetBytes(item, "status", itemStatus)
- item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args)
- item, _ = sjson.SetBytes(item, "call_id", outputItem.callID)
- item = st.applyFunctionCallNamespaceFields(item, outputItem.name, "")
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", outputItem.id)
+ _ = itemDoc.Set("status", itemStatus)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", args)
+ _ = itemDoc.Set("call_id", outputItem.callID)
+ st.applyFunctionCallNamespaceFieldsDoc(itemDoc, outputItem.name, "")
+ item = itemDoc.Bytes()
}
if len(item) > 0 {
outputs = append(outputs, item)
}
}
if len(outputs) > 0 {
- out, _ = sjson.SetRawBytes(out, "output", translatorcommon.JoinRawArray(outputs))
+ _ = outDoc.SetRawBytes("output", translatorcommon.JoinRawArray(outputs))
}
// Usage
inputTokens, outputTokens, totalTokens, cachedTokens := usageTokens.OpenAIResponsesUsage()
if inputTokens != 0 {
- out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
+ _ = outDoc.Set("usage.input_tokens", inputTokens)
}
if cachedTokens != 0 {
- out, _ = sjson.SetBytes(out, "usage.input_tokens_details.cached_tokens", cachedTokens)
+ _ = outDoc.Set("usage.input_tokens_details.cached_tokens", cachedTokens)
}
if outputTokens != 0 {
- out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
+ _ = outDoc.Set("usage.output_tokens", outputTokens)
}
if totalTokens != 0 {
- out, _ = sjson.SetBytes(out, "usage.total_tokens", totalTokens)
+ _ = outDoc.Set("usage.total_tokens", totalTokens)
}
reasoningLength := 0
for _, outputItem := range outputItems {
@@ -1772,11 +1779,11 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(ctx context.Context, _ stri
// Rough estimate similar to chat completions
reasoningTokens := int64(reasoningLength / 4)
if reasoningTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.output_tokens_details.reasoning_tokens", reasoningTokens)
+ _ = outDoc.Set("usage.output_tokens_details.reasoning_tokens", reasoningTokens)
}
}
- return out
+ return outDoc.Bytes()
}
// FinalizeToolInput rejects a patch-enabled stream lacking its source terminator.
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
index 8066707fd8d..948551ab610 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_response_test.go
@@ -7,11 +7,12 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
-func parseClaudeResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
+func parseClaudeResponsesSSEEvent(t *testing.T, chunk []byte) (string, ojson.Result) {
t.Helper()
var event string
@@ -29,7 +30,7 @@ func parseClaudeResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Res
t.Fatalf("SSE chunk has no data line: %s", string(chunk))
}
- return event, gjson.Parse(data)
+ return event, ojson.Parse(data)
}
func TestConvertClaudeResponseToOpenAIResponses_CreatedIncludesOriginalRequestModel(t *testing.T) {
@@ -89,8 +90,8 @@ func TestConvertClaudeResponseToOpenAIResponses_ThinkingIncludesSignature(t *tes
outputs = append(outputs, ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", nil, nil, chunk, ¶m)...)
}
- var reasoningDone gjson.Result
- var completed gjson.Result
+ var reasoningDone ojson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
switch event {
@@ -139,7 +140,7 @@ func TestConvertClaudeResponseToOpenAIResponses_RedactedThinkingBecomesMarkedRea
}
want := ClaudeResponsesRedactedThinkingPrefix + data
- var reasoningDone, completed gjson.Result
+ var reasoningDone, completed ojson.Result
for _, output := range outputs {
event, parsed := parseClaudeResponsesSSEEvent(t, output)
switch event {
@@ -176,7 +177,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_RedactedThinkingBecomes
}, "\n")
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, []byte(raw), nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if got := parsed.Get("output.0.type").String(); got != "reasoning" {
t.Fatalf("output.0.type = %q, want reasoning; body=%s", got, out)
}
@@ -217,7 +218,7 @@ func TestConvertClaudeResponseToOpenAIResponses_AggregatesTextBlocksUntilMessage
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
counts := map[string]int{}
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
counts[event]++
@@ -282,7 +283,7 @@ func TestConvertClaudeResponseToOpenAIResponses_FinalizesMessageBeforeFunctionCa
functionDonePosition := -1
messageDoneCount := 0
functionDoneCount := 0
- var completed gjson.Result
+ var completed ojson.Result
for position, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
itemType := data.Get("item.type").String()
@@ -382,7 +383,7 @@ func TestConvertClaudeResponseToOpenAIResponses_UsesContiguousIndicesForReasonin
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
seen := map[string]int{}
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
var itemType string
@@ -466,7 +467,7 @@ func TestConvertClaudeResponseToOpenAIResponses_ServerToolsSurfaceWithoutOutputI
messageAddedCount := 0
messageDoneCount := 0
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
switch {
@@ -517,7 +518,7 @@ func TestConvertClaudeResponseToOpenAIResponses_StartsNewMessageAfterFunctionCal
var lifecycle []string
var messageIDs []string
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.added" || event == "response.output_item.done" {
@@ -577,7 +578,7 @@ func TestConvertClaudeResponseToOpenAIResponses_FinalizesMessageBeforeReasoning(
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
var lifecycle []string
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.added" || event == "response.output_item.done" {
@@ -622,7 +623,7 @@ func TestConvertClaudeResponseToOpenAIResponses_PreservesMultipleReasoningItems(
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
reasoningDoneCount := 0
- var completed gjson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "reasoning" {
@@ -664,8 +665,8 @@ func TestConvertClaudeResponseToOpenAIResponses_NormalizesEmptyFunctionArguments
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
- var functionDone gjson.Result
- var completed gjson.Result
+ var functionDone ojson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "function_call" {
@@ -697,8 +698,8 @@ func TestConvertClaudeResponseToOpenAIResponses_IncludesEmptyReasoningInComplete
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
- var reasoningDone gjson.Result
- var completed gjson.Result
+ var reasoningDone ojson.Result
+ var completed ojson.Result
for _, output := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "reasoning" {
@@ -734,7 +735,7 @@ func TestConvertClaudeResponseToOpenAIResponses_ReportsCacheTokens(t *testing.T)
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -773,7 +774,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ThinkingIncludesSignatu
}, "\n"))
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("output.0.encrypted_content").String(); got != signature {
t.Fatalf("non-stream reasoning encrypted_content = %q, want %q", got, signature)
@@ -803,7 +804,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_PreservesContentBlockOr
`data: {"type":"message_stop"}`,
}, "\n"))
- root := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, raw, nil))
+ root := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, raw, nil))
wantTypes := []string{"message", "reasoning", "function_call", "message", "reasoning"}
if got := root.Get("output.#").Int(); got != int64(len(wantTypes)) {
t.Fatalf("non-stream output count = %d, want %d", got, len(wantTypes))
@@ -844,7 +845,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ReportsCacheTokens(t *t
}, "\n"))
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("usage.input_tokens").Int(); got != 22044 {
t.Fatalf("non-stream usage input_tokens = %d, want %d", got, 22044)
@@ -876,7 +877,7 @@ func TestConvertClaudeResponseToOpenAIResponses_RestoresAdditionalNamespaceCusto
}
var param any
- var added, inputDone, done, completed gjson.Result
+ var added, inputDone, done, completed ojson.Result
functionEvents := 0
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) {
@@ -908,7 +909,7 @@ func TestConvertClaudeResponseToOpenAIResponses_RestoresAdditionalNamespaceCusto
}
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
}{
{label: "added", item: added.Get("item")},
{label: "done", item: done.Get("item")},
@@ -952,7 +953,7 @@ func TestConvertClaudeResponseToOpenAIResponses_DirectCustomWinsNamespaceCollisi
}
var param any
- var streamCompleted gjson.Result
+ var streamCompleted ojson.Result
for _, chunk := range streamChunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -982,7 +983,7 @@ func TestConvertClaudeResponseToOpenAIResponses_DirectCustomWinsNamespaceCollisi
`data: {"type":"content_block_stop","index":0}`,
`data: {"type":"message_stop"}`,
}, "\n"))
- nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, nonStreamRaw, nil))
+ nonStream := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, nonStreamRaw, nil))
if got := nonStream.Get("output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("non-stream output type = %q, want custom_tool_call", got)
}
@@ -1013,7 +1014,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresAdditionalNames
`data: {"type":"message_stop"}`,
}, "\n"))
- root := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil))
+ root := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil))
if got := root.Get("output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("non-stream output type = %q, want custom_tool_call; output=%s", got, root.Raw)
}
@@ -1044,7 +1045,7 @@ func TestConvertClaudeResponseToOpenAIResponses_CustomToolEmptyInputMatchesNonSt
}
var param any
- var streamCompleted gjson.Result
+ var streamCompleted ojson.Result
for _, chunk := range streamChunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1063,7 +1064,7 @@ func TestConvertClaudeResponseToOpenAIResponses_CustomToolEmptyInputMatchesNonSt
`data: {"type":"content_block_stop","index":0}`,
`data: {"type":"message_stop"}`,
}, "\n"))
- nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil))
+ nonStream := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil))
if got := nonStream.Get("output.0.input").String(); got != "" {
t.Fatalf("non-stream empty custom input = %q, want empty string", got)
}
@@ -1089,9 +1090,9 @@ func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t
}
var param any
- var added gjson.Result
- var done gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var done ojson.Result
+ var completed ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", originalRequest, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1112,7 +1113,7 @@ func TestConvertClaudeResponseToOpenAIResponses_RestoresNamespaceFunctionCall(t
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
}{
{"added", added},
{"done", done},
@@ -1159,7 +1160,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_RestoresNamespaceFuncti
}, "\n"))
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", originalRequest, nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("output.0.name").String(); got != "js" {
t.Fatalf("non-stream output name = %q, want js", got)
@@ -1181,7 +1182,7 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensEmitsIncomplete(t *test
}
var param any
- var incomplete gjson.Result
+ var incomplete ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1227,7 +1228,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_MaxTokensPreservesParti
}, "\n"))
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-fable-5-1", nil, nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; response=%s", got, out)
}
@@ -1254,8 +1255,8 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithToolCallAndTextEmit
}
var param any
- var incomplete gjson.Result
- var funcDone gjson.Result
+ var incomplete ojson.Result
+ var funcDone ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1315,8 +1316,8 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithWebSearchEmitsIncom
}
var param any
- var incomplete gjson.Result
- var wsDone gjson.Result
+ var incomplete ojson.Result
+ var wsDone ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1352,7 +1353,7 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithWebSearchEmitsIncom
`data: {"type":"message_stop"}`,
}, "\n"))
nonStreamOut := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-fable-5-1", nil, nil, raw, nil)
- nsRoot := gjson.ParseBytes(nonStreamOut)
+ nsRoot := util.ParseGJSONBytesNoCopy(nonStreamOut)
if got := nsRoot.Get("status").String(); got != "incomplete" {
t.Fatalf("ns status = %q, want incomplete", got)
}
@@ -1371,8 +1372,8 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensReasoningWithoutBlockSt
}
var param any
- var incomplete gjson.Result
- var rsDone gjson.Result
+ var incomplete ojson.Result
+ var rsDone ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1416,8 +1417,8 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithToolBlockStopEmitsI
}
var param any
- var incomplete gjson.Result
- var funcDone gjson.Result
+ var incomplete ojson.Result
+ var funcDone ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1457,8 +1458,8 @@ func TestConvertClaudeResponseToOpenAIResponses_MaxTokensWithWebSearchResultsEmi
}
var param any
- var incomplete gjson.Result
- var wsDone gjson.Result
+ var incomplete ojson.Result
+ var wsDone ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1526,8 +1527,8 @@ func TestConvertClaudeResponseToOpenAIResponses_CustomToolTruncatedInputUnwrappe
}
var param any
- var customDone gjson.Result
- var incomplete gjson.Result
+ var customDone ojson.Result
+ var incomplete ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", originalRequest, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1570,9 +1571,9 @@ func TestConvertClaudeResponseToOpenAIResponses_EmptyFunctionArgsConsistentOnTru
}
var param any
- var funcDone gjson.Result
- var argsDone gjson.Result
- var incomplete gjson.Result
+ var funcDone ojson.Result
+ var argsDone ojson.Result
+ var incomplete ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-fable-5-1", nil, nil, chunk, ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -1620,9 +1621,9 @@ func applyPatchClaudeFragment(index int, fragment string) []byte {
func applyPatchClaudeEnd() [][]byte {
return [][]byte{[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"}}`), []byte(`data: {"type":"message_stop"}`)}
}
-func applyPatchClaudeEvents(t *testing.T, chunks [][]byte) []gjson.Result {
+func applyPatchClaudeEvents(t *testing.T, chunks [][]byte) []ojson.Result {
t.Helper()
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range chunks {
_, data := parseClaudeResponsesSSEEvent(t, chunk)
events = append(events, data)
@@ -1635,7 +1636,7 @@ const applyPatchClaudeRequest = `{"tools":[{"type":"custom","name":"apply_patch"
func TestApplyPatchClaudePreviewBeforeDone(t *testing.T) {
request := []byte(applyPatchClaudeRequest)
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "c1", "apply_patch"))
@@ -1646,7 +1647,7 @@ func TestApplyPatchClaudePreviewBeforeDone(t *testing.T) {
if preview[0].Get("call_id").String() != "c1" || preview[0].Get("item_id").String() != "ctc_c1" {
t.Fatalf("preview identity: %s", preview[0].Raw)
}
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, preview...)
events = append(events, feed(applyPatchClaudeFragment(0, `*** End Patch"}`))...)
for _, chunk := range applyPatchClaudeEnd() {
@@ -1689,7 +1690,7 @@ func TestApplyPatchClaudePreviewBeforeDone(t *testing.T) {
func TestApplyPatchClaudeLateIdentityAndInterleavedCalls(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "", ""))
@@ -1698,7 +1699,7 @@ func TestApplyPatchClaudeLateIdentityAndInterleavedCalls(t *testing.T) {
t.Fatalf("emitted before identity: %s", event.Raw)
}
}
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, feed(applyPatchClaudeStart(0, "c1", "apply_patch"))...)
events = append(events, feed(applyPatchClaudeStart(1, "c2", "apply_patch"))...)
events = append(events, feed(applyPatchClaudeFragment(1, `{"input":"second`))...)
@@ -1731,7 +1732,7 @@ func TestApplyPatchClaudeInvalidArgumentsFailOnce(t *testing.T) {
for _, arguments := range []string{`plain patch`, `{}`, `{"input":42}`, `{"input":"x","extra":1}`, `{"input":"x","input":"y"}`, `{"input":"x"} {}`, `{"input":"unfinished`, `{"input":"bad\q"}`, `{"input":"\ud800"}`} {
t.Run(arguments, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
feed := func(chunk []byte) {
events = append(events, applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))...)
}
@@ -1779,7 +1780,7 @@ func TestApplyPatchClaudeWinnerAndNamespace(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var param any
request := []byte(tc.request)
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "c1", tc.upstream))
@@ -1791,7 +1792,7 @@ func TestApplyPatchClaudeWinnerAndNamespace(t *testing.T) {
for _, chunk := range applyPatchClaudeEnd() {
events = append(events, feed(chunk)...)
}
- var item gjson.Result
+ var item ojson.Result
for _, event := range events {
if event.Get("type").String() == "response.completed" {
item = event.Get("response.output.0")
@@ -1821,7 +1822,7 @@ func TestApplyPatchClaudeNonStreamStrictInput(t *testing.T) {
t.Run(tc.arguments, func(t *testing.T) {
var param any
raw := []byte(string(applyPatchClaudeStart(0, "c1", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, tc.arguments)) + "\n" + `data: {"type":"message_stop"}`)
- result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
if tc.invalid {
state, ok := param.(interface{ ToolInputError() error })
if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || !ok || state.ToolInputError() == nil {
@@ -1836,7 +1837,7 @@ func TestApplyPatchClaudeNonStreamStrictInput(t *testing.T) {
func TestApplyPatchClaudeTerminalValidatesTruncatedCall(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "c1", "apply_patch"))
@@ -1852,7 +1853,7 @@ func TestApplyPatchClaudeUnicodeFragments(t *testing.T) {
arguments := `{"input":"line\n\u4f60\u597d \ud83d\ude00 \" \\ \u96ea"}`
for split := 1; split < len(arguments); split++ {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "c1", "apply_patch"))
@@ -1876,7 +1877,7 @@ func TestApplyPatchClaudeUnicodeFragments(t *testing.T) {
func TestApplyPatchClaudeIdentityFieldsArriveSeparately(t *testing.T) {
for _, tc := range []struct{ id, name string }{{"c1", ""}, {"", "apply_patch"}} {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, tc.id, tc.name))
@@ -1900,7 +1901,7 @@ func TestApplyPatchClaudeIdentityFieldsArriveSeparately(t *testing.T) {
func TestApplyPatchClaudeConflictingIdentityFails(t *testing.T) {
for _, tc := range []struct{ id, name string }{{"other", "apply_patch"}, {"c1", "other"}} {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "c1", "apply_patch"))
@@ -1919,7 +1920,7 @@ func TestApplyPatchClaudeNonStreamOriginalDeclarationWins(t *testing.T) {
original := []byte(`{"tools":[{"type":"function","name":"apply_patch"}]}`)
var param any
raw := []byte(string(applyPatchClaudeStart(0, "c1", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, `{"not_input":42}`)) + "\n" + `data: {"type":"message_stop"}`)
- result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", original, []byte(applyPatchClaudeRequest), raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", original, []byte(applyPatchClaudeRequest), raw, ¶m))
if result.Get("output.0.type").String() != "function_call" || result.Get("output.0.arguments").String() != `{"not_input":42}` {
t.Fatalf("converted declaration overrode original: %s", result.Raw)
}
@@ -1939,7 +1940,7 @@ func TestApplyPatchClaudeSnapshotsValidateAndDoNotPreview(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var param any
request := []byte(applyPatchClaudeRequest)
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))
}
start := applyPatchClaudeStart(0, "c1", "apply_patch")
@@ -1968,7 +1969,7 @@ func TestApplyPatchClaudeSnapshotsValidateAndDoNotPreview(t *testing.T) {
raw = append(raw, chunk...)
raw = append(raw, '\n')
}
- var terminal gjson.Result
+ var terminal ojson.Result
var input strings.Builder
for _, event := range events {
if event.Get("type").String() == "response.custom_tool_call_input.delta" {
@@ -1979,7 +1980,7 @@ func TestApplyPatchClaudeSnapshotsValidateAndDoNotPreview(t *testing.T) {
}
}
var nonStreamParam any
- nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, raw, &nonStreamParam))
+ nonStream := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, raw, &nonStreamParam))
if tc.invalid {
if terminal.Get("type").String() != "response.failed" || nonStream.Get("status").String() != "failed" {
t.Fatalf("conflicting/invalid snapshot succeeded: stream=%s nonstream=%s", terminal.Raw, nonStream.Raw)
@@ -1994,7 +1995,7 @@ func TestApplyPatchClaudeSnapshotsValidateAndDoNotPreview(t *testing.T) {
func TestApplyPatchClaudeNonStreamConflictingIdentityFails(t *testing.T) {
var param any
raw := []byte(string(applyPatchClaudeStart(0, "c1", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, `{"input":"x"}`)) + "\n" + string(applyPatchClaudeStart(0, "c2", "apply_patch")) + "\n" + `data: {"type":"message_stop"}`)
- result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
state, ok := param.(interface{ ToolInputError() error })
if result.Get("status").String() != "failed" || !ok || state.ToolInputError() == nil {
t.Fatalf("conflicting non-stream identity succeeded: %s", result.Raw)
@@ -2005,7 +2006,7 @@ func TestApplyPatchClaudeConflictingSnapshotsFail(t *testing.T) {
var param any
request := []byte(applyPatchClaudeRequest)
var raw []byte
- var events []gjson.Result
+ var events []ojson.Result
for _, input := range []string{`{"input":"first"}`, `{"input":"replacement"}`} {
chunk := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"c1","name":"apply_patch","input":%s}}`, input))
raw = append(raw, chunk...)
@@ -2027,7 +2028,7 @@ func TestApplyPatchClaudeConflictingSnapshotsFail(t *testing.T) {
}
}
var nonStreamParam any
- result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, raw, &nonStreamParam))
+ result := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, raw, &nonStreamParam))
if failures != 1 || result.Get("status").String() != "failed" {
t.Fatalf("snapshot replacement did not fail: stream failures=%d nonstream=%s", failures, result.Raw)
}
@@ -2035,7 +2036,7 @@ func TestApplyPatchClaudeConflictingSnapshotsFail(t *testing.T) {
func TestApplyPatchClaudePendingIdentityConflictFails(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "c1", ""))
@@ -2048,16 +2049,16 @@ func TestApplyPatchClaudePendingIdentityConflictFails(t *testing.T) {
func TestApplyPatchClaudeMissingIDSynthesizedAtTerminal(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
feed(applyPatchClaudeStart(0, "", "apply_patch"))
feed(applyPatchClaudeFragment(0, `{"input":"x"}`))
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range applyPatchClaudeEnd() {
events = append(events, feed(chunk)...)
}
- var completed gjson.Result
+ var completed ojson.Result
for _, event := range events {
if event.Get("type").String() == "response.completed" {
completed = event
@@ -2072,14 +2073,14 @@ func TestApplyPatchClaudeMissingIDSynthesizedAtTerminal(t *testing.T) {
func TestApplyPatchClaudeNonStreamLateIdentity(t *testing.T) {
var param any
raw := []byte(string(applyPatchClaudeStart(0, "", "apply_patch")) + "\n" + string(applyPatchClaudeFragment(0, `{"input":"x"}`)) + "\n" + string(applyPatchClaudeStart(0, "c1", "")) + "\n" + `data: {"type":"message_stop"}`)
- result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
item := result.Get("output.0")
if item.Get("type").String() != "custom_tool_call" || item.Get("input").String() != "x" || item.Get("id").String() != "ctc_c1" {
t.Fatalf("late ID/name lost: %s", result.Raw)
}
}
-func assertApplyPatchClaudeDeferredFailure(t *testing.T, events []gjson.Result, param any) {
+func assertApplyPatchClaudeDeferredFailure(t *testing.T, events []ojson.Result, param any) {
t.Helper()
failures := 0
for _, event := range events {
@@ -2120,9 +2121,9 @@ func TestApplyPatchClaudeDeferredIdentityConflict(t *testing.T) {
}
t.Run(tc.name+"/"+mode, func(t *testing.T) {
var param any
- var result gjson.Result
+ var result ojson.Result
if stream {
- var events []gjson.Result
+ var events []ojson.Result
for i, chunk := range chunks {
current := applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(tc.request), nil, chunk, ¶m))
if i < 2 && len(current) != 0 {
@@ -2140,7 +2141,7 @@ func TestApplyPatchClaudeDeferredIdentityConflict(t *testing.T) {
}
}
} else {
- result = gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(tc.request), nil, bytes.Join(chunks, []byte("\n")), ¶m))
+ result = util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(tc.request), nil, bytes.Join(chunks, []byte("\n")), ¶m))
if tc.invalid {
if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || param.(interface{ ToolInputError() error }).ToolInputError() == nil {
t.Fatalf("lost pending conflict: %s", result.Raw)
@@ -2207,9 +2208,9 @@ func TestApplyPatchClaudeDeferredSnapshots(t *testing.T) {
}
t.Run(tc.name+"/"+mode, func(t *testing.T) {
var param any
- var result gjson.Result
+ var result ojson.Result
if stream {
- var events []gjson.Result
+ var events []ojson.Result
var deltas strings.Builder
for i, chunk := range chunks {
current := applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(request), nil, chunk, ¶m))
@@ -2234,7 +2235,7 @@ func TestApplyPatchClaudeDeferredSnapshots(t *testing.T) {
t.Fatalf("snapshot/partial input mismatch: delta=%q want=%q", deltas.String(), tc.want)
}
} else {
- result = gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(request), nil, bytes.Join(chunks, []byte("\n")), ¶m))
+ result = util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(request), nil, bytes.Join(chunks, []byte("\n")), ¶m))
if tc.invalid {
if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || param.(interface{ ToolInputError() error }).ToolInputError() == nil {
t.Fatalf("lost pending snapshot evidence: %s", result.Raw)
@@ -2282,12 +2283,12 @@ func TestApplyPatchClaudeSnapshotAfterItemCompletion(t *testing.T) {
t.Run(tc.name+"/"+mode, func(t *testing.T) {
request := []byte(applyPatchClaudeRequest)
var param any
- var result gjson.Result
+ var result ojson.Result
if stream {
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))
}
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range chunks[:5] {
events = append(events, feed(chunk)...)
}
@@ -2321,7 +2322,7 @@ func TestApplyPatchClaudeSnapshotAfterItemCompletion(t *testing.T) {
if !tc.invalid && len(snapshotEvents) != 0 {
t.Fatalf("late snapshot fabricated events: %v", snapshotEvents)
}
- after := append([]gjson.Result(nil), snapshotEvents...)
+ after := append([]ojson.Result(nil), snapshotEvents...)
for _, chunk := range chunks[6:] {
after = append(after, feed(chunk)...)
}
@@ -2352,7 +2353,7 @@ func TestApplyPatchClaudeSnapshotAfterItemCompletion(t *testing.T) {
t.Fatalf("expected one successful response completion, got %d", completions)
}
} else {
- result = gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, bytes.Join(chunks, []byte("\n")), ¶m))
+ result = util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, bytes.Join(chunks, []byte("\n")), ¶m))
if tc.invalid {
if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || param.(interface{ ToolInputError() error }).ToolInputError() == nil {
t.Fatalf("late conflicting snapshot succeeded: %s", result.Raw)
@@ -2387,7 +2388,7 @@ func TestApplyPatchClaudeSuccessfulTerminalSealsState(t *testing.T) {
t.Run(tc.name+"/"+mode, func(t *testing.T) {
var param any
if stream {
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchClaudeEvents(t, ConvertClaudeResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, chunk, ¶m))
}
completions := 0
@@ -2413,7 +2414,7 @@ func TestApplyPatchClaudeSuccessfulTerminalSealsState(t *testing.T) {
raw := bytes.Join(chunks, []byte("\n"))
raw = append(raw, '\n')
raw = append(raw, tc.late...)
- result := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchClaudeRequest), nil, raw, ¶m))
if result.Get("status").String() != "completed" || result.Get("output.0.input").String() != "x" {
t.Fatalf("successful terminal became a failure: %s", result.Raw)
}
@@ -2447,7 +2448,7 @@ func TestConvertClaudeResponseToOpenAIResponses_PinsRequestPerStream(t *testing.
}
var param any
- var added, completed gjson.Result
+ var added, completed ojson.Result
for i, chunk := range chunks {
// Only the first chunk carries the request copy; the pinned state
// must keep tool metadata and completed-event fields working even
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_server_tool_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_server_tool_test.go
index 3e9269556e5..c5ff50c6a94 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_server_tool_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_server_tool_test.go
@@ -7,7 +7,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// --- L1 direction A: Claude server tool blocks -> Responses web_search_call ---
@@ -27,7 +28,7 @@ func claudeWebSearchStreamChunks() [][]byte {
func TestClaudeWebSearchBlocksBecomeWebSearchCallItem(t *testing.T) {
outputs := translateClaudeResponsesStreamThroughRegistry(claudeWebSearchStreamChunks())
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range outputs {
if ev, data := parseClaudeResponsesSSEEvent(t, o); ev == "response.completed" {
completed = data
@@ -67,9 +68,9 @@ func TestClaudeWebSearchBlocksBecomeWebSearchCallItemNonStream(t *testing.T) {
out := ConvertClaudeResponseToOpenAIResponsesNonStream(
context.Background(), "claude-test", nil, nil, []byte(strings.Join(lines, "\n")), nil)
- items := gjson.GetBytes(out, "output").Array()
+ items := ojson.GetBytesNoCopy(out, "output").Array()
if len(items) != 1 || items[0].Get("type").String() != "web_search_call" {
- t.Fatalf("output = %s", gjson.GetBytes(out, "output").Raw)
+ t.Fatalf("output = %s", ojson.GetBytesNoCopy(out, "output").Raw)
}
if got := items[0].Get("action.query").String(); got != "lindorm vector" {
t.Fatalf("action.query = %q", got)
@@ -93,7 +94,7 @@ func TestWebSearchCallItemReplaysAsClaudeServerToolBlocks(t *testing.T) {
got[0] != "server_tool_use" || got[1] != "web_search_tool_result" {
t.Fatalf("assistant blocks = %v, want [server_tool_use web_search_tool_result]", got)
}
- blocks := gjson.GetBytes(out, "messages.0.content")
+ blocks := ojson.GetBytesNoCopy(out, "messages.0.content")
use, result := blocks.Get("0"), blocks.Get("1")
if got := use.Get("id").String(); got != "srvtoolu_1" {
t.Fatalf("server_tool_use id = %q, want srvtoolu_1 (ws_ prefix stripped)", got)
@@ -128,7 +129,7 @@ func TestWebSearchCallWithoutEncryptedContentReplaysEmptyResults(t *testing.T) {
if got := claudeAssistantBlockTypes(t, out); len(got) != 2 {
t.Fatalf("assistant blocks = %v, want the server_tool_use/result pair", got)
}
- if got := gjson.GetBytes(out, "messages.0.content.1.content.#").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.1.content.#").Int(); got != 0 {
t.Fatalf("result content entries = %d, want 0", got)
}
}
@@ -141,7 +142,7 @@ func TestOutputTextAnnotationsReplayAsClaudeCitations(t *testing.T) {
]}]
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- block := gjson.GetBytes(out, "messages.0.content.0")
+ block := ojson.GetBytesNoCopy(out, "messages.0.content.0")
if got := block.Get("type").String(); got != "text" {
t.Fatalf("block type = %q", got)
}
@@ -165,7 +166,7 @@ func TestAnnotationsWithoutEncryptedIndexAreNotReplayedAsCitations(t *testing.T)
]}]
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- if gjson.GetBytes(out, "messages.0.content.0.citations").Exists() {
+ if ojson.GetBytesNoCopy(out, "messages.0.content.0.citations").Exists() {
t.Fatal("citations without encrypted_index must be dropped, not sent")
}
}
@@ -176,9 +177,9 @@ func TestRefusalPartReplaysAsClaudeText(t *testing.T) {
"content":[{"type":"refusal","refusal":"I cannot help with that."}]
}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- block := gjson.GetBytes(out, "messages.0.content")
+ block := ojson.GetBytesNoCopy(out, "messages.0.content")
text := block.Get("0.text").String()
- if block.Type == gjson.String {
+ if block.Type == ojson.String {
text = block.String()
}
if text != "I cannot help with that." {
@@ -214,14 +215,14 @@ func TestRoundTripPreservesReachableClaudeBlocks(t *testing.T) {
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range outputs {
if ev, data := parseClaudeResponsesSSEEvent(t, o); ev == "response.completed" {
completed = data
}
}
var items []json.RawMessage
- completed.Get("response.output").ForEach(func(_, v gjson.Result) bool {
+ completed.Get("response.output").ForEach(func(_, v ojson.Result) bool {
items = append(items, json.RawMessage(v.Raw))
return true
})
@@ -258,7 +259,7 @@ func TestWebSearchCallIDNormalisedToClaudeServerToolPattern(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
raw := responsesRequestFromItems(`{"type":"web_search_call","id":"` + tc.responsesID + `","status":"completed","action":{"type":"search","query":"q"}}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- blocks := gjson.GetBytes(out, "messages.0.content")
+ blocks := ojson.GetBytesNoCopy(out, "messages.0.content")
if got := blocks.Get("0.id").String(); got != tc.want {
t.Fatalf("server_tool_use id = %q, want %q", got, tc.want)
}
@@ -290,7 +291,7 @@ func TestClaudeWebSearchWithoutResultBlockStillEmitsItem(t *testing.T) {
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
- done, completed := 0, gjson.Result{}
+ done, completed := 0, ojson.Result{}
for _, o := range outputs {
event, data := parseClaudeResponsesSSEEvent(t, o)
if event == "response.output_item.done" && data.Get("item.type").String() == "web_search_call" {
@@ -319,7 +320,7 @@ func TestUnmappedServerToolProducesNoItem(t *testing.T) {
[]byte(`data: {"type":"message_stop"}`),
}
outputs := translateClaudeResponsesStreamThroughRegistry(chunks)
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range outputs {
if event, data := parseClaudeResponsesSSEEvent(t, o); event == "response.completed" {
completed = data
@@ -343,7 +344,7 @@ func TestWebSearchResultWithoutMatchingUseKeepsContinuationResult(t *testing.T)
[]byte(`data: {"type":"content_block_stop","index":0}`),
[]byte(`data: {"type":"message_stop"}`),
}
- check := func(t *testing.T, response gjson.Result) {
+ check := func(t *testing.T, response ojson.Result) {
t.Helper()
if got := response.Get("output.#").Int(); got != 1 {
t.Fatalf("output items = %d, want 1: %s", got, response.Get("output").Raw)
@@ -371,7 +372,7 @@ func TestWebSearchResultWithoutMatchingUseKeepsContinuationResult(t *testing.T)
}
output := ConvertClaudeResponseToOpenAIResponsesNonStream(
context.Background(), "claude-test", nil, nil, bytes.Join(chunks, []byte("\n")), nil)
- check(t, gjson.ParseBytes(output))
+ check(t, util.ParseGJSONBytesNoCopy(output))
}
// Native OpenAI clients emit `queries` alongside `query`, and use an `open_page`
@@ -386,7 +387,7 @@ func TestWebSearchCallQueryAcceptsNativeOpenAIActionShapes(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
raw := responsesRequestFromItems(`{"type":"web_search_call","id":"ws_srvtoolu_1","status":"completed","action":` + tc.action + `}`)
out, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", raw, false)
- if got := gjson.GetBytes(out, "messages.0.content.0.input.query").String(); got != tc.want {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.input.query").String(); got != tc.want {
t.Fatalf("input.query = %q, want %q", got, tc.want)
}
})
@@ -405,7 +406,7 @@ func TestClaudeWebSearchWithEmptyResultsKeepsEmptyList(t *testing.T) {
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_stop"}`),
}
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range translateClaudeResponsesStreamThroughRegistry(chunks) {
if event, data := parseClaudeResponsesSSEEvent(t, o); event == "response.completed" {
completed = data
@@ -427,7 +428,7 @@ func TestClaudeWebSearchErrorResultSurvivesRoundTrip(t *testing.T) {
[]byte(`data: {"type":"content_block_stop","index":1}`),
[]byte(`data: {"type":"message_stop"}`),
}
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range translateClaudeResponsesStreamThroughRegistry(chunks) {
if event, data := parseClaudeResponsesSSEEvent(t, o); event == "response.completed" {
completed = data
@@ -448,7 +449,7 @@ func TestClaudeWebSearchErrorResultSurvivesRoundTrip(t *testing.T) {
// Replay back to Claude
req, _ := json.Marshal(map[string]any{"model": "claude-test", "input": []json.RawMessage{json.RawMessage(outputItems[0].Raw)}})
replayed, _ := ConvertOpenAIResponsesRequestToClaude("claude-test", req, false)
- replayedBlocks := gjson.GetBytes(replayed, "messages.0.content")
+ replayedBlocks := ojson.GetBytesNoCopy(replayed, "messages.0.content")
if got := replayedBlocks.Get("1.content.0.type").String(); got != "web_search_tool_result_error" {
t.Fatalf("replayed error type = %q, want web_search_tool_result_error", got)
}
@@ -474,7 +475,7 @@ func TestTextSearchTextOrderPreservedInStreamingAndReplay(t *testing.T) {
[]byte(`data: {"type":"content_block_stop","index":3}`),
[]byte(`data: {"type":"message_stop"}`),
}
- var completed gjson.Result
+ var completed ojson.Result
for _, o := range translateClaudeResponsesStreamThroughRegistry(chunks) {
if event, data := parseClaudeResponsesSSEEvent(t, o); event == "response.completed" {
completed = data
@@ -504,9 +505,9 @@ func TestTextSearchTextOrderPreservedInStreamingAndReplay(t *testing.T) {
}
nonStreamOut := ConvertClaudeResponseToOpenAIResponsesNonStream(
context.Background(), "claude-test", nil, nil, []byte(strings.Join(lines, "\n")), nil)
- nonStreamItems := gjson.GetBytes(nonStreamOut, "output").Array()
+ nonStreamItems := ojson.GetBytesNoCopy(nonStreamOut, "output").Array()
if len(nonStreamItems) != 3 {
- t.Fatalf("non-stream expected 3 output items, got %d: %s", len(nonStreamItems), gjson.GetBytes(nonStreamOut, "output").Raw)
+ t.Fatalf("non-stream expected 3 output items, got %d: %s", len(nonStreamItems), ojson.GetBytesNoCopy(nonStreamOut, "output").Raw)
}
if nonStreamItems[0].Get("content.0.text").String() != "Before search." {
t.Fatalf("non-stream item 0 text = %q, want 'Before search.'", nonStreamItems[0].Get("content.0.text").String())
@@ -534,4 +535,119 @@ func TestTextSearchTextOrderPreservedInStreamingAndReplay(t *testing.T) {
if strings.Join(replayedBlocks, ",") != strings.Join(wantBlocks, ",") {
t.Fatalf("replayed block types = %v, want %v", replayedBlocks, wantBlocks)
}
+ if got := ojson.GetBytesNoCopy(replayed, "messages.0.content.3.citations.0.encrypted_index").String(); got != "IDX_ORD" {
+ t.Fatalf("replayed citation encrypted_index with results = %q, want IDX_ORD", got)
+ }
+}
+
+func TestWebSearchCallWithoutResultsDropsAssistantCitations(t *testing.T) {
+ sig := mustTestSignature(t)
+ t.Run("codex replay without results drops citations and keeps text", func(t *testing.T) {
+ raw := responsesRequestFromItems(
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"earlier context"}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"search question"}]}`,
+ `{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"thinking 1"}],"encrypted_content":"`+sig+`"}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_1","status":"completed","action":{"type":"search","query":"q"}}`,
+ `{"type":"reasoning","id":"rs_2","summary":[{"type":"summary_text","text":"thinking 2"}],"encrypted_content":"`+sig+`"}`,
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Cited answer.","annotations":[{"type":"web_search_result_location","url":"https://example.com/a","title":"A","cited_text":"Cited answer.","encrypted_index":"IDX_A"}]}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"Reply with OK"}]}`,
+ )
+ out, errConvert := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", raw, false)
+ if errConvert != nil {
+ t.Fatalf("ConvertOpenAIResponsesRequestToClaude() error = %v", errConvert)
+ }
+ assistant := ojson.GetBytesNoCopy(out, "messages.1.content")
+ if got := assistant.Get("2.type").String(); got != "web_search_tool_result" {
+ t.Fatalf("messages.1.content.2.type = %q, want web_search_tool_result: %s", got, assistant.Raw)
+ }
+ if got := assistant.Get("2.content.#").Int(); got != 0 {
+ t.Fatalf("messages.1.content.2.content.# = %d, want 0", got)
+ }
+ textBlock := assistant.Get("4")
+ if got := textBlock.Get("type").String(); got != "text" {
+ t.Fatalf("messages.1.content.4.type = %q, want text: %s", got, assistant.Raw)
+ }
+ if got := textBlock.Get("text").String(); got != "Cited answer." {
+ t.Fatalf("messages.1.content.4.text = %q, want %q", got, "Cited answer.")
+ }
+ if citations := textBlock.Get("citations"); citations.Exists() {
+ t.Fatalf("expected citations to be dropped when web_search_tool_result has no results, got: %s", citations.Raw)
+ }
+ })
+
+ t.Run("results without encrypted_content also drop citations", func(t *testing.T) {
+ raw := responsesRequestFromItems(
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"question"}]}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_1","status":"completed","action":{"type":"search","query":"q"},"results":[{"title":"T","url":"https://example.com/a"}]}`,
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Cited answer.","annotations":[{"type":"web_search_result_location","url":"https://example.com/a","title":"A","cited_text":"Cited answer.","encrypted_index":"IDX_A"}]}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"follow up"}]}`,
+ )
+ out, _ := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", raw, false)
+ textBlock := ojson.GetBytesNoCopy(out, "messages.1.content.2")
+ if got := textBlock.Get("text").String(); got != "Cited answer." {
+ t.Fatalf("messages.1.content.2.text = %q, want %q", got, "Cited answer.")
+ }
+ if citations := textBlock.Get("citations"); citations.Exists() {
+ t.Fatalf("expected citations to be dropped when results degrade to empty, got: %s", citations.Raw)
+ }
+ })
+
+ t.Run("interleaved client tool call within same assistant turn drops unbacked citations", func(t *testing.T) {
+ raw := responsesRequestFromItems(
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"question"}]}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_1","status":"completed","action":{"type":"search","query":"q"}}`,
+ `{"type":"function_call","call_id":"call_1","name":"exec","arguments":"{}"}`,
+ `{"type":"function_call_output","call_id":"call_1","output":"ok"}`,
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"After tool.","annotations":[{"type":"web_search_result_location","url":"https://example.com/a","title":"A","cited_text":"After tool.","encrypted_index":"IDX_A"}]}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"follow up"}]}`,
+ )
+ out, _ := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", raw, false)
+ msg3Content := ojson.GetBytesNoCopy(out, "messages.3.content")
+ text := msg3Content.String()
+ if msg3Content.IsArray() {
+ text = msg3Content.Get("0.text").String()
+ }
+ if text != "After tool." {
+ t.Fatalf("messages.3 text = %q, want %q: %s", text, "After tool.", ojson.GetBytesNoCopy(out, "messages.3").Raw)
+ }
+ if citations := msg3Content.Get("0.citations"); citations.Exists() {
+ t.Fatalf("expected citations after interleaved tool_result to be dropped, got: %s", citations.Raw)
+ }
+ })
+
+ t.Run("later turn with results keeps its citations after earlier turn dropped theirs", func(t *testing.T) {
+ raw := responsesRequestFromItems(
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"q1"}]}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_1","status":"completed","action":{"type":"search","query":"q1"}}`,
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Turn 1 answer.","annotations":[{"type":"web_search_result_location","url":"https://example.com/1","title":"1","cited_text":"Turn 1 answer.","encrypted_index":"IDX_1"}]}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"q2"}]}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_2","status":"completed","action":{"type":"search","query":"q2"},"results":[{"title":"2","url":"https://example.com/2","encrypted_content":"ENC_2"}]}`,
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Turn 2 answer.","annotations":[{"type":"web_search_result_location","url":"https://example.com/2","title":"2","cited_text":"Turn 2 answer.","encrypted_index":"IDX_2"}]}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"q3"}]}`,
+ )
+ out, _ := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", raw, false)
+ if citations := ojson.GetBytesNoCopy(out, "messages.1.content.2.citations"); citations.Exists() {
+ t.Fatalf("turn 1 citations must be dropped, got: %s", citations.Raw)
+ }
+ if got := ojson.GetBytesNoCopy(out, "messages.3.content.2.citations.0.encrypted_index").String(); got != "IDX_2" {
+ t.Fatalf("turn 2 citation encrypted_index = %q, want IDX_2: %s", got, ojson.GetBytesNoCopy(out, "messages.3").Raw)
+ }
+ })
+
+ t.Run("multiple searches in one turn drop citations when any search lacks results", func(t *testing.T) {
+ raw := responsesRequestFromItems(
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_1","status":"completed","action":{"type":"search","query":"q1"},"results":[{"title":"1","url":"https://example.com/1","encrypted_content":"ENC_1"}]}`,
+ `{"type":"web_search_call","id":"ws_srvtoolu_2","status":"completed","action":{"type":"search","query":"q2"}}`,
+ `{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Combined answer.","annotations":[{"type":"web_search_result_location","url":"https://example.com/2","title":"2","cited_text":"Combined answer.","encrypted_index":"IDX_2"}]}]}`,
+ `{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}`,
+ )
+ out, _ := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", raw, false)
+ if citations := ojson.GetBytesNoCopy(out, "messages.1.content.4.citations"); citations.Exists() {
+ t.Fatalf("expected citations to be dropped when one search in turn has no results, got: %s", citations.Raw)
+ }
+ if got := ojson.GetBytesNoCopy(out, "messages.1.content.4.text").String(); got != "Combined answer." {
+ t.Fatalf("expected text to be preserved, got %q", got)
+ }
+ })
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_stop_reasons_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_stop_reasons_test.go
index 3cb9fb83b07..abb612f6487 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_stop_reasons_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_stop_reasons_test.go
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// pausedSearchChunks renders one Claude response: signed thinking, an optional
@@ -61,10 +61,10 @@ func continuationChunks(messageID, pausedID string, stopReason string) [][]byte
}
// streamTerminal returns the terminal event name and its response object.
-func streamTerminal(t *testing.T, chunks [][]byte) (string, gjson.Result) {
+func streamTerminal(t *testing.T, chunks [][]byte) (string, ojson.Result) {
t.Helper()
var name string
- var response gjson.Result
+ var response ojson.Result
for _, output := range translateClaudeResponsesStreamThroughRegistry(chunks) {
event, data := parseClaudeResponsesSSEEvent(t, output)
switch event {
@@ -81,27 +81,27 @@ func streamTerminal(t *testing.T, chunks [][]byte) (string, gjson.Result) {
// codexHistoryRequest builds the next Responses request exactly as a client
// would: the user prompt followed by the output items it received, without
// re-adding anything the translator dropped.
-func codexHistoryRequest(t *testing.T, responses ...gjson.Result) []byte {
+func codexHistoryRequest(t *testing.T, responses ...ojson.Result) []byte {
t.Helper()
- request := []byte(`{"model":"claude-test","tools":[{"type":"web_search"}],"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"research"}]}]}`)
+ requestDoc := ojson.NewDocString(`{"model":"claude-test","tools":[{"type":"web_search"}],"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"research"}]}]}`)
for _, response := range responses {
for _, item := range response.Get("output").Array() {
// Codex does not keep web_search_call results; the proxy cache restores
// them later, so the history carries none.
raw := []byte(item.Raw)
if item.Get("type").String() == "web_search_call" {
- raw, _ = sjson.DeleteBytes(raw, "results")
+ raw, _ = ojson.DeleteBytes(raw, "results")
}
- request, _ = sjson.SetRawBytes(request, "input.-1", raw)
+ _ = requestDoc.SetRawBytes("input.-1", raw)
}
}
- return request
+ return requestDoc.Bytes()
}
-func claudeAssistantBlocks(t *testing.T, claudeRequest []byte) []gjson.Result {
+func claudeAssistantBlocks(t *testing.T, claudeRequest []byte) []ojson.Result {
t.Helper()
- var blocks []gjson.Result
- for _, message := range gjson.GetBytes(claudeRequest, "messages").Array() {
+ var blocks []ojson.Result
+ for _, message := range ojson.GetBytesNoCopy(claudeRequest, "messages").Array() {
if message.Get("role").String() == "assistant" {
blocks = append(blocks, message.Get("content").Array()...)
}
@@ -109,7 +109,7 @@ func claudeAssistantBlocks(t *testing.T, claudeRequest []byte) []gjson.Result {
return blocks
}
-func blockTypes(blocks []gjson.Result) string {
+func blockTypes(blocks []ojson.Result) string {
types := make([]string, 0, len(blocks))
for _, block := range blocks {
types = append(types, block.Get("type").String())
@@ -123,7 +123,7 @@ func TestPauseTurnRoundTripSendsPausedSearchBackWithoutResult(t *testing.T) {
if event != "response.completed" || response.Get("end_turn").Raw != "false" {
t.Fatalf("terminal = %s end_turn=%s", event, response.Get("end_turn").Raw)
}
- var pausedItem gjson.Result
+ var pausedItem ojson.Result
for _, item := range response.Get("output").Array() {
if item.Get("id").String() == responsesWebSearchCallID("srvtoolu_paused") {
pausedItem = item
@@ -137,7 +137,7 @@ func TestPauseTurnRoundTripSendsPausedSearchBackWithoutResult(t *testing.T) {
if errTranslate != nil {
t.Fatal(errTranslate)
}
- messages := gjson.GetBytes(claudeRequest, "messages").Array()
+ messages := ojson.GetBytesNoCopy(claudeRequest, "messages").Array()
last := messages[len(messages)-1]
if last.Get("role").String() != "assistant" {
t.Fatalf("last message role = %q: %s", last.Get("role").String(), claudeRequest)
@@ -224,7 +224,7 @@ func TestPausedSearchFollowedByUserTurnKeepsLegacyPair(t *testing.T) {
signature := mustTestSignature(t)
_, paused := streamTerminal(t, pausedSearchChunks(t, "msg_p1", signature, "", "srvtoolu_paused", "paused query"))
request := codexHistoryRequest(t, paused)
- request, _ = sjson.SetRawBytes(request, "input.-1", []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":"never mind"}]}`))
+ request, _ = ojson.SetRawBytes(request, "input.-1", []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":"never mind"}]}`))
claudeRequest, errTranslate := ConvertOpenAIResponsesRequestToClaude("claude-test", request, true)
if errTranslate != nil {
t.Fatal(errTranslate)
@@ -246,7 +246,7 @@ func TestRepeatedWebSearchIDsAcrossTurnsAreNotMerged(t *testing.T) {
`{"type":"message","role":"user","content":[{"type":"input_text","text":"again"}]}`,
`{"type":"function_call_output","call_id":"call_1","output":"ok"}`,
} {
- request := []byte(`{"model":"claude-test","tools":[{"type":"web_search"},{"type":"function","name":"f","parameters":{"type":"object","properties":{}}}],"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}]}`)
+ requestDoc := ojson.NewDocString(`{"model":"claude-test","tools":[{"type":"web_search"},{"type":"function","name":"f","parameters":{"type":"object","properties":{}}}],"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}]}`)
first := `{"id":"` + responsesWebSearchCallID("srvtoolu_x") + `","type":"web_search_call","status":"completed","action":{"type":"search","query":"query a"}}`
second := `{"id":"` + responsesWebSearchCallID("srvtoolu_x") + `","type":"web_search_call","status":"completed","action":{"type":"search","query":"query b"}}`
items := []string{first}
@@ -255,8 +255,9 @@ func TestRepeatedWebSearchIDsAcrossTurnsAreNotMerged(t *testing.T) {
}
items = append(items, boundary, second)
for _, item := range items {
- request, _ = sjson.SetRawBytes(request, "input.-1", []byte(item))
+ _ = requestDoc.SetRawBytes("input.-1", []byte(item))
}
+ request := requestDoc.Bytes()
claudeRequest, errTranslate := ConvertOpenAIResponsesRequestToClaude("claude-test", request, true)
if errTranslate != nil {
t.Fatal(errTranslate)
@@ -278,10 +279,11 @@ func TestRepeatedWebSearchIDsAcrossTurnsAreNotMerged(t *testing.T) {
func TestPausedSearchContinuationRequiresSameAssistantRun(t *testing.T) {
paused := `{"id":"` + responsesWebSearchCallID("srvtoolu_p") + `","type":"web_search_call","status":"in_progress","action":{"type":"search","query":"paused"}}`
later := `{"id":"` + responsesWebSearchCallID("srvtoolu_p") + `","type":"web_search_call","status":"completed","action":{"type":"search","query":""}}`
- request := []byte(`{"model":"claude-test","tools":[{"type":"web_search"}],"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}]}`)
+ requestDoc := ojson.NewDocString(`{"model":"claude-test","tools":[{"type":"web_search"}],"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"q"}]}]}`)
for _, item := range []string{paused, `{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}`, later} {
- request, _ = sjson.SetRawBytes(request, "input.-1", []byte(item))
+ _ = requestDoc.SetRawBytes("input.-1", []byte(item))
}
+ request := requestDoc.Bytes()
claudeRequest, errTranslate := ConvertOpenAIResponsesRequestToClaude("claude-test", request, true)
if errTranslate != nil {
t.Fatal(errTranslate)
@@ -315,7 +317,7 @@ func runStopReasonErrorsKeepOutput(t *testing.T) {
[]byte(`data: {"type":"message_stop"}`),
}
var order []string
- var terminal gjson.Result
+ var terminal ojson.Result
for _, output := range translateClaudeResponsesStreamThroughRegistry(chunks) {
event, data := parseClaudeResponsesSSEEvent(t, output)
if event == "response.output_item.done" && data.Get("item.type").String() == "message" {
@@ -335,7 +337,7 @@ func runStopReasonErrorsKeepOutput(t *testing.T) {
if terminal.Get("status").String() != "incomplete" {
t.Fatalf("status = %q, want incomplete", terminal.Get("status").String())
}
- if errNode := terminal.Get("error"); errNode.Exists() && errNode.Type != gjson.Null {
+ if errNode := terminal.Get("error"); errNode.Exists() && errNode.Type != ojson.Null {
t.Fatalf("unexpected error = %s", errNode.Raw)
}
if terminal.Get("usage.input_tokens").Int() != 10 || terminal.Get("usage.input_tokens_details.cached_tokens").Int() != 3 || terminal.Get("usage.output_tokens").Int() != 4 {
@@ -370,7 +372,7 @@ func TestResponsesParallelToolCallsFalseMapsToDisableParallelToolUse(t *testing.
if errTranslate != nil {
t.Fatal(errTranslate)
}
- if got := gjson.GetBytes(out, "tool_choice").Raw; got != tc.want {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice").Raw; got != tc.want {
t.Fatalf("tool_choice = %s, want %s", got, tc.want)
}
})
@@ -386,11 +388,11 @@ func TestContextWindowExceededNonStreamIsIncompleteMaxOutputTokens(t *testing.T)
[]byte(`data: {"type":"message_delta","delta":{"stop_reason":"model_context_window_exceeded"},"usage":{"output_tokens":2}}`),
[]byte(`data: {"type":"message_stop"}`),
}
- output := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, bytes.Join(chunks, []byte("\n")), nil))
+ output := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", nil, nil, bytes.Join(chunks, []byte("\n")), nil))
if output.Get("status").String() != "incomplete" || output.Get("incomplete_details.reason").String() != "max_output_tokens" {
t.Fatalf("response = %s", output.Raw)
}
- if errNode := output.Get("error"); errNode.Exists() && errNode.Type != gjson.Null {
+ if errNode := output.Get("error"); errNode.Exists() && errNode.Type != ojson.Null {
t.Fatalf("unexpected error = %s", errNode.Raw)
}
if output.Get("output.0.status").String() != "incomplete" || output.Get("output.0.content.0.text").String() != "cut" {
@@ -415,7 +417,7 @@ func TestPrefillRejectingModelStillDropsCompletedThinkingPlusTextTrailingAssista
if errTranslate != nil {
t.Fatal(errTranslate)
}
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "user" {
t.Fatalf("expected trailing thinking+text assistant message to be stripped on claude-sonnet-4-6, got %d messages: %s", len(messages), out)
}
@@ -430,12 +432,12 @@ func TestPauseTurnAfterCompletedSearchPreservedOnPrefillRejectingModel(t *testin
t.Fatalf("terminal = %s end_turn=%s", event, response.Get("end_turn").Raw)
}
req := codexHistoryRequest(t, response)
- req, _ = sjson.SetBytes(req, "model", "claude-opus-5-5")
+ req, _ = ojson.SetBytes(req, "model", "claude-opus-5-5")
claudeRequest, errTranslate := ConvertOpenAIResponsesRequestToClaude("claude-opus-5-5", req, true)
if errTranslate != nil {
t.Fatal(errTranslate)
}
- messages := gjson.GetBytes(claudeRequest, "messages").Array()
+ messages := ojson.GetBytesNoCopy(claudeRequest, "messages").Array()
if len(messages) != 2 || messages[1].Get("role").String() != "assistant" {
t.Fatalf("expected paused assistant search turn to be preserved on claude-opus-5-5, got: %s", claudeRequest)
}
@@ -459,14 +461,14 @@ func deferredSearchChunks() [][]byte {
}
}
-var execOutput = gjson.Parse(`{"output":[{"type":"function_call_output","call_id":"toolu_exec","output":"Linux"}]}`)
+var execOutput = ojson.Parse(`{"output":[{"type":"function_call_output","call_id":"toolu_exec","output":"Linux"}]}`)
func TestDeferredSearchReplaysWithoutResultAlongsideToolOutput(t *testing.T) {
_, response := streamTerminal(t, deferredSearchChunks())
if search := response.Get("output.0"); search.Get("status").String() != "in_progress" || search.Get("results").Exists() {
t.Fatalf("deferred search item = %s", search.Raw)
}
- nonStream := gjson.ParseBytes(ConvertClaudeResponseToOpenAIResponsesNonStream(
+ nonStream := util.ParseGJSONBytesNoCopy(ConvertClaudeResponseToOpenAIResponsesNonStream(
context.Background(), "claude-test", nil, nil, bytes.Join(deferredSearchChunks(), []byte("\n")), nil))
if search := nonStream.Get("output.0"); search.Get("status").String() != "in_progress" || search.Get("results").Exists() {
t.Fatalf("non-stream deferred search item = %s", search.Raw)
@@ -484,7 +486,7 @@ func TestDeferredSearchContinuationEmitsSinglePair(t *testing.T) {
_, deferred := streamTerminal(t, deferredSearchChunks())
_, continued := streamTerminal(t, continuationChunks("msg_d2", "srvtoolu_deferred", "end_turn"))
request := codexHistoryRequest(t, deferred, execOutput, continued)
- request, _ = sjson.SetRawBytes(request, "input.-1", []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":"thanks"}]}`))
+ request, _ = ojson.SetRawBytes(request, "input.-1", []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":"thanks"}]}`))
claudeRequest, errTranslate := ConvertOpenAIResponsesRequestToClaude("claude-test", request, true)
if errTranslate != nil {
t.Fatal(errTranslate)
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_testsupport_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_testsupport_test.go
index 512116bbb8f..eb3429f2ddf 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_testsupport_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_testsupport_test.go
@@ -3,7 +3,7 @@ package responses
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// claudeAssistantBlockTypes returns the content block types of the last Claude
@@ -11,12 +11,12 @@ import (
func claudeAssistantBlockTypes(t *testing.T, claudeReq []byte) []string {
t.Helper()
var kinds []string
- gjson.GetBytes(claudeReq, "messages").ForEach(func(_, m gjson.Result) bool {
+ ojson.GetBytesNoCopy(claudeReq, "messages").ForEach(func(_, m ojson.Result) bool {
if m.Get("role").String() != "assistant" {
return true
}
kinds = kinds[:0]
- m.Get("content").ForEach(func(_, b gjson.Result) bool {
+ m.Get("content").ForEach(func(_, b ojson.Result) bool {
kinds = append(kinds, b.Get("type").String())
return true
})
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go
index 12a3e953935..310fff78833 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names.go
@@ -8,8 +8,8 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
var claudeToolNamePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
@@ -22,11 +22,11 @@ type claudeToolNames struct {
fromClaude map[string]string
}
-func buildClaudeToolNames(root gjson.Result, toolSearch bool) claudeToolNames {
+func buildClaudeToolNames(root ojson.Result, toolSearch bool) claudeToolNames {
return buildClaudeToolNamesWithWinners(root, responsesToolWinners(root, toolSearch), toolSearch)
}
-func buildClaudeToolNamesWithWinners(root gjson.Result, winners map[string]responsesToolDescriptor, toolSearch bool) claudeToolNames {
+func buildClaudeToolNamesWithWinners(root ojson.Result, winners map[string]responsesToolDescriptor, toolSearch bool) claudeToolNames {
m := claudeToolNames{toClaude: map[string]string{}, fromClaude: map[string]string{}}
taken := map[string]bool{}
var declared []string
@@ -117,11 +117,11 @@ func (m claudeToolNames) allocate(identities []string, taken map[string]bool) {
// responsesHistoryToolIdentities lists qualified names of function_call and
// custom_tool_call items in input, in order.
-func responsesHistoryToolIdentities(root gjson.Result) []string {
+func responsesHistoryToolIdentities(root ojson.Result) []string {
var ids []string
input := root.Get("input")
if input.Exists() && input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
switch item.Get("type").String() {
case "function_call", "custom_tool_call":
name := item.Get("name").String()
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go
index b4111e30a83..86e4bfe4a68 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_names_test.go
@@ -6,7 +6,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestBuildClaudeToolNames_RoundTripAndStability(t *testing.T) {
@@ -25,7 +26,7 @@ func TestBuildClaudeToolNames_RoundTripAndStability(t *testing.T) {
]
}`)
- root := gjson.ParseBytes(reqJSON)
+ root := util.ParseGJSONBytesNoCopy(reqJSON)
names := buildClaudeToolNames(root, true)
// Verify all declared tools are unique and match pattern
@@ -85,8 +86,8 @@ func TestBuildClaudeToolNames_DeclarationOrderInvariance(t *testing.T) {
]
}`)
- namesA := buildClaudeToolNames(gjson.ParseBytes(reqA), true)
- namesB := buildClaudeToolNames(gjson.ParseBytes(reqB), true)
+ namesA := buildClaudeToolNames(util.ParseGJSONBytesNoCopy(reqA), true)
+ namesB := buildClaudeToolNames(util.ParseGJSONBytesNoCopy(reqB), true)
if namesA.claudeName(tool1) != namesB.claudeName(tool1) {
t.Fatalf("order affected tool1 mapping: %q vs %q", namesA.claudeName(tool1), namesB.claudeName(tool1))
@@ -104,7 +105,7 @@ func TestBuildClaudeToolNames_SingleLongName(t *testing.T) {
]
}`)
- names := buildClaudeToolNames(gjson.ParseBytes(reqJSON), true)
+ names := buildClaudeToolNames(util.ParseGJSONBytesNoCopy(reqJSON), true)
claudeName := names.claudeName(longTool)
if len(claudeName) > 64 {
t.Fatalf("claudeName length = %d > 64: %q", len(claudeName), claudeName)
@@ -128,7 +129,7 @@ func TestBuildClaudeToolNames_CustomToolCollision(t *testing.T) {
]
}`, tool1, tool2))
- root := gjson.ParseBytes(reqJSON)
+ root := util.ParseGJSONBytesNoCopy(reqJSON)
names := buildClaudeToolNames(root, true)
customMap := responsesCustomToolNames(reqJSON)
@@ -162,7 +163,7 @@ func TestBuildClaudeToolNames_CustomToolCollision(t *testing.T) {
`data: {"type":"message_stop"}`,
}, "\n"))
translatedResp := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-sonnet-5-5", reqJSON, nil, claudeResp, nil)
- outputItem := gjson.GetBytes(translatedResp, "output.0")
+ outputItem := ojson.GetBytesNoCopy(translatedResp, "output.0")
if outputItem.Get("type").String() != "custom_tool_call" {
t.Fatalf("output.0.type = %q, want custom_tool_call", outputItem.Get("type").String())
}
@@ -182,7 +183,7 @@ func TestBuildClaudeToolNames_DeclaredToolsPrecedeHistory(t *testing.T) {
]
}`)
- names := buildClaudeToolNames(gjson.ParseBytes(reqJSON), true)
+ names := buildClaudeToolNames(util.ParseGJSONBytesNoCopy(reqJSON), true)
// tool_x must keep its exact name
if names.claudeName("tool_x") != "tool_x" {
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go
index 3c47af15790..c0d7d531575 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search.go
@@ -5,10 +5,9 @@ import (
"strings"
"sync/atomic"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Codex declares client-executed tool search as {"type":"tool_search",
@@ -84,7 +83,7 @@ const responsesDeferredAnchorJSON = `{"name":"` + responsesDeferredAnchorName +
// keeps the client's tool and simply has no anchor.
func responsesDeferredAnchorTool(toolItems [][]byte) ([]byte, bool) {
for _, tool := range toolItems {
- if gjson.GetBytes(tool, "name").String() == responsesDeferredAnchorName {
+ if ojson.GetBytesNoCopy(tool, "name").String() == responsesDeferredAnchorName {
return nil, false
}
}
@@ -93,56 +92,62 @@ func responsesDeferredAnchorTool(toolItems [][]byte) ([]byte, bool) {
// isResponsesClientToolSearch reports whether a tool declaration asks the
// client to execute tool search. Hosted (server) search cannot run on Claude.
-func isResponsesClientToolSearch(tool gjson.Result) bool {
+func isResponsesClientToolSearch(tool ojson.Result) bool {
return strings.TrimSpace(tool.Get("type").String()) == responsesToolSearchType &&
strings.TrimSpace(tool.Get("execution").String()) == "client"
}
// convertResponsesToolSearchToolToClaude keeps the client's declared schema
// but guarantees the required query field Codex parses.
-func convertResponsesToolSearchToolToClaude(tool gjson.Result, claudeName string) ([]byte, bool) {
+func convertResponsesToolSearchToolToClaude(tool ojson.Result, claudeName string) ([]byte, bool) {
schema := []byte(claudeToolSearchDefaultSchema)
if parameters := tool.Get("parameters"); parameters.IsObject() {
schema = util.NormalizeClaudeToolInputSchema([]byte(parameters.Raw))
}
- if gjson.GetBytes(schema, "properties.query.type").String() != "string" {
- schema, _ = sjson.SetRawBytes(schema, "properties.query", []byte(`{"type":"string","description":"Search query for deferred tools."}`))
- }
- if !gjson.GetBytes(schema, `required.#(=="query")`).Exists() {
- schema, _ = sjson.SetBytes(schema, "required.-1", "query")
+ needQueryProp := ojson.GetBytesNoCopy(schema, "properties.query.type").String() != "string"
+ needQueryReq := !ojson.GetBytesNoCopy(schema, `required.#(=="query")`).Exists()
+ if needQueryProp || needQueryReq {
+ schemaDoc := ojson.NewDoc(schema)
+ if needQueryProp {
+ _ = schemaDoc.SetRawBytes("properties.query", []byte(`{"type":"string","description":"Search query for deferred tools."}`))
+ }
+ if needQueryReq {
+ _ = schemaDoc.Set("required.-1", "query")
+ }
+ schema = schemaDoc.Bytes()
}
- tJSON := []byte(`{"name":"","description":"","input_schema":{}}`)
- tJSON, _ = sjson.SetBytes(tJSON, "name", claudeName)
- tJSON, _ = sjson.SetBytes(tJSON, "description", responsesToolDescription(tool))
- tJSON, _ = sjson.SetRawBytes(tJSON, "input_schema", schema)
- return tJSON, true
+ tDoc := ojson.NewDocString(`{"name":"","description":"","input_schema":{}}`)
+ _ = tDoc.Set("name", claudeName)
+ _ = tDoc.Set("description", responsesToolDescription(tool))
+ _ = tDoc.SetRawBytes("input_schema", schema)
+ return tDoc.Bytes(), true
}
// convertResponsesToolSearchCallToClaude replays a client tool_search_call as
// an assistant tool_use and returns it with its raw call id. Hosted calls have
// no call id and return nil.
-func convertResponsesToolSearchCallToClaude(item gjson.Result, claudeName string) ([]byte, string) {
+func convertResponsesToolSearchCallToClaude(item ojson.Result, claudeName string) ([]byte, string) {
callID := strings.TrimSpace(item.Get("call_id").String())
if callID == "" || strings.TrimSpace(item.Get("execution").String()) != "client" {
return nil, ""
}
- toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUse, _ = sjson.SetBytes(toolUse, "id", util.SanitizeClaudeToolID(callID))
- toolUse, _ = sjson.SetBytes(toolUse, "name", claudeName)
+ toolUseDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = toolUseDoc.Set("id", util.SanitizeClaudeToolID(callID))
+ _ = toolUseDoc.Set("name", claudeName)
arguments := item.Get("arguments")
- if arguments.Type == gjson.String {
- arguments = gjson.Parse(arguments.String())
+ if arguments.Type == ojson.String {
+ arguments = ojson.Parse(arguments.String())
}
if arguments.IsObject() {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(arguments.Raw))
+ _ = toolUseDoc.SetRawBytes("input", []byte(arguments.Raw))
}
- return toolUse, callID
+ return toolUseDoc.Bytes(), callID
}
// responsesToolSearchOutputText summarizes discovered tools for the model.
// Their full definitions are declared in tools[], so names suffice here.
-func responsesToolSearchOutputText(item gjson.Result, names claudeToolNames) string {
+func responsesToolSearchOutputText(item ojson.Result, names claudeToolNames) string {
var discovered []string
for _, descriptor := range appendResponsesToolDescriptors(nil, item.Get("tools"), responsesDiscoveredToolPriority, true) {
discovered = append(discovered, names.claudeName(descriptor.name))
@@ -158,7 +163,7 @@ func responsesToolSearchOutputText(item gjson.Result, names claudeToolNames) str
// for each tool that only loads through search, so Claude expands the deferred
// definition at this point in the conversation. Tools that are already declared
// directly need no reference; when none qualify, the text summary is used.
-func responsesToolSearchOutputContent(item gjson.Result, names claudeToolNames, winners map[string]responsesToolDescriptor, nativeReferences bool) []byte {
+func responsesToolSearchOutputContent(item ojson.Result, names claudeToolNames, winners map[string]responsesToolDescriptor, nativeReferences bool) []byte {
if nativeReferences {
var references [][]byte
seen := make(map[string]struct{})
@@ -172,15 +177,15 @@ func responsesToolSearchOutputContent(item gjson.Result, names claudeToolNames,
continue
}
seen[claudeName] = struct{}{}
- reference, _ := sjson.SetBytes([]byte(`{"type":"tool_reference","tool_name":""}`), "tool_name", claudeName)
+ reference, _ := ojson.SetBytes([]byte(`{"type":"tool_reference","tool_name":""}`), "tool_name", claudeName)
references = append(references, reference)
}
if len(references) > 0 {
return common.JoinRawArray(references)
}
}
- text, _ := sjson.SetBytes([]byte(`{"text":""}`), "text", responsesToolSearchOutputText(item, names))
- return []byte(gjson.GetBytes(text, "text").Raw)
+ text, _ := ojson.SetBytes([]byte(`{"text":""}`), "text", responsesToolSearchOutputText(item, names))
+ return []byte(ojson.GetBytesNoCopy(text, "text").Raw)
}
// toolSearchCallStatus downgrades a completed call whose arguments are not a
@@ -193,30 +198,30 @@ func toolSearchCallStatus(status, arguments string) string {
}
func isJSONObject(raw string) bool {
- return gjson.Valid(raw) && gjson.Parse(raw).IsObject()
+ return ojson.Valid(raw) && ojson.Parse(raw).IsObject()
}
// buildResponsesToolSearchCallItem renders the tool_search_call item Codex
// parses: call_id, execution "client" and an arguments object.
func buildResponsesToolSearchCallItem(callID, status, arguments string) []byte {
- item := []byte(`{"id":"","type":"tool_search_call","status":"","call_id":"","execution":"client","arguments":{}}`)
- item, _ = sjson.SetBytes(item, "id", responsesToolSearchIDPrefix+callID)
- item, _ = sjson.SetBytes(item, "status", status)
- item, _ = sjson.SetBytes(item, "call_id", callID)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"tool_search_call","status":"","call_id":"","execution":"client","arguments":{}}`)
+ _ = itemDoc.Set("id", responsesToolSearchIDPrefix+callID)
+ _ = itemDoc.Set("status", status)
+ _ = itemDoc.Set("call_id", callID)
if isJSONObject(arguments) {
- item, _ = sjson.SetRawBytes(item, "arguments", []byte(arguments))
+ _ = itemDoc.SetRawBytes("arguments", []byte(arguments))
}
- return item
+ return itemDoc.Bytes()
}
// toolSearchCallEvent wraps a tool_search_call item in an output_item event.
// Responses has no argument delta events for it; Codex dispatches the search
// from output_item.done.
func toolSearchCallEvent(event string, sequence, outputIndex int, item []byte) []byte {
- payload := []byte(`{"type":"","sequence_number":0,"output_index":0,"item":{}}`)
- payload, _ = sjson.SetBytes(payload, "type", event)
- payload, _ = sjson.SetBytes(payload, "sequence_number", sequence)
- payload, _ = sjson.SetBytes(payload, "output_index", outputIndex)
- payload, _ = sjson.SetRawBytes(payload, "item", item)
- return emitEvent(event, payload)
+ payloadDoc := ojson.NewDocString(`{"type":"","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = payloadDoc.Set("type", event)
+ _ = payloadDoc.Set("sequence_number", sequence)
+ _ = payloadDoc.Set("output_index", outputIndex)
+ _ = payloadDoc.SetRawBytes("item", item)
+ return emitEvent(event, payloadDoc.Bytes())
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_disabled_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_disabled_test.go
index 971bbb4d2c9..7bbf020f32b 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_disabled_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_disabled_test.go
@@ -9,8 +9,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// The dev golden was produced by running renderToolSearchGolden against the
@@ -145,7 +145,7 @@ func TestToolSearchPinnedContextWinsOverSwitch(t *testing.T) {
var types []string
for _, chunk := range fixtures.Stream {
for _, output := range ConvertClaudeResponseToOpenAIResponses(ctx, "claude-test", session.Request, translated, []byte(chunk), ¶m) {
- if item := gjson.GetBytes(sseData(output), "item"); gjson.GetBytes(sseData(output), "type").String() == "response.output_item.done" {
+ if item := ojson.GetBytesNoCopy(sseData(output), "item"); ojson.GetBytesNoCopy(sseData(output), "type").String() == "response.output_item.done" {
types = append(types, item.Get("type").String())
}
}
@@ -154,12 +154,12 @@ func TestToolSearchPinnedContextWinsOverSwitch(t *testing.T) {
}
nonStreamFirstType := func(ctx context.Context, translated []byte) string {
out := ConvertClaudeResponseToOpenAIResponsesNonStream(ctx, "claude-test", session.Request, translated, []byte(strings.Join(fixtures.Stream, "\n")), nil)
- return gjson.GetBytes(out, "output.0.type").String()
+ return ojson.GetBytesNoCopy(out, "output.0.type").String()
}
setClientToolSearchEnabledForTest(t, false)
pinnedOn := WithClientToolSearch(context.Background(), true)
- if translated := translate(pinnedOn); !gjson.GetBytes(translated, `tools.#(name=="tool_search")`).Exists() {
+ if translated := translate(pinnedOn); !ojson.GetBytesNoCopy(translated, `tools.#(name=="tool_search")`).Exists() {
t.Fatalf("pinned-on request lost tool_search with the switch off: %s", translated)
}
if got := streamItemTypes(pinnedOn, translate(pinnedOn)); !strings.HasPrefix(got, "tool_search_call,") {
@@ -204,7 +204,7 @@ func TestToolSearchCompatContextFollowsPin(t *testing.T) {
if errConvert != nil {
t.Fatalf("convert: %v", errConvert)
}
- if got := gjson.GetBytes(out, `tools.#(name=="tool_search")`).Exists(); got != tc.pinned {
+ if got := ojson.GetBytesNoCopy(out, `tools.#(name=="tool_search")`).Exists(); got != tc.pinned {
t.Fatalf("global=%v pinned=%v: tool_search declared = %v", tc.global, tc.pinned, got)
}
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go
index f3ae872ed34..1766fab0843 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_tool_search_test.go
@@ -5,7 +5,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// Fixtures follow openai/codex: the tool_search spec from
@@ -37,18 +38,18 @@ func codexToolSearchRequest(items ...string) []byte {
`],"tool_choice":"auto","stream":true}`)
}
-func convertToolSearchRequest(t *testing.T, request []byte) gjson.Result {
+func convertToolSearchRequest(t *testing.T, request []byte) ojson.Result {
t.Helper()
out, err := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4-5", request, true)
if err != nil {
t.Fatalf("convert: %v", err)
}
- return gjson.ParseBytes(out)
+ return util.ParseGJSONBytesNoCopy(out)
}
-func claudeToolNamesOf(req gjson.Result) []string {
+func claudeToolNamesOf(req ojson.Result) []string {
var names []string
- req.Get("tools").ForEach(func(_, tool gjson.Result) bool {
+ req.Get("tools").ForEach(func(_, tool ojson.Result) bool {
names = append(names, tool.Get("name").String())
return true
})
@@ -171,9 +172,9 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolSearchHistoryReplaysAndActiva
func TestConvertOpenAIResponsesRequestToClaude_NamespacedFunctionCallHistoryIsQualified(t *testing.T) {
req := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn, codexSearchCall1, codexSearchOutput1, codexNSCall, codexNSCallOutput))
- var replayed gjson.Result
- req.Get("messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
+ var replayed ojson.Result
+ req.Get("messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("id").String() == "call_cal_1" {
replayed = block
}
@@ -222,7 +223,7 @@ var claudeToolSearchStream = []string{
`data: {"type":"message_stop"}`,
}
-func assertCodexToolSearchCall(t *testing.T, label string, item gjson.Result) {
+func assertCodexToolSearchCall(t *testing.T, label string, item ojson.Result) {
t.Helper()
if item.Get("type").String() != "tool_search_call" || item.Get("call_id").String() != "toolu_search" ||
item.Get("execution").String() != "client" || item.Get("status").String() != "completed" ||
@@ -234,7 +235,7 @@ func assertCodexToolSearchCall(t *testing.T, label string, item gjson.Result) {
}
}
-func assertOrdinaryToolCalls(t *testing.T, label string, calendar, exec gjson.Result) {
+func assertOrdinaryToolCalls(t *testing.T, label string, calendar, exec ojson.Result) {
t.Helper()
if calendar.Get("type").String() != "function_call" || calendar.Get("name").String() != "calendar_create_event" ||
calendar.Get("namespace").String() != "mcp__codex_apps__" || calendar.Get("arguments").String() != `{"title":"Standup"}` {
@@ -250,8 +251,8 @@ func TestConvertClaudeResponseToOpenAIResponses_ToolSearchBecomesToolSearchCall(
request := codexToolSearchRequest(codexUserTurn, codexSearchCall1, codexSearchOutput1)
var param any
- var added, done []gjson.Result
- var completed gjson.Result
+ var added, done []ojson.Result
+ var completed ojson.Result
for _, chunk := range claudeToolSearchStream {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", request, nil, []byte(chunk), ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -292,9 +293,9 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_ToolSearchBecomesToolSe
raw := []byte(strings.Join(claudeToolSearchStream, "\n"))
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", request, nil, raw, nil)
- output := gjson.GetBytes(out, "output").Array()
+ output := ojson.GetBytesNoCopy(out, "output").Array()
if len(output) != 3 {
- t.Fatalf("output = %s", gjson.GetBytes(out, "output").Raw)
+ t.Fatalf("output = %s", ojson.GetBytesNoCopy(out, "output").Raw)
}
assertCodexToolSearchCall(t, "non-stream", output[0])
assertOrdinaryToolCalls(t, "non-stream", output[1], output[2])
@@ -333,7 +334,7 @@ func TestConvertClaudeResponseToOpenAIResponses_TruncatedToolSearchIsIncomplete(
chunks = append(chunks, `data: {"type":"message_delta","delta":{"stop_reason":"`+tc.stopReason+`"},"usage":{"output_tokens":5}}`, `data: {"type":"message_stop"}`)
var param any
- var done, terminal gjson.Result
+ var done, terminal ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertClaudeResponseToOpenAIResponses(context.Background(), "claude-test", request, nil, []byte(chunk), ¶m) {
event, data := parseClaudeResponsesSSEEvent(t, output)
@@ -345,7 +346,7 @@ func TestConvertClaudeResponseToOpenAIResponses_TruncatedToolSearchIsIncomplete(
}
}
}
- for label, item := range map[string]gjson.Result{"done": done, "terminal": terminal} {
+ for label, item := range map[string]ojson.Result{"done": done, "terminal": terminal} {
if item.Get("type").String() != "tool_search_call" || item.Get("status").String() != "incomplete" ||
item.Get("arguments").Raw != tc.wantArguments {
t.Fatalf("%s item = %s, want incomplete tool_search_call with arguments %s", label, item.Raw, tc.wantArguments)
@@ -390,7 +391,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolSearchNameCollisionRoutesCons
}
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-test", request, nil, []byte(claudeCall), nil)
- if got := gjson.GetBytes(out, "output.0.type").String(); got != tc.wantType {
+ if got := ojson.GetBytesNoCopy(out, "output.0.type").String(); got != tc.wantType {
t.Fatalf("Claude call became %s, want %s: %s", got, tc.wantType, out)
}
})
@@ -411,7 +412,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolSearchNameCollisionRoutesCons
func TestConvertOpenAIResponsesRequestToClaude_ToolSearchOutputSkipsDirectlyDeclaredTools(t *testing.T) {
declaredOnly := `{"type":"tool_search_output","call_id":"call_search_1","status":"completed","execution":"client","tools":[` + codexExecCommandTool + `]}`
req := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn, codexSearchCall1, declaredOnly))
- if got := req.Get("messages.2.content.0.content"); got.Type != gjson.String || got.String() != "Tools now available to call: exec_command." {
+ if got := req.Get("messages.2.content.0.content"); got.Type != ojson.String || got.String() != "Tools now available to call: exec_command." {
t.Fatalf("declared-only output content = %s", got.Raw)
}
@@ -440,9 +441,9 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolSearchOutputDeduplicatesRefer
t.Fatalf("calendar declared %d times: %s", len(got), req.Get("tools").Raw)
}
var references []string
- req.Get("messages").ForEach(func(_, message gjson.Result) bool {
- message.Get("content").ForEach(func(_, block gjson.Result) bool {
- block.Get("content").ForEach(func(_, part gjson.Result) bool {
+ req.Get("messages").ForEach(func(_, message ojson.Result) bool {
+ message.Get("content").ForEach(func(_, block ojson.Result) bool {
+ block.Get("content").ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "tool_reference" && part.Get("tool_name").String() == "mcp__codex_apps__calendar_create_event" {
references = append(references, block.Get("tool_use_id").String())
}
@@ -460,7 +461,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ToolSearchOutputDeduplicatesRefer
func TestConvertOpenAIResponsesRequestToClaude_ToolSearchOutputWithoutToolsKeepsText(t *testing.T) {
empty := `{"type":"tool_search_output","call_id":"call_search_1","status":"completed","execution":"client","tools":[]}`
req := convertToolSearchRequest(t, codexToolSearchRequest(codexUserTurn, codexSearchCall1, empty))
- if got := req.Get("messages.2.content.0.content"); got.Type != gjson.String || got.String() != "No matching tools were found." {
+ if got := req.Get("messages.2.content.0.content"); got.Type != ojson.String || got.String() != "No matching tools were found." {
t.Fatalf("empty output content = %s", got.Raw)
}
}
@@ -557,6 +558,6 @@ func TestConvertOpenAIResponsesRequestToClaude_NoDeferredAnchorWithoutNativeTool
t.Fatal(err)
}
if !strings.Contains(string(out), responsesDeferredAnchorName) || strings.Contains(string(off), responsesDeferredAnchorName) {
- t.Fatalf("switch on/off: on=%s off=%s", gjson.GetBytes(out, "tools").Raw, gjson.GetBytes(off, "tools").Raw)
+ t.Fatalf("switch on/off: on=%s off=%s", ojson.GetBytesNoCopy(out, "tools").Raw, ojson.GetBytesNoCopy(off, "tools").Raw)
}
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_user_turn_test.go b/internal/translator/claude/openai/responses/claude_openai-responses_user_turn_test.go
index a2d40e851be..6355d1c3dc3 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_user_turn_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -89,7 +89,7 @@ func TestConvertOpenAIResponsesRequestToClaude_RefusesAnyEmptiedUserTurn(t *test
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
@@ -110,7 +110,7 @@ func TestConvertOpenAIResponsesRequestToClaude_KeepsTurnWithTextBesideAttachment
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "messages.2.content").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "messages.2.content").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
})
@@ -123,7 +123,7 @@ func TestConvertOpenAIResponsesRequestToClaude_InlineFileStaysADocument(t *testi
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "messages.2.content.0")
if part.Get("type").String() != "document" || part.Get("source.data").String() != "JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -134,7 +134,7 @@ func TestConvertOpenAIResponsesRequestToClaude_ExportedWrappersKeepAJSONBody(t *
plain, errPlain := ConvertOpenAIResponsesRequestToClaude("claude-sonnet-4", input, false)
compat, errCompat := ConvertOpenAIResponsesRequestToClaudeWithCompat("claude-sonnet-4", input, false)
for name, body := range map[string][]byte{"plain": plain, "compat": compat} {
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("%s: body is not JSON: %q", name, body)
}
}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go b/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go
index 8566d8a0153..b4696553152 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_web_search.go
@@ -1,12 +1,13 @@
package responses
import (
+ "context"
"regexp"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// Claude reports server-side web search as a pair of assistant content blocks: a
@@ -61,7 +62,7 @@ func claudeWebSearchQuery(input string) string {
if input == "" {
return ""
}
- return strings.TrimSpace(gjson.Get(input, "query").String())
+ return strings.TrimSpace(ojson.Get(input, "query").String())
}
// claudeWebSearchResultsToResponses converts the content of a Claude
@@ -71,7 +72,7 @@ func claudeWebSearchQuery(input string) string {
// Anthropic requires a genuine `encrypted_content` on every result it is asked
// to replay and rejects fabricated values, so anything we drop here can never be
// reconstructed later.
-func claudeWebSearchResultsToResponses(content gjson.Result) []byte {
+func claudeWebSearchResultsToResponses(content ojson.Result) []byte {
if content.IsObject() {
return []byte(content.Raw)
}
@@ -79,7 +80,7 @@ func claudeWebSearchResultsToResponses(content gjson.Result) []byte {
return nil
}
var results [][]byte
- content.ForEach(func(_, entry gjson.Result) bool {
+ content.ForEach(func(_, entry ojson.Result) bool {
if entry.Get("type").String() == "web_search_tool_result_error" || strings.TrimSpace(entry.Get("url").String()) != "" {
results = append(results, []byte(entry.Raw))
}
@@ -95,51 +96,97 @@ func claudeWebSearchResultsToResponses(content gjson.Result) []byte {
// Claude server-side search. results may be nil when the upstream turn ended
// before the result block arrived.
func buildResponsesWebSearchCallItem(claudeToolUseID, query string, results []byte) []byte {
- item := []byte(`{"id":"","type":"web_search_call","status":"completed","action":{"type":"search","query":""}}`)
- item, _ = sjson.SetBytes(item, "id", responsesWebSearchCallID(claudeToolUseID))
- item, _ = sjson.SetBytes(item, "action.query", query)
+ return buildResponsesWebSearchCallItemWithStatus(claudeToolUseID, query, "completed", results)
+}
+
+func buildResponsesWebSearchCallItemWithStatus(claudeToolUseID, query, status string, results []byte) []byte {
+ if status == "" {
+ status = "completed"
+ }
+ itemDoc := ojson.NewDocString(`{"id":"","type":"web_search_call","status":"completed","action":{"type":"search","query":""}}`)
+ _ = itemDoc.Set("id", responsesWebSearchCallID(claudeToolUseID))
+ _ = itemDoc.Set("status", status)
+ _ = itemDoc.Set("action.query", query)
if len(results) > 0 {
- item, _ = sjson.SetRawBytes(item, "results", results)
+ _ = itemDoc.SetRawBytes("results", results)
+ }
+ return itemDoc.Bytes()
+}
+
+type webSearchResultLookupContextKey struct{}
+
+// WithWebSearchResultLookup attaches a request-scoped lookup that restores
+// cached Claude web_search_tool_result content for a given server_tool_use id
+// when a Responses web_search_call item arrives without results.
+func WithWebSearchResultLookup(ctx context.Context, lookup func(toolUseID string) ([]byte, bool)) context.Context {
+ if lookup == nil {
+ return ctx
+ }
+ if ctx == nil {
+ ctx = context.Background()
}
- return item
+ return context.WithValue(ctx, webSearchResultLookupContextKey{}, lookup)
+}
+
+func lookupWebSearchResultFor(ctx context.Context, toolUseID string) []byte {
+ if ctx == nil || toolUseID == "" {
+ return nil
+ }
+ lookup, ok := ctx.Value(webSearchResultLookupContextKey{}).(func(string) ([]byte, bool))
+ if !ok || lookup == nil {
+ return nil
+ }
+ content, found := lookup(toolUseID)
+ if !found || len(content) == 0 {
+ return nil
+ }
+ return content
}
// convertResponsesWebSearchCallToClaudeBlocks is the inverse of
// buildResponsesWebSearchCallItem: it rebuilds the Claude block pair so a
// replayed turn still shows that the search happened and what it returned.
-func convertResponsesWebSearchCallToClaudeBlocks(item gjson.Result) [][]byte {
+func convertResponsesWebSearchCallToClaudeBlocks(item ojson.Result) [][]byte {
return convertResponsesWebSearchCallToClaudeBlocksWithQuery(item, "", true)
}
// convertResponsesWebSearchCallToClaudeBlocksWithQuery rebuilds the server_tool_use
// block, using fallbackQuery when the item carries none, and the result block
// only when withResult is set.
-func convertResponsesWebSearchCallToClaudeBlocksWithQuery(item gjson.Result, fallbackQuery string, withResult bool) [][]byte {
+func convertResponsesWebSearchCallToClaudeBlocksWithQuery(item ojson.Result, fallbackQuery string, withResult bool) [][]byte {
+ return convertResponsesWebSearchCallToClaudeBlocksWithContext(context.Background(), item, fallbackQuery, withResult)
+}
+
+func convertResponsesWebSearchCallToClaudeBlocksWithContext(ctx context.Context, item ojson.Result, fallbackQuery string, withResult bool) [][]byte {
toolUseID := claudeWebSearchToolUseID(strings.TrimSpace(item.Get("id").String()))
if toolUseID == "" {
return nil
}
- use := []byte(`{"type":"server_tool_use","id":"","name":"","input":{}}`)
- use, _ = sjson.SetBytes(use, "id", toolUseID)
- use, _ = sjson.SetBytes(use, "name", claudeWebSearchToolName)
+ useDoc := ojson.NewDocString(`{"type":"server_tool_use","id":"","name":"","input":{}}`)
+ _ = useDoc.Set("id", toolUseID)
+ _ = useDoc.Set("name", claudeWebSearchToolName)
query := responsesWebSearchCallQuery(item)
if query == "" {
query = fallbackQuery
}
if query != "" {
- use, _ = sjson.SetBytes(use, "input.query", query)
+ _ = useDoc.Set("input.query", query)
}
if !withResult {
- return [][]byte{use}
+ return [][]byte{useDoc.Bytes()}
}
- result := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
- result, _ = sjson.SetBytes(result, "tool_use_id", toolUseID)
- if content := responsesWebSearchResultsToClaude(item.Get("results")); len(content) > 0 {
- result, _ = sjson.SetRawBytes(result, "content", content)
+ resultDoc := ojson.NewDocString(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
+ _ = resultDoc.Set("tool_use_id", toolUseID)
+ content := responsesWebSearchResultsToClaude(item.Get("results"))
+ if len(content) == 0 {
+ content = lookupWebSearchResultFor(ctx, toolUseID)
}
- return [][]byte{use, result}
+ if len(content) > 0 {
+ _ = resultDoc.SetRawBytes("content", content)
+ }
+ return [][]byte{useDoc.Bytes(), resultDoc.Bytes()}
}
// responsesWebSearchHistory indexes web_search_call items of one request so a
@@ -162,7 +209,7 @@ type responsesWebSearchHistory struct {
pausedQuery map[int]string
}
-func responsesWebSearchItemIsPaused(item gjson.Result) bool {
+func responsesWebSearchItemIsPaused(item ojson.Result) bool {
return item.Get("type").String() == "web_search_call" &&
strings.EqualFold(strings.TrimSpace(item.Get("status").String()), "in_progress") &&
!item.Get("results").Exists()
@@ -170,7 +217,7 @@ func responsesWebSearchItemIsPaused(item gjson.Result) bool {
// responsesItemIsTurnBoundary reports a user turn. Tool outputs do not end the
// assistant turn: Anthropic runs a deferred search right after them.
-func responsesItemIsTurnBoundary(item gjson.Result) bool {
+func responsesItemIsTurnBoundary(item ojson.Result) bool {
typ := item.Get("type").String()
role := item.Get("role").String()
if typ == "" && role != "" {
@@ -179,7 +226,7 @@ func responsesItemIsTurnBoundary(item gjson.Result) bool {
return typ == "message" && role != "assistant"
}
-func scanResponsesWebSearchHistory(items []gjson.Result) responsesWebSearchHistory {
+func scanResponsesWebSearchHistory(items []ojson.Result) responsesWebSearchHistory {
history := responsesWebSearchHistory{
boundaryAfter: make([]bool, len(items)),
skip: map[int]bool{},
@@ -218,22 +265,26 @@ func scanResponsesWebSearchHistory(items []gjson.Result) responsesWebSearchHisto
return history
}
-func (h responsesWebSearchHistory) claudeBlocks(index int, item gjson.Result) [][]byte {
+func (h responsesWebSearchHistory) claudeBlocks(index int, item ojson.Result) [][]byte {
+ return h.claudeBlocksWithContext(context.Background(), index, item)
+}
+
+func (h responsesWebSearchHistory) claudeBlocksWithContext(ctx context.Context, index int, item ojson.Result) [][]byte {
if h.skip[index] {
return nil
}
query := h.pausedQuery[index]
tail := index >= len(h.boundaryAfter) || !h.boundaryAfter[index]
if responsesWebSearchItemIsPaused(item) && tail {
- return convertResponsesWebSearchCallToClaudeBlocksWithQuery(item, query, false)
+ return convertResponsesWebSearchCallToClaudeBlocksWithContext(ctx, item, query, false)
}
- return convertResponsesWebSearchCallToClaudeBlocksWithQuery(item, query, true)
+ return convertResponsesWebSearchCallToClaudeBlocksWithContext(ctx, item, query, true)
}
// responsesWebSearchCallQuery reads the query from a Responses web_search_call,
// tolerating the `queries` array and the `open_page` action shape that OpenAI
// clients emit.
-func responsesWebSearchCallQuery(item gjson.Result) string {
+func responsesWebSearchCallQuery(item ojson.Result) string {
if query := strings.TrimSpace(item.Get("action.query").String()); query != "" {
return query
}
@@ -243,7 +294,7 @@ func responsesWebSearchCallQuery(item gjson.Result) string {
return strings.TrimSpace(item.Get("action.url").String())
}
-func responsesWebSearchResultsToClaude(results gjson.Result) []byte {
+func responsesWebSearchResultsToClaude(results ojson.Result) []byte {
if results.IsObject() {
return []byte(results.Raw)
}
@@ -251,7 +302,7 @@ func responsesWebSearchResultsToClaude(results gjson.Result) []byte {
return nil
}
var blocks [][]byte
- results.ForEach(func(_, entry gjson.Result) bool {
+ results.ForEach(func(_, entry ojson.Result) bool {
if entry.Get("type").String() == "web_search_tool_result_error" {
blocks = append(blocks, []byte(entry.Raw))
return true
@@ -264,7 +315,7 @@ func responsesWebSearchResultsToClaude(results gjson.Result) []byte {
return true
}
block := []byte(entry.Raw)
- block, _ = sjson.SetBytes(block, "type", "web_search_result")
+ block, _ = ojson.SetBytes(block, "type", "web_search_result")
blocks = append(blocks, block)
return true
})
@@ -278,12 +329,12 @@ func responsesWebSearchResultsToClaude(results gjson.Result) []byte {
// block as `citations`. The annotations were copied verbatim from Claude on the
// way out, so they ride back unchanged; entries without the mandatory
// `encrypted_index` cannot be replayed and are dropped rather than rejected.
-func attachClaudeCitations(textBlock []byte, annotations gjson.Result) []byte {
+func attachClaudeCitations(textBlock []byte, annotations ojson.Result) []byte {
if !annotations.IsArray() {
return textBlock
}
var citations [][]byte
- annotations.ForEach(func(_, annotation gjson.Result) bool {
+ annotations.ForEach(func(_, annotation ojson.Result) bool {
if strings.TrimSpace(annotation.Get("encrypted_index").String()) != "" {
citations = append(citations, []byte(annotation.Raw))
}
@@ -292,9 +343,74 @@ func attachClaudeCitations(textBlock []byte, annotations gjson.Result) []byte {
if len(citations) == 0 {
return textBlock
}
- updated, err := sjson.SetRawBytes(textBlock, "citations", translatorcommon.JoinRawArray(citations))
+ updated, err := ojson.SetRawBytes(textBlock, "citations", translatorcommon.JoinRawArray(citations))
if err != nil {
return textBlock
}
return updated
}
+
+func claudeBlocksHaveEmptyWebSearchResult(blocks [][]byte) bool {
+ for _, raw := range blocks {
+ block := util.ParseGJSONBytesNoCopy(raw)
+ if block.Get("type").String() != "web_search_tool_result" {
+ continue
+ }
+ content := block.Get("content")
+ if !content.Exists() || content.Type == ojson.Null || (content.IsArray() && len(content.Array()) == 0) {
+ return true
+ }
+ }
+ return false
+}
+
+// stripClaudeWebSearchCitationsFromParts removes web_search_result_location
+// citations from text blocks when a web_search_tool_result in the same
+// assistant turn ended up without content. Anthropic intermittently rejects
+// citations whose encrypted_index has no matching search result in the turn.
+func stripClaudeWebSearchCitationsFromParts(parts [][]byte) ([][]byte, int) {
+ droppedTotal := 0
+ for i, raw := range parts {
+ part := util.ParseGJSONBytesNoCopy(raw)
+ if part.Get("type").String() != "text" {
+ continue
+ }
+ updated, dropped := stripClaudeWebSearchCitationsFromTextBlock(raw, part.Get("citations"))
+ if dropped > 0 {
+ parts[i] = updated
+ droppedTotal += dropped
+ }
+ }
+ return parts, droppedTotal
+}
+
+func stripClaudeWebSearchCitationsFromTextBlock(textBlock []byte, citations ojson.Result) ([]byte, int) {
+ if !citations.Exists() || !citations.IsArray() {
+ return textBlock, 0
+ }
+ var kept [][]byte
+ dropped := 0
+ citations.ForEach(func(_, citation ojson.Result) bool {
+ if citation.Get("type").String() == "web_search_result_location" {
+ dropped++
+ return true
+ }
+ kept = append(kept, []byte(citation.Raw))
+ return true
+ })
+ if dropped == 0 {
+ return textBlock, 0
+ }
+ if len(kept) == 0 {
+ updated, errDelete := ojson.DeleteBytes(textBlock, "citations")
+ if errDelete != nil {
+ return textBlock, 0
+ }
+ return updated, dropped
+ }
+ updated, errSet := ojson.SetRawBytes(textBlock, "citations", translatorcommon.JoinRawArray(kept))
+ if errSet != nil {
+ return textBlock, 0
+ }
+ return updated, dropped
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai_native_response_test.go b/internal/translator/claude/openai/responses/claude_openai_native_response_test.go
index bf9b93facf8..eec64eb7ea2 100644
--- a/internal/translator/claude/openai/responses/claude_openai_native_response_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai_native_response_test.go
@@ -5,7 +5,8 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertClaudeResponseToOpenAIResponsesNonStream_NativeMessagesJSON(t *testing.T) {
@@ -24,10 +25,10 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_NativeMessagesJSON(t *t
raw := []byte(fmt.Sprintf(`{"id":"msg_native","type":"message","role":"assistant","model":"claude-sonnet-4-6","content":%s,"stop_reason":%q,"stop_sequence":null,"usage":{"input_tokens":13,"cache_read_input_tokens":7,"cache_creation_input_tokens":3,"output_tokens":5}}`, tt.content, tt.stopReason))
request := []byte(`{"model":"claude-sonnet-4-6"}`)
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "claude-sonnet-4-6", request, request, raw, nil)
- if !gjson.ValidBytes(out) {
+ if !ojson.ValidBytes(out) {
t.Fatalf("invalid response JSON: %s", out)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
for path, want := range map[string]string{
"id": "msg_native",
"object": "response",
@@ -57,7 +58,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_NativeMessagesJSON(t *t
if got := root.Get("incomplete_details.reason").String(); got != "max_output_tokens" {
t.Errorf("incomplete reason = %q, want max_output_tokens", got)
}
- } else if got := root.Get("incomplete_details"); !got.Exists() || got.Type != gjson.Null {
+ } else if got := root.Get("incomplete_details"); !got.Exists() || got.Type != ojson.Null {
t.Errorf("incomplete_details = %s, want null", got.Raw)
}
if !tt.tools {
@@ -77,11 +78,11 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_NativeMessagesJSON(t *t
t.Errorf("%s = %q, want %q", path, got, want)
}
}
- args := gjson.Parse(root.Get("output.1.arguments").String())
+ args := ojson.Parse(root.Get("output.1.arguments").String())
if !args.IsObject() || len(args.Map()) != 2 || args.Get("city").String() != "Paris" || args.Get("days").Int() != 2 {
t.Errorf("weather arguments not preserved: %s", root.Get("output.1.arguments").Raw)
}
- emptyArgs := gjson.Parse(root.Get("output.2.arguments").String())
+ emptyArgs := ojson.Parse(root.Get("output.2.arguments").String())
if !emptyArgs.IsObject() || len(emptyArgs.Map()) != 0 {
t.Errorf("empty arguments not preserved: %s", root.Get("output.2.arguments").Raw)
}
@@ -92,7 +93,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStream_NativeMessagesJSON(t *t
func TestNativeMessagesJSONCitations(t *testing.T) {
raw := []byte(`{"id":"msg_citations","type":"message","model":"claude-native","content":[{"type":"text","text":"Answer.","citations":[{"type":"web_search_result_location","url":"https://example.com","title":"Source","cited_text":"Answer.","encrypted_index":"IDX"}]}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}`)
out := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "", nil, nil, raw, nil)
- annotations := gjson.GetBytes(out, "output.0.content.0.annotations")
+ annotations := ojson.GetBytesNoCopy(out, "output.0.content.0.annotations")
if len(annotations.Array()) != 1 || annotations.Get("0.url").String() != "https://example.com" || annotations.Get("0.encrypted_index").String() != "IDX" {
t.Fatalf("citations not preserved: %s", out)
}
diff --git a/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go b/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go
index cc5c993e288..9c33f33323b 100644
--- a/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go
+++ b/internal/translator/claude/openai/responses/claude_openai_responses_compat_test.go
@@ -3,26 +3,26 @@ package responses
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponsesRequestToClaudeWithCompatPreservesEmptyReasoning(t *testing.T) {
payload := []byte(`{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}],"encrypted_content":""}]}`)
withoutCompat, _ := ConvertOpenAIResponsesRequestToClaude("deepseek-v4", payload, false)
- if gjson.GetBytes(withoutCompat, "messages.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(withoutCompat, "messages.#").Int() != 0 {
t.Fatalf("default translation preserved empty reasoning: %s", withoutCompat)
}
withCompat, _ := ConvertOpenAIResponsesRequestToClaudeWithCompat("deepseek-v4", payload, false)
- part := gjson.GetBytes(withCompat, "messages.0.content.0")
+ part := ojson.GetBytesNoCopy(withCompat, "messages.0.content.0")
if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" {
t.Fatalf("compat translation missing unsigned thinking block: %s", withCompat)
}
opaquePayload := []byte(`{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"reason"}],"encrypted_content":"opaque-deepseek-id"}]}`)
opaqueCompat, _ := ConvertOpenAIResponsesRequestToClaudeWithCompat("deepseek-v4", opaquePayload, false)
- opaquePart := gjson.GetBytes(opaqueCompat, "messages.0.content.0")
+ opaquePart := ojson.GetBytesNoCopy(opaqueCompat, "messages.0.content.0")
if opaquePart.Get("type").String() != "thinking" || opaquePart.Get("thinking").String() != "reason" || opaquePart.Get("signature").String() != "opaque-deepseek-id" {
t.Fatalf("compat translation dropped invalid-signature thinking block: %s", opaqueCompat)
}
diff --git a/internal/translator/claude/openai/responses/echo_perf_test.go b/internal/translator/claude/openai/responses/echo_perf_test.go
index a6d593b5ea0..7890c6b2c80 100644
--- a/internal/translator/claude/openai/responses/echo_perf_test.go
+++ b/internal/translator/claude/openai/responses/echo_perf_test.go
@@ -10,7 +10,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func goldenHash(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
@@ -277,7 +277,7 @@ func TestClaudeEchoGoldenEdgeCases(t *testing.T) {
names func(req []byte) []string
}{
{"long-and-invalid-names", []byte(`{"model":"gpt-5","tools":` + toolsJSON + `,"input":[]}`), false, func(req []byte) []string {
- names := buildClaudeToolNames(gjson.ParseBytes(req), false)
+ names := buildClaudeToolNames(util.ParseGJSONBytesNoCopy(req), false)
return []string{names.claudeName(longName), names.claudeName(dotted), names.claudeName(collideA), names.claudeName(collideB), "editor__read"}
}},
{"toolsearch-on", []byte(`{"model":"gpt-5","tools":` + searchTools + `,"input":[]}`), true, func(req []byte) []string {
diff --git a/internal/translator/claude/openai/responses/noop_optimization_test.go b/internal/translator/claude/openai/responses/noop_optimization_test.go
index 81ebb50519f..30ef4b23837 100644
--- a/internal/translator/claude/openai/responses/noop_optimization_test.go
+++ b/internal/translator/claude/openai/responses/noop_optimization_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeResponseToOpenAIResponsesNonStreamKeepsZeroUsageDefaults(t *testing.T) {
@@ -13,7 +13,7 @@ func TestConvertClaudeResponseToOpenAIResponsesNonStreamKeepsZeroUsageDefaults(t
output := ConvertClaudeResponseToOpenAIResponsesNonStream(context.Background(), "", nil, nil, input, nil)
for _, path := range []string{"usage.input_tokens", "usage.input_tokens_details.cached_tokens", "usage.output_tokens", "usage.total_tokens"} {
- value := gjson.GetBytes(output, path)
+ value := ojson.GetBytesNoCopy(output, path)
if !value.Exists() || value.Int() != 0 {
t.Fatalf("%s = %s, want zero", path, value.Raw)
}
diff --git a/internal/translator/codex/claude/codex_claude_commentary_cross_format_test.go b/internal/translator/codex/claude/codex_claude_commentary_cross_format_test.go
index 3a07c4ed3f4..91a61cff8d1 100644
--- a/internal/translator/codex/claude/codex_claude_commentary_cross_format_test.go
+++ b/internal/translator/codex/claude/codex_claude_commentary_cross_format_test.go
@@ -4,11 +4,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
antigravityclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/antigravity/claude"
geminiclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/claude"
interactionsclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/interactions/claude"
openaiclaude "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/openai/claude"
- "github.com/tidwall/gjson"
)
// A client may replay CPA-relayed commentary history to a non-Codex upstream.
@@ -36,13 +36,13 @@ func assertCommentaryRestoredAsText(t *testing.T, name string, out []byte, err e
func TestCommentaryMarkerRestoredByOpenAIChatTranslator(t *testing.T) {
out, err := openaiclaude.ConvertClaudeRequestToOpenAI("test-model", []byte(crossFormatCommentaryPayload), false)
assertCommentaryRestoredAsText(t, "openai default", out, err)
- if strings.Contains(string(out), "reasoning_content") && strings.Contains(string(out), "COMMENTARY_SENTINEL") && gjson.GetBytes(out, "messages.1.reasoning_content").String() == "COMMENTARY_SENTINEL" {
+ if strings.Contains(string(out), "reasoning_content") && strings.Contains(string(out), "COMMENTARY_SENTINEL") && ojson.GetBytesNoCopy(out, "messages.1.reasoning_content").String() == "COMMENTARY_SENTINEL" {
t.Fatalf("openai default: commentary must not become reasoning_content: %s", out)
}
out, err = openaiclaude.ConvertClaudeRequestToOpenAIWithCompat("test-model", []byte(crossFormatCommentaryPayload), false)
assertCommentaryRestoredAsText(t, "openai compat", out, err)
- if got := gjson.GetBytes(out, "messages.1.reasoning_content").String(); got == "COMMENTARY_SENTINEL" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.reasoning_content").String(); got == "COMMENTARY_SENTINEL" {
t.Fatalf("openai compat: commentary must not become reasoning_content: %s", out)
}
}
@@ -51,7 +51,7 @@ func TestCommentaryMarkerRestoredByGeminiTranslator(t *testing.T) {
out, err := geminiclaude.ConvertClaudeRequestToGemini("test-model", []byte(crossFormatCommentaryPayload), false)
assertCommentaryRestoredAsText(t, "gemini default", out, err)
if strings.Contains(string(out), `"thought":true`) && strings.Contains(string(out), "COMMENTARY_SENTINEL") {
- for _, part := range gjson.GetBytes(out, "contents.1.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(out, "contents.1.parts").Array() {
if part.Get("text").String() == "COMMENTARY_SENTINEL" && part.Get("thought").Bool() {
t.Fatalf("gemini default: commentary must not become a thought part: %s", out)
}
@@ -60,7 +60,7 @@ func TestCommentaryMarkerRestoredByGeminiTranslator(t *testing.T) {
out, err = geminiclaude.ConvertClaudeRequestToGeminiWithCompat("test-model", []byte(crossFormatCommentaryPayload), false)
assertCommentaryRestoredAsText(t, "gemini compat", out, err)
- for _, content := range gjson.GetBytes(out, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(out, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("text").String() == "COMMENTARY_SENTINEL" && part.Get("thought").Bool() {
t.Fatalf("gemini compat: commentary must not become a thought part: %s", out)
@@ -86,7 +86,7 @@ func TestCommentaryMarkerRestoredByInteractionsTranslator(t *testing.T) {
func TestCommentaryMarkerRestoredByAntigravityTranslator(t *testing.T) {
out, err := antigravityclaude.ConvertClaudeRequestToAntigravity("test-model", []byte(crossFormatCommentaryPayload), false)
assertCommentaryRestoredAsText(t, "antigravity", out, err)
- for _, content := range gjson.GetBytes(out, "request.contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(out, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("text").String() == "COMMENTARY_SENTINEL" && part.Get("thought").Bool() {
t.Fatalf("antigravity: commentary must not become a thought part: %s", out)
diff --git a/internal/translator/codex/claude/codex_claude_commentary_phase_test.go b/internal/translator/codex/claude/codex_claude_commentary_phase_test.go
index aae06925047..9d93ca997fe 100644
--- a/internal/translator/codex/claude/codex_claude_commentary_phase_test.go
+++ b/internal/translator/codex/claude/codex_claude_commentary_phase_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// GPT-5.x models answer with two message output items: a "commentary" preamble
@@ -35,7 +35,7 @@ func collectCodexClaudeBlocks(t *testing.T, chunks [][]byte) codexClaudeBlocks {
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
curType = data.Get("content_block.type").String()
@@ -231,7 +231,7 @@ func TestConvertCodexResponseToClaudeNonStream_CommentaryPhaseIsThinking(t *test
],"usage":{"input_tokens":1,"output_tokens":1}}}`)
var param any
out := ConvertCodexResponseToClaudeNonStream(context.Background(), "", []byte(`{"messages":[]}`), nil, raw, ¶m)
- content := gjson.GetBytes(out, "content").Array()
+ content := ojson.GetBytesNoCopy(out, "content").Array()
if len(content) != 2 {
t.Fatalf("expected 2 content blocks, got %s", out)
}
diff --git a/internal/translator/codex/claude/codex_claude_commentary_replay_test.go b/internal/translator/codex/claude/codex_claude_commentary_replay_test.go
index 739ff51c020..ae7bdd8f3a9 100644
--- a/internal/translator/codex/claude/codex_claude_commentary_replay_test.go
+++ b/internal/translator/codex/claude/codex_claude_commentary_replay_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// The internal commentary marker is an implementation contract between the
@@ -20,7 +20,7 @@ func TestConvertCodexResponseToClaudeNonStream_CommentaryCarriesInternalMarker(t
],"usage":{"input_tokens":1,"output_tokens":1}}}`)
var param any
out := ConvertCodexResponseToClaudeNonStream(context.Background(), "", []byte(`{"messages":[]}`), nil, raw, ¶m)
- content := gjson.GetBytes(out, "content").Array()
+ content := ojson.GetBytesNoCopy(out, "content").Array()
if len(content) != 2 {
t.Fatalf("expected 2 content blocks, got %s", out)
}
@@ -78,7 +78,7 @@ func TestConvertClaudeRequestToCodex_CommentaryMarkerRestoresCommentaryMessage(t
if strings.Contains(string(result), testCommentaryMarkerSignature) {
t.Fatalf("stream=%v: internal marker leaked into upstream request: %s", stream, result)
}
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
if len(inputs) != 4 {
t.Fatalf("stream=%v: got %d input items, want 4: %s", stream, len(inputs), result)
}
@@ -124,7 +124,7 @@ func TestConvertClaudeRequestToCodex_CommentaryMarkerBeforeToolUseSurvivesToolRo
if strings.Contains(string(result), testCommentaryMarkerSignature) {
t.Fatalf("internal marker leaked into upstream request: %s", result)
}
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
if len(inputs) != 4 {
t.Fatalf("got %d input items, want 4: %s", len(inputs), result)
}
@@ -146,7 +146,7 @@ func TestConvertClaudeRequestToCodex_CommentaryMarkerRoundTripFromResponse(t *te
],"usage":{"input_tokens":1,"output_tokens":1}}}`)
var param any
claudeResponse := ConvertCodexResponseToClaudeNonStream(context.Background(), "", []byte(`{"messages":[]}`), nil, raw, ¶m)
- content := gjson.GetBytes(claudeResponse, "content").Raw
+ content := ojson.GetBytesNoCopy(claudeResponse, "content").Raw
replay := []byte(`{"model":"gpt-6.1-sol","messages":[{"role":"user","content":"First turn"},{"role":"assistant","content":` + content + `},{"role":"user","content":"Next turn"}]}`)
result, errConvert := ConvertClaudeRequestToCodex("gpt-6.1-sol", replay, false)
@@ -171,7 +171,7 @@ func TestConvertClaudeRequestToCodexWithCompat_CommentaryMarkerRestoresTextNotRe
if strings.Contains(string(result), testCommentaryMarkerSignature) {
t.Fatalf("internal marker leaked into upstream request: %s", result)
}
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
if len(inputs) != 1 {
t.Fatalf("got %d input items, want 1: %s", len(inputs), result)
}
diff --git a/internal/translator/codex/claude/codex_claude_compat_test.go b/internal/translator/codex/claude/codex_claude_compat_test.go
index 51fb1365cca..fd7a402fcc1 100644
--- a/internal/translator/codex/claude/codex_claude_compat_test.go
+++ b/internal/translator/codex/claude/codex_claude_compat_test.go
@@ -3,22 +3,22 @@ package claude
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeRequestToCodexWithCompatPreservesEmptyThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
withoutCompat, _ := ConvertClaudeRequestToCodex("deepseek-v4", payload, false)
- if gjson.GetBytes(withoutCompat, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(withoutCompat, "input.#").Int() != 0 {
t.Fatalf("default translation preserved empty-signature thinking: %s", withoutCompat)
}
withCompat, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", payload, false)
- if !gjson.GetBytes(withCompat, "input.0.type").Exists() || gjson.GetBytes(withCompat, "input.0.type").String() != "reasoning" {
+ if !ojson.GetBytesNoCopy(withCompat, "input.0.type").Exists() || ojson.GetBytesNoCopy(withCompat, "input.0.type").String() != "reasoning" {
t.Fatalf("compat translation missing reasoning item: %s", withCompat)
}
- if !gjson.GetBytes(withCompat, "input.0.encrypted_content").Exists() {
+ if !ojson.GetBytesNoCopy(withCompat, "input.0.encrypted_content").Exists() {
t.Fatalf("compat translation missing empty encrypted_content: %s", withCompat)
}
}
@@ -28,60 +28,60 @@ func TestConvertClaudeRequestToCodexWithCompat_UnknownThinkingSignatures(t *test
unknownPayload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + unknownSig + `"}]}]}`)
withCompatNonStream, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", unknownPayload, false)
- if gjson.GetBytes(withCompatNonStream, "input.0.type").String() != "reasoning" || gjson.GetBytes(withCompatNonStream, "input.0.encrypted_content").String() != unknownSig {
+ if ojson.GetBytesNoCopy(withCompatNonStream, "input.0.type").String() != "reasoning" || ojson.GetBytesNoCopy(withCompatNonStream, "input.0.encrypted_content").String() != unknownSig {
t.Fatalf("compat non-stream translation failed to preserve unknown signature: %s", withCompatNonStream)
}
withCompatStream, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", unknownPayload, true)
- if gjson.GetBytes(withCompatStream, "input.0.type").String() != "reasoning" || gjson.GetBytes(withCompatStream, "input.0.encrypted_content").String() != unknownSig {
+ if ojson.GetBytesNoCopy(withCompatStream, "input.0.type").String() != "reasoning" || ojson.GetBytesNoCopy(withCompatStream, "input.0.encrypted_content").String() != unknownSig {
t.Fatalf("compat stream translation failed to preserve unknown signature: %s", withCompatStream)
}
withoutCompatNonStream, _ := ConvertClaudeRequestToCodex("deepseek-v4", unknownPayload, false)
- if gjson.GetBytes(withoutCompatNonStream, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(withoutCompatNonStream, "input.#").Int() != 0 {
t.Fatalf("non-compat translation unexpectedly preserved unknown signature: %s", withoutCompatNonStream)
}
withoutCompatStream, _ := ConvertClaudeRequestToCodex("deepseek-v4", unknownPayload, true)
- if gjson.GetBytes(withoutCompatStream, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(withoutCompatStream, "input.#").Int() != 0 {
t.Fatalf("non-compat stream translation unexpectedly preserved unknown signature: %s", withoutCompatStream)
}
const claudeSig = "CAISqwIKiAEIEBgCKkBHRlRBsNiptQUWfPoOhuQKwi5LnncZVO9bB5jqOs76D7uBtgktML0zqJtNmLHXHHcgD6lk4MQu4QBXzFd1lbC3Mg5jbGF1ZGUtZmFibGUtNTgBQgh0aGlua2luZ1okZDk3NDM5NzUtNGJiMC00OTM2LTllMjgtZDViMGQyMWJkYzQ4EgxCGh+XVFFFeySAjtAaDL/A1LltGu6MMJ+eXSIwsN0oBpDrqLv22UBfkMnTotnIbkvkOyb9xZHgigG6OZVHaI3gThm+maLKmgO5PrFLKlDFYp+YZksy/wKwszJlnLTPzAK+NUlfzagOE1ymtZTXhAYK260XyFYmg/te/C231+Fr/hoX+EJoUBnrn0gD7hqMISOT+TaFEuOXYsN517GfaxgB"
claudePayload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + claudeSig + `"}]}]}`)
claudeCompat, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", claudePayload, false)
- if gjson.GetBytes(claudeCompat, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(claudeCompat, "input.#").Int() != 0 {
t.Fatalf("compat translation unexpectedly preserved valid Claude signature: %s", claudeCompat)
}
gptRawSig := validCodexReasoningSignature()
gptPayload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"gpt#` + gptRawSig + `"}]}]}`)
gptCompat, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", gptPayload, false)
- if gjson.GetBytes(gptCompat, "input.0.type").String() != "reasoning" || gjson.GetBytes(gptCompat, "input.0.encrypted_content").String() != gptRawSig {
+ if ojson.GetBytesNoCopy(gptCompat, "input.0.type").String() != "reasoning" || ojson.GetBytesNoCopy(gptCompat, "input.0.encrypted_content").String() != gptRawSig {
t.Fatalf("compat translation failed to normalize gpt# signature: %s", gptCompat)
}
nonStringPayloadInt := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":12345}]}]}`)
nonStringCompatInt, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", nonStringPayloadInt, false)
- if gjson.GetBytes(nonStringCompatInt, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(nonStringCompatInt, "input.#").Int() != 0 {
t.Fatalf("compat translation unexpectedly preserved non-string int signature: %s", nonStringCompatInt)
}
nonStringPayloadObj := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":{"opaque":"data"}}]}]}`)
nonStringCompatObj, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", nonStringPayloadObj, false)
- if gjson.GetBytes(nonStringCompatObj, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(nonStringCompatObj, "input.#").Int() != 0 {
t.Fatalf("compat translation unexpectedly preserved non-string object signature: %s", nonStringCompatObj)
}
nonStringPayloadBool := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":true}]}]}`)
nonStringCompatBool, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", nonStringPayloadBool, false)
- if gjson.GetBytes(nonStringCompatBool, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(nonStringCompatBool, "input.#").Int() != 0 {
t.Fatalf("compat translation unexpectedly preserved non-string bool signature: %s", nonStringCompatBool)
}
nonStringPayloadArr := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":["arr"]}]}]}`)
nonStringCompatArr, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", nonStringPayloadArr, false)
- if gjson.GetBytes(nonStringCompatArr, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(nonStringCompatArr, "input.#").Int() != 0 {
t.Fatalf("compat translation unexpectedly preserved non-string array signature: %s", nonStringCompatArr)
}
}
@@ -91,16 +91,16 @@ func TestConvertClaudeRequestToCodexWithCompat_PreservesMessageFlushingAndEscape
escapedPayload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"text","text":"before"},{"type":"thinking","thinking":"reason","signature":"enc:\"token\"\\with\\unicode-\u4e00"},{"type":"text","text":"after"}]}]}`)
withCompat, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", escapedPayload, false)
- if gjson.GetBytes(withCompat, "input.#").Int() != 3 {
+ if ojson.GetBytesNoCopy(withCompat, "input.#").Int() != 3 {
t.Fatalf("compat translation expected 3 items after message flush, got: %s", withCompat)
}
- if gjson.GetBytes(withCompat, "input.0.role").String() != "assistant" || gjson.GetBytes(withCompat, "input.0.content.0.text").String() != "before" {
+ if ojson.GetBytesNoCopy(withCompat, "input.0.role").String() != "assistant" || ojson.GetBytesNoCopy(withCompat, "input.0.content.0.text").String() != "before" {
t.Fatalf("first item unexpected: %s", withCompat)
}
- if gjson.GetBytes(withCompat, "input.1.type").String() != "reasoning" || gjson.GetBytes(withCompat, "input.1.encrypted_content").String() != escapedSig {
+ if ojson.GetBytesNoCopy(withCompat, "input.1.type").String() != "reasoning" || ojson.GetBytesNoCopy(withCompat, "input.1.encrypted_content").String() != escapedSig {
t.Fatalf("second item unexpected reasoning signature: %s", withCompat)
}
- if gjson.GetBytes(withCompat, "input.2.role").String() != "assistant" || gjson.GetBytes(withCompat, "input.2.content.0.text").String() != "after" {
+ if ojson.GetBytesNoCopy(withCompat, "input.2.role").String() != "assistant" || ojson.GetBytesNoCopy(withCompat, "input.2.content.0.text").String() != "after" {
t.Fatalf("third item unexpected: %s", withCompat)
}
}
@@ -108,13 +108,13 @@ func TestConvertClaudeRequestToCodexWithCompat_PreservesMessageFlushingAndEscape
func TestConvertClaudeRequestToCodexWithCompat_WhitespaceAndNullSignatures(t *testing.T) {
whitespacePayload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":" "}]}]}`)
whitespaceCompat, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", whitespacePayload, false)
- if gjson.GetBytes(whitespaceCompat, "input.0.type").String() != "reasoning" || gjson.GetBytes(whitespaceCompat, "input.0.encrypted_content").String() != " " {
+ if ojson.GetBytesNoCopy(whitespaceCompat, "input.0.type").String() != "reasoning" || ojson.GetBytesNoCopy(whitespaceCompat, "input.0.encrypted_content").String() != " " {
t.Fatalf("compat translation failed to preserve whitespace signature via empty fallback: %s", whitespaceCompat)
}
nullPayload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":null}]}]}`)
nullCompat, _ := ConvertClaudeRequestToCodexWithCompat("deepseek-v4", nullPayload, false)
- if gjson.GetBytes(nullCompat, "input.0.type").String() != "reasoning" || gjson.GetBytes(nullCompat, "input.0.encrypted_content").String() != "" {
+ if ojson.GetBytesNoCopy(nullCompat, "input.0.type").String() != "reasoning" || ojson.GetBytesNoCopy(nullCompat, "input.0.encrypted_content").String() != "" {
t.Fatalf("compat translation failed to preserve null signature via empty fallback: %s", nullCompat)
}
}
diff --git a/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go b/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go
index b92fd52a8c4..40c9dbcef05 100644
--- a/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go
+++ b/internal/translator/codex/claude/codex_claude_parallel_function_calls_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
type codexClaudeContentBlock struct {
@@ -41,7 +41,7 @@ func assertCodexClaudeContentBlockLifecycle(t *testing.T, outputs [][]byte) []*c
if !strings.HasPrefix(line, "data: ") {
continue
}
- event := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ event := ojson.Parse(strings.TrimPrefix(line, "data: "))
if messageState == 2 {
t.Fatalf("event emitted after message_stop: %s", event.Raw)
}
diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go
index 5842e509cf8..00fee4af765 100644
--- a/internal/translator/codex/claude/codex_claude_request.go
+++ b/internal/translator/codex/claude/codex_claude_request.go
@@ -7,19 +7,20 @@ package claude
import (
"bytes"
+ "context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
+ "regexp"
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ConvertClaudeRequestToCodex parses and transforms a Claude Code API request into the internal client format.
@@ -42,30 +43,45 @@ import (
// Returns:
// - []byte: The transformed request data in internal client format
func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, false)
+ return ConvertClaudeRequestToCodexContext(context.Background(), modelName, inputRawJSON, stream)
}
// ConvertClaudeRequestToCodexWithCompat preserves assistant thinking blocks with
// empty or unknown-format signatures for configured compatibility endpoints.
func ConvertClaudeRequestToCodexWithCompat(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, true)
+ return ConvertClaudeRequestToCodexWithCompatContext(context.Background(), modelName, inputRawJSON, stream)
}
-func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) ([]byte, error) {
+func ConvertClaudeRequestToCodexContext(ctx context.Context, modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
+ return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, false, translatorcommon.VisibleCodexCommentary(ctx))
+}
+
+func ConvertClaudeRequestToCodexWithCompatContext(ctx context.Context, modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
+ return convertClaudeRequestToCodex(modelName, inputRawJSON, stream, true, translatorcommon.VisibleCodexCommentary(ctx))
+}
+
+func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks, visibleCommentary bool) ([]byte, error) {
rawJSON := inputRawJSON
var drops translatorcommon.UserTurnDrops
- template := []byte(`{"model":"","instructions":"","input":[]}`)
+ templateDoc := ojson.NewDocString(`{"model":"","instructions":"","input":[]}`)
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
mayHaveToolAddition := claudePayloadMayContainToolAddition(rawJSON)
toolNameMap := buildReverseMapFromClaudeOriginalToShortFromResult(rootResult, mayHaveToolAddition)
signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, rawJSON)
- template, _ = sjson.SetBytes(template, "model", modelName)
+ _ = templateDoc.Set("model", modelName)
+ maxOutputTokens := rootResult.Get("max_tokens")
+ if !maxOutputTokens.Exists() {
+ maxOutputTokens = rootResult.Get("max_output_tokens")
+ }
+ if maxOutputTokens.Type == ojson.Number {
+ _ = templateDoc.Set("max_output_tokens", maxOutputTokens.Int())
+ }
inputItems := translatorcommon.NewRawArrayItems(rootResult.Get("messages.#").Int())
toolsResult := rootResult.Get("tools")
- var declaredToolsByName map[string]gjson.Result
+ var declaredToolsByName map[string]ojson.Result
declaredToolsIndexed := false
hasAdditionalTools := false
@@ -80,11 +96,11 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}
content := []byte(`{"type":"input_text","text":""}`)
- content, _ = sjson.SetBytes(content, "text", text)
+ content, _ = ojson.SetBytes(content, "text", text)
contentItems = append(contentItems, content)
}
- if systemsResult.Type == gjson.String {
+ if systemsResult.Type == ojson.String {
appendSystemText(systemsResult.String())
} else if systemsResult.IsArray() {
systemResults := systemsResult.Array()
@@ -98,7 +114,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
if len(contentItems) > 0 {
message := []byte(`{"type":"message","role":"developer"}`)
- message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems))
+ message, _ = ojson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems))
inputItems = append(inputItems, message)
}
}
@@ -109,15 +125,19 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
messageResults := messagesResult.Array()
var pendingToolUseIDs []string
var pendingSystemReminders [][]byte
+ var pendingCommentaryMirror string
for i := 0; i < len(messageResults); i++ {
messageResult := messageResults[i]
messageRole := messageResult.Get("role").String()
+ if messageRole != "assistant" {
+ pendingCommentaryMirror = ""
+ }
if messageRole == "system" {
systemContent := messageResult.Get("content")
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(systemContent); ok {
message := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)
- message, _ = sjson.SetBytes(message, "content.0.text", reminderText)
+ message, _ = ojson.SetBytes(message, "content.0.text", reminderText)
if len(pendingToolUseIDs) > 0 {
pendingSystemReminders = append(pendingSystemReminders, message)
} else {
@@ -150,41 +170,50 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
if len(contentItems) > 0 {
turnSendable += bufferedSendable
bufferedSendable = 0
- message := []byte(`{"type":"message","role":""}`)
- message, _ = sjson.SetBytes(message, "role", messageRole)
- message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray(contentItems))
- inputItems = append(inputItems, message)
+ messageDoc := ojson.NewDocString(`{"type":"message","role":""}`)
+ _ = messageDoc.Set("role", messageRole)
+ _ = messageDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
+ inputItems = append(inputItems, messageDoc.Bytes())
contentItems = contentItems[:0]
}
}
appendTextContent := func(text string) {
+ if messageRole == "assistant" && pendingCommentaryMirror != "" {
+ expected := pendingCommentaryMirror
+ pendingCommentaryMirror = ""
+ if text == expected {
+ return
+ }
+ }
partType := "input_text"
if messageRole == "assistant" {
partType = "output_text"
}
- content := []byte(`{"type":"","text":""}`)
- content, _ = sjson.SetBytes(content, "type", partType)
- content, _ = sjson.SetBytes(content, "text", text)
- contentItems = append(contentItems, content)
+ contentDoc := ojson.NewDocString(`{"type":"","text":""}`)
+ _ = contentDoc.Set("type", partType)
+ _ = contentDoc.Set("text", text)
+ contentItems = append(contentItems, contentDoc.Bytes())
if text != "" {
bufferedSendable++
}
}
appendImageContent := func(content []byte) {
+ pendingCommentaryMirror = ""
contentItems = append(contentItems, content)
bufferedSendable++
}
appendDocumentContent := func(dataURL string) {
+ pendingCommentaryMirror = ""
content := []byte(`{"type":"input_file","file_data":"","filename":"document.pdf"}`)
- content, _ = sjson.SetBytes(content, "file_data", dataURL)
+ content, _ = ojson.SetBytes(content, "file_data", dataURL)
contentItems = append(contentItems, content)
bufferedSendable++
}
- appendCommentaryContent := func(part gjson.Result) {
+ appendCommentaryContent := func(part ojson.Result) {
if messageRole != "assistant" {
return
}
@@ -198,24 +227,28 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
// forwarded upstream.
flushMessage()
content := []byte(`{"type":"output_text","text":""}`)
- content, _ = sjson.SetBytes(content, "text", text)
+ content, _ = ojson.SetBytes(content, "text", text)
message := []byte(`{"type":"message","role":"assistant","phase":"commentary"}`)
- message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray([][]byte{content}))
+ message, _ = ojson.SetRawBytes(message, "content", translatorcommon.JoinRawArray([][]byte{content}))
inputItems = append(inputItems, message)
}
- appendReasoningContent := func(part gjson.Result) {
+ appendReasoningContent := func(part ojson.Result) {
if messageRole != "assistant" {
return
}
+ pendingCommentaryMirror = ""
rawSignature := part.Get("signature").String()
if sigcompat.IsCodexCommentarySignatureMarker(rawSignature) {
appendCommentaryContent(part)
+ if visibleCommentary {
+ pendingCommentaryMirror = part.Get("thinking").String()
+ }
return
}
signature, ok := translatorcommon.CodexReplaySignatureForTarget(signatureTargetModel, rawSignature)
- if !ok && preserveEmptyThinkingBlocks && part.Get("signature").Type == gjson.String && strings.TrimSpace(rawSignature) != "" &&
+ if !ok && preserveEmptyThinkingBlocks && part.Get("signature").Type == ojson.String && strings.TrimSpace(rawSignature) != "" &&
sigcompat.DetectSignatureProviderForBlock(rawSignature, sigcompat.SignatureBlockKindClaudeThinking) == sigcompat.SignatureProviderUnknown {
signature = rawSignature
ok = true
@@ -230,7 +263,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
flushMessage()
reasoningItem := []byte(`{"type":"reasoning","summary":[],"content":null}`)
- reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", signature)
+ reasoningItem, _ = ojson.SetBytes(reasoningItem, "encrypted_content", signature)
inputItems = append(inputItems, reasoningItem)
}
@@ -239,6 +272,9 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
for j := 0; j < len(messageContentResults); j++ {
messageContentResult := messageContentResults[j]
contentType := messageContentResult.Get("type").String()
+ if contentType != "text" && contentType != "thinking" {
+ pendingCommentaryMirror = ""
+ }
switch contentType {
case "text":
@@ -270,12 +306,13 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
drops.Drop(contentType)
}
case "tool_use":
+ pendingCommentaryMirror = ""
flushMessage()
if id := messageContentResult.Get("id").String(); id != "" {
pendingToolUseIDs = append(pendingToolUseIDs, id)
}
- functionCallMessage := []byte(`{"type":"function_call"}`)
- functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("id").String()))
+ functionCallMessageDoc := ojson.NewDocString(`{"type":"function_call"}`)
+ _ = functionCallMessageDoc.Set("call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("id").String()))
{
name := messageContentResult.Get("name").String()
if short, ok := toolNameMap[name]; ok {
@@ -283,14 +320,14 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
} else {
name = shortenNameIfNeeded(name)
}
- functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "name", name)
+ _ = functionCallMessageDoc.Set("name", name)
}
- functionCallMessage, _ = sjson.SetBytes(functionCallMessage, "arguments", messageContentResult.Get("input").Raw)
- inputItems = append(inputItems, functionCallMessage)
+ _ = functionCallMessageDoc.Set("arguments", messageContentResult.Get("input").Raw)
+ inputItems = append(inputItems, functionCallMessageDoc.Bytes())
case "tool_result":
flushMessage()
- functionCallOutputMessage := []byte(`{"type":"function_call_output"}`)
- functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("tool_use_id").String()))
+ functionCallOutputMessageDoc := ojson.NewDocString(`{"type":"function_call_output"}`)
+ _ = functionCallOutputMessageDoc.Set("call_id", shortenCodexCallIDIfNeeded(messageContentResult.Get("tool_use_id").String()))
contentResult := messageContentResult.Get("content")
if contentResult.IsArray() {
@@ -316,26 +353,26 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
toolResultContent := []byte(`{"type":"input_image","image_url":""}`)
- toolResultContent, _ = sjson.SetBytes(toolResultContent, "image_url", dataURL)
+ toolResultContent, _ = ojson.SetBytes(toolResultContent, "image_url", dataURL)
toolResultContentItems = append(toolResultContentItems, toolResultContent)
}
}
} else if toolResultContentType == "text" {
toolResultContent := []byte(`{"type":"input_text","text":""}`)
- toolResultContent, _ = sjson.SetBytes(toolResultContent, "text", contentResults[k].Get("text").String())
+ toolResultContent, _ = ojson.SetBytes(toolResultContent, "text", contentResults[k].Get("text").String())
toolResultContentItems = append(toolResultContentItems, toolResultContent)
}
}
if len(toolResultContentItems) > 0 {
- functionCallOutputMessage, _ = sjson.SetRawBytes(functionCallOutputMessage, "output", translatorcommon.JoinRawArray(toolResultContentItems))
+ _ = functionCallOutputMessageDoc.SetRawBytes("output", translatorcommon.JoinRawArray(toolResultContentItems))
} else {
- functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
+ _ = functionCallOutputMessageDoc.Set("output", messageContentResult.Get("content").String())
}
} else {
- functionCallOutputMessage, _ = sjson.SetBytes(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
+ _ = functionCallOutputMessageDoc.Set("output", messageContentResult.Get("content").String())
}
- inputItems = append(inputItems, functionCallOutputMessage)
+ inputItems = append(inputItems, functionCallOutputMessageDoc.Bytes())
turnSendable++
}
}
@@ -344,7 +381,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
inputItems = append(inputItems, pendingSystemReminders...)
pendingSystemReminders = nil
}
- } else if messageContentsResult.Type == gjson.String {
+ } else if messageContentsResult.Type == ojson.String {
appendTextContent(messageContentsResult.String())
flushMessage()
if len(pendingSystemReminders) > 0 {
@@ -367,7 +404,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
var toolItems [][]byte
if toolsResult.IsArray() || hasAdditionalTools {
webSearchToolNames := buildClaudeWebSearchToolNameSet(toolsResult)
- template, _ = sjson.SetRawBytes(template, "tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames))
+ _ = templateDoc.SetRawBytes("tool_choice", convertClaudeToolChoiceToCodex(rootResult.Get("tool_choice"), toolNameMap, webSearchToolNames))
}
if toolsResult.IsArray() {
toolResults := toolsResult.Array()
@@ -391,12 +428,12 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
}
// Add additional configuration parameters for the Codex API.
- template, _ = sjson.SetBytes(template, "parallel_tool_calls", parallelToolCalls)
+ _ = templateDoc.Set("parallel_tool_calls", parallelToolCalls)
// Convert Claude thinking budgets and effort to reasoning.effort.
reasoningEffort := "medium"
effort := ""
- if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
+ if v := rootResult.Get("output_config.effort"); v.Exists() && v.Type == ojson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if thinkingConfig := rootResult.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
@@ -424,24 +461,24 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
} else if !thinkingConfig.Exists() && effort != "" {
reasoningEffort = effort
}
- template, _ = sjson.SetBytes(template, "reasoning.effort", reasoningEffort)
+ _ = templateDoc.Set("reasoning.effort", reasoningEffort)
// OpenAI documents reasoning summaries as explicit opt-in output. Leave
// reasoning.summary to the source request's canonical summary intent instead
// of coupling it to reasoning effort.
serviceTier := normalizeCodexServiceTier(rootResult.Get("service_tier"))
- if speed := rootResult.Get("speed"); speed.Type == gjson.String && speed.String() == "fast" {
+ if speed := rootResult.Get("speed"); speed.Type == ojson.String && speed.String() == "fast" {
serviceTier = "priority"
}
if serviceTier != "" {
- template, _ = sjson.SetBytes(template, "service_tier", serviceTier)
+ _ = templateDoc.Set("service_tier", serviceTier)
}
- template, _ = sjson.SetBytes(template, "stream", true)
- template, _ = sjson.SetBytes(template, "store", false)
+ _ = templateDoc.Set("stream", true)
+ _ = templateDoc.Set("store", false)
includeFields := []string{"reasoning.encrypted_content"}
if hasWebSearchTool {
includeFields = append(includeFields, "web_search_call.action.sources")
}
- template, _ = sjson.SetBytes(template, "include", includeFields)
+ _ = templateDoc.Set("include", includeFields)
// Map Claude output_config.format to Codex Responses text.format.
if format := rootResult.Get("output_config.format"); format.IsObject() && format.Get("type").String() == "json_schema" && format.Get("schema").IsObject() {
@@ -450,7 +487,7 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
name = n
}
strict := true
- if s := format.Get("strict"); s.Exists() && s.Type == gjson.False {
+ if s := format.Get("strict"); s.Exists() && s.Type == ojson.False {
strict = false
}
// OpenAI strict mode requires every declared property to be listed
@@ -459,23 +496,23 @@ func convertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool,
if strict && codexSchemaMissesRequired(format.Get("schema")) {
strict = false
}
- translatedFormat := []byte(`{"type":"json_schema","name":"","strict":true,"schema":{}}`)
- translatedFormat, _ = sjson.SetBytes(translatedFormat, "name", name)
- translatedFormat, _ = sjson.SetBytes(translatedFormat, "strict", strict)
- translatedFormat, _ = sjson.SetRawBytes(translatedFormat, "schema", []byte(format.Get("schema").Raw))
- template, _ = sjson.SetRawBytes(template, "text.format", translatedFormat)
+ translatedFormatDoc := ojson.NewDocString(`{"type":"json_schema","name":"","strict":true,"schema":{}}`)
+ _ = translatedFormatDoc.Set("name", name)
+ _ = translatedFormatDoc.Set("strict", strict)
+ _ = translatedFormatDoc.SetRawBytes("schema", []byte(format.Get("schema").Raw))
+ _ = templateDoc.SetRawBytes("text.format", translatedFormatDoc.Bytes())
}
if toolsResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = templateDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- template = translatorcommon.SetRawArrayItems(template, "input", inputItems)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "input", inputItems)
- return template, drops.Err()
+ return templateDoc.Bytes(), drops.Err()
}
-func normalizeCodexServiceTier(result gjson.Result) string {
- if !result.Exists() || result.Type != gjson.String {
+func normalizeCodexServiceTier(result ojson.Result) string {
+ if !result.Exists() || result.Type != ojson.String {
return ""
}
@@ -508,13 +545,13 @@ func isClaudeWebSearchToolType(toolType string) bool {
return toolType == "web_search_20250305" || toolType == "web_search_20260209"
}
-func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} {
+func buildClaudeWebSearchToolNameSet(tools ojson.Result) map[string]struct{} {
names := map[string]struct{}{}
if !tools.IsArray() {
return names
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
toolType := tool.Get("type").String()
if !isClaudeWebSearchToolType(toolType) {
return true
@@ -529,13 +566,13 @@ func buildClaudeWebSearchToolNameSet(tools gjson.Result) map[string]struct{} {
return names
}
-func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte {
- if !toolChoice.Exists() || toolChoice.Type == gjson.Null {
+func convertClaudeToolChoiceToCodex(toolChoice ojson.Result, toolNameMap map[string]string, webSearchToolNames map[string]struct{}) []byte {
+ if !toolChoice.Exists() || toolChoice.Type == ojson.Null {
return []byte(`"auto"`)
}
choiceType := toolChoice.Get("type").String()
- if choiceType == "" && toolChoice.Type == gjson.String {
+ if choiceType == "" && toolChoice.Type == ojson.String {
choiceType = toolChoice.String()
}
@@ -561,24 +598,29 @@ func convertClaudeToolChoiceToCodex(toolChoice gjson.Result, toolNameMap map[str
}
choice := []byte(`{"type":"function","name":""}`)
- choice, _ = sjson.SetBytes(choice, "name", name)
+ choice, _ = ojson.SetBytes(choice, "name", name)
return choice
default:
return []byte(`"auto"`)
}
}
-func convertClaudeWebSearchToolToCodex(tool gjson.Result) []byte {
- out := []byte(`{"type":"web_search"}`)
+func convertClaudeWebSearchToolToCodex(tool ojson.Result) []byte {
+ outDoc := ojson.NewDocString(`{"type":"web_search"}`)
if allowedDomains := tool.Get("allowed_domains"); allowedDomains.Exists() && allowedDomains.IsArray() {
- out, _ = sjson.SetRawBytes(out, "filters.allowed_domains", []byte(allowedDomains.Raw))
+ _ = outDoc.SetRawBytes("filters.allowed_domains", []byte(allowedDomains.Raw))
}
if userLocation := tool.Get("user_location"); userLocation.Exists() && userLocation.IsObject() {
- out, _ = sjson.SetRawBytes(out, "user_location", []byte(userLocation.Raw))
+ _ = outDoc.SetRawBytes("user_location", []byte(userLocation.Raw))
}
- return out
+ return outDoc.Bytes()
}
+// gatewayToolNamePattern matches gateway tool ids of the form
+// `[mcp_]-_`, e.g.
+// `mcp_github-12345678-9abc-def0-1234-56789abcdef0-12345678_add-issue-comment`.
+var gatewayToolNamePattern = regexp.MustCompile(`^(?:mcp_)?([a-z][a-z0-9-]*?)-[0-9a-f]{6,}(?:-[0-9a-f]+)*_(.+)$`)
+
// shortenNameIfNeeded applies a simple shortening rule for a single name.
func shortenNameIfNeeded(name string) string {
const limit = 64
@@ -588,11 +630,20 @@ func shortenNameIfNeeded(name string) string {
if strings.HasPrefix(name, "mcp__") {
idx := strings.LastIndex(name, "__")
if idx > 0 {
- cand := "mcp__" + name[idx+2:]
- if len(cand) > limit {
- return cand[:limit]
+ rest := name[idx+2:]
+ cand := "mcp__" + rest
+ if len(cand) <= limit {
+ return cand
}
- return cand
+ // Plain truncation keeps only the shared server prefix, so every tool of a
+ // long-named server collapses to the same 64 characters and the model cannot
+ // tell them apart. Keep the server's words and the tool's own name instead.
+ if m := gatewayToolNamePattern.FindStringSubmatch(rest); m != nil {
+ if readable := "mcp__" + m[1] + "_" + m[2]; len(readable) <= limit {
+ return readable
+ }
+ }
+ return cand[:limit]
}
}
return name[:limit]
@@ -604,22 +655,7 @@ func buildShortNameMap(names []string) map[string]string {
used := map[string]struct{}{}
m := map[string]string{}
- baseCandidate := func(n string) string {
- if len(n) <= limit {
- return n
- }
- if strings.HasPrefix(n, "mcp__") {
- idx := strings.LastIndex(n, "__")
- if idx > 0 {
- cand := "mcp__" + n[idx+2:]
- if len(cand) > limit {
- cand = cand[:limit]
- }
- return cand
- }
- }
- return n[:limit]
- }
+ baseCandidate := shortenNameIfNeeded
makeUnique := func(cand string) string {
if _, ok := used[cand]; !ok {
@@ -644,21 +680,22 @@ func buildShortNameMap(names []string) map[string]string {
}
for _, n := range names {
+ n = strings.Clone(n)
if _, exists := m[n]; exists {
continue
}
cand := baseCandidate(n)
- uniq := makeUnique(cand)
+ uniq := strings.Clone(makeUnique(cand))
used[uniq] = struct{}{}
m[n] = uniq
}
return m
}
-func convertClaudeFunctionToolToCodex(toolResult gjson.Result, toolNameMap map[string]string) []byte {
- tool := []byte(toolResult.Raw)
- if toolResult.Get("type").Type != gjson.String || toolResult.Get("type").String() != "function" {
- tool, _ = sjson.SetBytes(tool, "type", "function")
+func convertClaudeFunctionToolToCodex(toolResult ojson.Result, toolNameMap map[string]string) []byte {
+ toolDoc := ojson.NewDocString(toolResult.Raw)
+ if toolResult.Get("type").Type != ojson.String || toolResult.Get("type").String() != "function" {
+ _ = toolDoc.Set("type", "function")
}
// Apply shortened name if needed
if v := toolResult.Get("name"); v.Exists() {
@@ -669,23 +706,27 @@ func convertClaudeFunctionToolToCodex(toolResult gjson.Result, toolNameMap map[s
} else {
name = shortenNameIfNeeded(name)
}
- if v.Type != gjson.String || name != originalName {
- tool, _ = sjson.SetBytes(tool, "name", name)
+ if v.Type != ojson.String || name != originalName {
+ _ = toolDoc.Set("name", name)
}
}
- tool, _ = sjson.SetRawBytes(tool, "parameters", []byte(normalizeToolParameters(toolResult.Get("input_schema").Raw)))
- for _, path := range []string{"input_schema", "parameters.$schema", "cache_control", "defer_loading"} {
- if gjson.GetBytes(tool, path).Exists() {
- tool, _ = sjson.DeleteBytes(tool, path)
+ normalizedParams := normalizeToolParameters(toolResult.Get("input_schema").Raw)
+ _ = toolDoc.SetRawBytes("parameters", []byte(normalizedParams))
+ for _, path := range []string{"input_schema", "cache_control", "defer_loading"} {
+ if toolResult.Get(path).Exists() {
+ _ = toolDoc.Delete(path)
}
}
- if gjson.GetBytes(tool, "strict").Type != gjson.False {
- tool, _ = sjson.SetBytes(tool, "strict", false)
+ if ojson.GetBytesNoCopy([]byte(normalizedParams), "$schema").Exists() {
+ _ = toolDoc.Delete("parameters.$schema")
+ }
+ if toolResult.Get("strict").Type != ojson.False {
+ _ = toolDoc.Set("strict", false)
}
- return tool
+ return toolDoc.Bytes()
}
-func ensureClaudeDeclaredToolsIndexed(toolsResult gjson.Result, declaredToolsByName *map[string]gjson.Result, declaredToolsIndexed *bool) {
+func ensureClaudeDeclaredToolsIndexed(toolsResult ojson.Result, declaredToolsByName *map[string]ojson.Result, declaredToolsIndexed *bool) {
if *declaredToolsIndexed {
return
}
@@ -695,7 +736,7 @@ func ensureClaudeDeclaredToolsIndexed(toolsResult gjson.Result, declaredToolsByN
}
toolArr := toolsResult.Array()
if *declaredToolsByName == nil {
- *declaredToolsByName = make(map[string]gjson.Result, len(toolArr))
+ *declaredToolsByName = make(map[string]ojson.Result, len(toolArr))
}
for i := 0; i < len(toolArr); i++ {
if name := toolArr[i].Get("name").String(); name != "" {
@@ -708,7 +749,7 @@ func ensureClaudeDeclaredToolsIndexed(toolsResult gjson.Result, declaredToolsByN
// convertClaudeSystemAdditionalTools converts mid-conversation tool_addition blocks
// inside a role=system message into a Responses additional_tools input item.
-func convertClaudeSystemAdditionalTools(content, toolsResult gjson.Result, declaredToolsByName *map[string]gjson.Result, declaredToolsIndexed *bool, toolNameMap map[string]string) ([]byte, bool) {
+func convertClaudeSystemAdditionalTools(content, toolsResult ojson.Result, declaredToolsByName *map[string]ojson.Result, declaredToolsIndexed *bool, toolNameMap map[string]string) ([]byte, bool) {
if !content.IsArray() {
return nil, false
}
@@ -721,7 +762,7 @@ func convertClaudeSystemAdditionalTools(content, toolsResult gjson.Result, decla
continue
}
toolSpec := part.Get("tool")
- var def gjson.Result
+ var def ojson.Result
switch toolSpec.Get("type").String() {
case "tool_definition":
def = toolSpec.Get("definition")
@@ -729,7 +770,7 @@ func convertClaudeSystemAdditionalTools(content, toolsResult gjson.Result, decla
if name := def.Get("name").String(); name != "" {
ensureClaudeDeclaredToolsIndexed(toolsResult, declaredToolsByName, declaredToolsIndexed)
if *declaredToolsByName == nil {
- *declaredToolsByName = make(map[string]gjson.Result)
+ *declaredToolsByName = make(map[string]ojson.Result)
}
(*declaredToolsByName)[name] = def
}
@@ -749,7 +790,7 @@ func convertClaudeSystemAdditionalTools(content, toolsResult gjson.Result, decla
if name := def.Get("name").String(); name != "" {
ensureClaudeDeclaredToolsIndexed(toolsResult, declaredToolsByName, declaredToolsIndexed)
if *declaredToolsByName == nil {
- *declaredToolsByName = make(map[string]gjson.Result)
+ *declaredToolsByName = make(map[string]ojson.Result)
}
(*declaredToolsByName)[name] = def
}
@@ -777,11 +818,11 @@ func convertClaudeSystemAdditionalTools(content, toolsResult gjson.Result, decla
return nil, false
}
item := []byte(`{"type":"additional_tools","role":"developer","tools":[]}`)
- item, _ = sjson.SetRawBytes(item, "tools", translatorcommon.JoinRawArray(toolItems))
+ item, _ = ojson.SetRawBytes(item, "tools", translatorcommon.JoinRawArray(toolItems))
return item, true
}
-func collectClaudeDeclaredToolNames(rootResult gjson.Result, hasToolAddition bool) []string {
+func collectClaudeDeclaredToolNames(rootResult ojson.Result, hasToolAddition bool) []string {
var names []string
tools := rootResult.Get("tools")
if tools.IsArray() {
@@ -828,7 +869,7 @@ func collectClaudeDeclaredToolNames(rootResult gjson.Result, hasToolAddition boo
return names
}
-func buildReverseMapFromClaudeOriginalToShortFromResult(rootResult gjson.Result, hasToolAddition bool) map[string]string {
+func buildReverseMapFromClaudeOriginalToShortFromResult(rootResult ojson.Result, hasToolAddition bool) map[string]string {
names := collectClaudeDeclaredToolNames(rootResult, hasToolAddition)
if len(names) == 0 {
return map[string]string{}
@@ -846,7 +887,7 @@ func claudePayloadMayContainToolAddition(rawJSON []byte) bool {
func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string {
mayHaveToolAddition := claudePayloadMayContainToolAddition(original)
if !mayHaveToolAddition {
- tools := gjson.GetBytes(original, "tools")
+ tools := ojson.GetBytesNoCopy(original, "tools")
m := map[string]string{}
if !tools.IsArray() {
return m
@@ -864,7 +905,7 @@ func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string
}
return m
}
- return buildReverseMapFromClaudeOriginalToShortFromResult(gjson.ParseBytes(original), true)
+ return buildReverseMapFromClaudeOriginalToShortFromResult(util.ParseGJSONBytesNoCopy(original), true)
}
// normalizeToolParameters ensures object schemas contain at least an empty properties map,
@@ -872,7 +913,7 @@ func buildReverseMapFromClaudeOriginalToShort(original []byte) map[string]string
// Unicode property escapes (\p{...} / \P{...}) that cause upstream schema validation failures.
func normalizeToolParameters(raw string) string {
raw = strings.TrimSpace(raw)
- if raw == "" || raw == "null" || !gjson.Valid(raw) {
+ if raw == "" || raw == "null" || !ojson.Valid(raw) {
return `{"type":"object","properties":{}}`
}
var root map[string]any
@@ -980,11 +1021,11 @@ var (
// property missing from its sibling required list (recursively). OpenAI
// strict mode rejects such schemas with HTTP 400, so callers downgrade
// text.format.strict instead of emitting an unsatisfiable schema.
-func codexSchemaMissesRequired(schema gjson.Result) bool {
+func codexSchemaMissesRequired(schema ojson.Result) bool {
if !schema.IsObject() {
if schema.IsArray() {
miss := false
- schema.ForEach(func(_, child gjson.Result) bool {
+ schema.ForEach(func(_, child ojson.Result) bool {
if codexSchemaMissesRequired(child) {
miss = true
return false
@@ -1002,7 +1043,7 @@ func codexSchemaMissesRequired(schema gjson.Result) bool {
}
names := make(map[string]struct{}, len(required.Array()))
for _, item := range required.Array() {
- if item.Type == gjson.String {
+ if item.Type == ojson.String {
names[item.String()] = struct{}{}
}
}
@@ -1018,7 +1059,7 @@ func codexSchemaMissesRequired(schema gjson.Result) bool {
continue
}
miss := false
- children.ForEach(func(_, child gjson.Result) bool {
+ children.ForEach(func(_, child ojson.Result) bool {
if codexSchemaMissesRequired(child) {
miss = true
return false
@@ -1042,7 +1083,7 @@ func codexSchemaMissesRequired(schema gjson.Result) bool {
// claudeImageInputPart maps a Claude image source onto a Responses input_image
// part. Base64 bytes become a data URL, an http(s) url passes through and a file
// id is carried as file_id. It reports false for a source with none of them.
-func claudeImageInputPart(source gjson.Result) ([]byte, bool) {
+func claudeImageInputPart(source ojson.Result) ([]byte, bool) {
if !source.Exists() {
return nil, false
}
@@ -1060,18 +1101,18 @@ func claudeImageInputPart(source gjson.Result) ([]byte, bool) {
if mediaType == "" {
mediaType = "application/octet-stream"
}
- part, _ = sjson.SetBytes(part, "image_url", fmt.Sprintf("data:%s;base64,%s", mediaType, data))
+ part, _ = ojson.SetBytes(part, "image_url", fmt.Sprintf("data:%s;base64,%s", mediaType, data))
case source.Get("type").String() == "url" && translatorcommon.IsHTTPURL(source.Get("url").String()):
- part, _ = sjson.SetBytes(part, "image_url", strings.TrimSpace(source.Get("url").String()))
+ part, _ = ojson.SetBytes(part, "image_url", strings.TrimSpace(source.Get("url").String()))
case source.Get("type").String() == "file" && source.Get("file_id").String() != "":
- part, _ = sjson.SetBytes(part, "file_id", source.Get("file_id").String())
+ part, _ = ojson.SetBytes(part, "file_id", source.Get("file_id").String())
default:
return nil, false
}
return part, true
}
-func claudeDocumentDataURL(part gjson.Result) (string, bool) {
+func claudeDocumentDataURL(part ojson.Result) (string, bool) {
source := part.Get("source")
if source.Get("type").String() == "base64" {
mediaType := strings.TrimSpace(source.Get("media_type").String())
diff --git a/internal/translator/codex/claude/codex_claude_request_benchmark_test.go b/internal/translator/codex/claude/codex_claude_request_benchmark_test.go
index bcc689b23ce..d61deac8ea0 100644
--- a/internal/translator/codex/claude/codex_claude_request_benchmark_test.go
+++ b/internal/translator/codex/claude/codex_claude_request_benchmark_test.go
@@ -5,17 +5,17 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func BenchmarkConvertClaudeRequestToCodexLargeHistory(b *testing.B) {
for _, turns := range []int{16, 64} {
b.Run(strconv.Itoa(turns)+"_turns", func(b *testing.B) {
request := largeClaudeRequest(turns, 32, 8*1024)
- if !gjson.ValidBytes(request) {
+ if !ojson.ValidBytes(request) {
b.Fatal("benchmark generated an invalid Claude request")
}
- if result, _ := ConvertClaudeRequestToCodex("gpt-5.4", request, false); !gjson.ValidBytes(result) {
+ if result, _ := ConvertClaudeRequestToCodex("gpt-5.4", request, false); !ojson.ValidBytes(result) {
b.Fatal("translator generated invalid Codex JSON")
}
b.ReportAllocs()
diff --git a/internal/translator/codex/claude/codex_claude_request_test.go b/internal/translator/codex/claude/codex_claude_request_test.go
index b23b5e39374..451256f2e84 100644
--- a/internal/translator/codex/claude/codex_claude_request_test.go
+++ b/internal/translator/codex/claude/codex_claude_request_test.go
@@ -1,15 +1,50 @@
package claude
import (
- "encoding/base64"
"fmt"
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
+func TestConvertClaudeRequestToCodex_MaxOutputTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"no limit", `{}`, ""},
+ {"max_tokens", `{"max_tokens":1024}`, "1024"},
+ {"max_output_tokens fallback", `{"max_output_tokens":2048}`, "2048"},
+ {"max_tokens takes precedence", `{"max_tokens":1024,"max_output_tokens":2048}`, "1024"},
+ {"zero takes precedence", `{"max_tokens":0,"max_output_tokens":2048}`, "0"},
+ {"string ignored", `{"max_tokens":"1024"}`, ""},
+ {"null ignored", `{"max_tokens":null}`, ""},
+ {"boolean ignored", `{"max_tokens":true}`, ""},
+ {"object ignored", `{"max_tokens":{}}`, ""},
+ {"invalid primary does not use fallback", `{"max_tokens":"1024","max_output_tokens":2048}`, ""},
+ {"null primary does not use fallback", `{"max_tokens":null,"max_output_tokens":2048}`, ""},
+ {"invalid fallback ignored", `{"max_output_tokens":"2048"}`, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, errConvert := ConvertClaudeRequestToCodex("codex-test", []byte(tt.input), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertClaudeRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != tt.want {
+ t.Fatalf("max_output_tokens = %q, want %q; output=%s", got, tt.want, out)
+ }
+ if ojson.GetBytesNoCopy(out, "max_tokens").Exists() {
+ t.Fatalf("max_tokens should not be forwarded; output=%s", out)
+ }
+ })
+ }
+}
+
func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) {
tests := []struct {
name string
@@ -74,7 +109,7 @@ func TestConvertClaudeRequestToCodex_SystemMessageScenarios(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
inputs := resultJSON.Get("input").Array()
hasDeveloper := len(inputs) > 0 && inputs[0].Get("role").String() == "developer"
@@ -116,9 +151,9 @@ func TestConvertClaudeRequestToCodex_MessageSystemRoleWrapsAsUserReminder(t *tes
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
if len(inputs) != 5 {
- t.Fatalf("got %d input items, want 5: %s", len(inputs), gjson.GetBytes(result, "input").Raw)
+ t.Fatalf("got %d input items, want 5: %s", len(inputs), ojson.GetBytesNoCopy(result, "input").Raw)
}
if got := inputs[0].Get("role").String(); got != "developer" {
@@ -163,7 +198,7 @@ func TestConvertClaudeRequestToCodex_PreservesToolAdjacencyWithInterveningSystem
}`
result, _ := ConvertClaudeRequestToCodex("gpt-5.4", []byte(inputJSON), false)
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
// Expected item types:
// 0: message (role: user, "Execute tools")
@@ -233,7 +268,7 @@ func TestConvertClaudeRequestToCodex_ParallelToolCalls(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(tt.inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("parallel_tool_calls").Bool(); got != tt.wantParallelToolCalls {
t.Fatalf("parallel_tool_calls = %v, want %v. Output: %s", got, tt.wantParallelToolCalls, string(result))
@@ -295,19 +330,20 @@ func TestConvertClaudeRequestToCodex_ServiceTier(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- inputJSON := []byte(`{
+ inputDoc := ojson.NewDocString(`{
"model": "gpt-5.4",
"messages": [{"role": "user", "content": "Reply with OK"}]
}`)
if tt.serviceTierJSON != "" {
- inputJSON, _ = sjson.SetRawBytes(inputJSON, "service_tier", []byte(tt.serviceTierJSON))
+ _ = inputDoc.SetRawBytes("service_tier", []byte(tt.serviceTierJSON))
}
if tt.speedJSON != "" {
- inputJSON, _ = sjson.SetRawBytes(inputJSON, "speed", []byte(tt.speedJSON))
+ _ = inputDoc.SetRawBytes("speed", []byte(tt.speedJSON))
}
+ inputJSON := inputDoc.Bytes()
result, _ := ConvertClaudeRequestToCodex("gpt-5.4", inputJSON, false)
- serviceTierResult := gjson.GetBytes(result, "service_tier")
+ serviceTierResult := ojson.GetBytesNoCopy(result, "service_tier")
if serviceTierResult.Exists() != tt.wantExists {
t.Fatalf("service_tier exists = %v, want %v. Output: %s", serviceTierResult.Exists(), tt.wantExists, string(result))
}
@@ -341,7 +377,7 @@ func TestConvertClaudeRequestToCodex_ShortenLongToolUseIDs(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
var callID string
var outputCallID string
@@ -406,7 +442,7 @@ func TestConvertClaudeRequestToCodex_ToolChoiceModeMapping(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("tool_choice").String(); got != tt.wantCodexToolChoice {
t.Fatalf("tool_choice = %q, want %q. Output: %s", got, tt.wantCodexToolChoice, string(result))
@@ -427,7 +463,7 @@ func TestConvertClaudeRequestToCodex_ToolChoiceSpecificFunctionUsesConvertedName
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
@@ -472,7 +508,7 @@ func TestConvertClaudeRequestToCodex_WebSearchSourcesInclude(t *testing.T) {
if tt.wantSources {
wantInclude = `["reasoning.encrypted_content","web_search_call.action.sources"]`
}
- if got := gjson.GetBytes(result, "include").Raw; got != wantInclude {
+ if got := ojson.GetBytesNoCopy(result, "include").Raw; got != wantInclude {
t.Errorf("stream=%v: include = %s, want %s", stream, got, wantInclude)
}
}
@@ -502,7 +538,7 @@ func TestConvertClaudeRequestToCodex_WebSearchToolMapping(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("tools.0.type").String(); got != "web_search" {
t.Fatalf("tools.0.type = %q, want web_search. Output: %s", got, string(result))
@@ -533,7 +569,7 @@ func TestConvertClaudeRequestToCodex_WebSearchToolChoiceUsesDeclaredTypedToolNam
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
if got := resultJSON.Get("tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(result))
@@ -570,7 +606,7 @@ func TestConvertClaudeRequestToCodex_AssistantThinkingSignatureToReasoningItem(t
}`
result, _ := ConvertClaudeRequestToCodex("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
inputs := resultJSON.Get("input").Array()
if len(inputs) != 3 {
t.Fatalf("got %d input items, want 3. Output: %s", len(inputs), string(result))
@@ -618,7 +654,7 @@ func TestConvertClaudeRequestToCodex_PreservesBase64PDFDocumentContent(t *testin
}`
result, _ := ConvertClaudeRequestToCodex("gpt-5.6-sol", []byte(inputJSON), false)
- content := gjson.GetBytes(result, "input.0.content").Array()
+ content := ojson.GetBytesNoCopy(result, "input.0.content").Array()
if len(content) != 3 {
t.Fatalf("got %d content items, want 3. Output: %s", len(content), result)
}
@@ -667,7 +703,7 @@ func TestConvertClaudeRequestToCodex_PreservesContentOrderAcrossToolAndReasoning
}`
result, _ := ConvertClaudeRequestToCodex("gpt-5.4", []byte(inputJSON), false)
- inputs := gjson.GetBytes(result, "input").Array()
+ inputs := ojson.GetBytesNoCopy(result, "input").Array()
if len(inputs) != 8 {
t.Fatalf("got %d input items, want 8. Output: %s", len(inputs), result)
}
@@ -702,10 +738,10 @@ func TestConvertClaudeRequestToCodex_PreservesContentOrderAcrossToolAndReasoning
func TestConvertClaudeRequestToCodex_AssistantGrokSignatureToReasoningItem(t *testing.T) {
signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X"
payload := []byte(`{"model":"grok-4.5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+ payload, _ = ojson.SetBytes(payload, "messages.0.content.0.signature", signature)
out, _ := ConvertClaudeRequestToCodex("grok-4.5", payload, false)
- reasoning := gjson.GetBytes(out, "input.0")
+ reasoning := ojson.GetBytesNoCopy(out, "input.0")
if reasoning.Get("type").String() != "reasoning" {
t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
}
@@ -726,10 +762,10 @@ func testMetaClaudeSignature() string {
func TestConvertClaudeRequestToCodex_AssistantMetaSignatureToReasoningItem(t *testing.T) {
signature := testMetaClaudeSignature()
payload := []byte(`{"model":"muse-spark-1.3","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+ payload, _ = ojson.SetBytes(payload, "messages.0.content.0.signature", signature)
out, _ := ConvertClaudeRequestToCodex("muse-spark-1.3", payload, false)
- reasoning := gjson.GetBytes(out, "input.0")
+ reasoning := ojson.GetBytesNoCopy(out, "input.0")
if reasoning.Get("type").String() != "reasoning" {
t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
}
@@ -741,7 +777,7 @@ func TestConvertClaudeRequestToCodex_AssistantMetaSignatureToReasoningItem(t *te
func TestConvertClaudeRequestToCodex_IgnoresMetaSignatureForNonMetaTargets(t *testing.T) {
signature := testMetaClaudeSignature()
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+ payload, _ = ojson.SetBytes(payload, "messages.0.content.0.signature", signature)
for _, modelName := range []string{"gpt-5.4", "claude-sonnet-4-6", "grok-4.5"} {
t.Run(modelName, func(t *testing.T) {
@@ -759,10 +795,10 @@ func TestConvertClaudeRequestToCodex_AssistantRoutedMetaSignatureToReasoningItem
// verbatim.
signature := testMetaClaudeSignature() + "." + strings.Repeat("Ab3", 23)[:67]
payload := []byte(`{"model":"meta/muse-wonder","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+ payload, _ = ojson.SetBytes(payload, "messages.0.content.0.signature", signature)
out, _ := ConvertClaudeRequestToCodex("meta/muse-wonder", payload, false)
- reasoning := gjson.GetBytes(out, "input.0")
+ reasoning := ojson.GetBytesNoCopy(out, "input.0")
if reasoning.Get("type").String() != "reasoning" {
t.Fatalf("input.0 type = %q, want reasoning; output=%s", reasoning.Get("type").String(), out)
}
@@ -777,7 +813,7 @@ func TestConvertClaudeRequestToCodex_AssistantRoutedMetaSignatureToReasoningItem
// A foreign GPT signature must not be replayed when the target is a Meta Muse model.
gptPayload := []byte(`{"model":"muse-spark-1.3","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
- gptPayload, _ = sjson.SetBytes(gptPayload, "messages.0.content.0.signature", validCodexReasoningSignature())
+ gptPayload, _ = ojson.SetBytes(gptPayload, "messages.0.content.0.signature", validCodexReasoningSignature())
out, _ = ConvertClaudeRequestToCodex("muse-spark-1.3", gptPayload, false)
if got := countRequestInputItemsByType(out, "reasoning"); got != 0 {
t.Fatalf("got %d reasoning items for GPT signature on Meta target, want 0; output=%s", got, out)
@@ -824,7 +860,7 @@ func TestConvertClaudeRequestToCodex_ResolvesReplayTargetModelOncePerRequest(t *
func TestConvertClaudeRequestToCodex_IgnoresGrokSignatureForNonGrokTargets(t *testing.T) {
signature := "HmlYdr2aCAqCYP/m9mr8PS6KOsdMs72FGDigmydR+Jsmuv8KX97yWPlbOwmXJgWn0CbHaCacdQD3+n5EvpgLfPNmafS3kdICBjRuDf4bzHy7uBiUhNVhqPtp/ee1y9q4imPE4LYgD1VZ4J+bp9mTeqA1+nC9Oue58CiNEMV9SVaGenCD+aBnVuSTzQhD32Y+68i6HLJW0Dx6ifaRfb8hxYtA/sPM+/FTvAMW11nRho5a2BBSkpnzfqqAz/e/vGJ77/bygpXM823QA9wL9i0X"
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"summary","signature":""},{"type":"text","text":"answer"}]},{"role":"user","content":"next"}]}`)
- payload, _ = sjson.SetBytes(payload, "messages.0.content.0.signature", signature)
+ payload, _ = ojson.SetBytes(payload, "messages.0.content.0.signature", signature)
for _, modelName := range []string{"gpt-5.4", "claude-sonnet-4-6"} {
t.Run(modelName, func(t *testing.T) {
@@ -899,7 +935,7 @@ func TestConvertClaudeRequestToCodex_IgnoresNonCodexThinkingSignatures(t *testin
func countRequestInputItemsByType(result []byte, itemType string) int {
count := 0
- gjson.GetBytes(result, "input").ForEach(func(_, item gjson.Result) bool {
+ ojson.GetBytesNoCopy(result, "input").ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == itemType {
count++
}
@@ -939,7 +975,7 @@ func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) {
}`)
translated, _ := ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
- root := gjson.ParseBytes(translated)
+ root := util.ParseGJSONBytesNoCopy(translated)
if !root.Get("text.format").Exists() {
t.Fatalf("expected text.format in translated payload, got: %s", translated)
@@ -977,7 +1013,7 @@ func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) {
}`)
translated, _ := ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
- root := gjson.ParseBytes(translated)
+ root := util.ParseGJSONBytesNoCopy(translated)
if got := root.Get("text.format.name").String(); got != "custom_schema" {
t.Errorf("expected text.format.name to be 'custom_schema', got %q", got)
@@ -996,7 +1032,7 @@ func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) {
}`)
translated, _ := ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
- root := gjson.ParseBytes(translated)
+ root := util.ParseGJSONBytesNoCopy(translated)
if root.Get("text.format").Exists() {
t.Fatalf("expected no text.format in translated payload, got: %s", translated)
}
@@ -1012,7 +1048,7 @@ func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) {
}`)
translated, _ := ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
- root := gjson.ParseBytes(translated)
+ root := util.ParseGJSONBytesNoCopy(translated)
if root.Get("text.format").Exists() {
t.Fatalf("expected no text.format in translated payload, got: %s", translated)
}
@@ -1046,7 +1082,7 @@ func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) {
}`)
translated, _ := ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
- root := gjson.ParseBytes(translated)
+ root := util.ParseGJSONBytesNoCopy(translated)
if got := root.Get("text.format.strict").Bool(); got != false {
t.Errorf("expected text.format.strict to be false for non-strict-compatible schema, got %v (%s)", got, translated)
}
@@ -1077,7 +1113,7 @@ func TestConvertClaudeRequestToCodex_OutputConfigFormat(t *testing.T) {
}`)
translated, _ := ConvertClaudeRequestToCodex("gpt-5.4", payload, false)
- root := gjson.ParseBytes(translated)
+ root := util.ParseGJSONBytesNoCopy(translated)
if got := root.Get("text.format.strict").Bool(); !got {
t.Errorf("expected text.format.strict to stay true for strict-compatible schema, got %v (%s)", got, translated)
}
@@ -1116,7 +1152,7 @@ func TestNormalizeToolParameters_StripsNestedSchemaAndId(t *testing.T) {
}`
got := normalizeToolParameters(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("$schema").Exists() {
t.Errorf("expected root $schema to be removed, got %v", parsed.Get("$schema").Raw)
@@ -1169,7 +1205,7 @@ func TestNormalizeToolParameters_PreservesPropertyNamesAndLiteralData(t *testing
}`
got := normalizeToolParameters(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if !parsed.Get("properties.$schema").Exists() {
t.Errorf("expected property named '$schema' to be preserved")
@@ -1198,7 +1234,7 @@ func TestNormalizeToolParameters_PreservesPropertyNamesAndLiteralData(t *testing
// Test array union type preservation (e.g. ["object", "null"])
unionInput := `{"type": ["object", "null"]}`
unionGot := normalizeToolParameters(unionInput)
- unionParsed := gjson.Parse(unionGot)
+ unionParsed := ojson.Parse(unionGot)
typeArr := unionParsed.Get("type").Array()
if len(typeArr) != 2 || typeArr[0].String() != "object" || typeArr[1].String() != "null" {
t.Errorf("expected union type array to be preserved, got %s", unionParsed.Get("type").Raw)
@@ -1244,7 +1280,7 @@ func TestConvertClaudeRequestToCodex_StripsNestedToolSchemaMeta(t *testing.T) {
}`
translated, _ := ConvertClaudeRequestToCodex("gpt-5", []byte(inputJSON), false)
- tools := gjson.GetBytes(translated, "tools").Array()
+ tools := ojson.GetBytesNoCopy(translated, "tools").Array()
if len(tools) == 0 {
t.Fatalf("expected tools in translated payload, got: %s", translated)
}
@@ -1323,7 +1359,7 @@ func TestConvertClaudeRequestToCodex_StripsUnsupportedUnicodePropertyEscapePatte
}`
translated, _ := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false)
- tools := gjson.GetBytes(translated, "tools").Array()
+ tools := ojson.GetBytesNoCopy(translated, "tools").Array()
if len(tools) == 0 {
t.Fatalf("expected tools in translated payload, got: %s", translated)
}
@@ -1392,7 +1428,7 @@ func TestConvertClaudeRequestToCodex_StripsPatternPropertiesIncompatibleKeys(t *
}`
translated, _ := ConvertClaudeRequestToCodex("gpt-5.6", []byte(inputJSON), false)
- tools := gjson.GetBytes(translated, "tools").Array()
+ tools := ojson.GetBytesNoCopy(translated, "tools").Array()
if len(tools) == 0 {
t.Fatalf("expected tools in translated payload, got: %s", translated)
}
@@ -1494,9 +1530,9 @@ func TestConvertClaudeRequestToCodex_MidConversationToolAdditions(t *testing.T)
}
// Top-level tools[] must remain untouched (2 tools: Read and deferred_lookup) to preserve the prompt cache prefix.
- topTools := gjson.GetBytes(translated, "tools").Array()
+ topTools := ojson.GetBytesNoCopy(translated, "tools").Array()
if len(topTools) != 2 {
- t.Fatalf("expected 2 top-level tools, got %d: %s", len(topTools), gjson.GetBytes(translated, "tools").Raw)
+ t.Fatalf("expected 2 top-level tools, got %d: %s", len(topTools), ojson.GetBytesNoCopy(translated, "tools").Raw)
}
if topTools[0].Get("name").String() != "Read" || topTools[1].Get("name").String() != "deferred_lookup" {
t.Fatalf("unexpected top-level tool names: %q, %q", topTools[0].Get("name").String(), topTools[1].Get("name").String())
@@ -1510,9 +1546,9 @@ func TestConvertClaudeRequestToCodex_MidConversationToolAdditions(t *testing.T)
// 4: function_call_output (call_1)
// 5: additional_tools (developer: shortened longMCPTool, deferred after function_call_output)
// 6: message (user: "Finish up")
- inputs := gjson.GetBytes(translated, "input").Array()
+ inputs := ojson.GetBytesNoCopy(translated, "input").Array()
if len(inputs) != 7 {
- t.Fatalf("expected 7 input items, got %d: %s", len(inputs), gjson.GetBytes(translated, "input").Raw)
+ t.Fatalf("expected 7 input items, got %d: %s", len(inputs), ojson.GetBytesNoCopy(translated, "input").Raw)
}
addTools1 := inputs[2]
@@ -1529,7 +1565,7 @@ func TestConvertClaudeRequestToCodex_MidConversationToolAdditions(t *testing.T)
if addTools1List[0].Get("type").String() != "function" || addTools1List[0].Get("name").String() != "SubagentHandback" {
t.Fatalf("unexpected first additional tool: %s", addTools1List[0].Raw)
}
- if addTools1List[0].Get("strict").Type != gjson.False {
+ if addTools1List[0].Get("strict").Type != ojson.False {
t.Fatalf("expected strict=false on SubagentHandback, got: %s", addTools1List[0].Raw)
}
if addTools1List[0].Get("input_schema").Exists() || addTools1List[0].Get("parameters.$schema").Exists() {
diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go
index 5c28be11486..f4fdb1cead0 100644
--- a/internal/translator/codex/claude/codex_claude_response.go
+++ b/internal/translator/codex/claude/codex_claude_response.go
@@ -12,11 +12,10 @@ import (
"math"
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
var (
@@ -49,18 +48,20 @@ type ConvertCodexResponseToClaudeParams struct {
// phase "commentary": a preamble GPT-5.x emits before its final_answer item.
// Its text goes into a thinking block, so clients that render or speak text
// blocks get the answer once instead of the preamble followed by the answer.
- CommentaryMessageOpen bool
- ThinkingBlockOpen bool
- ThinkingSignature string
- ThinkingSummarySeen bool
- WebSearchToolUseIDs map[string]struct{}
- WebSearchToolResultIDs map[string]struct{}
- LastWebSearchToolUseID string
- FunctionCalls map[string]*codexFunctionCallStream
- FunctionCallQueue []*codexFunctionCallStream
- ActiveFunctionCall *codexFunctionCallStream
- LastFunctionCall *codexFunctionCallStream
- DeferredStreamEvents [][]byte
+ CommentaryMessageOpen bool
+ PublicCommentaryText strings.Builder
+ VisiblePublicCommentary bool
+ ThinkingBlockOpen bool
+ ThinkingSignature string
+ ThinkingSummarySeen bool
+ WebSearchToolUseIDs map[string]struct{}
+ WebSearchToolResultIDs map[string]struct{}
+ LastWebSearchToolUseID string
+ FunctionCalls map[string]*codexFunctionCallStream
+ FunctionCallQueue []*codexFunctionCallStream
+ ActiveFunctionCall *codexFunctionCallStream
+ LastFunctionCall *codexFunctionCallStream
+ DeferredStreamEvents [][]byte
}
type codexFunctionCallStream struct {
@@ -92,10 +93,11 @@ type codexFunctionCallStream struct {
//
// Returns:
// - [][]byte: A slice of Claude Code-compatible JSON responses
-func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte {
+func ConvertCodexResponseToClaude(ctx context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, param *any) [][]byte {
if *param == nil {
*param = &ConvertCodexResponseToClaudeParams{
- BlockIndex: 0,
+ BlockIndex: 0,
+ VisiblePublicCommentary: translatorcommon.VisibleCodexCommentary(ctx),
}
}
@@ -106,7 +108,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
rawJSON = bytes.TrimSpace(rawJSON[5:])
output := make([]byte, 0, 512)
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
params := (*param).(*ConvertCodexResponseToClaudeParams)
typeResult := rootResult.Get("type")
@@ -121,9 +123,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
case "error":
output = append(output, codexStreamErrorToClaudeError(rootResult)...)
case "response.created":
- template = []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0},"content":[],"stop_reason":null}}`)
- template, _ = sjson.SetBytes(template, "message.model", rootResult.Get("response.model").String())
- template, _ = sjson.SetBytes(template, "message.id", rootResult.Get("response.id").String())
+ templateDoc := ojson.NewDocString(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"claude-opus-4-1-20250805","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0},"content":[],"stop_reason":null}}`)
+ _ = templateDoc.Set("message.model", rootResult.Get("response.model").String())
+ _ = templateDoc.Set("message.id", rootResult.Get("response.id").String())
+ template = templateDoc.Bytes()
output = translatorcommon.AppendSSEEventBytes(output, "message_start", template, 2)
case "response.reasoning_summary_part.added":
@@ -170,9 +173,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
}
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, startCodexTextBlock(params)...)
- template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
- template, _ = sjson.SetBytes(template, "delta.text", rootResult.Get("delta").String())
+ templateDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
+ _ = templateDoc.Set("index", params.BlockIndex)
+ _ = templateDoc.Set("delta.text", rootResult.Get("delta").String())
+ template = templateDoc.Bytes()
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
case "response.content_part.done":
@@ -186,7 +190,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
case "response.web_search_call.searching", "response.web_search_call.completed", "response.web_search_call.in_progress":
// Wait for populated web_search_call items on output_item.done.
case "response.completed", "response.incomplete":
- template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
+ templateDoc := ojson.NewDocString(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
responseData := rootResult.Get("response")
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
@@ -194,18 +198,19 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = appendDeferredCodexStreamEvents(output, originalRequestRawJSON, param)
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, stopCodexTextBlock(params)...)
- template, _ = sjson.SetBytes(template, "delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse))
- template = setClaudeStopSequence(template, "delta.stop_sequence", responseData)
+ _ = templateDoc.Set("delta.stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), params.HasEmittedToolUse))
+ setClaudeStopSequenceDoc(templateDoc, "delta.stop_sequence", responseData)
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage"))
- template, _ = sjson.SetBytes(template, "usage.input_tokens", inputTokens)
- template, _ = sjson.SetBytes(template, "usage.output_tokens", outputTokens)
+ _ = templateDoc.Set("usage.input_tokens", inputTokens)
+ _ = templateDoc.Set("usage.output_tokens", outputTokens)
if cachedTokens > 0 {
- template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", cachedTokens)
+ _ = templateDoc.Set("usage.cache_read_input_tokens", cachedTokens)
}
if cacheWriteTokens > 0 {
- template, _ = sjson.SetBytes(template, "usage.cache_creation_input_tokens", cacheWriteTokens)
+ _ = templateDoc.Set("usage.cache_creation_input_tokens", cacheWriteTokens)
}
- template = setClaudeReasoningUsage(template, responseData.Get("usage"))
+ setClaudeReasoningUsageDoc(templateDoc, responseData.Get("usage"))
+ template = templateDoc.Bytes()
output = translatorcommon.AppendSSEEventBytes(output, "message_delta", template, 2)
output = translatorcommon.AppendSSEEventBytes(output, "message_stop", []byte(`{"type":"message_stop"}`), 2)
@@ -240,7 +245,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
params.ThinkingSummarySeen = false
// Kept only as a fallback for streams whose output_item.done omits
// encrypted_content; it is a pre-content snapshot, never the final value.
- params.ThinkingSignature = itemResult.Get("encrypted_content").String()
+ params.ThinkingSignature = strings.Clone(itemResult.Get("encrypted_content").String())
case "web_search_call":
// Defer server_tool_use until output_item.done carries action/query.
}
@@ -265,7 +270,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
return [][]byte{output}
}
var textBuilder strings.Builder
- contentResult.ForEach(func(_, part gjson.Result) bool {
+ contentResult.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() != "output_text" {
return true
}
@@ -292,9 +297,10 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = append(output, finalizeCodexThinkingBlock(params)...)
output = append(output, startCodexTextBlock(params)...)
- template = []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
- template, _ = sjson.SetBytes(template, "delta.text", text)
+ templateDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
+ _ = templateDoc.Set("index", params.BlockIndex)
+ _ = templateDoc.Set("delta.text", text)
+ template = templateDoc.Bytes()
output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
output = append(output, stopCodexTextBlock(params)...)
@@ -314,7 +320,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = append(output, finalizeCodexCommentaryBlock(params)...)
params.CommentaryMessageOpen = false
if signature := itemResult.Get("encrypted_content").String(); signature != "" {
- params.ThinkingSignature = signature
+ params.ThinkingSignature = strings.Clone(signature)
}
if !params.ThinkingSummarySeen {
if doneText := extractCodexReasoningItemText(itemResult); doneText != "" {
@@ -334,16 +340,16 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = appendCodexWebSearchToolResult(output, params, rootResult, itemResult)
}
case "response.function_call_arguments.delta":
- call := codexFunctionCallForEvent(params, rootResult, gjson.Result{})
+ call := codexFunctionCallForEvent(params, rootResult, ojson.Result{})
if call == nil {
- call = recordCodexFunctionCall(params, rootResult, gjson.Result{})
+ call = recordCodexFunctionCall(params, rootResult, ojson.Result{})
}
updateCodexFunctionCallArguments(call, rootResult.Get("delta").String(), true)
output = appendCodexFunctionCallBufferedArguments(output, params, call)
case "response.function_call_arguments.done":
- call := codexFunctionCallForEvent(params, rootResult, gjson.Result{})
+ call := codexFunctionCallForEvent(params, rootResult, ojson.Result{})
if call == nil {
- call = recordCodexFunctionCall(params, rootResult, gjson.Result{})
+ call = recordCodexFunctionCall(params, rootResult, ojson.Result{})
}
updateCodexFunctionCallArguments(call, rootResult.Get("arguments").String(), false)
output = appendCodexFunctionCallBufferedArguments(output, params, call)
@@ -355,7 +361,7 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
return [][]byte{output}
}
-func shouldDeferCodexStreamEvent(typeStr string, rootResult gjson.Result) bool {
+func shouldDeferCodexStreamEvent(typeStr string, rootResult ojson.Result) bool {
switch typeStr {
case "error", "response.completed", "response.incomplete", "response.function_call_arguments.delta", "response.function_call_arguments.done":
return false
@@ -386,7 +392,7 @@ func appendDeferredCodexStreamEvents(output []byte, originalRequestRawJSON []byt
return output
}
-func codexStreamErrorToClaudeError(rootResult gjson.Result) []byte {
+func codexStreamErrorToClaudeError(rootResult ojson.Result) []byte {
errorResult := rootResult.Get("error")
errType := strings.TrimSpace(errorResult.Get("type").String())
if errType == "" {
@@ -412,20 +418,20 @@ func codexStreamErrorToClaudeError(rootResult gjson.Result) []byte {
errType = "invalid_request_error"
}
- out := []byte(`{"type":"error","error":{"type":"api_error","message":""}}`)
- out, _ = sjson.SetBytes(out, "error.type", errType)
- out, _ = sjson.SetBytes(out, "error.message", message)
- return translatorcommon.AppendSSEEventBytes(nil, "error", out, 2)
+ outDoc := ojson.NewDocString(`{"type":"error","error":{"type":"api_error","message":""}}`)
+ _ = outDoc.Set("error.type", errType)
+ _ = outDoc.Set("error.message", message)
+ return translatorcommon.AppendSSEEventBytes(nil, "error", outDoc.Bytes(), 2)
}
// ConvertCodexResponseToClaudeNonStream converts a non-streaming Codex response to a non-streaming Claude Code response.
// This function processes the complete Codex response and transforms it into a single Claude Code-compatible
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
// the information into a single response that matches the Claude Code API format.
-func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) []byte {
+func ConvertCodexResponseToClaudeNonStream(ctx context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) []byte {
revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
typeStr := rootResult.Get("type").String()
if typeStr != "response.completed" && typeStr != "response.incomplete" {
return []byte{}
@@ -436,37 +442,37 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
return []byte{}
}
- out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
- out, _ = sjson.SetBytes(out, "id", responseData.Get("id").String())
- out, _ = sjson.SetBytes(out, "model", responseData.Get("model").String())
+ outDoc := ojson.NewDocString(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
+ _ = outDoc.Set("id", responseData.Get("id").String())
+ _ = outDoc.Set("model", responseData.Get("model").String())
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractResponsesUsage(responseData.Get("usage"))
- out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
+ _ = outDoc.Set("usage.input_tokens", inputTokens)
+ _ = outDoc.Set("usage.output_tokens", outputTokens)
if cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
+ _ = outDoc.Set("usage.cache_read_input_tokens", cachedTokens)
}
if cacheWriteTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens)
+ _ = outDoc.Set("usage.cache_creation_input_tokens", cacheWriteTokens)
}
- out = setClaudeReasoningUsage(out, responseData.Get("usage"))
+ setClaudeReasoningUsageDoc(outDoc, responseData.Get("usage"))
hasToolCall := false
webSearchSeen := make(map[string]struct{})
var contentBlocks [][]byte
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
- output.ForEach(func(_, item gjson.Result) bool {
+ output.ForEach(func(_, item ojson.Result) bool {
switch item.Get("type").String() {
case "reasoning":
thinkingText := extractCodexReasoningItemText(item)
signature := item.Get("encrypted_content").String()
if thinkingText != "" || signature != "" {
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", thinkingText)
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":""}`)
+ _ = blockDoc.Set("thinking", thinkingText)
if signature != "" {
- block, _ = sjson.SetBytes(block, "signature", signature)
+ _ = blockDoc.Set("signature", signature)
}
- contentBlocks = append(contentBlocks, block)
+ contentBlocks = append(contentBlocks, blockDoc.Bytes())
}
case "message":
// A "commentary" phase message is the model's preamble, not its answer:
@@ -477,11 +483,14 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
}
if content := item.Get("content"); content.Exists() {
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "output_text" {
text := part.Get("text").String()
if text != "" {
contentBlocks = append(contentBlocks, codexMessageTextBlock(blockType, text))
+ if blockType == "thinking" && translatorcommon.VisibleCodexCommentary(ctx) {
+ contentBlocks = append(contentBlocks, codexMessageTextBlock("text", text))
+ }
}
}
return true
@@ -490,6 +499,9 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
text := content.String()
if text != "" {
contentBlocks = append(contentBlocks, codexMessageTextBlock(blockType, text))
+ if blockType == "thinking" && translatorcommon.VisibleCodexCommentary(ctx) {
+ contentBlocks = append(contentBlocks, codexMessageTextBlock("text", text))
+ }
}
}
}
@@ -502,34 +514,34 @@ func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, original
name = original
}
- toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolBlock, _ = sjson.SetBytes(toolBlock, "id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(item.Get("call_id").String())))
- toolBlock, _ = sjson.SetBytes(toolBlock, "name", name)
+ toolBlockDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = toolBlockDoc.Set("id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(item.Get("call_id").String())))
+ _ = toolBlockDoc.Set("name", name)
inputRaw := "{}"
- if argsStr := item.Get("arguments").String(); argsStr != "" && gjson.Valid(argsStr) {
- argsJSON := gjson.Parse(argsStr)
+ if argsStr := item.Get("arguments").String(); argsStr != "" && ojson.Valid(argsStr) {
+ argsJSON := ojson.Parse(argsStr)
if argsJSON.IsObject() {
inputRaw = argsJSON.Raw
}
}
- toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
- contentBlocks = append(contentBlocks, toolBlock)
+ _ = toolBlockDoc.SetRawBytes("input", []byte(inputRaw))
+ contentBlocks = append(contentBlocks, toolBlockDoc.Bytes())
}
return true
})
}
if len(contentBlocks) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "content", contentBlocks)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "content", contentBlocks)
}
- out, _ = sjson.SetBytes(out, "stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall))
- out = setClaudeStopSequence(out, "stop_sequence", responseData)
+ _ = outDoc.Set("stop_reason", mapCodexStopReasonToClaude(codexStopReason(responseData), hasToolCall))
+ setClaudeStopSequenceDoc(outDoc, "stop_sequence", responseData)
- return out
+ return outDoc.Bytes()
}
-func codexStopReason(responseData gjson.Result) string {
+func codexStopReason(responseData ojson.Result) string {
if stopReason := responseData.Get("stop_reason"); stopReason.Exists() && stopReason.String() != "" {
if stopReason.String() == "stop" && codexStopSequence(responseData).String() != "" {
return "stop_sequence"
@@ -566,22 +578,21 @@ func mapCodexStopReasonToClaude(stopReason string, hasToolCall bool) string {
}
}
-func codexStopSequence(responseData gjson.Result) gjson.Result {
+func codexStopSequence(responseData ojson.Result) ojson.Result {
return responseData.Get("stop_sequence")
}
-func setClaudeStopSequence(out []byte, path string, responseData gjson.Result) []byte {
+func setClaudeStopSequenceDoc(doc *ojson.Doc, path string, responseData ojson.Result) {
if stopSequence := codexStopSequence(responseData); stopSequence.Exists() && stopSequence.String() != "" {
- out, _ = sjson.SetRawBytes(out, path, []byte(stopSequence.Raw))
+ _ = doc.SetRawBytes(path, []byte(stopSequence.Raw))
}
- return out
}
-func codexFunctionCallID(itemResult gjson.Result) string {
+func codexFunctionCallID(itemResult ojson.Result) string {
return itemResult.Get("call_id").String()
}
-func codexFunctionCallKeys(rootResult, itemResult gjson.Result) []string {
+func codexFunctionCallKeys(rootResult, itemResult ojson.Result) []string {
keys := make([]string, 0, 5)
if outputIndex := rootResult.Get("output_index"); outputIndex.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+outputIndex.Raw)
@@ -610,7 +621,7 @@ func appendUniqueCodexFunctionCallKey(keys []string, key string) []string {
return keys
}
}
- return append(keys, key)
+ return append(keys, strings.Clone(key))
}
func codexFunctionCallForKeys(params *ConvertCodexResponseToClaudeParams, keys []string) *codexFunctionCallStream {
@@ -625,7 +636,7 @@ func codexFunctionCallForKeys(params *ConvertCodexResponseToClaudeParams, keys [
return nil
}
-func codexFunctionCallForEvent(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) *codexFunctionCallStream {
+func codexFunctionCallForEvent(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult ojson.Result) *codexFunctionCallStream {
keys := codexFunctionCallKeys(rootResult, itemResult)
if len(keys) > 0 {
return codexFunctionCallForKeys(params, keys)
@@ -636,7 +647,7 @@ func codexFunctionCallForEvent(params *ConvertCodexResponseToClaudeParams, rootR
return params.LastFunctionCall
}
-func recordCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult gjson.Result) *codexFunctionCallStream {
+func recordCodexFunctionCall(params *ConvertCodexResponseToClaudeParams, rootResult, itemResult ojson.Result) *codexFunctionCallStream {
keys := codexFunctionCallKeys(rootResult, itemResult)
call := codexFunctionCallForKeys(params, keys)
if call == nil {
@@ -660,15 +671,15 @@ func addCodexFunctionCallAliases(params *ConvertCodexResponseToClaudeParams, cal
}
}
-func updateCodexFunctionCallIdentity(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream, rootResult, itemResult gjson.Result) {
+func updateCodexFunctionCallIdentity(params *ConvertCodexResponseToClaudeParams, call *codexFunctionCallStream, rootResult, itemResult ojson.Result) {
if call == nil {
return
}
if callID := codexFunctionCallID(itemResult); callID != "" {
- call.CallID = callID
+ call.CallID = strings.Clone(callID)
}
if name := itemResult.Get("name").String(); name != "" {
- call.Name = name
+ call.Name = strings.Clone(name)
}
addCodexFunctionCallAliases(params, call, codexFunctionCallKeys(rootResult, itemResult))
}
@@ -683,32 +694,32 @@ func updateCodexFunctionCallArguments(call *codexFunctionCallStream, arguments s
return
}
if !call.HasReceivedArgumentsDelta {
- call.Arguments = arguments
+ call.Arguments = strings.Clone(arguments)
return
}
if strings.HasPrefix(arguments, call.Arguments) {
- call.Arguments = arguments
+ call.Arguments = strings.Clone(arguments)
}
}
func appendCodexFunctionCallStart(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, callID, name string, blockIndex int) []byte {
- template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
- template, _ = sjson.SetBytes(template, "index", blockIndex)
- template, _ = sjson.SetBytes(template, "content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(callID)))
- template, _ = sjson.SetBytes(template, "content_block.name", params.resolveToolUseName(originalRequestRawJSON, name))
- return translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
+ templateDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
+ _ = templateDoc.Set("index", blockIndex)
+ _ = templateDoc.Set("content_block.id", shortenCodexCallIDIfNeeded(util.SanitizeClaudeToolID(callID)))
+ _ = templateDoc.Set("content_block.name", params.resolveToolUseName(originalRequestRawJSON, name))
+ return translatorcommon.AppendSSEEventBytes(output, "content_block_start", templateDoc.Bytes(), 2)
}
func appendCodexFunctionCallArgumentDelta(output []byte, partialJSON string, blockIndex int) []byte {
- template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
- template, _ = sjson.SetBytes(template, "index", blockIndex)
- template, _ = sjson.SetBytes(template, "delta.partial_json", partialJSON)
- return translatorcommon.AppendSSEEventBytes(output, "content_block_delta", template, 2)
+ templateDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
+ _ = templateDoc.Set("index", blockIndex)
+ _ = templateDoc.Set("delta.partial_json", partialJSON)
+ return translatorcommon.AppendSSEEventBytes(output, "content_block_delta", templateDoc.Bytes(), 2)
}
func appendCodexFunctionCallStop(output []byte, blockIndex int) []byte {
template := []byte(`{"type":"content_block_stop","index":0}`)
- template, _ = sjson.SetBytes(template, "index", blockIndex)
+ template, _ = ojson.SetBytes(template, "index", blockIndex)
return translatorcommon.AppendSSEEventBytes(output, "content_block_stop", template, 2)
}
@@ -782,17 +793,17 @@ func removeCodexFunctionCallFromQueue(params *ConvertCodexResponseToClaudeParams
}
}
-func appendCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, responseData gjson.Result) []byte {
+func appendCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexResponseToClaudeParams, originalRequestRawJSON []byte, responseData ojson.Result) []byte {
if params == nil {
return output
}
- responseData.Get("output").ForEach(func(index, item gjson.Result) bool {
+ responseData.Get("output").ForEach(func(index, item ojson.Result) bool {
if item.Get("type").String() != "function_call" {
return true
}
- keys := codexFunctionCallKeys(gjson.Result{}, item)
+ keys := codexFunctionCallKeys(ojson.Result{}, item)
if itemOutputIndex := item.Get("output_index"); itemOutputIndex.Exists() {
keys = appendUniqueCodexFunctionCallKey(keys, "output:"+itemOutputIndex.Raw)
}
@@ -805,7 +816,7 @@ func appendCodexFunctionCallsFromTerminal(output []byte, params *ConvertCodexRes
params.FunctionCallQueue = append(params.FunctionCallQueue, call)
}
addCodexFunctionCallAliases(params, call, keys)
- updateCodexFunctionCallIdentity(params, call, gjson.Result{}, item)
+ updateCodexFunctionCallIdentity(params, call, ojson.Result{}, item)
updateCodexFunctionCallArguments(call, item.Get("arguments").String(), false)
call.Done = true
return true
@@ -864,8 +875,8 @@ func resolveCodexClaudeToolUseName(originalRequestRawJSON []byte, name string) s
return name
}
-func extractResponsesUsage(usage gjson.Result) (int64, int64, int64, int64) {
- if !usage.Exists() || usage.Type == gjson.Null {
+func extractResponsesUsage(usage ojson.Result) (int64, int64, int64, int64) {
+ if !usage.Exists() || usage.Type == ojson.Null {
return 0, 0, 0, 0
}
@@ -903,13 +914,13 @@ func extractResponsesUsage(usage gjson.Result) (int64, int64, int64, int64) {
return inputTokens, outputTokens, cachedTokens, cacheWriteTokens
}
-func setClaudeReasoningUsage(out []byte, usage gjson.Result) []byte {
+func setClaudeReasoningUsageDoc(doc *ojson.Doc, usage ojson.Result) {
detail := usage.Get("output_tokens_details.reasoning_tokens")
- if !detail.Exists() || detail.Type != gjson.Number {
- return out
+ if !detail.Exists() || detail.Type != ojson.Number {
+ return
}
if strings.HasPrefix(detail.Raw, "-") || detail.Num < 0 {
- return out
+ return
}
outputTokens := max(int64(0), usage.Get("output_tokens").Int())
var tokens int64
@@ -918,11 +929,7 @@ func setClaudeReasoningUsage(out []byte, usage gjson.Result) []byte {
} else {
tokens = detail.Int()
}
- updated, errSetBytes := sjson.SetBytes(out, "usage.output_tokens_details.thinking_tokens", tokens)
- if errSetBytes != nil {
- return out
- }
- return updated
+ _ = doc.Set("usage.output_tokens_details.thinking_tokens", tokens)
}
// buildReverseMapFromClaudeOriginalShortToOriginal builds a map[short]original from original Claude request tools.
@@ -945,7 +952,7 @@ func startCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
}
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
+ template, _ = ojson.SetBytes(template, "index", params.BlockIndex)
params.TextBlockOpen = true
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
@@ -967,13 +974,13 @@ func finalizeCodexCommentaryBlock(params *ConvertCodexResponseToClaudeParams) []
// internal commentary marker in its signature field.
func codexMessageTextBlock(blockType, text string) []byte {
if blockType == "thinking" {
- block := []byte(`{"type":"thinking","thinking":"","signature":""}`)
- block, _ = sjson.SetBytes(block, "thinking", text)
- block, _ = sjson.SetBytes(block, "signature", codexCommentarySignatureMarker)
- return block
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":"","signature":""}`)
+ _ = blockDoc.Set("thinking", text)
+ _ = blockDoc.Set("signature", codexCommentarySignatureMarker)
+ return blockDoc.Bytes()
}
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", text)
+ block, _ = ojson.SetBytes(block, "text", text)
return block
}
@@ -983,18 +990,18 @@ func stopCodexTextBlock(params *ConvertCodexResponseToClaudeParams) []byte {
}
template := []byte(`{"type":"content_block_stop","index":0}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
+ template, _ = ojson.SetBytes(template, "index", params.BlockIndex)
params.TextBlockOpen = false
params.BlockIndex++
return translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", template, 2)
}
-func extractCodexReasoningItemText(item gjson.Result) string {
+func extractCodexReasoningItemText(item ojson.Result) string {
thinkingBuilder := strings.Builder{}
if summary := item.Get("summary"); summary.Exists() {
if summary.IsArray() {
- summary.ForEach(func(_, part gjson.Result) bool {
+ summary.ForEach(func(_, part ojson.Result) bool {
if txt := part.Get("text"); txt.Exists() {
thinkingBuilder.WriteString(txt.String())
} else {
@@ -1009,7 +1016,7 @@ func extractCodexReasoningItemText(item gjson.Result) string {
if thinkingBuilder.Len() == 0 {
if content := item.Get("content"); content.Exists() {
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if txt := part.Get("text"); txt.Exists() {
thinkingBuilder.WriteString(txt.String())
} else {
@@ -1031,7 +1038,7 @@ func startCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte
}
template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
+ template, _ = ojson.SetBytes(template, "index", params.BlockIndex)
params.ThinkingBlockOpen = true
return translatorcommon.AppendSSEEventBytes(nil, "content_block_start", template, 2)
@@ -1042,12 +1049,15 @@ func appendCodexThinkingDelta(params *ConvertCodexResponseToClaudeParams, text s
if text == "" {
return nil
}
+ if params.CommentaryMessageOpen && params.VisiblePublicCommentary {
+ params.PublicCommentaryText.WriteString(text)
+ }
- template := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
- template, _ = sjson.SetBytes(template, "delta.thinking", text)
+ templateDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
+ _ = templateDoc.Set("index", params.BlockIndex)
+ _ = templateDoc.Set("delta.thinking", text)
- return translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", template, 2)
+ return translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", templateDoc.Bytes(), 2)
}
func finalizeCodexSignatureOnlyThinkingBlock(params *ConvertCodexResponseToClaudeParams) []byte {
@@ -1074,18 +1084,32 @@ func finalizeCodexThinkingBlock(params *ConvertCodexResponseToClaudeParams) []by
params.ThinkingSignature = ""
}
if signatureToEmit != "" {
- signatureDelta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":""}}`)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", params.BlockIndex)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signatureToEmit)
- output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", signatureDelta, 2)
+ signatureDeltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":""}}`)
+ _ = signatureDeltaDoc.Set("index", params.BlockIndex)
+ _ = signatureDeltaDoc.Set("delta.signature", signatureToEmit)
+ output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", signatureDeltaDoc.Bytes(), 2)
}
contentBlockStop := []byte(`{"type":"content_block_stop","index":0}`)
- contentBlockStop, _ = sjson.SetBytes(contentBlockStop, "index", params.BlockIndex)
+ contentBlockStop, _ = ojson.SetBytes(contentBlockStop, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", contentBlockStop, 2)
params.BlockIndex++
params.ThinkingBlockOpen = false
+ // Preserve the phase marker for lossless replay and mirror public commentary
+ // into visible text. Genuine reasoning is never mirrored.
+ if signatureToEmit == codexCommentarySignatureMarker && params.PublicCommentaryText.Len() > 0 {
+ text := params.PublicCommentaryText.String()
+ params.PublicCommentaryText.Reset()
+ output = append(output, startCodexTextBlock(params)...)
+ deltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
+ _ = deltaDoc.Set("index", params.BlockIndex)
+ _ = deltaDoc.Set("delta.text", text)
+ output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", deltaDoc.Bytes(), 2)
+ output = append(output, stopCodexTextBlock(params)...)
+ } else {
+ params.PublicCommentaryText.Reset()
+ }
return output
}
diff --git a/internal/translator/codex/claude/codex_claude_response_test.go b/internal/translator/codex/claude/codex_claude_response_test.go
index 8de5dd84c16..4514e4b656b 100644
--- a/internal/translator/codex/claude/codex_claude_response_test.go
+++ b/internal/translator/codex/claude/codex_claude_response_test.go
@@ -7,7 +7,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing.T) {
@@ -37,7 +38,7 @@ func TestConvertCodexResponseToClaude_StreamThinkingIncludesSignature(t *testing
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
if data.Get("content_block.type").String() == "thinking" {
@@ -145,7 +146,7 @@ func TestConvertCodexResponseToClaude_StreamThinkingWithoutReasoningItemStillInc
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" {
thinkingStartFound = true
if data.Get("content_block.signature").Exists() {
@@ -203,7 +204,7 @@ func digestCodexThinkingStream(t *testing.T, chunks [][]byte) codexThinkingStrea
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
if data.Get("content_block.type").String() == "thinking" {
@@ -357,7 +358,7 @@ func TestConvertCodexResponseToClaude_StreamThinkingUsesEarlyCapturedSignatureWh
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "signature_delta" {
signatureDeltaCount++
if got := data.Get("delta.signature").String(); got != "enc_sig_early" {
@@ -397,7 +398,7 @@ func TestConvertCodexResponseToClaude_StreamThinkingUsesFinalDoneSignature(t *te
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "thinking" {
events = append(events, "thinking_start")
}
@@ -456,7 +457,7 @@ func TestConvertCodexResponseToClaude_StreamSignatureOnlyReasoningEmitsThinkingS
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
if data.Get("content_block.type").String() == "thinking" {
@@ -537,7 +538,7 @@ func TestConvertCodexResponseToClaudeNonStream_ThinkingIncludesSignature(t *test
}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
thinking := parsed.Get("content.0")
if thinking.Get("type").String() != "thinking" {
@@ -586,7 +587,7 @@ func TestConvertCodexResponseToClaude_StreamTextBeforeToolCallsDoesNotEmitGhostS
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
startIndices = append(startIndices, data.Get("index").Int())
@@ -635,7 +636,7 @@ func TestConvertCodexResponseToClaude_StreamFunctionCallDefersStartUntilDoneName
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
if data.Get("content_block.type").String() != "tool_use" {
@@ -693,7 +694,7 @@ func TestConvertCodexResponseToClaude_StreamUnnamedFunctionCallDoneByCallIDKeeps
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_start":
if data.Get("content_block.type").String() == "tool_use" {
@@ -745,7 +746,7 @@ func TestConvertCodexResponseToClaude_StreamDeferredUnnamedFunctionCallDoesNotRe
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "text" {
if got := data.Get("index").Int(); got != 0 {
t.Fatalf("text block index = %d, want 0; outputs=%q", got, outputs)
@@ -784,7 +785,7 @@ func TestConvertCodexResponseToClaude_StreamTerminalOutputHydratesOpenFunctionCa
continue
}
position++
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
switch data.Get("type").String() {
case "content_block_delta":
if data.Get("delta.type").String() == "input_json_delta" && data.Get("delta.partial_json").String() == `{"query":"example"}` {
@@ -907,7 +908,7 @@ func TestConvertCodexResponseToClaude_StreamEmptyOutputUsesOutputItemDoneMessage
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_delta" && data.Get("delta.type").String() == "text_delta" && data.Get("delta.text").String() == "ok" {
foundText = true
break
@@ -1009,13 +1010,13 @@ func TestConvertCodexResponseToClaude_StreamWebSearchCallActionSources(t *testin
outputs = append(outputs, ConvertCodexResponseToClaude(ctx, "", originalRequest, nil, chunk, ¶m)...)
}
- var webSearchResultBlock gjson.Result
+ var webSearchResultBlock ojson.Result
for _, out := range outputs {
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "web_search_tool_result" {
webSearchResultBlock = data.Get("content_block")
}
@@ -1062,7 +1063,7 @@ func TestConvertCodexResponseToClaude_ShortensLongToolUseIDs(t *testing.T) {
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "content_block_start" && data.Get("content_block.type").String() == "tool_use" {
toolID = data.Get("content_block.id").String()
}
@@ -1094,7 +1095,7 @@ func TestConvertCodexResponseToClaude_ShortensLongToolUseIDs(t *testing.T) {
}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
- toolID := gjson.GetBytes(out, "content.0.id").String()
+ toolID := ojson.GetBytesNoCopy(out, "content.0.id").String()
if toolID == "" {
t.Fatalf("missing nonstream tool_use id. Output: %s", string(out))
}
@@ -1189,9 +1190,9 @@ func TestConvertCodexResponseToClaudeNonStream_WebSearchCallEmitsServerToolBlock
originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`)
response := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex-spark","stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"web_search_call","id":"ws_123","status":"completed","action":{"type":"search","query":"search weather"}},{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
types := []string{}
- parsed.Get("content").ForEach(func(_, value gjson.Result) bool {
+ parsed.Get("content").ForEach(func(_, value ojson.Result) bool {
types = append(types, value.Get("type").String())
return true
})
@@ -1222,7 +1223,7 @@ func TestConvertCodexResponseToClaudeNonStream_WebSearchStopReasonEndTurn(t *tes
originalRequest := []byte(`{"tools":[{"type":"web_search_20250305","name":"web_search"}],"messages":[{"role":"user","content":"search weather"}]}`)
response := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex-spark","stop_reason":"stop","usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"web_search_call","id":"ws_123","status":"completed","action":{"type":"search","query":"search weather"}},{"type":"message","content":[{"type":"output_text","text":"done"}]}]}}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if got := parsed.Get("stop_reason").String(); got != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn when only server web_search and text are present", got)
}
@@ -1275,9 +1276,9 @@ func TestConvertCodexResponseToClaudeNonStream_WebSearchCallActionSources(t *tes
}
}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
- parsed := gjson.ParseBytes(out)
- var resultContent gjson.Result
- parsed.Get("content").ForEach(func(_, block gjson.Result) bool {
+ parsed := util.ParseGJSONBytesNoCopy(out)
+ var resultContent ojson.Result
+ parsed.Get("content").ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "web_search_tool_result" {
resultContent = block.Get("content")
return false
@@ -1374,7 +1375,7 @@ func TestConvertCodexResponseToClaudeNonStream_StopReasonMapping(t *testing.T) {
ctx := context.Background()
originalRequest := []byte(`{"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{}}}]}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, tt.response, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if got := parsed.Get("stop_reason").String(); got != tt.wantReason {
t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tt.wantReason, string(out))
@@ -1399,7 +1400,7 @@ func TestConvertCodexResponseToClaudeNonStream_StopSequenceMapping(t *testing.T)
}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, response, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
if got := parsed.Get("stop_reason").String(); got != "stop_sequence" {
t.Fatalf("stop_reason = %q, want stop_sequence. Output: %s", got, string(out))
@@ -1417,22 +1418,22 @@ func findClaudeStreamStopReason(outputs [][]byte) (string, bool) {
return messageDelta.Get("delta.stop_reason").String(), true
}
-func findClaudeStreamMessageDelta(outputs [][]byte) (gjson.Result, bool) {
+func findClaudeStreamMessageDelta(outputs [][]byte) (ojson.Result, bool) {
for _, out := range outputs {
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "data: ") {
continue
}
- data := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ data := ojson.Parse(strings.TrimPrefix(line, "data: "))
if data.Get("type").String() == "message_delta" {
return data, true
}
}
}
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
-func firstClaudeStreamPayloadForEvent(output, event string) (gjson.Result, bool) {
+func firstClaudeStreamPayloadForEvent(output, event string) (ojson.Result, bool) {
var currentEvent string
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "event: ") {
@@ -1442,9 +1443,9 @@ func firstClaudeStreamPayloadForEvent(output, event string) (gjson.Result, bool)
if currentEvent != event || !strings.HasPrefix(line, "data: ") {
continue
}
- return gjson.Parse(strings.TrimPrefix(line, "data: ")), true
+ return ojson.Parse(strings.TrimPrefix(line, "data: ")), true
}
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
func TestConvertCodexResponseToClaude_StreamPreservesCacheWriteUsage(t *testing.T) {
@@ -1667,7 +1668,7 @@ func TestConvertCodexResponseToClaudeNonStream_PreservesCacheWriteUsage(t *testi
ctx := context.Background()
originalRequest := []byte(`{"messages":[]}`)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, []byte(tt.responseJSON), nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
usage := parsed.Get("usage")
if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens {
@@ -1896,7 +1897,7 @@ func TestConvertCodexResponseToClaudeNonStream_PreservesReasoningUsage(t *testin
}
}`, tt.usageJSON)
out := ConvertCodexResponseToClaudeNonStream(ctx, "", originalRequest, nil, []byte(responseJSON), nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
usage := parsed.Get("usage")
if got := usage.Get("output_tokens").Int(); got != tt.wantOutputTokens {
@@ -2028,7 +2029,7 @@ func TestExtractResponsesUsage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- usage := gjson.Parse(tt.rawUsage)
+ usage := ojson.Parse(tt.rawUsage)
input, output, cached, cacheWrite := extractResponsesUsage(usage)
if input != tt.wantInputTokens {
t.Fatalf("input_tokens = %d, want %d", input, tt.wantInputTokens)
@@ -2080,8 +2081,8 @@ func TestConvertCodexResponseToClaude_RestoresLongMidConversationToolAdditionNam
if err != nil {
t.Fatalf("ConvertClaudeRequestToCodex returned error: %v", err)
}
- topShortName := gjson.GetBytes(translatedReq, "tools.0.name").String()
- midShortName := gjson.GetBytes(translatedReq, "input.1.tools.0.name").String()
+ topShortName := ojson.GetBytesNoCopy(translatedReq, "tools.0.name").String()
+ midShortName := ojson.GetBytesNoCopy(translatedReq, "input.1.tools.0.name").String()
if topShortName == "" || midShortName == "" || topShortName == midShortName {
t.Fatalf("expected distinct shortened names for colliding MCP tools, got top=%q mid=%q", topShortName, midShortName)
}
@@ -2098,10 +2099,10 @@ func TestConvertCodexResponseToClaude_RestoresLongMidConversationToolAdditionNam
// 2. Non-streaming response restoration
completedEvent := []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-6.1-sol","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":%q,"arguments":"{}"},{"type":"function_call","id":"fc_2","call_id":"call_2","name":%q,"arguments":"{}"}],"usage":{"input_tokens":10,"output_tokens":5}}}`, topShortName, midShortName))
nonStreamOut := ConvertCodexResponseToClaudeNonStream(context.Background(), "gpt-6.1-sol", originalRequest, nil, completedEvent, nil)
- if got := gjson.GetBytes(nonStreamOut, "content.0.name").String(); got != topLongMCPTool {
+ if got := ojson.GetBytesNoCopy(nonStreamOut, "content.0.name").String(); got != topLongMCPTool {
t.Fatalf("non-streaming response content.0.name = %q, want %q; body=%s", got, topLongMCPTool, nonStreamOut)
}
- if got := gjson.GetBytes(nonStreamOut, "content.1.name").String(); got != midLongMCPTool {
+ if got := ojson.GetBytesNoCopy(nonStreamOut, "content.1.name").String(); got != midLongMCPTool {
t.Fatalf("non-streaming response content.1.name = %q, want %q; body=%s", got, midLongMCPTool, nonStreamOut)
}
}
diff --git a/internal/translator/codex/claude/codex_claude_response_web_search.go b/internal/translator/codex/claude/codex_claude_response_web_search.go
index 333e021a84f..9ba87d082d2 100644
--- a/internal/translator/codex/claude/codex_claude_response_web_search.go
+++ b/internal/translator/codex/claude/codex_claude_response_web_search.go
@@ -5,12 +5,11 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
-func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte {
+func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexResponseToClaudeParams, root, item ojson.Result) []byte {
toolUseID := codexWebSearchToolUseID(params, root, item)
if toolUseID == "" {
return output
@@ -30,23 +29,23 @@ func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexRespon
if !alreadyStarted {
output = append(output, stopCodexTextBlock(params)...)
output = append(output, finalizeCodexThinkingBlock(params)...)
- template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"","name":"web_search","input":{}}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
- template, _ = sjson.SetBytes(template, "content_block.id", toolUseID)
- output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
+ templateDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"server_tool_use","id":"","name":"web_search","input":{}}}`)
+ _ = templateDoc.Set("index", params.BlockIndex)
+ _ = templateDoc.Set("content_block.id", toolUseID)
+ output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", templateDoc.Bytes(), 2)
}
if query != "" {
partialJSON, _ := json.Marshal(map[string]string{"query": query})
- delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
- delta, _ = sjson.SetBytes(delta, "index", params.BlockIndex)
- delta, _ = sjson.SetBytes(delta, "delta.partial_json", string(partialJSON))
- output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", delta, 2)
+ deltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
+ _ = deltaDoc.Set("index", params.BlockIndex)
+ _ = deltaDoc.Set("delta.partial_json", string(partialJSON))
+ output = translatorcommon.AppendSSEEventBytes(output, "content_block_delta", deltaDoc.Bytes(), 2)
}
if !alreadyStarted {
stop := []byte(`{"type":"content_block_stop","index":0}`)
- stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex)
+ stop, _ = ojson.SetBytes(stop, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2)
params.WebSearchToolUseIDs[toolUseID] = struct{}{}
params.BlockIndex++
@@ -54,7 +53,7 @@ func appendCodexWebSearchServerToolUse(output []byte, params *ConvertCodexRespon
return output
}
-func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) []byte {
+func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseToClaudeParams, root, item ojson.Result) []byte {
toolUseID := codexWebSearchToolUseID(params, root, item)
if toolUseID == "" {
return output
@@ -70,16 +69,16 @@ func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseT
return output
}
- template := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"","content":[]}}`)
- template, _ = sjson.SetBytes(template, "index", params.BlockIndex)
- template, _ = sjson.SetBytes(template, "content_block.tool_use_id", toolUseID)
+ templateDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"web_search_tool_result","tool_use_id":"","content":[]}}`)
+ _ = templateDoc.Set("index", params.BlockIndex)
+ _ = templateDoc.Set("content_block.tool_use_id", toolUseID)
if content := codexWebSearchResultContent(root, item); len(content) > 0 {
- template, _ = sjson.SetRawBytes(template, "content_block.content", content)
+ _ = templateDoc.SetRawBytes("content_block.content", content)
}
- output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", template, 2)
+ output = translatorcommon.AppendSSEEventBytes(output, "content_block_start", templateDoc.Bytes(), 2)
stop := []byte(`{"type":"content_block_stop","index":0}`)
- stop, _ = sjson.SetBytes(stop, "index", params.BlockIndex)
+ stop, _ = ojson.SetBytes(stop, "index", params.BlockIndex)
output = translatorcommon.AppendSSEEventBytes(output, "content_block_stop", stop, 2)
params.WebSearchToolResultIDs[toolUseID] = struct{}{}
params.BlockIndex++
@@ -89,13 +88,13 @@ func appendCodexWebSearchToolResult(output []byte, params *ConvertCodexResponseT
return output
}
-func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, item gjson.Result) string {
+func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, item ojson.Result) string {
for _, path := range []string{"id", "output_item_id", "call_id"} {
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
- return value
+ return strings.Clone(value)
}
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
- return value
+ return strings.Clone(value)
}
}
if params.LastWebSearchToolUseID != "" {
@@ -103,10 +102,10 @@ func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, i
}
for _, path := range []string{"item_id"} {
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
- return value
+ return strings.Clone(value)
}
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
- return value
+ return strings.Clone(value)
}
}
id := fmt.Sprintf("web_search_%d", params.BlockIndex)
@@ -114,7 +113,7 @@ func codexWebSearchToolUseID(params *ConvertCodexResponseToClaudeParams, root, i
return id
}
-func codexWebSearchQuery(root, item gjson.Result) string {
+func codexWebSearchQuery(root, item ojson.Result) string {
for _, path := range []string{"action.query", "query", "input.query"} {
if value := strings.TrimSpace(item.Get(path).String()); value != "" {
return value
@@ -126,9 +125,9 @@ func codexWebSearchQuery(root, item gjson.Result) string {
return ""
}
-func codexWebSearchResultContent(root, item gjson.Result) []byte {
- var results gjson.Result
- for _, candidate := range []gjson.Result{
+func codexWebSearchResultContent(root, item ojson.Result) []byte {
+ var results ojson.Result
+ for _, candidate := range []ojson.Result{
item.Get("results"),
root.Get("results"),
item.Get("action.sources"),
@@ -143,19 +142,19 @@ func codexWebSearchResultContent(root, item gjson.Result) []byte {
return nil
}
var resultBlocks [][]byte
- results.ForEach(func(_, result gjson.Result) bool {
+ results.ForEach(func(_, result ojson.Result) bool {
url := strings.TrimSpace(result.Get("url").String())
if url == "" {
return true
}
- block := []byte(`{"type":"web_search_result","title":"","url":"","page_age":null}`)
- block, _ = sjson.SetBytes(block, "url", url)
+ blockDoc := ojson.NewDocString(`{"type":"web_search_result","title":"","url":"","page_age":null}`)
+ _ = blockDoc.Set("url", url)
title := strings.TrimSpace(result.Get("title").String())
if title == "" {
title = url
}
- block, _ = sjson.SetBytes(block, "title", title)
- resultBlocks = append(resultBlocks, block)
+ _ = blockDoc.Set("title", title)
+ resultBlocks = append(resultBlocks, blockDoc.Bytes())
return true
})
if len(resultBlocks) == 0 {
@@ -164,7 +163,7 @@ func codexWebSearchResultContent(root, item gjson.Result) []byte {
return translatorcommon.JoinRawArray(resultBlocks)
}
-func appendCodexWebSearchNonStreamBlocks(contentBlocks [][]byte, item gjson.Result, seen map[string]struct{}) [][]byte {
+func appendCodexWebSearchNonStreamBlocks(contentBlocks [][]byte, item ojson.Result, seen map[string]struct{}) [][]byte {
id := strings.TrimSpace(item.Get("id").String())
if id == "" {
return contentBlocks
@@ -175,35 +174,39 @@ func appendCodexWebSearchNonStreamBlocks(contentBlocks [][]byte, item gjson.Resu
if _, ok := seen[id]; ok {
return contentBlocks
}
- emptyRoot := gjson.Result{}
+ emptyRoot := ojson.Result{}
query := codexWebSearchQuery(emptyRoot, item)
resultContent := codexWebSearchResultContent(emptyRoot, item)
if query == "" && len(resultContent) == 0 {
return contentBlocks
}
- useBlock := []byte(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
- useBlock, _ = sjson.SetBytes(useBlock, "id", id)
+ useBlockDoc := ojson.NewDocString(`{"type":"server_tool_use","id":"","name":"web_search","input":{}}`)
+ _ = useBlockDoc.Set("id", id)
if query != "" {
input, _ := json.Marshal(map[string]string{"query": query})
- useBlock, _ = sjson.SetRawBytes(useBlock, "input", input)
+ _ = useBlockDoc.SetRawBytes("input", input)
}
- contentBlocks = append(contentBlocks, useBlock)
+ contentBlocks = append(contentBlocks, useBlockDoc.Bytes())
- resultBlock := []byte(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
- resultBlock, _ = sjson.SetBytes(resultBlock, "tool_use_id", id)
+ resultBlockDoc := ojson.NewDocString(`{"type":"web_search_tool_result","tool_use_id":"","content":[]}`)
+ _ = resultBlockDoc.Set("tool_use_id", id)
if len(resultContent) > 0 {
- resultBlock, _ = sjson.SetRawBytes(resultBlock, "content", resultContent)
+ _ = resultBlockDoc.SetRawBytes("content", resultContent)
}
- contentBlocks = append(contentBlocks, resultBlock)
+ contentBlocks = append(contentBlocks, resultBlockDoc.Bytes())
seen[id] = struct{}{}
return contentBlocks
}
-func appendCodexWebSearchNonStreamContent(out []byte, item gjson.Result, seen map[string]struct{}) []byte {
+func appendCodexWebSearchNonStreamContent(out []byte, item ojson.Result, seen map[string]struct{}) []byte {
blocks := appendCodexWebSearchNonStreamBlocks(nil, item, seen)
+ if len(blocks) == 0 {
+ return out
+ }
+ outDoc := ojson.NewDoc(out)
for _, block := range blocks {
- out, _ = sjson.SetRawBytes(out, "content.-1", block)
+ _ = outDoc.SetRawBytes("content.-1", block)
}
- return out
+ return outDoc.Bytes()
}
diff --git a/internal/translator/codex/claude/codex_claude_tool_names_test.go b/internal/translator/codex/claude/codex_claude_tool_names_test.go
new file mode 100644
index 00000000000..d85ff71a6f5
--- /dev/null
+++ b/internal/translator/codex/claude/codex_claude_tool_names_test.go
@@ -0,0 +1,65 @@
+package claude
+
+import (
+ "strings"
+ "testing"
+)
+
+const gatewayPrefix = "mcp__gateway__mcp_github-12345678-9abc-def0-1234-56789abcdef0-12345678_"
+
+func TestBuildShortNameMapKeepsGatewayToolNamesDistinct(t *testing.T) {
+ names := []string{
+ gatewayPrefix + "add-comment-to-pending-review",
+ gatewayPrefix + "create-branch",
+ gatewayPrefix + "get_file_contents",
+ "mcp__gateway__mcp_playwright-browser-67892a0b_browser_click",
+ "Bash",
+ }
+ m := buildShortNameMap(names)
+ want := map[string]string{
+ names[0]: "mcp__github_add-comment-to-pending-review",
+ names[1]: "mcp__github_create-branch",
+ names[2]: "mcp__github_get_file_contents",
+ names[3]: "mcp__gateway__mcp_playwright-browser-67892a0b_browser_click",
+ names[4]: "Bash",
+ }
+ for orig, short := range want {
+ if got := m[orig]; got != short {
+ t.Errorf("short(%q) = %q, want %q", orig, got, short)
+ }
+ if len(m[orig]) > 64 {
+ t.Errorf("short(%q) is %d chars", orig, len(m[orig]))
+ }
+ }
+}
+
+func TestBuildShortNameMapDisambiguatesSameToolOnTwoServers(t *testing.T) {
+ a := gatewayPrefix + "create-branch"
+ b := "mcp__gateway__mcp_github-0b9e7c1d-1111-2222-3333-444455556666-89abcdef_create-branch"
+ m := buildShortNameMap([]string{a, b})
+ if m[a] == m[b] {
+ t.Fatalf("both servers mapped to %q", m[a])
+ }
+ if !strings.HasPrefix(m[b], "mcp__github_create-branch") {
+ t.Fatalf("second server lost its readable name: %q", m[b])
+ }
+}
+
+func TestReverseMapRestoresReadableGatewayNames(t *testing.T) {
+ orig := gatewayPrefix + "add-issue-comment"
+ req := []byte(`{"tools":[{"name":"` + orig + `","input_schema":{"type":"object"}}]}`)
+ rev := buildReverseMapFromClaudeOriginalShortToOriginal(req)
+ if got := rev["mcp__github_add-issue-comment"]; got != orig {
+ t.Fatalf("reverse map = %q, want %q", got, orig)
+ }
+ if got := shortenNameIfNeeded(orig); got != "mcp__github_add-issue-comment" {
+ t.Fatalf("single-name shortening = %q", got)
+ }
+}
+
+func TestShortenNameIfNeededFallsBackForUnknownLongNames(t *testing.T) {
+ long := "mcp__srv__" + strings.Repeat("x", 80)
+ if got := shortenNameIfNeeded(long); len(got) != 64 || !strings.HasPrefix(got, "mcp__xxx") {
+ t.Fatalf("fallback = %q", got)
+ }
+}
diff --git a/internal/translator/codex/claude/codex_claude_user_turn_test.go b/internal/translator/codex/claude/codex_claude_user_turn_test.go
index ae4637f63bb..800695e91b6 100644
--- a/internal/translator/codex/claude/codex_claude_user_turn_test.go
+++ b/internal/translator/codex/claude/codex_claude_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const userTurnUpload = `{"type":"container_upload","file_id":"file-1"}`
@@ -49,7 +49,7 @@ func TestConvertClaudeRequestToCodex_RefusesAnyEmptiedUserTurn(t *testing.T) {
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -62,7 +62,7 @@ func TestConvertClaudeRequestToCodex_KeepsTurnWithTextBesideAttachment(t *testin
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -73,7 +73,7 @@ func TestConvertClaudeRequestToCodex_Base64DocumentAfterHistoryStaysAFile(t *tes
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "input.2.content.0")
if part.Get("type").String() != "input_file" || part.Get("file_data").String() != "data:application/pdf;base64,JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -91,7 +91,7 @@ func TestConvertClaudeRequestToCodex_EmptyTextDoesNotHideAnEmptiedUserTurn(t *te
if !errors.As(err, &unsupported) || unsupported.Type != "container_upload" || unsupported.StatusCode() != 400 {
t.Fatalf("err = %v, want unsupported content part: container_upload; body = %s", err, body)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -104,7 +104,7 @@ func TestConvertClaudeRequestToCodex_NonEmptyTextBesideAttachmentStillSucceeds(t
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.0.content.1.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "input.0.content.1.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -152,7 +152,7 @@ func TestClaudeToCodexImageOnlyTurnKeepsTheImage(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0")
if part.Get("type").String() != "input_image" || part.Get(tc.field).String() != tc.want {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -187,7 +187,7 @@ func TestClaudeToCodexUnrepresentableImageBesideTextStillSucceeds(t *testing.T)
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", envelope.Body)
}
}
diff --git a/internal/translator/codex/claude/init.go b/internal/translator/codex/claude/init.go
index eb121ece9d9..859ec005963 100644
--- a/internal/translator/codex/claude/init.go
+++ b/internal/translator/codex/claude/init.go
@@ -1,9 +1,12 @@
package claude
import (
+ "context"
+
. "github.com/router-for-me/CLIProxyAPI/v8/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/translator"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
)
func init() {
@@ -17,4 +20,9 @@ func init() {
TokenCount: ClaudeTokenCount,
},
)
+ sdktranslator.RegisterRequestEnvelope(sdktranslator.FormatClaude, sdktranslator.FormatCodex,
+ func(ctx context.Context, req sdktranslator.RequestEnvelope) sdktranslator.RequestEnvelope {
+ req.Body, req.Err = ConvertClaudeRequestToCodexContext(ctx, req.Model, req.Body, req.Stream)
+ return req
+ })
}
diff --git a/internal/translator/codex/claude/noop_optimization_test.go b/internal/translator/codex/claude/noop_optimization_test.go
index 55a59ba546d..e51b8377f31 100644
--- a/internal/translator/codex/claude/noop_optimization_test.go
+++ b/internal/translator/codex/claude/noop_optimization_test.go
@@ -3,7 +3,7 @@ package claude
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeRequestToCodexNormalizesNonStringToolName(t *testing.T) {
@@ -11,8 +11,8 @@ func TestConvertClaudeRequestToCodexNormalizesNonStringToolName(t *testing.T) {
output, _ := ConvertClaudeRequestToCodex("gpt-test", input, false)
- name := gjson.GetBytes(output, "tools.0.name")
- if name.Type != gjson.String || name.String() != "123" {
+ name := ojson.GetBytesNoCopy(output, "tools.0.name")
+ if name.Type != ojson.String || name.String() != "123" {
t.Fatalf("tools.0.name = %s, want string 123", name.Raw)
}
}
diff --git a/internal/translator/codex/claude/public_commentary_test.go b/internal/translator/codex/claude/public_commentary_test.go
new file mode 100644
index 00000000000..7337e18192b
--- /dev/null
+++ b/internal/translator/codex/claude/public_commentary_test.go
@@ -0,0 +1,115 @@
+package claude
+
+import (
+ "context"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+func TestVisiblePublicCommentaryPreservesReasoningAndFinalAnswer(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "1")
+ chunks := [][]byte{
+ []byte(`data: {"type":"response.created","response":{"id":"r","model":"gpt-6.1-sol"}}`),
+ []byte(`data: {"type":"response.output_item.added","item":{"type":"reasoning","encrypted_content":"secret-signature"}}`),
+ []byte(`data: {"type":"response.reasoning_summary_part.added"}`),
+ []byte(`data: {"type":"response.reasoning_summary_text.delta","delta":"Private reasoning summary"}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"reasoning","encrypted_content":"secret-signature"}}`),
+ []byte(`data: {"type":"response.output_item.added","item":{"type":"message","role":"assistant","phase":"commentary"}}`),
+ []byte(`data: {"type":"response.output_text.delta","delta":"Two agents are working."}`),
+ []byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"}}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","phase":"commentary"}}`),
+ []byte(`data: {"type":"response.output_item.added","item":{"type":"message","role":"assistant","phase":"final_answer"}}`),
+ []byte(`data: {"type":"response.output_text.delta","delta":"Done."}`),
+ []byte(`data: {"type":"response.content_part.done","part":{"type":"output_text"}}`),
+ []byte(`data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`),
+ }
+ got := collectCodexClaudeBlocks(t, chunks)
+ if len(got.text) != 2 || got.text[0] != "Two agents are working." || got.text[1] != "Done." {
+ t.Fatalf("public commentary and final answer must be visible once: %q", got.text)
+ }
+ if len(got.thinking) != 2 || got.thinking[0] != "Private reasoning summary" || got.signatures[1] != codexCommentarySignatureMarker {
+ t.Fatalf("reasoning and replay marker must be preserved: %#v", got)
+ }
+}
+
+func TestVisibleCommentaryDoneOnlyIsMirrored(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "1")
+ got := collectCodexClaudeBlocks(t, [][]byte{
+ []byte(`data: {"type":"response.created","response":{"id":"r","model":"gpt-6.1-sol"}}`),
+ []byte(`data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Progress"}]}}`),
+ []byte(`data: {"type":"response.completed","response":{"usage":{}}}`),
+ })
+ if len(got.text) != 1 || got.text[0] != "Progress" || len(got.thinking) != 1 {
+ t.Fatalf("unexpected blocks: %#v", got)
+ }
+}
+
+func TestVisibleCommentaryNonStreamContext(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "")
+ ctx := translatorcommon.WithVisibleCodexCommentary(context.Background(), true)
+ raw := []byte(`{"type":"response.completed","response":{"id":"r","model":"gpt-6.1-sol","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"Reasoning summary"}],"encrypted_content":"sig"},{"type":"message","phase":"commentary","content":[{"type":"output_text","text":"Public progress"}]},{"type":"message","phase":"final_answer","content":[{"type":"output_text","text":"Final answer"}]}],"usage":{"input_tokens":5,"output_tokens":7}}}`)
+ var param any
+ out := ConvertCodexResponseToClaudeNonStream(ctx, "", []byte(`{"messages":[]}`), nil, raw, ¶m)
+ content := ojson.GetBytesNoCopy(out, "content").Array()
+ if len(content) != 4 || content[0].Get("type").String() != "thinking" || content[1].Get("signature").String() != codexCommentarySignatureMarker || content[2].Get("text").String() != "Public progress" || content[3].Get("text").String() != "Final answer" {
+ t.Fatalf("non-stream progress/reasoning/final blocks changed: %s", out)
+ }
+ if ojson.GetBytesNoCopy(out, "usage.output_tokens").Int() != 7 {
+ t.Fatalf("mirroring must not inflate model token accounting: %s", out)
+ }
+}
+
+func TestVisibleCommentaryReplayDefaultPreservesRepeatedFinal(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "")
+ input := []byte(`{"model":"m","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"same","signature":"cpa:commentary:v1"},{"type":"text","text":"same"}]}]}`)
+ out, err := ConvertClaudeRequestToCodex("gpt-6.1-sol", input, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ojson.GetBytesNoCopy(out, "input").Array()) != 2 {
+ t.Fatalf("default mode changed existing replay semantics: %s", out)
+ }
+}
+
+func TestVisibleCommentaryReplayContextPreservesAdjacentFinalText(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "")
+ ctx := translatorcommon.WithVisibleCodexCommentary(context.Background(), true)
+ input := []byte(`{"model":"m","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"Progress","signature":"cpa:commentary:v1"},{"type":"text","text":"Progress"},{"type":"text","text":"Actual final answer"}]},{"role":"user","content":"What are the agents doing?"}]}`)
+ result := sdktranslator.TranslateRequestEnvelope(ctx, sdktranslator.FormatClaude, sdktranslator.FormatCodex,
+ sdktranslator.RequestEnvelope{Format: sdktranslator.FormatClaude, Model: "gpt-6.1-sol", Body: input})
+ if result.Err != nil {
+ t.Fatal(result.Err)
+ }
+ items := ojson.GetBytesNoCopy(result.Body, "input").Array()
+ if len(items) != 3 || items[0].Get("phase").String() != "commentary" || items[1].Get("content.0.text").String() != "Actual final answer" || items[2].Get("content.0.text").String() != "What are the agents doing?" {
+ t.Fatalf("mirror removal lost final text or user steering: %s", result.Body)
+ }
+}
+
+func TestVisibleCommentaryReplayAcrossAssistantMessageBoundary(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "1")
+ input := []byte(`{"model":"m","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"Progress","signature":"cpa:commentary:v1"}]},{"role":"assistant","content":[{"type":"text","text":"Progress"},{"type":"text","text":"Final"}]}]}`)
+ out, err := ConvertClaudeRequestToCodex("gpt-6.1-sol", input, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(ojson.GetBytesNoCopy(out, "input").Array()) != 2 || ojson.GetBytesNoCopy(out, "input.1.content.0.text").String() != "Final" {
+ t.Fatalf("split native history replay did not preserve exactly one progress: %s", out)
+ }
+}
+
+func TestVisibleCommentaryReplayKeepsPhaseWithoutDuplicate(t *testing.T) {
+ t.Setenv("CPA_VISIBLE_CODEX_COMMENTARY", "1")
+ input := []byte(`{"model":"gpt-6.1-sol","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"Two agents are working.","signature":"cpa:commentary:v1"},{"type":"text","text":"Two agents are working."},{"type":"tool_use","id":"call_1","name":"Bash","input":{"command":"pwd"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"/tmp"},{"type":"text","text":"User asks: what are the agents doing?"}]}]}`)
+ body, err := ConvertClaudeRequestToCodexWithCompat("gpt-6.1-sol", input, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ items := ojson.GetBytesNoCopy(body, "input").Array()
+ if len(items) != 4 || items[0].Get("phase").String() != "commentary" || items[1].Get("type").String() != "function_call" || items[3].Get("content.0.text").String() != "User asks: what are the agents doing?" {
+ t.Fatalf("replay lost phase, tool call, or user steering: %s", body)
+ }
+}
diff --git a/internal/translator/codex/gemini/codex_gemini_request.go b/internal/translator/codex/gemini/codex_gemini_request.go
index 2fce6b1d3b7..ea367ebd3ec 100644
--- a/internal/translator/codex/gemini/codex_gemini_request.go
+++ b/internal/translator/codex/gemini/codex_gemini_request.go
@@ -6,16 +6,15 @@
package gemini
import (
- "encoding/base64"
"fmt"
"strconv"
"strings"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ConvertGeminiRequestToCodex parses and transforms a Gemini API request into Codex API format.
@@ -38,9 +37,9 @@ import (
func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) {
rawJSON := inputRawJSON
// Base template
- out := []byte(`{"model":"","instructions":"","input":[]}`)
+ outDoc := ojson.NewDocString(`{"model":"","instructions":"","input":[]}`)
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
inputItems := translatorcommon.NewRawArrayItems(root.Get("contents.#").Int())
// Pre-compute tool name shortening map from declared functionDeclarations
@@ -73,7 +72,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
emittedThoughtSignatures := map[string]bool{}
signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, inputRawJSON)
- getGeminiCallID := func(value gjson.Result) string {
+ getGeminiCallID := func(value ojson.Result) string {
if callID := strings.TrimSpace(value.Get("id").String()); callID != "" {
return callID
}
@@ -93,9 +92,9 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
}
// Model
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
if serviceTier := normalizeGeminiCodexServiceTier(root.Get("service_tier")); serviceTier != "" {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier)
+ _ = outDoc.Set("service_tier", serviceTier)
}
// System instruction -> as a user message with input_text parts
@@ -112,15 +111,15 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
continue
}
if t := p.Get("text"); t.Exists() {
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_text")
- part, _ = sjson.SetBytes(part, "text", t.String())
- contentItems = append(contentItems, part)
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_text")
+ _ = partDoc.Set("text", t.String())
+ contentItems = append(contentItems, partDoc.Bytes())
}
}
if len(contentItems) > 0 {
msg := []byte(`{"type":"message","role":"developer","content":[]}`)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
+ msg, _ = ojson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
inputItems = append(inputItems, msg)
}
}
@@ -129,17 +128,16 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
contents := root.Get("contents")
if contents.IsArray() {
items := contents.Array()
+ previousRole := ""
for i := 0; i < len(items); i++ {
item := items[i]
- role := item.Get("role").String()
- if role == "model" {
- role = "assistant"
- }
-
parts := item.Get("parts")
if !parts.IsArray() {
continue
}
+
+ role := translatorcommon.NormalizeGeminiTurnRole(item.Get("role").String(), parts, previousRole)
+ previousRole = role
parr := parts.Array()
var pendingThoughtSummaryParts [][]byte
for j := 0; j < len(parr); j++ {
@@ -154,21 +152,21 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
if role == "assistant" {
if rawSignature := geminiPartThoughtSignature(p); rawSignature != "" {
if normalizedSignature, ok := normalizedGeminiThoughtSignatureForCodexTarget(signatureTargetModel, rawSignature); ok && !emittedThoughtSignatures[normalizedSignature] {
- reasoningItem := []byte(`{"type":"reasoning","encrypted_content":"","summary":[]}`)
- reasoningItem, _ = sjson.SetBytes(reasoningItem, "encrypted_content", normalizedSignature)
+ reasoningItemDoc := ojson.NewDocString(`{"type":"reasoning","encrypted_content":"","summary":[]}`)
+ _ = reasoningItemDoc.Set("encrypted_content", normalizedSignature)
summaryParts := pendingThoughtSummaryParts
pendingThoughtSummaryParts = nil
if translatorcommon.IsGeminiThoughtPart(p) {
if thoughtText := p.Get("text").String(); thoughtText != "" {
summaryPart := []byte(`{"type":"summary_text","text":""}`)
- summaryPart, _ = sjson.SetBytes(summaryPart, "text", thoughtText)
+ summaryPart, _ = ojson.SetBytes(summaryPart, "text", thoughtText)
summaryParts = append(summaryParts, summaryPart)
}
}
if len(summaryParts) > 0 {
- reasoningItem, _ = sjson.SetRawBytes(reasoningItem, "summary", translatorcommon.JoinRawArray(summaryParts))
+ _ = reasoningItemDoc.SetRawBytes("summary", translatorcommon.JoinRawArray(summaryParts))
}
- inputItems = append(inputItems, reasoningItem)
+ inputItems = append(inputItems, reasoningItemDoc.Bytes())
emittedThoughtSignatures[normalizedSignature] = true
} else {
pendingThoughtSummaryParts = nil
@@ -176,7 +174,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
} else if translatorcommon.IsGeminiThoughtPart(p) {
if thoughtText := p.Get("text").String(); thoughtText != "" {
summaryPart := []byte(`{"type":"summary_text","text":""}`)
- summaryPart, _ = sjson.SetBytes(summaryPart, "text", thoughtText)
+ summaryPart, _ = ojson.SetBytes(summaryPart, "text", thoughtText)
pendingThoughtSummaryParts = append(pendingThoughtSummaryParts, summaryPart)
}
} else {
@@ -194,10 +192,10 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
if role == "assistant" {
partType = "output_text"
}
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", partType)
- part, _ = sjson.SetBytes(part, "text", t.String())
- inputItems = append(inputItems, codexMessageWithPart(role, part))
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", partType)
+ _ = partDoc.Set("text", t.String())
+ inputItems = append(inputItems, codexMessageWithPart(role, partDoc.Bytes()))
continue
}
@@ -212,8 +210,8 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
}
// function call from model
- if fc := p.Get("functionCall"); fc.Exists() {
- fn := []byte(`{"type":"function_call"}`)
+ if fc := translatorcommon.GeminiPartFunctionCall(p); fc.Exists() {
+ fnDoc := ojson.NewDocString(`{"type":"function_call"}`)
if name := fc.Get("name"); name.Exists() {
n := name.String()
if short, ok := shortMap[n]; ok {
@@ -221,10 +219,10 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
} else {
n = shortenNameIfNeeded(n)
}
- fn, _ = sjson.SetBytes(fn, "name", n)
+ _ = fnDoc.Set("name", n)
}
if args := fc.Get("args"); args.Exists() {
- fn, _ = sjson.SetBytes(fn, "arguments", args.Raw)
+ _ = fnDoc.Set("arguments", args.Raw)
}
// Reuse gateway-provided IDs when present, otherwise generate one for pairing.
id := getGeminiCallID(fc)
@@ -232,22 +230,21 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
callCounter++
id = fmt.Sprintf("call_gemini_%016d", callCounter)
}
- fn, _ = sjson.SetBytes(fn, "call_id", id)
+ _ = fnDoc.Set("call_id", id)
pendingCallIDs = append(pendingCallIDs, id)
- inputItems = append(inputItems, fn)
+ inputItems = append(inputItems, fnDoc.Bytes())
continue
}
// function response from user
- if fr := p.Get("functionResponse"); fr.Exists() {
- fno := []byte(`{"type":"function_call_output"}`)
+ if fr := translatorcommon.GeminiPartFunctionResponse(p); fr.Exists() {
+ fnoDoc := ojson.NewDocString(`{"type":"function_call_output"}`)
// Prefer a string result if present; otherwise embed the raw response as a string
if res := fr.Get("response.result"); res.Exists() {
- fno, _ = sjson.SetBytes(fno, "output", res.String())
+ _ = fnoDoc.Set("output", res.String())
} else if resp := fr.Get("response"); resp.Exists() {
- fno, _ = sjson.SetBytes(fno, "output", resp.Raw)
+ _ = fnoDoc.Set("output", resp.Raw)
}
- // fno, _ = sjson.SetBytes(fno, "call_id", "call_W6nRJzFXyPM2LFBbfo98qAbq")
// attach the oldest queued call_id to pair the response
// with its call. If the queue is empty, generate a new id.
var id string
@@ -262,21 +259,23 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
callCounter++
id = fmt.Sprintf("call_gemini_%016d", callCounter)
}
- fno, _ = sjson.SetBytes(fno, "call_id", id)
- inputItems = append(inputItems, fno)
+ _ = fnoDoc.Set("call_id", id)
+ inputItems = append(inputItems, fnoDoc.Bytes())
continue
}
}
}
}
- out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "input", inputItems)
// Tools mapping: Gemini functionDeclarations -> Codex tools
tools := root.Get("tools")
+ hasTools := false
if tools.IsArray() {
+ hasTools = true
var toolItems [][]byte
- out, _ = sjson.SetBytes(out, "tool_choice", "auto")
+ _ = outDoc.Set("tool_choice", "auto")
tarr := tools.Array()
for i := 0; i < len(tarr); i++ {
td := tarr[i]
@@ -287,8 +286,8 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
farr := fns.Array()
for j := 0; j < len(farr); j++ {
fn := farr[j]
- tool := []byte(`{}`)
- tool, _ = sjson.SetBytes(tool, "type", "function")
+ toolDoc := ojson.NewDocString(`{}`)
+ _ = toolDoc.Set("type", "function")
if v := fn.Get("name"); v.Exists() {
name := v.String()
if short, ok := shortMap[name]; ok {
@@ -296,33 +295,73 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
} else {
name = shortenNameIfNeeded(name)
}
- tool, _ = sjson.SetBytes(tool, "name", name)
+ _ = toolDoc.Set("name", name)
}
if v := fn.Get("description"); v.Exists() {
- tool, _ = sjson.SetBytes(tool, "description", v.String())
+ _ = toolDoc.Set("description", v.String())
}
if prm := fn.Get("parameters"); prm.Exists() {
cleaned := cleanGeminiCodexToolParameters(prm)
- tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned)
+ _ = toolDoc.SetRawBytes("parameters", cleaned)
} else if prm = fn.Get("parametersJsonSchema"); prm.Exists() {
cleaned := cleanGeminiCodexToolParameters(prm)
- tool, _ = sjson.SetRawBytes(tool, "parameters", cleaned)
+ _ = toolDoc.SetRawBytes("parameters", cleaned)
+ }
+ _ = toolDoc.Set("strict", false)
+ toolItems = append(toolItems, toolDoc.Bytes())
+ }
+ }
+ toolsRaw := translatorcommon.JoinRawArray(toolItems)
+ var pathsToLower []string
+ toolsResult := util.ParseGJSONBytesNoCopy(toolsRaw)
+ util.Walk(toolsResult, "", "type", &pathsToLower)
+ if len(pathsToLower) > 0 {
+ toolsDoc := ojson.NewDoc(toolsRaw)
+ mutatedTools := false
+ for _, p := range pathsToLower {
+ typeValue := toolsResult.Get(p)
+ if typeValue.Type != ojson.String {
+ continue
+ }
+ normalizedType := strings.ToLower(typeValue.String())
+ if normalizedType == typeValue.String() {
+ continue
}
- tool, _ = sjson.SetBytes(tool, "strict", false)
- toolItems = append(toolItems, tool)
+ _ = toolsDoc.Set(p, normalizedType)
+ mutatedTools = true
+ }
+ if mutatedTools {
+ toolsRaw = toolsDoc.Bytes()
}
}
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", toolsRaw)
}
// Fixed flags aligning with Codex expectations
- out, _ = sjson.SetBytes(out, "parallel_tool_calls", true)
- out = setCodexToolChoiceFromGeminiToolConfig(out, root.Get("toolConfig.functionCallingConfig"))
+ _ = outDoc.Set("parallel_tool_calls", true)
+ setCodexToolChoiceFromGeminiToolConfigDoc(outDoc, hasTools, root.Get("toolConfig.functionCallingConfig"))
+
+ genConfig := root.Get("generationConfig")
+ if !genConfig.Exists() {
+ genConfig = root.Get("generation_config")
+ }
+ for _, v := range []ojson.Result{
+ genConfig.Get("maxOutputTokens"),
+ genConfig.Get("max_output_tokens"),
+ root.Get("maxOutputTokens"),
+ root.Get("max_output_tokens"),
+ root.Get("max_tokens"),
+ } {
+ if v.Type == ojson.Number {
+ _ = outDoc.Set("max_output_tokens", v.Int())
+ break
+ }
+ }
// Convert Gemini thinkingConfig to Codex reasoning.effort.
// Note: Google official Python SDK sends snake_case fields (thinking_level/thinking_budget).
effortSet := false
- if genConfig := root.Get("generationConfig"); genConfig.Exists() {
+ if genConfig.Exists() {
thinkingLevel := genConfig.Get("thinkingLevel")
if !thinkingLevel.Exists() {
thinkingLevel = genConfig.Get("thinking_level")
@@ -330,7 +369,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
+ _ = outDoc.Set("reasoning.effort", effort)
effortSet = true
}
} else if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
@@ -341,7 +380,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
+ _ = outDoc.Set("reasoning.effort", effort)
effortSet = true
}
} else {
@@ -351,7 +390,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
}
if thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
- out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
+ _ = outDoc.Set("reasoning.effort", effort)
effortSet = true
}
}
@@ -360,37 +399,21 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
}
if !effortSet {
// No thinking config, set default effort
- out, _ = sjson.SetBytes(out, "reasoning.effort", "medium")
+ _ = outDoc.Set("reasoning.effort", "medium")
}
// OpenAI documents reasoning summaries as explicit opt-in output. Leave
// reasoning.summary to the source request's canonical summary intent instead
// of coupling it to reasoning effort.
- out, _ = sjson.SetBytes(out, "stream", true)
- out, _ = sjson.SetBytes(out, "store", false)
- out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"})
-
- var pathsToLower []string
- toolsResult := gjson.GetBytes(out, "tools")
- util.Walk(toolsResult, "", "type", &pathsToLower)
- for _, p := range pathsToLower {
- fullPath := fmt.Sprintf("tools.%s", p)
- typeValue := gjson.GetBytes(out, fullPath)
- if typeValue.Type != gjson.String {
- continue
- }
- normalizedType := strings.ToLower(typeValue.String())
- if normalizedType == typeValue.String() {
- continue
- }
- out, _ = sjson.SetBytes(out, fullPath, normalizedType)
- }
+ _ = outDoc.Set("stream", true)
+ _ = outDoc.Set("store", false)
+ _ = outDoc.Set("include", []string{"reasoning.encrypted_content"})
- return out, nil
+ return outDoc.Bytes(), nil
}
// geminiPartThoughtSignature extracts a part's thought signature under either
// the camelCase or snake_case field spelling.
-func geminiPartThoughtSignature(part gjson.Result) string {
+func geminiPartThoughtSignature(part ojson.Result) string {
if signature := strings.TrimSpace(part.Get("thoughtSignature").String()); signature != "" {
return signature
}
@@ -419,53 +442,84 @@ func normalizedGeminiThoughtSignatureForCodexTarget(modelName, rawSignature stri
return translatorcommon.CodexReplaySignatureForTarget(modelName, decodeGeminiCodexThoughtSignature(rawSignature))
}
-func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig gjson.Result) []byte {
+func setCodexToolChoiceFromGeminiToolConfigDoc(doc *ojson.Doc, hasTools bool, functionCallingConfig ojson.Result) {
+ if !functionCallingConfig.Exists() {
+ return
+ }
+ mode := functionCallingConfig.Get("mode").String()
+ switch mode {
+ case "NONE":
+ _ = doc.Set("tool_choice", "none")
+ case "AUTO":
+ if !hasTools {
+ _ = doc.Set("tool_choice", "auto")
+ }
+ case "ANY":
+ allowedNames := functionCallingConfig.Get("allowedFunctionNames")
+ allowedNameItems := allowedNames.Array()
+ if allowedNames.IsArray() && len(allowedNameItems) == 1 {
+ choice := []byte(`{"type":"function","name":""}`)
+ choice, _ = ojson.SetBytes(choice, "name", shortenNameIfNeeded(allowedNameItems[0].String()))
+ _ = doc.SetRawBytes("tool_choice", choice)
+ } else {
+ _ = doc.Set("tool_choice", "required")
+ }
+ }
+}
+
+func setCodexToolChoiceFromGeminiToolConfig(out []byte, functionCallingConfig ojson.Result) []byte {
if !functionCallingConfig.Exists() {
return out
}
mode := functionCallingConfig.Get("mode").String()
switch mode {
case "NONE":
- out, _ = sjson.SetBytes(out, "tool_choice", "none")
+ out, _ = ojson.SetBytes(out, "tool_choice", "none")
case "AUTO":
- current := gjson.GetBytes(out, "tool_choice")
- if current.Type != gjson.String || current.String() != "auto" {
- out, _ = sjson.SetBytes(out, "tool_choice", "auto")
+ current := ojson.GetBytesNoCopy(out, "tool_choice")
+ if current.Type != ojson.String || current.String() != "auto" {
+ out, _ = ojson.SetBytes(out, "tool_choice", "auto")
}
case "ANY":
allowedNames := functionCallingConfig.Get("allowedFunctionNames")
allowedNameItems := allowedNames.Array()
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
choice := []byte(`{"type":"function","name":""}`)
- choice, _ = sjson.SetBytes(choice, "name", shortenNameIfNeeded(allowedNameItems[0].String()))
- out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
+ choice, _ = ojson.SetBytes(choice, "name", shortenNameIfNeeded(allowedNameItems[0].String()))
+ out, _ = ojson.SetRawBytes(out, "tool_choice", choice)
} else {
- out, _ = sjson.SetBytes(out, "tool_choice", "required")
+ out, _ = ojson.SetBytes(out, "tool_choice", "required")
}
}
return out
}
-func cleanGeminiCodexToolParameters(parameters gjson.Result) []byte {
- cleaned := []byte(parameters.Raw)
- if parameters.Get("$schema").Exists() {
- cleaned, _ = sjson.DeleteBytes(cleaned, "$schema")
+func cleanGeminiCodexToolParameters(parameters ojson.Result) []byte {
+ cleaned := util.NormalizeJSONSchemaNumericConstraints(parameters.Raw)
+ hasSchema := parameters.Get("$schema").Exists()
+ needsAdditionalProps := parameters.Get("additionalProperties").Type != ojson.False
+ if !hasSchema && !needsAdditionalProps {
+ return []byte(cleaned)
}
- if additionalProperties := parameters.Get("additionalProperties"); additionalProperties.Type != gjson.False {
- cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
+ doc := ojson.NewDocString(cleaned)
+ if hasSchema {
+ _ = doc.Delete("$schema")
}
- return cleaned
+ if needsAdditionalProps {
+ _ = doc.Set("additionalProperties", false)
+ }
+ return doc.Bytes()
}
func codexMessageWithPart(role string, part []byte) []byte {
- msg := []byte(`{"type":"message","role":"","content":[]}`)
- msg, _ = sjson.SetBytes(msg, "role", role)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray([][]byte{part}))
- return msg
+ msgDoc := ojson.NewDocString(`{"type":"message","role":"","content":[]}`)
+ _ = msgDoc.Set("role", role)
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray([][]byte{part}))
+ return msgDoc.Bytes()
}
-func normalizeGeminiCodexServiceTier(serviceTier gjson.Result) string {
- if !serviceTier.Exists() || serviceTier.Type != gjson.String {
+func normalizeGeminiCodexServiceTier(serviceTier ojson.Result) string {
+ if !serviceTier.Exists() || serviceTier.Type != ojson.String {
return ""
}
switch strings.ToLower(strings.TrimSpace(serviceTier.String())) {
@@ -475,7 +529,7 @@ func normalizeGeminiCodexServiceTier(serviceTier gjson.Result) string {
return ""
}
-func codexContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
+func codexContentPartFromGeminiInlineData(part ojson.Result) ([]byte, bool) {
inlineData := part.Get("inlineData")
if !inlineData.Exists() {
inlineData = part.Get("inline_data")
@@ -495,22 +549,22 @@ func codexContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
contentPart := []byte(`{"type":"input_image","image_url":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
+ contentPart, _ = ojson.SetBytes(contentPart, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
return contentPart, true
case strings.HasPrefix(lowerMimeType, "audio/"):
- contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data)
- contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", codexInputAudioFormatFromMIME(mimeType))
- return contentPart, true
+ contentPartDoc := ojson.NewDocString(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
+ _ = contentPartDoc.Set("input_audio.data", data)
+ _ = contentPartDoc.Set("input_audio.format", codexInputAudioFormatFromMIME(mimeType))
+ return contentPartDoc.Bytes(), true
default:
- contentPart := []byte(`{"type":"input_file","file_data":"","filename":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "file_data", data)
- contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType))
- return contentPart, true
+ contentPartDoc := ojson.NewDocString(`{"type":"input_file","file_data":"","filename":""}`)
+ _ = contentPartDoc.Set("file_data", data)
+ _ = contentPartDoc.Set("filename", codexFileNameFromMIME(mimeType))
+ return contentPartDoc.Bytes(), true
}
}
-func codexContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
+func codexContentPartFromGeminiFileData(part ojson.Result) ([]byte, bool) {
fileData := part.Get("fileData")
if !fileData.Exists() {
fileData = part.Get("file_data")
@@ -532,21 +586,21 @@ func codexContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
lowerMimeType := strings.ToLower(mimeType)
if strings.HasPrefix(lowerMimeType, "image/") {
contentPart := []byte(`{"type":"input_image","image_url":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "image_url", fileURI)
+ contentPart, _ = ojson.SetBytes(contentPart, "image_url", fileURI)
return contentPart, true
}
if strings.HasPrefix(lowerMimeType, "video/") || strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") {
- contentPart := []byte(`{"type":"input_file","file_url":"","filename":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "file_url", fileURI)
- contentPart, _ = sjson.SetBytes(contentPart, "filename", codexFileNameFromMIME(mimeType))
- return contentPart, true
+ contentPartDoc := ojson.NewDocString(`{"type":"input_file","file_url":"","filename":""}`)
+ _ = contentPartDoc.Set("file_url", fileURI)
+ _ = contentPartDoc.Set("filename", codexFileNameFromMIME(mimeType))
+ return contentPartDoc.Bytes(), true
}
fileInfo := "File: " + fileURI
if mimeType != "" {
fileInfo += " (Type: " + mimeType + ")"
}
contentPart := []byte(`{"type":"input_text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", fileInfo)
return contentPart, true
}
diff --git a/internal/translator/codex/gemini/codex_gemini_request_test.go b/internal/translator/codex/gemini/codex_gemini_request_test.go
index 3b6d9996632..76dbd221bed 100644
--- a/internal/translator/codex/gemini/codex_gemini_request_test.go
+++ b/internal/translator/codex/gemini/codex_gemini_request_test.go
@@ -1,15 +1,75 @@
package gemini
import (
- "encoding/base64"
"fmt"
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
)
+func TestConvertGeminiRequestToCodex_MaxOutputTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"no limit", `{}`, ""},
+ {"camel config camel field", `{"generationConfig":{"maxOutputTokens":1024}}`, "1024"},
+ {"camel config snake field", `{"generationConfig":{"max_output_tokens":1024}}`, "1024"},
+ {"snake config snake field", `{"generation_config":{"max_output_tokens":2048}}`, "2048"},
+ {"snake config camel field", `{"generation_config":{"maxOutputTokens":2048}}`, "2048"},
+ {"top-level camel field", `{"maxOutputTokens":1024}`, "1024"},
+ {"top-level snake field", `{"max_output_tokens":1024}`, "1024"},
+ {"top-level max_tokens", `{"max_tokens":1024}`, "1024"},
+ {"config takes precedence", `{"generationConfig":{"maxOutputTokens":1024},"maxOutputTokens":2048,"max_output_tokens":4096,"max_tokens":8192}`, "1024"},
+ {"snake config takes precedence", `{"generation_config":{"max_output_tokens":1024},"max_output_tokens":2048}`, "1024"},
+ {"camel config takes precedence", `{"generationConfig":{"maxOutputTokens":1024},"generation_config":{"max_output_tokens":2048}}`, "1024"},
+ {"camel config field takes precedence", `{"generationConfig":{"maxOutputTokens":1024,"max_output_tokens":2048}}`, "1024"},
+ {"top-level camel field takes precedence", `{"maxOutputTokens":1024,"max_output_tokens":2048,"max_tokens":4096}`, "1024"},
+ {"top-level snake field takes precedence", `{"max_output_tokens":1024,"max_tokens":2048}`, "1024"},
+ {"empty config uses top-level fallback", `{"generationConfig":{},"max_tokens":1024}`, "1024"},
+ {"empty snake config uses top-level fallback", `{"generation_config":{},"max_tokens":1024}`, "1024"},
+ {"invalid config field skipped", `{"generationConfig":{"maxOutputTokens":"1024","max_output_tokens":2048}}`, "2048"},
+ {"null config field skipped", `{"generation_config":{"max_output_tokens":null},"max_tokens":1024}`, "1024"},
+ {"invalid top-level fields skipped", `{"maxOutputTokens":false,"max_output_tokens":{},"max_tokens":1024}`, "1024"},
+ {"zero takes precedence", `{"generationConfig":{"maxOutputTokens":0},"max_tokens":1024}`, "0"},
+ {"non-numeric limits ignored", `{"generationConfig":{"maxOutputTokens":"1024"},"max_tokens":null}`, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, errConvert := ConvertGeminiRequestToCodex("codex-test", []byte(tt.input), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertGeminiRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != tt.want {
+ t.Fatalf("max_output_tokens = %q, want %q; output=%s", got, tt.want, out)
+ }
+ })
+ }
+}
+
+func TestConvertGeminiRequestToCodex_GenerationConfigAliases(t *testing.T) {
+ for _, path := range []string{"generationConfig", "generation_config"} {
+ t.Run(path, func(t *testing.T) {
+ inputDoc := ojson.NewDocString(`{}`)
+ _ = inputDoc.Set(path+".thinking_level", "high")
+ _ = inputDoc.Set(path+".max_output_tokens", 1024)
+ out, errConvert := ConvertGeminiRequestToCodex("codex-test", inputDoc.Bytes(), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertGeminiRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "reasoning.effort").String(); got != "high" {
+ t.Fatalf("reasoning.effort = %q, want high; output=%s", got, out)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Int(); got != 1024 {
+ t.Fatalf("max_output_tokens = %d, want 1024; output=%s", got, out)
+ }
+ })
+ }
+}
+
func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) {
tests := []struct {
name string
@@ -52,12 +112,12 @@ func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) {
out, _ := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
- gotCallID := gjson.GetBytes(out, "input.0.call_id").String()
+ gotCallID := ojson.GetBytesNoCopy(out, "input.0.call_id").String()
if gotCallID != tt.want {
t.Fatalf("expected function_call call_id %q, got %q; output=%s", tt.want, gotCallID, string(out))
}
- gotOutputID := gjson.GetBytes(out, "input.1.call_id").String()
+ gotOutputID := ojson.GetBytesNoCopy(out, "input.1.call_id").String()
if gotOutputID != tt.want {
t.Fatalf("expected function_call_output call_id %q, got %q; output=%s", tt.want, gotOutputID, string(out))
}
@@ -67,10 +127,10 @@ func TestConvertGeminiRequestToCodex_PreservesCustomCallIDs(t *testing.T) {
func TestConvertGeminiRequestToCodex_AcceptsInlineData(t *testing.T) {
out, _ := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_image" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "input_image" {
t.Fatalf("content type = %q, want input_image. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image_url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
@@ -78,13 +138,13 @@ func TestConvertGeminiRequestToCodex_AcceptsInlineData(t *testing.T) {
func TestConvertGeminiRequestToCodex_SplitsNonImageInlineDataByMIME(t *testing.T) {
out, _ := ConvertGeminiRequestToCodex("gpt-5.1-codex", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_audio" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.content.0.type").String(); got != "input_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.content.0.type").String(); got != "input_file" {
t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.content.0.type").String(); got != "input_file" {
t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out))
}
}
@@ -98,7 +158,7 @@ func TestConvertGeminiRequestToCodex_DropsHiddenThoughtParts(t *testing.T) {
]
}`), false)
- input := gjson.GetBytes(out, "input").Array()
+ input := ojson.GetBytesNoCopy(out, "input").Array()
if len(input) != 1 || input[0].Get("role").String() != "user" || input[0].Get("content.0.text").String() != "continue" {
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
}
@@ -112,7 +172,7 @@ func TestConvertGeminiRequestToCodex_DropsHiddenThoughtParts(t *testing.T) {
]}]
}`), false)
- input := gjson.GetBytes(out, "input").Array()
+ input := ojson.GetBytesNoCopy(out, "input").Array()
if len(input) != 1 || input[0].Get("content.0.type").String() != "output_text" || input[0].Get("content.0.text").String() != "visible answer" {
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
}
@@ -131,10 +191,10 @@ func testGeminiMetaThoughtSignature() string {
func TestConvertGeminiRequestToCodex_PreservesMetaThoughtSignatureForMuseTarget(t *testing.T) {
signature := testGeminiMetaThoughtSignature()
payload := []byte(`{"contents":[{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":""},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`)
- payload, _ = sjson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", signature)
+ payload, _ = ojson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", signature)
out, _ := ConvertGeminiRequestToCodex("muse-spark-1.3", payload, false)
- input := gjson.GetBytes(out, "input").Array()
+ input := ojson.GetBytesNoCopy(out, "input").Array()
if len(input) != 3 {
t.Fatalf("input len = %d, want reasoning+message+user. Output: %s", len(input), string(out))
}
@@ -155,12 +215,12 @@ func TestConvertGeminiRequestToCodex_PreservesMetaThoughtSignatureForMuseTarget(
func TestConvertGeminiRequestToCodex_DropsMetaThoughtSignatureForNonMetaTargets(t *testing.T) {
signature := testGeminiMetaThoughtSignature()
payload := []byte(`{"contents":[{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":""},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`)
- payload, _ = sjson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", signature)
+ payload, _ = ojson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", signature)
for _, modelName := range []string{"gpt-5.1-codex", "claude-sonnet-4-6", "grok-4.5"} {
t.Run(modelName, func(t *testing.T) {
out, _ := ConvertGeminiRequestToCodex(modelName, payload, false)
- for _, item := range gjson.GetBytes(out, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(out, "input").Array() {
if item.Get("type").String() == "reasoning" {
t.Fatalf("Meta signature leaked to %s target. Output: %s", modelName, string(out))
}
@@ -200,10 +260,10 @@ func TestConvertGeminiRequestToCodex_NormalizesPrefixedAndCarrierSignatures(t *t
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := []byte(`{"contents":[{"role":"model","parts":[{"thought":true,"text":"internal reasoning","thoughtSignature":""},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"continue"}]}]}`)
- payload, _ = sjson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", tc.echoed)
+ payload, _ = ojson.SetBytes(payload, "contents.0.parts.0.thoughtSignature", tc.echoed)
out, _ := ConvertGeminiRequestToCodex(tc.model, payload, false)
found := ""
- for _, item := range gjson.GetBytes(out, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(out, "input").Array() {
if item.Get("type").String() == "reasoning" {
found = item.Get("encrypted_content").String()
}
@@ -258,10 +318,10 @@ func TestConvertGeminiRequestToCodex_DeterministicCallIDs(t *testing.T) {
wantID1 := "call_gemini_0000000000000001"
wantID2 := "call_gemini_0000000000000002"
- gotCall1 := gjson.GetBytes(out1, "input.0.call_id").String()
- gotResp1 := gjson.GetBytes(out1, "input.1.call_id").String()
- gotCall2 := gjson.GetBytes(out1, "input.2.call_id").String()
- gotResp2 := gjson.GetBytes(out1, "input.3.call_id").String()
+ gotCall1 := ojson.GetBytesNoCopy(out1, "input.0.call_id").String()
+ gotResp1 := ojson.GetBytesNoCopy(out1, "input.1.call_id").String()
+ gotCall2 := ojson.GetBytesNoCopy(out1, "input.2.call_id").String()
+ gotResp2 := ojson.GetBytesNoCopy(out1, "input.3.call_id").String()
if gotCall1 != wantID1 || gotResp1 != wantID1 {
t.Fatalf("expected first tool pair to have id %q, got call=%q, resp=%q", wantID1, gotCall1, gotResp1)
@@ -270,3 +330,21 @@ func TestConvertGeminiRequestToCodex_DeterministicCallIDs(t *testing.T) {
t.Fatalf("expected second tool pair to have id %q, got call=%q, resp=%q", wantID2, gotCall2, gotResp2)
}
}
+
+func TestConvertGeminiRequestToCodex_DefaultsOmittedContentRoleToUser(t *testing.T) {
+ raw := []byte(`{"contents":[{"parts":[{"text":"Reply with exactly: OK"}]}]}`)
+
+ out, err := ConvertGeminiRequestToCodex("gpt-5.1-codex", raw, false)
+ if err != nil {
+ t.Fatalf("ConvertGeminiRequestToCodex() error = %v", err)
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.0.role").String(); got != "user" {
+ t.Fatalf("input.0.role = %q, want user; out=%s", got, string(out))
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "input_text" {
+ t.Fatalf("input.0.content.0.type = %q, want input_text; out=%s", got, string(out))
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "Reply with exactly: OK" {
+ t.Fatalf("input.0.content.0.text = %q, want Reply with exactly: OK; out=%s", got, string(out))
+ }
+}
diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go
index 3de5631d5a7..e4a8d470f1c 100644
--- a/internal/translator/codex/gemini/codex_gemini_response.go
+++ b/internal/translator/codex/gemini/codex_gemini_response.go
@@ -8,13 +8,13 @@ import (
"bytes"
"context"
"crypto/sha256"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var (
@@ -75,7 +75,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
}
rawJSON = bytes.TrimSpace(rawJSON[5:])
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
typeResult := rootResult.Get("type")
typeStr := typeResult.String()
@@ -91,25 +91,24 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
params.StorageOutputs = nil
return append(outputs, extra...)
}
- attachPendingThoughtSignature := func(part []byte) []byte {
+ attachPendingThoughtSignatureDoc := func(doc *ojson.Doc) {
if params.PendingThoughtSignature == "" {
- return part
+ return
}
- part = setGeminiThoughtSignature(part, params.PendingThoughtSignature)
+ setGeminiThoughtSignatureDoc(doc, params.PendingThoughtSignature)
params.PendingThoughtSignature = ""
- return part
}
// Base Gemini response template
- template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`)
+ templateDoc := ojson.NewDocString(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"gemini-2.5-pro","createTime":"2025-08-15T02:52:03.884209Z","responseId":"06CeaPH7NaCU48APvNXDyA4"}`)
{
- template, _ = sjson.SetBytes(template, "modelVersion", params.Model)
+ _ = templateDoc.Set("modelVersion", params.Model)
createdAtResult := rootResult.Get("response.created_at")
if createdAtResult.Exists() {
params.CreatedAt = createdAtResult.Int()
- template, _ = sjson.SetBytes(template, "createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano))
+ _ = templateDoc.Set("createTime", time.Unix(params.CreatedAt, 0).Format(time.RFC3339Nano))
}
- template, _ = sjson.SetBytes(template, "responseId", params.ResponseID)
+ _ = templateDoc.Set("responseId", params.ResponseID)
}
if typeStr == "response.image_generation_call.partial_image" {
@@ -126,18 +125,18 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
return [][]byte{}
}
- params.LastImageHashByID[itemID] = hash
+ params.LastImageHashByID[strings.Clone(itemID)] = hash
}
outputFormat := rootResult.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
- part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
- part, _ = sjson.SetBytes(part, "inlineData.data", b64)
- part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
- part = attachPendingThoughtSignature(part)
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
- return flushStorageOutputs(template)
+ partDoc := ojson.NewDocString(`{"inlineData":{"data":"","mimeType":""}}`)
+ _ = partDoc.Set("inlineData.data", b64)
+ _ = partDoc.Set("inlineData.mimeType", mimeType)
+ attachPendingThoughtSignatureDoc(partDoc)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", [][]byte{partDoc.Bytes()})
+ return flushStorageOutputs(templateDoc.Bytes())
}
// Handle function call completion
@@ -179,15 +178,15 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
}
for _, thoughtText := range thoughtTexts {
part := []byte(`{"thought":true,"text":""}`)
- part, _ = sjson.SetBytes(part, "text", thoughtText)
+ part, _ = ojson.SetBytes(part, "text", thoughtText)
emittedParts = append(emittedParts, part)
}
if signature != "" {
- params.PendingThoughtSignature = signature
+ params.PendingThoughtSignature = strings.Clone(signature)
}
if len(emittedParts) > 0 {
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", emittedParts)
- return flushStorageOutputs(template)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", emittedParts)
+ return flushStorageOutputs(templateDoc.Bytes())
}
return [][]byte{}
}
@@ -205,22 +204,22 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
if last, ok := params.LastImageHashByID[itemID]; ok && last == hash {
return [][]byte{}
}
- params.LastImageHashByID[itemID] = hash
+ params.LastImageHashByID[strings.Clone(itemID)] = hash
}
outputFormat := itemResult.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
- part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
- part, _ = sjson.SetBytes(part, "inlineData.data", b64)
- part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
- part = attachPendingThoughtSignature(part)
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
- return flushStorageOutputs(template)
+ partDoc := ojson.NewDocString(`{"inlineData":{"data":"","mimeType":""}}`)
+ _ = partDoc.Set("inlineData.data", b64)
+ _ = partDoc.Set("inlineData.mimeType", mimeType)
+ attachPendingThoughtSignatureDoc(partDoc)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", [][]byte{partDoc.Bytes()})
+ return flushStorageOutputs(templateDoc.Bytes())
}
if itemType == "function_call" {
// Create function call part
- functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
+ functionCallDoc := ojson.NewDocString(`{"functionCall":{"name":"","args":{}}}`)
{
// Restore original tool name if shortened
n := itemResult.Get("name").String()
@@ -228,24 +227,24 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
if orig, ok := rev[n]; ok {
n = orig
}
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", n)
+ _ = functionCallDoc.Set("functionCall.name", n)
}
// Parse and set arguments
argsStr := itemResult.Get("arguments").String()
if argsStr != "" {
- argsResult := gjson.Parse(argsStr)
+ argsResult := ojson.Parse(argsStr)
if argsResult.IsObject() {
- functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
+ _ = functionCallDoc.SetRawBytes("functionCall.args", []byte(argsStr))
}
}
- functionCall = setGeminiFunctionCallID(functionCall, itemResult)
- functionCall = attachPendingThoughtSignature(functionCall)
+ setGeminiFunctionCallIDDoc(functionCallDoc, itemResult)
+ attachPendingThoughtSignatureDoc(functionCallDoc)
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{functionCall})
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", [][]byte{functionCallDoc.Bytes()})
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
- params.StorageOutputs = append(params.StorageOutputs, append([]byte(nil), template...))
+ params.StorageOutputs = append(params.StorageOutputs, append([]byte(nil), templateDoc.Bytes()...))
// Use this return to storage message
return [][]byte{}
@@ -261,16 +260,16 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
params.HasReasoningDelta = false
params.StreamedReasoningItems = nil
params.HasOutputTextDelta = false
- template, _ = sjson.SetBytes(template, "modelVersion", rootResult.Get("response.model").String())
- template, _ = sjson.SetBytes(template, "responseId", rootResult.Get("response.id").String())
- params.ResponseID = rootResult.Get("response.id").String()
+ _ = templateDoc.Set("modelVersion", rootResult.Get("response.model").String())
+ _ = templateDoc.Set("responseId", rootResult.Get("response.id").String())
+ params.ResponseID = strings.Clone(rootResult.Get("response.id").String())
} else if typeStr == "response.output_item.added" && rootResult.Get("item.type").String() == "reasoning" {
params.HasReasoningDelta = false
if params.PendingThoughtSignature != "" {
part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature)
params.PendingThoughtSignature = ""
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
- return flushStorageOutputs(template)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", [][]byte{part})
+ return flushStorageOutputs(templateDoc.Bytes())
}
return [][]byte{}
} else if typeStr == "response.reasoning_summary_text.delta" || typeStr == "response.reasoning_text.delta" { // Handle reasoning/thinking content delta
@@ -279,7 +278,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
if params.StreamedReasoningItems == nil {
params.StreamedReasoningItems = make(map[string]bool)
}
- params.StreamedReasoningItems[itemID] = true
+ params.StreamedReasoningItems[strings.Clone(itemID)] = true
}
var emittedParts [][]byte
if params.PendingThoughtSignature != "" {
@@ -288,19 +287,19 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
emittedParts = append(emittedParts, carrier)
}
part := []byte(`{"thought":true,"text":""}`)
- part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
+ part, _ = ojson.SetBytes(part, "text", rootResult.Get("delta").String())
emittedParts = append(emittedParts, part)
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", emittedParts)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", emittedParts)
} else if typeStr == "response.output_text.delta" { // Handle regular text content delta
params.HasOutputTextDelta = true
- part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", rootResult.Get("delta").String())
+ partDoc := ojson.NewDocString(`{"text":""}`)
+ _ = partDoc.Set("text", rootResult.Get("delta").String())
if rootResult.Get("delta").String() != "" {
// An empty text part may be discarded by clients; never spend the
// only signature copy on one.
- part = attachPendingThoughtSignature(part)
+ attachPendingThoughtSignatureDoc(partDoc)
}
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", [][]byte{partDoc.Bytes()})
} else if typeStr == "response.output_item.done" { // Fallback: emit final message text when no delta chunks were received
itemResult := rootResult.Get("item")
if itemResult.Get("type").String() != "message" || params.HasOutputTextDelta {
@@ -311,7 +310,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
return [][]byte{}
}
wroteText := false
- contentResult.ForEach(func(_, partResult gjson.Result) bool {
+ contentResult.ForEach(func(_, partResult ojson.Result) bool {
if partResult.Get("type").String() != "output_text" {
return true
}
@@ -319,38 +318,38 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
if text == "" {
return true
}
- part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
- part = attachPendingThoughtSignature(part)
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts.-1", part)
+ partDoc := ojson.NewDocString(`{"text":""}`)
+ _ = partDoc.Set("text", text)
+ attachPendingThoughtSignatureDoc(partDoc)
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts.-1", partDoc.Bytes())
wroteText = true
return true
})
if wroteText {
params.HasOutputTextDelta = true
- return flushStorageOutputs(template)
+ return flushStorageOutputs(templateDoc.Bytes())
}
return [][]byte{}
} else if typeStr == "response.completed" || typeStr == "response.incomplete" { // Handle response completion with usage metadata
if params.PendingThoughtSignature != "" {
part := setGeminiThoughtSignature([]byte(`{"thought":true,"text":""}`), params.PendingThoughtSignature)
params.PendingThoughtSignature = ""
- template = translatorcommon.SetRawArrayItems(template, "candidates.0.content.parts", [][]byte{part})
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "candidates.0.content.parts", [][]byte{part})
}
- template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
- template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
+ _ = templateDoc.Set("usageMetadata.promptTokenCount", rootResult.Get("response.usage.input_tokens").Int())
+ _ = templateDoc.Set("usageMetadata.candidatesTokenCount", rootResult.Get("response.usage.output_tokens").Int())
totalTokens := rootResult.Get("response.usage.input_tokens").Int() + rootResult.Get("response.usage.output_tokens").Int()
- template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens)
+ _ = templateDoc.Set("usageMetadata.totalTokenCount", totalTokens)
if typeStr == "response.incomplete" {
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(rootResult.Get("response.incomplete_details.reason").String()))
+ _ = templateDoc.Set("candidates.0.finishReason", codexGeminiIncompleteFinishReason(rootResult.Get("response.incomplete_details.reason").String()))
} else {
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", "STOP")
+ _ = templateDoc.Set("candidates.0.finishReason", "STOP")
}
} else {
return [][]byte{}
}
- return flushStorageOutputs(template)
+ return flushStorageOutputs(templateDoc.Bytes())
}
// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.
@@ -367,7 +366,7 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
// Returns:
// - []byte: A Gemini-compatible JSON response containing all message content and metadata
func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
// Verify this is a terminal response event.
responseType := rootResult.Get("type").String()
@@ -376,25 +375,25 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
}
// Base Gemini response template for non-streaming
- template := []byte(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
+ templateDoc := ojson.NewDocString(`{"candidates":[{"content":{"role":"model","parts":[]},"finishReason":"STOP"}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"},"modelVersion":"","createTime":"","responseId":""}`)
// Set model version
- template, _ = sjson.SetBytes(template, "modelVersion", modelName)
+ _ = templateDoc.Set("modelVersion", modelName)
// Set response metadata from the completed response
responseData := rootResult.Get("response")
if responseData.Exists() {
if responseType == "response.incomplete" {
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", codexGeminiIncompleteFinishReason(responseData.Get("incomplete_details.reason").String()))
+ _ = templateDoc.Set("candidates.0.finishReason", codexGeminiIncompleteFinishReason(responseData.Get("incomplete_details.reason").String()))
}
// Set response ID
if responseId := responseData.Get("id"); responseId.Exists() {
- template, _ = sjson.SetBytes(template, "responseId", responseId.String())
+ _ = templateDoc.Set("responseId", responseId.String())
}
// Set creation time
if createdAt := responseData.Get("created_at"); createdAt.Exists() {
- template, _ = sjson.SetBytes(template, "createTime", time.Unix(createdAt.Int(), 0).Format(time.RFC3339Nano))
+ _ = templateDoc.Set("createTime", time.Unix(createdAt.Int(), 0).Format(time.RFC3339Nano))
}
// Set usage metadata
@@ -403,9 +402,9 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
outputTokens := usage.Get("output_tokens").Int()
totalTokens := inputTokens + outputTokens
- template, _ = sjson.SetBytes(template, "usageMetadata.promptTokenCount", inputTokens)
- template, _ = sjson.SetBytes(template, "usageMetadata.candidatesTokenCount", outputTokens)
- template, _ = sjson.SetBytes(template, "usageMetadata.totalTokenCount", totalTokens)
+ _ = templateDoc.Set("usageMetadata.promptTokenCount", inputTokens)
+ _ = templateDoc.Set("usageMetadata.candidatesTokenCount", outputTokens)
+ _ = templateDoc.Set("usageMetadata.totalTokenCount", totalTokens)
}
// Process output content to build parts array
@@ -423,7 +422,7 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
}
if output := responseData.Get("output"); output.Exists() && output.IsArray() {
- output.ForEach(func(key, value gjson.Result) bool {
+ output.ForEach(func(key, value ojson.Result) bool {
itemType := value.Get("type").String()
switch itemType {
@@ -439,12 +438,12 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
signature := strings.TrimSpace(value.Get("encrypted_content").String())
thoughtTexts := extractCodexGeminiReasoningTexts(value)
for i, thoughtText := range thoughtTexts {
- part := []byte(`{"text":"","thought":true}`)
- part, _ = sjson.SetBytes(part, "text", thoughtText)
+ partDoc := ojson.NewDocString(`{"text":"","thought":true}`)
+ _ = partDoc.Set("text", thoughtText)
if i == len(thoughtTexts)-1 && signature != "" {
- part = setGeminiThoughtSignature(part, signature)
+ setGeminiThoughtSignatureDoc(partDoc, signature)
}
- parts = append(parts, part)
+ parts = append(parts, partDoc.Bytes())
}
if len(thoughtTexts) == 0 && signature != "" {
part := setGeminiThoughtSignature([]byte(`{"text":"","thought":true}`), signature)
@@ -457,11 +456,11 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
// Add regular text content
if content := value.Get("content"); content.Exists() && content.IsArray() {
- content.ForEach(func(_, contentItem gjson.Result) bool {
+ content.ForEach(func(_, contentItem ojson.Result) bool {
if contentItem.Get("type").String() == "output_text" {
if text := contentItem.Get("text"); text.Exists() {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", text.String())
+ part, _ = ojson.SetBytes(part, "text", text.String())
parts = append(parts, part)
}
}
@@ -478,33 +477,33 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
outputFormat := value.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
- part := []byte(`{"inlineData":{"data":"","mimeType":""}}`)
- part, _ = sjson.SetBytes(part, "inlineData.data", b64)
- part, _ = sjson.SetBytes(part, "inlineData.mimeType", mimeType)
- parts = append(parts, part)
+ partDoc := ojson.NewDocString(`{"inlineData":{"data":"","mimeType":""}}`)
+ _ = partDoc.Set("inlineData.data", b64)
+ _ = partDoc.Set("inlineData.mimeType", mimeType)
+ parts = append(parts, partDoc.Bytes())
case "function_call":
// Collect function call for potential merging with consecutive ones
- functionCall := []byte(`{"functionCall":{"args":{},"name":""}}`)
+ functionCallDoc := ojson.NewDocString(`{"functionCall":{"args":{},"name":""}}`)
{
n := value.Get("name").String()
rev := buildReverseMapFromGeminiOriginal(originalRequestRawJSON)
if orig, ok := rev[n]; ok {
n = orig
}
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", n)
+ _ = functionCallDoc.Set("functionCall.name", n)
}
// Parse and set arguments
if argsStr := value.Get("arguments").String(); argsStr != "" {
- argsResult := gjson.Parse(argsStr)
+ argsResult := ojson.Parse(argsStr)
if argsResult.IsObject() {
- functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsStr))
+ _ = functionCallDoc.SetRawBytes("functionCall.args", []byte(argsStr))
}
}
- functionCall = setGeminiFunctionCallID(functionCall, value)
+ setGeminiFunctionCallIDDoc(functionCallDoc, value)
- pendingFunctionCalls = append(pendingFunctionCalls, functionCall)
+ pendingFunctionCalls = append(pendingFunctionCalls, functionCallDoc.Bytes())
}
return true
})
@@ -513,16 +512,16 @@ func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string,
flushPendingFunctionCalls()
if len(parts) > 0 {
- template, _ = sjson.SetRawBytes(template, "candidates.0.content.parts", translatorcommon.JoinRawArray(parts))
+ _ = templateDoc.SetRawBytes("candidates.0.content.parts", translatorcommon.JoinRawArray(parts))
}
}
}
- return template
+ return templateDoc.Bytes()
}
// buildReverseMapFromGeminiOriginal builds a map[short]original from original Gemini request tools.
func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
- tools := gjson.GetBytes(original, "tools")
+ tools := ojson.GetBytesNoCopy(original, "tools")
rev := map[string]string{}
if !tools.IsArray() {
return rev
@@ -556,7 +555,7 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
// string verbatim makes official SDK []byte decoding fail. The carrier therefore
// stores magic+rawSignature as the byte payload and relies on standard base64
// JSON encoding, which every Gemini client decodes and echoes losslessly.
-func extractCodexGeminiReasoningTexts(value gjson.Result) []string {
+func extractCodexGeminiReasoningTexts(value ojson.Result) []string {
var thoughtTexts []string
if summary := value.Get("summary"); summary.IsArray() {
for _, entry := range summary.Array() {
@@ -573,7 +572,7 @@ func extractCodexGeminiReasoningTexts(value gjson.Result) []string {
}
}
}
- } else if content.Type == gjson.String && content.String() != "" {
+ } else if content.Type == ojson.String && content.String() != "" {
thoughtTexts = append(thoughtTexts, content.String())
}
return thoughtTexts
@@ -594,19 +593,26 @@ func setGeminiThoughtSignature(part []byte, rawSignature string) []byte {
if encoded == "" {
return part
}
- part, _ = sjson.SetBytes(part, "thoughtSignature", encoded)
+ part, _ = ojson.SetBytes(part, "thoughtSignature", encoded)
return part
}
-func setGeminiFunctionCallID(functionCall []byte, item gjson.Result) []byte {
+func setGeminiThoughtSignatureDoc(doc *ojson.Doc, rawSignature string) {
+ encoded := encodeGeminiCodexThoughtSignature(rawSignature)
+ if encoded == "" {
+ return
+ }
+ _ = doc.Set("thoughtSignature", encoded)
+}
+
+func setGeminiFunctionCallIDDoc(doc *ojson.Doc, item ojson.Result) {
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", callID)
- return functionCall
+ _ = doc.Set("functionCall.id", callID)
+ return
}
if id := strings.TrimSpace(item.Get("id").String()); id != "" {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", id)
+ _ = doc.Set("functionCall.id", id)
}
- return functionCall
}
func codexGeminiIncompleteFinishReason(reason string) string {
diff --git a/internal/translator/codex/gemini/codex_gemini_response_test.go b/internal/translator/codex/gemini/codex_gemini_response_test.go
index c8a1ed3a7d0..60e019a1388 100644
--- a/internal/translator/codex/gemini/codex_gemini_response_test.go
+++ b/internal/translator/codex/gemini/codex_gemini_response_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertCodexResponseToGemini_IncompleteTerminal(t *testing.T) {
@@ -18,12 +18,12 @@ func TestConvertCodexResponseToGemini_IncompleteTerminal(t *testing.T) {
if len(streamOut) != 1 {
t.Fatalf("expected 1 streaming terminal chunk, got %d", len(streamOut))
}
- if got := gjson.GetBytes(streamOut[0], "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
+ if got := ojson.GetBytesNoCopy(streamOut[0], "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
t.Fatalf("stream finishReason = %q, want MAX_TOKENS; payload=%s", got, streamOut[0])
}
nonStreamOut := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", nil, nil, terminal, nil)
- if got := gjson.GetBytes(nonStreamOut, "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
+ if got := ojson.GetBytesNoCopy(nonStreamOut, "candidates.0.finishReason").String(); got != "MAX_TOKENS" {
t.Fatalf("non-stream finishReason = %q, want MAX_TOKENS; payload=%s", got, nonStreamOut)
}
}
@@ -45,7 +45,7 @@ func TestConvertCodexResponseToGemini_StreamEmptyOutputUsesOutputItemDoneMessage
found := false
for _, out := range outputs {
- if gjson.GetBytes(out, "candidates.0.content.parts.0.text").String() == "ok" {
+ if ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.text").String() == "ok" {
found = true
break
}
@@ -66,12 +66,12 @@ func TestConvertCodexResponseToGemini_StreamPartialImageEmitsInlineData(t *testi
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
+ got := ojson.GetBytesNoCopy(out[0], "candidates.0.content.parts.0.inlineData.data").String()
if got != "aGVsbG8=" {
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out[0]))
}
- gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
+ gotMime := ojson.GetBytesNoCopy(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
if gotMime != "image/png" {
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out[0]))
}
@@ -102,12 +102,12 @@ func TestConvertCodexResponseToGemini_StreamImageGenerationCallDoneEmitsInlineDa
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- got := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.data").String()
+ got := ojson.GetBytesNoCopy(out[0], "candidates.0.content.parts.0.inlineData.data").String()
if got != "Ymll" {
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "Ymll", got, string(out[0]))
}
- gotMime := gjson.GetBytes(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
+ gotMime := ojson.GetBytesNoCopy(out[0], "candidates.0.content.parts.0.inlineData.mimeType").String()
if gotMime != "image/jpeg" {
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/jpeg", gotMime, string(out[0]))
}
@@ -120,12 +120,12 @@ func TestConvertCodexResponseToGemini_NonStreamImageGenerationCallAddsInlineData
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
- got := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.data").String()
+ got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.1.inlineData.data").String()
if got != "aGVsbG8=" {
t.Fatalf("expected inlineData.data %q, got %q; chunk=%s", "aGVsbG8=", got, string(out))
}
- gotMime := gjson.GetBytes(out, "candidates.0.content.parts.1.inlineData.mimeType").String()
+ gotMime := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.1.inlineData.mimeType").String()
if gotMime != "image/png" {
t.Fatalf("expected inlineData.mimeType %q, got %q; chunk=%s", "image/png", gotMime, string(out))
}
@@ -148,7 +148,7 @@ func TestConvertCodexResponseToGemini_StreamPreservesFunctionCallID(t *testing.T
got := ""
for _, chunk := range out {
- if value := gjson.GetBytes(chunk, "candidates.0.content.parts.0.functionCall.id").String(); value != "" {
+ if value := ojson.GetBytesNoCopy(chunk, "candidates.0.content.parts.0.functionCall.id").String(); value != "" {
got = value
break
}
@@ -165,7 +165,7 @@ func TestConvertCodexResponseToGemini_ReasoningThoughtSignature(t *testing.T) {
t.Run("non-stream summary carries signature", func(t *testing.T) {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"muse-spark-1.3","created_at":1,"usage":{"input_tokens":1,"output_tokens":2},"output":[{"type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"thinking summary"}]},{"type":"message","content":[{"type":"output_text","text":"answer"}]}]}}`)
out := ConvertCodexResponseToGeminiNonStream(ctx, "muse-spark-1.3", nil, nil, raw, nil)
- part := gjson.GetBytes(out, "candidates.0.content.parts.0")
+ part := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0")
if !part.Get("thought").Bool() || part.Get("text").String() != "thinking summary" {
t.Fatalf("part.0 = %s, want thought summary part; payload=%s", part.Raw, out)
}
@@ -183,7 +183,7 @@ func TestConvertCodexResponseToGemini_ReasoningThoughtSignature(t *testing.T) {
if got := decodeGeminiCodexThoughtSignature(part.Get("thoughtSignature").String()); got != signature {
t.Fatalf("carrier round-trip = %q, want raw Meta signature", got)
}
- if got := gjson.GetBytes(out, "candidates.0.content.parts.1.text").String(); got != "answer" {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.1.text").String(); got != "answer" {
t.Fatalf("part.1 text = %q, want answer; payload=%s", got, out)
}
})
@@ -191,7 +191,7 @@ func TestConvertCodexResponseToGemini_ReasoningThoughtSignature(t *testing.T) {
t.Run("non-stream empty summary still carries signature", func(t *testing.T) {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"muse-spark-1.3","created_at":1,"usage":{"input_tokens":1,"output_tokens":2},"output":[{"type":"reasoning","encrypted_content":"` + signature + `","summary":[]},{"type":"message","content":[{"type":"output_text","text":"answer"}]}]}}`)
out := ConvertCodexResponseToGeminiNonStream(ctx, "muse-spark-1.3", nil, nil, raw, nil)
- part := gjson.GetBytes(out, "candidates.0.content.parts.0")
+ part := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0")
if !part.Get("thought").Bool() || part.Get("thoughtSignature").String() != encodeGeminiCodexThoughtSignature(signature) {
t.Fatalf("part.0 = %s, want empty thought carrier with signature; payload=%s", part.Raw, out)
}
@@ -207,7 +207,7 @@ func TestConvertCodexResponseToGemini_ReasoningThoughtSignature(t *testing.T) {
if len(out) != 1 {
t.Fatalf("text delta emitted %d chunks, want 1", len(out))
}
- part := gjson.GetBytes(out[0], "candidates.0.content.parts.0")
+ part := ojson.GetBytesNoCopy(out[0], "candidates.0.content.parts.0")
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != encodeGeminiCodexThoughtSignature(signature) {
t.Fatalf("text part = %s, want answer with Meta thoughtSignature", part.Raw)
}
@@ -231,7 +231,7 @@ func TestConvertCodexResponseToGemini_StreamMultipleFunctionCallsKeepSignatureAn
var names []string
signatureOnAlpha := false
for _, chunk := range outputs {
- for _, part := range gjson.GetBytes(chunk, "candidates.0.content.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(chunk, "candidates.0.content.parts").Array() {
name := part.Get("functionCall.name").String()
if name == "" {
continue
@@ -256,11 +256,11 @@ func TestConvertCodexResponseToGeminiNonStream_MultiSummaryRoundTrip(t *testing.
raw := []byte(`{"type":"response.completed","response":{"id":"resp_multi","model":"muse-spark-1.3","created_at":1,"usage":{"input_tokens":1,"output_tokens":2},"output":[{"type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"step A"},{"type":"summary_text","text":"step B"}]},{"type":"message","content":[{"type":"output_text","text":"final answer"}]}]}}`)
geminiOut := ConvertCodexResponseToGeminiNonStream(ctx, "muse-spark-1.3", nil, nil, raw, nil)
- partsRaw := gjson.GetBytes(geminiOut, "candidates.0.content.parts").Raw
+ partsRaw := ojson.GetBytesNoCopy(geminiOut, "candidates.0.content.parts").Raw
reqPayload := []byte(`{"model":"muse-spark-1.3","contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":` + partsRaw + `},{"role":"user","parts":[{"text":"next"}]}]}`)
codexIn, _ := ConvertGeminiRequestToCodex("deployment-alias", reqPayload, false)
- reasoning := gjson.GetBytes(codexIn, "input.1")
+ reasoning := ojson.GetBytesNoCopy(codexIn, "input.1")
if reasoning.Get("type").String() != "reasoning" || reasoning.Get("encrypted_content").String() != signature {
t.Fatalf("round-trip reasoning item = %s, want Meta signature", reasoning.Raw)
}
@@ -274,11 +274,11 @@ func TestConvertCodexResponseToGeminiNonStream_MultiSummaryRoundTrip(t *testing.
var streamParam any
doneOnly := []byte(`data: {"type":"response.output_item.done","item":{"id":"rs_done_only","type":"reasoning","encrypted_content":"` + signature + `","summary":[{"type":"summary_text","text":"done-only thought"}]}}`)
doneChunks := ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, doneOnly, &streamParam)
- if len(doneChunks) != 1 || !gjson.GetBytes(doneChunks[0], "candidates.0.content.parts.0.thought").Bool() || gjson.GetBytes(doneChunks[0], "candidates.0.content.parts.0.text").String() != "done-only thought" {
+ if len(doneChunks) != 1 || !ojson.GetBytesNoCopy(doneChunks[0], "candidates.0.content.parts.0.thought").Bool() || ojson.GetBytesNoCopy(doneChunks[0], "candidates.0.content.parts.0.text").String() != "done-only thought" {
t.Fatalf("done-only reasoning chunks = %q, want thought summary chunk", doneChunks)
}
textChunks := ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, []byte(`data: {"type":"response.output_text.delta","delta":"visible"}`), &streamParam)
- if len(textChunks) != 1 || decodeGeminiCodexThoughtSignature(gjson.GetBytes(textChunks[0], "candidates.0.content.parts.0.thoughtSignature").String()) != signature {
+ if len(textChunks) != 1 || decodeGeminiCodexThoughtSignature(ojson.GetBytesNoCopy(textChunks[0], "candidates.0.content.parts.0.thoughtSignature").String()) != signature {
t.Fatalf("text chunk after done-only reasoning = %q, want carrier signature", textChunks)
}
@@ -296,14 +296,14 @@ func TestConvertCodexResponseToGeminiNonStream_MultiSummaryRoundTrip(t *testing.
[]byte(`data: {"type":"response.output_text.delta","delta":"answer"}`),
} {
for _, chunk := range ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, ev, &consecutiveParam) {
- for _, p := range gjson.GetBytes(chunk, "candidates.0.content.parts").Array() {
+ for _, p := range ojson.GetBytesNoCopy(chunk, "candidates.0.content.parts").Array() {
streamedParts = append(streamedParts, p.Raw)
}
}
}
replayReq := []byte(`{"model":"muse-spark-1.3","contents":[{"role":"user","parts":[{"text":"q"}]},{"role":"model","parts":[` + strings.Join(streamedParts, ",") + `]},{"role":"user","parts":[{"text":"next"}]}]}`)
replayCodex, _ := ConvertGeminiRequestToCodex("muse-spark-1.3", replayReq, false)
- if gjson.GetBytes(replayCodex, "input.1.summary.0.text").String() != "first summary" || gjson.GetBytes(replayCodex, "input.2.summary.0.text").String() != "second summary" {
+ if ojson.GetBytesNoCopy(replayCodex, "input.1.summary.0.text").String() != "first summary" || ojson.GetBytesNoCopy(replayCodex, "input.2.summary.0.text").String() != "second summary" {
t.Fatalf("replayed consecutive reasoning items = %s, want separate summaries", replayCodex)
}
@@ -319,16 +319,16 @@ func TestConvertCodexResponseToGeminiNonStream_MultiSummaryRoundTrip(t *testing.
[]byte(`data: {"type":"response.output_text.delta","delta":"ok"}`),
} {
for _, chunk := range ConvertCodexResponseToGemini(ctx, "muse-spark-1.3", nil, nil, ev, &wsParam) {
- for _, p := range gjson.GetBytes(chunk, "candidates.0.content.parts").Array() {
+ for _, p := range ojson.GetBytesNoCopy(chunk, "candidates.0.content.parts").Array() {
wsParts = append(wsParts, p.Raw)
}
}
}
wsReplayReq := []byte(`{"model":"muse-spark-1.3","contents":[{"role":"user","parts":[{"text":"q"}]},{"role":"model","parts":[` + strings.Join(wsParts, ",") + `]},{"role":"user","parts":[{"text":"next"}]}]}`)
wsReplayCodex, _ := ConvertGeminiRequestToCodex("muse-spark-1.3", wsReplayReq, false)
- wsSummaries := gjson.GetBytes(wsReplayCodex, "input.1.summary").Array()
+ wsSummaries := ojson.GetBytesNoCopy(wsReplayCodex, "input.1.summary").Array()
if len(wsSummaries) != 3 || wsSummaries[0].Get("text").String()+wsSummaries[1].Get("text").String()+wsSummaries[2].Get("text").String() != "Hello world" {
- t.Fatalf("whitespace thought chunks round-trip = %s, want ['Hello',' ','world']", gjson.GetBytes(wsReplayCodex, "input.1.summary").Raw)
+ t.Fatalf("whitespace thought chunks round-trip = %s, want ['Hello',' ','world']", ojson.GetBytesNoCopy(wsReplayCodex, "input.1.summary").Raw)
}
}
@@ -339,7 +339,7 @@ func TestConvertCodexResponseToGeminiNonStreamPreservesFunctionCallID(t *testing
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"usage":{"input_tokens":1,"output_tokens":1},"output":[{"type":"function_call","call_id":"call_gateway","name":"lookup","arguments":"{\"query\":\"status\"}"}]}}`)
out := ConvertCodexResponseToGeminiNonStream(ctx, "gemini-2.5-pro", originalRequest, nil, raw, nil)
- got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String()
+ got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.id").String()
if got != "call_gateway" {
t.Fatalf("expected functionCall.id %q, got %q; chunk=%s", "call_gateway", got, string(out))
}
@@ -349,7 +349,7 @@ func TestConvertCodexResponseToGemini_CompletedTerminalSetsStop(t *testing.T) {
var param any
terminal := []byte(`{"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":3,"output_tokens":1}}}`)
out := ConvertCodexResponseToGemini(context.Background(), "gpt-test", nil, nil, append([]byte("data: "), terminal...), ¶m)
- if len(out) != 1 || gjson.GetBytes(out[0], "candidates.0.finishReason").String() != "STOP" {
+ if len(out) != 1 || ojson.GetBytesNoCopy(out[0], "candidates.0.finishReason").String() != "STOP" {
t.Fatalf("want one chunk with finishReason STOP, got %q", out)
}
}
diff --git a/internal/translator/codex/gemini/noop_optimization_test.go b/internal/translator/codex/gemini/noop_optimization_test.go
index 508382be976..c283fd609ed 100644
--- a/internal/translator/codex/gemini/noop_optimization_test.go
+++ b/internal/translator/codex/gemini/noop_optimization_test.go
@@ -3,13 +3,14 @@ package gemini
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestCleanGeminiCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`)
- output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input))
+ output := cleanGeminiCodexToolParameters(util.ParseGJSONBytesNoCopy(input))
if string(output) != string(input) {
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
@@ -18,7 +19,7 @@ func TestCleanGeminiCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
func TestSetCodexToolChoiceFromGeminiToolConfigReusesAutoChoice(t *testing.T) {
input := []byte(`{"tool_choice":"auto","input":[]}`)
- config := gjson.Parse(`{"mode":"AUTO"}`)
+ config := ojson.Parse(`{"mode":"AUTO"}`)
output := setCodexToolChoiceFromGeminiToolConfig(input, config)
@@ -30,12 +31,12 @@ func TestSetCodexToolChoiceFromGeminiToolConfigReusesAutoChoice(t *testing.T) {
func TestCleanGeminiCodexToolParametersNormalizesSchema(t *testing.T) {
input := []byte(`{"type":"object","$schema":"draft","additionalProperties":true}`)
- output := cleanGeminiCodexToolParameters(gjson.ParseBytes(input))
+ output := cleanGeminiCodexToolParameters(util.ParseGJSONBytesNoCopy(input))
- if gjson.GetBytes(output, "$schema").Exists() {
+ if ojson.GetBytesNoCopy(output, "$schema").Exists() {
t.Fatal("$schema should be removed")
}
- if additionalProperties := gjson.GetBytes(output, "additionalProperties"); additionalProperties.Type != gjson.False {
+ if additionalProperties := ojson.GetBytesNoCopy(output, "additionalProperties"); additionalProperties.Type != ojson.False {
t.Fatalf("additionalProperties = %s, want false", additionalProperties.Raw)
}
}
diff --git a/internal/translator/codex/interactions/interactions_codex_request.go b/internal/translator/codex/interactions/interactions_codex_request.go
index 47be307a291..93f11529375 100644
--- a/internal/translator/codex/interactions/interactions_codex_request.go
+++ b/internal/translator/codex/interactions/interactions_codex_request.go
@@ -5,10 +5,10 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -19,43 +19,43 @@ func ConvertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, st
// convertInteractionsRequestToCodex also reports a user turn that was left empty
// because its only attachment has no Codex equivalent.
func convertInteractionsRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","instructions":"","store":false,"input":[]}`)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","instructions":"","store":false,"input":[]}`)
+ _ = outDoc.Set("model", modelName)
if stream || root.Get("stream").Bool() {
- out, _ = sjson.SetBytes(out, "stream", true)
+ _ = outDoc.Set("stream", true)
}
- out = copyInteractionsSystemToCodex(out, root)
- out = copyInteractionsGenerationConfigToCodex(out, root)
+ copyInteractionsSystemToCodexDoc(outDoc, root)
+ hasToolChoice := copyInteractionsGenerationConfigToCodexDoc(outDoc, root)
signatureTargetModel := translatorcommon.ResolveCodexReplayTargetModel(modelName, inputRawJSON)
inputItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int())
errInput := appendInteractionsInputToCodex(&inputItems, root.Get("input"), signatureTargetModel)
- out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
- out = copyInteractionsToolsToCodex(out, root)
- out = copyInteractionsCodexTopLevel(out, root)
- out = ensureInteractionsCodexEncryptedContentInclude(out)
- return out, errInput
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "input", inputItems)
+ copyInteractionsToolsToCodexDoc(outDoc, root, hasToolChoice)
+ copyInteractionsCodexTopLevelDoc(outDoc, root)
+ ensureInteractionsCodexEncryptedContentIncludeDoc(outDoc, root)
+ return outDoc.Bytes(), errInput
}
-func copyInteractionsSystemToCodex(out []byte, root gjson.Result) []byte {
+func copyInteractionsSystemToCodexDoc(doc *ojson.Doc, root ojson.Result) {
systemInstruction := root.Get("system_instruction")
if !systemInstruction.Exists() {
systemInstruction = root.Get("systemInstruction")
}
if !systemInstruction.Exists() {
- return out
+ return
}
- if systemInstruction.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "instructions", systemInstruction.String())
- return out
+ if systemInstruction.Type == ojson.String {
+ _ = doc.Set("instructions", systemInstruction.String())
+ return
}
- if text := systemInstruction.Get("text"); text.Exists() && text.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "instructions", text.String())
- return out
+ if text := systemInstruction.Get("text"); text.Exists() && text.Type == ojson.String {
+ _ = doc.Set("instructions", text.String())
+ return
}
if parts := systemInstruction.Get("parts"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
text := part.Get("text").String()
if text == "" {
return true
@@ -67,64 +67,72 @@ func copyInteractionsSystemToCodex(out []byte, root gjson.Result) []byte {
return true
})
if builder.Len() > 0 {
- out, _ = sjson.SetBytes(out, "instructions", builder.String())
+ _ = doc.Set("instructions", builder.String())
}
}
- return out
}
-func copyInteractionsGenerationConfigToCodex(out []byte, root gjson.Result) []byte {
+func copyInteractionsGenerationConfigToCodexDoc(doc *ojson.Doc, root ojson.Result) bool {
cfg := root.Get("generation_config")
if !cfg.Exists() {
cfg = root.Get("generationConfig")
}
if !cfg.Exists() {
if reasoning := root.Get("reasoning"); reasoning.Exists() {
- out, _ = sjson.SetRawBytes(out, "reasoning", []byte(reasoning.Raw))
+ _ = doc.SetRawBytes("reasoning", []byte(reasoning.Raw))
}
- return out
+ return false
}
if reasoning := cfg.Get("reasoning"); reasoning.Exists() {
- out, _ = sjson.SetRawBytes(out, "reasoning", []byte(reasoning.Raw))
+ _ = doc.SetRawBytes("reasoning", []byte(reasoning.Raw))
}
if effort := interactionsCodexReasoningEffort(cfg); effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
+ _ = doc.Set("reasoning.effort", effort)
}
if summary := interactionsCodexReasoningSummary(cfg); summary != "" {
- out, _ = sjson.SetBytes(out, "reasoning.summary", summary)
- }
- copyRawPaths := map[string]string{
- "max_output_tokens": "max_output_tokens",
- "maxOutputTokens": "max_output_tokens",
- "max_tokens": "max_output_tokens",
- "temperature": "temperature",
- "top_p": "top_p",
- "topP": "top_p",
- "presence_penalty": "presence_penalty",
- "presencePenalty": "presence_penalty",
- "frequency_penalty": "frequency_penalty",
- "frequencyPenalty": "frequency_penalty",
- "parallel_tool_calls": "parallel_tool_calls",
- "parallelToolCalls": "parallel_tool_calls",
- "response_format": "response_format",
- "responseFormat": "response_format",
- "text": "text",
- "verbosity": "text.verbosity",
- "truncation": "truncation",
- "tool_choice": "tool_choice",
- "toolChoice": "tool_choice",
- "service_tier": "service_tier",
- "serviceTier": "service_tier",
- }
- for sourcePath, targetPath := range copyRawPaths {
- if value := cfg.Get(sourcePath); value.Exists() {
- out, _ = sjson.SetRawBytes(out, targetPath, []byte(value.Raw))
+ _ = doc.Set("reasoning.summary", summary)
+ }
+ copyRawPaths := []struct {
+ sourcePath string
+ targetPath string
+ }{
+ {"max_output_tokens", "max_output_tokens"},
+ {"maxOutputTokens", "max_output_tokens"},
+ {"max_completion_tokens", "max_output_tokens"},
+ {"maxCompletionTokens", "max_output_tokens"},
+ {"max_tokens", "max_output_tokens"},
+ {"temperature", "temperature"},
+ {"top_p", "top_p"},
+ {"topP", "top_p"},
+ {"presence_penalty", "presence_penalty"},
+ {"presencePenalty", "presence_penalty"},
+ {"frequency_penalty", "frequency_penalty"},
+ {"frequencyPenalty", "frequency_penalty"},
+ {"parallel_tool_calls", "parallel_tool_calls"},
+ {"parallelToolCalls", "parallel_tool_calls"},
+ {"response_format", "response_format"},
+ {"responseFormat", "response_format"},
+ {"text", "text"},
+ {"verbosity", "text.verbosity"},
+ {"truncation", "truncation"},
+ {"tool_choice", "tool_choice"},
+ {"toolChoice", "tool_choice"},
+ {"service_tier", "service_tier"},
+ {"serviceTier", "service_tier"},
+ }
+ hasToolChoice := false
+ for _, paths := range copyRawPaths {
+ if value := cfg.Get(paths.sourcePath); value.Exists() && !doc.Get(paths.targetPath).Exists() {
+ _ = doc.SetRawBytes(paths.targetPath, []byte(value.Raw))
+ if paths.targetPath == "tool_choice" {
+ hasToolChoice = true
+ }
}
}
- return out
+ return hasToolChoice
}
-func interactionsCodexReasoningEffort(cfg gjson.Result) string {
+func interactionsCodexReasoningEffort(cfg ojson.Result) string {
for _, path := range []string{
"thinking_level",
"thinkingLevel",
@@ -158,13 +166,13 @@ func interactionsCodexReasoningEffort(cfg gjson.Result) string {
return ""
}
-func interactionsCodexReasoningSummary(cfg gjson.Result) string {
+func interactionsCodexReasoningSummary(cfg ojson.Result) string {
for _, path := range []string{
"thinking_summaries",
"thinkingSummaries",
"reasoning.summary",
} {
- if value := cfg.Get(path); value.Type == gjson.String {
+ if value := cfg.Get(path); value.Type == ojson.String {
summary := strings.ToLower(strings.TrimSpace(value.String()))
switch summary {
case "auto", "none":
@@ -181,9 +189,9 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string {
"thinkingConfig.includeThoughts",
} {
switch value := cfg.Get(path); value.Type {
- case gjson.True:
+ case ojson.True:
return "auto"
- case gjson.False:
+ case ojson.False:
return "none"
}
}
@@ -192,24 +200,24 @@ func interactionsCodexReasoningSummary(cfg gjson.Result) string {
// appendInteractionsInputToCodex adds the input steps and reports a user turn that
// was left with nothing to send because of an attachment Codex cannot carry.
-func appendInteractionsInputToCodex(items *[][]byte, input gjson.Result, modelName string) error {
+func appendInteractionsInputToCodex(items *[][]byte, input ojson.Result, modelName string) error {
if !input.Exists() {
return nil
}
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
appendInteractionsTextToCodex(items, "user", input.String())
return nil
}
var run translatorcommon.UserRun
emittedSignatures := make(map[string]bool)
if input.IsArray() {
- input.ForEach(func(_, step gjson.Result) bool {
+ input.ForEach(func(_, step ojson.Result) bool {
appendInteractionsStepToCodex(items, step, "user", &run, modelName, emittedSignatures)
return true
})
} else if steps := input.Get("steps"); steps.Exists() && steps.IsArray() {
defaultRole := interactionsCodexStepRole(input, "user")
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
appendInteractionsStepToCodex(items, step, defaultRole, &run, modelName, emittedSignatures)
return true
})
@@ -230,14 +238,14 @@ func interactionsCodexUserRun(run *translatorcommon.UserRun, role string) *trans
return nil
}
-func appendInteractionsStepToCodex(items *[][]byte, step gjson.Result, defaultRole string, run *translatorcommon.UserRun, modelName string, emittedSignatures map[string]bool) {
- if step.Type == gjson.String {
+func appendInteractionsStepToCodex(items *[][]byte, step ojson.Result, defaultRole string, run *translatorcommon.UserRun, modelName string, emittedSignatures map[string]bool) {
+ if step.Type == ojson.String {
appendInteractionsRoleTextToCodex(items, defaultRole, step.String(), run)
return
}
if steps := step.Get("steps"); steps.Exists() && steps.IsArray() {
role := interactionsCodexStepRole(step, defaultRole)
- steps.ForEach(func(_, nested gjson.Result) bool {
+ steps.ForEach(func(_, nested ojson.Result) bool {
appendInteractionsStepToCodex(items, nested, role, run, modelName, emittedSignatures)
return true
})
@@ -284,17 +292,17 @@ func appendInteractionsRoleTextToCodex(items *[][]byte, role, text string, run *
}
}
-func appendInteractionsContentToCodexItem(items *[][]byte, content gjson.Result, role string, run *translatorcommon.UserRun) {
+func appendInteractionsContentToCodexItem(items *[][]byte, content ojson.Result, role string, run *translatorcommon.UserRun) {
if !content.Exists() {
return
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
appendInteractionsRoleTextToCodex(items, role, content.String(), run)
return
}
userRun := interactionsCodexUserRun(run, role)
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
appendInteractionsContentPartToCodex(items, part, role, userRun)
return true
})
@@ -308,7 +316,7 @@ func appendInteractionsContentToCodexItem(items *[][]byte, content gjson.Result,
// appendInteractionsContentPartToCodex adds one content part. run is nil for
// content that is not user content; for user content it records what is sent and
// what cannot be.
-func appendInteractionsContentPartToCodex(items *[][]byte, part gjson.Result, role string, run *translatorcommon.UserRun) {
+func appendInteractionsContentPartToCodex(items *[][]byte, part ojson.Result, role string, run *translatorcommon.UserRun) {
item := interactionsCodexMessagePart(part, role)
if len(item) == 0 {
if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" {
@@ -325,51 +333,51 @@ func appendInteractionsContentPartToCodex(items *[][]byte, part gjson.Result, ro
// interactionsCodexIsBlankText reports whether a Codex message part is a text part
// holding only whitespace.
func interactionsCodexIsBlankText(item []byte) bool {
- parsed := gjson.ParseBytes(item)
+ parsed := util.ParseGJSONBytesNoCopy(item)
partType := parsed.Get("type").String()
return (partType == "input_text" || partType == "output_text") && strings.TrimSpace(parsed.Get("text").String()) == ""
}
-func appendInteractionsFunctionCallToCodex(items *[][]byte, step gjson.Result) {
- item := []byte(`{"type":"function_call"}`)
+func appendInteractionsFunctionCallToCodex(items *[][]byte, step ojson.Result) {
+ itemDoc := ojson.NewDocString(`{"type":"function_call"}`)
if name := step.Get("name"); name.Exists() {
- item, _ = sjson.SetBytes(item, "name", shortenCodexToolNameIfNeeded(name.String()))
+ _ = itemDoc.Set("name", shortenCodexToolNameIfNeeded(name.String()))
}
if callID := interactionsCodexCallID(step); callID != "" {
- item, _ = sjson.SetBytes(item, "call_id", callID)
+ _ = itemDoc.Set("call_id", callID)
}
if args := step.Get("arguments"); args.Exists() {
- item, _ = sjson.SetBytes(item, "arguments", interactionsCodexJSONString(args))
+ _ = itemDoc.Set("arguments", interactionsCodexJSONString(args))
} else if args := step.Get("args"); args.Exists() {
- item, _ = sjson.SetBytes(item, "arguments", interactionsCodexJSONString(args))
+ _ = itemDoc.Set("arguments", interactionsCodexJSONString(args))
}
- *items = append(*items, item)
+ *items = append(*items, itemDoc.Bytes())
}
-func appendInteractionsFunctionResultToCodex(items *[][]byte, step gjson.Result) {
- item := []byte(`{"type":"function_call_output"}`)
+func appendInteractionsFunctionResultToCodex(items *[][]byte, step ojson.Result) {
+ itemDoc := ojson.NewDocString(`{"type":"function_call_output"}`)
if callID := interactionsCodexCallID(step); callID != "" {
- item, _ = sjson.SetBytes(item, "call_id", callID)
+ _ = itemDoc.Set("call_id", callID)
}
if result := step.Get("result"); result.Exists() {
- item, _ = sjson.SetBytes(item, "output", interactionsCodexOutputString(result))
+ _ = itemDoc.Set("output", interactionsCodexOutputString(result))
} else if output := step.Get("output"); output.Exists() {
- item, _ = sjson.SetBytes(item, "output", interactionsCodexOutputString(output))
+ _ = itemDoc.Set("output", interactionsCodexOutputString(output))
}
- *items = append(*items, item)
+ *items = append(*items, itemDoc.Bytes())
}
-func copyInteractionsToolsToCodex(out []byte, root gjson.Result) []byte {
+func copyInteractionsToolsToCodexDoc(doc *ojson.Doc, root ojson.Result, hasToolChoice bool) {
tools := root.Get("tools")
if !tools.Exists() {
- return out
+ return
}
if !tools.IsArray() {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = doc.SetRawBytes("tools", []byte(tools.Raw))
+ return
}
normalized := make([]map[string]any, 0)
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if decls := tool.Get("function_declarations"); decls.Exists() {
appendCodexToolDeclarations(&normalized, decls)
return true
@@ -384,71 +392,81 @@ func copyInteractionsToolsToCodex(out []byte, root gjson.Result) []byte {
return true
})
if len(normalized) == 0 {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = doc.SetRawBytes("tools", []byte(tools.Raw))
+ return
}
raw, errMarshal := json.Marshal(normalized)
if errMarshal != nil {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = doc.SetRawBytes("tools", []byte(tools.Raw))
+ return
}
- out, _ = sjson.SetRawBytes(out, "tools", raw)
- if !gjson.GetBytes(out, "tool_choice").Exists() {
- out, _ = sjson.SetBytes(out, "tool_choice", "auto")
+ _ = doc.SetRawBytes("tools", raw)
+ if !hasToolChoice {
+ _ = doc.Set("tool_choice", "auto")
+ }
+}
+
+func copyInteractionsCodexTopLevelDoc(doc *ojson.Doc, root ojson.Result) {
+ if !doc.Get("max_output_tokens").Exists() {
+ for _, path := range []string{
+ "max_output_tokens",
+ "maxOutputTokens",
+ "max_completion_tokens",
+ "maxCompletionTokens",
+ "max_tokens",
+ "agent_config.max_total_tokens",
+ "agentConfig.maxTotalTokens",
+ } {
+ if value := root.Get(path); value.Exists() {
+ _ = doc.SetRawBytes("max_output_tokens", []byte(value.Raw))
+ break
+ }
+ }
}
- return out
-}
-
-func copyInteractionsCodexTopLevel(out []byte, root gjson.Result) []byte {
if serviceTier := normalizeInteractionsCodexServiceTier(root.Get("service_tier")); serviceTier != "" {
- current := gjson.GetBytes(out, "service_tier")
- if current.Type != gjson.String || current.String() != serviceTier {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier)
- }
+ _ = doc.Set("service_tier", serviceTier)
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
- out = setInteractionsCodexRawIfDifferent(out, "tool_choice", toolChoice)
+ _ = doc.SetRawBytes("tool_choice", []byte(toolChoice.Raw))
}
for _, path := range []string{"parallel_tool_calls", "store", "metadata", "include", "truncation"} {
if value := root.Get(path); value.Exists() {
- out = setInteractionsCodexRawIfDifferent(out, path, value)
+ _ = doc.SetRawBytes(path, []byte(value.Raw))
}
}
- return out
}
-func setInteractionsCodexRawIfDifferent(out []byte, path string, value gjson.Result) []byte {
- current := gjson.GetBytes(out, path)
+func setInteractionsCodexRawIfDifferent(out []byte, path string, value ojson.Result) []byte {
+ current := ojson.GetBytesNoCopy(out, path)
if current.Exists() && current.Raw == value.Raw {
return out
}
- updated, errSet := sjson.SetRawBytes(out, path, []byte(value.Raw))
+ updated, errSet := ojson.SetRawBytes(out, path, []byte(value.Raw))
if errSet != nil {
return out
}
return updated
}
-// ensureInteractionsCodexEncryptedContentInclude makes the outbound request
+// ensureInteractionsCodexEncryptedContentIncludeDoc makes the outbound request
// ask the upstream for encrypted reasoning content so a signature can come
// back for replay.
-func ensureInteractionsCodexEncryptedContentInclude(out []byte) []byte {
+func ensureInteractionsCodexEncryptedContentIncludeDoc(doc *ojson.Doc, root ojson.Result) {
const reasoningEncryptedContent = "reasoning.encrypted_content"
- include := gjson.GetBytes(out, "include")
+ include := root.Get("include")
if !include.Exists() {
- out, _ = sjson.SetRawBytes(out, "include", []byte(`["reasoning.encrypted_content"]`))
- return out
+ _ = doc.SetRawBytes("include", []byte(`["reasoning.encrypted_content"]`))
+ return
}
if !include.IsArray() {
- return out
+ return
}
for _, value := range include.Array() {
- if value.Type == gjson.String && value.String() == reasoningEncryptedContent {
- return out
+ if value.Type == ojson.String && value.String() == reasoningEncryptedContent {
+ return
}
}
- out, _ = sjson.SetBytes(out, "include.-1", reasoningEncryptedContent)
- return out
+ _ = doc.Set("include.-1", reasoningEncryptedContent)
}
// appendInteractionsThoughtToCodex adds a thought step. A client-echoed
@@ -461,7 +479,7 @@ func ensureInteractionsCodexEncryptedContentInclude(out []byte) []byte {
// a reasoning item without accepted encrypted_content carries no replayable
// state, and the legacy text fallback shape is rejected by both the native
// Meta upstream (missing summary) and routers (content expected array).
-func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result, modelName string, emittedSignatures map[string]bool) {
+func appendInteractionsThoughtToCodex(items *[][]byte, step ojson.Result, modelName string, emittedSignatures map[string]bool) {
text := interactionsCodexContentText(step.Get("content"))
if text == "" {
text = step.Get("text").String()
@@ -469,21 +487,21 @@ func appendInteractionsThoughtToCodex(items *[][]byte, step gjson.Result, modelN
if raw := interactionsThoughtStepSignature(step); raw != "" {
if normalized, ok := translatorcommon.CodexReplaySignatureForTarget(modelName, raw); ok && !emittedSignatures[normalized] {
emittedSignatures[normalized] = true
- item := []byte(`{"type":"reasoning","encrypted_content":"","summary":[]}`)
- item, _ = sjson.SetBytes(item, "encrypted_content", normalized)
+ itemDoc := ojson.NewDocString(`{"type":"reasoning","encrypted_content":"","summary":[]}`)
+ _ = itemDoc.Set("encrypted_content", normalized)
if text != "" {
summaryPart := []byte(`{"type":"summary_text","text":""}`)
- summaryPart, _ = sjson.SetBytes(summaryPart, "text", text)
- item, _ = sjson.SetRawBytes(item, "summary", translatorcommon.JoinRawArray([][]byte{summaryPart}))
+ summaryPart, _ = ojson.SetBytes(summaryPart, "text", text)
+ _ = itemDoc.SetRawBytes("summary", translatorcommon.JoinRawArray([][]byte{summaryPart}))
}
- *items = append(*items, item)
+ *items = append(*items, itemDoc.Bytes())
}
}
}
// interactionsThoughtStepSignature extracts a thought step's signature under
// any of the field spellings seen on the wire.
-func interactionsThoughtStepSignature(step gjson.Result) string {
+func interactionsThoughtStepSignature(step ojson.Result) string {
for _, path := range []string{"signature", "thought_signature", "thoughtSignature"} {
if signature := strings.TrimSpace(step.Get(path).String()); signature != "" {
return signature
@@ -493,33 +511,33 @@ func interactionsThoughtStepSignature(step gjson.Result) string {
}
func appendInteractionsTextToCodex(items *[][]byte, role, text string) {
- part := []byte(`{"type":"","text":""}`)
+ partDoc := ojson.NewDocString(`{"type":"","text":""}`)
if role == "assistant" {
- part, _ = sjson.SetBytes(part, "type", "output_text")
+ _ = partDoc.Set("type", "output_text")
} else {
- part, _ = sjson.SetBytes(part, "type", "input_text")
+ _ = partDoc.Set("type", "input_text")
}
- part, _ = sjson.SetBytes(part, "text", text)
- appendInteractionsMessagePartToCodex(items, role, part)
+ _ = partDoc.Set("text", text)
+ appendInteractionsMessagePartToCodex(items, role, partDoc.Bytes())
}
func appendInteractionsMessagePartToCodex(items *[][]byte, role string, part []byte) {
- message := []byte(`{"type":"message","role":"","content":[]}`)
- message, _ = sjson.SetBytes(message, "role", role)
- message, _ = sjson.SetRawBytes(message, "content", translatorcommon.JoinRawArray([][]byte{part}))
- *items = append(*items, message)
+ messageDoc := ojson.NewDocString(`{"type":"message","role":"","content":[]}`)
+ _ = messageDoc.Set("role", role)
+ _ = messageDoc.SetRawBytes("content", translatorcommon.JoinRawArray([][]byte{part}))
+ *items = append(*items, messageDoc.Bytes())
}
-func interactionsCodexMessagePart(part gjson.Result, role string) []byte {
+func interactionsCodexMessagePart(part ojson.Result, role string) []byte {
if text := part.Get("text"); text.Exists() {
- item := []byte(`{"type":"","text":""}`)
+ itemDoc := ojson.NewDocString(`{"type":"","text":""}`)
if role == "assistant" {
- item, _ = sjson.SetBytes(item, "type", "output_text")
+ _ = itemDoc.Set("type", "output_text")
} else {
- item, _ = sjson.SetBytes(item, "type", "input_text")
+ _ = itemDoc.Set("type", "input_text")
}
- item, _ = sjson.SetBytes(item, "text", text.String())
- return item
+ _ = itemDoc.Set("text", text.String())
+ return itemDoc.Bytes()
}
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
switch partType {
@@ -529,14 +547,14 @@ func interactionsCodexMessagePart(part gjson.Result, role string) []byte {
return interactionsCodexImagePart(part)
case "image_url":
item := []byte(`{"type":"input_image","image_url":""}`)
- item, _ = sjson.SetBytes(item, "image_url", part.Get("image_url.url").String())
+ item, _ = ojson.SetBytes(item, "image_url", part.Get("image_url.url").String())
return item
case "audio":
return interactionsCodexAudioPart(part)
case "input_audio":
item := []byte(`{"type":"input_audio","input_audio":{}}`)
if audio := part.Get("input_audio"); audio.Exists() {
- item, _ = sjson.SetRawBytes(item, "input_audio", []byte(audio.Raw))
+ item, _ = ojson.SetRawBytes(item, "input_audio", []byte(audio.Raw))
}
return item
case "video", "document", "file":
@@ -558,11 +576,11 @@ func interactionsCodexMessagePart(part gjson.Result, role string) []byte {
return nil
}
-func interactionsCodexImagePart(part gjson.Result) []byte {
+func interactionsCodexImagePart(part ojson.Result) []byte {
// uri is the Interactions spelling of file_uri.
if imageURL := firstNonBlankString(part, "url", "file_uri", "fileUri", "uri"); imageURL != "" {
item := []byte(`{"type":"input_image","image_url":""}`)
- item, _ = sjson.SetBytes(item, "image_url", imageURL)
+ item, _ = ojson.SetBytes(item, "image_url", imageURL)
return item
}
mimeType := firstString(part, "mime_type", "mimeType")
@@ -571,48 +589,48 @@ func interactionsCodexImagePart(part gjson.Result) []byte {
return nil
}
item := []byte(`{"type":"input_image","image_url":""}`)
- item, _ = sjson.SetBytes(item, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
+ item, _ = ojson.SetBytes(item, "image_url", fmt.Sprintf("data:%s;base64,%s", mimeType, data))
return item
}
-func interactionsCodexAudioPart(part gjson.Result) []byte {
+func interactionsCodexAudioPart(part ojson.Result) []byte {
mimeType := firstString(part, "mime_type", "mimeType")
data := part.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
- item := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
- item, _ = sjson.SetBytes(item, "input_audio.data", data)
- item, _ = sjson.SetBytes(item, "input_audio.format", codexInputAudioFormatFromMIME(mimeType))
- return item
+ itemDoc := ojson.NewDocString(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
+ _ = itemDoc.Set("input_audio.data", data)
+ _ = itemDoc.Set("input_audio.format", codexInputAudioFormatFromMIME(mimeType))
+ return itemDoc.Bytes()
}
-func interactionsCodexFilePart(part gjson.Result) []byte {
+func interactionsCodexFilePart(part ojson.Result) []byte {
if fileData := part.Get("file.file_data").String(); fileData != "" {
- item := []byte(`{"type":"input_file","file_data":"","filename":""}`)
- item, _ = sjson.SetBytes(item, "file_data", fileData)
- item, _ = sjson.SetBytes(item, "filename", part.Get("file.filename").String())
- return item
+ itemDoc := ojson.NewDocString(`{"type":"input_file","file_data":"","filename":""}`)
+ _ = itemDoc.Set("file_data", fileData)
+ _ = itemDoc.Set("filename", part.Get("file.filename").String())
+ return itemDoc.Bytes()
}
mimeType := firstString(part, "mime_type", "mimeType")
// uri is the Interactions spelling of file_uri.
if fileURI := firstNonBlankString(part, "file_uri", "fileUri", "uri", "url"); fileURI != "" {
- item := []byte(`{"type":"input_file","file_url":"","filename":""}`)
- item, _ = sjson.SetBytes(item, "file_url", fileURI)
- item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType))
- return item
+ itemDoc := ojson.NewDocString(`{"type":"input_file","file_url":"","filename":""}`)
+ _ = itemDoc.Set("file_url", fileURI)
+ _ = itemDoc.Set("filename", codexFileNameFromMIME(mimeType))
+ return itemDoc.Bytes()
}
data := part.Get("data").String()
if mimeType == "" || data == "" {
return nil
}
- item := []byte(`{"type":"input_file","file_data":"","filename":""}`)
- item, _ = sjson.SetBytes(item, "file_data", data)
- item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType))
- return item
+ itemDoc := ojson.NewDocString(`{"type":"input_file","file_data":"","filename":""}`)
+ _ = itemDoc.Set("file_data", data)
+ _ = itemDoc.Set("filename", codexFileNameFromMIME(mimeType))
+ return itemDoc.Bytes()
}
-func interactionsCodexInlinePart(inline gjson.Result) []byte {
+func interactionsCodexInlinePart(inline ojson.Result) []byte {
mimeType := firstString(inline, "mime_type", "mimeType")
data := inline.Get("data").String()
if mimeType == "" || data == "" {
@@ -620,15 +638,15 @@ func interactionsCodexInlinePart(inline gjson.Result) []byte {
}
switch {
case strings.HasPrefix(strings.ToLower(mimeType), "image/"):
- return interactionsCodexImagePart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return interactionsCodexImagePart(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
case strings.HasPrefix(strings.ToLower(mimeType), "audio/"):
- return interactionsCodexAudioPart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return interactionsCodexAudioPart(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
default:
- return interactionsCodexFilePart(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return interactionsCodexFilePart(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
-func interactionsCodexFileDataPart(fileData gjson.Result) []byte {
+func interactionsCodexFileDataPart(fileData ojson.Result) []byte {
mimeType := firstString(fileData, "mime_type", "mimeType")
fileURI := firstString(fileData, "file_uri", "fileUri")
if fileURI == "" {
@@ -636,20 +654,20 @@ func interactionsCodexFileDataPart(fileData gjson.Result) []byte {
}
if strings.HasPrefix(strings.ToLower(mimeType), "image/") {
item := []byte(`{"type":"input_image","image_url":""}`)
- item, _ = sjson.SetBytes(item, "image_url", fileURI)
+ item, _ = ojson.SetBytes(item, "image_url", fileURI)
return item
}
- item := []byte(`{"type":"input_file","file_url":"","filename":""}`)
- item, _ = sjson.SetBytes(item, "file_url", fileURI)
- item, _ = sjson.SetBytes(item, "filename", codexFileNameFromMIME(mimeType))
- return item
+ itemDoc := ojson.NewDocString(`{"type":"input_file","file_url":"","filename":""}`)
+ _ = itemDoc.Set("file_url", fileURI)
+ _ = itemDoc.Set("filename", codexFileNameFromMIME(mimeType))
+ return itemDoc.Bytes()
}
-func appendCodexToolDeclarations(normalized *[]map[string]any, declarations gjson.Result) {
+func appendCodexToolDeclarations(normalized *[]map[string]any, declarations ojson.Result) {
if !declarations.IsArray() {
return
}
- declarations.ForEach(func(_, declaration gjson.Result) bool {
+ declarations.ForEach(func(_, declaration ojson.Result) bool {
if declaration.Get("name").Exists() {
*normalized = append(*normalized, codexToolFromDeclaration(declaration))
}
@@ -657,7 +675,7 @@ func appendCodexToolDeclarations(normalized *[]map[string]any, declarations gjso
})
}
-func codexToolFromDeclaration(declaration gjson.Result) map[string]any {
+func codexToolFromDeclaration(declaration ojson.Result) map[string]any {
tool := map[string]any{
"type": "function",
"name": shortenCodexToolNameIfNeeded(declaration.Get("name").String()),
@@ -676,22 +694,27 @@ func codexToolFromDeclaration(declaration gjson.Result) map[string]any {
return tool
}
-func cleanedCodexToolParameters(params gjson.Result) json.RawMessage {
- cleaned := []byte(params.Raw)
- if params.Get("$schema").Exists() {
- cleaned, _ = sjson.DeleteBytes(cleaned, "$schema")
+func cleanedCodexToolParameters(params ojson.Result) json.RawMessage {
+ hasSchema := params.Get("$schema").Exists()
+ needsAdditionalProps := params.Get("additionalProperties").Type != ojson.False
+ if !hasSchema && !needsAdditionalProps {
+ return json.RawMessage(params.Raw)
+ }
+ doc := ojson.NewDocString(params.Raw)
+ if hasSchema {
+ _ = doc.Delete("$schema")
}
- if params.Get("additionalProperties").Type != gjson.False {
- cleaned, _ = sjson.SetBytes(cleaned, "additionalProperties", false)
+ if needsAdditionalProps {
+ _ = doc.Set("additionalProperties", false)
}
- return json.RawMessage(cleaned)
+ return json.RawMessage(doc.Bytes())
}
-func interactionsCodexContentText(content gjson.Result) string {
+func interactionsCodexContentText(content ojson.Result) string {
if !content.Exists() {
return ""
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String()
}
if content.IsObject() {
@@ -699,7 +722,7 @@ func interactionsCodexContentText(content gjson.Result) string {
}
if content.IsArray() {
var builder strings.Builder
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
text := part.Get("text").String()
if text == "" {
return true
@@ -715,15 +738,15 @@ func interactionsCodexContentText(content gjson.Result) string {
return ""
}
-func interactionsCodexCallID(step gjson.Result) string {
+func interactionsCodexCallID(step ojson.Result) string {
if callID := strings.TrimSpace(step.Get("call_id").String()); callID != "" {
return callID
}
return strings.TrimSpace(step.Get("id").String())
}
-func interactionsCodexJSONString(value gjson.Result) string {
- if value.Type == gjson.String {
+func interactionsCodexJSONString(value ojson.Result) string {
+ if value.Type == ojson.String {
return value.String()
}
if value.Exists() {
@@ -732,8 +755,8 @@ func interactionsCodexJSONString(value gjson.Result) string {
return "{}"
}
-func interactionsCodexOutputString(value gjson.Result) string {
- if value.Type == gjson.String {
+func interactionsCodexOutputString(value ojson.Result) string {
+ if value.Type == ojson.String {
return value.String()
}
if value.Exists() {
@@ -746,7 +769,7 @@ func interactionsCodexOutputString(value gjson.Result) string {
// carries system or developer content, named by its role or by its type, is
// developer content: it closes the open user turn and never keeps an emptied one
// alive. Any other step uses its own role, then the role it inherits.
-func interactionsCodexStepRole(step gjson.Result, inherited string) string {
+func interactionsCodexStepRole(step ojson.Result, inherited string) string {
if translatorcommon.IsInteractionsInstructionStep(step, inherited == "developer") {
return "developer"
}
@@ -768,8 +791,8 @@ func interactionsCodexDefaultRole(role, fallback string) string {
return "user"
}
-func normalizeInteractionsCodexServiceTier(serviceTier gjson.Result) string {
- if !serviceTier.Exists() || serviceTier.Type != gjson.String {
+func normalizeInteractionsCodexServiceTier(serviceTier ojson.Result) string {
+ if !serviceTier.Exists() || serviceTier.Type != ojson.String {
return ""
}
switch strings.ToLower(strings.TrimSpace(serviceTier.String())) {
@@ -833,7 +856,7 @@ func shortenCodexToolNameIfNeeded(name string) string {
}
// firstNonBlankString returns the first non-blank string found at paths.
-func firstNonBlankString(root gjson.Result, paths ...string) string {
+func firstNonBlankString(root ojson.Result, paths ...string) string {
for _, path := range paths {
if value := strings.TrimSpace(root.Get(path).String()); value != "" {
return value
@@ -842,7 +865,7 @@ func firstNonBlankString(root gjson.Result, paths ...string) string {
return ""
}
-func firstString(root gjson.Result, paths ...string) string {
+func firstString(root ojson.Result, paths ...string) string {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value.String()
diff --git a/internal/translator/codex/interactions/interactions_codex_response.go b/internal/translator/codex/interactions/interactions_codex_response.go
index 9e1a899389e..68f86fca2e5 100644
--- a/internal/translator/codex/interactions/interactions_codex_response.go
+++ b/internal/translator/codex/interactions/interactions_codex_response.go
@@ -8,9 +8,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type codexToInteractionsStreamState struct {
@@ -53,14 +53,14 @@ func ConvertCodexResponseToInteractions(ctx context.Context, modelName string, o
if bytes.Equal(payload, []byte("[DONE]")) {
out := appendCodexInteractionsStepStop(nil, st)
if !st.Completed {
- out = appendCodexInteractionsCompleted(out, st, gjson.Result{})
+ out = appendCodexInteractionsCompleted(out, st, ojson.Result{})
}
return appendCodexInteractionsDone(out, st)
}
if len(payload) == 0 {
return nil
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "response.created":
return appendCodexInteractionsCreated(nil, st, root.Get("response"))
@@ -88,27 +88,27 @@ func ConvertCodexResponseToInteractionsNonStream(ctx context.Context, modelName
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
response := root.Get("response")
if !response.Exists() {
response = root
}
- out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
+ outDoc := ojson.NewDocString(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
if status := response.Get("status").String(); status != "" {
- out, _ = sjson.SetBytes(out, "status", status)
+ _ = outDoc.Set("status", status)
}
id := response.Get("id").String()
if id == "" {
id = fmt.Sprintf("interaction_%d", time.Now().UnixNano())
}
- out, _ = sjson.SetBytes(out, "id", id)
+ _ = outDoc.Set("id", id)
if model := response.Get("model").String(); model != "" {
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
} else {
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
}
var steps [][]byte
- response.Get("output").ForEach(func(_, item gjson.Result) bool {
+ response.Get("output").ForEach(func(_, item ojson.Result) bool {
switch item.Get("type").String() {
case "message":
if step := buildCodexMessageItemToInteractions(item); len(step) > 0 {
@@ -130,10 +130,10 @@ func ConvertCodexResponseToInteractionsNonStream(ctx context.Context, modelName
return true
})
if len(steps) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "steps", steps)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "steps", steps)
}
- out = setCodexInteractionsUsage(out, "usage", response.Get("usage"), false)
- return out
+ setCodexInteractionsUsageDoc(outDoc, "usage", response.Get("usage"), false)
+ return outDoc.Bytes()
}
func codexStreamPayload(rawJSON []byte) []byte {
@@ -149,34 +149,34 @@ func codexStreamEventType(rawJSON []byte) string {
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
return ""
}
- return gjson.GetBytes(payload, "type").String()
+ return ojson.GetBytesNoCopy(payload, "type").String()
}
-func appendCodexInteractionsCreated(out [][]byte, st *codexToInteractionsStreamState, response gjson.Result) [][]byte {
+func appendCodexInteractionsCreated(out [][]byte, st *codexToInteractionsStreamState, response ojson.Result) [][]byte {
if st.Started {
return out
}
if id := response.Get("id").String(); id != "" {
- st.ID = id
+ st.ID = strings.Clone(id)
}
if model := response.Get("model").String(); model != "" {
- st.Model = model
+ st.Model = strings.Clone(model)
}
if createdAt := response.Get("created_at"); createdAt.Exists() {
st.CreatedAt = createdAt.Int()
}
- created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
- created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
- created, _ = sjson.SetBytes(created, "interaction.model", st.Model)
- out = append(out, translatorcommon.SSEEventData("interaction.created", created))
+ createdDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
+ _ = createdDoc.Set("interaction.id", st.ID)
+ _ = createdDoc.Set("interaction.model", st.Model)
+ out = append(out, translatorcommon.SSEEventData("interaction.created", createdDoc.Bytes()))
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
- statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
+ statusUpdate, _ = ojson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.Started = true
return out
}
-func appendCodexInteractionsCompleted(out [][]byte, st *codexToInteractionsStreamState, response gjson.Result) [][]byte {
+func appendCodexInteractionsCompleted(out [][]byte, st *codexToInteractionsStreamState, response ojson.Result) [][]byte {
if st.Completed {
return out
}
@@ -184,16 +184,16 @@ func appendCodexInteractionsCompleted(out [][]byte, st *codexToInteractionsStrea
if st.CreatedAt > 0 {
created = time.Unix(st.CreatedAt, 0).UTC()
}
- completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
- completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
- completed, _ = sjson.SetBytes(completed, "interaction.created", created.Format(time.RFC3339))
- completed, _ = sjson.SetBytes(completed, "interaction.updated", time.Now().UTC().Format(time.RFC3339))
- completed, _ = sjson.SetBytes(completed, "interaction.model", st.Model)
+ completedDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
+ _ = completedDoc.Set("interaction.id", st.ID)
+ _ = completedDoc.Set("interaction.created", created.Format(time.RFC3339))
+ _ = completedDoc.Set("interaction.updated", time.Now().UTC().Format(time.RFC3339))
+ _ = completedDoc.Set("interaction.model", st.Model)
if status := response.Get("status").String(); status != "" {
- completed, _ = sjson.SetBytes(completed, "interaction.status", status)
+ _ = completedDoc.Set("interaction.status", status)
}
- completed = setCodexInteractionsUsage(completed, "interaction.usage", response.Get("usage"), true)
- out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
+ setCodexInteractionsUsageDoc(completedDoc, "interaction.usage", response.Get("usage"), true)
+ out = append(out, translatorcommon.SSEEventData("interaction.completed", completedDoc.Bytes()))
st.Completed = true
return out
}
@@ -207,7 +207,7 @@ func appendCodexInteractionsDone(out [][]byte, st *codexToInteractionsStreamStat
return out
}
-func codexOutputItemAddedToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
+func codexOutputItemAddedToInteractions(st *codexToInteractionsStreamState, root ojson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
item := root.Get("item")
switch item.Get("type").String() {
@@ -216,50 +216,50 @@ func codexOutputItemAddedToInteractions(st *codexToInteractionsStreamState, root
case "reasoning":
return ensureCodexInteractionsStep(out, st, "thought", item)
case "function_call", "tool_call":
- st.FunctionCallName = item.Get("name").String()
- st.FunctionCallID = codexItemCallID(item)
+ st.FunctionCallName = strings.Clone(item.Get("name").String())
+ st.FunctionCallID = strings.Clone(codexItemCallID(item))
return ensureCodexInteractionsStep(out, st, "function_call", item)
}
return out
}
-func codexOutputTextDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
+func codexOutputTextDeltaToInteractions(st *codexToInteractionsStreamState, root ojson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
- out = ensureCodexInteractionsStep(out, st, "model_output", gjson.Result{})
- delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.text", root.Get("delta").String())
+ out = ensureCodexInteractionsStep(out, st, "model_output", ojson.Result{})
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.text", root.Get("delta").String())
st.HasOutputText = true
- return append(out, translatorcommon.SSEEventData("step.delta", delta))
+ return append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
-func codexReasoningDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
+func codexReasoningDeltaToInteractions(st *codexToInteractionsStreamState, root ojson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
- out = ensureCodexInteractionsStep(out, st, "thought", gjson.Result{})
- delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.content.text", root.Get("delta").String())
+ out = ensureCodexInteractionsStep(out, st, "thought", ojson.Result{})
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.content.text", root.Get("delta").String())
if itemID := strings.TrimSpace(root.Get("item_id").String()); itemID != "" {
if st.StreamedSummaryItems == nil {
st.StreamedSummaryItems = make(map[string]bool)
}
- st.StreamedSummaryItems[itemID] = true
+ st.StreamedSummaryItems[strings.Clone(itemID)] = true
}
st.HasThoughtSummary = true
- return append(out, translatorcommon.SSEEventData("step.delta", delta))
+ return append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
-func codexFunctionArgumentsDeltaToInteractions(st *codexToInteractionsStreamState, root gjson.Result) [][]byte {
+func codexFunctionArgumentsDeltaToInteractions(st *codexToInteractionsStreamState, root ojson.Result) [][]byte {
out := appendCodexInteractionsCreated(nil, st, root.Get("response"))
out = ensureCodexInteractionsStep(out, st, "function_call", root.Get("item"))
- delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.arguments", root.Get("delta").String())
- return append(out, translatorcommon.SSEEventData("step.delta", delta))
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.arguments", root.Get("delta").String())
+ return append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
-func codexOutputItemDoneToInteractions(st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
- out := appendCodexInteractionsCreated(nil, st, gjson.Result{})
+func codexOutputItemDoneToInteractions(st *codexToInteractionsStreamState, item ojson.Result) [][]byte {
+ out := appendCodexInteractionsCreated(nil, st, ojson.Result{})
switch item.Get("type").String() {
case "message":
if st.HasOutputText {
@@ -280,7 +280,7 @@ func codexOutputItemDoneToInteractions(st *codexToInteractionsStreamState, item
return out
}
-func ensureCodexInteractionsStep(out [][]byte, st *codexToInteractionsStreamState, stepType string, item gjson.Result) [][]byte {
+func ensureCodexInteractionsStep(out [][]byte, st *codexToInteractionsStreamState, stepType string, item ojson.Result) [][]byte {
if st.ActiveStepOpen && st.ActiveStepType == stepType {
return out
}
@@ -288,7 +288,7 @@ func ensureCodexInteractionsStep(out [][]byte, st *codexToInteractionsStreamStat
return appendCodexInteractionsStepStart(out, st, stepType, item)
}
-func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStreamState, stepType string, item gjson.Result) [][]byte {
+func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStreamState, stepType string, item ojson.Result) [][]byte {
st.ActiveStepIndex = st.StepIndex
st.StepIndex++
st.ActiveStepOpen = true
@@ -296,9 +296,9 @@ func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStrea
if stepType == "thought" {
st.HasThoughtSummary = false
}
- stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
- stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex)
- stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType)
+ stepStartDoc := ojson.NewDocString(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
+ _ = stepStartDoc.Set("index", st.ActiveStepIndex)
+ _ = stepStartDoc.Set("step.type", stepType)
if stepType == "function_call" {
name := item.Get("name").String()
if name == "" {
@@ -311,12 +311,12 @@ func appendCodexInteractionsStepStart(out [][]byte, st *codexToInteractionsStrea
if callID == "" {
callID = fmt.Sprintf("step_%d", time.Now().UnixNano())
}
- stepStart, _ = sjson.SetBytes(stepStart, "step.id", callID)
- stepStart, _ = sjson.SetBytes(stepStart, "step.call_id", callID)
- stepStart, _ = sjson.SetBytes(stepStart, "step.name", name)
- stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`))
+ _ = stepStartDoc.Set("step.id", callID)
+ _ = stepStartDoc.Set("step.call_id", callID)
+ _ = stepStartDoc.Set("step.name", name)
+ _ = stepStartDoc.SetRawBytes("step.arguments", []byte(`{}`))
}
- return append(out, translatorcommon.SSEEventData("step.start", stepStart))
+ return append(out, translatorcommon.SSEEventData("step.start", stepStartDoc.Bytes()))
}
func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStreamState) [][]byte {
@@ -324,7 +324,7 @@ func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStream
return out
}
stepStop := []byte(`{"index":0,"event_type":"step.stop"}`)
- stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex)
+ stepStop, _ = ojson.SetBytes(stepStop, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", stepStop))
if st.ActiveStepType == "thought" {
st.HasThoughtSummary = false
@@ -334,9 +334,9 @@ func appendCodexInteractionsStepStop(out [][]byte, st *codexToInteractionsStream
return out
}
-func buildCodexMessageItemToInteractions(item gjson.Result) []byte {
+func buildCodexMessageItemToInteractions(item ojson.Result) []byte {
var contents [][]byte
- item.Get("content").ForEach(func(_, content gjson.Result) bool {
+ item.Get("content").ForEach(func(_, content ojson.Result) bool {
if contentItem := codexContentToInteractionsContent(content); len(contentItem) > 0 {
contents = append(contents, contentItem)
}
@@ -349,89 +349,61 @@ func buildCodexMessageItemToInteractions(item gjson.Result) []byte {
return translatorcommon.SetRawArrayItems(step, "content", contents)
}
-func buildCodexReasoningItemToInteractions(item gjson.Result) []byte {
+func buildCodexReasoningItemToInteractions(item ojson.Result) []byte {
text := codexReasoningText(item)
sig := strings.TrimSpace(item.Get("encrypted_content").String())
if text == "" && sig == "" {
return nil
}
- step := []byte(`{"type":"thought"}`)
+ stepDoc := ojson.NewDocString(`{"type":"thought"}`)
if sig != "" {
- step, _ = sjson.SetBytes(step, "signature", sig)
+ _ = stepDoc.Set("signature", sig)
}
if text != "" {
- step, _ = sjson.SetRawBytes(step, "content", []byte(`[{"type":"text","text":""}]`))
- step, _ = sjson.SetBytes(step, "content.0.text", text)
+ _ = stepDoc.SetRawBytes("content", []byte(`[{"type":"text","text":""}]`))
+ _ = stepDoc.Set("content.0.text", text)
}
- return step
+ return stepDoc.Bytes()
}
-func buildCodexFunctionCallItemToInteractions(item gjson.Result) []byte {
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
- step, _ = sjson.SetBytes(step, "name", item.Get("name").String())
+func buildCodexFunctionCallItemToInteractions(item ojson.Result) []byte {
+ stepDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
+ _ = stepDoc.Set("name", item.Get("name").String())
if callID := codexItemCallID(item); callID != "" {
- step, _ = sjson.SetBytes(step, "call_id", callID)
+ _ = stepDoc.Set("call_id", callID)
}
if args := codexArgumentsJSON(item.Get("arguments")); len(args) > 0 {
- step, _ = sjson.SetRawBytes(step, "arguments", args)
+ _ = stepDoc.SetRawBytes("arguments", args)
}
- return step
+ return stepDoc.Bytes()
}
-func buildCodexImageItemToInteractions(item gjson.Result) []byte {
+func buildCodexImageItemToInteractions(item ojson.Result) []byte {
result := item.Get("result").String()
if result == "" {
return nil
}
- step := []byte(`{"type":"model_output","content":[{"type":"image","mime_type":"","data":""}]}`)
- step, _ = sjson.SetBytes(step, "content.0.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String()))
- step, _ = sjson.SetBytes(step, "content.0.data", result)
- return step
-}
-
-func appendCodexMessageItemToInteractions(out []byte, item gjson.Result) []byte {
- if step := buildCodexMessageItemToInteractions(item); len(step) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps.-1", step)
- }
- return out
+ stepDoc := ojson.NewDocString(`{"type":"model_output","content":[{"type":"image","mime_type":"","data":""}]}`)
+ _ = stepDoc.Set("content.0.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String()))
+ _ = stepDoc.Set("content.0.data", result)
+ return stepDoc.Bytes()
}
-func appendCodexReasoningItemToInteractions(out []byte, item gjson.Result) []byte {
- if step := buildCodexReasoningItemToInteractions(item); len(step) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps.-1", step)
- }
- return out
-}
-
-func appendCodexFunctionCallItemToInteractions(out []byte, item gjson.Result) []byte {
- if step := buildCodexFunctionCallItemToInteractions(item); len(step) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps.-1", step)
- }
- return out
-}
-
-func appendCodexImageItemToInteractions(out []byte, item gjson.Result) []byte {
- if step := buildCodexImageItemToInteractions(item); len(step) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps.-1", step)
- }
- return out
-}
-
-func appendCodexMessageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
- item.Get("content").ForEach(func(_, content gjson.Result) bool {
+func appendCodexMessageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item ojson.Result) [][]byte {
+ item.Get("content").ForEach(func(_, content ojson.Result) bool {
if text := codexContentText(content); text != "" {
out = ensureCodexInteractionsStep(out, st, "model_output", item)
- delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.text", text)
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.text", text)
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
return true
})
return out
}
-func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
+func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item ojson.Result) [][]byte {
text := codexReasoningText(item)
sig := strings.TrimSpace(item.Get("encrypted_content").String())
if sig != "" {
@@ -451,17 +423,17 @@ func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInter
}
if text != "" && !hadStreamedSummary {
out = ensureCodexInteractionsStep(out, st, "thought", item)
- delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.content.text", text)
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.content.text", text)
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
if sig != "" {
out = ensureCodexInteractionsStep(out, st, "thought", item)
- signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", sig)
- out = append(out, translatorcommon.SSEEventData("step.delta", signatureDelta))
+ signatureDeltaDoc := ojson.NewDocString(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
+ _ = signatureDeltaDoc.Set("index", st.ActiveStepIndex)
+ _ = signatureDeltaDoc.Set("delta.signature", sig)
+ out = append(out, translatorcommon.SSEEventData("step.delta", signatureDeltaDoc.Bytes()))
}
// Completing a reasoning item resets the current item's summary flag so a
// subsequent done-only reasoning item is not suppressed by an earlier
@@ -470,53 +442,53 @@ func appendCodexReasoningItemToInteractionsStream(out [][]byte, st *codexToInter
return out
}
-func appendCodexFunctionCallItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
+func appendCodexFunctionCallItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item ojson.Result) [][]byte {
out = ensureCodexInteractionsStep(out, st, "function_call", item)
- delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.arguments", item.Get("arguments").String())
- return append(out, translatorcommon.SSEEventData("step.delta", delta))
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.arguments", item.Get("arguments").String())
+ return append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
-func appendCodexImageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item gjson.Result) [][]byte {
+func appendCodexImageItemToInteractionsStream(out [][]byte, st *codexToInteractionsStreamState, item ojson.Result) [][]byte {
result := item.Get("result").String()
if result == "" {
return out
}
out = ensureCodexInteractionsStep(out, st, "model_output", item)
- delta := []byte(`{"index":0,"delta":{"content":{"type":"image","mime_type":"","data":""},"type":"content"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.content.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String()))
- delta, _ = sjson.SetBytes(delta, "delta.content.data", result)
- return append(out, translatorcommon.SSEEventData("step.delta", delta))
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"content":{"type":"image","mime_type":"","data":""},"type":"content"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.content.mime_type", mimeTypeFromCodexOutputFormat(item.Get("output_format").String()))
+ _ = deltaDoc.Set("delta.content.data", result)
+ return append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
}
-func codexContentToInteractionsContent(content gjson.Result) []byte {
+func codexContentToInteractionsContent(content ojson.Result) []byte {
if text := codexContentText(content); text != "" {
item := []byte(`{"type":"text","text":""}`)
- item, _ = sjson.SetBytes(item, "text", text)
+ item, _ = ojson.SetBytes(item, "text", text)
return item
}
return nil
}
-func codexContentText(content gjson.Result) string {
+func codexContentText(content ojson.Result) string {
for _, path := range []string{"text", "content"} {
- if value := content.Get(path); value.Exists() && value.Type == gjson.String {
+ if value := content.Get(path); value.Exists() && value.Type == ojson.String {
return value.String()
}
}
return ""
}
-func codexReasoningText(item gjson.Result) string {
+func codexReasoningText(item ojson.Result) string {
if content := item.Get("content"); content.Exists() {
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String()
}
if content.IsArray() {
var builder strings.Builder
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
text := codexContentText(part)
if text == "" {
text = part.Get("summary_text").String()
@@ -534,12 +506,12 @@ func codexReasoningText(item gjson.Result) string {
}
}
if summary := item.Get("summary"); summary.Exists() {
- if summary.Type == gjson.String {
+ if summary.Type == ojson.String {
return summary.String()
}
if summary.IsArray() {
var builder strings.Builder
- summary.ForEach(func(_, part gjson.Result) bool {
+ summary.ForEach(func(_, part ojson.Result) bool {
text := codexContentText(part)
if text == "" {
return true
@@ -556,19 +528,19 @@ func codexReasoningText(item gjson.Result) string {
return ""
}
-func codexItemCallID(item gjson.Result) string {
+func codexItemCallID(item ojson.Result) string {
if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" {
return callID
}
return strings.TrimSpace(item.Get("id").String())
}
-func codexArgumentsJSON(arguments gjson.Result) []byte {
+func codexArgumentsJSON(arguments ojson.Result) []byte {
if !arguments.Exists() {
return nil
}
- if arguments.Type == gjson.String {
- parsed := gjson.Parse(arguments.String())
+ if arguments.Type == ojson.String {
+ parsed := ojson.Parse(arguments.String())
if parsed.Exists() && parsed.IsObject() {
return []byte(arguments.String())
}
@@ -580,9 +552,9 @@ func codexArgumentsJSON(arguments gjson.Result) []byte {
return nil
}
-func setCodexInteractionsUsage(out []byte, path string, usage gjson.Result, stream bool) []byte {
+func setCodexInteractionsUsageDoc(doc *ojson.Doc, path string, usage ojson.Result, stream bool) {
if !usage.Exists() {
- return out
+ return
}
inputTokens := usage.Get("input_tokens").Int()
outputTokens := usage.Get("output_tokens").Int()
@@ -605,25 +577,24 @@ func setCodexInteractionsUsage(out []byte, path string, usage gjson.Result, stre
cachedTokens = usage.Get("cached_tokens").Int()
}
if stream {
- out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
- out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
- out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens)))
- out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens)
- out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
- out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0)
- out, _ = sjson.SetBytes(out, path+".total_thought_tokens", reasoningTokens)
- return out
- }
- out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens)
- out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
+ _ = doc.Set(path+".total_tokens", totalTokens)
+ _ = doc.Set(path+".total_input_tokens", inputTokens)
+ _ = doc.SetRawBytes(path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens)))
+ _ = doc.Set(path+".total_cached_tokens", cachedTokens)
+ _ = doc.Set(path+".total_output_tokens", outputTokens)
+ _ = doc.Set(path+".total_tool_use_tokens", 0)
+ _ = doc.Set(path+".total_thought_tokens", reasoningTokens)
+ return
+ }
+ _ = doc.Set(path+".input_tokens", inputTokens)
+ _ = doc.Set(path+".output_tokens", outputTokens)
+ _ = doc.Set(path+".total_tokens", totalTokens)
if reasoningTokens > 0 {
- out, _ = sjson.SetBytes(out, path+".reasoning_tokens", reasoningTokens)
+ _ = doc.Set(path+".reasoning_tokens", reasoningTokens)
}
if cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", cachedTokens)
+ _ = doc.Set(path+".cached_tokens", cachedTokens)
}
- return out
}
func mimeTypeFromCodexOutputFormat(outputFormat string) string {
diff --git a/internal/translator/codex/interactions/interactions_codex_test.go b/internal/translator/codex/interactions/interactions_codex_test.go
index eb85e412fbe..56ab243fca1 100644
--- a/internal/translator/codex/interactions/interactions_codex_test.go
+++ b/internal/translator/codex/interactions/interactions_codex_test.go
@@ -3,36 +3,147 @@ package interactions
import (
"bytes"
"context"
- "encoding/base64"
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/base64"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
+func TestConvertInteractionsRequestToCodexMaxOutputTokens(t *testing.T) {
+ for _, path := range []string{
+ "generation_config.max_output_tokens",
+ "generation_config.maxOutputTokens",
+ "generation_config.max_completion_tokens",
+ "generation_config.maxCompletionTokens",
+ "generation_config.max_tokens",
+ "generationConfig.max_output_tokens",
+ "generationConfig.maxOutputTokens",
+ "generationConfig.max_completion_tokens",
+ "generationConfig.maxCompletionTokens",
+ "generationConfig.max_tokens",
+ "max_output_tokens",
+ "maxOutputTokens",
+ "max_completion_tokens",
+ "maxCompletionTokens",
+ "max_tokens",
+ "agent_config.max_total_tokens",
+ "agentConfig.maxTotalTokens",
+ } {
+ t.Run(path, func(t *testing.T) {
+ inputDoc := ojson.NewDocString(`{"input":"hi"}`)
+ _ = inputDoc.Set(path, 1024)
+ out, errConvert := ConvertInteractionsRequestToCodex("codex-test", inputDoc.Bytes(), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertInteractionsRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != "1024" {
+ t.Fatalf("max_output_tokens = %q, want 1024; output=%s", got, out)
+ }
+ for _, sourcePath := range []string{"generation_config", "generationConfig", "maxOutputTokens", "max_completion_tokens", "maxCompletionTokens", "max_tokens", "agent_config", "agentConfig"} {
+ if ojson.GetBytesNoCopy(out, sourcePath).Exists() {
+ t.Fatalf("%s should not be forwarded; output=%s", sourcePath, out)
+ }
+ }
+ })
+ }
+}
+
+func TestConvertInteractionsRequestToCodexMaxOutputTokensPriority(t *testing.T) {
+ for _, prefix := range []string{"generation_config.", "generationConfig.", ""} {
+ paths := []string{"max_output_tokens", "maxOutputTokens", "max_completion_tokens", "maxCompletionTokens", "max_tokens"}
+ if prefix == "" {
+ paths = append(paths, "agent_config.max_total_tokens", "agentConfig.maxTotalTokens")
+ }
+ for index, path := range paths {
+ t.Run(prefix+path, func(t *testing.T) {
+ inputDoc := ojson.NewDocString(`{"input":"hi"}`)
+ for offset, sourcePath := range paths[index:] {
+ _ = inputDoc.Set(prefix+sourcePath, 1024+offset)
+ }
+ if prefix != "" {
+ _ = inputDoc.Set("max_output_tokens", 4096)
+ _ = inputDoc.Set("agent_config.max_total_tokens", 8192)
+ }
+ out, errConvert := ConvertInteractionsRequestToCodex("codex-test", inputDoc.Bytes(), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertInteractionsRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != "1024" {
+ t.Fatalf("max_output_tokens = %q, want first matching limit 1024; output=%s", got, out)
+ }
+ })
+ }
+ }
+}
+
+func TestConvertInteractionsRequestToCodexMaxOutputTokensFallbacks(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"no limit", `{"input":"hi"}`, ""},
+ {"empty generation config", `{"input":"hi","generation_config":{},"max_tokens":1024}`, "1024"},
+ {"generation config without token limit", `{"input":"hi","generationConfig":{"thinking_level":"high"},"agentConfig":{"maxTotalTokens":1024}}`, "1024"},
+ {"zero config limit takes precedence", `{"input":"hi","generation_config":{"max_tokens":0},"max_output_tokens":1024}`, "0"},
+ {"zero top-level limit takes precedence", `{"input":"hi","max_output_tokens":0,"max_tokens":1024}`, "0"},
+ {"snake config takes precedence", `{"input":"hi","generation_config":{"max_tokens":1024},"generationConfig":{"max_output_tokens":2048}}`, "1024"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, errConvert := ConvertInteractionsRequestToCodex("codex-test", []byte(tt.input), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertInteractionsRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != tt.want {
+ t.Fatalf("max_output_tokens = %q, want %q; output=%s", got, tt.want, out)
+ }
+ })
+ }
+}
+
+func TestConvertInteractionsRequestToCodexGenerationConfigAliasPriority(t *testing.T) {
+ out, errConvert := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi","generation_config":{"top_p":0.5,"topP":0.9,"parallel_tool_calls":false,"parallelToolCalls":true,"tool_choice":"none","toolChoice":"auto","text":{"verbosity":"low"},"verbosity":"high"}}`), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertInteractionsRequestToCodex() error = %v", errConvert)
+ }
+ for path, want := range map[string]string{
+ "top_p": "0.5",
+ "parallel_tool_calls": "false",
+ "tool_choice": `"none"`,
+ "text.verbosity": `"low"`,
+ } {
+ if got := ojson.GetBytesNoCopy(out, path).Raw; got != want {
+ t.Fatalf("%s = %q, want %q; output=%s", path, got, want, out)
+ }
+ }
+}
+
func TestConvertInteractionsRequestToCodexWithToolMessagesDirect(t *testing.T) {
out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","system_instruction":"be brief","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","content":[{"type":"text","text":"thinking"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}`), false)
- if got := gjson.GetBytes(out, "instructions").String(); got != "be brief" {
+ if got := ojson.GetBytesNoCopy(out, "instructions").String(); got != "be brief" {
t.Fatalf("instructions = %q, want be brief. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("input.0.content.0.text = %q, want hi. Output: %s", got, string(out))
}
// The signatureless thought step is dropped entirely: a reasoning item
// without replayable encrypted_content is rejected by both upstreams.
- if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.type").String(); got != "function_call" {
t.Fatalf("input.1.type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("function_call call_id = %q, want call_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_call_output" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.type").String(); got != "function_call_output" {
t.Fatalf("input.2.type = %q, want function_call_output. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "contents").Exists() || gjson.GetBytes(out, "systemInstruction").Exists() {
+ if ojson.GetBytesNoCopy(out, "contents").Exists() || ojson.GetBytesNoCopy(out, "systemInstruction").Exists() {
t.Fatalf("Codex request must not use foreign request shape. Output: %s", string(out))
}
}
@@ -40,46 +151,46 @@ func TestConvertInteractionsRequestToCodexWithToolMessagesDirect(t *testing.T) {
func TestConvertInteractionsRequestToCodexPreservesNonImageMediaContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","input":[{"type":"model_output","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.role").String(); got != "assistant" {
t.Fatalf("input.0.role = %q, want assistant. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_audio" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.content.0.type").String(); got != "input_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.content.0.type").String(); got != "input_file" {
t.Fatalf("video content type = %q, want input_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.content.0.type").String(); got != "input_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.content.0.type").String(); got != "input_file" {
t.Fatalf("document content type = %q, want input_file. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToCodexPreservesTopLevelThinkingLevel(t *testing.T) {
out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","generation_config":{"thinking_level":"high"},"input":"hi"}`), true)
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "stream").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToCodexUsesBodyStream(t *testing.T) {
out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","stream":true,"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "stream").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToCodexFunctionDeclarations(t *testing.T) {
out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"model":"codex-test","input":"hi","tools":[{"function_declarations":[{"name":"lookup","description":"Lookup data","parameters":{"type":"object","$schema":"http://json-schema.org/draft-07/schema#","properties":{"q":{"type":"string"}}}}]}]}`), false)
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "tools.0.parameters.$schema").Exists() {
+ if ojson.GetBytesNoCopy(out, "tools.0.parameters.$schema").Exists() {
t.Fatalf("tool parameters should not keep $schema. Output: %s", string(out))
}
}
@@ -87,7 +198,7 @@ func TestConvertInteractionsRequestToCodexFunctionDeclarations(t *testing.T) {
func TestConvertCodexResponseToInteractionsIncompleteTerminal(t *testing.T) {
raw := []byte(`{"type":"response.incomplete","response":{"id":"resp_1","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`)
nonStreamOut := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(nonStreamOut, "status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(nonStreamOut, "status").String(); got != "incomplete" {
t.Fatalf("non-stream status = %q, want incomplete. Output: %s", got, nonStreamOut)
}
@@ -97,7 +208,7 @@ func TestConvertCodexResponseToInteractionsIncompleteTerminal(t *testing.T) {
if len(payload) == 0 {
t.Fatalf("stream incomplete event did not terminate interaction: %q", streamOut)
}
- if got := gjson.GetBytes(payload, "interaction.status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.status").String(); got != "incomplete" {
t.Fatalf("stream status = %q, want incomplete. Payload: %s", got, payload)
}
}
@@ -105,16 +216,16 @@ func TestConvertCodexResponseToInteractionsIncompleteTerminal(t *testing.T) {
func TestConvertCodexResponseToInteractionsNonStream(t *testing.T) {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"usage":{"input_tokens":3,"output_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"reasoning","content":"thinking"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}]}}`)
out := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("steps.0.content.0.text = %q, want ok. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "steps.1.type").String(); got != "thought" {
+ if got := ojson.GetBytesNoCopy(out, "steps.1.type").String(); got != "thought" {
t.Fatalf("steps.1.type = %q, want thought. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "steps.2.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "steps.2.type").String(); got != "function_call" {
t.Fatalf("steps.2.type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 5 {
t.Fatalf("usage.total_tokens = %d, want 5. Output: %s", got, string(out))
}
}
@@ -126,7 +237,7 @@ func TestConvertCodexResponseToInteractionsStream(t *testing.T) {
if len(payload) == 0 {
t.Fatalf("step.delta event not found: %q", events)
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "ok" {
t.Fatalf("delta.text = %q, want ok. Payload: %s", got, string(payload))
}
}
@@ -135,7 +246,7 @@ func TestConvertCodexResponseToInteractionsStreamFunctionCallStartHasCallID(t *t
var param any
events := ConvertCodexResponseToInteractions(context.Background(), "codex-test", nil, nil, []byte(`data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}"}}`), ¶m)
payload := findCodexInteractionsEventPayload(events, "step.start")
- if got := gjson.GetBytes(payload, "step.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(payload, "step.call_id").String(); got != "call_1" {
t.Fatalf("step.call_id = %q, want call_1. Payload: %s", got, string(payload))
}
}
@@ -158,7 +269,7 @@ func TestConvertCodexResponseToInteractionsStreamCompletesAfterSteps(t *testing.
t.Fatalf("events = %s, want %s", got, want)
}
completed := findCodexInteractionsEventPayload(events, "interaction.completed")
- if gotTokens := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
+ if gotTokens := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completed))
}
}
@@ -178,8 +289,8 @@ func testInteractionsGPTSignature() string {
return base64.RawURLEncoding.EncodeToString(decoded)
}
-func findCodexInteractionsEventPayloads(events [][]byte, eventType string) []gjson.Result {
- var out []gjson.Result
+func findCodexInteractionsEventPayloads(events [][]byte, eventType string) []ojson.Result {
+ var out []ojson.Result
for _, event := range events {
for _, line := range bytes.Split(event, []byte("\n")) {
line = bytes.TrimSpace(line)
@@ -188,7 +299,7 @@ func findCodexInteractionsEventPayloads(events [][]byte, eventType string) []gjs
}
payload := bytes.TrimSpace(line[len("data:"):])
if codexInteractionsEventName(codexInteractionsFrameEventName(event), payload) == eventType {
- out = append(out, gjson.ParseBytes(payload))
+ out = append(out, util.ParseGJSONBytesNoCopy(payload))
}
}
}
@@ -205,7 +316,7 @@ func TestConvertCodexResponseToInteractionsNonStream_ReasoningSignature(t *testi
`]}}`)
out := ConvertCodexResponseToInteractionsNonStream(context.Background(), "codex-test", nil, nil, raw, nil)
- steps := gjson.GetBytes(out, "steps").Array()
+ steps := ojson.GetBytesNoCopy(out, "steps").Array()
if len(steps) != 2 {
t.Fatalf("steps len = %d, want 2 (empty reasoning emits no step). Output: %s", len(steps), string(out))
}
@@ -318,7 +429,7 @@ func TestConvertInteractionsRequestToCodex_RestoresThoughtSignature(t *testing.T
t.Run(tc.name, func(t *testing.T) {
payload := []byte(`{"model":"` + tc.model + `","input":[{"type":"thought","signature":"` + tc.sig + `","content":[{"type":"text","text":"thinking"}]}]}`)
out, _ := ConvertInteractionsRequestToCodex(tc.model, payload, false)
- item := gjson.GetBytes(out, "input.0")
+ item := ojson.GetBytesNoCopy(out, "input.0")
if got := item.Get("type").String(); got != "reasoning" {
t.Fatalf("input.0.type = %q, want reasoning. Output: %s", got, string(out))
}
@@ -342,7 +453,7 @@ func TestConvertInteractionsRequestToCodex_ThoughtSignatureTargetMismatch(t *tes
// A foreign signature is never replayed and the thought step is dropped
// entirely rather than sent as an unsigned reasoning item.
out, _ := ConvertInteractionsRequestToCodex("gpt-5.4", payload, false)
- if got := len(gjson.GetBytes(out, "input").Array()); got != 0 {
+ if got := len(ojson.GetBytesNoCopy(out, "input").Array()); got != 0 {
t.Fatalf("input len = %d, want 0 (mismatched thought dropped). Output: %s", got, string(out))
}
}
@@ -353,27 +464,27 @@ func TestConvertInteractionsRequestToCodex_DropsEmptyThoughtSteps(t *testing.T)
// A rejected signature with no text emits no item at all: a bare
// reasoning item carries nothing replayable and is rejected upstream.
out, _ := ConvertInteractionsRequestToCodex("gpt-5.4", []byte(`{"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","signature":"`+metaSig+`"}]}`), false)
- if got := len(gjson.GetBytes(out, "input").Array()); got != 1 {
+ if got := len(ojson.GetBytesNoCopy(out, "input").Array()); got != 1 {
t.Fatalf("input len = %d, want 1 (thought step dropped). Output: %s", got, string(out))
}
// A signatureless, textless thought step emits no item.
out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought"}]}`), false)
- if got := len(gjson.GetBytes(out, "input").Array()); got != 0 {
+ if got := len(ojson.GetBytesNoCopy(out, "input").Array()); got != 0 {
t.Fatalf("input len = %d, want 0. Output: %s", got, string(out))
}
// A duplicate accepted signature with no text emits nothing the second
// time either.
out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought","signature":"`+metaSig+`"},{"type":"thought","signature":"`+metaSig+`"}]}`), false)
- if got := len(gjson.GetBytes(out, "input").Array()); got != 1 {
+ if got := len(ojson.GetBytesNoCopy(out, "input").Array()); got != 1 {
t.Fatalf("input len = %d, want 1 (duplicate without text dropped). Output: %s", got, string(out))
}
// A signatureless thought with text is dropped too, and the message/tool
// items around it keep their order.
out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"thought","content":[{"type":"text","text":"thinking"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}]}`), false)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 2 || items[0].Get("type").String() != "message" || items[1].Get("type").String() != "function_call" {
t.Fatalf("input = %s, want [message, function_call] with the thought dropped", items)
}
@@ -385,40 +496,40 @@ func TestConvertInteractionsRequestToCodex_ThoughtSignatureVariants(t *testing.T
// A cache-prefixed signature normalizes to its native payload.
out, _ := ConvertInteractionsRequestToCodex("gpt-5.4", []byte(`{"input":[{"type":"thought","signature":"gpt#`+gptSig+`"}]}`), false)
- if got := gjson.GetBytes(out, "input.0.encrypted_content").String(); got != gptSig {
+ if got := ojson.GetBytesNoCopy(out, "input.0.encrypted_content").String(); got != gptSig {
t.Fatalf("prefixed GPT signature should normalize to native payload. Output: %s", string(out))
}
// The snake_case thought_signature spelling is accepted too.
out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought","thought_signature":"`+metaSig+`"}]}`), false)
- if got := gjson.GetBytes(out, "input.0.encrypted_content").String(); got != metaSig {
+ if got := ojson.GetBytesNoCopy(out, "input.0.encrypted_content").String(); got != metaSig {
t.Fatalf("thought_signature field should be accepted. Output: %s", string(out))
}
// An identical signature replays once; the duplicate thought is dropped.
out, _ = ConvertInteractionsRequestToCodex("muse-spark-1.3", []byte(`{"input":[{"type":"thought","signature":"`+metaSig+`","content":[{"type":"text","text":"one"}]},{"type":"thought","signature":"`+metaSig+`","content":[{"type":"text","text":"two"}]}]}`), false)
- if got := len(gjson.GetBytes(out, "input").Array()); got != 1 {
+ if got := len(ojson.GetBytesNoCopy(out, "input").Array()); got != 1 {
t.Fatalf("input len = %d, want 1 (duplicate dropped). Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.encrypted_content").String(); got != metaSig {
+ if got := ojson.GetBytesNoCopy(out, "input.0.encrypted_content").String(); got != metaSig {
t.Fatalf("first thought should carry the signature. Output: %s", string(out))
}
}
func TestConvertInteractionsRequestToCodex_IncludeEncryptedContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "include.0").String(); got != "reasoning.encrypted_content" {
+ if got := ojson.GetBytesNoCopy(out, "include.0").String(); got != "reasoning.encrypted_content" {
t.Fatalf("include.0 = %q, want reasoning.encrypted_content. Output: %s", got, string(out))
}
out, _ = ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi","include":["web_search_call.action.sources"]}`), false)
- include := gjson.GetBytes(out, "include").Array()
+ include := ojson.GetBytesNoCopy(out, "include").Array()
if len(include) != 2 || include[0].String() != "web_search_call.action.sources" || include[1].String() != "reasoning.encrypted_content" {
t.Fatalf("include = %s, want sources + reasoning.encrypted_content. Output: %s", include, string(out))
}
out, _ = ConvertInteractionsRequestToCodex("codex-test", []byte(`{"input":"hi","include":["reasoning.encrypted_content"]}`), false)
- if include := gjson.GetBytes(out, "include").Array(); len(include) != 1 {
+ if include := ojson.GetBytesNoCopy(out, "include").Array(); len(include) != 1 {
t.Fatalf("include = %s, want unchanged single entry. Output: %s", include, string(out))
}
}
@@ -460,10 +571,10 @@ func codexInteractionsEventNames(events [][]byte) []string {
}
func codexInteractionsEventName(eventName string, payload []byte) string {
- if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
+ if eventType := ojson.GetBytesNoCopy(payload, "event_type").String(); eventType != "" {
return eventType
}
- if eventType := gjson.GetBytes(payload, "type").String(); eventType != "" {
+ if eventType := ojson.GetBytesNoCopy(payload, "type").String(); eventType != "" {
return eventType
}
return eventName
diff --git a/internal/translator/codex/interactions/interactions_codex_uri_test.go b/internal/translator/codex/interactions/interactions_codex_uri_test.go
index 36e20513592..23dcb1868c7 100644
--- a/internal/translator/codex/interactions/interactions_codex_uri_test.go
+++ b/internal/translator/codex/interactions/interactions_codex_uri_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const interactionsCodexHistory = `{"type":"user_input","content":[{"type":"text","text":"a"}]},{"type":"model_output","content":[{"type":"text","text":"b"}]}`
@@ -35,7 +35,7 @@ func requireInteractionsToCodexRefusal(t *testing.T, envelope sdktranslator.Requ
if got, want := envelope.Err.Error(), "unsupported content part: "+wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
}
@@ -47,10 +47,10 @@ func TestInteractionsToCodexKeepsTheMediaGeminiToInteractionsWrites(t *testing.T
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.#").Int(); got != 3 {
t.Fatalf("input has %d items, want 3: %s", got, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0")
if part.Get("type").String() != "input_image" || part.Get("image_url").String() != "gs://b/a.png" {
t.Fatalf("last turn lost the file: %s", envelope.Body)
}
@@ -63,7 +63,7 @@ func TestInteractionsToCodexReadsURIAsAFileReference(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0")
if part.Get("type").String() != "input_image" || part.Get("image_url").String() != "https://example.test/a.png" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -73,7 +73,7 @@ func TestInteractionsToCodexReadsURIAsAFileReference(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0")
if part.Get("type").String() != "input_file" || part.Get("file_url").String() != "https://example.test/a.pdf" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -86,15 +86,15 @@ func TestInteractionsToCodexInlineDataStillConverts(t *testing.T) {
if image.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", image.Err, image.Body)
}
- if got := gjson.GetBytes(image.Body, "input.2.content.0.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(image.Body, "input.2.content.0.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image_url = %q, body = %s", got, image.Body)
}
audio := interactionsFinalTurnToCodex(`{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="}`)
if audio.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", audio.Err, audio.Body)
}
- if got := gjson.GetBytes(audio.Body, "input.2.content.0.input_audio.data").String(); got != "UklGRg==" {
- t.Fatalf("input_audio = %s, body = %s", gjson.GetBytes(audio.Body, "input.2.content.0").Raw, audio.Body)
+ if got := ojson.GetBytesNoCopy(audio.Body, "input.2.content.0.input_audio.data").String(); got != "UklGRg==" {
+ t.Fatalf("input_audio = %s, body = %s", ojson.GetBytesNoCopy(audio.Body, "input.2.content.0").Raw, audio.Body)
}
}
@@ -149,7 +149,7 @@ func TestInteractionsToCodexTypeOnlyInstructionStepIsSentAsDeveloper(t *testing.
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- last := gjson.GetBytes(envelope.Body, "input.2")
+ last := ojson.GetBytesNoCopy(envelope.Body, "input.2")
if last.Get("role").String() != "developer" || last.Get("content.0.text").String() != "note" {
t.Fatalf("last item = %s, body = %s", last.Raw, envelope.Body)
}
@@ -162,10 +162,10 @@ func TestInteractionsToCodexUnrepresentableAttachmentBesideTextStillSucceeds(t *
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.#").Int(); got != 3 {
t.Fatalf("input has %d items, want 3: %s", got, envelope.Body)
}
}
@@ -196,7 +196,7 @@ func TestInteractionsToCodexAssistantMediaIsNotAUserTurn(t *testing.T) {
func TestInteractionsToCodexExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","input":[` + interactionsCodexHistory + `,{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav"}]}]}`)
- if body, _ := ConvertInteractionsRequestToCodex("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertInteractionsRequestToCodex("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/codex/interactions/noop_optimization_test.go b/internal/translator/codex/interactions/noop_optimization_test.go
index 8b1da38f5a0..96ea2dcd121 100644
--- a/internal/translator/codex/interactions/noop_optimization_test.go
+++ b/internal/translator/codex/interactions/noop_optimization_test.go
@@ -3,13 +3,14 @@ package interactions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestCleanedCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
input := []byte(`{"type":"object","properties":{"value":{"type":"string"}},"additionalProperties":false}`)
- output := []byte(cleanedCodexToolParameters(gjson.ParseBytes(input)))
+ output := []byte(cleanedCodexToolParameters(util.ParseGJSONBytesNoCopy(input)))
if string(output) != string(input) {
t.Fatalf("canonical schema changed:\n got: %s\nwant: %s", output, input)
@@ -18,7 +19,7 @@ func TestCleanedCodexToolParametersPreservesCanonicalSchema(t *testing.T) {
func TestSetInteractionsCodexRawIfDifferentReusesMatchingValue(t *testing.T) {
input := []byte(`{"tool_choice":"auto","input":[]}`)
- value := gjson.Parse(`"auto"`)
+ value := ojson.Parse(`"auto"`)
output := setInteractionsCodexRawIfDifferent(input, "tool_choice", value)
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request.go b/internal/translator/codex/openai/chat-completions/codex_openai_request.go
index 57119489c20..389a65fce69 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_request.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_request.go
@@ -1,5 +1,5 @@
// Package openai provides utilities to translate OpenAI Chat Completions
-// request JSON into OpenAI Responses API request JSON using gjson/sjson.
+// request JSON into OpenAI Responses API request JSON using gjson/ojson.
// It supports tools, multimodal text/image inputs, and Structured Outputs.
// The package handles the conversion of OpenAI API requests into the format
// expected by the OpenAI Responses API, including proper mapping of messages,
@@ -12,10 +12,10 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ConvertOpenAIRequestToCodex converts an OpenAI Chat Completions request JSON
@@ -40,51 +40,39 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
rawJSON := inputRawJSON
var drops translatorcommon.UserTurnDrops
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
tools := root.Get("tools")
toolResults := tools.Array()
// Start with empty JSON object
- out := []byte(`{"instructions":""}`)
+ outDoc := ojson.NewDocString(`{"instructions":""}`)
// Stream must be set to true
- out, _ = sjson.SetBytes(out, "stream", stream)
-
- // Codex not support temperature, top_p, top_k, max_output_tokens, so comment them
- // if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() {
- // out, _ = sjson.SetBytes(out, "temperature", v.Value())
- // }
- // if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() {
- // out, _ = sjson.SetBytes(out, "top_p", v.Value())
- // }
- // if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() {
- // out, _ = sjson.SetBytes(out, "top_k", v.Value())
- // }
-
- // Map token limits
- // if v := gjson.GetBytes(rawJSON, "max_tokens"); v.Exists() {
- // out, _ = sjson.SetBytes(out, "max_output_tokens", v.Value())
- // }
- // if v := gjson.GetBytes(rawJSON, "max_completion_tokens"); v.Exists() {
- // out, _ = sjson.SetBytes(out, "max_output_tokens", v.Value())
- // }
+ _ = outDoc.Set("stream", stream)
+
+ for _, path := range []string{"max_completion_tokens", "max_tokens", "max_output_tokens"} {
+ if v := root.Get(path); v.Type == ojson.Number {
+ _ = outDoc.Set("max_output_tokens", v.Int())
+ break
+ }
+ }
// Map reasoning effort
- if v := gjson.GetBytes(rawJSON, "reasoning_effort"); v.Exists() {
- out, _ = sjson.SetBytes(out, "reasoning.effort", v.Value())
+ if v := root.Get("reasoning_effort"); v.Exists() {
+ _ = outDoc.Set("reasoning.effort", v.Value())
} else {
- out, _ = sjson.SetBytes(out, "reasoning.effort", "medium")
+ _ = outDoc.Set("reasoning.effort", "medium")
}
if serviceTier := normalizeCodexServiceTier(root.Get("service_tier")); serviceTier != "" {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier)
+ _ = outDoc.Set("service_tier", serviceTier)
}
- out, _ = sjson.SetBytes(out, "parallel_tool_calls", gjson.GetBytes(rawJSON, "parallel_tool_calls").Type != gjson.False)
+ _ = outDoc.Set("parallel_tool_calls", root.Get("parallel_tool_calls").Type != ojson.False)
// OpenAI documents reasoning summaries as explicit opt-in output. Leave
// reasoning.summary to the source request's canonical summary intent instead
// of coupling it to reasoning effort.
- out, _ = sjson.SetBytes(out, "include", []string{"reasoning.encrypted_content"})
+ _ = outDoc.Set("include", []string{"reasoning.encrypted_content"})
// Model
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Build request-local tool metadata and name shortening map.
originalToolNameMap := map[string]string{}
@@ -112,7 +100,7 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
}
- resolveToolCall := func(toolCall gjson.Result) (callType, name, input string, valid bool) {
+ resolveToolCall := func(toolCall ojson.Result) (callType, name, input string, valid bool) {
switch toolCall.Get("type").String() {
case "custom":
return "custom", toolCall.Get("custom.name").String(), toolCall.Get("custom.input").String(), true
@@ -136,7 +124,7 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
// Extract system instructions from first system message (string or text object)
- messages := gjson.GetBytes(rawJSON, "messages")
+ messages := root.Get("messages")
type pendingToolCall struct {
callID string
sourceCallID string
@@ -145,24 +133,8 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
var pendingToolCalls []pendingToolCall
ambiguousToolCallIDs := map[string]struct{}{}
- // if messages.IsArray() {
- // arr := messages.Array()
- // for i := 0; i < len(arr); i++ {
- // m := arr[i]
- // if m.Get("role").String() == "system" {
- // c := m.Get("content")
- // if c.Type == gjson.String {
- // out, _ = sjson.SetBytes(out, "instructions", c.String())
- // } else if c.IsObject() && c.Get("type").String() == "text" {
- // out, _ = sjson.SetBytes(out, "instructions", c.Get("text").String())
- // }
- // break
- // }
- // }
- // }
// Build input from messages, handling all message types including tool calls
- out, _ = sjson.SetRawBytes(out, "input", []byte(`[]`))
inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int())
if messages.IsArray() {
arr := messages.Array()
@@ -201,11 +173,11 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
outputType = "custom_tool_call_output"
}
- toolOutput := []byte(`{}`)
- toolOutput, _ = sjson.SetBytes(toolOutput, "type", outputType)
- toolOutput, _ = sjson.SetBytes(toolOutput, "call_id", toolCallID)
- toolOutput = setToolCallOutputContent(toolOutput, m.Get("content"))
- inputItems = append(inputItems, toolOutput)
+ toolOutputDoc := ojson.NewDocString(`{}`)
+ _ = toolOutputDoc.Set("type", outputType)
+ _ = toolOutputDoc.Set("call_id", toolCallID)
+ setToolCallOutputContentDoc(toolOutputDoc, m.Get("content"))
+ inputItems = append(inputItems, toolOutputDoc.Bytes())
default:
// A new conversational message starts a new tool-call batch.
@@ -213,12 +185,12 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
ambiguousToolCallIDs = map[string]struct{}{}
// Handle regular messages
- msg := []byte(`{}`)
- msg, _ = sjson.SetBytes(msg, "type", "message")
+ msgDoc := ojson.NewDocString(`{}`)
+ _ = msgDoc.Set("type", "message")
if role == "system" {
- msg, _ = sjson.SetBytes(msg, "role", "developer")
+ _ = msgDoc.Set("role", "developer")
} else {
- msg, _ = sjson.SetBytes(msg, "role", role)
+ _ = msgDoc.Set("role", role)
}
contentItems := make([][]byte, 0, 4)
@@ -227,17 +199,17 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
// Handle regular content
c := m.Get("content")
- if c.Exists() && c.Type == gjson.String && c.String() != "" {
+ if c.Exists() && c.Type == ojson.String && c.String() != "" {
turnSendable++
// Single string content
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", partType)
- part, _ = sjson.SetBytes(part, "text", c.String())
- contentItems = append(contentItems, part)
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", partType)
+ _ = partDoc.Set("text", c.String())
+ contentItems = append(contentItems, partDoc.Bytes())
} else if c.Exists() && c.IsArray() {
items := c.Array()
for j := 0; j < len(items); j++ {
@@ -249,22 +221,22 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
if role == "assistant" {
partType = "output_text"
}
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", partType)
- part, _ = sjson.SetBytes(part, "text", it.Get("text").String())
- contentItems = append(contentItems, part)
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", partType)
+ _ = partDoc.Set("text", it.Get("text").String())
+ contentItems = append(contentItems, partDoc.Bytes())
if it.Get("text").String() != "" {
turnSendable++
}
case "image_url":
// Map image inputs to input_image for Responses API
if role == "user" {
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_image")
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_image")
if u := it.Get("image_url.url"); u.Exists() {
- part, _ = sjson.SetBytes(part, "image_url", u.String())
+ _ = partDoc.Set("image_url", u.String())
}
- contentItems = append(contentItems, part)
+ contentItems = append(contentItems, partDoc.Bytes())
turnSendable++
}
case "file":
@@ -281,13 +253,13 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
audioData := it.Get("input_audio.data").String()
audioFormat := it.Get("input_audio.format").String()
if audioData != "" {
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_audio")
- part, _ = sjson.SetBytes(part, "data", audioData)
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_audio")
+ _ = partDoc.Set("data", audioData)
if audioFormat != "" {
- part, _ = sjson.SetBytes(part, "format", audioFormat)
+ _ = partDoc.Set("format", audioFormat)
}
- contentItems = append(contentItems, part)
+ contentItems = append(contentItems, partDoc.Bytes())
turnSendable++
} else {
drops.Drop(t)
@@ -304,8 +276,8 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
// are present — Responses API needs function_call items
// directly, otherwise call_id matching fails (#2132).
if role != "assistant" || len(contentItems) > 0 {
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
- inputItems = append(inputItems, msg)
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
+ inputItems = append(inputItems, msgDoc.Bytes())
}
// Handle tool calls for assistant messages as separate top-level objects
@@ -371,29 +343,29 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
switch toolCallType {
case "function":
// Create function_call as top-level object
- funcCall := []byte(`{}`)
- funcCall, _ = sjson.SetBytes(funcCall, "type", "function_call")
- funcCall, _ = sjson.SetBytes(funcCall, "call_id", callID)
+ funcCallDoc := ojson.NewDocString(`{}`)
+ _ = funcCallDoc.Set("type", "function_call")
+ _ = funcCallDoc.Set("call_id", callID)
if short, ok := originalToolNameMap[toolCallName]; ok {
toolCallName = short
} else {
toolCallName = shortenNameIfNeeded(toolCallName)
}
- funcCall, _ = sjson.SetBytes(funcCall, "name", toolCallName)
- funcCall, _ = sjson.SetBytes(funcCall, "arguments", toolCallInput)
- inputItems = append(inputItems, funcCall)
+ _ = funcCallDoc.Set("name", toolCallName)
+ _ = funcCallDoc.Set("arguments", toolCallInput)
+ inputItems = append(inputItems, funcCallDoc.Bytes())
case "custom":
- customCall := []byte(`{}`)
- customCall, _ = sjson.SetBytes(customCall, "type", "custom_tool_call")
- customCall, _ = sjson.SetBytes(customCall, "call_id", callID)
+ customCallDoc := ojson.NewDocString(`{}`)
+ _ = customCallDoc.Set("type", "custom_tool_call")
+ _ = customCallDoc.Set("call_id", callID)
if short, ok := originalToolNameMap[toolCallName]; ok {
toolCallName = short
} else {
toolCallName = shortenNameIfNeeded(toolCallName)
}
- customCall, _ = sjson.SetBytes(customCall, "name", toolCallName)
- customCall, _ = sjson.SetBytes(customCall, "input", toolCallInput)
- inputItems = append(inputItems, customCall)
+ _ = customCallDoc.Set("name", toolCallName)
+ _ = customCallDoc.Set("input", toolCallInput)
+ inputItems = append(inputItems, customCallDoc.Bytes())
}
}
}
@@ -401,33 +373,31 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
}
}
- out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "input", inputItems)
// Map response_format and text settings to Responses API text.format
- rf := gjson.GetBytes(rawJSON, "response_format")
- text := gjson.GetBytes(rawJSON, "text")
+ rf := root.Get("response_format")
+ text := root.Get("text")
if rf.Exists() {
// Always create text object when response_format provided
- if !gjson.GetBytes(out, "text").Exists() {
- out, _ = sjson.SetRawBytes(out, "text", []byte(`{}`))
- }
+ _ = outDoc.SetRawBytes("text", []byte(`{}`))
rft := rf.Get("type").String()
switch rft {
case "text":
- out, _ = sjson.SetBytes(out, "text.format.type", "text")
+ _ = outDoc.Set("text.format.type", "text")
case "json_schema":
js := rf.Get("json_schema")
if js.Exists() {
- out, _ = sjson.SetBytes(out, "text.format.type", "json_schema")
+ _ = outDoc.Set("text.format.type", "json_schema")
if v := js.Get("name"); v.Exists() {
- out, _ = sjson.SetBytes(out, "text.format.name", v.Value())
+ _ = outDoc.Set("text.format.name", v.Value())
}
if v := js.Get("strict"); v.Exists() {
- out, _ = sjson.SetBytes(out, "text.format.strict", v.Value())
+ _ = outDoc.Set("text.format.strict", v.Value())
}
if v := js.Get("schema"); v.Exists() {
- out, _ = sjson.SetRawBytes(out, "text.format.schema", []byte(v.Raw))
+ _ = outDoc.SetRawBytes("text.format.schema", []byte(v.Raw))
}
}
}
@@ -435,16 +405,14 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
// Map verbosity if provided
if text.Exists() {
if v := text.Get("verbosity"); v.Exists() {
- out, _ = sjson.SetBytes(out, "text.verbosity", v.Value())
+ _ = outDoc.Set("text.verbosity", v.Value())
}
}
} else if text.Exists() {
// If only text.verbosity present (no response_format), map verbosity
if v := text.Get("verbosity"); v.Exists() {
- if !gjson.GetBytes(out, "text").Exists() {
- out, _ = sjson.SetRawBytes(out, "text", []byte(`{}`))
- }
- out, _ = sjson.SetBytes(out, "text.verbosity", v.Value())
+ _ = outDoc.SetRawBytes("text", []byte(`{}`))
+ _ = outDoc.Set("text.verbosity", v.Value())
}
}
@@ -463,7 +431,7 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
} else {
name = shortenNameIfNeeded(name)
}
- item, _ = sjson.SetBytes(item, "name", name)
+ item, _ = ojson.SetBytes(item, "name", name)
toolItems = append(toolItems, item)
continue
}
@@ -476,8 +444,8 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
if toolType == "function" {
- item := []byte(`{}`)
- item, _ = sjson.SetBytes(item, "type", "function")
+ itemDoc := ojson.NewDocString(`{}`)
+ _ = itemDoc.Set("type", "function")
fn := t.Get("function")
if fn.Exists() {
if v := fn.Get("name"); v.Exists() {
@@ -487,35 +455,35 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
} else {
name = shortenNameIfNeeded(name)
}
- item, _ = sjson.SetBytes(item, "name", name)
+ _ = itemDoc.Set("name", name)
}
if v := fn.Get("description"); v.Exists() {
- item, _ = sjson.SetBytes(item, "description", v.Value())
+ _ = itemDoc.Set("description", v.Value())
}
if v := fn.Get("parameters"); v.Exists() {
- item, _ = sjson.SetRawBytes(item, "parameters", []byte(v.Raw))
+ _ = itemDoc.SetRawBytes("parameters", []byte(v.Raw))
}
if v := fn.Get("strict"); v.Exists() {
- item, _ = sjson.SetBytes(item, "strict", v.Value())
+ _ = itemDoc.Set("strict", v.Value())
} else {
// Chat Completions defaults strict to false while the Responses API
// defaults it to true, so an omitted value must be forwarded explicitly.
- item, _ = sjson.SetBytes(item, "strict", false)
+ _ = itemDoc.Set("strict", false)
}
}
- toolItems = append(toolItems, item)
+ toolItems = append(toolItems, itemDoc.Bytes())
}
}
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
// Map tool_choice when present.
// Chat Completions: "tool_choice" can be a string ("auto"/"none") or an object (e.g. {"type":"function","function":{"name":"..."}}).
// Responses API: keep built-in tool choices as-is and flatten named choices to {"type":"...","name":"..."}.
- if tc := gjson.GetBytes(rawJSON, "tool_choice"); tc.Exists() {
+ if tc := root.Get("tool_choice"); tc.Exists() {
switch {
- case tc.Type == gjson.String:
- out, _ = sjson.SetBytes(out, "tool_choice", tc.String())
+ case tc.Type == ojson.String:
+ _ = outDoc.Set("tool_choice", tc.String())
case tc.IsObject():
tcType := tc.Get("type").String()
if tcType == "function" || tcType == "custom" {
@@ -533,55 +501,55 @@ func convertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
name = shortenNameIfNeeded(name)
}
}
- choice := []byte(`{}`)
- choice, _ = sjson.SetBytes(choice, "type", tcType)
+ choiceDoc := ojson.NewDocString(`{}`)
+ _ = choiceDoc.Set("type", tcType)
if name != "" {
- choice, _ = sjson.SetBytes(choice, "name", name)
+ _ = choiceDoc.Set("name", name)
}
- out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
+ _ = outDoc.SetRawBytes("tool_choice", choiceDoc.Bytes())
} else if tcType != "" {
// Built-in tool choices (e.g. {"type":"web_search"}) are already Responses-compatible.
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(tc.Raw))
+ _ = outDoc.SetRawBytes("tool_choice", []byte(tc.Raw))
}
}
}
- out, _ = sjson.SetBytes(out, "store", false)
- return out, drops.Err()
+ _ = outDoc.Set("store", false)
+ return outDoc.Bytes(), drops.Err()
}
-func setToolCallOutputContent(funcOutput []byte, content gjson.Result) []byte {
+func setToolCallOutputContentDoc(doc *ojson.Doc, content ojson.Result) {
switch {
- case content.Type == gjson.String:
- structuredContent := gjson.Parse(content.String())
+ case content.Type == ojson.String:
+ structuredContent := ojson.Parse(content.String())
if hasToolOutputImagePart(structuredContent) {
- return setToolCallOutputContent(funcOutput, structuredContent)
+ setToolCallOutputContentDoc(doc, structuredContent)
+ return
}
- funcOutput, _ = sjson.SetBytes(funcOutput, "output", content.String())
+ _ = doc.Set("output", content.String())
case content.IsArray():
outputItems := make([][]byte, 0, 4)
for _, item := range content.Array() {
outputItems = append(outputItems, toolOutputContentPart(item))
}
- funcOutput, _ = sjson.SetRawBytes(funcOutput, "output", translatorcommon.JoinRawArray(outputItems))
+ _ = doc.SetRawBytes("output", translatorcommon.JoinRawArray(outputItems))
default:
fallbackOutput := content.Raw
if fallbackOutput == "" {
fallbackOutput = content.String()
}
- funcOutput, _ = sjson.SetBytes(funcOutput, "output", fallbackOutput)
+ _ = doc.Set("output", fallbackOutput)
}
- return funcOutput
}
-func toolOutputContentPart(item gjson.Result) []byte {
+func toolOutputContentPart(item ojson.Result) []byte {
itemType := item.Get("type").String()
switch itemType {
case "text", "input_text", "output_text":
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_text")
- part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
- return part
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_text")
+ _ = partDoc.Set("text", item.Get("text").String())
+ return partDoc.Bytes()
case "image_url", "input_image":
imageURL := item.Get("image_url.url").String()
fileID := item.Get("image_url.file_id").String()
@@ -592,22 +560,22 @@ func toolOutputContentPart(item gjson.Result) []byte {
if imageURL == "" && fileID == "" {
return toolOutputFallbackPart(item)
}
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_image")
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_image")
if imageURL != "" {
- part, _ = sjson.SetBytes(part, "image_url", imageURL)
+ _ = partDoc.Set("image_url", imageURL)
}
if fileID != "" {
- part, _ = sjson.SetBytes(part, "file_id", fileID)
+ _ = partDoc.Set("file_id", fileID)
}
detail := item.Get("image_url.detail").String()
if itemType == "input_image" {
detail = item.Get("detail").String()
}
if detail != "" {
- part, _ = sjson.SetBytes(part, "detail", detail)
+ _ = partDoc.Set("detail", detail)
}
- return part
+ return partDoc.Bytes()
case "file":
if part, ok := codexInputFilePart(item); ok {
return part
@@ -620,31 +588,31 @@ func toolOutputContentPart(item gjson.Result) []byte {
// codexInputFilePart maps a Chat Completions file part onto a Responses input_file
// part. It reports false when the part names no file id, bytes or url.
-func codexInputFilePart(item gjson.Result) ([]byte, bool) {
+func codexInputFilePart(item ojson.Result) ([]byte, bool) {
fileID := item.Get("file.file_id").String()
fileData := item.Get("file.file_data").String()
fileURL := item.Get("file.file_url").String()
if fileID == "" && fileData == "" && fileURL == "" {
return nil, false
}
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_file")
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_file")
if fileID != "" {
- part, _ = sjson.SetBytes(part, "file_id", fileID)
+ _ = partDoc.Set("file_id", fileID)
}
if fileData != "" {
- part, _ = sjson.SetBytes(part, "file_data", fileData)
+ _ = partDoc.Set("file_data", fileData)
}
if fileURL != "" {
- part, _ = sjson.SetBytes(part, "file_url", fileURL)
+ _ = partDoc.Set("file_url", fileURL)
}
if filename := item.Get("file.filename").String(); filename != "" {
- part, _ = sjson.SetBytes(part, "filename", filename)
+ _ = partDoc.Set("filename", filename)
}
- return part, true
+ return partDoc.Bytes(), true
}
-func hasToolOutputImagePart(content gjson.Result) bool {
+func hasToolOutputImagePart(content ojson.Result) bool {
if !content.IsArray() {
return false
}
@@ -663,15 +631,15 @@ func hasToolOutputImagePart(content gjson.Result) bool {
return false
}
-func toolOutputFallbackPart(item gjson.Result) []byte {
+func toolOutputFallbackPart(item ojson.Result) []byte {
text := item.Raw
if text == "" {
text = item.String()
}
- part := []byte(`{}`)
- part, _ = sjson.SetBytes(part, "type", "input_text")
- part, _ = sjson.SetBytes(part, "text", text)
- return part
+ partDoc := ojson.NewDocString(`{}`)
+ _ = partDoc.Set("type", "input_text")
+ _ = partDoc.Set("text", text)
+ return partDoc.Bytes()
}
// sanitizeToolName normalizes a tool name by replacing any character outside
@@ -724,13 +692,14 @@ func collectRequestToolNames(rawJSON []byte) []string {
return
}
if _, ok := seen[name]; !ok {
- names = append(names, name)
- seen[name] = struct{}{}
+ n := strings.Clone(name)
+ names = append(names, n)
+ seen[n] = struct{}{}
}
}
// 1. tools declarations
- tools := gjson.GetBytes(rawJSON, "tools")
+ tools := ojson.GetBytesNoCopy(rawJSON, "tools")
if tools.IsArray() {
for _, tool := range tools.Array() {
switch tool.Get("type").String() {
@@ -743,7 +712,7 @@ func collectRequestToolNames(rawJSON []byte) []string {
}
// 2. tool_choice
- tc := gjson.GetBytes(rawJSON, "tool_choice")
+ tc := ojson.GetBytesNoCopy(rawJSON, "tool_choice")
if tc.IsObject() {
switch tc.Get("type").String() {
case "function":
@@ -758,7 +727,7 @@ func collectRequestToolNames(rawJSON []byte) []string {
}
// 3. assistant tool_calls in messages
- messages := gjson.GetBytes(rawJSON, "messages")
+ messages := ojson.GetBytesNoCopy(rawJSON, "messages")
if messages.IsArray() {
for _, msg := range messages.Array() {
if msg.Get("role").String() == "assistant" {
@@ -823,8 +792,8 @@ func buildShortNameMap(names []string) map[string]string {
return m
}
-func normalizeCodexServiceTier(result gjson.Result) string {
- if !result.Exists() || result.Type != gjson.String {
+func normalizeCodexServiceTier(result ojson.Result) string {
+ if !result.Exists() || result.Type != ojson.String {
return ""
}
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go
index fe7892efda3..96b41fea73f 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_request_test.go
@@ -5,9 +5,45 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
+func TestConvertOpenAIRequestToCodex_MaxOutputTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"no limit", `{}`, ""},
+ {"max_tokens", `{"max_tokens":1024}`, "1024"},
+ {"max_completion_tokens", `{"max_completion_tokens":2048}`, "2048"},
+ {"max_output_tokens fallback", `{"max_output_tokens":4096}`, "4096"},
+ {"completion limit takes precedence", `{"max_completion_tokens":1024,"max_tokens":2048,"max_output_tokens":4096}`, "1024"},
+ {"max_tokens takes precedence over output limit", `{"max_tokens":1024,"max_output_tokens":2048}`, "1024"},
+ {"invalid completion limit skipped", `{"max_completion_tokens":"1024","max_tokens":2048}`, "2048"},
+ {"null completion limit skipped", `{"max_completion_tokens":null,"max_tokens":2048}`, "2048"},
+ {"invalid legacy limits skipped", `{"max_completion_tokens":false,"max_tokens":{},"max_output_tokens":4096}`, "4096"},
+ {"zero takes precedence", `{"max_completion_tokens":0,"max_tokens":2048}`, "0"},
+ {"non-numeric limits ignored", `{"max_completion_tokens":"1024","max_tokens":null,"max_output_tokens":true}`, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, errConvert := ConvertOpenAIRequestToCodex("codex-test", []byte(tt.input), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertOpenAIRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != tt.want {
+ t.Fatalf("max_output_tokens = %q, want %q; output=%s", got, tt.want, out)
+ }
+ for _, path := range []string{"max_completion_tokens", "max_tokens"} {
+ if ojson.GetBytesNoCopy(out, path).Exists() {
+ t.Fatalf("%s should not be forwarded; output=%s", path, out)
+ }
+ }
+ })
+ }
+}
+
// Basic tool-call: system + user + assistant(tool_calls, no content) + tool result.
// Expects developer msg + user msg + function_call + function_call_output.
// No empty assistant message should appear between user and function_call.
@@ -52,9 +88,9 @@ func TestToolCallSimple(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
if len(items) != 4 {
- t.Fatalf("expected 4 input items, got %d: %s", len(items), gjson.Get(result, "input").Raw)
+ t.Fatalf("expected 4 input items, got %d: %s", len(items), ojson.Get(result, "input").Raw)
}
// system -> developer
@@ -141,10 +177,10 @@ func TestToolCallWithContent(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
// user + assistant(with content) + function_call + function_call_output
if len(items) != 4 {
- t.Fatalf("expected 4 input items, got %d: %s", len(items), gjson.Get(result, "input").Raw)
+ t.Fatalf("expected 4 input items, got %d: %s", len(items), ojson.Get(result, "input").Raw)
}
if items[0].Get("role").String() != "user" {
@@ -218,7 +254,7 @@ func TestToolCallOutputWithMultimodalContent(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- output := gjson.Get(result, "input.2.output")
+ output := ojson.Get(result, "input.2.output")
if !output.IsArray() {
t.Fatalf("expected tool output to be an array, got: %s", output.Raw)
}
@@ -307,7 +343,7 @@ func TestToolCallOutputWithStringifiedImageContent(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- output := gjson.GetBytes(out, "input.2.output")
+ output := ojson.GetBytesNoCopy(out, "input.2.output")
if !output.IsArray() {
t.Fatalf("expected stringified image output to be an array, got: %s", output.Raw)
}
@@ -365,8 +401,8 @@ func TestToolCallOutputKeepsNonImageStrings(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- output := gjson.GetBytes(out, "input.2.output")
- if output.Type != gjson.String {
+ output := ojson.GetBytesNoCopy(out, "input.2.output")
+ if output.Type != ojson.String {
t.Fatalf("expected output to remain a string, got: %s", output.Raw)
}
if output.String() != tt.expectedOutput {
@@ -406,9 +442,9 @@ func TestToolCallOutputFallsBackForInvalidStructuredParts(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- parts := gjson.Get(result, "input.2.output").Array()
+ parts := ojson.Get(result, "input.2.output").Array()
if len(parts) != 3 {
- t.Fatalf("expected 3 output parts, got %d: %s", len(parts), gjson.Get(result, "input.2.output").Raw)
+ t.Fatalf("expected 3 output parts, got %d: %s", len(parts), ojson.Get(result, "input.2.output").Raw)
}
expectedFallbacks := []string{
@@ -463,9 +499,9 @@ func TestToolCallOutputWithNonStringJSONContent(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- output := gjson.Get(result, "input.2.output")
+ output := ojson.Get(result, "input.2.output")
if !output.Exists() {
- t.Fatalf("expected output field to exist: %s", gjson.Get(result, "input.2").Raw)
+ t.Fatalf("expected output field to exist: %s", ojson.Get(result, "input.2").Raw)
}
if output.String() != tt.expectedOutput {
t.Fatalf("expected output %s, got %s", tt.expectedOutput, output.String())
@@ -489,9 +525,9 @@ func TestConvertOpenAIRequestToCodexPreservesInputAudio(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.5", input, true)
- parts := gjson.GetBytes(out, "input.0.content").Array()
+ parts := ojson.GetBytesNoCopy(out, "input.0.content").Array()
if len(parts) != 2 {
- t.Fatalf("expected 2 content parts, got %d: %s", len(parts), gjson.GetBytes(out, "input.0.content").Raw)
+ t.Fatalf("expected 2 content parts, got %d: %s", len(parts), ojson.GetBytesNoCopy(out, "input.0.content").Raw)
}
if parts[0].Get("type").String() != "input_text" || parts[0].Get("text").String() != "Transcribe this audio verbatim." {
t.Fatalf("part 0: expected input_text with prompt text, got %s", parts[0].Raw)
@@ -563,10 +599,10 @@ func TestMultipleToolCalls(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
// user + 3 function_call + 3 function_call_output = 7
if len(items) != 7 {
- t.Fatalf("expected 7 input items, got %d: %s", len(items), gjson.Get(result, "input").Raw)
+ t.Fatalf("expected 7 input items, got %d: %s", len(items), ojson.Get(result, "input").Raw)
}
if items[0].Get("role").String() != "user" {
@@ -634,7 +670,7 @@ func TestNoSpuriousEmptyAssistantMessage(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
for i, item := range items {
typ := item.Get("type").String()
@@ -649,7 +685,7 @@ func TestNoSpuriousEmptyAssistantMessage(t *testing.T) {
// should be exactly: user + function_call + function_call_output
if len(items) != 3 {
- t.Fatalf("expected 3 input items (user + function_call + function_call_output), got %d: %s", len(items), gjson.Get(result, "input").Raw)
+ t.Fatalf("expected 3 input items (user + function_call + function_call_output), got %d: %s", len(items), ojson.Get(result, "input").Raw)
}
if items[0].Get("type").String() != "message" || items[0].Get("role").String() != "user" {
t.Errorf("item 0: expected user message")
@@ -698,10 +734,10 @@ func TestMultiTurnToolCalling(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
// user, func_call(r1), func_output(r1), assistant text, user, func_call(r2), func_output(r2)
if len(items) != 7 {
- t.Fatalf("expected 7 input items, got %d: %s", len(items), gjson.Get(result, "input").Raw)
+ t.Fatalf("expected 7 input items, got %d: %s", len(items), ojson.Get(result, "input").Raw)
}
for i, item := range items {
@@ -782,10 +818,10 @@ func TestToolNameShortening(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
// find function_call
- var funcCallItem gjson.Result
+ var funcCallItem ojson.Result
for _, item := range items {
if item.Get("type").String() == "function_call" {
funcCallItem = item
@@ -833,9 +869,9 @@ func TestCustomToolNameShortening(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 3 {
- t.Fatalf("expected user, custom call, and custom output, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected user, custom call, and custom output, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[1].Get("type").String(); got != "custom_tool_call" {
t.Fatalf("expected custom_tool_call, got %s", items[1].Raw)
@@ -844,13 +880,13 @@ func TestCustomToolNameShortening(t *testing.T) {
if shortName == longName || len(shortName) > 64 {
t.Fatalf("expected shortened custom tool name, got %q", shortName)
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != shortName {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != shortName {
t.Fatalf("expected custom declaration name %q, got %q", shortName, got)
}
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "custom" {
- t.Fatalf("expected custom tool choice, got %s", gjson.GetBytes(out, "tool_choice").Raw)
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "custom" {
+ t.Fatalf("expected custom tool choice, got %s", ojson.GetBytesNoCopy(out, "tool_choice").Raw)
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != shortName {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.name").String(); got != shortName {
t.Fatalf("expected shortened custom tool choice name %q, got %q", shortName, got)
}
if got := items[2].Get("type").String(); got != "custom_tool_call_output" {
@@ -879,9 +915,9 @@ func TestCustomToolShortNameCollisionPreservesFunctionFamily(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 2 {
- t.Fatalf("expected function call and output, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected function call and output, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[0].Get("type").String(); got != "function_call" {
t.Fatalf("expected colliding original function name to remain function_call, got %s", items[0].Raw)
@@ -889,11 +925,11 @@ func TestCustomToolShortNameCollisionPreservesFunctionFamily(t *testing.T) {
if got := items[1].Get("type").String(); got != "function_call_output" {
t.Fatalf("expected colliding function output to remain function_call_output, got %s", items[1].Raw)
}
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
- t.Fatalf("expected colliding function choice to remain function, got %s", gjson.GetBytes(out, "tool_choice").Raw)
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
+ t.Fatalf("expected colliding function choice to remain function, got %s", ojson.GetBytesNoCopy(out, "tool_choice").Raw)
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != gjson.GetBytes(out, "tools.1.name").String() {
- t.Fatalf("expected function choice name to match translated declaration, got %s", gjson.GetBytes(out, "tool_choice").Raw)
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.name").String(); got != ojson.GetBytesNoCopy(out, "tools.1.name").String() {
+ t.Fatalf("expected function choice name to match translated declaration, got %s", ojson.GetBytesNoCopy(out, "tool_choice").Raw)
}
}
@@ -913,9 +949,9 @@ func TestSameNameCustomAndFunctionDefaultsToFunctionFamily(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 2 {
- t.Fatalf("expected function call and output, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected function call and output, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[0].Get("type").String(); got != "function_call" {
t.Fatalf("expected ambiguous normalized call to preserve function family, got %s", items[0].Raw)
@@ -923,10 +959,10 @@ func TestSameNameCustomAndFunctionDefaultsToFunctionFamily(t *testing.T) {
if got := items[1].Get("type").String(); got != "function_call_output" {
t.Fatalf("expected ambiguous output to preserve function family, got %s", items[1].Raw)
}
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
- t.Fatalf("expected ambiguous function choice to preserve function family, got %s", gjson.GetBytes(out, "tool_choice").Raw)
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
+ t.Fatalf("expected ambiguous function choice to preserve function family, got %s", ojson.GetBytesNoCopy(out, "tool_choice").Raw)
}
- if first, second := gjson.GetBytes(out, "tools.0.name").String(), gjson.GetBytes(out, "tools.1.name").String(); first != second {
+ if first, second := ojson.GetBytesNoCopy(out, "tools.0.name").String(), ojson.GetBytesNoCopy(out, "tools.1.name").String(); first != second {
t.Fatalf("expected same-name declarations to use a consistent translated name, got %q and %q", first, second)
}
}
@@ -965,7 +1001,7 @@ func TestEmptyStringContent(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
for i, item := range items {
if item.Get("type").String() == "message" && item.Get("role").String() == "assistant" {
@@ -1007,7 +1043,7 @@ func TestCallIDsMatchBetweenCallAndOutput(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- items := gjson.Get(result, "input").Array()
+ items := ojson.Get(result, "input").Array()
// collect call_ids from function_call items
callIDs := make(map[string]bool)
@@ -1082,9 +1118,9 @@ func TestCustomToolCallHistory(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 5 {
- t.Fatalf("expected 5 input items, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected 5 input items, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
customCall := items[2]
@@ -1114,11 +1150,11 @@ func TestCustomToolCallHistory(t *testing.T) {
if got := items[4].Get("content.0.text").String(); got != "The specification is updated." {
t.Fatalf("expected final assistant continuation, got %s", items[4].Raw)
}
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "custom" {
- t.Fatalf("expected normalized custom tool choice, got %s", gjson.GetBytes(out, "tool_choice").Raw)
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "custom" {
+ t.Fatalf("expected normalized custom tool choice, got %s", ojson.GetBytesNoCopy(out, "tool_choice").Raw)
}
- if got := gjson.GetBytes(out, "tool_choice.name").String(); got != "apply_patch" {
- t.Fatalf("expected custom tool choice name apply_patch, got %s", gjson.GetBytes(out, "tool_choice").Raw)
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.name").String(); got != "apply_patch" {
+ t.Fatalf("expected custom tool choice name apply_patch, got %s", ojson.GetBytesNoCopy(out, "tool_choice").Raw)
}
}
@@ -1138,7 +1174,7 @@ func TestCustomToolCallResponseFollowUpRoundTrip(t *testing.T) {
}`)
chatResponse := ConvertCodexResponseToOpenAINonStream(nil, "", originalRequest, nil, upstreamResponse, nil)
- assistantMessage := gjson.GetBytes(chatResponse, "choices.0.message")
+ assistantMessage := ojson.GetBytesNoCopy(chatResponse, "choices.0.message")
if got := assistantMessage.Get("tool_calls.0.type").String(); got != "function" {
t.Fatalf("expected response to normalize custom call as function, got %s", assistantMessage.Raw)
}
@@ -1155,9 +1191,9 @@ func TestCustomToolCallResponseFollowUpRoundTrip(t *testing.T) {
"tools":[{"type":"custom","name":"apply_patch","description":"Apply a patch."}]
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", followUpRequest, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 3 {
- t.Fatalf("expected user, custom call, and custom output, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected user, custom call, and custom output, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[1].Get("type").String(); got != "custom_tool_call" {
t.Fatalf("expected custom_tool_call after response round trip, got %s", items[1].Raw)
@@ -1188,9 +1224,9 @@ func TestMixedToolCallHistoryPreservesCallFamilies(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 5 {
- t.Fatalf("expected 5 input items, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected 5 input items, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
expectedTypes := []string{"message", "function_call", "custom_tool_call", "custom_tool_call_output", "function_call_output"}
@@ -1223,9 +1259,9 @@ func TestToolCallHistoryAllowsReusedCallIDAcrossRounds(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 5 {
- t.Fatalf("expected 5 input items, got %d: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected 5 input items, got %d: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[2].Get("type").String(); got != "function_call_output" {
t.Fatalf("expected first reused call output to remain function_call_output, got %s", items[2].Raw)
@@ -1247,9 +1283,9 @@ func TestCustomToolCallHistorySynthesizesMissingCallID(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 2 {
- t.Fatalf("expected orphan output to be dropped and missing ID pair preserved, got %d items: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected orphan output to be dropped and missing ID pair preserved, got %d items: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[0].Get("type").String(); got != "custom_tool_call" {
t.Fatalf("expected custom_tool_call, got %s", items[0].Raw)
@@ -1280,9 +1316,9 @@ func TestToolCallHistoryClearsUnmatchedCallAtNewBatch(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 3 {
- t.Fatalf("expected two calls and one output, got %d items: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected two calls and one output, got %d items: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[2].Get("type").String(); got != "function_call_output" {
t.Fatalf("expected new batch output to match function call, got %s", items[2].Raw)
@@ -1302,9 +1338,9 @@ func TestToolCallOutputWithoutIDUsesPendingCall(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 4 {
- t.Fatalf("expected two calls and two outputs, got %d items: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected two calls and two outputs, got %d items: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[2].Get("type").String(); got != "function_call_output" {
t.Fatalf("expected first empty-ID output to match function call, got %s", items[2].Raw)
@@ -1334,9 +1370,9 @@ func TestAmbiguousDuplicateToolCallIDsAreDropped(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 1 || items[0].Get("role").String() != "user" {
- t.Fatalf("expected ambiguous calls and outputs to be dropped, got %s", gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected ambiguous calls and outputs to be dropped, got %s", ojson.GetBytesNoCopy(out, "input").Raw)
}
}
@@ -1356,9 +1392,9 @@ func TestOrphanAndDuplicateToolCallOutputsAreDropped(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
if len(items) != 2 {
- t.Fatalf("expected only the matched call and first output, got %d items: %s", len(items), gjson.GetBytes(out, "input").Raw)
+ t.Fatalf("expected only the matched call and first output, got %d items: %s", len(items), ojson.GetBytesNoCopy(out, "input").Raw)
}
if got := items[0].Get("type").String(); got != "custom_tool_call" {
t.Fatalf("expected custom_tool_call, got %s", items[0].Raw)
@@ -1393,7 +1429,7 @@ func TestToolsDefinitionTranslated(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-4o", input, true)
result := string(out)
- tools := gjson.Get(result, "tools").Array()
+ tools := ojson.Get(result, "tools").Array()
if len(tools) == 0 {
t.Fatal("no tools found in output")
}
@@ -1406,7 +1442,7 @@ func TestToolsDefinitionTranslated(t *testing.T) {
}
}
if !found {
- t.Errorf("tool 'search' not found in output tools: %s", gjson.Get(result, "tools").Raw)
+ t.Errorf("tool 'search' not found in output tools: %s", ojson.Get(result, "tools").Raw)
}
}
@@ -1444,9 +1480,9 @@ func TestFunctionToolStrictDefaultsToFalse(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 3 {
- t.Fatalf("expected 3 tools, got %d: %s", len(tools), gjson.GetBytes(out, "tools").Raw)
+ t.Fatalf("expected 3 tools, got %d: %s", len(tools), ojson.GetBytesNoCopy(out, "tools").Raw)
}
expected := map[string]bool{"omitted": false, "explicit_true": true, "explicit_false": false}
@@ -1502,14 +1538,14 @@ func TestNormalizeInvalidToolNames(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- toolNameInTools := gjson.GetBytes(out, "tools.0.name").String()
+ toolNameInTools := ojson.GetBytesNoCopy(out, "tools.0.name").String()
nameRegex := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
if !nameRegex.MatchString(toolNameInTools) {
t.Fatalf("expected tool name in tools to match ^[a-zA-Z0-9_-]+$, got %q", toolNameInTools)
}
var funcCallName string
- for _, item := range gjson.GetBytes(out, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(out, "input").Array() {
if item.Get("type").String() == "function_call" {
funcCallName = item.Get("name").String()
break
@@ -1519,7 +1555,7 @@ func TestNormalizeInvalidToolNames(t *testing.T) {
t.Fatalf("expected function_call name %q to match tools declaration %q", funcCallName, toolNameInTools)
}
- toolChoiceName := gjson.GetBytes(out, "tool_choice.name").String()
+ toolChoiceName := ojson.GetBytesNoCopy(out, "tool_choice.name").String()
if toolChoiceName != toolNameInTools {
t.Fatalf("expected tool_choice name %q to match tools declaration %q", toolChoiceName, toolNameInTools)
}
@@ -1554,7 +1590,7 @@ func TestNormalizeInvalidToolNamesCollisionAndNonASCII(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 3 {
t.Fatalf("expected 3 tools, got %d", len(tools))
}
@@ -1608,10 +1644,10 @@ func TestHistoricalToolCallCollisionWithDeclaredTool(t *testing.T) {
}`)
out, _ := ConvertOpenAIRequestToCodex("gpt-5.6-sol", input, true)
- toolDeclared := gjson.GetBytes(out, "tools.0.name").String()
+ toolDeclared := ojson.GetBytesNoCopy(out, "tools.0.name").String()
var histCallName string
- for _, item := range gjson.GetBytes(out, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(out, "input").Array() {
if item.Get("type").String() == "function_call" {
histCallName = item.Get("name").String()
break
@@ -1707,14 +1743,14 @@ func TestConvertOpenAIRequestToCodexServiceTier(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-6-sol", []byte(tt.body), true)
- tierRes := gjson.GetBytes(out, "service_tier")
+ tierRes := ojson.GetBytesNoCopy(out, "service_tier")
if tierRes.Exists() != tt.wantExists {
t.Fatalf("service_tier exists = %v, want %v; payload=%s", tierRes.Exists(), tt.wantExists, out)
}
if tt.wantExists && tierRes.String() != tt.wantTier {
t.Fatalf("service_tier = %q, want %q; payload=%s", tierRes.String(), tt.wantTier, out)
}
- if gotEffort := gjson.GetBytes(out, "reasoning.effort").String(); gotEffort != tt.wantEffort {
+ if gotEffort := ojson.GetBytesNoCopy(out, "reasoning.effort").String(); gotEffort != tt.wantEffort {
t.Fatalf("reasoning.effort = %q, want %q; payload=%s", gotEffort, tt.wantEffort, out)
}
})
@@ -1732,7 +1768,7 @@ func TestApplyPatchChatHistoryBoundary(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
raw := []byte(`{"tools":` + tc.tools + `,"messages":[{"role":"assistant","tool_calls":[` + tc.call + `]}]}`)
out, _ := ConvertOpenAIRequestToCodex("m", raw, true)
- items := gjson.GetBytes(out, "input").Array()
+ items := ojson.GetBytesNoCopy(out, "input").Array()
item := items[len(items)-1]
field := "arguments"
if tc.wantType == "custom_tool_call" {
@@ -1773,7 +1809,7 @@ func TestOverLengthToolCallIDsAreShortenedConsistently(t *testing.T) {
out, _ := ConvertOpenAIRequestToCodex("gpt-6.1-sol", input, true)
var callIDs, outputIDs []string
- for _, item := range gjson.GetBytes(out, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(out, "input").Array() {
switch item.Get("type").String() {
case "function_call":
callIDs = append(callIDs, item.Get("call_id").String())
@@ -1829,7 +1865,7 @@ func TestShortenedToolCallIDCollisionWithLegalIDKeepsResultsMatched(t *testing.T
callIDByName := map[string]string{}
outputIDByResult := map[string]string{}
- for _, item := range gjson.GetBytes(out, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(out, "input").Array() {
switch item.Get("type").String() {
case "function_call":
callIDByName[item.Get("name").String()] = item.Get("call_id").String()
@@ -1855,12 +1891,12 @@ func TestConvertOpenAIRequestToCodexParallelToolCalls(t *testing.T) {
tests := []struct {
name string
field string
- wantType gjson.Type
+ wantType ojson.Type
}{
- {name: "false preserved", field: `"parallel_tool_calls":false,`, wantType: gjson.False},
- {name: "true preserved", field: `"parallel_tool_calls":true,`, wantType: gjson.True},
- {name: "null defaults to true", field: `"parallel_tool_calls":null,`, wantType: gjson.True},
- {name: "omitted defaults to true", field: "", wantType: gjson.True},
+ {name: "false preserved", field: `"parallel_tool_calls":false,`, wantType: ojson.False},
+ {name: "true preserved", field: `"parallel_tool_calls":true,`, wantType: ojson.True},
+ {name: "null defaults to true", field: `"parallel_tool_calls":null,`, wantType: ojson.True},
+ {name: "omitted defaults to true", field: "", wantType: ojson.True},
}
for _, tt := range tests {
@@ -1870,7 +1906,7 @@ func TestConvertOpenAIRequestToCodexParallelToolCalls(t *testing.T) {
if errConvert != nil {
t.Fatalf("ConvertOpenAIRequestToCodex returned error: %v", errConvert)
}
- if got := gjson.GetBytes(output, "parallel_tool_calls"); got.Type != tt.wantType {
+ if got := ojson.GetBytesNoCopy(output, "parallel_tool_calls"); got.Type != tt.wantType {
t.Fatalf("parallel_tool_calls = %s (type %v), want type %v; output: %s", got.Raw, got.Type, tt.wantType, string(output))
}
})
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
index a311a2d6382..5938d1fc6c8 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
@@ -14,12 +14,11 @@ import (
"time"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
var (
@@ -53,15 +52,15 @@ func (m *codexChatToolMetadata) resolve(original []byte) {
m.resolved = true
m.reverseToolNames = buildReverseMapFromOriginalOpenAI(original)
m.functionToolNames = map[string]struct{}{}
- for _, tool := range gjson.GetBytes(original, "tools").Array() {
+ for _, tool := range ojson.GetBytesNoCopy(original, "tools").Array() {
if tool.Get("type").String() == "function" {
- m.functionToolNames[tool.Get("function.name").String()] = struct{}{}
+ m.functionToolNames[strings.Clone(tool.Get("function.name").String())] = struct{}{}
}
}
m.customPatchWinners = map[string]bool{}
- for name, winner := range util.CollectResponsesToolWinners(gjson.ParseBytes(original)) {
+ for name, winner := range util.CollectResponsesToolWinners(util.ParseGJSONBytesNoCopy(original)) {
if applypatch.IsCustomTool(winner.Tool) {
- m.customPatchWinners[name] = true
+ m.customPatchWinners[strings.Clone(name)] = true
}
}
}
@@ -77,7 +76,7 @@ func (m *codexChatToolMetadata) restoreToolName(original []byte, name string) st
}
// isCustomPatch matches isOriginalCustomPatch using the cached metadata.
-func (m *codexChatToolMetadata) isCustomPatch(original []byte, item gjson.Result) bool {
+func (m *codexChatToolMetadata) isCustomPatch(original []byte, item ojson.Result) bool {
if item.Get("type").String() != "custom_tool_call" {
return false
}
@@ -137,26 +136,26 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
rawJSON = bytes.TrimSpace(rawJSON[5:])
// Initialize the OpenAI SSE template.
- template := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":null}]}`)
+ templateDoc := ojson.NewDocString(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":null}]}`)
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
p := (*param).(*ConvertCliToOpenAIParams)
if tier := codexResponseServiceTier(rootResult.Get("response")); tier != "" {
- p.ServiceTier = tier
+ p.ServiceTier = strings.Clone(tier)
} else if tier := codexResponseServiceTier(rootResult); tier != "" {
- p.ServiceTier = tier
+ p.ServiceTier = strings.Clone(tier)
}
if p.ServiceTier != "" {
- template, _ = sjson.SetBytes(template, "service_tier", p.ServiceTier)
+ _ = templateDoc.Set("service_tier", p.ServiceTier)
}
typeResult := rootResult.Get("type")
dataType := typeResult.String()
if dataType == "response.created" {
- (*param).(*ConvertCliToOpenAIParams).ResponseID = rootResult.Get("response.id").String()
+ (*param).(*ConvertCliToOpenAIParams).ResponseID = strings.Clone(rootResult.Get("response.id").String())
(*param).(*ConvertCliToOpenAIParams).CreatedAt = rootResult.Get("response.created_at").Int()
- (*param).(*ConvertCliToOpenAIParams).Model = rootResult.Get("response.model").String()
+ (*param).(*ConvertCliToOpenAIParams).Model = strings.Clone(rootResult.Get("response.model").String())
if (*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID == nil {
(*param).(*ConvertCliToOpenAIParams).LastImageHashByItemID = make(map[string][32]byte)
}
@@ -165,52 +164,52 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
// Extract and set the model version.
cachedModel := (*param).(*ConvertCliToOpenAIParams).Model
- if modelResult := gjson.GetBytes(rawJSON, "model"); modelResult.Exists() {
- template, _ = sjson.SetBytes(template, "model", modelResult.String())
+ if modelResult := rootResult.Get("model"); modelResult.Exists() {
+ _ = templateDoc.Set("model", modelResult.String())
} else if cachedModel != "" {
- template, _ = sjson.SetBytes(template, "model", cachedModel)
+ _ = templateDoc.Set("model", cachedModel)
} else if modelName != "" {
- template, _ = sjson.SetBytes(template, "model", modelName)
+ _ = templateDoc.Set("model", modelName)
}
- template, _ = sjson.SetBytes(template, "created", (*param).(*ConvertCliToOpenAIParams).CreatedAt)
+ _ = templateDoc.Set("created", (*param).(*ConvertCliToOpenAIParams).CreatedAt)
// Extract and set the response ID.
- template, _ = sjson.SetBytes(template, "id", (*param).(*ConvertCliToOpenAIParams).ResponseID)
+ _ = templateDoc.Set("id", (*param).(*ConvertCliToOpenAIParams).ResponseID)
// Extract and set usage metadata (token counts).
- if usageResult := gjson.GetBytes(rawJSON, "response.usage"); usageResult.Exists() {
+ if usageResult := rootResult.Get("response.usage"); usageResult.Exists() {
if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens", outputTokensResult.Int())
+ _ = templateDoc.Set("usage.completion_tokens", outputTokensResult.Int())
}
if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokensResult.Int())
+ _ = templateDoc.Set("usage.total_tokens", totalTokensResult.Int())
}
if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens", inputTokensResult.Int())
+ _ = templateDoc.Set("usage.prompt_tokens", inputTokensResult.Int())
}
if cachedTokensResult := usageResult.Get("input_tokens_details.cached_tokens"); cachedTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int())
+ _ = templateDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int())
}
- template = setCodexCacheWriteTokens(template, usageResult)
+ setCodexCacheWriteTokensDoc(templateDoc, usageResult)
if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
+ _ = templateDoc.Set("usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
}
}
if dataType == "response.reasoning_summary_text.delta" || dataType == "response.reasoning_text.delta" {
if deltaResult := rootResult.Get("delta"); deltaResult.Exists() {
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", deltaResult.String())
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.Set("choices.0.delta.reasoning_content", deltaResult.String())
}
} else if dataType == "response.reasoning_summary_text.done" || dataType == "response.reasoning_text.done" {
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", "\n\n")
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.Set("choices.0.delta.reasoning_content", "\n\n")
} else if dataType == "response.output_text.delta" {
if deltaResult := rootResult.Get("delta"); deltaResult.Exists() {
delta := deltaResult.String()
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetBytes(template, "choices.0.delta.content", delta)
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.Set("choices.0.delta.content", delta)
p.emittedTextRunes += int64(utf8.RuneCountInString(delta))
}
} else if dataType == "response.output_text.annotation.added" || dataType == "response.output_text.done" || dataType == "response.content_part.done" {
@@ -218,8 +217,8 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if len(citations) == 0 {
return [][]byte{}
}
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.annotations", translatorcommon.JoinRawArray(citations))
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.annotations", translatorcommon.JoinRawArray(citations))
} else if dataType == "response.image_generation_call.partial_image" {
itemID := rootResult.Get("item_id").String()
b64 := rootResult.Get("partial_image_b64").String()
@@ -235,24 +234,19 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash {
return [][]byte{}
}
- p.LastImageHashByItemID[itemID] = hash
+ p.LastImageHashByItemID[strings.Clone(itemID)] = hash
}
outputFormat := rootResult.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
imageURL := "data:" + mimeType + ";base64," + b64
- imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
- if !imagesResult.Exists() || !imagesResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
- }
- imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
- imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
- imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
+ imagePayloadDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = imagePayloadDoc.Set("index", 0)
+ _ = imagePayloadDoc.Set("image_url.url", imageURL)
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.images", translatorcommon.JoinRawArray([][]byte{imagePayloadDoc.Bytes()}))
} else if dataType == "response.completed" || dataType == "response.incomplete" {
finishReason := "stop"
nativeFinishReason := finishReason
@@ -268,8 +262,8 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
finishReason = "tool_calls"
nativeFinishReason = finishReason
}
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
- template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
+ _ = templateDoc.Set("choices.0.finish_reason", finishReason)
+ _ = templateDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
} else if dataType == "response.output_item.added" {
itemResult := rootResult.Get("item")
if !itemResult.Exists() || !isCodexToolCallType(itemResult.Get("type").String()) {
@@ -282,22 +276,21 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
state := &toolCallStreamState{Index: p.FunctionCallIndex, Patch: p.toolMetadata.isCustomPatch(originalRequestRawJSON, itemResult)}
registerToolCallState(p, rootResult, itemResult, state)
- functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
+ functionCallItemDoc := ojson.NewDocString(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ _ = functionCallItemDoc.Set("index", state.Index)
+ _ = functionCallItemDoc.Set("id", itemResult.Get("call_id").String())
// Restore original tool name if it was shortened.
name := p.toolMetadata.restoreToolName(originalRequestRawJSON, itemResult.Get("name").String())
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", "")
+ _ = functionCallItemDoc.Set("function.name", name)
+ _ = functionCallItemDoc.Set("function.arguments", "")
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls", translatorcommon.JoinRawArray([][]byte{functionCallItemDoc.Bytes()}))
} else if dataType == "response.function_call_arguments.delta" || dataType == "response.custom_tool_call_input.delta" {
p := (*param).(*ConvertCliToOpenAIParams)
- state := findToolCallState(p, rootResult, gjson.Result{})
+ state := findToolCallState(p, rootResult, ojson.Result{})
deltaValue := rootResult.Get("delta").String()
if state == nil || state.Done || deltaValue == "" {
return [][]byte{}
@@ -311,16 +304,15 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
}
}
- functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", deltaValue)
+ functionCallItemDoc := ojson.NewDocString(`{"index":0,"function":{"arguments":""}}`)
+ _ = functionCallItemDoc.Set("index", state.Index)
+ _ = functionCallItemDoc.Set("function.arguments", deltaValue)
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls", translatorcommon.JoinRawArray([][]byte{functionCallItemDoc.Bytes()}))
} else if dataType == "response.function_call_arguments.done" || dataType == "response.custom_tool_call_input.done" {
p := (*param).(*ConvertCliToOpenAIParams)
- state := findToolCallState(p, rootResult, gjson.Result{})
+ state := findToolCallState(p, rootResult, ojson.Result{})
if state == nil || state.Done || state.InputClosed || (state.ArgumentsEmitted && !state.Patch) {
return [][]byte{}
}
@@ -338,12 +330,11 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if fullArgs == "" {
return [][]byte{}
}
- functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs)
+ functionCallItemDoc := ojson.NewDocString(`{"index":0,"function":{"arguments":""}}`)
+ _ = functionCallItemDoc.Set("index", state.Index)
+ _ = functionCallItemDoc.Set("function.arguments", fullArgs)
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls", translatorcommon.JoinRawArray([][]byte{functionCallItemDoc.Bytes()}))
} else if dataType == "response.output_item.done" {
itemResult := rootResult.Get("item")
@@ -355,9 +346,9 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if len(citations) == 0 {
return [][]byte{}
}
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.annotations", translatorcommon.JoinRawArray(citations))
- return [][]byte{template}
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.annotations", translatorcommon.JoinRawArray(citations))
+ return [][]byte{templateDoc.Bytes()}
}
itemType := itemResult.Get("type").String()
if itemType == "image_generation_call" {
@@ -375,25 +366,20 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if last, ok := p.LastImageHashByItemID[itemID]; ok && last == hash {
return [][]byte{}
}
- p.LastImageHashByItemID[itemID] = hash
+ p.LastImageHashByItemID[strings.Clone(itemID)] = hash
}
outputFormat := itemResult.Get("output_format").String()
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
imageURL := "data:" + mimeType + ";base64," + b64
- imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
- if !imagesResult.Exists() || !imagesResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
- }
- imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
- imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
- imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
+ imagePayloadDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = imagePayloadDoc.Set("index", 0)
+ _ = imagePayloadDoc.Set("image_url.url", imageURL)
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
- return [][]byte{template}
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.images", translatorcommon.JoinRawArray([][]byte{imagePayloadDoc.Bytes()}))
+ return [][]byte{templateDoc.Bytes()}
}
if !isCodexToolCallType(itemType) {
return [][]byte{}
@@ -420,12 +406,11 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
if fullArgs == "" {
return [][]byte{}
}
- functionCallItemTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs)
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
- return [][]byte{template}
+ functionCallItemDoc := ojson.NewDocString(`{"index":0,"function":{"arguments":""}}`)
+ _ = functionCallItemDoc.Set("index", state.Index)
+ _ = functionCallItemDoc.Set("function.arguments", fullArgs)
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls", translatorcommon.JoinRawArray([][]byte{functionCallItemDoc.Bytes()}))
+ return [][]byte{templateDoc.Bytes()}
}
// Fallback path: model skipped output_item.added, so emit the complete tool call now.
@@ -433,29 +418,27 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
state = &toolCallStreamState{Index: p.FunctionCallIndex, ArgumentsEmitted: true, Done: true, Patch: p.toolMetadata.isCustomPatch(originalRequestRawJSON, itemResult)}
registerToolCallState(p, rootResult, itemResult, state)
- functionCallItemTemplate := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "index", state.Index)
-
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", itemResult.Get("call_id").String())
+ functionCallItemDoc := ojson.NewDocString(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ _ = functionCallItemDoc.Set("index", state.Index)
+ _ = functionCallItemDoc.Set("id", itemResult.Get("call_id").String())
// Restore original tool name if it was shortened.
name := p.toolMetadata.restoreToolName(originalRequestRawJSON, itemResult.Get("name").String())
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", name)
+ _ = functionCallItemDoc.Set("function.name", name)
fullArgs := codexToolCallArguments(itemResult)
if state.Patch {
fullArgs = finishPatchChatArguments(state, fullArgs)
}
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fullArgs)
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
+ _ = functionCallItemDoc.Set("function.arguments", fullArgs)
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls", translatorcommon.JoinRawArray([][]byte{functionCallItemDoc.Bytes()}))
} else {
return [][]byte{}
}
- return [][]byte{template}
+ return [][]byte{templateDoc.Bytes()}
}
// ConvertCodexResponseToOpenAINonStream converts a non-streaming Codex response to a non-streaming OpenAI response.
@@ -472,7 +455,7 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
// Returns:
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
- rootResult := gjson.ParseBytes(rawJSON)
+ rootResult := util.ParseGJSONBytesNoCopy(rawJSON)
// Verify this is a terminal response event.
responseType := rootResult.Get("type").String()
if responseType != "response.completed" && responseType != "response.incomplete" {
@@ -483,48 +466,48 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
responseResult := rootResult.Get("response")
- template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
+ templateDoc := ojson.NewDocString(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
if tier := codexResponseServiceTier(responseResult); tier != "" {
- template, _ = sjson.SetBytes(template, "service_tier", tier)
+ _ = templateDoc.Set("service_tier", tier)
} else if tier := codexResponseServiceTier(rootResult); tier != "" {
- template, _ = sjson.SetBytes(template, "service_tier", tier)
+ _ = templateDoc.Set("service_tier", tier)
}
// Extract and set the model version.
if modelResult := responseResult.Get("model"); modelResult.Exists() {
- template, _ = sjson.SetBytes(template, "model", modelResult.String())
+ _ = templateDoc.Set("model", modelResult.String())
}
// Extract and set the creation timestamp.
if createdAtResult := responseResult.Get("created_at"); createdAtResult.Exists() {
- template, _ = sjson.SetBytes(template, "created", createdAtResult.Int())
+ _ = templateDoc.Set("created", createdAtResult.Int())
} else {
- template, _ = sjson.SetBytes(template, "created", unixTimestamp)
+ _ = templateDoc.Set("created", unixTimestamp)
}
// Extract and set the response ID.
if idResult := responseResult.Get("id"); idResult.Exists() {
- template, _ = sjson.SetBytes(template, "id", idResult.String())
+ _ = templateDoc.Set("id", idResult.String())
}
// Extract and set usage metadata (token counts).
if usageResult := responseResult.Get("usage"); usageResult.Exists() {
if outputTokensResult := usageResult.Get("output_tokens"); outputTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens", outputTokensResult.Int())
+ _ = templateDoc.Set("usage.completion_tokens", outputTokensResult.Int())
}
if totalTokensResult := usageResult.Get("total_tokens"); totalTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokensResult.Int())
+ _ = templateDoc.Set("usage.total_tokens", totalTokensResult.Int())
}
if inputTokensResult := usageResult.Get("input_tokens"); inputTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens", inputTokensResult.Int())
+ _ = templateDoc.Set("usage.prompt_tokens", inputTokensResult.Int())
}
if cachedTokensResult := usageResult.Get("input_tokens_details.cached_tokens"); cachedTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int())
+ _ = templateDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokensResult.Int())
}
- template = setCodexCacheWriteTokens(template, usageResult)
+ setCodexCacheWriteTokensDoc(templateDoc, usageResult)
if reasoningTokensResult := usageResult.Get("output_tokens_details.reasoning_tokens"); reasoningTokensResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
+ _ = templateDoc.Set("usage.completion_tokens_details.reasoning_tokens", reasoningTokensResult.Int())
}
}
@@ -588,24 +571,24 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
}
case "function_call", "custom_tool_call":
// Handle function and custom tool call content.
- functionCallTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ functionCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
if callIdResult := outputItem.Get("call_id"); callIdResult.Exists() {
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", callIdResult.String())
+ _ = functionCallDoc.Set("id", callIdResult.String())
}
if nameResult := outputItem.Get("name"); nameResult.Exists() {
n := toolMetadata.restoreToolName(originalRequestRawJSON, nameResult.String())
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", n)
+ _ = functionCallDoc.Set("function.name", n)
}
fullArgs := codexToolCallArguments(outputItem)
if toolMetadata.isCustomPatch(originalRequestRawJSON, outputItem) {
fullArgs = applypatch.WrapInput(fullArgs)
}
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fullArgs)
+ _ = functionCallDoc.Set("function.arguments", fullArgs)
- toolCalls = append(toolCalls, functionCallTemplate)
+ toolCalls = append(toolCalls, functionCallDoc.Bytes())
case "image_generation_call":
b64 := outputItem.Get("result").String()
if b64 == "" {
@@ -615,34 +598,34 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
mimeType := mimeTypeFromCodexOutputFormat(outputFormat)
imageURL := "data:" + mimeType + ";base64," + b64
- imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
- imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
- images = append(images, imagePayload)
+ imagePayloadDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = imagePayloadDoc.Set("index", len(images))
+ _ = imagePayloadDoc.Set("image_url.url", imageURL)
+ images = append(images, imagePayloadDoc.Bytes())
}
}
// Set content and reasoning content if found
if contentText != "" {
- template, _ = sjson.SetBytes(template, "choices.0.message.content", contentText)
+ _ = templateDoc.Set("choices.0.message.content", contentText)
}
if reasoningText != "" {
- template, _ = sjson.SetBytes(template, "choices.0.message.reasoning_content", reasoningText)
+ _ = templateDoc.Set("choices.0.message.reasoning_content", reasoningText)
}
if len(messageAnnotations) > 0 {
- template, _ = sjson.SetRawBytes(template, "choices.0.message.annotations", translatorcommon.JoinRawArray(messageAnnotations))
+ _ = templateDoc.SetRawBytes("choices.0.message.annotations", translatorcommon.JoinRawArray(messageAnnotations))
}
// Add tool calls if any
if len(toolCalls) > 0 {
- template, _ = sjson.SetRawBytes(template, "choices.0.message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
+ _ = templateDoc.SetRawBytes("choices.0.message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
}
// Add images if any
if len(images) > 0 {
- template, _ = sjson.SetRawBytes(template, "choices.0.message.images", translatorcommon.JoinRawArray(images))
+ _ = templateDoc.SetRawBytes("choices.0.message.images", translatorcommon.JoinRawArray(images))
}
}
@@ -671,31 +654,31 @@ func ConvertCodexResponseToOpenAINonStream(_ context.Context, _ string, original
}
}
if finishReason != "" {
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
- template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
+ _ = templateDoc.Set("choices.0.finish_reason", finishReason)
+ _ = templateDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
}
}
- return template
+ return templateDoc.Bytes()
}
-func codexAnnotationResults(value gjson.Result) []gjson.Result {
+func codexAnnotationResults(value ojson.Result) []ojson.Result {
if !value.Exists() {
return nil
}
if value.IsArray() {
- results := make([]gjson.Result, 0, len(value.Array()))
- value.ForEach(func(_, item gjson.Result) bool {
+ results := make([]ojson.Result, 0, len(value.Array()))
+ value.ForEach(func(_, item ojson.Result) bool {
results = append(results, item)
return true
})
return results
}
- return []gjson.Result{value}
+ return []ojson.Result{value}
}
-func codexAnnotationsFromEvent(event gjson.Result) []gjson.Result {
- annotations := make([]gjson.Result, 0)
+func codexAnnotationsFromEvent(event ojson.Result) []ojson.Result {
+ annotations := make([]ojson.Result, 0)
for _, path := range []string{"annotation", "annotations", "part.annotations"} {
annotations = append(annotations, codexAnnotationResults(event.Get(path))...)
}
@@ -711,7 +694,7 @@ func codexAnnotationsFromEvent(event gjson.Result) []gjson.Result {
return annotations
}
-func buildCodexURLCitations(annotations []gjson.Result, runeOffset int64, seen map[string]struct{}) [][]byte {
+func buildCodexURLCitations(annotations []ojson.Result, runeOffset int64, seen map[string]struct{}) [][]byte {
citations := make([][]byte, 0, len(annotations))
for _, annotation := range annotations {
if annotation.Get("type").String() != "url_citation" {
@@ -747,37 +730,37 @@ func buildCodexURLCitations(annotations []gjson.Result, runeOffset int64, seen m
continue
}
for _, key := range keys {
- seen[key] = struct{}{}
+ seen[strings.Clone(key)] = struct{}{}
}
}
- citation := []byte(`{"type":"url_citation","url":"","title":"","start_index":0,"end_index":0}`)
- citation, _ = sjson.SetBytes(citation, "url", url)
- citation, _ = sjson.SetBytes(citation, "title", annotation.Get("title").String())
- citation, _ = sjson.SetBytes(citation, "start_index", startIndex)
- citation, _ = sjson.SetBytes(citation, "end_index", endIndex)
- citations = append(citations, citation)
+ citationDoc := ojson.NewDocString(`{"type":"url_citation","url":"","title":"","start_index":0,"end_index":0}`)
+ _ = citationDoc.Set("url", url)
+ _ = citationDoc.Set("title", annotation.Get("title").String())
+ _ = citationDoc.Set("start_index", startIndex)
+ _ = citationDoc.Set("end_index", endIndex)
+ citations = append(citations, citationDoc.Bytes())
}
return citations
}
-func registerToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult gjson.Result, state *toolCallStreamState) {
+func registerToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult ojson.Result, state *toolCallStreamState) {
if p.toolCallStates == nil {
p.toolCallStates = make(map[string]*toolCallStreamState)
}
if itemID := eventResult.Get("item_id").String(); itemID != "" {
- p.toolCallStates["item:"+itemID] = state
+ p.toolCallStates[strings.Clone("item:"+itemID)] = state
}
if itemID := itemResult.Get("id").String(); itemID != "" {
- p.toolCallStates["item:"+itemID] = state
+ p.toolCallStates[strings.Clone("item:"+itemID)] = state
}
if outputIndex := eventResult.Get("output_index"); outputIndex.Exists() {
- p.toolCallStates["output:"+outputIndex.Raw] = state
+ p.toolCallStates[strings.Clone("output:"+outputIndex.Raw)] = state
}
p.currentToolCall = state
}
-func findToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult gjson.Result) *toolCallStreamState {
+func findToolCallState(p *ConvertCliToOpenAIParams, eventResult, itemResult ojson.Result) *toolCallStreamState {
if itemID := eventResult.Get("item_id").String(); itemID != "" {
if state := p.toolCallStates["item:"+itemID]; state != nil {
return state
@@ -800,7 +783,7 @@ func isCodexToolCallType(itemType string) bool {
return itemType == "function_call" || itemType == "custom_tool_call"
}
-func codexToolCallArguments(itemResult gjson.Result) string {
+func codexToolCallArguments(itemResult ojson.Result) string {
if itemResult.Get("type").String() == "custom_tool_call" {
return itemResult.Get("input").String()
}
@@ -843,21 +826,21 @@ func mimeTypeFromCodexOutputFormat(outputFormat string) string {
}
// codexResponseServiceTier returns only an actual nonempty upstream tier.
-func codexResponseServiceTier(response gjson.Result) string {
+func codexResponseServiceTier(response ojson.Result) string {
tier := response.Get("service_tier")
- if tier.Type != gjson.String || strings.TrimSpace(tier.Str) == "" {
+ if tier.Type != ojson.String || strings.TrimSpace(tier.Str) == "" {
return ""
}
return strings.TrimSpace(tier.Str)
}
-// setCodexCacheWriteTokens preserves the upstream integer without float conversion.
-func setCodexCacheWriteTokens(template []byte, usage gjson.Result) []byte {
+// setCodexCacheWriteTokensDoc preserves the upstream integer without float conversion.
+func setCodexCacheWriteTokensDoc(doc *ojson.Doc, usage ojson.Result) {
value := usage.Get("input_tokens_details.cache_write_tokens")
- if !value.Exists() || value.Type == gjson.Null {
- return template
+ if !value.Exists() || value.Type == ojson.Null {
+ return
}
- valid := value.Type == gjson.Number && value.Raw != ""
+ valid := value.Type == ojson.Number && value.Raw != ""
for _, digit := range value.Raw {
if digit < '0' || digit > '9' {
valid = false
@@ -866,26 +849,25 @@ func setCodexCacheWriteTokens(template []byte, usage gjson.Result) []byte {
}
if !valid {
log.WithField("field", "usage.input_tokens_details.cache_write_tokens").Warn("Ignoring invalid Codex cache write token count")
- return template
+ return
}
- template, _ = sjson.SetRawBytes(template, "usage.prompt_tokens_details.cache_write_tokens", []byte(value.Raw))
- template, _ = sjson.SetRawBytes(template, "usage.prompt_tokens_details.cached_creation_tokens", []byte(value.Raw))
- return template
+ _ = doc.SetRawBytes("usage.prompt_tokens_details.cache_write_tokens", []byte(value.Raw))
+ _ = doc.SetRawBytes("usage.prompt_tokens_details.cached_creation_tokens", []byte(value.Raw))
}
// isOriginalCustomPatch never promotes an ordinary same-name function to custom.
-func isOriginalCustomPatch(original []byte, item gjson.Result) bool {
+func isOriginalCustomPatch(original []byte, item ojson.Result) bool {
if item.Get("type").String() != "custom_tool_call" {
return false
}
name := util.QualifyResponsesNamespaceToolName(item.Get("namespace").String(), item.Get("name").String())
// Chat Completions prefers ordinary functions for ambiguous names, regardless of order.
- for _, tool := range gjson.GetBytes(original, "tools").Array() {
+ for _, tool := range ojson.GetBytesNoCopy(original, "tools").Array() {
if tool.Get("type").String() == "function" && tool.Get("function.name").String() == name {
return false
}
}
- winner, ok := util.CollectResponsesToolWinners(gjson.ParseBytes(original))[name]
+ winner, ok := util.CollectResponsesToolWinners(util.ParseGJSONBytesNoCopy(original))[name]
return ok && applypatch.IsCustomTool(winner.Tool)
}
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
index 18e35fe5777..47166e356d7 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertCodexResponseToOpenAI_IncompleteTerminal(t *testing.T) {
@@ -18,22 +18,22 @@ func TestConvertCodexResponseToOpenAI_IncompleteTerminal(t *testing.T) {
if len(streamOut) != 1 {
t.Fatalf("expected 1 streaming terminal chunk, got %d", len(streamOut))
}
- if got := gjson.GetBytes(streamOut[0], "choices.0.finish_reason").String(); got != "length" {
+ if got := ojson.GetBytesNoCopy(streamOut[0], "choices.0.finish_reason").String(); got != "length" {
t.Fatalf("stream finish_reason = %q, want length; payload=%s", got, streamOut[0])
}
- if got := gjson.GetBytes(streamOut[0], "choices.0.native_finish_reason").String(); got != "max_output_tokens" {
+ if got := ojson.GetBytesNoCopy(streamOut[0], "choices.0.native_finish_reason").String(); got != "max_output_tokens" {
t.Fatalf("stream native_finish_reason = %q, want max_output_tokens; payload=%s", got, streamOut[0])
}
var toolParam any
_ = ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, []byte(`data: {"type":"response.output_item.added","item":{"type":"function_call","call_id":"call_1","name":"lookup"}}`), &toolParam)
toolStreamOut := ConvertCodexResponseToOpenAI(ctx, "gpt-5.5", nil, nil, append([]byte("data: "), terminal...), &toolParam)
- if got := gjson.GetBytes(toolStreamOut[0], "choices.0.finish_reason").String(); got != "length" {
+ if got := ojson.GetBytesNoCopy(toolStreamOut[0], "choices.0.finish_reason").String(); got != "length" {
t.Fatalf("tool stream finish_reason = %q, want length; payload=%s", got, toolStreamOut[0])
}
nonStreamOut := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, terminal, nil)
- if got := gjson.GetBytes(nonStreamOut, "choices.0.finish_reason").String(); got != "length" {
+ if got := ojson.GetBytesNoCopy(nonStreamOut, "choices.0.finish_reason").String(); got != "length" {
t.Fatalf("non-stream finish_reason = %q, want length; payload=%s", got, nonStreamOut)
}
}
@@ -54,7 +54,7 @@ func TestConvertCodexResponseToOpenAI_StreamSetsModelFromResponseCreated(t *test
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- gotModel := gjson.GetBytes(out[0], "model").String()
+ gotModel := ojson.GetBytesNoCopy(out[0], "model").String()
if gotModel != modelName {
t.Fatalf("expected model %q, got %q", modelName, gotModel)
}
@@ -71,7 +71,7 @@ func TestConvertCodexResponseToOpenAI_FirstChunkUsesRequestModelName(t *testing.
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- gotModel := gjson.GetBytes(out[0], "model").String()
+ gotModel := ojson.GetBytesNoCopy(out[0], "model").String()
if gotModel != modelName {
t.Fatalf("expected model %q, got %q", modelName, gotModel)
}
@@ -82,14 +82,14 @@ func TestConvertCodexResponseToOpenAI_PreservesURLCitations(t *testing.T) {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_citation","model":"gpt-5.5","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"前🙂"},{"type":"output_text","text":"引用","annotations":[{"type":"url_citation","url":"https://example.com","title":"Example","start_index":0,"end_index":2},{"type":"file_citation","file_id":"file_1","index":0}]}]}]}}`)
out := ConvertCodexResponseToOpenAINonStream(t.Context(), "gpt-5.5", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "choices.0.message.content").String(); got != "前🙂引用" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.content").String(); got != "前🙂引用" {
t.Fatalf("content = %q, want %q; response=%s", got, "前🙂引用", out)
}
- annotation := gjson.GetBytes(out, "choices.0.message.annotations.0")
+ annotation := ojson.GetBytesNoCopy(out, "choices.0.message.annotations.0")
if !annotation.Exists() {
t.Fatalf("expected message annotation, response=%s", out)
}
- if got := gjson.GetBytes(out, "choices.0.message.annotations.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.annotations.#").Int(); got != 1 {
t.Fatalf("annotation count = %d, want 1 after filtering unsupported types; response=%s", got, out)
}
if got := annotation.Get("type").String(); got != "url_citation" {
@@ -120,7 +120,7 @@ func TestConvertCodexResponseToOpenAI_PreservesURLCitations(t *testing.T) {
if len(out) != 1 {
t.Fatalf("expected citation chunk, got %d", len(out))
}
- annotation := gjson.GetBytes(out[0], "choices.0.delta.annotations.0")
+ annotation := ojson.GetBytesNoCopy(out[0], "choices.0.delta.annotations.0")
if !annotation.Exists() {
t.Fatalf("expected delta annotation, chunk=%s", out[0])
}
@@ -156,7 +156,7 @@ func TestConvertCodexResponseToOpenAI_PreservesURLCitations(t *testing.T) {
if len(out) != 1 {
t.Fatalf("expected citation completion chunk, got %d", len(out))
}
- annotation := gjson.GetBytes(out[0], "choices.0.delta.annotations.0")
+ annotation := ojson.GetBytesNoCopy(out[0], "choices.0.delta.annotations.0")
if got := annotation.Get("start_index").Int(); got != 4 {
t.Fatalf("annotation start_index = %d, want 4; chunk=%s", got, out[0])
}
@@ -166,10 +166,10 @@ func TestConvertCodexResponseToOpenAI_PreservesURLCitations(t *testing.T) {
contentPartDoneEvent := []byte(`data: {"type":"response.content_part.done","part":{"type":"output_text","text":"前🙂引用","annotations":[{"type":"url_citation","url":"https://other.example","title":"Other","start_index":0,"end_index":1}]}}`)
out = ConvertCodexResponseToOpenAI(t.Context(), "gpt-5.5", nil, nil, contentPartDoneEvent, ¶m)
- if len(out) != 1 || gjson.GetBytes(out[0], "choices.0.delta.annotations.0.url").String() != "https://other.example" {
+ if len(out) != 1 || ojson.GetBytesNoCopy(out[0], "choices.0.delta.annotations.0.url").String() != "https://other.example" {
t.Fatalf("expected content-part citation chunk, got %d: %s", len(out), out)
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.annotations.0.start_index").Int(); got != 4 {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.annotations.0.start_index").Int(); got != 4 {
t.Fatalf("content-part annotation start_index = %d, want 4; chunk=%s", got, out[0])
}
@@ -189,13 +189,13 @@ func TestConvertCodexResponseToOpenAI_ToolCallChunkOmitsNullContentFields(t *tes
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
+ if ojson.GetBytesNoCopy(out[0], "choices.0.delta.content").Exists() {
t.Fatalf("expected content to be omitted, got %s", string(out[0]))
}
- if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
+ if ojson.GetBytesNoCopy(out[0], "choices.0.delta.reasoning_content").Exists() {
t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
}
- if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls").Exists() {
+ if !ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls").Exists() {
t.Fatalf("expected tool_calls to exist, got %s", string(out[0]))
}
}
@@ -214,13 +214,13 @@ func TestConvertCodexResponseToOpenAI_ToolCallArgumentsDeltaOmitsNullContentFiel
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if gjson.GetBytes(out[0], "choices.0.delta.content").Exists() {
+ if ojson.GetBytesNoCopy(out[0], "choices.0.delta.content").Exists() {
t.Fatalf("expected content to be omitted, got %s", string(out[0]))
}
- if gjson.GetBytes(out[0], "choices.0.delta.reasoning_content").Exists() {
+ if ojson.GetBytesNoCopy(out[0], "choices.0.delta.reasoning_content").Exists() {
t.Fatalf("expected reasoning_content to be omitted, got %s", string(out[0]))
}
- if !gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").Exists() {
+ if !ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").Exists() {
t.Fatalf("expected tool call arguments delta to exist, got %s", string(out[0]))
}
}
@@ -236,7 +236,7 @@ func TestConvertCodexResponseToOpenAI_CustomToolCallStreamDeltas(t *testing.T) {
if len(out) != 1 {
t.Fatalf("expected 1 announcement chunk, got %d", len(out))
}
- toolCall := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0")
+ toolCall := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0")
if got := toolCall.Get("index").Int(); got != 0 {
t.Fatalf("expected tool index 0, got %d; chunk=%s", got, out[0])
}
@@ -255,7 +255,7 @@ func TestConvertCodexResponseToOpenAI_CustomToolCallStreamDeltas(t *testing.T) {
if len(out) != 1 {
t.Fatalf("expected 1 arguments delta chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != delta {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != delta {
t.Fatalf("expected arguments delta %q, got %q; chunk=%s", delta, got, out[0])
}
}
@@ -274,7 +274,7 @@ func TestConvertCodexResponseToOpenAI_CustomToolCallStreamDeltas(t *testing.T) {
if len(out) != 1 {
t.Fatalf("expected 1 completion chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.finish_reason").String(); got != "tool_calls" {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("expected finish reason tool_calls, got %q; chunk=%s", got, out[0])
}
}
@@ -293,7 +293,7 @@ func TestConvertCodexResponseToOpenAI_EmptyCustomToolDeltaUsesDoneFallback(t *te
if len(out) != 1 {
t.Fatalf("expected 1 done fallback chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "full patch" {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "full patch" {
t.Fatalf("expected full patch arguments, got %q; chunk=%s", got, out[0])
}
}
@@ -306,16 +306,16 @@ func TestConvertCodexResponseToOpenAI_InterleavedToolCallsKeepStateByItem(t *tes
}
out := send(`{"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_lookup","name":"lookup","arguments":""}}`)
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
t.Fatalf("expected function call index 0, got %d; chunk=%s", got, out[0])
}
out = send(`{"type":"response.output_item.added","output_index":1,"item":{"id":"ctc_2","type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":""}}`)
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
t.Fatalf("expected custom call index 1, got %d; chunk=%s", got, out[0])
}
out = send(`{"type":"response.function_call_arguments.delta","item_id":"fc_1","output_index":0,"delta":"{\"query\":"}`)
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
t.Fatalf("expected interleaved function delta index 0, got %d; chunk=%s", got, out[0])
}
out = send(`{"type":"response.custom_tool_call_input.delta","output_index":1,"delta":""}`)
@@ -326,10 +326,10 @@ func TestConvertCodexResponseToOpenAI_InterleavedToolCallsKeepStateByItem(t *tes
if len(out) != 1 {
t.Fatalf("expected custom done fallback, got %d chunks", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
t.Fatalf("expected output-index-routed custom fallback index 1, got %d; chunk=%s", got, out[0])
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "patch" {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "patch" {
t.Fatalf("expected custom fallback arguments patch, got %q; chunk=%s", got, out[0])
}
@@ -353,7 +353,7 @@ func TestConvertCodexResponseToOpenAI_CustomToolCallInputDoneFallback(t *testing
if len(out) != 1 {
t.Fatalf("expected 1 fallback arguments chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "full patch" {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "full patch" {
t.Fatalf("expected full patch arguments, got %q; chunk=%s", got, out[0])
}
@@ -373,7 +373,7 @@ func TestConvertCodexResponseToOpenAI_ToolCallOutputItemDoneFallbacks(t *testing
if len(out) != 1 {
t.Fatalf("expected 1 fallback arguments chunk, got %d", len(out))
}
- toolCall := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0")
+ toolCall := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0")
if got := toolCall.Get("index").Int(); got != 0 {
t.Fatalf("expected tool index 0, got %d; chunk=%s", got, out[0])
}
@@ -389,7 +389,7 @@ func TestConvertCodexResponseToOpenAI_ToolCallOutputItemDoneFallbacks(t *testing
if len(out) != 1 {
t.Fatalf("expected 1 second fallback arguments chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
t.Fatalf("expected second tool index 1, got %d; chunk=%s", got, out[0])
}
})
@@ -401,7 +401,7 @@ func TestConvertCodexResponseToOpenAI_ToolCallOutputItemDoneFallbacks(t *testing
if len(out) != 1 {
t.Fatalf("expected 1 complete fallback chunk, got %d", len(out))
}
- toolCall := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0")
+ toolCall := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0")
if got := toolCall.Get("id").String(); got != "call_apply" {
t.Fatalf("expected call id call_apply, got %q; chunk=%s", got, out[0])
}
@@ -422,7 +422,7 @@ func TestConvertCodexResponseToOpenAI_ToolCallOutputItemDoneFallbacks(t *testing
if len(out) != 1 {
t.Fatalf("expected 1 function arguments fallback chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"query":"test"}` {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"query":"test"}` {
t.Fatalf("expected function arguments fallback, got %q; chunk=%s", got, out[0])
}
})
@@ -456,13 +456,13 @@ func TestConvertCodexResponseToOpenAI_ToolCallStateFallsBackFromUnknownItemID(t
t.Fatalf("done chunks = %d, want 1", len(done))
}
- addedName := gjson.GetBytes(added[0], "choices.0.delta.tool_calls.0.function.name").String()
- doneName := gjson.GetBytes(done[0], "choices.0.delta.tool_calls.0.function.name").String()
+ addedName := ojson.GetBytesNoCopy(added[0], "choices.0.delta.tool_calls.0.function.name").String()
+ doneName := ojson.GetBytesNoCopy(done[0], "choices.0.delta.tool_calls.0.function.name").String()
if got := addedName + doneName; got != "TaskCreate" {
t.Fatalf("assembled tool name = %q, want %q", got, "TaskCreate")
}
- toolCall := gjson.GetBytes(done[0], "choices.0.delta.tool_calls.0")
+ toolCall := ojson.GetBytesNoCopy(done[0], "choices.0.delta.tool_calls.0")
if toolCall.Get("id").Exists() || toolCall.Get("function.name").Exists() {
t.Fatalf("done chunk repeated tool identity: %s", toolCall.Raw)
}
@@ -479,7 +479,7 @@ func TestConvertCodexResponseToOpenAINonStream_CustomToolCall(t *testing.T) {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.5","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"custom_tool_call","call_id":"call_apply","name":"ApplyPatch","input":"full patch"}]}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, raw, nil)
- toolCall := gjson.GetBytes(out, "choices.0.message.tool_calls.0")
+ toolCall := ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0")
if got := toolCall.Get("id").String(); got != "call_apply" {
t.Fatalf("expected call id call_apply, got %q; response=%s", got, out)
}
@@ -489,7 +489,7 @@ func TestConvertCodexResponseToOpenAINonStream_CustomToolCall(t *testing.T) {
if got := toolCall.Get("function.arguments").String(); got != "full patch" {
t.Fatalf("expected full patch arguments, got %q; response=%s", got, out)
}
- if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("expected finish reason tool_calls, got %q; response=%s", got, out)
}
}
@@ -505,7 +505,7 @@ func TestConvertCodexResponseToOpenAI_StreamPartialImageEmitsDeltaImages(t *test
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String()
+ gotURL := ojson.GetBytesNoCopy(out[0], "choices.0.delta.images.0.image_url.url").String()
if gotURL != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out[0]))
}
@@ -535,7 +535,7 @@ func TestConvertCodexResponseToOpenAI_StreamImageGenerationCallDoneEmitsDeltaIma
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- gotURL := gjson.GetBytes(out[0], "choices.0.delta.images.0.image_url.url").String()
+ gotURL := ojson.GetBytesNoCopy(out[0], "choices.0.delta.images.0.image_url.url").String()
if gotURL != "data:image/jpeg;base64,Ymll" {
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/jpeg;base64,Ymll", gotURL, string(out[0]))
}
@@ -547,7 +547,7 @@ func TestConvertCodexResponseToOpenAI_NonStreamImageGenerationCallAddsMessageIma
raw := []byte(`{"type":"response.completed","response":{"id":"resp_123","created_at":1700000000,"model":"gpt-5.4","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]},{"type":"image_generation_call","output_format":"png","result":"aGVsbG8="}]}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.4", nil, nil, raw, nil)
- gotURL := gjson.GetBytes(out, "choices.0.message.images.0.image_url.url").String()
+ gotURL := ojson.GetBytesNoCopy(out, "choices.0.message.images.0.image_url.url").String()
if gotURL != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("expected image url %q, got %q; chunk=%s", "data:image/png;base64,aGVsbG8=", gotURL, string(out))
}
@@ -632,24 +632,24 @@ func mustJSONMarshal(t *testing.T, value any) []byte {
func assertUsageMapping(t *testing.T, payload []byte, wantCachedCreation int64, expectCachedCreation bool) {
t.Helper()
- if got := gjson.GetBytes(payload, "usage.prompt_tokens").Int(); got != 100 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.prompt_tokens").Int(); got != 100 {
t.Fatalf("expected prompt_tokens=100, got %d; payload=%s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "usage.completion_tokens").Int(); got != 20 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.completion_tokens").Int(); got != 20 {
t.Fatalf("expected completion_tokens=20, got %d; payload=%s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "usage.total_tokens").Int(); got != 120 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.total_tokens").Int(); got != 120 {
t.Fatalf("expected total_tokens=120, got %d; payload=%s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_tokens").Int(); got != 30 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.prompt_tokens_details.cached_tokens").Int(); got != 30 {
t.Fatalf("expected cached_tokens=30, got %d; payload=%s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 5 {
t.Fatalf("expected reasoning_tokens=5, got %d; payload=%s", got, string(payload))
}
- gotCachedCreation := gjson.GetBytes(payload, "usage.prompt_tokens_details.cached_creation_tokens")
- gotCacheWrite := gjson.GetBytes(payload, "usage.prompt_tokens_details.cache_write_tokens")
+ gotCachedCreation := ojson.GetBytesNoCopy(payload, "usage.prompt_tokens_details.cached_creation_tokens")
+ gotCacheWrite := ojson.GetBytesNoCopy(payload, "usage.prompt_tokens_details.cache_write_tokens")
if expectCachedCreation {
if !gotCachedCreation.Exists() {
t.Fatalf("expected cached_creation_tokens to exist, payload=%s", string(payload))
@@ -683,8 +683,8 @@ func TestConvertCodexResponseToOpenAI_NonStreamMultiMessageEmptyTrailingKeepsCon
`]}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.5", nil, nil, raw, nil)
- got := gjson.GetBytes(out, "choices.0.message.content")
- if !got.Exists() || got.Type == gjson.Null {
+ got := ojson.GetBytesNoCopy(out, "choices.0.message.content")
+ if !got.Exists() || got.Type == ojson.Null {
t.Fatalf("content was dropped to null by trailing empty message; resp=%s", string(out))
}
if got.String() != "the real answer" {
@@ -701,10 +701,10 @@ func TestConvertCodexResponseToOpenAI_StreamReasoningTextDeltaAndDone(t *testing
if len(streamOut) != 1 {
t.Fatalf("expected 1 streaming chunk for reasoning_text.delta, got %d", len(streamOut))
}
- if got := gjson.GetBytes(streamOut[0], "choices.0.delta.reasoning_content").String(); got != "Thinking step 1" {
+ if got := ojson.GetBytesNoCopy(streamOut[0], "choices.0.delta.reasoning_content").String(); got != "Thinking step 1" {
t.Fatalf("expected reasoning_content %q, got %q; payload=%s", "Thinking step 1", got, streamOut[0])
}
- if got := gjson.GetBytes(streamOut[0], "choices.0.delta.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(streamOut[0], "choices.0.delta.role").String(); got != "assistant" {
t.Fatalf("expected role assistant, got %q; payload=%s", got, streamOut[0])
}
@@ -713,7 +713,7 @@ func TestConvertCodexResponseToOpenAI_StreamReasoningTextDeltaAndDone(t *testing
if len(doneOut) != 1 {
t.Fatalf("expected 1 streaming chunk for reasoning_text.done, got %d", len(doneOut))
}
- if got := gjson.GetBytes(doneOut[0], "choices.0.delta.reasoning_content").String(); got != "\n\n" {
+ if got := ojson.GetBytesNoCopy(doneOut[0], "choices.0.delta.reasoning_content").String(); got != "\n\n" {
t.Fatalf("expected reasoning_content %q, got %q; payload=%s", "\n\n", got, doneOut[0])
}
}
@@ -726,8 +726,8 @@ func TestConvertCodexResponseToOpenAI_NonStreamReasoningTextContent(t *testing.T
`]}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "MiniMax-M3", nil, nil, raw, nil)
- got := gjson.GetBytes(out, "choices.0.message.reasoning_content")
- if !got.Exists() || got.Type == gjson.Null {
+ got := ojson.GetBytesNoCopy(out, "choices.0.message.reasoning_content")
+ if !got.Exists() || got.Type == ojson.Null {
t.Fatalf("expected reasoning_content to exist, got null/missing; payload=%s", string(out))
}
if got.String() != "Full reasoning from MiniMax" {
@@ -743,8 +743,8 @@ func TestConvertCodexResponseToOpenAI_NonStreamReasoningSummaryAndContent(t *tes
`]}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "MiniMax-M3", nil, nil, raw, nil)
- got := gjson.GetBytes(out, "choices.0.message.reasoning_content")
- if !got.Exists() || got.Type == gjson.Null {
+ got := ojson.GetBytesNoCopy(out, "choices.0.message.reasoning_content")
+ if !got.Exists() || got.Type == ojson.Null {
t.Fatalf("expected reasoning_content to exist, got null/missing; payload=%s", string(out))
}
if got.String() != "Summary part and Content part" {
@@ -760,25 +760,25 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
t.Run("non-stream response retains cache_write_tokens and service_tier", func(t *testing.T) {
out := ConvertCodexResponseToOpenAINonStream(ctx, "example-model", nil, nil, rawTerminal, nil)
- if got := gjson.GetBytes(out, "service_tier").String(); got != "default" {
+ if got := ojson.GetBytesNoCopy(out, "service_tier").String(); got != "default" {
t.Fatalf("expected service_tier 'default', got %q; payload=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.prompt_tokens").Int(); got != 7378 {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens").Int(); got != 7378 {
t.Fatalf("expected usage.prompt_tokens=7378, got %d; payload=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.completion_tokens").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(out, "usage.completion_tokens").Int(); got != 6 {
t.Fatalf("expected usage.completion_tokens=6, got %d; payload=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 7384 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 7384 {
t.Fatalf("expected usage.total_tokens=7384, got %d; payload=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cache_write_tokens").Int(); got != 128 {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cache_write_tokens").Int(); got != 128 {
t.Fatalf("expected usage.prompt_tokens_details.cache_write_tokens=128, got %d; payload=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens").Int(); got != 128 {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_creation_tokens").Int(); got != 128 {
t.Fatalf("expected usage.prompt_tokens_details.cached_creation_tokens=128, got %d; payload=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_tokens").Int(); got != 7168 {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_tokens").Int(); got != 7168 {
t.Fatalf("expected usage.prompt_tokens_details.cached_tokens=7168, got %d; payload=%s", got, string(out))
}
})
@@ -790,22 +790,22 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(chunks))
}
- if got := gjson.GetBytes(chunks[0], "service_tier").String(); got != "default" {
+ if got := ojson.GetBytesNoCopy(chunks[0], "service_tier").String(); got != "default" {
t.Fatalf("expected stream chunk service_tier 'default', got %q; payload=%s", got, string(chunks[0]))
}
- if got := gjson.GetBytes(chunks[0], "usage.prompt_tokens").Int(); got != 7378 {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens").Int(); got != 7378 {
t.Fatalf("expected stream chunk prompt_tokens=7378, got %d; payload=%s", got, string(chunks[0]))
}
- if got := gjson.GetBytes(chunks[0], "usage.completion_tokens").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.completion_tokens").Int(); got != 6 {
t.Fatalf("expected stream chunk completion_tokens=6, got %d; payload=%s", got, string(chunks[0]))
}
- if got := gjson.GetBytes(chunks[0], "usage.total_tokens").Int(); got != 7384 {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.total_tokens").Int(); got != 7384 {
t.Fatalf("expected stream chunk total_tokens=7384, got %d; payload=%s", got, string(chunks[0]))
}
- if got := gjson.GetBytes(chunks[0], "usage.prompt_tokens_details.cache_write_tokens").Int(); got != 128 {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens_details.cache_write_tokens").Int(); got != 128 {
t.Fatalf("expected stream chunk cache_write_tokens=128, got %d; payload=%s", got, string(chunks[0]))
}
- if got := gjson.GetBytes(chunks[0], "usage.prompt_tokens_details.cached_creation_tokens").Int(); got != 128 {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens_details.cached_creation_tokens").Int(); got != 128 {
t.Fatalf("expected stream chunk cached_creation_tokens=128, got %d; payload=%s", got, string(chunks[0]))
}
})
@@ -823,7 +823,7 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
if len(deltaChunks) != 1 {
t.Fatalf("expected 1 delta chunk, got %d", len(deltaChunks))
}
- if got := gjson.GetBytes(deltaChunks[0], "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytesNoCopy(deltaChunks[0], "service_tier").String(); got != "priority" {
t.Fatalf("expected delta chunk service_tier 'priority', got %q; payload=%s", got, string(deltaChunks[0]))
}
@@ -833,7 +833,7 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
if len(termChunks) != 1 {
t.Fatalf("expected 1 terminal chunk, got %d", len(termChunks))
}
- if got := gjson.GetBytes(termChunks[0], "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytesNoCopy(termChunks[0], "service_tier").String(); got != "priority" {
t.Fatalf("expected terminal chunk to retain prior service_tier 'priority', got %q; payload=%s", got, string(termChunks[0]))
}
})
@@ -849,7 +849,7 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
if len(deltaChunks) != 1 {
t.Fatalf("expected 1 delta chunk, got %d", len(deltaChunks))
}
- if got := gjson.GetBytes(deltaChunks[0], "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytesNoCopy(deltaChunks[0], "service_tier").String(); got != "priority" {
t.Fatalf("expected in_progress tier 'priority', got %q; chunk=%s", got, string(deltaChunks[0]))
}
@@ -859,7 +859,7 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
if len(completedChunks) != 1 {
t.Fatalf("expected 1 completed chunk, got %d", len(completedChunks))
}
- if got := gjson.GetBytes(completedChunks[0], "service_tier").String(); got != "scale" {
+ if got := ojson.GetBytesNoCopy(completedChunks[0], "service_tier").String(); got != "scale" {
t.Fatalf("expected completed tier 'scale', got %q; chunk=%s", got, string(completedChunks[0]))
}
})
@@ -868,10 +868,10 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
beyondInt64Val := "999999999999999999999999999999" // > 2^63 - 1
raw := []byte(`{"type":"response.completed","response":{"id":"resp_large","model":"example-model","service_tier":"default","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cache_write_tokens":` + beyondInt64Val + `}}}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "example-model", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cache_write_tokens").Raw; got != beyondInt64Val {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cache_write_tokens").Raw; got != beyondInt64Val {
t.Fatalf("expected exact raw large cache_write_tokens %s, got %s; payload=%s", beyondInt64Val, got, string(out))
}
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens").Raw; got != beyondInt64Val {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_creation_tokens").Raw; got != beyondInt64Val {
t.Fatalf("expected exact raw large cached_creation_tokens %s, got %s; payload=%s", beyondInt64Val, got, string(out))
}
@@ -882,10 +882,10 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
if len(chunks) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(chunks))
}
- if got := gjson.GetBytes(chunks[0], "usage.prompt_tokens_details.cache_write_tokens").Raw; got != beyondInt64Val {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens_details.cache_write_tokens").Raw; got != beyondInt64Val {
t.Fatalf("expected stream exact raw large cache_write_tokens %s, got %s; chunk=%s", beyondInt64Val, got, string(chunks[0]))
}
- if got := gjson.GetBytes(chunks[0], "usage.prompt_tokens_details.cached_creation_tokens").Raw; got != beyondInt64Val {
+ if got := ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens_details.cached_creation_tokens").Raw; got != beyondInt64Val {
t.Fatalf("expected stream exact raw large cached_creation_tokens %s, got %s; chunk=%s", beyondInt64Val, got, string(chunks[0]))
}
})
@@ -894,10 +894,10 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
largeVal := "9007199254740993" // 2^53 + 1
raw := []byte(`{"type":"response.completed","response":{"id":"resp_large","model":"example-model","service_tier":"default","usage":{"input_tokens":100,"output_tokens":20,"total_tokens":120,"input_tokens_details":{"cache_write_tokens":` + largeVal + `}}}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "example-model", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cache_write_tokens").Raw; got != largeVal {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cache_write_tokens").Raw; got != largeVal {
t.Fatalf("expected exact raw large cache_write_tokens %s, got %s; payload=%s", largeVal, got, string(out))
}
- if got := gjson.GetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens").Raw; got != largeVal {
+ if got := ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_creation_tokens").Raw; got != largeVal {
t.Fatalf("expected exact raw large cached_creation_tokens %s, got %s; payload=%s", largeVal, got, string(out))
}
})
@@ -912,10 +912,10 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
for _, usageJSON := range invalidCases {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_inv","model":"example-model","usage":` + usageJSON + `}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "example-model", nil, nil, raw, nil)
- if gjson.GetBytes(out, "usage.prompt_tokens_details.cache_write_tokens").Exists() {
+ if ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cache_write_tokens").Exists() {
t.Fatalf("expected invalid cache_write_tokens to be omitted; payload=%s", string(out))
}
- if gjson.GetBytes(out, "usage.prompt_tokens_details.cached_creation_tokens").Exists() {
+ if ojson.GetBytesNoCopy(out, "usage.prompt_tokens_details.cached_creation_tokens").Exists() {
t.Fatalf("expected invalid cached_creation_tokens to be omitted; payload=%s", string(out))
}
@@ -924,10 +924,10 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
chunk := append([]byte("data: "), raw...)
chunks := ConvertCodexResponseToOpenAI(ctx, "example-model", nil, nil, chunk, ¶m)
if len(chunks) == 1 {
- if gjson.GetBytes(chunks[0], "usage.prompt_tokens_details.cache_write_tokens").Exists() {
+ if ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens_details.cache_write_tokens").Exists() {
t.Fatalf("expected stream invalid cache_write_tokens to be omitted; chunk=%s", string(chunks[0]))
}
- if gjson.GetBytes(chunks[0], "usage.prompt_tokens_details.cached_creation_tokens").Exists() {
+ if ojson.GetBytesNoCopy(chunks[0], "usage.prompt_tokens_details.cached_creation_tokens").Exists() {
t.Fatalf("expected stream invalid cached_creation_tokens to be omitted; chunk=%s", string(chunks[0]))
}
}
@@ -945,7 +945,7 @@ func TestConvertCodexResponseToOpenAI_Issue5543_CacheWriteTokensAndServiceTier(t
for _, tierVal := range invalidTiers {
raw := []byte(`{"type":"response.completed","response":{"id":"resp_tier","model":"example-model","service_tier":` + tierVal + `,"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`)
out := ConvertCodexResponseToOpenAINonStream(ctx, "example-model", nil, nil, raw, nil)
- if gjson.GetBytes(out, "service_tier").Exists() {
+ if ojson.GetBytesNoCopy(out, "service_tier").Exists() {
t.Fatalf("expected invalid service_tier %s to be omitted; payload=%s", tierVal, string(out))
}
}
@@ -970,7 +970,7 @@ func TestConvertCodexResponseToOpenAI_RestoresNormalizedToolNames(t *testing.T)
// Test non-stream response
rawNonStream := []byte(`{"type":"response.completed","response":{"id":"resp_1","created_at":1700000000,"model":"gpt-5.6-sol","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"output":[{"type":"function_call","call_id":"call_1","name":"` + normalizedName + `","arguments":"{}"}]}}`)
outNonStream := ConvertCodexResponseToOpenAINonStream(ctx, "gpt-5.6-sol", originalRequest, nil, rawNonStream, nil)
- gotNameNonStream := gjson.GetBytes(outNonStream, "choices.0.message.tool_calls.0.function.name").String()
+ gotNameNonStream := ojson.GetBytesNoCopy(outNonStream, "choices.0.message.tool_calls.0.function.name").String()
if gotNameNonStream != originalName {
t.Fatalf("non-stream expected restored name %q, got %q", originalName, gotNameNonStream)
}
@@ -982,7 +982,7 @@ func TestConvertCodexResponseToOpenAI_RestoresNormalizedToolNames(t *testing.T)
if len(streamChunks) != 1 {
t.Fatalf("expected 1 stream chunk, got %d", len(streamChunks))
}
- gotNameStream := gjson.GetBytes(streamChunks[0], "choices.0.delta.tool_calls.0.function.name").String()
+ gotNameStream := ojson.GetBytesNoCopy(streamChunks[0], "choices.0.delta.tool_calls.0.function.name").String()
if gotNameStream != originalName {
t.Fatalf("stream expected restored name %q, got %q", originalName, gotNameStream)
}
@@ -997,7 +997,7 @@ func TestApplyPatchCustomChatCompletionsWrapper(t *testing.T) {
var arguments strings.Builder
send := func(event string) {
for _, out := range ConvertCodexResponseToOpenAI(t.Context(), "model", request, request, []byte("data: "+event), ¶m) {
- arguments.WriteString(gjson.GetBytes(out, "choices.0.delta.tool_calls.0.function.arguments").String())
+ arguments.WriteString(ojson.GetBytesNoCopy(out, "choices.0.delta.tool_calls.0.function.arguments").String())
}
}
send(`{"type":"response.output_item.added","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":""}}`)
@@ -1016,7 +1016,7 @@ func TestApplyPatchCustomChatCompletionsWrapper(t *testing.T) {
}
var nonStream any
out := ConvertCodexResponseToOpenAINonStream(t.Context(), "model", request, request, []byte(`{"type":"response.completed","response":{"output":[{"type":"custom_tool_call","name":"apply_patch","call_id":"c","input":`+string(mustJSONMarshal(t, patch))+`}]}}`), &nonStream)
- if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != want {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != want {
t.Fatalf("nonstream arguments=%q want=%q", got, want)
}
})
@@ -1031,7 +1031,7 @@ func TestApplyPatchCustomChatCompletionsDoneFallback(t *testing.T) {
_ = ConvertCodexResponseToOpenAI(t.Context(), "m", request, request, []byte(`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":""}}`), ¶m)
}
out := ConvertCodexResponseToOpenAI(t.Context(), "m", request, request, []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"custom_tool_call","id":"a","call_id":"c","name":"apply_patch","input":"p"}}`), ¶m)
- if len(out) != 1 || gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.arguments").String() != `{"input":"p"}` {
+ if len(out) != 1 || ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.arguments").String() != `{"input":"p"}` {
t.Fatalf("fallback: %s", out)
}
}
@@ -1043,10 +1043,10 @@ func TestApplyPatchChatCompletionNativeHistoryRoundTrip(t *testing.T) {
original := []byte(`{"messages":[{"role":"user","content":"patch"}],"tools":[{"type":"custom","name":"apply_patch"}]}`)
response := []byte(`{"type":"response.completed","response":{"output":[{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":"p"}]}}`)
out := ConvertCodexResponseToOpenAINonStream(t.Context(), "m", original, original, response, nil)
- message := gjson.GetBytes(out, "choices.0.message")
+ message := ojson.GetBytesNoCopy(out, "choices.0.message")
followup := []byte(`{"messages":[` + message.Raw + `,{"role":"tool","tool_call_id":"c","content":"ok"}],"tools":[{"type":"custom","name":"apply_patch"}]}`)
request, _ := ConvertOpenAIRequestToCodex("m", followup, true)
- if got := gjson.GetBytes(request, "input.0.input").String(); got != "p" {
+ if got := ojson.GetBytesNoCopy(request, "input.0.input").String(); got != "p" {
t.Fatalf("normalized function history must restore raw patch before native Codex: got %q, request=%s", got, request)
}
}
@@ -1054,7 +1054,7 @@ func TestApplyPatchChatCompletionNativeHistoryRoundTrip(t *testing.T) {
func TestApplyPatchChatResponseOrdinaryFunctionPreference(t *testing.T) {
original := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","function":{"name":"apply_patch","parameters":{}}}]}`)
out := ConvertCodexResponseToOpenAINonStream(t.Context(), "m", original, nil, []byte(`{"type":"response.completed","response":{"output":[{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":"raw"}]}}`), nil)
- if gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String() != "raw" {
+ if ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0.function.arguments").String() != "raw" {
t.Fatalf("ordinary preference stolen: %s", out)
}
}
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_user_turn_test.go b/internal/translator/codex/openai/chat-completions/codex_openai_user_turn_test.go
index 4d91d23036a..ee429621068 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_user_turn_test.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -63,7 +63,7 @@ func TestConvertOpenAIRequestToCodex_RefusesAnyEmptiedUserTurn(t *testing.T) {
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
@@ -97,7 +97,7 @@ func TestConvertOpenAIRequestToCodex_FileIDStaysAnInputFile(t *testing.T) {
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "input.2.content.0")
if part.Get("type").String() != "input_file" || part.Get(tc.key).String() != tc.want {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -111,7 +111,7 @@ func TestConvertOpenAIRequestToCodex_KeepsTurnWithTextBesideAttachment(t *testin
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -122,7 +122,7 @@ func TestConvertOpenAIRequestToCodex_AudioAfterHistoryStaysAudio(t *testing.T) {
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "input.2.content.0")
if part.Get("type").String() != "input_audio" || part.Get("data").String() != "UklGRg==" {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -130,7 +130,7 @@ func TestConvertOpenAIRequestToCodex_AudioAfterHistoryStaysAudio(t *testing.T) {
func TestConvertOpenAIRequestToCodex_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","messages":[{"role":"user","content":[` + userTurnEmptyFile + `]}]}`)
- if body, _ := ConvertOpenAIRequestToCodex("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIRequestToCodex("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/codex/openai/chat-completions/noop_optimization_test.go b/internal/translator/codex/openai/chat-completions/noop_optimization_test.go
index 1f5f5db3f64..eb1694a3204 100644
--- a/internal/translator/codex/openai/chat-completions/noop_optimization_test.go
+++ b/internal/translator/codex/openai/chat-completions/noop_optimization_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertCodexResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) {
@@ -12,7 +12,7 @@ func TestConvertCodexResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) {
output := ConvertCodexResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil)
- if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" {
+ if role := ojson.GetBytesNoCopy(output, "choices.0.message.role").String(); role != "assistant" {
t.Fatalf("role = %q, want assistant", role)
}
}
diff --git a/internal/translator/codex/openai/chat-completions/tool_cache_perf_test.go b/internal/translator/codex/openai/chat-completions/tool_cache_perf_test.go
index 98565ebe2c5..96a4e78c01f 100644
--- a/internal/translator/codex/openai/chat-completions/tool_cache_perf_test.go
+++ b/internal/translator/codex/openai/chat-completions/tool_cache_perf_test.go
@@ -9,7 +9,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func tcHash(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
@@ -150,7 +150,7 @@ func TestCodexChatToolMetadataEquivalence(t *testing.T) {
}
}
for _, itemJSON := range items {
- item := gjson.ParseBytes([]byte(itemJSON))
+ item := util.ParseGJSONBytesNoCopy([]byte(itemJSON))
if got, want := metadata.isCustomPatch(req, item), isOriginalCustomPatch(req, item); got != want {
t.Errorf("req %d isCustomPatch(%s) = %v, want %v", ri, itemJSON, got, want)
}
diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go
index 4a7ecf1b493..aee02e15a7f 100644
--- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go
+++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go
@@ -5,88 +5,104 @@ import (
"encoding/json"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) {
rawJSON := inputRawJSON
+ var doc *ojson.Doc
+ ensureDoc := func() *ojson.Doc {
+ if doc == nil {
+ doc = ojson.NewDoc(rawJSON)
+ }
+ return doc
+ }
inputResult := util.GetGJSONBytesNoCopy(rawJSON, "input")
- if inputResult.Type == gjson.String {
- input, _ := sjson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String())
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", input)
- inputResult = util.GetGJSONBytesNoCopy(rawJSON, "input")
- }
-
- rawJSON = setCodexRequiredBool(rawJSON, "stream", true)
- rawJSON = setCodexRequiredBool(rawJSON, "store", false)
- if gjson.GetBytes(rawJSON, "parallel_tool_calls").Type != gjson.False {
- rawJSON = setCodexRequiredBool(rawJSON, "parallel_tool_calls", true)
- }
- rawJSON = setCodexRequiredInclude(rawJSON)
- // Codex Responses rejects token limit fields, so strip them out before forwarding.
- rawJSON = deleteCodexRequestFields(rawJSON, "max_output_tokens", "max_completion_tokens", "temperature", "top_p")
- if serviceTier := gjson.GetBytes(rawJSON, "service_tier"); serviceTier.Exists() {
- if serviceTier.Type == gjson.String {
+ if inputResult.Type == ojson.String {
+ input, _ := ojson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String())
+ _ = ensureDoc().SetRawBytes("input", input)
+ inputResult = util.ParseGJSONBytesNoCopy(input)
+ }
+
+ setCodexRequiredBoolDoc(rawJSON, ensureDoc, "stream", true)
+ setCodexRequiredBoolDoc(rawJSON, ensureDoc, "store", false)
+ if ojson.GetBytesNoCopy(rawJSON, "parallel_tool_calls").Type != ojson.False {
+ setCodexRequiredBoolDoc(rawJSON, ensureDoc, "parallel_tool_calls", true)
+ }
+ setCodexRequiredIncludeDoc(rawJSON, ensureDoc)
+ // Preserve max_output_tokens and fill it from legacy token limits when unset.
+ if maxOutputTokens := ojson.GetBytesNoCopy(rawJSON, "max_output_tokens"); !maxOutputTokens.Exists() || maxOutputTokens.Type == ojson.Null {
+ for _, path := range []string{"max_completion_tokens", "max_tokens"} {
+ if v := ojson.GetBytesNoCopy(rawJSON, path); v.Type == ojson.Number {
+ _ = ensureDoc().Set("max_output_tokens", v.Int())
+ break
+ }
+ }
+ }
+ deleteCodexRequestFieldsDoc(rawJSON, ensureDoc, "max_completion_tokens", "max_tokens", "temperature", "top_p")
+ if serviceTier := ojson.GetBytesNoCopy(rawJSON, "service_tier"); serviceTier.Exists() {
+ if serviceTier.Type == ojson.String {
switch strings.ToLower(strings.TrimSpace(serviceTier.String())) {
case "priority", "fast":
if serviceTier.String() != "priority" {
- rawJSON, _ = sjson.SetBytes(rawJSON, "service_tier", "priority")
+ _ = ensureDoc().Set("service_tier", "priority")
}
case "ultrafast":
if serviceTier.String() != "ultrafast" {
- rawJSON, _ = sjson.SetBytes(rawJSON, "service_tier", "ultrafast")
+ _ = ensureDoc().Set("service_tier", "ultrafast")
}
default:
- rawJSON = deleteCodexRequestFields(rawJSON, "service_tier")
+ _ = ensureDoc().Delete("service_tier")
}
} else {
- rawJSON = deleteCodexRequestFields(rawJSON, "service_tier")
+ _ = ensureDoc().Delete("service_tier")
}
}
- rawJSON = deleteCodexRequestFields(rawJSON, "truncation", "prompt_cache_options", "prompt_cache_retention")
- rawJSON = stripCodexResponsesCacheBreakpoints(rawJSON)
- rawJSON = applyResponsesCompactionCompatibility(rawJSON)
+ deleteCodexRequestFieldsDoc(rawJSON, ensureDoc, "truncation", "prompt_cache_options", "prompt_cache_retention")
+ inputResult = stripCodexResponsesCacheBreakpointsDoc(rawJSON, inputResult, ensureDoc)
+ applyResponsesCompactionCompatibilityDoc(rawJSON, ensureDoc)
// Delete the user field as it is not supported by the Codex upstream.
- rawJSON = deleteCodexRequestFields(rawJSON, "user")
+ deleteCodexRequestFieldsDoc(rawJSON, ensureDoc, "user")
// Convert role "system" to "developer" in input array to comply with Codex API requirements.
- rawJSON = convertSystemRoleToDeveloper(rawJSON)
- rawJSON = normalizeCodexBuiltinTools(rawJSON)
- rawJSON = normalizeEmptyFunctionCallArguments(rawJSON)
+ inputResult = convertSystemRoleToDeveloperDoc(inputResult, ensureDoc)
+ normalizeCodexBuiltinToolsDoc(rawJSON, ensureDoc)
+ normalizeEmptyFunctionCallArgumentsDoc(inputResult, ensureDoc)
+ if doc != nil {
+ return doc.Bytes(), nil
+ }
return rawJSON, nil
}
-// normalizeEmptyFunctionCallArguments rewrites blank string arguments on
+// normalizeEmptyFunctionCallArgumentsDoc rewrites blank string arguments on
// history function_call items to "{}". Some Responses clients serialize
// parameter-less tool calls as an empty string, which strict Codex Responses
// upstreams reject with "`arguments` must be valid JSON". Only blank strings
// are touched: non-empty strings (even invalid JSON) pass through unchanged
// so other errors keep their original shape, and custom_tool_call items use
// the separate input field and are never affected.
-func normalizeEmptyFunctionCallArguments(rawJSON []byte) []byte {
- inputResult := util.GetGJSONBytesNoCopy(rawJSON, "input")
+func normalizeEmptyFunctionCallArgumentsDoc(inputResult ojson.Result, ensureDoc func() *ojson.Doc) {
if !inputResult.IsArray() {
- return rawJSON
+ return
}
items := inputResult.Array()
if len(items) == 0 {
- return rawJSON
+ return
}
changed := false
rebuilt := make([][]byte, 0, len(items))
for _, item := range items {
itemRaw := []byte(item.Raw)
if item.IsObject() && item.Get("type").String() == "function_call" {
- if args := item.Get("arguments"); args.Type == gjson.String && strings.TrimSpace(args.String()) == "" {
- if updated, errSet := sjson.SetBytes(itemRaw, "arguments", "{}"); errSet == nil {
+ if args := item.Get("arguments"); args.Type == ojson.String && strings.TrimSpace(args.String()) == "" {
+ if updated, errSet := ojson.SetBytes(itemRaw, "arguments", "{}"); errSet == nil {
itemRaw = updated
changed = true
}
@@ -95,46 +111,37 @@ func normalizeEmptyFunctionCallArguments(rawJSON []byte) []byte {
rebuilt = append(rebuilt, itemRaw)
}
if !changed {
- return rawJSON
+ return
}
- updated, errSetRaw := sjson.SetRawBytes(rawJSON, "input", translatorcommon.JoinRawArray(rebuilt))
- if errSetRaw != nil {
- return rawJSON
- }
- return updated
+ _ = ensureDoc().SetRawBytes("input", translatorcommon.JoinRawArray(rebuilt))
}
-func setCodexRequiredBool(rawJSON []byte, path string, value bool) []byte {
- current := gjson.GetBytes(rawJSON, path)
- if value && current.Type == gjson.True || !value && current.Type == gjson.False {
- return rawJSON
- }
-
- updated, errSet := sjson.SetBytes(rawJSON, path, value)
- if errSet != nil {
- return rawJSON
+func setCodexRequiredBoolDoc(rawJSON []byte, ensureDoc func() *ojson.Doc, path string, value bool) {
+ current := ojson.GetBytesNoCopy(rawJSON, path)
+ if value && current.Type == ojson.True || !value && current.Type == ojson.False {
+ return
}
- return updated
+ _ = ensureDoc().Set(path, value)
}
-func setCodexRequiredInclude(rawJSON []byte) []byte {
- current := gjson.GetBytes(rawJSON, "include")
+func setCodexRequiredIncludeDoc(rawJSON []byte, ensureDoc func() *ojson.Doc) {
+ current := ojson.GetBytesNoCopy(rawJSON, "include")
includeSources := false
if current.IsArray() {
values := current.Array()
for _, value := range values {
- if value.Type == gjson.String && value.String() == "web_search_call.action.sources" {
+ if value.Type == ojson.String && value.String() == "web_search_call.action.sources" {
includeSources = true
break
}
}
- if !includeSources && len(values) == 1 && values[0].Type == gjson.String && values[0].String() == "reasoning.encrypted_content" {
- return rawJSON
+ if !includeSources && len(values) == 1 && values[0].Type == ojson.String && values[0].String() == "reasoning.encrypted_content" {
+ return
}
if includeSources && len(values) == 2 &&
- values[0].Type == gjson.String && values[0].String() == "reasoning.encrypted_content" &&
- values[1].Type == gjson.String && values[1].String() == "web_search_call.action.sources" {
- return rawJSON
+ values[0].Type == ojson.String && values[0].String() == "reasoning.encrypted_content" &&
+ values[1].Type == ojson.String && values[1].String() == "web_search_call.action.sources" {
+ return
}
}
@@ -142,53 +149,48 @@ func setCodexRequiredInclude(rawJSON []byte) []byte {
if includeSources {
encoded = []byte(`["reasoning.encrypted_content","web_search_call.action.sources"]`)
}
- updated, errSet := sjson.SetRawBytes(rawJSON, "include", encoded)
- if errSet != nil {
- return rawJSON
- }
- return updated
+ _ = ensureDoc().SetRawBytes("include", encoded)
}
-func deleteCodexRequestFields(rawJSON []byte, paths ...string) []byte {
+func deleteCodexRequestFieldsDoc(rawJSON []byte, ensureDoc func() *ojson.Doc, paths ...string) {
for _, path := range paths {
- if !gjson.GetBytes(rawJSON, path).Exists() {
+ if !ojson.GetBytesNoCopy(rawJSON, path).Exists() {
continue
}
-
- updated, errDelete := sjson.DeleteBytes(rawJSON, path)
- if errDelete == nil {
- rawJSON = updated
- }
+ _ = ensureDoc().Delete(path)
}
- return rawJSON
}
-// stripCodexResponsesCacheBreakpoints removes any "prompt_cache_breakpoint" hint
+// stripCodexResponsesCacheBreakpointsDoc removes any "prompt_cache_breakpoint" hint
// attached to input items: inside content-part arrays (message input[].content[]
// and function_call_output input[].output[]) or as an item-level field. Some
// clients (e.g. GitHub Copilot CLI) attach this field per content item when
// targeting the OpenAI Responses format. Codex Responses rejects it outright:
// {"error":{"message":"prompt_cache_breakpoint is not supported on this model", ...}}.
// The top-level prompt_cache_options strip above does not cover these nested cases.
-func stripCodexResponsesCacheBreakpoints(rawJSON []byte) []byte {
+func stripCodexResponsesCacheBreakpointsDoc(rawJSON []byte, input ojson.Result, ensureDoc func() *ojson.Doc) ojson.Result {
if !bytes.Contains(rawJSON, []byte(`"prompt_cache_breakpoint"`)) {
- return rawJSON
+ return input
}
-
- input := util.GetGJSONBytesNoCopy(rawJSON, "input")
if !input.IsArray() {
- return rawJSON
+ return input
}
inputItems := input.Array()
if len(inputItems) == 0 {
- return rawJSON
+ return input
}
changed := false
rebuiltInput := make([][]byte, 0, len(inputItems))
for _, item := range inputItems {
- itemRaw := []byte(item.Raw)
+ var itemDoc *ojson.Doc
+ ensureItemDoc := func() *ojson.Doc {
+ if itemDoc == nil {
+ itemDoc = ojson.NewDocString(item.Raw)
+ }
+ return itemDoc
+ }
for _, arrayPath := range []string{"content", "output"} {
arrayResult := item.Get(arrayPath)
if !arrayResult.IsArray() {
@@ -198,33 +200,33 @@ func stripCodexResponsesCacheBreakpoints(rawJSON []byte) []byte {
if !arrayChanged {
continue
}
- if updatedItem, errSet := sjson.SetRawBytes(itemRaw, arrayPath, updatedArray); errSet == nil {
- itemRaw = updatedItem
+ if errSet := ensureItemDoc().SetRawBytes(arrayPath, updatedArray); errSet == nil {
changed = true
}
}
if item.Get("prompt_cache_breakpoint").Exists() {
- if updatedItem, errDelete := sjson.DeleteBytes(itemRaw, "prompt_cache_breakpoint"); errDelete == nil {
- itemRaw = updatedItem
+ if errDelete := ensureItemDoc().Delete("prompt_cache_breakpoint"); errDelete == nil {
changed = true
}
}
- rebuiltInput = append(rebuiltInput, itemRaw)
+ if itemDoc != nil {
+ rebuiltInput = append(rebuiltInput, itemDoc.Bytes())
+ } else {
+ rebuiltInput = append(rebuiltInput, []byte(item.Raw))
+ }
}
if !changed {
- return rawJSON
+ return input
}
- updated, errSet := sjson.SetRawBytes(rawJSON, "input", translatorcommon.JoinRawArray(rebuiltInput))
- if errSet != nil {
- return rawJSON
- }
- return updated
+ joined := translatorcommon.JoinRawArray(rebuiltInput)
+ _ = ensureDoc().SetRawBytes("input", joined)
+ return util.ParseGJSONBytesNoCopy(joined)
}
// stripPromptCacheBreakpointFromContent removes "prompt_cache_breakpoint" from each
// content part that carries it and reports whether anything changed.
-func stripPromptCacheBreakpointFromContent(content gjson.Result) ([]byte, bool) {
+func stripPromptCacheBreakpointFromContent(content ojson.Result) ([]byte, bool) {
parts := content.Array()
hasBreakpoint := false
for _, part := range parts {
@@ -242,7 +244,7 @@ func stripPromptCacheBreakpointFromContent(content gjson.Result) ([]byte, bool)
for _, part := range parts {
partRaw := []byte(part.Raw)
if part.Get("prompt_cache_breakpoint").Exists() {
- if updated, errDelete := sjson.DeleteBytes(partRaw, "prompt_cache_breakpoint"); errDelete == nil {
+ if updated, errDelete := ojson.DeleteBytes(partRaw, "prompt_cache_breakpoint"); errDelete == nil {
partRaw = updated
changed = true
}
@@ -263,13 +265,11 @@ func stripPromptCacheBreakpointFromContent(content gjson.Result) ([]byte, bool)
//
// Compatibility strategy:
// 1) Remove context_management before forwarding to Codex upstream.
-func applyResponsesCompactionCompatibility(rawJSON []byte) []byte {
- if !gjson.GetBytes(rawJSON, "context_management").Exists() {
- return rawJSON
+func applyResponsesCompactionCompatibilityDoc(rawJSON []byte, ensureDoc func() *ojson.Doc) {
+ if !ojson.GetBytesNoCopy(rawJSON, "context_management").Exists() {
+ return
}
-
- rawJSON, _ = sjson.DeleteBytes(rawJSON, "context_management")
- return rawJSON
+ _ = ensureDoc().Delete("context_management")
}
// convertSystemRoleToDeveloper traverses the input array and converts any message items
@@ -279,7 +279,54 @@ func convertSystemRoleToDeveloper(rawJSON []byte) []byte {
return convertSystemRoleToDeveloperWithInput(rawJSON, util.GetGJSONBytesNoCopy(rawJSON, "input"))
}
-func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult gjson.Result) []byte {
+func convertSystemRoleToDeveloperDoc(inputResult ojson.Result, ensureDoc func() *ojson.Doc) ojson.Result {
+ if !inputResult.IsArray() {
+ return inputResult
+ }
+
+ inputItems := inputResult.Array()
+ if len(inputItems) == 0 {
+ return inputResult
+ }
+
+ hasSystemRole := false
+ for _, item := range inputItems {
+ if item.IsObject() && item.Get("role").String() == "system" {
+ hasSystemRole = true
+ break
+ }
+ }
+ if !hasSystemRole {
+ return inputResult
+ }
+
+ changed := false
+ rebuiltInput := make([]json.RawMessage, 0, len(inputItems))
+ for _, item := range inputItems {
+ itemRaw := []byte(item.Raw)
+ if item.IsObject() && item.Get("role").String() == "system" {
+ updatedItem, errSetItem := ojson.SetRawBytes(itemRaw, "role", []byte(`"developer"`))
+ if errSetItem != nil {
+ return inputResult
+ }
+ itemRaw = updatedItem
+ changed = true
+ }
+ rebuiltInput = append(rebuiltInput, json.RawMessage(itemRaw))
+ }
+ if !changed {
+ return inputResult
+ }
+
+ inputRaw, errMarshalInput := json.Marshal(rebuiltInput)
+ if errMarshalInput != nil {
+ return inputResult
+ }
+ _ = ensureDoc().SetRawBytes("input", inputRaw)
+ return util.ParseGJSONBytesNoCopy(inputRaw)
+}
+
+func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult ojson.Result) []byte {
if !inputResult.IsArray() {
return rawJSON
}
@@ -305,7 +352,7 @@ func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult gjson.Res
for _, item := range inputItems {
itemRaw := []byte(item.Raw)
if item.IsObject() && item.Get("role").String() == "system" {
- updatedItem, errSetItem := sjson.SetRawBytes(itemRaw, "role", []byte(`"developer"`))
+ updatedItem, errSetItem := ojson.SetRawBytes(itemRaw, "role", []byte(`"developer"`))
if errSetItem != nil {
return rawJSON
}
@@ -322,35 +369,35 @@ func convertSystemRoleToDeveloperWithInput(rawJSON []byte, inputResult gjson.Res
if errMarshalInput != nil {
return rawJSON
}
- updated, errSetInput := sjson.SetRawBytes(rawJSON, "input", inputRaw)
+ updated, errSetInput := ojson.SetRawBytes(rawJSON, "input", inputRaw)
if errSetInput != nil {
return rawJSON
}
return updated
}
-// normalizeCodexBuiltinTools rewrites legacy/preview built-in tool variants to the
+// normalizeCodexBuiltinToolsDoc rewrites legacy/preview built-in tool variants to the
// stable names expected by the current Codex upstream.
-func normalizeCodexBuiltinTools(rawJSON []byte) []byte {
- result := normalizeCodexBuiltinToolArray(rawJSON, "tools")
- result = normalizeCodexBuiltinToolAtPath(result, "tool_choice.type")
- return normalizeCodexBuiltinToolArray(result, "tool_choice.tools")
+func normalizeCodexBuiltinToolsDoc(rawJSON []byte, ensureDoc func() *ojson.Doc) {
+ normalizeCodexBuiltinToolArrayDoc(rawJSON, ensureDoc, "tools")
+ normalizeCodexBuiltinToolAtPathDoc(rawJSON, ensureDoc, "tool_choice.type")
+ normalizeCodexBuiltinToolArrayDoc(rawJSON, ensureDoc, "tool_choice.tools")
}
-func normalizeCodexBuiltinToolArray(rawJSON []byte, path string) []byte {
- tools := gjson.GetBytes(rawJSON, path)
+func normalizeCodexBuiltinToolArrayDoc(rawJSON []byte, ensureDoc func() *ojson.Doc, path string) {
+ tools := ojson.GetBytesNoCopy(rawJSON, path)
if !tools.IsArray() {
- return rawJSON
+ return
}
changed := false
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
item := []byte(tool.Raw)
currentType := tool.Get("type").String()
normalizedType := normalizeCodexBuiltinToolType(currentType)
if normalizedType != "" {
- updated, errSetType := sjson.SetBytes(item, "type", normalizedType)
+ updated, errSetType := ojson.SetBytes(item, "type", normalizedType)
if errSetType == nil {
item = updated
changed = true
@@ -361,30 +408,24 @@ func normalizeCodexBuiltinToolArray(rawJSON []byte, path string) []byte {
return true
})
if !changed {
- return rawJSON
+ return
}
- updated, errSetTools := sjson.SetRawBytes(rawJSON, path, translatorcommon.JoinRawArray(toolItems))
- if errSetTools != nil {
- return rawJSON
- }
- return updated
+ _ = ensureDoc().SetRawBytes(path, translatorcommon.JoinRawArray(toolItems))
}
-func normalizeCodexBuiltinToolAtPath(rawJSON []byte, path string) []byte {
- currentType := gjson.GetBytes(rawJSON, path).String()
+func normalizeCodexBuiltinToolAtPathDoc(rawJSON []byte, ensureDoc func() *ojson.Doc, path string) {
+ currentType := ojson.GetBytesNoCopy(rawJSON, path).String()
normalizedType := normalizeCodexBuiltinToolType(currentType)
if normalizedType == "" {
- return rawJSON
+ return
}
- updated, err := sjson.SetBytes(rawJSON, path, normalizedType)
- if err != nil {
- return rawJSON
+ if errSet := ensureDoc().Set(path, normalizedType); errSet != nil {
+ return
}
log.Debugf("codex responses: normalized builtin tool type at %s from %q to %q", path, currentType, normalizedType)
- return updated
}
// normalizeCodexBuiltinToolType centralizes the current known Codex Responses
diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go
index edacd15349f..bede0ae168c 100644
--- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go
+++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go
@@ -6,13 +6,53 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
var benchmarkConvertSystemRoleOutput []byte
var benchmarkConvertNormalizedOutput []byte
+func TestConvertOpenAIResponsesRequestToCodexMaxOutputTokens(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {"no limit", `{}`, ""},
+ {"preserves output limit", `{"max_output_tokens":4096}`, "4096"},
+ {"completion limit fallback", `{"max_completion_tokens":2048}`, "2048"},
+ {"max_tokens fallback", `{"max_tokens":1024}`, "1024"},
+ {"output limit takes precedence", `{"max_output_tokens":4096,"max_completion_tokens":2048,"max_tokens":1024}`, "4096"},
+ {"completion limit takes precedence", `{"max_completion_tokens":2048,"max_tokens":1024}`, "2048"},
+ {"zero output limit takes precedence", `{"max_output_tokens":0,"max_completion_tokens":2048,"max_tokens":1024}`, "0"},
+ {"null output limit uses completion fallback", `{"max_output_tokens":null,"max_completion_tokens":2048,"max_tokens":1024}`, "2048"},
+ {"null output limit uses max_tokens fallback", `{"max_output_tokens":null,"max_tokens":1024}`, "1024"},
+ {"invalid completion limit skipped", `{"max_completion_tokens":"2048","max_tokens":1024}`, "1024"},
+ {"null completion limit skipped", `{"max_completion_tokens":null,"max_tokens":1024}`, "1024"},
+ {"zero completion limit takes precedence", `{"max_completion_tokens":0,"max_tokens":1024}`, "0"},
+ {"non-numeric output limit preserved", `{"max_output_tokens":"4096","max_completion_tokens":2048}`, `"4096"`},
+ {"non-numeric legacy limits stripped", `{"max_completion_tokens":true,"max_tokens":{}}`, ""},
+ {"null legacy limits stripped", `{"max_completion_tokens":null,"max_tokens":null}`, ""},
+ {"null output limit without numeric fallback preserved", `{"max_output_tokens":null,"max_tokens":"1024"}`, "null"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, errConvert := ConvertOpenAIResponsesRequestToCodex("codex-test", []byte(tt.input), false)
+ if errConvert != nil {
+ t.Fatalf("ConvertOpenAIResponsesRequestToCodex() error = %v", errConvert)
+ }
+ if got := ojson.GetBytesNoCopy(out, "max_output_tokens").Raw; got != tt.want {
+ t.Fatalf("max_output_tokens = %q, want %q; output=%s", got, tt.want, out)
+ }
+ for _, path := range []string{"max_completion_tokens", "max_tokens"} {
+ if ojson.GetBytesNoCopy(out, path).Exists() {
+ t.Fatalf("%s should be stripped; output=%s", path, out)
+ }
+ }
+ })
+ }
+}
+
// TestConvertSystemRoleToDeveloper_BasicConversion tests the basic system -> developer role conversion
func TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) {
inputJSON := []byte(`{
@@ -35,19 +75,19 @@ func TestConvertSystemRoleToDeveloper_BasicConversion(t *testing.T) {
outputStr := string(output)
// Check that system role was converted to developer
- firstItemRole := gjson.Get(outputStr, "input.0.role")
+ firstItemRole := ojson.Get(outputStr, "input.0.role")
if firstItemRole.String() != "developer" {
t.Errorf("Expected role 'developer', got '%s'", firstItemRole.String())
}
// Check that user role remains unchanged
- secondItemRole := gjson.Get(outputStr, "input.1.role")
+ secondItemRole := ojson.Get(outputStr, "input.1.role")
if secondItemRole.String() != "user" {
t.Errorf("Expected role 'user', got '%s'", secondItemRole.String())
}
// Check content is preserved
- firstItemContent := gjson.Get(outputStr, "input.0.content.0.text")
+ firstItemContent := ojson.Get(outputStr, "input.0.content.0.text")
if firstItemContent.String() != "You are a pirate." {
t.Errorf("Expected content 'You are a pirate.', got '%s'", firstItemContent.String())
}
@@ -80,18 +120,18 @@ func TestConvertSystemRoleToDeveloper_MultipleSystemMessages(t *testing.T) {
outputStr := string(output)
// Check that both system roles were converted
- firstRole := gjson.Get(outputStr, "input.0.role")
+ firstRole := ojson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected first role 'developer', got '%s'", firstRole.String())
}
- secondRole := gjson.Get(outputStr, "input.1.role")
+ secondRole := ojson.Get(outputStr, "input.1.role")
if secondRole.String() != "developer" {
t.Errorf("Expected second role 'developer', got '%s'", secondRole.String())
}
// Check that user role is unchanged
- thirdRole := gjson.Get(outputStr, "input.2.role")
+ thirdRole := ojson.Get(outputStr, "input.2.role")
if thirdRole.String() != "user" {
t.Errorf("Expected third role 'user', got '%s'", thirdRole.String())
}
@@ -119,12 +159,12 @@ func TestConvertSystemRoleToDeveloper_NoSystemMessages(t *testing.T) {
outputStr := string(output)
// Check that user and assistant roles are unchanged
- firstRole := gjson.Get(outputStr, "input.0.role")
+ firstRole := ojson.Get(outputStr, "input.0.role")
if firstRole.String() != "user" {
t.Errorf("Expected role 'user', got '%s'", firstRole.String())
}
- secondRole := gjson.Get(outputStr, "input.1.role")
+ secondRole := ojson.Get(outputStr, "input.1.role")
if secondRole.String() != "assistant" {
t.Errorf("Expected role 'assistant', got '%s'", secondRole.String())
}
@@ -141,7 +181,7 @@ func TestConvertSystemRoleToDeveloper_EmptyInput(t *testing.T) {
outputStr := string(output)
// Check that input is still an empty array
- inputArray := gjson.Get(outputStr, "input")
+ inputArray := ojson.Get(outputStr, "input")
if !inputArray.IsArray() {
t.Error("Input should still be an array")
}
@@ -161,12 +201,12 @@ func TestConvertSystemRoleToDeveloper_NoInputField(t *testing.T) {
outputStr := string(output)
// Check that other fields are still set correctly
- stream := gjson.Get(outputStr, "stream")
+ stream := ojson.Get(outputStr, "stream")
if !stream.Bool() {
t.Error("Stream should be set to true by conversion")
}
- store := gjson.Get(outputStr, "store")
+ store := ojson.Get(outputStr, "store")
if store.Bool() {
t.Error("Store should be set to false by conversion")
}
@@ -196,29 +236,29 @@ func TestConvertOpenAIResponsesRequestToCodex_OriginalIssue(t *testing.T) {
outputStr := string(output)
// Verify system role was converted to developer
- firstRole := gjson.Get(outputStr, "input.0.role")
+ firstRole := ojson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected role 'developer', got '%s'", firstRole.String())
}
// Verify stream was set to true (as required by Codex)
- stream := gjson.Get(outputStr, "stream")
+ stream := ojson.Get(outputStr, "stream")
if !stream.Bool() {
t.Error("Stream should be set to true")
}
// Verify other required fields for Codex
- store := gjson.Get(outputStr, "store")
+ store := ojson.Get(outputStr, "store")
if store.Bool() {
t.Error("Store should be false")
}
- parallelCalls := gjson.Get(outputStr, "parallel_tool_calls")
+ parallelCalls := ojson.Get(outputStr, "parallel_tool_calls")
if !parallelCalls.Bool() {
t.Error("parallel_tool_calls should be true")
}
- include := gjson.Get(outputStr, "include")
+ include := ojson.Get(outputStr, "include")
if !include.IsArray() || len(include.Array()) != 1 {
t.Error("include should be an array with one element")
} else if include.Array()[0].String() != "reasoning.encrypted_content" {
@@ -260,24 +300,26 @@ func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
- if stream := gjson.GetBytes(output, "stream"); stream.Type != gjson.True {
+ if stream := ojson.GetBytesNoCopy(output, "stream"); stream.Type != ojson.True {
t.Fatalf("stream = %s, want true", stream.Raw)
}
- if store := gjson.GetBytes(output, "store"); store.Type != gjson.False {
+ if store := ojson.GetBytesNoCopy(output, "store"); store.Type != ojson.False {
t.Fatalf("store = %s, want false", store.Raw)
}
- if parallel := gjson.GetBytes(output, "parallel_tool_calls"); parallel.Type != gjson.True {
+ if parallel := ojson.GetBytesNoCopy(output, "parallel_tool_calls"); parallel.Type != ojson.True {
t.Fatalf("parallel_tool_calls = %s, want true", parallel.Raw)
}
- include := gjson.GetBytes(output, "include").Array()
- if len(include) != 1 || include[0].Type != gjson.String || include[0].String() != "reasoning.encrypted_content" {
- t.Fatalf("include = %s, want reasoning.encrypted_content only", gjson.GetBytes(output, "include").Raw)
+ include := ojson.GetBytesNoCopy(output, "include").Array()
+ if len(include) != 1 || include[0].Type != ojson.String || include[0].String() != "reasoning.encrypted_content" {
+ t.Fatalf("include = %s, want reasoning.encrypted_content only", ojson.GetBytesNoCopy(output, "include").Raw)
}
- if role := gjson.GetBytes(output, "input.0.role").String(); role != "developer" {
+ if role := ojson.GetBytesNoCopy(output, "input.0.role").String(); role != "developer" {
t.Fatalf("input.0.role = %q, want developer", role)
}
+ if maxOutputTokens := ojson.GetBytesNoCopy(output, "max_output_tokens").Int(); maxOutputTokens != 4096 {
+ t.Fatalf("max_output_tokens = %d, want 4096", maxOutputTokens)
+ }
for _, path := range []string{
- "max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
@@ -287,7 +329,7 @@ func TestConvertOpenAIResponsesRequestToCodexNormalizesRequiredFields(t *testing
"prompt_cache_retention",
"user",
} {
- if gjson.GetBytes(output, path).Exists() {
+ if ojson.GetBytesNoCopy(output, path).Exists() {
t.Fatalf("%s should be removed: %s", path, output)
}
}
@@ -321,7 +363,7 @@ func TestConvertOpenAIResponsesRequestToCodex_PreservesWebSearchSourcesInclude(t
inputJSON := []byte(`{"model":"gpt-5.6","input":[{"type":"message","role":"user","content":"hi"}]}`)
if tt.includeJSON != "" {
var errSet error
- inputJSON, errSet = sjson.SetRawBytes(inputJSON, "include", []byte(tt.includeJSON))
+ inputJSON, errSet = ojson.SetRawBytes(inputJSON, "include", []byte(tt.includeJSON))
if errSet != nil {
t.Fatalf("setting include: %v", errSet)
}
@@ -332,13 +374,13 @@ func TestConvertOpenAIResponsesRequestToCodex_PreservesWebSearchSourcesInclude(t
if string(inputJSON) != before {
t.Fatalf("caller input changed:\n got: %s\nwant: %s", inputJSON, before)
}
- got := gjson.GetBytes(output, "include").Array()
+ got := ojson.GetBytesNoCopy(output, "include").Array()
if len(got) != len(tt.want) {
- t.Fatalf("include = %s, want %v", gjson.GetBytes(output, "include").Raw, tt.want)
+ t.Fatalf("include = %s, want %v", ojson.GetBytesNoCopy(output, "include").Raw, tt.want)
}
for i, want := range tt.want {
- if got[i].Type != gjson.String || got[i].String() != want {
- t.Fatalf("include[%d] = %s, want %q; include = %s", i, got[i].Raw, want, gjson.GetBytes(output, "include").Raw)
+ if got[i].Type != ojson.String || got[i].String() != want {
+ t.Fatalf("include[%d] = %s, want %q; include = %s", i, got[i].Raw, want, ojson.GetBytesNoCopy(output, "include").Raw)
}
}
})
@@ -351,9 +393,9 @@ func TestConvertOpenAIResponsesRequestToCodex_WebSearchToolDoesNotOptIntoSources
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
- include := gjson.GetBytes(output, "include").Array()
- if len(include) != 1 || include[0].Type != gjson.String || include[0].String() != "reasoning.encrypted_content" {
- t.Fatalf("include = %s, want reasoning.encrypted_content only", gjson.GetBytes(output, "include").Raw)
+ include := ojson.GetBytesNoCopy(output, "include").Array()
+ if len(include) != 1 || include[0].Type != ojson.String || include[0].String() != "reasoning.encrypted_content" {
+ t.Fatalf("include = %s, want reasoning.encrypted_content only", ojson.GetBytesNoCopy(output, "include").Raw)
}
}
@@ -395,7 +437,7 @@ func TestConvertOpenAIResponsesRequestToCodex_FiltersPromptCacheRetention(t *tes
}`)
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.6-terra", inputJSON, true)
- if gjson.GetBytes(output, "prompt_cache_retention").Exists() {
+ if ojson.GetBytesNoCopy(output, "prompt_cache_retention").Exists() {
t.Fatalf("prompt_cache_retention should be removed: %s", string(output))
}
}
@@ -427,19 +469,19 @@ func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) {
outputStr := string(output)
// Check system -> developer
- firstRole := gjson.Get(outputStr, "input.0.role")
+ firstRole := ojson.Get(outputStr, "input.0.role")
if firstRole.String() != "developer" {
t.Errorf("Expected first role 'developer', got '%s'", firstRole.String())
}
// Check user unchanged
- secondRole := gjson.Get(outputStr, "input.1.role")
+ secondRole := ojson.Get(outputStr, "input.1.role")
if secondRole.String() != "user" {
t.Errorf("Expected second role 'user', got '%s'", secondRole.String())
}
// Check assistant unchanged
- thirdRole := gjson.Get(outputStr, "input.2.role")
+ thirdRole := ojson.Get(outputStr, "input.2.role")
if thirdRole.String() != "assistant" {
t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String())
}
@@ -463,16 +505,16 @@ func TestConvertOpenAIResponsesRequestToCodex_NormalizesWebSearchPreview(t *test
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.4-mini", inputJSON, false)
- if got := gjson.GetBytes(output, "tools.0.type").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(output, "tools.0.type").String(); got != "web_search" {
t.Fatalf("tools.0.type = %q, want %q: %s", got, "web_search", string(output))
}
- if got := gjson.GetBytes(output, "tool_choice.type").String(); got != "allowed_tools" {
+ if got := ojson.GetBytesNoCopy(output, "tool_choice.type").String(); got != "allowed_tools" {
t.Fatalf("tool_choice.type = %q, want %q: %s", got, "allowed_tools", string(output))
}
- if got := gjson.GetBytes(output, "tool_choice.tools.0.type").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(output, "tool_choice.tools.0.type").String(); got != "web_search" {
t.Fatalf("tool_choice.tools.0.type = %q, want %q: %s", got, "web_search", string(output))
}
- if got := gjson.GetBytes(output, "tool_choice.tools.1.type").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(output, "tool_choice.tools.1.type").String(); got != "web_search" {
t.Fatalf("tool_choice.tools.1.type = %q, want %q: %s", got, "web_search", string(output))
}
}
@@ -486,7 +528,7 @@ func TestConvertOpenAIResponsesRequestToCodex_NormalizesTopLevelToolChoicePrevie
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.4-mini", inputJSON, false)
- if got := gjson.GetBytes(output, "tool_choice.type").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(output, "tool_choice.type").String(); got != "web_search" {
t.Fatalf("tool_choice.type = %q, want %q: %s", got, "web_search", string(output))
}
}
@@ -502,7 +544,7 @@ func TestUserFieldDeletion(t *testing.T) {
outputStr := string(output)
// Verify user field is deleted
- userField := gjson.Get(outputStr, "user")
+ userField := ojson.Get(outputStr, "user")
if userField.Exists() {
t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw)
}
@@ -523,10 +565,10 @@ func TestContextManagementCompactionCompatibility(t *testing.T) {
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
- if gjson.Get(outputStr, "context_management").Exists() {
+ if ojson.Get(outputStr, "context_management").Exists() {
t.Fatalf("context_management should be removed for Codex compatibility")
}
- if gjson.Get(outputStr, "truncation").Exists() {
+ if ojson.Get(outputStr, "truncation").Exists() {
t.Fatalf("truncation should be removed for Codex compatibility")
}
}
@@ -541,7 +583,7 @@ func TestTruncationRemovedForCodexCompatibility(t *testing.T) {
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false)
outputStr := string(output)
- if gjson.Get(outputStr, "truncation").Exists() {
+ if ojson.Get(outputStr, "truncation").Exists() {
t.Fatalf("truncation should be removed for Codex compatibility")
}
}
@@ -574,10 +616,10 @@ func TestStripCodexResponsesCacheBreakpoints(t *testing.T) {
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
- if gjson.Get(outputStr, "input.0.content.0.text").String() != "Hello world" {
+ if ojson.Get(outputStr, "input.0.content.0.text").String() != "Hello world" {
t.Fatalf("text content should be preserved")
}
- if gjson.Get(outputStr, "input.0.content.1.text").String() != "Second part" {
+ if ojson.Get(outputStr, "input.0.content.1.text").String() != "Second part" {
t.Fatalf("second content part should be preserved")
}
}
@@ -612,7 +654,7 @@ func TestStripCodexResponsesCacheBreakpoints_FunctionCallOutputParts(t *testing.
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
- if gjson.Get(outputStr, "input.1.output.0.text").String() != "tool output" {
+ if ojson.Get(outputStr, "input.1.output.0.text").String() != "tool output" {
t.Fatalf("function_call_output text should be preserved")
}
}
@@ -642,10 +684,10 @@ func TestStripCodexResponsesCacheBreakpoints_ItemLevel(t *testing.T) {
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
- if gjson.Get(outputStr, "input.0.content.0.text").String() != "hi" {
+ if ojson.Get(outputStr, "input.0.content.0.text").String() != "hi" {
t.Fatalf("message content should be preserved")
}
- if gjson.Get(outputStr, "input.1.output").String() != "plain string output" {
+ if ojson.Get(outputStr, "input.1.output").String() != "plain string output" {
t.Fatalf("string output should be preserved")
}
}
@@ -679,16 +721,16 @@ func TestStripCodexResponsesCacheBreakpoints_CombinedItemAndPartLevel(t *testing
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
- if gjson.Get(outputStr, "input.#").Int() != 1 {
- t.Fatalf("expected 1 input item, got %d", gjson.Get(outputStr, "input.#").Int())
+ if ojson.Get(outputStr, "input.#").Int() != 1 {
+ t.Fatalf("expected 1 input item, got %d", ojson.Get(outputStr, "input.#").Int())
}
- if gjson.Get(outputStr, "input.0.call_id").String() != "call_123" {
+ if ojson.Get(outputStr, "input.0.call_id").String() != "call_123" {
t.Fatalf("call_id should be preserved")
}
- if gjson.Get(outputStr, "input.0.output.0.text").String() != "result part 1" {
+ if ojson.Get(outputStr, "input.0.output.0.text").String() != "result part 1" {
t.Fatalf("output part 0 text should be preserved")
}
- if gjson.Get(outputStr, "input.0.output.1.text").String() != "result part 2" {
+ if ojson.Get(outputStr, "input.0.output.1.text").String() != "result part 2" {
t.Fatalf("output part 1 text should be preserved")
}
}
@@ -726,18 +768,18 @@ func TestStripCodexResponsesCacheBreakpoints_WithSystemRole(t *testing.T) {
outputStr := string(output)
// Check system role is converted to developer
- if gjson.Get(outputStr, "input.0.role").String() != "developer" {
- t.Fatalf("expected role 'developer', got %q", gjson.Get(outputStr, "input.0.role").String())
+ if ojson.Get(outputStr, "input.0.role").String() != "developer" {
+ t.Fatalf("expected role 'developer', got %q", ojson.Get(outputStr, "input.0.role").String())
}
// Check prompt_cache_breakpoint is completely removed from payload
if strings.Contains(outputStr, "prompt_cache_breakpoint") {
t.Fatalf("prompt_cache_breakpoint should not exist in the output JSON")
}
- if gjson.Get(outputStr, "input.0.content.0.text").String() != "System prompt" {
- t.Fatalf("expected system prompt text preserved, got %q", gjson.Get(outputStr, "input.0.content.0.text").String())
+ if ojson.Get(outputStr, "input.0.content.0.text").String() != "System prompt" {
+ t.Fatalf("expected system prompt text preserved, got %q", ojson.Get(outputStr, "input.0.content.0.text").String())
}
- if gjson.Get(outputStr, "input.1.content.0.text").String() != "User query" {
- t.Fatalf("expected user query text preserved, got %q", gjson.Get(outputStr, "input.1.content.0.text").String())
+ if ojson.Get(outputStr, "input.1.content.0.text").String() != "User query" {
+ t.Fatalf("expected user query text preserved, got %q", ojson.Get(outputStr, "input.1.content.0.text").String())
}
}
@@ -777,10 +819,10 @@ func BenchmarkConvertSystemRoleToDeveloperLargeInput(b *testing.B) {
for _, benchmark := range benchmarks {
b.Run(testCase.name+"/"+benchmark.name, func(b *testing.B) {
output := benchmark.fn(testCase.inputJSON)
- if got := gjson.GetBytes(output, "input.0.role").String(); got != "developer" {
+ if got := ojson.GetBytesNoCopy(output, "input.0.role").String(); got != "developer" {
b.Fatalf("input.0.role = %q, want %q", got, "developer")
}
- if got := gjson.GetBytes(output, "input.1.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(output, "input.1.role").String(); got != "user" {
b.Fatalf("input.1.role = %q, want %q", got, "user")
}
@@ -855,7 +897,7 @@ func makeLargeResponsesInputForBenchmark(inputCount int, systemEvery int) []byte
}
func convertSystemRoleToDeveloperPreviousRootPathRewriteForBenchmark(rawJSON []byte) []byte {
- inputResult := gjson.GetBytes(rawJSON, "input")
+ inputResult := ojson.GetBytesNoCopy(rawJSON, "input")
if !inputResult.IsArray() {
return rawJSON
}
@@ -865,8 +907,8 @@ func convertSystemRoleToDeveloperPreviousRootPathRewriteForBenchmark(rawJSON []b
for i := 0; i < len(inputArray); i++ {
rolePath := fmt.Sprintf("input.%d.role", i)
- if gjson.GetBytes(result, rolePath).String() == "system" {
- result, _ = sjson.SetBytes(result, rolePath, "developer")
+ if ojson.GetBytesNoCopy(result, rolePath).String() == "system" {
+ result, _ = ojson.SetBytes(result, rolePath, "developer")
}
}
@@ -956,7 +998,7 @@ func TestConvertOpenAIResponsesRequestToCodex_ServiceTier(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
inputJSON := []byte(fmt.Sprintf(`{"model":"gpt-5.6","service_tier":%s,"input":[{"type":"message","role":"user","content":"hello"}]}`, tt.tierJSON))
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
- res := gjson.GetBytes(output, "service_tier")
+ res := ojson.GetBytesNoCopy(output, "service_tier")
if res.Exists() != tt.wantExists {
t.Fatalf("service_tier exists = %v, want %v; output: %s", res.Exists(), tt.wantExists, string(output))
}
@@ -987,32 +1029,32 @@ func TestConvertOpenAIResponsesRequestToCodex_NormalizesEmptyFunctionCallArgumen
]}`)
output, _ := ConvertOpenAIResponsesRequestToCodex("gpt-5.6", inputJSON, true)
for index, want := range []string{"{}", "{}", `{"cmd":"pwd"}`, "not-json"} {
- if got := gjson.GetBytes(output, "input."+strconv.Itoa(index)+".arguments").String(); got != want {
+ if got := ojson.GetBytesNoCopy(output, "input."+strconv.Itoa(index)+".arguments").String(); got != want {
t.Fatalf("input.%d.arguments = %q, want %q; output: %s", index, got, want, string(output))
}
}
- if gjson.GetBytes(output, "input.4.arguments").Exists() {
+ if ojson.GetBytesNoCopy(output, "input.4.arguments").Exists() {
t.Fatalf("input.4 (missing arguments) should not gain arguments field; output: %s", string(output))
}
- if got := gjson.GetBytes(output, "input.5.arguments").Type; got != gjson.Null {
+ if got := ojson.GetBytesNoCopy(output, "input.5.arguments").Type; got != ojson.Null {
t.Fatalf("input.5.arguments type = %v, want null; output: %s", got, string(output))
}
- if got := gjson.GetBytes(output, "input.6.arguments").Int(); got != 123 {
+ if got := ojson.GetBytesNoCopy(output, "input.6.arguments").Int(); got != 123 {
t.Fatalf("input.6.arguments = %d, want 123; output: %s", got, string(output))
}
- if got := gjson.GetBytes(output, "input.7.type").String(); got != "function_call_output" || gjson.GetBytes(output, "input.7.output").String() != "worktree created" {
+ if got := ojson.GetBytesNoCopy(output, "input.7.type").String(); got != "function_call_output" || ojson.GetBytesNoCopy(output, "input.7.output").String() != "worktree created" {
t.Fatalf("input.7 should be function_call_output with unchanged output; output: %s", string(output))
}
- if got := gjson.GetBytes(output, "input.8.type").String(); got != "custom_tool_call" {
+ if got := ojson.GetBytesNoCopy(output, "input.8.type").String(); got != "custom_tool_call" {
t.Fatalf("input.8.type = %q, want custom_tool_call; output: %s", got, string(output))
}
- if got := gjson.GetBytes(output, "input.8.input").String(); got != "exact patch" {
+ if got := ojson.GetBytesNoCopy(output, "input.8.input").String(); got != "exact patch" {
t.Fatalf("input.8.input = %q, want exact patch; output: %s", got, string(output))
}
- if gjson.GetBytes(output, "input.8.arguments").Exists() {
+ if ojson.GetBytesNoCopy(output, "input.8.arguments").Exists() {
t.Fatalf("custom_tool_call must not gain arguments; output: %s", string(output))
}
- if got := gjson.GetBytes(output, "input.9.type").String(); got != "message" {
+ if got := ojson.GetBytesNoCopy(output, "input.9.type").String(); got != "message" {
t.Fatalf("input.9.type = %q, want message; output: %s", got, string(output))
}
}
@@ -1021,12 +1063,12 @@ func TestConvertOpenAIResponsesRequestToCodexParallelToolCalls(t *testing.T) {
tests := []struct {
name string
field string
- wantType gjson.Type
+ wantType ojson.Type
}{
- {name: "false preserved", field: `"parallel_tool_calls":false,`, wantType: gjson.False},
- {name: "true preserved", field: `"parallel_tool_calls":true,`, wantType: gjson.True},
- {name: "null defaults to true", field: `"parallel_tool_calls":null,`, wantType: gjson.True},
- {name: "omitted defaults to true", field: "", wantType: gjson.True},
+ {name: "false preserved", field: `"parallel_tool_calls":false,`, wantType: ojson.False},
+ {name: "true preserved", field: `"parallel_tool_calls":true,`, wantType: ojson.True},
+ {name: "null defaults to true", field: `"parallel_tool_calls":null,`, wantType: ojson.True},
+ {name: "omitted defaults to true", field: "", wantType: ojson.True},
}
for _, tt := range tests {
@@ -1036,7 +1078,7 @@ func TestConvertOpenAIResponsesRequestToCodexParallelToolCalls(t *testing.T) {
if errConvert != nil {
t.Fatalf("ConvertOpenAIResponsesRequestToCodex returned error: %v", errConvert)
}
- if got := gjson.GetBytes(output, "parallel_tool_calls"); got.Type != tt.wantType {
+ if got := ojson.GetBytesNoCopy(output, "parallel_tool_calls"); got.Type != tt.wantType {
t.Fatalf("parallel_tool_calls = %s (type %v), want type %v; output: %s", got.Raw, got.Type, tt.wantType, string(output))
}
})
diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_response.go b/internal/translator/codex/openai/responses/codex_openai-responses_response.go
index 0d1ca19aac5..f85f80b2bf2 100644
--- a/internal/translator/codex/openai/responses/codex_openai-responses_response.go
+++ b/internal/translator/codex/openai/responses/codex_openai-responses_response.go
@@ -4,9 +4,9 @@ import (
"bytes"
"context"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
@@ -49,11 +49,11 @@ func responsesBridge(param *any) *translatorcommon.ApplyPatchResponsesBridge {
}
func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON, requestRawJSON []byte) []byte {
- eventType := gjson.GetBytes(rawJSON, "type").String()
+ eventType := ojson.GetBytesNoCopy(rawJSON, "type").String()
if eventType != "response.created" && eventType != "response.in_progress" {
return rawJSON
}
- if gjson.GetBytes(rawJSON, "response.model").Exists() {
+ if ojson.GetBytesNoCopy(rawJSON, "response.model").Exists() {
return rawJSON
}
@@ -65,7 +65,7 @@ func setResponsesModel(rawJSON []byte, modelName string, originalRequestRawJSON,
return rawJSON
}
- updated, errSet := sjson.SetBytes(rawJSON, "response.model", requestModelName)
+ updated, errSet := ojson.SetBytes(rawJSON, "response.model", requestModelName)
if errSet != nil {
return rawJSON
}
@@ -84,7 +84,7 @@ func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, _ string,
return nil
}
}
- rootResult := gjson.ParseBytes(converted)
+ rootResult := util.ParseGJSONBytesNoCopy(converted)
// Verify this is a terminal response event.
responseType := rootResult.Get("type").String()
if responseType == "" && rootResult.Get("output").IsArray() {
diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go
index e0b525acbb9..30bf0fe9129 100644
--- a/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go
+++ b/internal/translator/codex/openai/responses/codex_openai-responses_response_test.go
@@ -4,8 +4,8 @@ import (
"context"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
)
func TestConvertCodexResponseToOpenAIResponses_CreatedIncludesOriginalRequestModel(t *testing.T) {
@@ -19,7 +19,7 @@ func TestConvertCodexResponseToOpenAIResponses_CreatedIncludesOriginalRequestMod
if len(outputs) != 1 {
t.Fatalf("%s outputs = %d, want 1", eventName, len(outputs))
}
- if got := gjson.GetBytes(outputs[0], "response.model").String(); got != "original-codex-model" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "response.model").String(); got != "original-codex-model" {
t.Fatalf("%s models = %q, want original-codex-model; payload=%s", eventName, got, outputs[0])
}
}
@@ -30,10 +30,10 @@ func TestConvertCodexResponseToOpenAIResponsesNonStreamIncomplete(t *testing.T)
out := ConvertCodexResponseToOpenAIResponsesNonStream(context.Background(), "gpt-5.5", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; payload=%s", got, out)
}
- if got := gjson.GetBytes(out, "incomplete_details.reason").String(); got != "max_output_tokens" {
+ if got := ojson.GetBytesNoCopy(out, "incomplete_details.reason").String(); got != "max_output_tokens" {
t.Fatalf("incomplete reason = %q, want max_output_tokens; payload=%s", got, out)
}
}
@@ -47,12 +47,12 @@ func TestConvertCodexResponseToOpenAIResponses_PreservesWebSearchSources(t *test
if len(outputs) != 1 {
t.Fatalf("output_item.done outputs = %d, want 1", len(outputs))
}
- if got := gjson.GetBytes(outputs[0], "item.id").String(); got != "ws_fixture_0123456789abcdef0123456789abcdef" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "item.id").String(); got != "ws_fixture_0123456789abcdef0123456789abcdef" {
t.Fatalf("item.id = %q, want ws_fixture_0123456789abcdef0123456789abcdef", got)
}
- sources := gjson.GetBytes(outputs[0], "item.action.sources").Array()
+ sources := ojson.GetBytesNoCopy(outputs[0], "item.action.sources").Array()
if len(sources) != 2 {
- t.Fatalf("item.action.sources = %s, want 2 entries", gjson.GetBytes(outputs[0], "item.action.sources").Raw)
+ t.Fatalf("item.action.sources = %s, want 2 entries", ojson.GetBytesNoCopy(outputs[0], "item.action.sources").Raw)
}
if got := sources[0].Get("url").String(); got != "https://docs.python.org/3.13/library/asyncio.html" {
t.Fatalf("sources[0].url = %q", got)
@@ -66,13 +66,13 @@ func TestConvertCodexResponseToOpenAIResponses_PreservesWebSearchSources(t *test
if len(outputs) != 1 {
t.Fatalf("response.completed outputs = %d, want 1", len(outputs))
}
- if got := gjson.GetBytes(outputs[0], "response.output.0.action.sources.1.url").String(); got != "https://docs.python.org/3/library/asyncio-task.html?highlight=n" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "response.output.0.action.sources.1.url").String(); got != "https://docs.python.org/3/library/asyncio-task.html?highlight=n" {
t.Fatalf("response.output.0.action.sources.1.url = %q", got)
}
- if got := gjson.GetBytes(outputs[0], "response.output.1.id").String(); got != "msg_fixture_0123456789abcdef0123456789abcdef" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "response.output.1.id").String(); got != "msg_fixture_0123456789abcdef0123456789abcdef" {
t.Fatalf("response.output.1.id = %q, want msg_fixture_0123456789abcdef0123456789abcdef", got)
}
- if got := gjson.GetBytes(outputs[0], "response.output.1.content.0.annotations.0.url").String(); got != "https://docs.python.org/3/library/asyncio.html?trk=article-ssr-frontend-pulse_little-text-block&utm_source=openai" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "response.output.1.content.0.annotations.0.url").String(); got != "https://docs.python.org/3/library/asyncio.html?trk=article-ssr-frontend-pulse_little-text-block&utm_source=openai" {
t.Fatalf("citation url = %q", got)
}
}
@@ -82,12 +82,12 @@ func TestConvertCodexResponseToOpenAIResponsesNonStream_PreservesWebSearchSource
out := ConvertCodexResponseToOpenAIResponsesNonStream(context.Background(), "gpt-6-luna", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "output.0.id").String(); got != "ws_fixture_0123456789abcdef0123456789abcdef" {
+ if got := ojson.GetBytesNoCopy(out, "output.0.id").String(); got != "ws_fixture_0123456789abcdef0123456789abcdef" {
t.Fatalf("output.0.id = %q, want ws_fixture_0123456789abcdef0123456789abcdef", got)
}
- sources := gjson.GetBytes(out, "output.0.action.sources").Array()
+ sources := ojson.GetBytesNoCopy(out, "output.0.action.sources").Array()
if len(sources) != 2 {
- t.Fatalf("output.0.action.sources = %s, want 2 entries", gjson.GetBytes(out, "output.0.action.sources").Raw)
+ t.Fatalf("output.0.action.sources = %s, want 2 entries", ojson.GetBytesNoCopy(out, "output.0.action.sources").Raw)
}
if got := sources[0].Get("url").String(); got != "https://docs.python.org/3.13/library/asyncio.html" {
t.Fatalf("sources[0].url = %q", got)
@@ -95,10 +95,10 @@ func TestConvertCodexResponseToOpenAIResponsesNonStream_PreservesWebSearchSource
if got := sources[1].Get("url").String(); got != "https://docs.python.org/3/library/asyncio-task.html?highlight=n" {
t.Fatalf("sources[1].url = %q", got)
}
- if got := gjson.GetBytes(out, "output.1.content.0.annotations.0.type").String(); got != "url_citation" {
+ if got := ojson.GetBytesNoCopy(out, "output.1.content.0.annotations.0.type").String(); got != "url_citation" {
t.Fatalf("annotation type = %q, want url_citation", got)
}
- if got := gjson.GetBytes(out, "output.1.content.0.annotations.0.url").String(); got != "https://docs.python.org/3/library/asyncio.html?trk=article-ssr-frontend-pulse_little-text-block&utm_source=openai" {
+ if got := ojson.GetBytesNoCopy(out, "output.1.content.0.annotations.0.url").String(); got != "https://docs.python.org/3/library/asyncio.html?trk=article-ssr-frontend-pulse_little-text-block&utm_source=openai" {
t.Fatalf("citation url = %q", got)
}
}
@@ -112,7 +112,7 @@ func TestConvertCodexResponseToOpenAIResponses_MissingSourcesStayAbsentWithCitat
if len(outputs) != 1 {
t.Fatalf("output_item.done outputs = %d, want 1", len(outputs))
}
- if gjson.GetBytes(outputs[0], "item.action.sources").Exists() {
+ if ojson.GetBytesNoCopy(outputs[0], "item.action.sources").Exists() {
t.Fatalf("sources should not be fabricated on output_item.done: %s", outputs[0])
}
@@ -121,18 +121,18 @@ func TestConvertCodexResponseToOpenAIResponses_MissingSourcesStayAbsentWithCitat
if len(outputs) != 1 {
t.Fatalf("response.completed outputs = %d, want 1", len(outputs))
}
- if gjson.GetBytes(outputs[0], "response.output.0.action.sources").Exists() {
+ if ojson.GetBytesNoCopy(outputs[0], "response.output.0.action.sources").Exists() {
t.Fatalf("sources should not be fabricated on response.completed: %s", outputs[0])
}
- if got := gjson.GetBytes(outputs[0], "response.output.1.content.0.annotations.0.type").String(); got != "url_citation" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "response.output.1.content.0.annotations.0.type").String(); got != "url_citation" {
t.Fatalf("annotation type = %q, want url_citation; payload=%s", got, outputs[0])
}
out := ConvertCodexResponseToOpenAIResponsesNonStream(context.Background(), "gpt-6-luna", nil, nil, []byte(`{"type":"response.completed","response":{"id":"resp_missing","status":"completed","output":[`+searchItem+","+messageItem+"]}}"), nil)
- if gjson.GetBytes(out, "output.0.action.sources").Exists() {
+ if ojson.GetBytesNoCopy(out, "output.0.action.sources").Exists() {
t.Fatalf("sources should not be fabricated on non-stream response: %s", out)
}
- if got := gjson.GetBytes(out, "output.1.content.0.annotations.0.url").String(); got != "https://docs.python.org/3/library/asyncio.html?trk=article-ssr-frontend-pulse_little-text-block&utm_source=openai" {
+ if got := ojson.GetBytesNoCopy(out, "output.1.content.0.annotations.0.url").String(); got != "https://docs.python.org/3/library/asyncio.html?trk=article-ssr-frontend-pulse_little-text-block&utm_source=openai" {
t.Fatalf("citation url = %q", got)
}
}
@@ -156,12 +156,12 @@ func TestApplyPatchResponsesActualRequestGatesNativeCodex(t *testing.T) {
}
var xai any = translatorcommon.NewApplyPatchResponsesBridge(original)
out = ConvertCodexResponseToOpenAIResponses(t.Context(), "m", original, bridged, raw, &xai)
- if len(out) != 3 || gjson.GetBytes(out[2][6:], "item.input").String() != "p" {
+ if len(out) != 3 || ojson.GetBytesNoCopy(out[2][6:], "item.input").String() != "p" {
t.Fatalf("same Codex wire format bypassed bridge: %s", out)
}
var failed any = translatorcommon.NewApplyPatchResponsesBridge(original)
out = ConvertCodexResponseToOpenAIResponses(t.Context(), "m", original, bridged, []byte(`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","name":"apply_patch","arguments":"{}"}}`), &failed)
- if len(out) != 1 || gjson.GetBytes(out[0][6:], "type").String() != "response.failed" || failed.(interface{ ToolInputError() error }).ToolInputError() == nil {
+ if len(out) != 1 || ojson.GetBytesNoCopy(out[0][6:], "type").String() != "response.failed" || failed.(interface{ ToolInputError() error }).ToolInputError() == nil {
t.Fatalf("failure: %s", out)
}
}
diff --git a/internal/translator/common/apply_patch_events.go b/internal/translator/common/apply_patch_events.go
index 45e3c9177f4..cbc4e5d901f 100644
--- a/internal/translator/common/apply_patch_events.go
+++ b/internal/translator/common/apply_patch_events.go
@@ -1,6 +1,6 @@
package common
-import "github.com/tidwall/sjson"
+import "github.com/MoeForge/moego/ojson"
// ApplyPatchCallState owns the input decoder and identity of one tool call.
type ApplyPatchCallState struct {
@@ -28,34 +28,33 @@ func (s *ApplyPatchCallState) FinishArguments(arguments string) (string, string,
// ApplyPatchInputDelta builds a Responses custom-tool input delta without SSE framing.
func ApplyPatchInputDelta(s *ApplyPatchCallState, delta string, sequence int) []byte {
- payload := []byte(`{"type":"response.custom_tool_call_input.delta","item_id":"","call_id":"","output_index":0,"sequence_number":0,"delta":""}`)
- payload = applyPatchEventIdentity(payload, s, sequence)
- payload, _ = sjson.SetBytes(payload, "delta", delta)
- return payload
+ doc := ojson.NewDoc([]byte(`{"type":"response.custom_tool_call_input.delta","item_id":"","call_id":"","output_index":0,"sequence_number":0,"delta":""}`))
+ applyPatchEventIdentityDoc(doc, s, sequence)
+ _ = doc.Set("delta", delta)
+ return doc.Bytes()
}
// ApplyPatchInputDone builds a Responses custom-tool input completion without SSE framing.
func ApplyPatchInputDone(s *ApplyPatchCallState, input string, sequence int) []byte {
- payload := []byte(`{"type":"response.custom_tool_call_input.done","item_id":"","call_id":"","output_index":0,"sequence_number":0,"input":""}`)
- payload = applyPatchEventIdentity(payload, s, sequence)
- payload, _ = sjson.SetBytes(payload, "input", input)
- return payload
+ doc := ojson.NewDoc([]byte(`{"type":"response.custom_tool_call_input.done","item_id":"","call_id":"","output_index":0,"sequence_number":0,"input":""}`))
+ applyPatchEventIdentityDoc(doc, s, sequence)
+ _ = doc.Set("input", input)
+ return doc.Bytes()
}
-func applyPatchEventIdentity(payload []byte, s *ApplyPatchCallState, sequence int) []byte {
- payload, _ = sjson.SetBytes(payload, "item_id", s.ItemID)
- payload, _ = sjson.SetBytes(payload, "call_id", s.CallID)
- payload, _ = sjson.SetBytes(payload, "output_index", s.OutputIndex)
- payload, _ = sjson.SetBytes(payload, "sequence_number", sequence)
- return payload
+func applyPatchEventIdentityDoc(doc *ojson.Doc, s *ApplyPatchCallState, sequence int) {
+ _ = doc.Set("item_id", s.ItemID)
+ _ = doc.Set("call_id", s.CallID)
+ _ = doc.Set("output_index", s.OutputIndex)
+ _ = doc.Set("sequence_number", sequence)
}
// ApplyPatchFailure builds a terminal Responses failure without exposing upstream arguments.
func ApplyPatchFailure(responseID string, sequence int) []byte {
- payload := []byte(`{"type":"response.failed","sequence_number":0,"response":{"id":"","object":"response","status":"failed","error":{"type":"server_error","code":"invalid_tool_arguments","message":"Invalid apply_patch tool arguments received from upstream.","param":null}}}`)
- payload, _ = sjson.SetBytes(payload, "response.id", responseID)
- payload, _ = sjson.SetBytes(payload, "sequence_number", sequence)
- return payload
+ doc := ojson.NewDoc([]byte(`{"type":"response.failed","sequence_number":0,"response":{"id":"","object":"response","status":"failed","error":{"type":"server_error","code":"invalid_tool_arguments","message":"Invalid apply_patch tool arguments received from upstream.","param":null}}}`))
+ _ = doc.Set("response.id", responseID)
+ _ = doc.Set("sequence_number", sequence)
+ return doc.Bytes()
}
// ApplyPatchErrorState retains a conversion error for the caller.
diff --git a/internal/translator/common/apply_patch_events_test.go b/internal/translator/common/apply_patch_events_test.go
index 2ec03d7d0f9..f745a227ce4 100644
--- a/internal/translator/common/apply_patch_events_test.go
+++ b/internal/translator/common/apply_patch_events_test.go
@@ -9,7 +9,8 @@ import (
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestApplyPatchCallStateInterleavedCalls(t *testing.T) {
@@ -88,7 +89,7 @@ func TestApplyPatchCallStateConcurrentIndependentCalls(t *testing.T) {
return
}
payload := ApplyPatchInputDone(&state, input, index)
- if !json.Valid(payload) || gjson.GetBytes(payload, "call_id").String() != state.CallID {
+ if !json.Valid(payload) || ojson.GetBytesNoCopy(payload, "call_id").String() != state.CallID {
results <- fmt.Errorf("call %d: invalid done identity", index)
return
}
@@ -120,7 +121,7 @@ func TestApplyPatchEventsPayloads(t *testing.T) {
if !json.Valid(test.payload) {
t.Fatalf("payload is not plain JSON: %q", test.payload)
}
- root := gjson.ParseBytes(test.payload)
+ root := util.ParseGJSONBytesNoCopy(test.payload)
for key, want := range map[string]string{"type": test.kind, "item_id": state.ItemID, "call_id": state.CallID, test.textKey: text} {
if got := root.Get(key).String(); got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
@@ -133,7 +134,7 @@ func TestApplyPatchEventsPayloads(t *testing.T) {
}
zero := ApplyPatchInputDelta(&ApplyPatchCallState{}, "", 0)
for _, key := range []string{"item_id", "call_id", "output_index", "sequence_number", "delta"} {
- if !gjson.GetBytes(zero, key).Exists() {
+ if !ojson.GetBytesNoCopy(zero, key).Exists() {
t.Fatalf("zero-valued payload omitted %s", key)
}
}
@@ -155,7 +156,7 @@ func TestApplyPatchFailureSanitizesClientError(t *testing.T) {
if !json.Valid(payload) || strings.Contains(string(payload), "secret-token-value") || strings.Contains(string(payload), "Begin Patch") {
t.Fatalf("invalid or unsanitized failure payload: %q", payload)
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
for key, want := range map[string]string{
"type": "response.failed",
"response.id": "resp_\"1",
diff --git a/internal/translator/common/apply_patch_identity_test.go b/internal/translator/common/apply_patch_identity_test.go
index fcdc667e08a..0645d0410f8 100644
--- a/internal/translator/common/apply_patch_identity_test.go
+++ b/internal/translator/common/apply_patch_identity_test.go
@@ -5,7 +5,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// A known name does not make either missing downstream ID safe to publish.
@@ -49,7 +50,7 @@ func TestApplyPatchResponsesNamedLateIdentity(t *testing.T) {
counts := map[string]int{}
var fragments []string
for _, event := range events {
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
kind := root.Get("type").String()
counts[kind]++
identity := root
@@ -117,7 +118,7 @@ func TestApplyPatchResponsesNamedLateIdentityEvidence(t *testing.T) {
}
out, errTransform = b.Transform(tc.after)
}
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytesNoCopy(out[0], "type").String() != "response.failed" {
t.Fatalf("lost evidence: %s %v", out, errTransform)
}
if out, errTransform = b.Transform(tc.after); len(out) != 0 || errTransform != nil {
@@ -143,7 +144,7 @@ func TestApplyPatchResponsesNamedLateIdentityInterleaved(t *testing.T) {
}
events = append(events, patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`))...)
for _, event := range events {
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
if root.Get("type").String() == "response.completed" {
for i, item := range root.Get("response.output").Array() {
if item.Get("id").String() != fmt.Sprint("a", i) || item.Get("call_id").String() != fmt.Sprint("c", i) || item.Get("input").String() != fmt.Sprint(i) {
diff --git a/internal/translator/common/apply_patch_responses.go b/internal/translator/common/apply_patch_responses.go
index 5229ada36db..276953ab688 100644
--- a/internal/translator/common/apply_patch_responses.go
+++ b/internal/translator/common/apply_patch_responses.go
@@ -5,19 +5,18 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// NormalizeApplyPatchResponsesRequest adapts declarations and explicit custom patch history.
// Winners are collected before rewriting: normalization must not change declaration precedence.
func NormalizeApplyPatchResponsesRequest(raw []byte) ([]byte, error) {
- if !gjson.ValidBytes(raw) {
+ if !ojson.ValidBytes(raw) {
return raw, errors.New("invalid Responses request JSON")
}
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
winners := util.CollectResponsesToolWinners(root)
affected := map[string]bool{}
for _, d := range util.CollectResponsesToolDescriptors(root) {
@@ -25,15 +24,15 @@ func NormalizeApplyPatchResponsesRequest(raw []byte) ([]byte, error) {
affected[d.Name] = true
}
}
- var normalizeTools func(gjson.Result, string) []byte
- normalizeTools = func(tools gjson.Result, namespace string) []byte {
+ var normalizeTools func(ojson.Result, string) []byte
+ normalizeTools = func(tools ojson.Result, namespace string) []byte {
var items [][]byte
for _, tool := range tools.Array() {
item := []byte(tool.Raw)
if tool.Get("type").String() == "namespace" {
for _, key := range []string{"tools", "children"} {
if children := tool.Get(key); children.IsArray() {
- item, _ = sjson.SetRawBytes(item, key, normalizeTools(children, tool.Get("name").String()))
+ item, _ = ojson.SetRawBytes(item, key, normalizeTools(children, tool.Get("name").String()))
break
}
}
@@ -49,10 +48,12 @@ func NormalizeApplyPatchResponsesRequest(raw []byte) ([]byte, error) {
continue
}
if applypatch.IsCustomTool(tool) {
- item, _ = sjson.SetBytes(item, "type", "function")
- item, _ = sjson.SetBytes(item, "description", applypatch.Description(tool))
- item, _ = sjson.SetRawBytes(item, "parameters", applypatch.Parameters())
- item, _ = sjson.DeleteBytes(item, "format")
+ itemDoc := ojson.NewDoc(item)
+ _ = itemDoc.Set("type", "function")
+ _ = itemDoc.Set("description", applypatch.Description(tool))
+ _ = itemDoc.SetRawBytes("parameters", applypatch.Parameters())
+ _ = itemDoc.Delete("format")
+ item = itemDoc.Bytes()
}
}
}
@@ -60,8 +61,9 @@ func NormalizeApplyPatchResponsesRequest(raw []byte) ([]byte, error) {
}
return JoinRawArray(items)
}
+ doc := ojson.NewDoc(raw)
if tools := root.Get("tools"); tools.IsArray() {
- raw, _ = sjson.SetRawBytes(raw, "tools", normalizeTools(tools, ""))
+ _ = doc.SetRawBytes("tools", normalizeTools(tools, ""))
}
patchHistory := map[string]bool{}
for _, item := range root.Get("input").Array() {
@@ -74,41 +76,41 @@ func NormalizeApplyPatchResponsesRequest(raw []byte) ([]byte, error) {
switch item.Get("type").String() {
case "additional_tools":
if tools := item.Get("tools"); tools.IsArray() {
- raw, _ = sjson.SetRawBytes(raw, path+".tools", normalizeTools(tools, ""))
+ _ = doc.SetRawBytes(path+".tools", normalizeTools(tools, ""))
}
case "custom_tool_call":
if strings.TrimSpace(item.Get("name").String()) != "apply_patch" {
continue
}
- if item.Get("input").Type != gjson.String {
+ if item.Get("input").Type != ojson.String {
return nil, errors.New("apply_patch history input must be a string")
}
- raw, _ = sjson.SetBytes(raw, path+".type", "function_call")
- raw, _ = sjson.SetBytes(raw, path+".arguments", applypatch.WrapInput(item.Get("input").String()))
- raw, _ = sjson.DeleteBytes(raw, path+".input")
+ _ = doc.Set(path+".type", "function_call")
+ _ = doc.Set(path+".arguments", applypatch.WrapInput(item.Get("input").String()))
+ _ = doc.Delete(path + ".input")
case "custom_tool_call_output":
if patchHistory[item.Get("call_id").String()] {
- raw, _ = sjson.SetBytes(raw, path+".type", "function_call_output")
+ _ = doc.Set(path+".type", "function_call_output")
}
}
}
- var normalizeChoice func(gjson.Result) []byte
- normalizeChoice = func(choice gjson.Result) []byte {
- out := []byte(choice.Raw)
+ var normalizeChoice func(ojson.Result) []byte
+ normalizeChoice = func(choice ojson.Result) []byte {
+ choiceDoc := ojson.NewDoc([]byte(choice.Raw))
name := choice.Get("name").String()
namespace := choice.Get("namespace").String()
if d, ok := winners[util.QualifyResponsesNamespaceToolName(namespace, name)]; ok && applypatch.IsCustomTool(d.Tool) && choice.Get("type").String() == "custom" {
- out, _ = sjson.SetBytes(out, "type", "function")
+ _ = choiceDoc.Set("type", "function")
}
for i, child := range choice.Get("tools").Array() {
- out, _ = sjson.SetRawBytes(out, fmt.Sprintf("tools.%d", i), normalizeChoice(child))
+ _ = choiceDoc.SetRawBytes(fmt.Sprintf("tools.%d", i), normalizeChoice(child))
}
- return out
+ return choiceDoc.Bytes()
}
if choice := root.Get("tool_choice"); choice.IsObject() {
- raw, _ = sjson.SetRawBytes(raw, "tool_choice", normalizeChoice(choice))
+ _ = doc.SetRawBytes("tool_choice", normalizeChoice(choice))
}
- return raw, nil
+ return doc.Bytes(), nil
}
type responsesPatchRecord struct {
@@ -138,7 +140,7 @@ type ApplyPatchResponsesBridge struct {
// NewApplyPatchResponsesBridge resolves original declarations before any normalization.
func NewApplyPatchResponsesBridge(originalRequest []byte) *ApplyPatchResponsesBridge {
- b := &ApplyPatchResponsesBridge{tools: util.CollectResponsesToolWinners(gjson.ParseBytes(originalRequest)), byItemID: map[string]*responsesPatchRecord{}, byCallID: map[string]*responsesPatchRecord{}, byOutputIndex: map[int]*responsesPatchRecord{}}
+ b := &ApplyPatchResponsesBridge{tools: util.CollectResponsesToolWinners(util.ParseGJSONBytesNoCopy(originalRequest)), byItemID: map[string]*responsesPatchRecord{}, byCallID: map[string]*responsesPatchRecord{}, byOutputIndex: map[int]*responsesPatchRecord{}}
for _, d := range b.tools {
if applypatch.IsCustomTool(d.Tool) {
b.active = true
@@ -160,7 +162,7 @@ func (b *ApplyPatchResponsesBridge) failure(err error) ([][]byte, error) {
// Fail terminates an executor-owned bridge with the same one-shot failure contract.
func (b *ApplyPatchResponsesBridge) Fail(err error) ([][]byte, error) { return b.failure(err) }
-func (b *ApplyPatchResponsesBridge) descriptor(item gjson.Result) (util.ResponsesToolDescriptor, bool) {
+func (b *ApplyPatchResponsesBridge) descriptor(item ojson.Result) (util.ResponsesToolDescriptor, bool) {
name := util.QualifyResponsesNamespaceToolName(item.Get("namespace").String(), item.Get("name").String())
d, ok := b.tools[name]
return d, ok
@@ -168,7 +170,7 @@ func (b *ApplyPatchResponsesBridge) descriptor(item gjson.Result) (util.Response
// resolve checks every supplied identity, not just the first usable key. Conflicting
// unmatched keys and multiple matched records retain evidence until patch provenance is known.
-func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*responsesPatchRecord, error) {
+func (b *ApplyPatchResponsesBridge) resolve(event, item ojson.Result) (*responsesPatchRecord, error) {
ids := []string{event.Get("item_id").String(), item.Get("id").String()}
calls := []string{event.Get("call_id").String(), item.Get("call_id").String()}
index := event.Get("output_index")
@@ -234,12 +236,12 @@ func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*response
// must not create a fresh record and erase the earlier contradiction.
for _, id := range ids {
if id != "" && b.byItemID[id] == nil {
- b.byItemID[id] = r
+ b.byItemID[strings.Clone(id)] = r
}
}
for _, id := range calls {
if id != "" && b.byCallID[id] == nil {
- b.byCallID[id] = r
+ b.byCallID[strings.Clone(id)] = r
}
}
if index.Exists() && b.byOutputIndex[int(index.Int())] == nil {
@@ -251,14 +253,16 @@ func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*response
} else {
for _, id := range ids {
if id != "" {
- r.state.ItemID = id
- b.byItemID[id] = r
+ clonedID := strings.Clone(id)
+ r.state.ItemID = clonedID
+ b.byItemID[clonedID] = r
}
}
for _, id := range calls {
if id != "" {
- r.state.CallID = id
- b.byCallID[id] = r
+ clonedID := strings.Clone(id)
+ r.state.CallID = clonedID
+ b.byCallID[clonedID] = r
}
}
if index.Exists() {
@@ -272,7 +276,7 @@ func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*response
r.evidence = errors.New("conflicting apply_patch call type")
}
if r.kind == "" {
- r.kind = kind
+ r.kind = strings.Clone(kind)
}
}
name := item.Get("name").String()
@@ -285,9 +289,9 @@ func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*response
r.evidence = errors.New("conflicting apply_patch call name")
}
r.named = true
- r.qualified = qualified
- r.state.Name = name
- r.state.Namespace = item.Get("namespace").String()
+ r.qualified = strings.Clone(qualified)
+ r.state.Name = strings.Clone(name)
+ r.state.Namespace = strings.Clone(item.Get("namespace").String())
if known {
r.state.Name = d.LocalName
r.state.Namespace = d.Namespace
@@ -305,54 +309,55 @@ func (b *ApplyPatchResponsesBridge) resolve(event, item gjson.Result) (*response
// CheckIdentity retains every supplied key before a folded dispatcher reveals its child.
// Names are deliberately withheld: a dispatcher name is not the selected child name.
func (b *ApplyPatchResponsesBridge) CheckIdentity(event []byte) error {
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
item := root.Get("item")
if item.Exists() {
- raw := []byte(item.Raw)
- raw, _ = sjson.DeleteBytes(raw, "name")
- raw, _ = sjson.DeleteBytes(raw, "namespace")
- item = gjson.ParseBytes(raw)
+ rawDoc := ojson.NewDocString(item.Raw)
+ _ = rawDoc.Delete("name")
+ _ = rawDoc.Delete("namespace")
+ item = util.ParseGJSONBytesNoCopy(rawDoc.Bytes())
}
_, errResolve := b.resolve(root, item)
return errResolve
}
func (b *ApplyPatchResponsesBridge) restoreItem(item []byte, r *responsesPatchRecord, input string, added bool) []byte {
+ itemDoc := ojson.NewDoc(item)
if r.patch {
- item, _ = sjson.SetBytes(item, "type", "custom_tool_call")
- item, _ = sjson.DeleteBytes(item, "arguments")
- item, _ = sjson.SetBytes(item, "input", input)
+ _ = itemDoc.Set("type", "custom_tool_call")
+ _ = itemDoc.Delete("arguments")
+ _ = itemDoc.Set("input", input)
}
if d, ok := b.tools[r.qualified]; ok && d.Namespace != "" {
- item, _ = sjson.SetBytes(item, "name", d.LocalName)
- item, _ = sjson.SetBytes(item, "namespace", d.Namespace)
+ _ = itemDoc.Set("name", d.LocalName)
+ _ = itemDoc.Set("namespace", d.Namespace)
}
if r.patch && !added {
if r.state.ItemID != "" {
- item, _ = sjson.SetBytes(item, "id", r.state.ItemID)
+ _ = itemDoc.Set("id", r.state.ItemID)
}
if r.state.CallID != "" {
- item, _ = sjson.SetBytes(item, "call_id", r.state.CallID)
+ _ = itemDoc.Set("call_id", r.state.CallID)
}
- item, _ = sjson.SetBytes(item, "name", r.state.Name)
+ _ = itemDoc.Set("name", r.state.Name)
}
- return item
+ return itemDoc.Bytes()
}
func (b *ApplyPatchResponsesBridge) itemEvent(kind string, item []byte, r *responsesPatchRecord) []byte {
- out := []byte(`{"type":"","output_index":0,"sequence_number":0,"item":{}}`)
- out, _ = sjson.SetBytes(out, "type", kind)
- out, _ = sjson.SetBytes(out, "output_index", r.state.OutputIndex)
- out, _ = sjson.SetBytes(out, "sequence_number", b.next())
- out, _ = sjson.SetRawBytes(out, "item", item)
- return out
+ outDoc := ojson.NewDoc([]byte(`{"type":"","output_index":0,"sequence_number":0,"item":{}}`))
+ _ = outDoc.Set("type", kind)
+ _ = outDoc.Set("output_index", r.state.OutputIndex)
+ _ = outDoc.Set("sequence_number", b.next())
+ _ = outDoc.SetRawBytes("item", item)
+ return outDoc.Bytes()
}
-func (b *ApplyPatchResponsesBridge) snapshot(r *responsesPatchRecord, arguments gjson.Result, final bool) error {
+func (b *ApplyPatchResponsesBridge) snapshot(r *responsesPatchRecord, arguments ojson.Result, final bool) error {
if !arguments.Exists() {
return nil
}
- if arguments.Type != gjson.String {
+ if arguments.Type != ojson.String {
return errors.New("apply_patch arguments snapshot must be a string")
}
if arguments.String() == "" && !final {
@@ -372,7 +377,7 @@ func (b *ApplyPatchResponsesBridge) snapshot(r *responsesPatchRecord, arguments
if !strings.HasPrefix(decoder.Input(), r.state.Decoder.Input()) {
return errors.New("apply_patch snapshot conflicts with streamed input")
}
- r.snapshot = arguments.String()
+ r.snapshot = strings.Clone(arguments.String())
r.hasSnapshot = true
return nil
}
@@ -385,7 +390,7 @@ func (b *ApplyPatchResponsesBridge) patchEvent(raw []byte, r *responsesPatchReco
if !r.identityReady() {
return nil, errors.New("unresolved apply_patch call identity")
}
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
kind := root.Get("type").String()
item := root.Get("item")
b.converted = true
@@ -403,17 +408,18 @@ func (b *ApplyPatchResponsesBridge) patchEvent(raw []byte, r *responsesPatchReco
if item.Exists() {
added = []byte(item.Raw)
}
- added, _ = sjson.SetBytes(added, "name", r.state.Name)
+ addedDoc := ojson.NewDoc(added)
+ _ = addedDoc.Set("name", r.state.Name)
if r.state.ItemID != "" {
- added, _ = sjson.SetBytes(added, "id", r.state.ItemID)
+ _ = addedDoc.Set("id", r.state.ItemID)
}
if r.state.CallID != "" {
- added, _ = sjson.SetBytes(added, "call_id", r.state.CallID)
+ _ = addedDoc.Set("call_id", r.state.CallID)
}
if r.state.Namespace != "" {
- added, _ = sjson.SetBytes(added, "namespace", r.state.Namespace)
+ _ = addedDoc.Set("namespace", r.state.Namespace)
}
- out = append(out, b.itemEvent("response.output_item.added", b.restoreItem(added, r, "", true), r))
+ out = append(out, b.itemEvent("response.output_item.added", b.restoreItem(addedDoc.Bytes(), r, "", true), r))
r.added = true
}
switch kind {
@@ -475,17 +481,17 @@ func (b *ApplyPatchResponsesBridge) patchEvent(raw []byte, r *responsesPatchReco
}
func (b *ApplyPatchResponsesBridge) transformItemEvent(raw []byte) ([][]byte, error) {
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
item := root.Get("item")
if !item.Exists() && root.Get("name").Exists() {
// Arguments events can supply late names and identities at the root.
- identity := []byte(`{"type":"function_call"}`)
+ identityDoc := ojson.NewDoc([]byte(`{"type":"function_call"}`))
for _, key := range []string{"name", "namespace", "call_id"} {
if value := root.Get(key); value.Exists() {
- identity, _ = sjson.SetBytes(identity, key, value.Value())
+ _ = identityDoc.Set(key, value.Value())
}
}
- item = gjson.ParseBytes(identity)
+ item = util.ParseGJSONBytesNoCopy(identityDoc.Bytes())
}
r, errResolve := b.resolve(root, item)
if errResolve != nil {
@@ -522,23 +528,25 @@ func (b *ApplyPatchResponsesBridge) transformItemEvent(raw []byte) ([][]byte, er
}
out = append(out, pending...)
if root.Get("item").Exists() && r.kind == "function_call" && b.tools[r.qualified].Namespace != "" {
- raw, _ = sjson.SetRawBytes(raw, "item", b.restoreItem([]byte(item.Raw), r, "", false))
+ raw, _ = ojson.SetRawBytes(raw, "item", b.restoreItem([]byte(item.Raw), r, "", false))
}
if root.Get("type").String() == "response.output_item.done" && item.Exists() {
r.itemDone = true
- r.completedItem = gjson.GetBytes(raw, "item").Raw
+ r.completedItem = strings.Clone(ojson.GetBytesNoCopy(raw, "item").Raw)
}
return append(out, raw), nil
}
func (b *ApplyPatchResponsesBridge) envelope(raw []byte, stream bool) ([]byte, [][]byte, error) {
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
path := "output"
response := root
if root.Get("response").Exists() {
path = "response.output"
response = root.Get("response")
}
+ rawDoc := ojson.NewDoc(raw)
+ modified := false
var preceding [][]byte
seen := map[*responsesPatchRecord]bool{}
var items [][]byte
@@ -560,12 +568,14 @@ func (b *ApplyPatchResponsesBridge) envelope(raw []byte, stream bool) ([]byte, [
}
}
}
- event := gjson.ParseBytes(patchEnvelopeItem(index, item))
+ eventBytes := patchEnvelopeItem(index, item)
+ event := util.ParseGJSONBytesNoCopy(eventBytes)
r, errResolve := b.resolve(event, item)
if errResolve != nil {
return nil, nil, errResolve
}
seen[r] = true
+ itemBytes := []byte(item.Raw)
if r.patch {
pending := r.pending
r.pending = nil
@@ -576,16 +586,20 @@ func (b *ApplyPatchResponsesBridge) envelope(raw []byte, stream bool) ([]byte, [
}
preceding = append(preceding, events...)
}
- events, errEvent := b.patchEvent([]byte(event.Raw), r)
+ events, errEvent := b.patchEvent(eventBytes, r)
if errEvent != nil {
return nil, nil, errEvent
}
preceding = append(preceding, events...)
- raw, _ = sjson.SetRawBytes(raw, fmt.Sprintf("%s.%d", path, i), b.restoreItem([]byte(item.Raw), r, r.state.Decoder.Input(), false))
+ itemBytes = b.restoreItem(itemBytes, r, r.state.Decoder.Input(), false)
+ _ = rawDoc.SetRawBytes(fmt.Sprintf("%s.%d", path, i), itemBytes)
+ modified = true
} else if item.Get("type").String() == "function_call" && b.tools[r.qualified].Namespace != "" {
- raw, _ = sjson.SetRawBytes(raw, fmt.Sprintf("%s.%d", path, i), b.restoreItem([]byte(item.Raw), r, "", false))
+ itemBytes = b.restoreItem(itemBytes, r, "", false)
+ _ = rawDoc.SetRawBytes(fmt.Sprintf("%s.%d", path, i), itemBytes)
+ modified = true
}
- items = append(items, []byte(gjson.GetBytes(raw, fmt.Sprintf("%s.%d", path, i)).Raw))
+ items = append(items, itemBytes)
}
for _, r := range b.records {
if seen[r] || (!r.patch && !b.converted) {
@@ -608,7 +622,8 @@ func (b *ApplyPatchResponsesBridge) envelope(raw []byte, stream bool) ([]byte, [
}
}
if len(items) != len(response.Get("output").Array()) {
- raw, _ = sjson.SetRawBytes(raw, path, JoinRawArray(items))
+ _ = rawDoc.SetRawBytes(path, JoinRawArray(items))
+ modified = true
}
if stream {
if errFinish := b.Finish(); errFinish != nil {
@@ -619,17 +634,21 @@ func (b *ApplyPatchResponsesBridge) envelope(raw []byte, stream bool) ([]byte, [
r.pending = nil
}
if b.converted {
- raw, _ = sjson.SetBytes(raw, "sequence_number", b.next())
+ _ = rawDoc.Set("sequence_number", b.next())
+ modified = true
}
}
+ if modified {
+ raw = rawDoc.Bytes()
+ }
return raw, preceding, nil
}
-func patchEnvelopeItem(index int, item gjson.Result) []byte {
- out := []byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`)
- out, _ = sjson.SetBytes(out, "output_index", index)
- out, _ = sjson.SetRawBytes(out, "item", []byte(item.Raw))
- return out
+func patchEnvelopeItem(index int, item ojson.Result) []byte {
+ outDoc := ojson.NewDoc([]byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`))
+ _ = outDoc.Set("output_index", index)
+ _ = outDoc.SetRaw("item", item.Raw)
+ return outDoc.Bytes()
}
// Transform converts one payload. A failure is terminal and is emitted only once.
@@ -640,9 +659,9 @@ func (b *ApplyPatchResponsesBridge) Transform(event []byte) ([][]byte, error) {
if !b.active {
return [][]byte{event}, nil
}
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
if id := root.Get("response.id").String(); id != "" {
- b.responseID = id
+ b.responseID = strings.Clone(id)
}
if seq := int(root.Get("sequence_number").Int()); seq > b.sequence {
b.sequence = seq
@@ -670,13 +689,13 @@ func (b *ApplyPatchResponsesBridge) Transform(event []byte) ([][]byte, error) {
return b.failure(errTransform)
}
for i, event := range out {
- seq := int(gjson.GetBytes(event, "sequence_number").Int())
+ seq := int(ojson.GetBytesNoCopy(event, "sequence_number").Int())
// Native custom payloads are opaque. Only a stream that acquired a function
// patch call needs resequencing of its other compatibility events.
nativeCustom := strings.HasPrefix(kind, "response.custom_tool_call_input.") || root.Get("item.type").String() == "custom_tool_call"
if b.converted && !nativeCustom && seq <= b.lastSequence {
seq = b.next()
- out[i], _ = sjson.SetBytes(event, "sequence_number", seq)
+ out[i], _ = ojson.SetBytes(event, "sequence_number", seq)
}
if seq > b.lastSequence {
b.lastSequence = seq
diff --git a/internal/translator/common/apply_patch_responses_test.go b/internal/translator/common/apply_patch_responses_test.go
index 54b47f5f9e8..537ccc9443e 100644
--- a/internal/translator/common/apply_patch_responses_test.go
+++ b/internal/translator/common/apply_patch_responses_test.go
@@ -6,8 +6,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var patchResponsesRequest = []byte(`{"tools":[{"type":"custom","name":"apply_patch"}]}`)
@@ -51,7 +52,7 @@ func TestApplyPatchResponsesBridgeDeltaAndFourCompletions(t *testing.T) {
counts := map[string]int{}
lastSequence := int64(-1)
for _, event := range events {
- root := gjson.ParseBytes(event)
+ root := util.ParseGJSONBytesNoCopy(event)
kind := root.Get("type").String()
counts[kind]++
seq := root.Get("sequence_number").Int()
@@ -127,7 +128,7 @@ func TestApplyPatchResponsesBridgeIdentityAndSnapshotEvidence(t *testing.T) {
patchSend(t, b, e)
}
out, errTransform := b.Transform(tc.after)
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" || b.ToolInputError() == nil {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytesNoCopy(out[0], "type").String() != "response.failed" || b.ToolInputError() == nil {
t.Fatalf("unsafe evidence accepted: %s %v", out, errTransform)
}
if more, errMore := b.Transform([]byte(`{"type":"response.completed","response":{"output":[]}}`)); len(more) != 0 || errMore != nil {
@@ -152,7 +153,7 @@ func TestApplyPatchResponsesBridgeStagesAndFinish(t *testing.T) {
t.Fatalf("missing input.done: %s", out)
}
out = patchSend(t, b, patchEvent("response.output_item.done", i, patchItem("function_call", fmt.Sprint("a", i), fmt.Sprint("c", i), "apply_patch", args)))
- if len(out) != 1 || gjson.GetBytes(out[0], "item.input").String() != fmt.Sprint("patch", i) {
+ if len(out) != 1 || ojson.GetBytesNoCopy(out[0], "item.input").String() != fmt.Sprint("patch", i) {
t.Fatalf("duplicate arguments: %s", out)
}
if out = patchSend(t, b, patchEvent("response.output_item.done", i, patchItem("function_call", fmt.Sprint("a", i), fmt.Sprint("c", i), "apply_patch", args))); len(out) != 0 {
@@ -170,17 +171,17 @@ func TestApplyPatchResponsesRequestHistoryAndWinners(t *testing.T) {
`{"tools":[{"type":"function","name":"apply_patch"}],"input":[]}`,
`{"input":[]}`,
} {
- root := gjson.Parse(req)
+ root := ojson.Parse(req)
_ = root
raw := strings.Replace(req, `"input":[]`, `"input":[{"type":"custom_tool_call","call_id":"old","name":"apply_patch","input":"{\"input\":\"raw\"}"},{"type":"custom_tool_call_output","call_id":"old","output":"ok"},{"type":"function_call","call_id":"fn","name":"apply_patch","arguments":"{\"input\":\"existing\"}"}]`, 1)
out, errNormalize := NormalizeApplyPatchResponsesRequest([]byte(raw))
if errNormalize != nil {
t.Fatal(errNormalize)
}
- if gjson.GetBytes(out, "input.0.arguments").String() != applypatch.WrapInput(`{"input":"raw"}`) || gjson.GetBytes(out, "input.1.type").String() != "function_call_output" || gjson.GetBytes(out, "input.2.arguments").String() != `{"input":"existing"}` {
+ if ojson.GetBytesNoCopy(out, "input.0.arguments").String() != applypatch.WrapInput(`{"input":"raw"}`) || ojson.GetBytesNoCopy(out, "input.1.type").String() != "function_call_output" || ojson.GetBytesNoCopy(out, "input.2.arguments").String() != `{"input":"existing"}` {
t.Fatalf("history: %s", out)
}
- if strings.Contains(req, `"type":"function"`) && gjson.GetBytes(out, "tools.0.parameters").Exists() {
+ if strings.Contains(req, `"type":"function"`) && ojson.GetBytesNoCopy(out, "tools.0.parameters").Exists() {
t.Fatalf("ordinary declaration changed: %s", out)
}
}
@@ -198,7 +199,7 @@ func TestApplyPatchResponsesRequestHistoryAndWinners(t *testing.T) {
item = patchItem("function_call", "a", "c", "n__apply_patch", `{"x":1}`)
}
got := patchSend(t, winners, patchEvent("response.output_item.done", 0, item))
- if len(got) != 1 || gjson.GetBytes(got[0], "item.type").String() != "function_call" {
+ if len(got) != 1 || ojson.GetBytesNoCopy(got[0], "item.type").String() != "function_call" {
t.Fatalf("loser stole ordinary identity: %s normalized=%s", got, out)
}
}
@@ -212,7 +213,7 @@ func TestApplyPatchResponsesNamespaceMixedNonStream(t *testing.T) {
if errTransform != nil {
t.Fatal(errTransform)
}
- if gjson.GetBytes(out, "output.0.type").String() != "custom_tool_call" || gjson.GetBytes(out, "output.0.name").String() != "apply_patch" || gjson.GetBytes(out, "output.0.namespace").String() != "n" || gjson.GetBytes(out, "output.1.name").String() != "lookup" || gjson.GetBytes(out, "output.1.namespace").String() != "n" || gjson.GetBytes(out, "output.1.arguments").String() != `{"x":1}` {
+ if ojson.GetBytesNoCopy(out, "output.0.type").String() != "custom_tool_call" || ojson.GetBytesNoCopy(out, "output.0.name").String() != "apply_patch" || ojson.GetBytesNoCopy(out, "output.0.namespace").String() != "n" || ojson.GetBytesNoCopy(out, "output.1.name").String() != "lookup" || ojson.GetBytesNoCopy(out, "output.1.namespace").String() != "n" || ojson.GetBytesNoCopy(out, "output.1.arguments").String() != `{"x":1}` {
t.Fatalf("namespace routing: %s", out)
}
}
@@ -228,11 +229,11 @@ func TestApplyPatchResponsesContinuationMissingSnapshots(t *testing.T) {
}
patchSend(t, b, []byte(fmt.Sprintf(`{"type":%s,"item_id":"a",%s:%s}`, patchJSON(kind), patchJSON(field), patchJSON(applypatch.WrapInput("p")))))
out := patchSend(t, b, []byte(`{"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"a","call_id":"c","name":"apply_patch"}}`))
- if gjson.GetBytes(out[len(out)-1], "item.input").String() != "p" {
+ if ojson.GetBytesNoCopy(out[len(out)-1], "item.input").String() != "p" {
t.Fatalf("missing snapshot: %s", out)
}
out = patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`))
- if gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" {
+ if ojson.GetBytesNoCopy(out[len(out)-1], "response.output.0.input").String() != "p" {
t.Fatalf("omitted completed item: %s", out)
}
})
@@ -256,7 +257,7 @@ func TestApplyPatchResponsesContinuationRootLateName(t *testing.T) {
b := NewApplyPatchResponsesBridge(patchResponsesRequest)
patchSend(t, b, []byte(`{"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"a"}}`))
out := patchSend(t, b, []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"a","call_id":"c","name":"apply_patch","arguments":"{\"input\":\"p\"}"}`))
- if len(out) == 0 || gjson.GetBytes(out[len(out)-1], "type").String() != "response.custom_tool_call_input.done" {
+ if len(out) == 0 || ojson.GetBytesNoCopy(out[len(out)-1], "type").String() != "response.custom_tool_call_input.done" {
t.Fatalf("late root name: %s", out)
}
if errFinish := b.Finish(); errFinish != nil {
@@ -268,7 +269,7 @@ func TestApplyPatchResponsesContinuationNoInventedPreview(t *testing.T) {
b := NewApplyPatchResponsesBridge(patchResponsesRequest)
out := patchSend(t, b, patchEvent("response.output_item.done", 0, patchItem("function_call", "a", "c", "apply_patch", applypatch.WrapInput("p"))))
for _, e := range out {
- if gjson.GetBytes(e, "type").String() == "response.custom_tool_call_input.delta" {
+ if ojson.GetBytesNoCopy(e, "type").String() == "response.custom_tool_call_input.delta" {
t.Fatalf("invented progress: %s", out)
}
}
@@ -290,7 +291,7 @@ func TestApplyPatchResponsesContinuationAllMatchedProvenance(t *testing.T) {
vals[patchKey] = map[string]string{"output_index": "1", "item_id": `"b"`, "call_id": `"cb"`}[patchKey]
e := []byte(fmt.Sprintf(`{"type":"response.function_call_arguments.delta","output_index":%s,"item_id":%s,"call_id":%s,"delta":"{}"}`, vals["output_index"], vals["item_id"], vals["call_id"]))
out, errTransform := b.Transform(e)
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytesNoCopy(out[0], "type").String() != "response.failed" {
t.Fatalf("lost matched provenance: %s %v", out, errTransform)
}
})
@@ -338,13 +339,13 @@ func TestApplyPatchResponsesContinuationOmittedMixedCompletedItems(t *testing.T)
ordinary := `{"type":"message","id":"m","content":[{"type":"output_text","text":"ok"}]}`
patchSend(t, b, patchEvent("response.output_item.done", 1, ordinary))
out := patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[]}}`))
- if gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" || gjson.GetBytes(out[len(out)-1], "response.output.1.id").String() != "m" {
+ if ojson.GetBytesNoCopy(out[len(out)-1], "response.output.0.input").String() != "p" || ojson.GetBytesNoCopy(out[len(out)-1], "response.output.1.id").String() != "m" {
t.Fatalf("completed output lost: %s", out)
}
b = NewApplyPatchResponsesBridge(patchResponsesRequest)
patchSend(t, b, patchEvent("response.output_item.done", 0, patchItem("function_call", "p", "cp", "apply_patch", applypatch.WrapInput("p"))))
out = patchSend(t, b, []byte(`{"type":"response.completed","response":{"output":[`+ordinary+`]}}`))
- if gjson.GetBytes(out[len(out)-1], "response.output.0.input").String() != "p" || gjson.GetBytes(out[len(out)-1], "response.output.1.id").String() != "m" {
+ if ojson.GetBytesNoCopy(out[len(out)-1], "response.output.0.input").String() != "p" || ojson.GetBytesNoCopy(out[len(out)-1], "response.output.1.id").String() != "m" {
t.Fatalf("new final output collided: %s", out)
}
}
@@ -359,7 +360,7 @@ func TestApplyPatchResponsesContinuationUnmatchedIdentityEvidence(t *testing.T)
}
patchSend(t, b, []byte(`{"type":"response.output_item.added","output_index":1,"item_id":"a","call_id":"ca","item":{"type":"function_call","id":"b","call_id":"cb","name":""}}`))
out, errTransform := b.Transform([]byte(`{"type":"response.function_call_arguments.done",` + key + `,"name":"apply_patch","arguments":"{\"input\":\"p\"}"}`))
- if errTransform == nil || len(out) != 1 || gjson.GetBytes(out[0], "type").String() != "response.failed" {
+ if errTransform == nil || len(out) != 1 || ojson.GetBytesNoCopy(out[0], "type").String() != "response.failed" {
t.Fatalf("unmatched-key evidence lost: %s %v", out, errTransform)
}
})
@@ -381,7 +382,7 @@ func TestApplyPatchResponsesContinuationMixedSequence(t *testing.T) {
}
last := int64(-1)
for _, event := range events {
- seq := gjson.GetBytes(event, "sequence_number").Int()
+ seq := ojson.GetBytesNoCopy(event, "sequence_number").Int()
if seq <= last {
t.Fatalf("nonmonotonic mixed sequence: %s", events)
}
diff --git a/internal/translator/common/bytes.go b/internal/translator/common/bytes.go
index 687d56bc2a6..bba08607752 100644
--- a/internal/translator/common/bytes.go
+++ b/internal/translator/common/bytes.go
@@ -5,8 +5,7 @@ import (
"encoding/json"
"strconv"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func GeminiTokenCountJSON(count int64) []byte {
@@ -61,7 +60,7 @@ func SetRawArrayItems(data []byte, path string, items [][]byte) []byte {
return data
}
if len(items) == 1 {
- array := gjson.GetBytes(data, path)
+ array := ojson.GetBytesNoCopy(data, path)
if array.Raw == "[]" && array.Index >= 0 && array.Index+len(array.Raw) <= len(data) {
out := make([]byte, 0, len(data)+len(items[0]))
out = append(out, data[:array.Index]...)
@@ -71,10 +70,18 @@ func SetRawArrayItems(data []byte, path string, items [][]byte) []byte {
return append(out, data[array.Index+len(array.Raw):]...)
}
}
- data, _ = sjson.SetRawBytes(data, path, JoinRawArray(items))
+ data, _ = ojson.SetRawBytes(data, path, JoinRawArray(items))
return data
}
+// SetRawArrayItemsDoc replaces a JSON array at path in doc with raw items.
+func SetRawArrayItemsDoc(doc *ojson.Doc, path string, items [][]byte) {
+ if doc == nil || len(items) == 0 {
+ return
+ }
+ _ = doc.SetRawBytes(path, JoinRawArray(items))
+}
+
// SSEEventData builds one complete SSE frame. Each frame carries its own
// blank-line terminator so concatenated frames stay separable downstream.
func SSEEventData(event string, payload []byte) []byte {
@@ -112,7 +119,7 @@ func AppendSSEEventBytes(out []byte, event string, payload []byte, trailingNewli
}
// SetStringWithoutHTMLEscape sets a string field in a JSON payload without escaping
-// HTML characters (<, >, &). Standard sjson.Set/SetBytes delegates string values
+// HTML characters (<, >, &). Standard ojson.Set/SetBytes delegates string values
// containing quotes or special characters to encoding/json.Marshal, which unconditionally
// escapes <, >, & to \u003c, \u003e, \u0026.
func SetStringWithoutHTMLEscape(data []byte, path, value string) ([]byte, error) {
@@ -120,8 +127,24 @@ func SetStringWithoutHTMLEscape(data []byte, path, value string) ([]byte, error)
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
if errEncode := enc.Encode(value); errEncode != nil {
- return sjson.SetBytes(data, path, value)
+ return ojson.SetBytes(data, path, value)
+ }
+ raw := bytes.TrimRight(buf.Bytes(), "\n")
+ return ojson.SetRawBytes(data, path, raw)
+}
+
+// SetStringWithoutHTMLEscapeDoc sets a string field in an ojson.Doc without escaping
+// HTML characters (<, >, &).
+func SetStringWithoutHTMLEscapeDoc(doc *ojson.Doc, path, value string) error {
+ if doc == nil {
+ return nil
+ }
+ buf := &bytes.Buffer{}
+ enc := json.NewEncoder(buf)
+ enc.SetEscapeHTML(false)
+ if errEncode := enc.Encode(value); errEncode != nil {
+ return doc.Set(path, value)
}
raw := bytes.TrimRight(buf.Bytes(), "\n")
- return sjson.SetRawBytes(data, path, raw)
+ return doc.SetRawBytes(path, raw)
}
diff --git a/internal/translator/common/bytes_test.go b/internal/translator/common/bytes_test.go
index 3f02dce7324..96e2e81fd74 100644
--- a/internal/translator/common/bytes_test.go
+++ b/internal/translator/common/bytes_test.go
@@ -4,7 +4,7 @@ import (
"bytes"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestJoinRawArray(t *testing.T) {
@@ -56,8 +56,14 @@ func TestSetRawArrayItems(t *testing.T) {
if string(got) != test.want {
t.Fatalf("SetRawArrayItems() = %s, want %s", got, test.want)
}
+ doc := ojson.NewDocString(test.data)
+ SetRawArrayItemsDoc(doc, test.path, test.items)
+ if gotDoc := doc.String(); gotDoc != test.want {
+ t.Fatalf("SetRawArrayItemsDoc() = %s, want %s", gotDoc, test.want)
+ }
})
}
+ SetRawArrayItemsDoc(nil, "items", [][]byte{[]byte(`{"id":1}`)})
}
func TestSetStringWithoutHTMLEscape(t *testing.T) {
@@ -130,15 +136,26 @@ func TestSetStringWithoutHTMLEscape(t *testing.T) {
}
// Verify that the JSON parses back and decodes to the exact same value (round-trip)
- res := gjson.GetBytes(got, tc.path)
+ res := ojson.GetBytesNoCopy(got, tc.path)
if !res.Exists() {
t.Fatalf("path %q does not exist in result: %s", tc.path, string(got))
}
if res.String() != tc.value {
t.Fatalf("round-trip value mismatch: got %q, want %q", res.String(), tc.value)
}
+
+ doc := ojson.NewDocString(tc.data)
+ if errDoc := SetStringWithoutHTMLEscapeDoc(doc, tc.path, tc.value); errDoc != nil {
+ t.Fatalf("SetStringWithoutHTMLEscapeDoc() error: %v", errDoc)
+ }
+ if gotDoc := doc.String(); gotDoc != tc.want {
+ t.Fatalf("SetStringWithoutHTMLEscapeDoc() = %s, want %s", gotDoc, tc.want)
+ }
})
}
+ if errNil := SetStringWithoutHTMLEscapeDoc(nil, "arguments", "val"); errNil != nil {
+ t.Fatalf("SetStringWithoutHTMLEscapeDoc(nil) error: %v", errNil)
+ }
}
func TestSSEEventData_SelfTerminatingFrame(t *testing.T) {
diff --git a/internal/translator/common/cache_control.go b/internal/translator/common/cache_control.go
index c52d24e5c0b..39e6245ed29 100644
--- a/internal/translator/common/cache_control.go
+++ b/internal/translator/common/cache_control.go
@@ -4,20 +4,20 @@ import (
"fmt"
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// isValidCacheControl checks if the cache_control object has an exact valid "type" of "ephemeral".
-func isValidCacheControl(cc gjson.Result) bool {
- if !cc.Exists() || cc.Type == gjson.Null || !cc.IsObject() {
+func isValidCacheControl(cc ojson.Result) bool {
+ if !cc.Exists() || cc.Type == ojson.Null || !cc.IsObject() {
return false
}
typ := cc.Get("type")
- return typ.Type == gjson.String && typ.String() == "ephemeral"
+ return typ.Type == ojson.String && typ.String() == "ephemeral"
}
-func claudeBlockCanHostCacheControl(block gjson.Result) bool {
+func claudeBlockCanHostCacheControl(block ojson.Result) bool {
if !block.IsObject() {
return false
}
@@ -35,12 +35,12 @@ func claudeBlockCanHostCacheControl(block gjson.Result) bool {
// AttachCacheControl copies a Claude-compatible cache_control object from src onto dst.
// Returns dst unchanged when cache_control is missing, not an object, or dst is an empty text block.
-func AttachCacheControl(dst []byte, src gjson.Result) []byte {
+func AttachCacheControl(dst []byte, src ojson.Result) []byte {
cc := src.Get("cache_control")
- if !isValidCacheControl(cc) || !claudeBlockCanHostCacheControl(gjson.ParseBytes(dst)) {
+ if !isValidCacheControl(cc) || !claudeBlockCanHostCacheControl(util.ParseGJSONBytesNoCopy(dst)) {
return dst
}
- out, err := sjson.SetRawBytes(dst, "cache_control", []byte(cc.Raw))
+ out, err := ojson.SetRawBytes(dst, "cache_control", []byte(cc.Raw))
if err != nil {
return dst
}
@@ -50,13 +50,13 @@ func AttachCacheControl(dst []byte, src gjson.Result) []byte {
// AttachMessageCacheControl applies message-level cache_control onto the last eligible non-empty content block.
// Part-level cache_control wins when the target block already has one.
// String content is promoted to a content array so Claude can accept cache_control.
-func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte {
+func AttachMessageCacheControl(msg []byte, src ojson.Result) []byte {
cc := src.Get("cache_control")
if !isValidCacheControl(cc) {
return msg
}
- content := gjson.GetBytes(msg, "content")
+ content := ojson.GetBytesNoCopy(msg, "content")
if content.IsArray() {
arr := content.Array()
targetIdx := -1
@@ -73,35 +73,34 @@ func AttachMessageCacheControl(msg []byte, src gjson.Result) []byte {
return msg
}
path := fmt.Sprintf("content.%d.cache_control", targetIdx)
- out, err := sjson.SetRawBytes(msg, path, []byte(cc.Raw))
+ out, err := ojson.SetRawBytes(msg, path, []byte(cc.Raw))
if err != nil {
return msg
}
return out
}
- if content.Type != gjson.String || strings.TrimSpace(content.String()) == "" {
+ if content.Type != ojson.String || strings.TrimSpace(content.String()) == "" {
return msg
}
- textPart := []byte(`{"type":"text","text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", content.String())
- textPart, errSet := sjson.SetRawBytes(textPart, "cache_control", []byte(cc.Raw))
- if errSet != nil {
+ textPartDoc := ojson.NewDoc([]byte(`{"type":"text","text":""}`))
+ _ = textPartDoc.Set("text", content.String())
+ if errSet := textPartDoc.SetRaw("cache_control", cc.Raw); errSet != nil {
return msg
}
- out, err := sjson.SetRawBytes(msg, "content", []byte("[]"))
- if err != nil {
+ outDoc := ojson.NewDoc(msg)
+ if errSetContent := outDoc.SetRaw("content", "[]"); errSetContent != nil {
return msg
}
- out, _ = sjson.SetRawBytes(out, "content.-1", textPart)
- return out
+ _ = outDoc.SetRawBytes("content.-1", textPartDoc.Bytes())
+ return outDoc.Bytes()
}
// AttachToolMessageCacheControl hoists part-level or message-level cache_control
// onto the tool_result content block in msg.
// Part-level cache_control from src content takes precedence over message-level cache_control.
-func AttachToolMessageCacheControl(msg []byte, src gjson.Result) []byte {
+func AttachToolMessageCacheControl(msg []byte, src ojson.Result) []byte {
rawCC := extractFirstPartCacheControl(src)
if rawCC == "" {
if cc := src.Get("cache_control"); isValidCacheControl(cc) {
@@ -112,7 +111,7 @@ func AttachToolMessageCacheControl(msg []byte, src gjson.Result) []byte {
return msg
}
- content := gjson.GetBytes(msg, "content")
+ content := ojson.GetBytesNoCopy(msg, "content")
if content.IsArray() {
arr := content.Array()
if len(arr) == 0 {
@@ -129,7 +128,7 @@ func AttachToolMessageCacheControl(msg []byte, src gjson.Result) []byte {
return msg
}
path := fmt.Sprintf("content.%d.cache_control", targetIdx)
- out, err := sjson.SetRawBytes(msg, path, []byte(rawCC))
+ out, err := ojson.SetRawBytes(msg, path, []byte(rawCC))
if err == nil {
return out
}
@@ -137,14 +136,14 @@ func AttachToolMessageCacheControl(msg []byte, src gjson.Result) []byte {
return msg
}
-func extractFirstPartCacheControl(src gjson.Result) string {
+func extractFirstPartCacheControl(src ojson.Result) string {
content := src.Get("content")
if !content.Exists() {
content = src
}
if content.IsArray() {
var raw string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if cc := part.Get("cache_control"); isValidCacheControl(cc) {
raw = cc.Raw
return false
diff --git a/internal/translator/common/cache_control_test.go b/internal/translator/common/cache_control_test.go
index 26f21e32d99..a7a1b4b2c09 100644
--- a/internal/translator/common/cache_control_test.go
+++ b/internal/translator/common/cache_control_test.go
@@ -4,68 +4,68 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestAttachCacheControl_CopiesObject(t *testing.T) {
- src := gjson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral","ttl":"5m"}}`)
+ src := ojson.Parse(`{"text":"hi","cache_control":{"type":"ephemeral","ttl":"5m"}}`)
dst := []byte(`{"type":"text","text":"hi"}`)
out := AttachCacheControl(dst, src)
- if got := gjson.GetBytes(out, "cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytesNoCopy(out, "cache_control.type").String(); got != "ephemeral" {
t.Fatalf("cache_control.type = %q, want ephemeral; out=%s", got, out)
}
- if got := gjson.GetBytes(out, "cache_control.ttl").String(); got != "5m" {
+ if got := ojson.GetBytesNoCopy(out, "cache_control.ttl").String(); got != "5m" {
t.Fatalf("cache_control.ttl = %q, want 5m; out=%s", got, out)
}
}
func TestAttachCacheControl_IgnoresMissing(t *testing.T) {
- src := gjson.Parse(`{"text":"hi"}`)
+ src := ojson.Parse(`{"text":"hi"}`)
dst := []byte(`{"type":"text","text":"hi"}`)
out := AttachCacheControl(dst, src)
- if gjson.GetBytes(out, "cache_control").Exists() {
+ if ojson.GetBytesNoCopy(out, "cache_control").Exists() {
t.Fatalf("cache_control should be absent; out=%s", out)
}
}
func TestAttachMessageCacheControl_PromotesStringContent(t *testing.T) {
- src := gjson.Parse(`{"role":"user","content":"hi","cache_control":{"type":"ephemeral"}}`)
+ src := ojson.Parse(`{"role":"user","content":"hi","cache_control":{"type":"ephemeral"}}`)
msg := []byte(`{"role":"user","content":"hi"}`)
out := AttachMessageCacheControl(msg, src)
- if got := gjson.GetBytes(out, "content.0.type").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.type").String(); got != "text" {
t.Fatalf("content.0.type = %q, want text; out=%s", got, out)
}
- if got := gjson.GetBytes(out, "content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.text").String(); got != "hi" {
t.Fatalf("content.0.text = %q, want hi; out=%s", got, out)
}
- if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral; out=%s", got, out)
}
}
func TestAttachMessageCacheControl_SkipsWhenLastPartHasCacheControl(t *testing.T) {
- src := gjson.Parse(`{"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
+ src := ojson.Parse(`{"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
msg := []byte(`{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}`)
out := AttachMessageCacheControl(msg, src)
- if gjson.GetBytes(out, "content.0.cache_control.ttl").Exists() {
+ if ojson.GetBytesNoCopy(out, "content.0.cache_control.ttl").Exists() {
t.Fatalf("part-level cache_control should win; out=%s", out)
}
}
func TestAttachToolMessageCacheControl_HoistsPartLevel(t *testing.T) {
- src := gjson.Parse(`{"role":"tool","content":[{"type":"text","text":"4","cache_control":{"type":"ephemeral"}}],"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
+ src := ojson.Parse(`{"role":"tool","content":[{"type":"text","text":"4","cache_control":{"type":"ephemeral"}}],"cache_control":{"type":"ephemeral","ttl":"1h"}}`)
msg := []byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":[{"type":"text","text":"4"}]}]}`)
out := AttachToolMessageCacheControl(msg, src)
- if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("tool_result cache_control.type = %q, want ephemeral; out=%s", got, out)
}
// Part-level does not carry ttl, so ttl should be absent
- if gjson.GetBytes(out, "content.0.cache_control.ttl").Exists() {
+ if ojson.GetBytesNoCopy(out, "content.0.cache_control.ttl").Exists() {
t.Fatalf("part-level should take precedence over message-level; out=%s", out)
}
}
@@ -83,14 +83,14 @@ func TestAttachToolMessageCacheControl_InvalidPartDoesNotBlockMessageLevel(t *te
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- src := gjson.Parse(fmt.Sprintf(`{"role":"tool","content":[{"type":"text","text":"4","cache_control":%s}],"cache_control":{"type":"ephemeral","ttl":"1h"}}`, tc.partCCJSON))
+ src := ojson.Parse(fmt.Sprintf(`{"role":"tool","content":[{"type":"text","text":"4","cache_control":%s}],"cache_control":{"type":"ephemeral","ttl":"1h"}}`, tc.partCCJSON))
msg := []byte(`{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":[{"type":"text","text":"4"}]}]}`)
out := AttachToolMessageCacheControl(msg, src)
- if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("[%s] expected message-level ephemeral to win; got %q; out=%s", tc.name, got, out)
}
- if got := gjson.GetBytes(out, "content.0.cache_control.ttl").String(); got != "1h" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.cache_control.ttl").String(); got != "1h" {
t.Fatalf("[%s] expected message-level ttl=1h; got %q; out=%s", tc.name, got, out)
}
})
@@ -98,25 +98,25 @@ func TestAttachToolMessageCacheControl_InvalidPartDoesNotBlockMessageLevel(t *te
}
func TestAttachToolMessageCacheControl_NoToolResultLeavesMessageUntouched(t *testing.T) {
- src := gjson.Parse(`{"role":"tool","cache_control":{"type":"ephemeral"}}`)
+ src := ojson.Parse(`{"role":"tool","cache_control":{"type":"ephemeral"}}`)
msg := []byte(`{"role":"user","content":[{"type":"text","text":"just text"}]}`)
out := AttachToolMessageCacheControl(msg, src)
- if gjson.GetBytes(out, "content.0.cache_control").Exists() {
+ if ojson.GetBytesNoCopy(out, "content.0.cache_control").Exists() {
t.Fatalf("expected text block to not receive cache_control; out=%s", out)
}
}
func TestAttachMessageCacheControl_SkipsEmptyAndWhitespaceTextBlocks(t *testing.T) {
- src := gjson.Parse(`{"cache_control":{"type":"ephemeral"}}`)
+ src := ojson.Parse(`{"cache_control":{"type":"ephemeral"}}`)
t.Run("picks last non-empty block when trailing blocks are empty", func(t *testing.T) {
msg := []byte(`{"role":"user","content":[{"type":"text","text":"hello"},{"type":"text","text":""},{"type":"text","text":" "}]}`)
out := AttachMessageCacheControl(msg, src)
- if got := gjson.GetBytes(out, "content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("content.0.cache_control.type = %q, want ephemeral; out=%s", got, out)
}
- if gjson.GetBytes(out, "content.1.cache_control").Exists() || gjson.GetBytes(out, "content.2.cache_control").Exists() {
+ if ojson.GetBytesNoCopy(out, "content.1.cache_control").Exists() || ojson.GetBytesNoCopy(out, "content.2.cache_control").Exists() {
t.Fatalf("empty/whitespace text blocks must not receive cache_control; out=%s", out)
}
})
@@ -128,7 +128,7 @@ func TestAttachMessageCacheControl_SkipsEmptyAndWhitespaceTextBlocks(t *testing.
[]byte(`{"role":"user","content":[{"type":"text","text":""},{"type":"text","text":" \n "}]}`),
} {
out := AttachMessageCacheControl(msg, src)
- if gjson.GetBytes(out, "content.0.cache_control").Exists() {
+ if ojson.GetBytesNoCopy(out, "content.0.cache_control").Exists() {
t.Fatalf("empty message content must not receive cache_control; out=%s", out)
}
}
diff --git a/internal/translator/common/claude_messages.go b/internal/translator/common/claude_messages.go
index 158fb11c14a..17c918235d9 100644
--- a/internal/translator/common/claude_messages.go
+++ b/internal/translator/common/claude_messages.go
@@ -1,8 +1,10 @@
package common
import (
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "strings"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ClaudeMessageAccumulator groups consecutive Claude messages by role.
@@ -25,7 +27,7 @@ func (a *ClaudeMessageAccumulator) Append(message []byte) {
if len(message) == 0 {
return
}
- root := gjson.ParseBytes(message)
+ root := util.ParseGJSONBytesNoCopy(message)
role := root.Get("role").String()
if role != "user" && role != "assistant" {
return
@@ -37,9 +39,9 @@ func (a *ClaudeMessageAccumulator) Append(message []byte) {
if a.role != "" && a.role != role {
a.Flush()
}
- a.role = role
+ a.role = strings.Clone(role)
for _, part := range parts {
- if role == "assistant" && gjson.GetBytes(part, "type").String() == "tool_use" {
+ if role == "assistant" && ojson.GetBytesNoCopy(part, "type").String() == "tool_use" {
a.toolUseParts = append(a.toolUseParts, part)
continue
}
@@ -60,10 +62,10 @@ func (a *ClaudeMessageAccumulator) Flush() {
parts = combined
}
if len(parts) > 0 {
- message := []byte(`{"role":"","content":[]}`)
- message, _ = sjson.SetBytes(message, "role", a.role)
- message, _ = sjson.SetRawBytes(message, "content", JoinRawArray(parts))
- a.messages = append(a.messages, message)
+ messageDoc := ojson.NewDoc([]byte(`{"role":"","content":[]}`))
+ _ = messageDoc.Set("role", a.role)
+ _ = messageDoc.SetRawBytes("content", JoinRawArray(parts))
+ a.messages = append(a.messages, messageDoc.Bytes())
}
a.role = ""
a.content = nil
@@ -79,13 +81,13 @@ func (a *ClaudeMessageAccumulator) Messages() [][]byte {
// AlignClaudeToolResults orders tool_result blocks by the preceding tool_use IDs,
// preserving non-result content blocks at their existing indexes. If a complete
// one-to-one match is unavailable, the original content is returned.
-func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Result {
+func AlignClaudeToolResults(content ojson.Result, toolUseIDs []string) ojson.Result {
if !content.IsArray() || len(toolUseIDs) == 0 {
return content
}
parts := content.Array()
- toolResults := make([]gjson.Result, 0, len(toolUseIDs))
+ toolResults := make([]ojson.Result, 0, len(toolUseIDs))
toolResultIndices := make([]int, 0, len(toolUseIDs))
for i, part := range parts {
if part.Get("type").String() == "tool_result" {
@@ -97,7 +99,7 @@ func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Res
return content
}
- reorderedResults := make([]gjson.Result, 0, len(toolUseIDs))
+ reorderedResults := make([]ojson.Result, 0, len(toolUseIDs))
used := make([]bool, len(toolResults))
for _, toolUseID := range toolUseIDs {
matched := -1
@@ -121,26 +123,26 @@ func AlignClaudeToolResults(content gjson.Result, toolUseIDs []string) gjson.Res
for i, slotIndex := range toolResultIndices {
ordered[slotIndex] = []byte(reorderedResults[i].Raw)
}
- return gjson.ParseBytes(JoinRawArray(ordered))
+ return util.ParseGJSONBytesNoCopy(JoinRawArray(ordered))
}
-func claudeMessageContentParts(content gjson.Result) [][]byte {
- if !content.Exists() || content.Type == gjson.Null {
+func claudeMessageContentParts(content ojson.Result) [][]byte {
+ if !content.Exists() || content.Type == ojson.Null {
return nil
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
if content.String() == "" {
return nil
}
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", content.String())
+ part, _ = ojson.SetBytes(part, "text", content.String())
return [][]byte{part}
}
if !content.IsArray() {
return nil
}
parts := make([][]byte, 0, len(content.Array()))
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.IsObject() {
parts = append(parts, []byte(part.Raw))
}
diff --git a/internal/translator/common/claude_messages_test.go b/internal/translator/common/claude_messages_test.go
index 5358f33cc0e..2fd9fb1b7e1 100644
--- a/internal/translator/common/claude_messages_test.go
+++ b/internal/translator/common/claude_messages_test.go
@@ -3,7 +3,7 @@ package common
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestClaudeMessageAccumulatorGroupsAndOrdersAssistantParts(t *testing.T) {
@@ -16,7 +16,7 @@ func TestClaudeMessageAccumulatorGroupsAndOrdersAssistantParts(t *testing.T) {
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1", len(messages))
}
- content := gjson.GetBytes(messages[0], "content").Array()
+ content := ojson.GetBytesNoCopy(messages[0], "content").Array()
wantTypes := []string{"thinking", "text", "tool_use", "tool_use"}
if len(content) != len(wantTypes) {
t.Fatalf("content count = %d, want %d. Message: %s", len(content), len(wantTypes), string(messages[0]))
@@ -44,16 +44,16 @@ func TestClaudeMessageAccumulatorPreservesUserOrderAndRoleBoundaries(t *testing.
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2", len(messages))
}
- if got := gjson.GetBytes(messages[0], "role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(messages[0], "role").String(); got != "user" {
t.Fatalf("messages[0].role = %q, want user", got)
}
- if got := gjson.GetBytes(messages[0], "content.0.type").String(); got != "tool_result" {
+ if got := ojson.GetBytesNoCopy(messages[0], "content.0.type").String(); got != "tool_result" {
t.Fatalf("first user block type = %q, want tool_result", got)
}
- if got := gjson.GetBytes(messages[0], "content.1.text").String(); got != "continue" {
+ if got := ojson.GetBytesNoCopy(messages[0], "content.1.text").String(); got != "continue" {
t.Fatalf("second user block text = %q, want continue", got)
}
- if got := gjson.GetBytes(messages[1], "role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(messages[1], "role").String(); got != "assistant" {
t.Fatalf("messages[1].role = %q, want assistant", got)
}
}
@@ -72,7 +72,7 @@ func TestClaudeMessageAccumulatorSkipsEmptyMessagesWithoutBreakingTurn(t *testin
if len(messages) != 1 {
t.Fatalf("message count = %d, want 1", len(messages))
}
- if got := gjson.GetBytes(messages[0], "content.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(messages[0], "content.#").Int(); got != 2 {
t.Fatalf("assistant content count = %d, want 2. Message: %s", got, string(messages[0]))
}
}
@@ -87,10 +87,10 @@ func TestClaudeMessageAccumulatorFlushPreservesExplicitBoundary(t *testing.T) {
if len(messages) != 2 {
t.Fatalf("message count = %d, want 2", len(messages))
}
- if got := gjson.GetBytes(messages[0], "content.0.text").String(); got != "system reminder" {
+ if got := ojson.GetBytesNoCopy(messages[0], "content.0.text").String(); got != "system reminder" {
t.Fatalf("first message text = %q, want system reminder", got)
}
- if got := gjson.GetBytes(messages[1], "content.0.text").String(); got != "question" {
+ if got := ojson.GetBytesNoCopy(messages[1], "content.0.text").String(); got != "question" {
t.Fatalf("second message text = %q, want question", got)
}
}
@@ -101,17 +101,17 @@ func TestClaudeMessageAccumulatorPreservesBlockCacheControl(t *testing.T) {
accumulator.Append([]byte(`{"role":"user","content":[{"type":"text","text":"fresh"}]}`))
messages := accumulator.Messages()
- if got := gjson.GetBytes(messages[0], "content.0.cache_control.type").String(); got != "ephemeral" {
+ if got := ojson.GetBytesNoCopy(messages[0], "content.0.cache_control.type").String(); got != "ephemeral" {
t.Fatalf("cache_control.type = %q, want ephemeral", got)
}
- if gjson.GetBytes(messages[0], "content.1.cache_control").Exists() {
+ if ojson.GetBytesNoCopy(messages[0], "content.1.cache_control").Exists() {
t.Fatalf("second block should not have cache_control: %s", string(messages[0]))
}
}
func TestAlignClaudeToolResults(t *testing.T) {
t.Run("reorders permuted results while preserving non-result slots", func(t *testing.T) {
- input := gjson.Parse(`[
+ input := ojson.Parse(`[
{"type":"tool_result","tool_use_id":"call_2","content":"two"},
{"type":"text","text":"extra user text"},
{"type":"tool_result","tool_use_id":"call_1","content":"one"}
@@ -131,7 +131,7 @@ func TestAlignClaudeToolResults(t *testing.T) {
t.Fatalf("parts[2].tool_use_id = %q, want call_2", parts[2].Get("tool_use_id").String())
}
- leadingTextInput := gjson.Parse(`[
+ leadingTextInput := ojson.Parse(`[
{"type":"text","text":"leading text"},
{"type":"tool_result","tool_use_id":"call_2","content":"two"},
{"type":"tool_result","tool_use_id":"call_1","content":"one"}
@@ -153,7 +153,7 @@ func TestAlignClaudeToolResults(t *testing.T) {
})
t.Run("returns original content when count mismatches", func(t *testing.T) {
- input := gjson.Parse(`[
+ input := ojson.Parse(`[
{"type":"tool_result","tool_use_id":"call_1","content":"one"}
]`)
aligned := AlignClaudeToolResults(input, []string{"call_1", "call_2"})
@@ -163,7 +163,7 @@ func TestAlignClaudeToolResults(t *testing.T) {
})
t.Run("returns original content when id not found", func(t *testing.T) {
- input := gjson.Parse(`[
+ input := ojson.Parse(`[
{"type":"tool_result","tool_use_id":"call_unknown","content":"unknown"},
{"type":"tool_result","tool_use_id":"call_1","content":"one"}
]`)
@@ -174,7 +174,7 @@ func TestAlignClaudeToolResults(t *testing.T) {
})
t.Run("returns original content for non-array", func(t *testing.T) {
- input := gjson.Parse(`"text content"`)
+ input := ojson.Parse(`"text content"`)
aligned := AlignClaudeToolResults(input, []string{"call_1"})
if aligned.Raw != input.Raw {
t.Fatalf("aligned = %s, want %s", aligned.Raw, input.Raw)
diff --git a/internal/translator/common/claude_native_response.go b/internal/translator/common/claude_native_response.go
index 16c9f4f5357..b1dd1ae8c77 100644
--- a/internal/translator/common/claude_native_response.go
+++ b/internal/translator/common/claude_native_response.go
@@ -3,18 +3,19 @@ package common
import (
"bytes"
"encoding/json"
+ "strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ClaudeMessagesJSONToSSE adapts a native Messages response for the existing SSE
// aggregators and returns its model. SSE and other payloads pass through unchanged.
func ClaudeMessagesJSONToSSE(rawJSON []byte) ([]byte, string) {
- if !gjson.ValidBytes(rawJSON) {
+ if !ojson.ValidBytes(rawJSON) {
return rawJSON, ""
}
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
if root.Get("type").String() != "message" || !root.Get("content").IsArray() {
return rawJSON, ""
}
@@ -27,28 +28,31 @@ func ClaudeMessagesJSONToSSE(rawJSON []byte) ([]byte, string) {
out.Write(payload)
out.WriteString("\n\n")
}
- message, _ := sjson.SetRawBytes(rawJSON, "content", []byte(`[]`))
- message, _ = sjson.SetBytes(message, "stop_reason", nil)
- message, _ = sjson.SetBytes(message, "stop_sequence", nil)
- emit(map[string]any{"type": "message_start", "message": json.RawMessage(message)})
+ messageDoc := ojson.NewDoc(rawJSON)
+ _ = messageDoc.SetRawBytes("content", []byte(`[]`))
+ _ = messageDoc.Set("stop_reason", nil)
+ _ = messageDoc.Set("stop_sequence", nil)
+ emit(map[string]any{"type": "message_start", "message": json.RawMessage(messageDoc.Bytes())})
for index, block := range root.Get("content").Array() {
start := []byte(block.Raw)
var delta map[string]any
switch block.Get("type").String() {
case "text":
- start, _ = sjson.SetBytes(start, "text", "")
+ start, _ = ojson.SetBytes(start, "text", "")
delta = map[string]any{"type": "text_delta", "text": block.Get("text").String()}
case "tool_use":
- start, _ = sjson.SetRawBytes(start, "input", []byte(`{}`))
+ start, _ = ojson.SetRawBytes(start, "input", []byte(`{}`))
input := block.Get("input").Raw
if input == "" {
input = "{}"
}
delta = map[string]any{"type": "input_json_delta", "partial_json": input}
case "thinking":
- start, _ = sjson.SetBytes(start, "thinking", "")
- start, _ = sjson.SetBytes(start, "signature", "")
+ startDoc := ojson.NewDoc(start)
+ _ = startDoc.Set("thinking", "")
+ _ = startDoc.Set("signature", "")
+ start = startDoc.Bytes()
delta = map[string]any{"type": "thinking_delta", "thinking": block.Get("thinking").String()}
}
emit(map[string]any{"type": "content_block_start", "index": index, "content_block": json.RawMessage(start)})
@@ -77,5 +81,5 @@ func ClaudeMessagesJSONToSSE(rawJSON []byte) ([]byte, string) {
"stop_reason": root.Get("stop_reason").Value(), "stop_sequence": root.Get("stop_sequence").Value(),
}, "usage": usage})
emit(map[string]any{"type": "message_stop"})
- return out.Bytes(), root.Get("model").String()
+ return out.Bytes(), strings.Clone(root.Get("model").String())
}
diff --git a/internal/translator/common/claude_native_response_test.go b/internal/translator/common/claude_native_response_test.go
index 615040a00bd..90c76a42b71 100644
--- a/internal/translator/common/claude_native_response_test.go
+++ b/internal/translator/common/claude_native_response_test.go
@@ -8,7 +8,7 @@ import (
chat "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/claude/openai/chat-completions"
responses "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/claude/openai/responses"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestClaudeNativeResponseNonStreamModelAndContent(t *testing.T) {
@@ -42,7 +42,7 @@ func TestClaudeNativeResponseNonStreamModelAndContent(t *testing.T) {
for _, request := range []string{`{}`, `{"model":"request-alias"}`} {
t.Run(request, func(t *testing.T) {
out := provider.convert(context.Background(), "executor-alias", []byte(request), []byte(request), raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
for path, want := range map[string]string{"model": "claude-native", "id": "msg_native", provider.textPath: text, provider.thinkPath: text} {
if got := root.Get(path).String(); got != want {
t.Errorf("%s = %q, want %q", path, got, want)
diff --git a/internal/translator/common/claude_native_sse_test.go b/internal/translator/common/claude_native_sse_test.go
index fd7279a5abb..c2dd7e54e32 100644
--- a/internal/translator/common/claude_native_sse_test.go
+++ b/internal/translator/common/claude_native_sse_test.go
@@ -6,7 +6,8 @@ import (
"reflect"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestClaudeMessagesJSONToSSEPassthrough(t *testing.T) {
@@ -42,16 +43,16 @@ func TestClaudeMessagesJSONToSSEEvents(t *testing.T) {
if model != "claude-native" {
t.Errorf("model = %q, want claude-native", model)
}
- var events []gjson.Result
+ var events []ojson.Result
for _, line := range bytes.Split(out, []byte("\n")) {
if !bytes.HasPrefix(line, []byte("data: ")) {
continue
}
payload := bytes.TrimPrefix(line, []byte("data: "))
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
t.Fatalf("invalid SSE JSON: %s", payload)
}
- events = append(events, gjson.ParseBytes(payload))
+ events = append(events, util.ParseGJSONBytesNoCopy(payload))
}
wantTypes := []string{
"message_start",
@@ -70,7 +71,7 @@ func TestClaudeMessagesJSONToSSEEvents(t *testing.T) {
t.Errorf("event %d type = %q, want %q", i, got, want)
}
}
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
for _, path := range []string{"id", "model", "role", "usage"} {
if got, want := events[0].Get("message."+path).Value(), root.Get(path).Value(); !reflect.DeepEqual(got, want) {
t.Errorf("message_start %s = %#v, want %#v", path, got, want)
@@ -123,7 +124,7 @@ func TestClaudeMessagesJSONToSSECitations(t *testing.T) {
out, _ := ClaudeMessagesJSONToSSE(raw)
count := 0
for _, line := range bytes.Split(out, []byte("\n")) {
- event := gjson.ParseBytes(bytes.TrimPrefix(line, []byte("data: ")))
+ event := util.ParseGJSONBytesNoCopy(bytes.TrimPrefix(line, []byte("data: ")))
if event.Get("delta.type").String() == "citations_delta" {
count++
if event.Get("index").Int() != 0 || event.Get("delta.citation.encrypted_index").String() != "IDX" {
diff --git a/internal/translator/common/claude_system.go b/internal/translator/common/claude_system.go
index 71f30fe8d10..0c096c6a955 100644
--- a/internal/translator/common/claude_system.go
+++ b/internal/translator/common/claude_system.go
@@ -3,8 +3,8 @@ package common
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
const (
@@ -21,7 +21,7 @@ func SystemReminderText(text string) string {
// ClaudeMessageSystemReminderText converts a Claude message-level system value
// into ordinary user-visible reminder text for non-Claude upstream formats.
-func ClaudeMessageSystemReminderText(content gjson.Result) (string, bool) {
+func ClaudeMessageSystemReminderText(content ojson.Result) (string, bool) {
parts := claudeSystemTextParts(content)
if len(parts) == 0 {
return "", false
@@ -33,11 +33,11 @@ func ClaudeMessageSystemReminderText(content gjson.Result) (string, bool) {
return SystemReminderText(text), true
}
-func claudeSystemTextParts(content gjson.Result) []string {
+func claudeSystemTextParts(content ojson.Result) []string {
if !content.Exists() {
return nil
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
text := content.String()
if text == "" || util.IsClaudeCodeAttributionSystemText(text) {
return nil
@@ -48,7 +48,7 @@ func claudeSystemTextParts(content gjson.Result) []string {
return nil
}
parts := make([]string, 0)
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() != "text" {
return true
}
@@ -65,7 +65,7 @@ func claudeSystemTextParts(content gjson.Result) []string {
// BuildClaudeStructuredOutputInstruction formats structured output settings
// (from Chat Completions response_format or Responses text.format) as explicit
// instructions to inject into Claude's system prompt.
-func BuildClaudeStructuredOutputInstruction(format gjson.Result) string {
+func BuildClaudeStructuredOutputInstruction(format ojson.Result) string {
if !format.Exists() {
return ""
}
diff --git a/internal/translator/common/claude_system_test.go b/internal/translator/common/claude_system_test.go
index 942a0e27756..dcc0d68aa1e 100644
--- a/internal/translator/common/claude_system_test.go
+++ b/internal/translator/common/claude_system_test.go
@@ -4,21 +4,21 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestBuildClaudeStructuredOutputInstruction(t *testing.T) {
t.Run("empty or invalid", func(t *testing.T) {
- if got := BuildClaudeStructuredOutputInstruction(gjson.Result{}); got != "" {
+ if got := BuildClaudeStructuredOutputInstruction(ojson.Result{}); got != "" {
t.Fatalf("expected empty instruction, got: %s", got)
}
- if got := BuildClaudeStructuredOutputInstruction(gjson.Parse(`{"type":"text"}`)); got != "" {
+ if got := BuildClaudeStructuredOutputInstruction(ojson.Parse(`{"type":"text"}`)); got != "" {
t.Fatalf("expected empty instruction for text, got: %s", got)
}
})
t.Run("json_object", func(t *testing.T) {
- got := BuildClaudeStructuredOutputInstruction(gjson.Parse(`{"type":"json_object"}`))
+ got := BuildClaudeStructuredOutputInstruction(ojson.Parse(`{"type":"json_object"}`))
if !strings.Contains(got, "JSON object") {
t.Fatalf("expected JSON object instruction, got: %s", got)
}
@@ -36,7 +36,7 @@ func TestBuildClaudeStructuredOutputInstruction(t *testing.T) {
}
}
}`
- got := BuildClaudeStructuredOutputInstruction(gjson.Parse(input))
+ got := BuildClaudeStructuredOutputInstruction(ojson.Parse(input))
if !strings.Contains(got, "extracted_facts") {
t.Fatalf("expected schema name in instruction, got: %s", got)
}
@@ -56,7 +56,7 @@ func TestBuildClaudeStructuredOutputInstruction(t *testing.T) {
"properties": {"key": {"type": "string"}}
}
}`
- got := BuildClaudeStructuredOutputInstruction(gjson.Parse(input))
+ got := BuildClaudeStructuredOutputInstruction(ojson.Parse(input))
if !strings.Contains(got, "key") {
t.Fatalf("expected key in instruction, got: %s", got)
}
@@ -64,7 +64,7 @@ func TestBuildClaudeStructuredOutputInstruction(t *testing.T) {
t.Run("json_schema without schema falls back to json_object instruction", func(t *testing.T) {
input := `{"type": "json_schema"}`
- got := BuildClaudeStructuredOutputInstruction(gjson.Parse(input))
+ got := BuildClaudeStructuredOutputInstruction(ojson.Parse(input))
if !strings.Contains(got, "JSON object") {
t.Fatalf("expected JSON object fallback, got: %s", got)
}
diff --git a/internal/translator/common/claude_user_id.go b/internal/translator/common/claude_user_id.go
index 0862a5d4ba5..2a48d6cc90b 100644
--- a/internal/translator/common/claude_user_id.go
+++ b/internal/translator/common/claude_user_id.go
@@ -5,7 +5,8 @@ import (
"encoding/hex"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// DeriveClaudeUserID returns a stable value for the Claude request field
@@ -16,16 +17,16 @@ import (
// the same user_id on every worker and every turn, while different
// conversations get different values.
func DeriveClaudeUserID(rawJSON []byte) string {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
- if v := root.Get("metadata.user_id"); v.Exists() && v.Type == gjson.String {
+ if v := root.Get("metadata.user_id"); v.Exists() && v.Type == ojson.String {
if raw := v.String(); strings.TrimSpace(raw) != "" {
- return raw
+ return strings.Clone(raw)
}
}
- if v := root.Get("user"); v.Exists() && v.Type == gjson.String {
+ if v := root.Get("user"); v.Exists() && v.Type == ojson.String {
if raw := v.String(); strings.TrimSpace(raw) != "" {
- return raw
+ return strings.Clone(raw)
}
}
@@ -55,7 +56,7 @@ func DeriveClaudeUserID(rawJSON []byte) string {
if sid := strings.TrimSpace(conversation.Get("id").String()); sid != "" {
seed.WriteString("conversation_id:")
seed.WriteString(sid)
- } else if conversation.Type == gjson.String {
+ } else if conversation.Type == ojson.String {
if sid := strings.TrimSpace(conversation.String()); sid != "" {
seed.WriteString("conversation_id:")
seed.WriteString(sid)
@@ -108,10 +109,10 @@ func DeriveClaudeUserID(rawJSON []byte) string {
return hex.EncodeToString(sum[:])
}
-func firstStableRequestContent(root gjson.Result) string {
+func firstStableRequestContent(root ojson.Result) string {
if messages := root.Get("messages"); messages.IsArray() {
var content string
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
if role == "user" {
content = extractTextContent(message.Get("content"))
@@ -127,13 +128,13 @@ func firstStableRequestContent(root gjson.Result) string {
}
if input := root.Get("input"); input.Exists() {
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
if text := strings.TrimSpace(input.String()); text != "" {
return text
}
} else if input.IsArray() {
var content string
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if isResponsesUserItem(item) {
content = extractResponsesItemText(item.Get("content"))
if content != "" {
@@ -150,13 +151,13 @@ func firstStableRequestContent(root gjson.Result) string {
if contents := root.Get("contents"); contents.IsArray() {
var content string
- contents.ForEach(func(_, contentItem gjson.Result) bool {
+ contents.ForEach(func(_, contentItem ojson.Result) bool {
role := strings.ToLower(strings.TrimSpace(contentItem.Get("role").String()))
// In Gemini API format, missing role defaults to "user"
if role == "" || role == "user" {
if parts := contentItem.Get("parts"); parts.IsArray() {
var texts []string
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if IsGeminiThoughtPart(part) {
return true
}
@@ -183,15 +184,15 @@ func firstStableRequestContent(root gjson.Result) string {
return ""
}
-func extractTextContent(content gjson.Result) string {
- if content.Type == gjson.String {
+func extractTextContent(content ojson.Result) string {
+ if content.Type == ojson.String {
return strings.TrimSpace(content.String())
}
if !content.IsArray() {
return ""
}
var texts []string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() == "text" {
if text := part.Get("text"); text.Exists() {
if val := strings.TrimSpace(text.String()); val != "" {
@@ -204,7 +205,7 @@ func extractTextContent(content gjson.Result) string {
return strings.TrimSpace(strings.Join(texts, "\n"))
}
-func isResponsesUserItem(item gjson.Result) bool {
+func isResponsesUserItem(item ojson.Result) bool {
role := strings.ToLower(strings.TrimSpace(item.Get("role").String()))
if role == "user" {
return true
@@ -220,15 +221,15 @@ func isResponsesUserItem(item gjson.Result) bool {
return false
}
-func extractResponsesItemText(content gjson.Result) string {
- if content.Type == gjson.String {
+func extractResponsesItemText(content ojson.Result) string {
+ if content.Type == ojson.String {
return strings.TrimSpace(content.String())
}
if !content.IsArray() {
return ""
}
var texts []string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
switch part.Get("type").String() {
case "input_text", "output_text", "text":
if text := part.Get("text"); text.Exists() {
diff --git a/internal/translator/common/codex_commentary.go b/internal/translator/common/codex_commentary.go
new file mode 100644
index 00000000000..c0ee89f135f
--- /dev/null
+++ b/internal/translator/common/codex_commentary.go
@@ -0,0 +1,27 @@
+package common
+
+import (
+ "context"
+ "os"
+)
+
+type codexCommentaryKey struct{}
+
+// WithVisibleCodexCommentary snapshots the display policy for one request.
+func WithVisibleCodexCommentary(ctx context.Context, enabled bool) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return context.WithValue(ctx, codexCommentaryKey{}, enabled)
+}
+
+// VisibleCodexCommentary reports the per-request display policy. The environment
+// fallback supports SDK-only users and existing deployments of the opt-in mode.
+func VisibleCodexCommentary(ctx context.Context) bool {
+ if ctx != nil {
+ if enabled, ok := ctx.Value(codexCommentaryKey{}).(bool); ok {
+ return enabled
+ }
+ }
+ return os.Getenv("CPA_VISIBLE_CODEX_COMMENTARY") == "1"
+}
diff --git a/internal/translator/common/codex_replay_signature.go b/internal/translator/common/codex_replay_signature.go
index ff290ee8919..ccf232c6651 100644
--- a/internal/translator/common/codex_replay_signature.go
+++ b/internal/translator/common/codex_replay_signature.go
@@ -3,9 +3,9 @@ package common
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
)
// ResolveCodexReplayTargetModel returns the model name to use for reasoning
@@ -17,10 +17,10 @@ func ResolveCodexReplayTargetModel(modelName string, rawPayload []byte) string {
return modelName
}
if len(rawPayload) > 0 {
- if rawModel := strings.TrimSpace(gjson.GetBytes(rawPayload, "model").String()); rawModel != "" {
+ if rawModel := strings.TrimSpace(ojson.GetBytesNoCopy(rawPayload, "model").String()); rawModel != "" {
switch sigcompat.SignatureProviderFromModelName(thinking.ParseSuffix(rawModel).ModelName) {
case sigcompat.SignatureProviderMeta, sigcompat.SignatureProviderGrok, sigcompat.SignatureProviderGPT:
- return rawModel
+ return strings.Clone(rawModel)
}
}
}
diff --git a/internal/translator/common/codex_replay_signature_test.go b/internal/translator/common/codex_replay_signature_test.go
index 0a9471adc50..c88e71b17ca 100644
--- a/internal/translator/common/codex_replay_signature_test.go
+++ b/internal/translator/common/codex_replay_signature_test.go
@@ -1,7 +1,7 @@
package common
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
)
diff --git a/internal/translator/common/devin_tools.go b/internal/translator/common/devin_tools.go
index 08109e6f0b1..0061f424868 100644
--- a/internal/translator/common/devin_tools.go
+++ b/internal/translator/common/devin_tools.go
@@ -34,11 +34,8 @@ var (
// ObfuscateExecCommandDescription replaces "returning output or a session ID for ongoing interaction"
// with "returning output or an session ID for ongoing interaction".
func ObfuscateExecCommandDescription(desc string) string {
- if strings.Contains(desc, execCommandObfuscatedPhrase) {
- return desc
- }
if strings.Contains(desc, execCommandTargetPhrase) {
- return strings.ReplaceAll(desc, execCommandTargetPhrase, execCommandObfuscatedPhrase)
+ desc = strings.ReplaceAll(desc, execCommandTargetPhrase, execCommandObfuscatedPhrase)
}
return execCommandRegex.ReplaceAllString(desc, execCommandObfuscatedPhrase)
}
@@ -46,26 +43,21 @@ func ObfuscateExecCommandDescription(desc string) string {
// ObfuscateWriteStdinDescription replaces "Writes characters to an existing unified exec session and returns recent output."
// with "Writes characters to a existing unified exec session and returns recent output.".
func ObfuscateWriteStdinDescription(desc string) string {
- if strings.Contains(desc, writeStdinObfuscatedPhrase) {
- return desc
- }
if strings.Contains(desc, writeStdinTargetPhrase) {
- return strings.ReplaceAll(desc, writeStdinTargetPhrase, writeStdinObfuscatedPhrase)
+ desc = strings.ReplaceAll(desc, writeStdinTargetPhrase, writeStdinObfuscatedPhrase)
}
return writeStdinRegex.ReplaceAllString(desc, "Writes characters to a existing unified exec session and returns recent output$1")
}
// SanitizeDevinToolDescription applies description obfuscation for Devin function tools.
-func SanitizeDevinToolDescription(toolName, desc string) string {
+// Clients may embed exec_command or write_stdin descriptions inside another tool
+// (such as the Codex code-mode "exec" tool), and Devin rejects those phrases
+// regardless of the tool name.
+func SanitizeDevinToolDescription(_ string, desc string) string {
if desc == "" {
return desc
}
- cleanTool := strings.ToLower(strings.TrimSpace(toolName))
- if cleanTool == "exec_command" || strings.HasSuffix(cleanTool, "__exec_command") {
- desc = ObfuscateExecCommandDescription(desc)
- }
- if cleanTool == "write_stdin" || strings.HasSuffix(cleanTool, "__write_stdin") {
- desc = ObfuscateWriteStdinDescription(desc)
- }
+ desc = ObfuscateExecCommandDescription(desc)
+ desc = ObfuscateWriteStdinDescription(desc)
return desc
}
diff --git a/internal/translator/common/devin_tools_test.go b/internal/translator/common/devin_tools_test.go
index 0f2792afb50..5fa7e1ba728 100644
--- a/internal/translator/common/devin_tools_test.go
+++ b/internal/translator/common/devin_tools_test.go
@@ -91,9 +91,31 @@ func TestSanitizeDevinToolDescription(t *testing.T) {
t.Errorf("SanitizeDevinToolDescription(write_stdin) does not contain 'to a existing unified exec session': %q", sanitizedStdin)
}
- otherDesc := "Writes characters to an existing unified exec session and returns recent output."
- sanitizedOther := SanitizeDevinToolDescription("other_tool", otherDesc)
- if sanitizedOther != otherDesc {
- t.Errorf("SanitizeDevinToolDescription(other_tool) modified unrelated tool: %q", sanitizedOther)
+ codeModeExecDesc := "Run JavaScript code to orchestrate/compose tool calls\n" +
+ "### exec_command\nRuns a command in a PTY, returning output or a session ID for ongoing interaction.\n" +
+ "### write_stdin\nWrites characters to an existing unified exec session and returns recent output.\n"
+ for _, toolName := range []string{"exec", "functions__exec", "other_tool"} {
+ sanitized := SanitizeDevinToolDescription(toolName, codeModeExecDesc)
+ if !strings.Contains(sanitized, "an session ID") {
+ t.Errorf("SanitizeDevinToolDescription(%s) does not contain 'an session ID': %q", toolName, sanitized)
+ }
+ if !strings.Contains(sanitized, "to a existing unified exec session") {
+ t.Errorf("SanitizeDevinToolDescription(%s) does not contain 'to a existing unified exec session': %q", toolName, sanitized)
+ }
+ }
+
+ mixedDesc := "First occurrence: returning output or an session ID for ongoing interaction.\n" +
+ "Second occurrence: returning output or a session ID for ongoing interaction.\n" +
+ "First stdin: Writes characters to a existing unified exec session and returns recent output.\n" +
+ "Second stdin: Writes characters to an existing unified exec session and returns recent output."
+ sanitizedMixed := SanitizeDevinToolDescription("functions__exec", mixedDesc)
+ if strings.Contains(sanitizedMixed, "a session ID") || strings.Contains(sanitizedMixed, "to an existing unified") {
+ t.Errorf("SanitizeDevinToolDescription(functions__exec) left raw phrase in mixed description: %q", sanitizedMixed)
+ }
+
+ unrelatedDesc := "Writes arbitrary input to a process"
+ sanitizedUnrelated := SanitizeDevinToolDescription("other_tool", unrelatedDesc)
+ if sanitizedUnrelated != unrelatedDesc {
+ t.Errorf("SanitizeDevinToolDescription(other_tool) modified unrelated description: %q", sanitizedUnrelated)
}
}
diff --git a/internal/translator/common/gemini.go b/internal/translator/common/gemini.go
index 8545e187c08..1042402006b 100644
--- a/internal/translator/common/gemini.go
+++ b/internal/translator/common/gemini.go
@@ -3,12 +3,11 @@ package common
import (
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsGeminiThoughtPart reports whether a Gemini part contains hidden model thought.
-func IsGeminiThoughtPart(part gjson.Result) bool {
+func IsGeminiThoughtPart(part ojson.Result) bool {
return part.Get("thought").Bool()
}
@@ -27,17 +26,17 @@ func MergeAdjacentGeminiContents(contents [][]byte) [][]byte {
if len(content) == 0 {
continue
}
- role := gjson.GetBytes(content, "role").String()
- partsResult := gjson.GetBytes(content, "parts")
+ role := ojson.GetBytesNoCopy(content, "role").String()
+ partsResult := ojson.GetBytesNoCopy(content, "parts")
if !partsResult.IsArray() || len(partsResult.Array()) == 0 {
continue
}
if len(merged) > 0 {
lastIndex := len(merged) - 1
lastJSON := merged[lastIndex]
- lastRole := gjson.GetBytes(lastJSON, "role").String()
+ lastRole := ojson.GetBytesNoCopy(lastJSON, "role").String()
if lastRole == "user" && role == "user" {
- lastParts := gjson.GetBytes(lastJSON, "parts").Array()
+ lastParts := ojson.GetBytesNoCopy(lastJSON, "parts").Array()
combinedParts := make([][]byte, 0, len(lastParts)+len(partsResult.Array()))
for _, p := range lastParts {
combinedParts = append(combinedParts, []byte(p.Raw))
@@ -46,7 +45,7 @@ func MergeAdjacentGeminiContents(contents [][]byte) [][]byte {
combinedParts = append(combinedParts, []byte(p.Raw))
}
combinedParts = ReorderGeminiUserParts(combinedParts)
- updated, err := sjson.SetRawBytes(lastJSON, "parts", JoinRawArray(combinedParts))
+ updated, err := ojson.SetRawBytes(lastJSON, "parts", JoinRawArray(combinedParts))
if err == nil {
merged[lastIndex] = updated
continue
@@ -60,8 +59,14 @@ func MergeAdjacentGeminiContents(contents [][]byte) [][]byte {
// ContentHasGeminiFunctionCall reports whether a Gemini content turn contains any functionCall part.
func ContentHasGeminiFunctionCall(content []byte) bool {
+ return GeminiPartsHaveFunctionCall(ojson.GetBytesNoCopy(content, "parts"))
+}
+
+// GeminiPartsHaveFunctionCall reports whether a parsed Gemini parts array contains any functionCall part
+// without copying or re-parsing the enclosing content raw bytes.
+func GeminiPartsHaveFunctionCall(parts ojson.Result) bool {
hasFC := false
- gjson.GetBytes(content, "parts").ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() || part.Get("function_call").Exists() {
hasFC = true
return false
@@ -73,8 +78,14 @@ func ContentHasGeminiFunctionCall(content []byte) bool {
// ContentHasGeminiFunctionResponse reports whether a Gemini content turn contains any functionResponse part.
func ContentHasGeminiFunctionResponse(content []byte) bool {
+ return GeminiPartsHaveFunctionResponse(ojson.GetBytesNoCopy(content, "parts"))
+}
+
+// GeminiPartsHaveFunctionResponse reports whether a parsed Gemini parts array contains any functionResponse part
+// without copying or re-parsing the enclosing content raw bytes.
+func GeminiPartsHaveFunctionResponse(parts ojson.Result) bool {
hasFR := false
- gjson.GetBytes(content, "parts").ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if part.Get("functionResponse").Exists() || part.Get("function_response").Exists() {
hasFR = true
return false
@@ -84,6 +95,45 @@ func ContentHasGeminiFunctionResponse(content []byte) bool {
return hasFR
}
+// GeminiPartFunctionCall returns the functionCall or snake_case function_call object from a Gemini part.
+func GeminiPartFunctionCall(part ojson.Result) ojson.Result {
+ if fc := part.Get("functionCall"); fc.Exists() {
+ return fc
+ }
+ return part.Get("function_call")
+}
+
+// GeminiPartFunctionResponse returns the functionResponse or snake_case function_response object from a Gemini part.
+func GeminiPartFunctionResponse(part ojson.Result) ojson.Result {
+ if fr := part.Get("functionResponse"); fr.Exists() {
+ return fr
+ }
+ return part.Get("function_response")
+}
+
+// NormalizeGeminiTurnRole maps a Gemini content role to "user" or "assistant",
+// inferring missing or unrecognized roles from functionCall/functionResponse parts
+// or alternating from previousRole (matching Gemini ingress normalization).
+func NormalizeGeminiTurnRole(rawRole string, parts ojson.Result, previousRole string) string {
+ switch strings.ToLower(strings.TrimSpace(rawRole)) {
+ case "model", "assistant":
+ return "assistant"
+ case "user", "function", "tool":
+ return "user"
+ default:
+ if GeminiPartsHaveFunctionCall(parts) {
+ return "assistant"
+ }
+ if GeminiPartsHaveFunctionResponse(parts) {
+ return "user"
+ }
+ if previousRole == "user" {
+ return "assistant"
+ }
+ return "user"
+ }
+}
+
// ReorderGeminiUserParts reorders parts within a Gemini user turn so that
// text parts (such as prompt text and system reminders) precede functionResponse
// parts. This resolves upstream provider validation failures (such as Google Cloud
@@ -93,10 +143,10 @@ func ReorderGeminiUserParts(parts [][]byte) [][]byte {
hasFR := false
hasTrailingText := false
for _, p := range parts {
- isFR := gjson.GetBytes(p, "functionResponse").Exists() || gjson.GetBytes(p, "function_response").Exists()
+ isFR := ojson.GetBytesNoCopy(p, "functionResponse").Exists() || ojson.GetBytesNoCopy(p, "function_response").Exists()
if isFR {
hasFR = true
- } else if hasFR && gjson.GetBytes(p, "text").Exists() {
+ } else if hasFR && ojson.GetBytesNoCopy(p, "text").Exists() {
hasTrailingText = true
break
}
@@ -108,7 +158,7 @@ func ReorderGeminiUserParts(parts [][]byte) [][]byte {
promptParts := make([][]byte, 0, len(parts))
toolParts := make([][]byte, 0, len(parts))
for _, p := range parts {
- if gjson.GetBytes(p, "text").Exists() {
+ if ojson.GetBytesNoCopy(p, "text").Exists() {
promptParts = append(promptParts, p)
} else {
toolParts = append(toolParts, p)
@@ -128,17 +178,17 @@ func MergeAdjacentGeminiUserContents(contents [][]byte) [][]byte {
if len(content) == 0 {
continue
}
- role := gjson.GetBytes(content, "role").String()
- partsResult := gjson.GetBytes(content, "parts")
+ role := ojson.GetBytesNoCopy(content, "role").String()
+ partsResult := ojson.GetBytesNoCopy(content, "parts")
if !partsResult.IsArray() || partsResult.Raw == "[]" || !partsResult.Get("0").Exists() {
continue
}
if len(merged) > 0 {
lastIndex := len(merged) - 1
lastJSON := merged[lastIndex]
- lastRole := gjson.GetBytes(lastJSON, "role").String()
+ lastRole := ojson.GetBytesNoCopy(lastJSON, "role").String()
if lastRole == "user" && role == "user" && !ContentHasGeminiFunctionResponse(lastJSON) && !ContentHasGeminiFunctionResponse(content) {
- lastParts := gjson.GetBytes(lastJSON, "parts").Array()
+ lastParts := ojson.GetBytesNoCopy(lastJSON, "parts").Array()
currentParts := partsResult.Array()
combinedParts := make([][]byte, 0, len(lastParts)+len(currentParts))
for _, p := range lastParts {
@@ -147,7 +197,7 @@ func MergeAdjacentGeminiUserContents(contents [][]byte) [][]byte {
for _, p := range currentParts {
combinedParts = append(combinedParts, []byte(p.Raw))
}
- updated, err := sjson.SetRawBytes(lastJSON, "parts", JoinRawArray(combinedParts))
+ updated, err := ojson.SetRawBytes(lastJSON, "parts", JoinRawArray(combinedParts))
if err == nil {
merged[lastIndex] = updated
continue
@@ -170,11 +220,11 @@ func SplitGeminiFunctionResponseTurns(contents [][]byte) [][]byte {
}
split := make([][]byte, 0, len(contents))
for _, content := range contents {
- if gjson.GetBytes(content, "role").String() != "user" || !ContentHasGeminiFunctionResponse(content) {
+ if ojson.GetBytesNoCopy(content, "role").String() != "user" || !ContentHasGeminiFunctionResponse(content) {
split = append(split, content)
continue
}
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
responseParts := make([][]byte, 0, len(parts))
otherParts := make([][]byte, 0, len(parts))
for _, part := range parts {
@@ -187,10 +237,10 @@ func SplitGeminiFunctionResponseTurns(contents [][]byte) [][]byte {
var responseTurn, otherTurn []byte
var responseErr, otherErr error
if len(responseParts) > 0 {
- responseTurn, responseErr = sjson.SetRawBytes(content, "parts", JoinRawArray(responseParts))
+ responseTurn, responseErr = ojson.SetRawBytes(content, "parts", JoinRawArray(responseParts))
}
if len(otherParts) > 0 {
- otherTurn, otherErr = sjson.SetRawBytes(content, "parts", JoinRawArray(otherParts))
+ otherTurn, otherErr = ojson.SetRawBytes(content, "parts", JoinRawArray(otherParts))
}
if responseErr != nil || otherErr != nil {
// Preserve the original turn if rebuilding either split turn fails.
@@ -213,18 +263,18 @@ func SplitGeminiFunctionResponseTurns(contents [][]byte) [][]byte {
out := make([][]byte, 0, len(split))
n := len(split)
for i := 0; i < n; {
- if gjson.GetBytes(split[i], "role").String() != "user" {
+ if ojson.GetBytesNoCopy(split[i], "role").String() != "user" {
out = append(out, split[i])
i++
continue
}
precedingModelHasFC := len(out) > 0 &&
- gjson.GetBytes(out[len(out)-1], "role").String() == "model" &&
+ ojson.GetBytesNoCopy(out[len(out)-1], "role").String() == "model" &&
ContentHasGeminiFunctionCall(out[len(out)-1])
j := i
hasFR := false
- for j < n && gjson.GetBytes(split[j], "role").String() == "user" {
+ for j < n && ojson.GetBytesNoCopy(split[j], "role").String() == "user" {
if ContentHasGeminiFunctionResponse(split[j]) {
hasFR = true
}
@@ -236,7 +286,7 @@ func SplitGeminiFunctionResponseTurns(contents [][]byte) [][]byte {
otherTurns := make([][]byte, 0, len(userRun))
for _, turn := range userRun {
if ContentHasGeminiFunctionResponse(turn) {
- parts := gjson.GetBytes(turn, "parts").Array()
+ parts := ojson.GetBytesNoCopy(turn, "parts").Array()
for _, p := range parts {
combinedFRParts = append(combinedFRParts, []byte(p.Raw))
}
@@ -246,7 +296,7 @@ func SplitGeminiFunctionResponseTurns(contents [][]byte) [][]byte {
}
if len(combinedFRParts) > 0 {
frTurn := []byte(`{"role":"user","parts":[]}`)
- frTurn, _ = sjson.SetRawBytes(frTurn, "parts", JoinRawArray(combinedFRParts))
+ frTurn, _ = ojson.SetRawBytes(frTurn, "parts", JoinRawArray(combinedFRParts))
out = append(out, frTurn)
}
out = append(out, otherTurns...)
@@ -259,13 +309,13 @@ func SplitGeminiFunctionResponseTurns(contents [][]byte) [][]byte {
}
// ContainsJSONRef reports whether value (recursively) contains a string-valued "$ref" property.
-func ContainsJSONRef(value gjson.Result) bool {
+func ContainsJSONRef(value ojson.Result) bool {
if !value.IsObject() && !value.IsArray() {
return false
}
found := false
- value.ForEach(func(key, child gjson.Result) bool {
- if value.IsObject() && key.String() == "$ref" && child.Type == gjson.String {
+ value.ForEach(func(key, child ojson.Result) bool {
+ if value.IsObject() && key.String() == "$ref" && child.Type == ojson.String {
found = true
return false
}
@@ -280,14 +330,14 @@ func ContainsJSONRef(value gjson.Result) bool {
// SetGeminiFunctionResponseResult sets the Gemini functionResponse result or response field.
// If the result contains a JSON Schema or OpenAPI "$ref" property with a string value, it is
-// preserved as opaque JSON text with sjson.SetBytes. This prevents Gemini/Vertex AI from
+// preserved as opaque JSON text with ojson.SetBytes. This prevents Gemini/Vertex AI from
// interpreting "$ref" as a reference to a media part in function_response.parts and rejecting
// the request with HTTP 400.
// If path points to "functionResponse.response" or "response", a stringified result is placed
// under the ".result" child object to satisfy Gemini's requirement that "response" is an object.
-func SetGeminiFunctionResponseResult(part []byte, path string, result gjson.Result) []byte {
+func SetGeminiFunctionResponseResult(part []byte, path string, result ojson.Result) []byte {
if !result.Exists() {
- part, _ = sjson.SetBytes(part, path, "")
+ part, _ = ojson.SetBytes(part, path, "")
return part
}
if ContainsJSONRef(result) {
@@ -295,10 +345,10 @@ func SetGeminiFunctionResponseResult(part []byte, path string, result gjson.Resu
if strings.HasSuffix(path, "response") {
targetPath = path + ".result"
}
- part, _ = sjson.SetBytes(part, targetPath, result.Raw)
+ part, _ = ojson.SetBytes(part, targetPath, result.Raw)
return part
}
- part, _ = sjson.SetRawBytes(part, path, []byte(result.Raw))
+ part, _ = ojson.SetRawBytes(part, path, []byte(result.Raw))
return part
}
@@ -307,8 +357,42 @@ func SetGeminiFunctionResponseResult(part []byte, path string, result gjson.Resu
func SetGeminiFunctionResponseRaw(part []byte, path string, rawJSON string) []byte {
trimmed := strings.TrimSpace(rawJSON)
if trimmed == "" {
- part, _ = sjson.SetBytes(part, path, "")
+ part, _ = ojson.SetBytes(part, path, "")
return part
}
- return SetGeminiFunctionResponseResult(part, path, gjson.Parse(trimmed))
+ return SetGeminiFunctionResponseResult(part, path, ojson.Parse(trimmed))
+}
+
+// SetGeminiFunctionResponseResultDoc sets the Gemini functionResponse result or response field on doc.
+func SetGeminiFunctionResponseResultDoc(doc *ojson.Doc, path string, result ojson.Result) {
+ if doc == nil {
+ return
+ }
+ if !result.Exists() {
+ _ = doc.Set(path, "")
+ return
+ }
+ if ContainsJSONRef(result) {
+ targetPath := path
+ if strings.HasSuffix(path, "response") {
+ targetPath = path + ".result"
+ }
+ _ = doc.Set(targetPath, result.Raw)
+ return
+ }
+ _ = doc.SetRaw(path, result.Raw)
+}
+
+// SetGeminiFunctionResponseRawDoc is a convenience wrapper around SetGeminiFunctionResponseResultDoc
+// for raw JSON strings.
+func SetGeminiFunctionResponseRawDoc(doc *ojson.Doc, path string, rawJSON string) {
+ if doc == nil {
+ return
+ }
+ trimmed := strings.TrimSpace(rawJSON)
+ if trimmed == "" {
+ _ = doc.Set(path, "")
+ return
+ }
+ SetGeminiFunctionResponseResultDoc(doc, path, ojson.Parse(trimmed))
}
diff --git a/internal/translator/common/gemini_test.go b/internal/translator/common/gemini_test.go
index fcbac518630..9f5744a607e 100644
--- a/internal/translator/common/gemini_test.go
+++ b/internal/translator/common/gemini_test.go
@@ -3,7 +3,8 @@ package common
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestMergeAdjacentGeminiContents(t *testing.T) {
@@ -32,7 +33,7 @@ func TestMergeAdjacentGeminiContents(t *testing.T) {
}
// Turn 0: user with 3 parts
- turn0 := gjson.ParseBytes(merged[0])
+ turn0 := util.ParseGJSONBytesNoCopy(merged[0])
if turn0.Get("role").String() != "user" {
t.Fatalf("expected role user, got %s", turn0.Get("role").String())
}
@@ -47,13 +48,13 @@ func TestMergeAdjacentGeminiContents(t *testing.T) {
}
// Turn 1: model with 1 part
- turn1 := gjson.ParseBytes(merged[1])
+ turn1 := util.ParseGJSONBytesNoCopy(merged[1])
if turn1.Get("role").String() != "model" {
t.Fatalf("expected role model, got %s", turn1.Get("role").String())
}
// Turn 2: user with 1 part
- turn2 := gjson.ParseBytes(merged[2])
+ turn2 := util.ParseGJSONBytesNoCopy(merged[2])
if turn2.Get("role").String() != "user" {
t.Fatalf("expected role user, got %s", turn2.Get("role").String())
}
@@ -93,7 +94,7 @@ func TestMergeAdjacentGeminiContents(t *testing.T) {
if len(merged) != 1 {
t.Fatalf("expected 1 merged turn, got %d", len(merged))
}
- parts := gjson.GetBytes(merged[0], "parts").Array()
+ parts := ojson.GetBytesNoCopy(merged[0], "parts").Array()
if len(parts) != 2 {
t.Fatalf("expected 2 parts, got %d", len(parts))
}
@@ -116,7 +117,7 @@ func TestMergeAdjacentGeminiUserContents(t *testing.T) {
if len(merged) != 1 {
t.Fatalf("expected 1 merged user turn, got %d", len(merged))
}
- parts := gjson.GetBytes(merged[0], "parts").Array()
+ parts := ojson.GetBytesNoCopy(merged[0], "parts").Array()
if len(parts) != 2 {
t.Fatalf("expected 2 parts, got %d", len(parts))
}
@@ -161,7 +162,7 @@ func TestSplitGeminiFunctionResponseTurns(t *testing.T) {
t.Run("handles multiple responses and alternate key", func(t *testing.T) {
input := [][]byte{[]byte(`{"role":"user","parts":[{"text":"before"},{"functionResponse":{"name":"one"}},{"function_response":{"name":"two"}},{"text":"after"}]}`)}
got := SplitGeminiFunctionResponseTurns(input)
- if len(got) != 2 || len(gjson.GetBytes(got[0], "parts").Array()) != 2 || len(gjson.GetBytes(got[1], "parts").Array()) != 2 {
+ if len(got) != 2 || len(ojson.GetBytesNoCopy(got[0], "parts").Array()) != 2 || len(ojson.GetBytesNoCopy(got[1], "parts").Array()) != 2 {
t.Fatalf("unexpected split: %s", JoinRawArray(got))
}
if !ContentHasGeminiFunctionResponse(got[0]) || ContentHasGeminiFunctionResponse(got[1]) {
@@ -209,7 +210,7 @@ func TestSplitGeminiFunctionResponseTurns(t *testing.T) {
if !ContentHasGeminiFunctionResponse(got[1]) {
t.Fatalf("expected function response immediately after model turn, got %s", got[1])
}
- if gotText := gjson.GetBytes(got[2], "parts.0.text").String(); gotText != "system reminder" {
+ if gotText := ojson.GetBytesNoCopy(got[2], "parts.0.text").String(); gotText != "system reminder" {
t.Fatalf("expected text reminder after function response, got %s", got[2])
}
})
@@ -224,7 +225,7 @@ func TestSplitGeminiFunctionResponseTurns(t *testing.T) {
if len(got) != 2 {
t.Fatalf("got %d turns, want 2", len(got))
}
- if len(gjson.GetBytes(got[1], "parts").Array()) != 2 {
+ if len(ojson.GetBytesNoCopy(got[1], "parts").Array()) != 2 {
t.Fatalf("expected 2 parts in single function response turn, got %s", got[1])
}
})
@@ -237,7 +238,7 @@ func TestReorderGeminiUserParts(t *testing.T) {
[]byte(`{"inline_data":{"mime_type":"image/png","data":"abc"}}`),
}
reordered := ReorderGeminiUserParts(parts)
- if len(reordered) != 2 || gjson.GetBytes(reordered[0], "text").String() != "hello" {
+ if len(reordered) != 2 || ojson.GetBytesNoCopy(reordered[0], "text").String() != "hello" {
t.Fatalf("unexpected parts: %v", reordered)
}
})
@@ -248,7 +249,7 @@ func TestReorderGeminiUserParts(t *testing.T) {
[]byte(`{"functionResponse":{"name":"read","response":{"result":"ok"}}}`),
}
reordered := ReorderGeminiUserParts(parts)
- if len(reordered) != 2 || gjson.GetBytes(reordered[0], "text").String() != "context" {
+ if len(reordered) != 2 || ojson.GetBytesNoCopy(reordered[0], "text").String() != "context" {
t.Fatalf("unexpected parts: %v", reordered)
}
})
@@ -262,10 +263,10 @@ func TestReorderGeminiUserParts(t *testing.T) {
if len(reordered) != 2 {
t.Fatalf("expected 2 parts, got %d", len(reordered))
}
- if got := gjson.GetBytes(reordered[0], "text").String(); got != "reminder " {
+ if got := ojson.GetBytesNoCopy(reordered[0], "text").String(); got != "reminder " {
t.Fatalf("expected parts[0] to be text, got %q", got)
}
- if got := gjson.GetBytes(reordered[1], "functionResponse.name").String(); got != "read" {
+ if got := ojson.GetBytesNoCopy(reordered[1], "functionResponse.name").String(); got != "read" {
t.Fatalf("expected parts[1] to be functionResponse, got %q", got)
}
})
@@ -281,16 +282,16 @@ func TestReorderGeminiUserParts(t *testing.T) {
if len(reordered) != 4 {
t.Fatalf("expected 4 parts, got %d", len(reordered))
}
- if got := gjson.GetBytes(reordered[0], "text").String(); got != "leading" {
+ if got := ojson.GetBytesNoCopy(reordered[0], "text").String(); got != "leading" {
t.Fatalf("parts[0] text = %q, want leading", got)
}
- if got := gjson.GetBytes(reordered[1], "text").String(); got != "trailing" {
+ if got := ojson.GetBytesNoCopy(reordered[1], "text").String(); got != "trailing" {
t.Fatalf("parts[1] text = %q, want trailing", got)
}
- if got := gjson.GetBytes(reordered[2], "functionResponse.name").String(); got != "tool1" {
+ if got := ojson.GetBytesNoCopy(reordered[2], "functionResponse.name").String(); got != "tool1" {
t.Fatalf("parts[2] name = %q, want tool1", got)
}
- if got := gjson.GetBytes(reordered[3], "functionResponse.name").String(); got != "tool2" {
+ if got := ojson.GetBytesNoCopy(reordered[3], "functionResponse.name").String(); got != "tool2" {
t.Fatalf("parts[3] name = %q, want tool2", got)
}
})
@@ -341,7 +342,7 @@ func TestContainsJSONRef(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := ContainsJSONRef(gjson.Parse(tt.json))
+ got := ContainsJSONRef(ojson.Parse(tt.json))
if got != tt.want {
t.Errorf("ContainsJSONRef() = %v, want %v", got, tt.want)
}
@@ -352,19 +353,19 @@ func TestContainsJSONRef(t *testing.T) {
func TestSetGeminiFunctionResponseResult(t *testing.T) {
t.Run("preserves $ref as string under result", func(t *testing.T) {
part := []byte(`{"functionResponse":{"name":"test"}}`)
- res := gjson.Parse(`{"schema":{"$ref":"#/components/schemas/ErrorModel"}}`)
+ res := ojson.Parse(`{"schema":{"$ref":"#/components/schemas/ErrorModel"}}`)
updated := SetGeminiFunctionResponseResult(part, "functionResponse.response.result", res)
- val := gjson.GetBytes(updated, "functionResponse.response.result")
- if val.Type != gjson.String {
+ val := ojson.GetBytesNoCopy(updated, "functionResponse.response.result")
+ if val.Type != ojson.String {
t.Fatalf("expected string type, got %s (raw: %s)", val.Type, val.Raw)
}
})
t.Run("keeps non-$ref object as raw JSON under result", func(t *testing.T) {
part := []byte(`{"functionResponse":{"name":"test"}}`)
- res := gjson.Parse(`{"ok":true,"code":200}`)
+ res := ojson.Parse(`{"ok":true,"code":200}`)
updated := SetGeminiFunctionResponseResult(part, "functionResponse.response.result", res)
- val := gjson.GetBytes(updated, "functionResponse.response.result")
+ val := ojson.GetBytesNoCopy(updated, "functionResponse.response.result")
if !val.IsObject() {
t.Fatalf("expected object type, got %s (raw: %s)", val.Type, val.Raw)
}
@@ -375,10 +376,10 @@ func TestSetGeminiFunctionResponseResult(t *testing.T) {
t.Run("places stringified $ref under .result when path ends with response", func(t *testing.T) {
part := []byte(`{"functionResponse":{"name":"test"}}`)
- res := gjson.Parse(`{"schema":{"$ref":"#/components/schemas/ErrorModel"}}`)
+ res := ojson.Parse(`{"schema":{"$ref":"#/components/schemas/ErrorModel"}}`)
updated := SetGeminiFunctionResponseResult(part, "functionResponse.response", res)
- val := gjson.GetBytes(updated, "functionResponse.response.result")
- if val.Type != gjson.String {
+ val := ojson.GetBytesNoCopy(updated, "functionResponse.response.result")
+ if val.Type != ojson.String {
t.Fatalf("expected string type under response.result, got %s (raw: %s)", val.Type, val.Raw)
}
})
diff --git a/internal/translator/common/interactions_usage.go b/internal/translator/common/interactions_usage.go
index eabe4273a29..d899e0d4853 100644
--- a/internal/translator/common/interactions_usage.go
+++ b/internal/translator/common/interactions_usage.go
@@ -1,8 +1,8 @@
package common
-import "github.com/tidwall/gjson"
+import "github.com/MoeForge/moego/ojson"
-func InteractionsUsage(root gjson.Result) gjson.Result {
+func InteractionsUsage(root ojson.Result) ojson.Result {
for _, path := range []string{
"interaction.usage",
"usage",
@@ -15,5 +15,5 @@ func InteractionsUsage(root gjson.Result) gjson.Result {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
diff --git a/internal/translator/common/openai_tools.go b/internal/translator/common/openai_tools.go
index d539dd79ecc..f5a2fab27a3 100644
--- a/internal/translator/common/openai_tools.go
+++ b/internal/translator/common/openai_tools.go
@@ -4,7 +4,7 @@ import (
"sort"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// AlignOpenAIToolCallMessages reorders tool result messages to immediately follow
@@ -34,10 +34,10 @@ func AlignOpenAIToolCallMessages(messages [][]byte, extraAmbiguousIDs ...string)
toolMsgIndicesByCallID := make(map[string][]int)
for i, raw := range messages {
- role := gjson.GetBytes(raw, "role").String()
+ role := ojson.GetBytesNoCopy(raw, "role").String()
switch role {
case "assistant":
- toolCalls := gjson.GetBytes(raw, "tool_calls")
+ toolCalls := ojson.GetBytesNoCopy(raw, "tool_calls")
if toolCalls.Exists() && toolCalls.IsArray() {
rawCalls := toolCalls.Array()
if len(rawCalls) > 0 {
@@ -68,7 +68,7 @@ func AlignOpenAIToolCallMessages(messages [][]byte, extraAmbiguousIDs ...string)
}
case "tool":
- callID := gjson.GetBytes(raw, "tool_call_id").String()
+ callID := ojson.GetBytesNoCopy(raw, "tool_call_id").String()
if callID == "" {
ambiguousCallIDs[""] = true
} else {
diff --git a/internal/translator/common/openai_tools_test.go b/internal/translator/common/openai_tools_test.go
index 7494bb71999..80469b83e76 100644
--- a/internal/translator/common/openai_tools_test.go
+++ b/internal/translator/common/openai_tools_test.go
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestAlignOpenAIToolCallMessages(t *testing.T) {
@@ -62,24 +62,24 @@ func TestAlignOpenAIToolCallMessages(t *testing.T) {
// 6: user "final prompt"
roles := make([]string, len(got))
for i, m := range got {
- roles[i] = gjson.GetBytes(m, "role").String()
+ roles[i] = ojson.GetBytesNoCopy(m, "role").String()
}
wantRoles := []string{"user", "assistant", "tool", "tool", "assistant", "user", "user"}
if !reflect.DeepEqual(roles, wantRoles) {
t.Fatalf("unexpected roles: got %v, want %v", roles, wantRoles)
}
- if gjson.GetBytes(got[2], "tool_call_id").String() != "call_b" {
- t.Errorf("got[2] tool_call_id = %s, want call_b", gjson.GetBytes(got[2], "tool_call_id").String())
+ if ojson.GetBytesNoCopy(got[2], "tool_call_id").String() != "call_b" {
+ t.Errorf("got[2] tool_call_id = %s, want call_b", ojson.GetBytesNoCopy(got[2], "tool_call_id").String())
}
- if gjson.GetBytes(got[3], "tool_call_id").String() != "call_a" {
- t.Errorf("got[3] tool_call_id = %s, want call_a", gjson.GetBytes(got[3], "tool_call_id").String())
+ if ojson.GetBytesNoCopy(got[3], "tool_call_id").String() != "call_a" {
+ t.Errorf("got[3] tool_call_id = %s, want call_a", ojson.GetBytesNoCopy(got[3], "tool_call_id").String())
}
- if gjson.GetBytes(got[4], "reasoning_content").String() != "step 1" {
- t.Errorf("got[4] reasoning_content = %s, want step 1", gjson.GetBytes(got[4], "reasoning_content").String())
+ if ojson.GetBytesNoCopy(got[4], "reasoning_content").String() != "step 1" {
+ t.Errorf("got[4] reasoning_content = %s, want step 1", ojson.GetBytesNoCopy(got[4], "reasoning_content").String())
}
- if gjson.GetBytes(got[5], "content").String() != "system reminder" {
- t.Errorf("got[5] content = %s, want system reminder", gjson.GetBytes(got[5], "content").String())
+ if ojson.GetBytesNoCopy(got[5], "content").String() != "system reminder" {
+ t.Errorf("got[5] content = %s, want system reminder", ojson.GetBytesNoCopy(got[5], "content").String())
}
})
@@ -195,17 +195,17 @@ func TestAlignOpenAIToolCallMessages(t *testing.T) {
}
roles := make([]string, len(got))
for i, m := range got {
- roles[i] = gjson.GetBytes(m, "role").String()
+ roles[i] = ojson.GetBytesNoCopy(m, "role").String()
}
wantRoles := []string{"assistant", "tool", "user", "assistant", "tool", "user"}
if !reflect.DeepEqual(roles, wantRoles) {
t.Fatalf("unexpected roles: got %v, want %v", roles, wantRoles)
}
- if gjson.GetBytes(got[1], "tool_call_id").String() != "call_1" {
- t.Errorf("got[1] tool_call_id = %s, want call_1", gjson.GetBytes(got[1], "tool_call_id").String())
+ if ojson.GetBytesNoCopy(got[1], "tool_call_id").String() != "call_1" {
+ t.Errorf("got[1] tool_call_id = %s, want call_1", ojson.GetBytesNoCopy(got[1], "tool_call_id").String())
}
- if gjson.GetBytes(got[4], "tool_call_id").String() != "call_2" {
- t.Errorf("got[4] tool_call_id = %s, want call_2", gjson.GetBytes(got[4], "tool_call_id").String())
+ if ojson.GetBytesNoCopy(got[4], "tool_call_id").String() != "call_2" {
+ t.Errorf("got[4] tool_call_id = %s, want call_2", ojson.GetBytesNoCopy(got[4], "tool_call_id").String())
}
})
}
diff --git a/internal/translator/common/parts.go b/internal/translator/common/parts.go
index adfa2829562..76b0a296bed 100644
--- a/internal/translator/common/parts.go
+++ b/internal/translator/common/parts.go
@@ -5,7 +5,8 @@ import (
"net/url"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// UnsupportedPartError is a request-scoped rejection. The part type was present
@@ -107,7 +108,7 @@ func (r *UserRun) Err() error {
// decides, falling back to its type; a step that names neither inherits the
// answer from the wrapper it sits in. Instruction content is still sent, but it
// closes the open user turn and never keeps an emptied one alive.
-func IsInteractionsInstructionStep(step gjson.Result, inherited bool) bool {
+func IsInteractionsInstructionStep(step ojson.Result, inherited bool) bool {
name := strings.ToLower(strings.TrimSpace(step.Get("role").String()))
if name == "" {
name = strings.ToLower(strings.TrimSpace(step.Get("type").String()))
@@ -124,7 +125,7 @@ func IsInteractionsInstructionStep(step gjson.Result, inherited bool) bool {
// InteractionsAttachmentType names the type of an Interactions content part that
// carries an attachment, or returns "" for text and for parts that name nothing.
// It is the type reported when a translation cannot send the part.
-func InteractionsAttachmentType(part gjson.Result) string {
+func InteractionsAttachmentType(part ojson.Result) string {
if !part.IsObject() {
return ""
}
@@ -146,7 +147,7 @@ func InteractionsAttachmentType(part gjson.Result) string {
// GeminiPartIsSendable reports whether a Gemini part gives the model something to
// read. A text part that is empty or only whitespace does not.
func GeminiPartIsSendable(part []byte) bool {
- parsed := gjson.ParseBytes(part)
+ parsed := util.ParseGJSONBytesNoCopy(part)
text := parsed.Get("text")
if !text.Exists() || strings.TrimSpace(text.String()) != "" {
return true
diff --git a/internal/translator/common/parts_test.go b/internal/translator/common/parts_test.go
index 92c889e40ae..d3cc743bc24 100644
--- a/internal/translator/common/parts_test.go
+++ b/internal/translator/common/parts_test.go
@@ -4,7 +4,7 @@ import (
"errors"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestUserTurnDropsRefusesAnEmptiedTurnEvenWhenOtherTurnsRemain(t *testing.T) {
@@ -130,7 +130,7 @@ func TestInteractionsAttachmentType(t *testing.T) {
`{"type":"image_url","image_url":{"url":""}}`: "image_url",
}
for raw, want := range cases {
- if got := InteractionsAttachmentType(gjson.Parse(raw)); got != want {
+ if got := InteractionsAttachmentType(ojson.Parse(raw)); got != want {
t.Fatalf("InteractionsAttachmentType(%s) = %q, want %q", raw, got, want)
}
}
@@ -172,7 +172,7 @@ func TestIsInteractionsInstructionStep(t *testing.T) {
{`"plain string"`, false, false},
}
for _, tc := range cases {
- if got := IsInteractionsInstructionStep(gjson.Parse(tc.raw), tc.inherited); got != tc.want {
+ if got := IsInteractionsInstructionStep(ojson.Parse(tc.raw), tc.inherited); got != tc.want {
t.Fatalf("IsInteractionsInstructionStep(%s, %v) = %v, want %v", tc.raw, tc.inherited, got, tc.want)
}
}
diff --git a/internal/translator/common/request.go b/internal/translator/common/request.go
index 544575de46f..fa098f66884 100644
--- a/internal/translator/common/request.go
+++ b/internal/translator/common/request.go
@@ -4,7 +4,8 @@ import (
"crypto/rand"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const tooluLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -46,15 +47,15 @@ func RequestModelName(originalRequestRawJSON, requestRawJSON []byte) string {
}
func requestModelName(rawJSON []byte) string {
- if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
+ if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) {
return ""
}
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
for _, path := range []string{"model", "request.model"} {
model := root.Get(path)
- if model.Type == gjson.String && strings.TrimSpace(model.String()) != "" {
- return model.String()
+ if model.Type == ojson.String && strings.TrimSpace(model.String()) != "" {
+ return strings.Clone(model.String())
}
}
return ""
diff --git a/internal/translator/common/responses.go b/internal/translator/common/responses.go
index 6ad0afdfa42..51e5e4cd2a5 100644
--- a/internal/translator/common/responses.go
+++ b/internal/translator/common/responses.go
@@ -3,31 +3,40 @@ package common
import (
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
-// SetResponsesToolCallIdentity writes a resolved Responses tool name and namespace.
-func SetResponsesToolCallIdentity(item []byte, name, namespace, itemPath string) []byte {
+// SetResponsesToolCallIdentityDoc writes a resolved Responses tool name and namespace into doc.
+func SetResponsesToolCallIdentityDoc(doc *ojson.Doc, name, namespace, itemPath string) {
+ if doc == nil {
+ return
+ }
namePath := "name"
namespacePath := "namespace"
if itemPath != "" {
namePath = itemPath + ".name"
namespacePath = itemPath + ".namespace"
}
- item, _ = sjson.SetBytes(item, namePath, name)
+ _ = doc.Set(namePath, name)
if namespace != "" {
- item, _ = sjson.SetBytes(item, namespacePath, namespace)
+ _ = doc.Set(namespacePath, namespace)
} else {
- item, _ = sjson.DeleteBytes(item, namespacePath)
+ _ = doc.Delete(namespacePath)
}
- return item
+}
+
+// SetResponsesToolCallIdentity writes a resolved Responses tool name and namespace.
+func SetResponsesToolCallIdentity(item []byte, name, namespace, itemPath string) []byte {
+ doc := ojson.NewDoc(item)
+ SetResponsesToolCallIdentityDoc(doc, name, namespace, itemPath)
+ return doc.Bytes()
}
// ExtractResponsesCallID extracts the tool call ID from a Responses API item,
// prioritizing dedicated tool call references over generic item IDs:
// call_id -> tool_call_id -> callId -> id (excluding fco_ output item IDs)
-func ExtractResponsesCallID(node gjson.Result) string {
+func ExtractResponsesCallID(node ojson.Result) string {
if callID := strings.TrimSpace(node.Get("call_id").String()); callID != "" {
return callID
}
@@ -52,12 +61,12 @@ func ExtractResponsesCallID(node gjson.Result) string {
// 3. FIFO queue fallback for remaining outputs that omit a call ID (skipping pending calls reserved by future explicit outputs).
// Outputs that already carry an explicit call ID that does not match any pending call
// are never rewritten or stolen.
-func NormalizeResponsesToolCallOutputs(items []gjson.Result) []gjson.Result {
+func NormalizeResponsesToolCallOutputs(items []ojson.Result) []ojson.Result {
if len(items) == 0 {
return items
}
- normalized := make([]gjson.Result, len(items))
+ normalized := make([]ojson.Result, len(items))
copy(normalized, items)
explicitOutputCounts := make(map[string]int)
@@ -164,8 +173,8 @@ func NormalizeResponsesToolCallOutputs(items []gjson.Result) []gjson.Result {
matchedOut := outputs[outIdx]
if matchedOut.Get("call_id").String() != pendingID {
raw := []byte(matchedOut.Raw)
- raw, _ = sjson.SetBytes(raw, "call_id", pendingID)
- normalized[start+outIdx] = gjson.ParseBytes(raw)
+ raw, _ = ojson.SetBytes(raw, "call_id", pendingID)
+ normalized[start+outIdx] = util.ParseGJSONBytesNoCopy(raw)
}
}
pendingCallIDs = remainingPending
diff --git a/internal/translator/common/responses_test.go b/internal/translator/common/responses_test.go
index 45883ef481e..b87423a5793 100644
--- a/internal/translator/common/responses_test.go
+++ b/internal/translator/common/responses_test.go
@@ -3,7 +3,8 @@ package common
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestSetResponsesToolCallIdentity(t *testing.T) {
@@ -55,10 +56,10 @@ func TestSetResponsesToolCallIdentity(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := SetResponsesToolCallIdentity([]byte(test.input), test.toolName, test.namespace, test.itemPath)
- if actual := gjson.GetBytes(got, test.namePath).String(); actual != test.wantName {
+ if actual := ojson.GetBytesNoCopy(got, test.namePath).String(); actual != test.wantName {
t.Fatalf("name = %q, want %q; output=%s", actual, test.wantName, got)
}
- namespace := gjson.GetBytes(got, test.namespacePath)
+ namespace := ojson.GetBytesNoCopy(got, test.namespacePath)
if namespace.Exists() != test.wantNamespaceExists {
t.Fatalf("namespace exists = %t, want %t; output=%s", namespace.Exists(), test.wantNamespaceExists, got)
}
@@ -129,7 +130,7 @@ func TestExtractResponsesCallID(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- node := gjson.Parse(tt.input)
+ node := ojson.Parse(tt.input)
if got := ExtractResponsesCallID(node); got != tt.expected {
t.Fatalf("ExtractResponsesCallID(%s) = %q, want %q", tt.input, got, tt.expected)
}
@@ -145,7 +146,7 @@ func TestNormalizeResponsesToolCallOutputs(t *testing.T) {
{"type":"function_call_output","output":"result_b"},
{"type":"function_call_output","tool_call_id":"call_a","output":"result_a"}
]`)
- items := gjson.ParseBytes(input).Array()
+ items := util.ParseGJSONBytesNoCopy(input).Array()
normalized := NormalizeResponsesToolCallOutputs(items)
if len(normalized) != 4 {
t.Fatalf("expected 4 items, got %d", len(normalized))
@@ -169,7 +170,7 @@ func TestNormalizeResponsesToolCallOutputs(t *testing.T) {
{"type":"message","role":"user","content":[{"type":"input_text","text":"intervening"}]},
{"type":"function_call_output","call_id":"call_a","output":"result_a"}
]`)
- items := gjson.ParseBytes(input).Array()
+ items := util.ParseGJSONBytesNoCopy(input).Array()
normalized := NormalizeResponsesToolCallOutputs(items)
if len(normalized) != 5 {
t.Fatalf("expected 5 items, got %d", len(normalized))
@@ -191,7 +192,7 @@ func TestNormalizeResponsesToolCallOutputs(t *testing.T) {
{"type":"function_call","call_id":"call_a","name":"tool_a"},
{"type":"function_call_output","call_id":"call_other","output":"result_other"}
]`)
- items := gjson.ParseBytes(input).Array()
+ items := util.ParseGJSONBytesNoCopy(input).Array()
normalized := NormalizeResponsesToolCallOutputs(items)
if got := normalized[1].Get("call_id").String(); got != "call_other" {
t.Fatalf("normalized[1].call_id = %q, want call_other", got)
@@ -203,7 +204,7 @@ func TestNormalizeResponsesToolCallOutputs(t *testing.T) {
{"type":"function_call","call_id":"call_1788961125480214178_817","name":"Bash"},
{"type":"function_call_output","id":"fco_01a08664-2d16-7a91-8ab2-2eccd49e4c3e","output":"result"}
]`)
- items := gjson.ParseBytes(input).Array()
+ items := util.ParseGJSONBytesNoCopy(input).Array()
normalized := NormalizeResponsesToolCallOutputs(items)
if len(normalized) != 2 {
t.Fatalf("expected 2 items, got %d", len(normalized))
@@ -220,7 +221,7 @@ func TestNormalizeResponsesToolCallOutputs(t *testing.T) {
{"type":"function_call_output","id":"fco_b","output":"result_b"},
{"type":"function_call_output","call_id":"call_a","output":"result_a"}
]`)
- items := gjson.ParseBytes(input).Array()
+ items := util.ParseGJSONBytesNoCopy(input).Array()
normalized := NormalizeResponsesToolCallOutputs(items)
if len(normalized) != 4 {
t.Fatalf("expected 4 items, got %d", len(normalized))
diff --git a/internal/translator/common/vertex_partial_args.go b/internal/translator/common/vertex_partial_args.go
index 2cf55ab36fb..4d189254d5e 100644
--- a/internal/translator/common/vertex_partial_args.go
+++ b/internal/translator/common/vertex_partial_args.go
@@ -7,7 +7,7 @@ import (
"strings"
"unicode/utf16"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const (
@@ -112,7 +112,7 @@ func (a *VertexPartialArgsAssembler) setError(err error) {
// IsVertexStreamingFunctionCall reports whether a Gemini/Vertex functionCall object
// uses streaming arguments (partialArgs or willContinue).
-func IsVertexStreamingFunctionCall(fc gjson.Result) bool {
+func IsVertexStreamingFunctionCall(fc ojson.Result) bool {
if !fc.Exists() || !fc.IsObject() {
return false
}
@@ -137,13 +137,13 @@ func IsVertexStreamingFunctionCall(fc gjson.Result) bool {
// VertexFunctionCallWillContinue reports whether a functionCall chunk indicates that
// subsequent chunks will follow for the same function call.
-func VertexFunctionCallWillContinue(fc gjson.Result) bool {
+func VertexFunctionCallWillContinue(fc ojson.Result) bool {
return fc.Get("willContinue").Bool() || fc.Get("will_continue").Bool()
}
// PushFunctionCall processes one functionCall chunk and returns the newly emitted JSON
// argument delta and whether the function call has completed cleanly.
-func (a *VertexPartialArgsAssembler) PushFunctionCall(fc gjson.Result) (string, bool) {
+func (a *VertexPartialArgsAssembler) PushFunctionCall(fc ojson.Result) (string, bool) {
if a.err != nil {
return "", false
}
@@ -160,7 +160,7 @@ func (a *VertexPartialArgsAssembler) PushFunctionCall(fc gjson.Result) (string,
a.setError(errors.New("vertex partialArgs must be an array"))
return "", false
}
- partialArgs.ForEach(func(_, item gjson.Result) bool {
+ partialArgs.ForEach(func(_, item ojson.Result) bool {
a.pushPartialArg(item)
return a.err == nil
})
@@ -188,8 +188,8 @@ func (a *VertexPartialArgsAssembler) PushFunctionCall(fc gjson.Result) (string,
var rawBuf strings.Builder
a.serializeNode(a.root, &rawBuf)
cleaned := a.cleaner(rawBuf.String())
- if gjson.Valid(cleaned) {
- a.pruneHeldNodes(a.root, gjson.Parse(cleaned))
+ if ojson.Valid(cleaned) {
+ a.pruneHeldNodes(a.root, ojson.Parse(cleaned))
} else {
a.clearHeldFlags(a.root)
}
@@ -283,7 +283,7 @@ func (a *VertexPartialArgsAssembler) findNode(segments []pathSegment) *vertexAST
return curr
}
-func (a *VertexPartialArgsAssembler) pushPartialArg(item gjson.Result) {
+func (a *VertexPartialArgsAssembler) pushPartialArg(item ojson.Result) {
if !item.IsObject() {
a.setError(errors.New("vertex partialArgs element must be an object"))
return
@@ -324,9 +324,9 @@ func (a *VertexPartialArgsAssembler) pushPartialArg(item gjson.Result) {
nullRes = item.Get("null_value")
}
- hasString := strRes.Exists() && strRes.Type != gjson.Null
- hasNumber := numRes.Exists() && numRes.Type != gjson.Null
- hasBool := boolRes.Exists() && boolRes.Type != gjson.Null
+ hasString := strRes.Exists() && strRes.Type != ojson.Null
+ hasNumber := numRes.Exists() && numRes.Type != ojson.Null
+ hasBool := boolRes.Exists() && boolRes.Type != ojson.Null
hasNull := nullRes.Exists() && !hasString && !hasNumber && !hasBool
hasAnyValue := hasString || hasNumber || hasBool || hasNull
@@ -565,8 +565,8 @@ func (a *VertexPartialArgsAssembler) isKeyRemovedByCleaner(path []pathSegment, p
probeJSON := buildSegmentProbeJSON(path, probeKey)
cleaned := a.cleaner(probeJSON)
removed := false
- if gjson.Valid(cleaned) {
- removed = !segmentProbeKeyExists(gjson.Parse(cleaned), path, probeKey)
+ if ojson.Valid(cleaned) {
+ removed = !segmentProbeKeyExists(ojson.Parse(cleaned), path, probeKey)
}
a.closedEmptyCache[cacheKey] = removed
return removed
@@ -591,7 +591,7 @@ func buildSegmentProbeJSON(path []pathSegment, probeKey string) string {
return inner
}
-func segmentProbeKeyExists(cur gjson.Result, path []pathSegment, probeKey string) bool {
+func segmentProbeKeyExists(cur ojson.Result, path []pathSegment, probeKey string) bool {
for _, seg := range path {
if seg.kind == segmentArrayIndex {
arr := cur.Array()
@@ -696,7 +696,7 @@ func (a *VertexPartialArgsAssembler) serializeNode(n *vertexASTNode, out *string
}
}
-func (a *VertexPartialArgsAssembler) pruneHeldNodes(n *vertexASTNode, cleaned gjson.Result) {
+func (a *VertexPartialArgsAssembler) pruneHeldNodes(n *vertexASTNode, cleaned ojson.Result) {
if n == nil {
return
}
@@ -729,7 +729,7 @@ func (a *VertexPartialArgsAssembler) pruneHeldNodes(n *vertexASTNode, cleaned gj
continue
}
elem.holdUntilFinish = false
- var cleanedElem gjson.Result
+ var cleanedElem ojson.Result
if i < len(cleanedArr) {
cleanedElem = cleanedArr[i]
}
@@ -878,16 +878,16 @@ func (a *VertexPartialArgsAssembler) emitNode(node *vertexASTNode, final bool) b
return false
}
-func formatVertexJSONNumber(res gjson.Result) string {
- if res.Type == gjson.Number {
+func formatVertexJSONNumber(res ojson.Result) string {
+ if res.Type == ojson.Number {
raw := strings.TrimSpace(res.Raw)
if raw != "" {
return raw
}
}
- if res.Type == gjson.String {
+ if res.Type == ojson.String {
s := strings.TrimSpace(res.String())
- if s != "" && gjson.Valid(s) && gjson.Parse(s).Type == gjson.Number {
+ if s != "" && ojson.Valid(s) && ojson.Parse(s).Type == ojson.Number {
return s
}
}
@@ -1108,7 +1108,7 @@ func NewVertexStreamingToolCall(rawName, name, upstreamID, id string, index int)
}
// CanContinueWith reports whether fc belongs to this in-progress streaming tool call.
-func (c *VertexStreamingToolCall) CanContinueWith(fc gjson.Result) bool {
+func (c *VertexStreamingToolCall) CanContinueWith(fc ojson.Result) bool {
if c == nil || c.Assembler == nil || !fc.Exists() || !fc.IsObject() {
return false
}
@@ -1134,7 +1134,7 @@ func (c *VertexStreamingToolCall) CanContinueWith(fc gjson.Result) bool {
// CanAcceptContinuation reports whether fc's partialArgs can continue the current
// in-progress argument tree without overwriting any already-completed leaf value.
-func (a *VertexPartialArgsAssembler) CanAcceptContinuation(fc gjson.Result) bool {
+func (a *VertexPartialArgsAssembler) CanAcceptContinuation(fc ojson.Result) bool {
if a == nil || a.err != nil {
return false
}
@@ -1149,7 +1149,7 @@ func (a *VertexPartialArgsAssembler) CanAcceptContinuation(fc gjson.Result) bool
return false
}
compatible := true
- partialArgs.ForEach(func(_, item gjson.Result) bool {
+ partialArgs.ForEach(func(_, item ojson.Result) bool {
if !item.IsObject() {
compatible = false
return false
@@ -1187,9 +1187,9 @@ func (a *VertexPartialArgsAssembler) CanAcceptContinuation(fc gjson.Result) bool
if !nullRes.Exists() {
nullRes = item.Get("null_value")
}
- hasString := strRes.Exists() && strRes.Type != gjson.Null
- hasNumber := numRes.Exists() && numRes.Type != gjson.Null
- hasBool := boolRes.Exists() && boolRes.Type != gjson.Null
+ hasString := strRes.Exists() && strRes.Type != ojson.Null
+ hasNumber := numRes.Exists() && numRes.Type != ojson.Null
+ hasBool := boolRes.Exists() && boolRes.Type != ojson.Null
hasNull := nullRes.Exists() && !hasString && !hasNumber && !hasBool
hasAnyValue := hasString || hasNumber || hasBool || hasNull
if !hasAnyValue && !innerWillContinue {
diff --git a/internal/translator/common/vertex_partial_args_test.go b/internal/translator/common/vertex_partial_args_test.go
index 3c744d182cc..634b3ff8a39 100644
--- a/internal/translator/common/vertex_partial_args_test.go
+++ b/internal/translator/common/vertex_partial_args_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestVertexPartialArgsAssembler_ComplexTypesAndPaths(t *testing.T) {
@@ -20,7 +20,7 @@ func TestVertexPartialArgsAssembler_ComplexTypesAndPaths(t *testing.T) {
asm := NewVertexPartialArgsAssembler()
var deltas []string
for i, raw := range chunks {
- delta, done := asm.PushFunctionCall(gjson.Parse(raw))
+ delta, done := asm.PushFunctionCall(ojson.Parse(raw))
if asm.Err() != nil {
t.Fatalf("chunk %d unexpected error: %v", i, asm.Err())
}
@@ -57,7 +57,7 @@ func TestVertexPartialArgsAssembler_InterleavedStringContinuations(t *testing.T)
asm := NewVertexPartialArgsAssembler()
var deltas []string
for i, raw := range chunks {
- delta, done := asm.PushFunctionCall(gjson.Parse(raw))
+ delta, done := asm.PushFunctionCall(ojson.Parse(raw))
if asm.Err() != nil {
t.Fatalf("chunk %d unexpected error: %v", i, asm.Err())
}
@@ -83,7 +83,7 @@ func TestVertexPartialArgsAssembler_NonDepthFirstAndArrayOrdering(t *testing.T)
asm := NewVertexPartialArgsAssembler()
var deltas []string
for _, raw := range chunks {
- d, _ := asm.PushFunctionCall(gjson.Parse(raw))
+ d, _ := asm.PushFunctionCall(ojson.Parse(raw))
if asm.Err() != nil {
t.Fatalf("unexpected error: %v", asm.Err())
}
@@ -104,7 +104,7 @@ func TestVertexPartialArgsAssembler_NonDepthFirstAndArrayOrdering(t *testing.T)
asm := NewVertexPartialArgsAssembler()
var deltas []string
for _, raw := range chunks {
- d, _ := asm.PushFunctionCall(gjson.Parse(raw))
+ d, _ := asm.PushFunctionCall(ojson.Parse(raw))
if asm.Err() != nil {
t.Fatalf("unexpected error: %v", asm.Err())
}
@@ -124,7 +124,7 @@ func TestVertexPartialArgsAssembler_NonDepthFirstAndArrayOrdering(t *testing.T)
asm := NewVertexPartialArgsAssembler()
var deltas []string
for _, raw := range chunks {
- d, _ := asm.PushFunctionCall(gjson.Parse(raw))
+ d, _ := asm.PushFunctionCall(ojson.Parse(raw))
if asm.Err() != nil {
t.Fatalf("unexpected error: %v", asm.Err())
}
@@ -138,7 +138,7 @@ func TestVertexPartialArgsAssembler_NonDepthFirstAndArrayOrdering(t *testing.T)
}
func TestVertexPartialArgsAssembler_SingleChunkParameterlessAndEmptyPartialArgsFallback(t *testing.T) {
- ping := gjson.Parse(`{"name":"ping","willContinue":false}`)
+ ping := ojson.Parse(`{"name":"ping","willContinue":false}`)
if !IsVertexStreamingFunctionCall(ping) {
t.Fatalf("expected IsVertexStreamingFunctionCall=true for single-chunk willContinue:false call")
}
@@ -148,7 +148,7 @@ func TestVertexPartialArgsAssembler_SingleChunkParameterlessAndEmptyPartialArgsF
t.Fatalf("got delta=%q done=%v, want {} true", deltaPing, donePing)
}
- withArgs := gjson.Parse(`{"name":"get_weather","args":{"city":"Paris"},"partialArgs":[]}`)
+ withArgs := ojson.Parse(`{"name":"get_weather","args":{"city":"Paris"},"partialArgs":[]}`)
if IsVertexStreamingFunctionCall(withArgs) {
t.Fatalf("expected IsVertexStreamingFunctionCall=false when complete args is present with empty partialArgs")
}
@@ -160,18 +160,18 @@ func TestVertexPartialArgsAssembler_SingleChunkParameterlessAndEmptyPartialArgsF
}
func TestVertexStreamingToolCall_CanContinueWith(t *testing.T) {
- c1 := gjson.Parse(`{"id":"call_1","name":"get_weather","partialArgs":[{"jsonPath":"$.city","stringValue":"New ","willContinue":true}],"willContinue":true}`)
+ c1 := ojson.Parse(`{"id":"call_1","name":"get_weather","partialArgs":[{"jsonPath":"$.city","stringValue":"New ","willContinue":true}],"willContinue":true}`)
active := NewVertexStreamingToolCall("get_weather", "get_weather", "call_1", "call_1", 0)
if _, done := active.Assembler.PushFunctionCall(c1); done {
t.Fatalf("expected done=false on chunk 1")
}
- c2OmittedID := gjson.Parse(`{"name":"get_weather","partialArgs":[{"jsonPath":"$.city","stringValue":"York"}],"willContinue":false}`)
+ c2OmittedID := ojson.Parse(`{"name":"get_weather","partialArgs":[{"jsonPath":"$.city","stringValue":"York"}],"willContinue":false}`)
if !active.CanContinueWith(c2OmittedID) {
t.Fatalf("expected CanContinueWith=true when continuation repeats name and omits id")
}
- c2DifferentID := gjson.Parse(`{"id":"call_2","name":"get_weather","partialArgs":[{"jsonPath":"$.city","stringValue":"LA"}],"willContinue":false}`)
+ c2DifferentID := ojson.Parse(`{"id":"call_2","name":"get_weather","partialArgs":[{"jsonPath":"$.city","stringValue":"LA"}],"willContinue":false}`)
if active.CanContinueWith(c2DifferentID) {
t.Fatalf("expected CanContinueWith=false when explicit id differs")
}
@@ -183,7 +183,7 @@ func TestVertexPartialArgsAssembler_QuotedJSONPathUnicodeAndLimits(t *testing.T)
`{"name":"fn","partialArgs":[{"jsonPath":"$['\\u0061']","numberValue":1},{"jsonPath":"$[\"\\uD83D\\uDE00\"]","stringValue":"smile"},{"jsonPath":"$['k\\'ey\\\\name']","boolValue":true}],"willContinue":false}`,
}
asm := NewVertexPartialArgsAssembler()
- delta, done := asm.PushFunctionCall(gjson.Parse(chunks[0]))
+ delta, done := asm.PushFunctionCall(ojson.Parse(chunks[0]))
if asm.Err() != nil {
t.Fatalf("unexpected error: %v", asm.Err())
}
@@ -205,7 +205,7 @@ func TestVertexPartialArgsAssembler_QuotedJSONPathUnicodeAndLimits(t *testing.T)
}
for _, raw := range invalidChunks {
asm := NewVertexPartialArgsAssembler()
- _, done := asm.PushFunctionCall(gjson.Parse(raw))
+ _, done := asm.PushFunctionCall(ojson.Parse(raw))
if asm.Err() == nil {
t.Fatalf("expected error for invalid chunk %s, got nil", raw)
}
diff --git a/internal/translator/gemini/claude/gemini_claude_compat_test.go b/internal/translator/gemini/claude/gemini_claude_compat_test.go
index b2436860b06..1d9dcda9c10 100644
--- a/internal/translator/gemini/claude/gemini_claude_compat_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_compat_test.go
@@ -3,8 +3,8 @@ package claude
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
)
const capturedGeminiThinkingSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
@@ -36,7 +36,7 @@ func TestConvertClaudeRequestToGeminiWithCompat_SignatureCompatibility(t *testin
t.Run(tt.name, func(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + tt.signature + `"}]}]}`)
withCompat, _ := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
- part := gjson.GetBytes(withCompat, "contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(withCompat, "contents.0.parts.0")
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
t.Fatalf("compat translation missing thought part: %s", withCompat)
}
@@ -51,12 +51,12 @@ func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
withoutCompat, _ := ConvertClaudeRequestToGemini("deepseek-v4", payload, false)
- if gjson.GetBytes(withoutCompat, "contents.0.parts.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(withoutCompat, "contents.0.parts.#").Int() != 0 {
t.Fatalf("default translation preserved thinking: %s", withoutCompat)
}
withCompat, _ := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
- part := gjson.GetBytes(withCompat, "contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(withCompat, "contents.0.parts.0")
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
t.Fatalf("compat translation missing thought part: %s", withCompat)
}
diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go
index 92e245ab6d4..db1022ed940 100644
--- a/internal/translator/gemini/claude/gemini_claude_request.go
+++ b/internal/translator/gemini/claude/gemini_claude_request.go
@@ -8,20 +8,19 @@ package claude
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete
// Gemini request body (as JSON bytes) ready to be sent via SendRawMessageStream.
-// All JSON transformations are performed using gjson/sjson.
+// All JSON transformations are performed using gjson/ojson.
//
// Parameters:
// - modelName: The name of the model.
@@ -47,21 +46,21 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
rawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON)
var drops translatorcommon.UserTurnDrops
// Build output Gemini request JSON
- out := []byte(`{"contents":[]}`)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ outDoc := ojson.NewDocString(`{"contents":[]}`)
+ _ = outDoc.Set("model", modelName)
// system instruction
- if systemResult := gjson.GetBytes(rawJSON, "system"); systemResult.IsArray() {
+ if systemResult := ojson.GetBytesNoCopy(rawJSON, "system"); systemResult.IsArray() {
systemParts := make([][]byte, 0, 2)
- systemResult.ForEach(func(_, systemPromptResult gjson.Result) bool {
+ systemResult.ForEach(func(_, systemPromptResult ojson.Result) bool {
if systemPromptResult.Get("type").String() == "text" {
textResult := systemPromptResult.Get("text")
- if textResult.Type == gjson.String {
+ if textResult.Type == ojson.String {
if util.IsClaudeCodeAttributionSystemText(textResult.String()) {
return true
}
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", textResult.String())
+ part, _ = ojson.SetBytes(part, "text", textResult.String())
systemParts = append(systemParts, part)
}
}
@@ -69,25 +68,25 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
})
if len(systemParts) > 0 {
systemInstruction := []byte(`{"role":"user","parts":[]}`)
- systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(systemParts))
- out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
+ systemInstruction, _ = ojson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(systemParts))
+ _ = outDoc.SetRawBytes("systemInstruction", systemInstruction)
}
- } else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
+ } else if systemResult.Type == ojson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", systemResult.String())
+ part, _ = ojson.SetBytes(part, "text", systemResult.String())
systemInstruction := []byte(`{"parts":[]}`)
systemInstruction = translatorcommon.SetRawArrayItems(systemInstruction, "parts", [][]byte{part})
- out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
+ _ = outDoc.SetRawBytes("systemInstruction", systemInstruction)
}
// contents
- if messagesResult := gjson.GetBytes(rawJSON, "messages"); messagesResult.IsArray() {
+ if messagesResult := ojson.GetBytesNoCopy(rawJSON, "messages"); messagesResult.IsArray() {
contentItems := translatorcommon.NewRawArrayItems(messagesResult.Get("#").Int())
toolNameByID := make(map[string]string)
var pendingToolUseIDs []string
- messagesResult.ForEach(func(_, messageResult gjson.Result) bool {
+ messagesResult.ForEach(func(_, messageResult ojson.Result) bool {
roleResult := messageResult.Get("role")
- if roleResult.Type != gjson.String {
+ if roleResult.Type != ojson.String {
return true
}
originalRole := roleResult.String()
@@ -108,7 +107,7 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
if roleResult.String() == "system" || roleResult.String() == "developer" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", reminderText)
+ part, _ = ojson.SetBytes(part, "text", reminderText)
partItems = append(partItems, part)
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
}
@@ -118,7 +117,7 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
if originalRole == "user" {
contentsResult = translatorcommon.AlignClaudeToolResults(contentsResult, precedingToolUseIDs)
}
- contentsResult.ForEach(func(_, contentResult gjson.Result) bool {
+ contentsResult.ForEach(func(_, contentResult ojson.Result) bool {
switch contentResult.Get("type").String() {
case "text":
text := contentResult.Get("text").String()
@@ -126,18 +125,18 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
return true
}
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
partItems = append(partItems, part)
case "thinking":
if !preserveEmptyThinkingBlocks {
return true
}
- part := []byte(`{"text":"","thought":true,"thoughtSignature":""}`)
- part, _ = sjson.SetBytes(part, "text", contentResult.Get("thinking").String())
+ partDoc := ojson.NewDocString(`{"text":"","thought":true,"thoughtSignature":""}`)
+ _ = partDoc.Set("text", contentResult.Get("thinking").String())
signature := sigcompat.GeminiReplaySignatureOrBypass(contentResult.Get("signature").String(), sigcompat.SignatureBlockKindGeminiModelPart)
- part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
- partItems = append(partItems, part)
+ _ = partDoc.Set("thoughtSignature", signature)
+ partItems = append(partItems, partDoc.Bytes())
case "tool_use":
functionName := contentResult.Get("name").String()
@@ -147,16 +146,16 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
}
functionName = util.SanitizeFunctionName(functionName)
functionArgs := contentResult.Get("input").String()
- argsResult := gjson.Parse(functionArgs)
- if argsResult.IsObject() && gjson.Valid(functionArgs) {
- part := []byte(`{"thoughtSignature":"","functionCall":{"name":"","args":{}}}`)
- part, _ = sjson.SetBytes(part, "thoughtSignature", geminiClaudeThoughtSignature)
+ argsResult := ojson.Parse(functionArgs)
+ if argsResult.IsObject() && ojson.Valid(functionArgs) {
+ partDoc := ojson.NewDocString(`{"thoughtSignature":"","functionCall":{"name":"","args":{}}}`)
+ _ = partDoc.Set("thoughtSignature", geminiClaudeThoughtSignature)
if toolUseID != "" {
- part, _ = sjson.SetBytes(part, "functionCall.id", toolUseID)
+ _ = partDoc.Set("functionCall.id", toolUseID)
}
- part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
- partItems = append(partItems, part)
+ _ = partDoc.Set("functionCall.name", functionName)
+ _ = partDoc.SetRaw("functionCall.args", functionArgs)
+ partItems = append(partItems, partDoc.Bytes())
if originalRole == "assistant" {
pendingToolUseIDs = append(pendingToolUseIDs, toolUseID)
}
@@ -176,25 +175,25 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
}
funcName = util.SanitizeFunctionName(funcName)
toolResult := util.ConvertClaudeToolResult(contentResult)
- part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
- part, _ = sjson.SetBytes(part, "functionResponse.id", toolCallID)
- part, _ = sjson.SetBytes(part, "functionResponse.name", funcName)
+ partDoc := ojson.NewDocString(`{"functionResponse":{"name":"","response":{}}}`)
+ _ = partDoc.Set("functionResponse.id", toolCallID)
+ _ = partDoc.Set("functionResponse.name", funcName)
resultPath := "functionResponse.response.result"
if toolResult.IsError {
resultPath = "functionResponse.response.error"
}
if toolResult.ResultIsRaw {
- part = translatorcommon.SetGeminiFunctionResponseRaw(part, resultPath, toolResult.Result)
+ translatorcommon.SetGeminiFunctionResponseRawDoc(partDoc, resultPath, toolResult.Result)
} else {
- part, _ = sjson.SetBytes(part, resultPath, toolResult.Result)
+ _ = partDoc.Set(resultPath, toolResult.Result)
}
- partItems = append(partItems, part)
for _, img := range toolResult.Images {
- imagePart := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
- imagePart, _ = sjson.SetBytes(imagePart, "inline_data.mime_type", img.MimeType)
- imagePart, _ = sjson.SetBytes(imagePart, "inline_data.data", img.Data)
- partItems = append(partItems, imagePart)
+ imagePartDoc := ojson.NewDocString(`{"inlineData":{"mimeType":"","data":""}}`)
+ _ = imagePartDoc.Set("inlineData.mimeType", img.MimeType)
+ _ = imagePartDoc.Set("inlineData.data", img.Data)
+ _ = partDoc.SetRawBytes("functionResponse.parts.-1", imagePartDoc.Bytes())
}
+ partItems = append(partItems, partDoc.Bytes())
case "image", "document", "container_upload":
if part := claudeBase64InlineData(contentResult.Get("source")); part != nil {
@@ -218,9 +217,9 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
if len(partItems) > 0 {
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
}
- } else if contentsResult.Type == gjson.String {
+ } else if contentsResult.Type == ojson.String {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", contentsResult.String())
+ part, _ = ojson.SetBytes(part, "text", contentsResult.String())
partItems = append(partItems, part)
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
}
@@ -229,10 +228,10 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
// Strip a trailing model turn with unanswered function calls.
if len(contentItems) > 0 {
- last := gjson.ParseBytes(contentItems[len(contentItems)-1])
+ last := util.ParseGJSONBytesNoCopy(contentItems[len(contentItems)-1])
if last.Get("role").String() == "model" {
hasFunctionCall := false
- last.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ last.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
hasFunctionCall = true
return false
@@ -244,42 +243,40 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
}
}
}
- out = translatorcommon.SetRawArrayItems(out, "contents", translatorcommon.MergeAdjacentGeminiContents(contentItems))
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "contents", translatorcommon.MergeAdjacentGeminiContents(contentItems))
}
// tools
var toolItems [][]byte
hasStrictTool := false
- if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() {
- toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
- if toolResult.Get("strict").Type == gjson.True {
+ if toolsResult := ojson.GetBytesNoCopy(rawJSON, "tools"); toolsResult.IsArray() {
+ toolsResult.ForEach(func(_, toolResult ojson.Result) bool {
+ if toolResult.Get("strict").Type == ojson.True {
hasStrictTool = true
}
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
inputSchema := util.CleanJSONSchemaForGeminiJSONSchema(inputSchemaResult.Raw)
- tool := []byte(toolResult.Raw)
- var err error
- tool, err = sjson.DeleteBytes(tool, "input_schema")
- if err != nil {
+ toolDoc := ojson.NewDocString(toolResult.Raw)
+ if errDelete := toolDoc.Delete("input_schema"); errDelete != nil {
return true
}
- tool, err = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema))
- if err != nil {
+ if errSetRaw := toolDoc.SetRaw("parametersJsonSchema", inputSchema); errSetRaw != nil {
return true
}
for _, path := range []string{"strict", "input_examples", "type", "cache_control", "defer_loading", "eager_input_streaming"} {
if toolResult.Get(path).Exists() {
- tool, _ = sjson.DeleteBytes(tool, path)
+ _ = toolDoc.Delete(path)
}
}
nameResult := toolResult.Get("name")
originalName := nameResult.String()
sanitizedName := util.SanitizeFunctionName(originalName)
- if nameResult.Type != gjson.String || sanitizedName != originalName {
- tool, _ = sjson.SetBytes(tool, "name", sanitizedName)
+ if nameResult.Type != ojson.String || sanitizedName != originalName {
+ _ = toolDoc.Set("name", sanitizedName)
}
- if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() {
+ tool := toolDoc.Bytes()
+ if ojson.ValidBytes(tool) && util.ParseGJSONBytesNoCopy(tool).IsObject() {
toolItems = append(toolItems, tool)
}
}
@@ -287,52 +284,52 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
})
if len(toolItems) > 0 {
tools := []byte(`[{"functionDeclarations":[]}]`)
- tools, _ = sjson.SetRawBytes(tools, "0.functionDeclarations", translatorcommon.JoinRawArray(toolItems))
- out, _ = sjson.SetRawBytes(out, "tools", tools)
+ tools, _ = ojson.SetRawBytes(tools, "0.functionDeclarations", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", tools)
}
}
// tool_choice
- toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
- if toolChoiceResult.Exists() && toolChoiceResult.Type != gjson.Null {
+ toolChoiceResult := ojson.GetBytesNoCopy(rawJSON, "tool_choice")
+ if toolChoiceResult.Exists() && toolChoiceResult.Type != ojson.Null {
toolChoiceType := ""
toolChoiceName := ""
if toolChoiceResult.IsObject() {
toolChoiceType = toolChoiceResult.Get("type").String()
toolChoiceName = toolChoiceResult.Get("name").String()
- } else if toolChoiceResult.Type == gjson.String {
+ } else if toolChoiceResult.Type == ojson.String {
toolChoiceType = toolChoiceResult.String()
}
switch toolChoiceType {
case "auto":
if hasStrictTool {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "VALIDATED")
} else {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "AUTO")
}
case "none":
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
case "any":
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "ANY")
case "tool":
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "ANY")
if toolChoiceName != "" {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)})
+ _ = outDoc.Set("toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)})
}
}
} else if hasStrictTool && len(toolItems) > 0 {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "VALIDATED")
}
// Map Anthropic thinking -> Gemini thinking config when enabled
// Translator only does format conversion, ApplyThinking handles model capability validation.
- if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
+ if t := ojson.GetBytesNoCopy(rawJSON, "thinking"); t.Exists() && t.IsObject() {
switch t.Get("type").String() {
case "enabled":
- if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
+ if b := t.Get("budget_tokens"); b.Exists() && b.Type == ojson.Number {
budget := int(b.Int())
- out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
+ _ = outDoc.Set("generationConfig.thinkingConfig.thinkingBudget", budget)
}
case "adaptive", "auto":
// For adaptive thinking:
@@ -340,41 +337,40 @@ func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool,
// - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max.
// ApplyThinking handles clamping to target model's supported levels.
effort := ""
- if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
+ if v := ojson.GetBytesNoCopy(rawJSON, "output_config.effort"); v.Exists() && v.Type == ojson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
- out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", effort)
+ _ = outDoc.Set("generationConfig.thinkingConfig.thinkingLevel", effort)
} else {
maxBudget := 0
if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil {
maxBudget = mi.Thinking.Max
}
if maxBudget > 0 {
- out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget)
+ _ = outDoc.Set("generationConfig.thinkingConfig.thinkingBudget", maxBudget)
} else {
- out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
+ _ = outDoc.Set("generationConfig.thinkingConfig.thinkingLevel", "high")
}
}
}
}
- if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.temperature", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "temperature"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.temperature", v.Num)
}
- if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.topP", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "top_p"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.topP", v.Num)
}
- if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() && v.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.topK", v.Num)
+ if v := ojson.GetBytesNoCopy(rawJSON, "top_k"); v.Exists() && v.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.topK", v.Num)
}
- result := out
- result = common.AttachDefaultSafetySettings(result, "safetySettings")
+ common.AttachDefaultSafetySettingsDoc(outDoc, "safetySettings")
- return result, drops.Err()
+ return outDoc.Bytes(), drops.Err()
}
-func claudeBase64InlineData(source gjson.Result) []byte {
+func claudeBase64InlineData(source ojson.Result) []byte {
if source.Get("type").String() != "base64" {
return nil
}
@@ -383,17 +379,17 @@ func claudeBase64InlineData(source gjson.Result) []byte {
if mimeType == "" || data == "" {
return nil
}
- part := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
- part, _ = sjson.SetBytes(part, "inline_data.mime_type", mimeType)
- part, _ = sjson.SetBytes(part, "inline_data.data", data)
- return part
+ partDoc := ojson.NewDocString(`{"inline_data":{"mime_type":"","data":""}}`)
+ _ = partDoc.Set("inline_data.mime_type", mimeType)
+ _ = partDoc.Set("inline_data.data", data)
+ return partDoc.Bytes()
}
func geminiContentWithParts(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDocString(`{"role":"","parts":[]}`)
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
func toolNameFromClaudeToolUseID(toolUseID string) string {
diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go
index 29471e5fe1d..79569385c6a 100644
--- a/internal/translator/gemini/claude/gemini_claude_request_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_request_test.go
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
)
func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {
@@ -37,12 +37,12 @@ func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- if got := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
+ if got := ojson.GetBytesNoCopy(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
}
- allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
+ allowed := ojson.GetBytesNoCopy(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if len(allowed) != 1 || allowed[0].String() != "json" {
- t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
+ t.Fatalf("Expected allowedFunctionNames ['json'], got %s", ojson.GetBytesNoCopy(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
}
}
@@ -55,13 +55,13 @@ func TestConvertClaudeRequestToGemini_StringSystemInstruction(t *testing.T) {
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- if got := gjson.GetBytes(output, "systemInstruction.parts.0.text").String(); got != "Be concise" {
+ if got := ojson.GetBytesNoCopy(output, "systemInstruction.parts.0.text").String(); got != "Be concise" {
t.Fatalf("Expected systemInstruction text %q, got %q", "Be concise", got)
}
- if gjson.GetBytes(output, "systemInstruction.role").Exists() {
- t.Fatalf("Expected systemInstruction.role to not exist, got %q", gjson.GetBytes(output, "systemInstruction.role").String())
+ if ojson.GetBytesNoCopy(output, "systemInstruction.role").Exists() {
+ t.Fatalf("Expected systemInstruction.role to not exist, got %q", ojson.GetBytesNoCopy(output, "systemInstruction.role").String())
}
- if gjson.GetBytes(output, "system_instruction").Exists() {
+ if ojson.GetBytesNoCopy(output, "system_instruction").Exists() {
t.Fatalf("Legacy system_instruction field should not be emitted: %s", output)
}
}
@@ -89,7 +89,7 @@ func TestConvertClaudeRequestToGemini_ImageContent(t *testing.T) {
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("Expected 2 parts, got %d", len(parts))
}
@@ -117,9 +117,9 @@ func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T)
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "systemInstruction.parts").Array()
if len(parts) != 2 {
- t.Fatalf("Expected 2 system parts after attribution strip, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
+ t.Fatalf("Expected 2 system parts after attribution strip, got %d: %s", len(parts), ojson.GetBytesNoCopy(output, "systemInstruction.parts").Raw)
}
if got := parts[0].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
t.Fatalf("Unexpected first system part: %q", got)
@@ -127,8 +127,8 @@ func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T)
if got := parts[1].Get("text").String(); got != "User system prompt" {
t.Fatalf("Unexpected second system part: %q", got)
}
- if gjson.GetBytes(output, `systemInstruction.parts.#(text%"x-anthropic-billing-header:*")`).Exists() {
- t.Fatalf("Claude Code attribution block was forwarded: %s", gjson.GetBytes(output, "systemInstruction.parts").Raw)
+ if ojson.GetBytesNoCopy(output, `systemInstruction.parts.#(text%"x-anthropic-billing-header:*")`).Exists() {
+ t.Fatalf("Claude Code attribution block was forwarded: %s", ojson.GetBytesNoCopy(output, "systemInstruction.parts").Raw)
}
}
@@ -145,13 +145,13 @@ func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() {
+ if systemContent := ojson.GetBytesNoCopy(output, `contents.#(role=="system")`); systemContent.Exists() {
t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw)
}
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 1 {
- t.Fatalf("Expected consecutive user and message-level system turns to be merged into a single user turn, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
+ t.Fatalf("Expected consecutive user and message-level system turns to be merged into a single user turn, got %d: %s", len(contents), ojson.GetBytesNoCopy(output, "contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
@@ -170,9 +170,9 @@ func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *
t.Fatalf("Unexpected array message-level system content text: %q", got)
}
- systemInstructionParts := gjson.GetBytes(output, "systemInstruction.parts").Array()
+ systemInstructionParts := ojson.GetBytesNoCopy(output, "systemInstruction.parts").Array()
if len(systemInstructionParts) != 1 {
- t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), ojson.GetBytesNoCopy(output, "systemInstruction.parts").Raw)
}
if got := systemInstructionParts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
@@ -192,13 +192,13 @@ func TestConvertClaudeRequestToGemini_MessageLevelDeveloperInstructionsBecomeMer
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- if devContent := gjson.GetBytes(output, `contents.#(role=="developer")`); devContent.Exists() {
+ if devContent := ojson.GetBytesNoCopy(output, `contents.#(role=="developer")`); devContent.Exists() {
t.Fatalf("developer role should not be emitted in contents: %s", devContent.Raw)
}
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 1 {
- t.Fatalf("Expected consecutive user and developer turns to be merged into a single user turn, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
+ t.Fatalf("Expected consecutive user and developer turns to be merged into a single user turn, got %d: %s", len(contents), ojson.GetBytesNoCopy(output, "contents").Raw)
}
if got := contents[0].Get("role").String(); got != "user" {
t.Fatalf("Expected first content role user, got %q", got)
@@ -217,9 +217,9 @@ func TestConvertClaudeRequestToGemini_MessageLevelDeveloperInstructionsBecomeMer
t.Fatalf("Unexpected array developer content text: %q", got)
}
- systemInstructionParts := gjson.GetBytes(output, "systemInstruction.parts").Array()
+ systemInstructionParts := ojson.GetBytesNoCopy(output, "systemInstruction.parts").Array()
if len(systemInstructionParts) != 1 {
- t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
+ t.Fatalf("Expected only top-level system parts, got %d: %s", len(systemInstructionParts), ojson.GetBytesNoCopy(output, "systemInstruction.parts").Raw)
}
if got := systemInstructionParts[0].Get("text").String(); got != "Top-level rules" {
t.Fatalf("Unexpected first system part: %q", got)
@@ -261,7 +261,7 @@ func TestConvertClaudeRequestToGemini_PreservesToolPairingWithInterveningSystemM
t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v\noutput: %s", errPairing, output)
}
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 merged contents (user, model, user), got %d: %s", len(contents), output)
}
@@ -309,7 +309,7 @@ func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) {
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("Expected 1 part after skipping empty text, got %d: %s", len(parts), output)
}
@@ -346,24 +346,153 @@ func TestConvertClaudeRequestToGemini_StructuredToolResult(t *testing.T) {
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
+ fr := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse")
if !fr.Exists() {
- t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
+ t.Fatalf("expected functionResponse part, contents=%s", ojson.GetBytesNoCopy(output, "contents").Raw)
}
// The text block must remain structured JSON, not a double-encoded string blob.
if got := fr.Get("response.result.text").String(); got != "alpha" {
t.Fatalf("expected structured result text 'alpha', got result=%s", fr.Get("response.result").Raw)
}
- // The image block must be emitted as a separate inline_data part, not embedded in result.
- img := gjson.GetBytes(output, "contents.1.parts.1.inline_data")
- if got := img.Get("mime_type").String(); got != "image/png" {
- t.Fatalf("expected image mime type 'image/png', got '%s'", got)
+ parts := ojson.GetBytesNoCopy(output, "contents.1.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("expected 1 part (functionResponse with nested inlineData), got %d: %s", len(parts), ojson.GetBytesNoCopy(output, "contents.1").Raw)
+ }
+ // The image block must be nested under functionResponse.parts as inlineData, not emitted as a sibling part.
+ img := fr.Get("parts.0.inlineData")
+ if !img.Exists() {
+ t.Fatalf("expected functionResponse.parts.0.inlineData to exist, got %s", fr.Raw)
+ }
+ if got := img.Get("mimeType").String(); got != "image/png" {
+ t.Fatalf("expected image mimeType 'image/png', got '%s'", got)
}
if got := img.Get("data").String(); got != "aGVsbG8=" {
t.Fatalf("expected image data 'aGVsbG8=', got '%s'", got)
}
}
+func TestConvertClaudeRequestToGemini_ToolResultImagesNestedInFunctionResponseParts(t *testing.T) {
+ t.Run("image-only tool_result nests inlineData under functionResponse.parts", func(t *testing.T) {
+ inputJSON := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "tool_use", "id": "toolu_read_img", "name": "Read", "input": {"file_path": "/tmp/photo.png"}}
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_read_img",
+ "content": [
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "iVBORw0KGgoAAAANSUhEUg=="}}
+ ]
+ }
+ ]
+ }
+ ]
+ }`)
+
+ output, err := ConvertClaudeRequestToGemini("gemini-3.8-flash-high", inputJSON, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ parts := ojson.GetBytesNoCopy(output, "contents.1.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("expected 1 part in tool response turn, got %d: %s", len(parts), ojson.GetBytesNoCopy(output, "contents.1").Raw)
+ }
+ fr := parts[0].Get("functionResponse")
+ if got := fr.Get("name").String(); got != "Read" {
+ t.Fatalf("functionResponse.name = %q, want Read", got)
+ }
+ if got := fr.Get("response.result").String(); got != "" {
+ t.Fatalf("functionResponse.response.result = %q, want empty string", got)
+ }
+ img := fr.Get("parts.0.inlineData")
+ if !img.Exists() {
+ t.Fatalf("expected functionResponse.parts.0.inlineData to exist, got: %s", fr.Raw)
+ }
+ if got := img.Get("mimeType").String(); got != "image/png" {
+ t.Fatalf("mimeType = %q, want image/png", got)
+ }
+ if got := img.Get("data").String(); got != "iVBORw0KGgoAAAANSUhEUg==" {
+ t.Fatalf("data = %q, want iVBORw0KGgoAAAANSUhEUg==", got)
+ }
+ })
+
+ t.Run("multiple images and parallel aligned tool_results stay bound to their own functionResponse", func(t *testing.T) {
+ inputJSON := []byte(`{
+ "model": "gemini-3.8-flash-high",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "tool_use", "id": "call_a", "name": "Read", "input": {"file_path": "/tmp/a.png"}},
+ {"type": "tool_use", "id": "call_b", "name": "Read", "input": {"file_path": "/tmp/b.jpg"}}
+ ]
+ },
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "call_b",
+ "content": [
+ {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "DATA_B1"}},
+ {"type": "image", "source": {"type": "base64", "media_type": "image/webp", "data": "DATA_B2"}}
+ ]
+ },
+ {
+ "type": "tool_result",
+ "tool_use_id": "call_a",
+ "content": [
+ {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "DATA_A1"}}
+ ]
+ }
+ ]
+ }
+ ]
+ }`)
+
+ output, err := ConvertClaudeRequestToGemini("gemini-3.8-flash-high", inputJSON, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ parts := ojson.GetBytesNoCopy(output, "contents.1.parts").Array()
+ if len(parts) != 2 {
+ t.Fatalf("expected 2 functionResponse parts, got %d: %s", len(parts), ojson.GetBytesNoCopy(output, "contents.1").Raw)
+ }
+
+ frA := parts[0].Get("functionResponse")
+ if got := frA.Get("id").String(); got != "call_a" {
+ t.Fatalf("first functionResponse.id = %q, want call_a", got)
+ }
+ frAParts := frA.Get("parts").Array()
+ if len(frAParts) != 1 || frAParts[0].Get("inlineData.mimeType").String() != "image/png" || frAParts[0].Get("inlineData.data").String() != "DATA_A1" {
+ t.Fatalf("unexpected call_a functionResponse.parts: %s", frA.Raw)
+ }
+
+ frB := parts[1].Get("functionResponse")
+ if got := frB.Get("id").String(); got != "call_b" {
+ t.Fatalf("second functionResponse.id = %q, want call_b", got)
+ }
+ frBParts := frB.Get("parts").Array()
+ if len(frBParts) != 2 {
+ t.Fatalf("expected 2 nested image parts for call_b, got %d: %s", len(frBParts), frB.Raw)
+ }
+ if frBParts[0].Get("inlineData.mimeType").String() != "image/jpeg" || frBParts[0].Get("inlineData.data").String() != "DATA_B1" {
+ t.Fatalf("unexpected call_b image 0: %s", frBParts[0].Raw)
+ }
+ if frBParts[1].Get("inlineData.mimeType").String() != "image/webp" || frBParts[1].Get("inlineData.data").String() != "DATA_B2" {
+ t.Fatalf("unexpected call_b image 1: %s", frBParts[1].Raw)
+ }
+ })
+}
+
func TestConvertClaudeRequestToGemini_AlignsPermutedParallelToolResultsWithMixedText(t *testing.T) {
inputJSON := []byte(`{
"model":"gemini-3.7-flash-high",
@@ -384,8 +513,8 @@ func TestConvertClaudeRequestToGemini_AlignsPermutedParallelToolResultsWithMixed
}`)
output, _ := ConvertClaudeRequestToGemini("gemini-3.7-flash-high", inputJSON, false)
- callParts := gjson.GetBytes(output, "contents.0.parts").Array()
- responseParts := gjson.GetBytes(output, "contents.1.parts").Array()
+ callParts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
+ responseParts := ojson.GetBytesNoCopy(output, "contents.1.parts").Array()
if len(callParts) != 3 || len(responseParts) != 5 {
t.Fatalf("translated parts = %d calls and %d response-turn parts; output=%s", len(callParts), len(responseParts), output)
}
@@ -435,9 +564,9 @@ func TestConvertClaudeRequestToGemini_StringToolResult(t *testing.T) {
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
+ fr := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse")
if !fr.Exists() {
- t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
+ t.Fatalf("expected functionResponse part, contents=%s", ojson.GetBytesNoCopy(output, "contents").Raw)
}
// String content must not be double-encoded: result should be exactly "alpha".
if got := fr.Get("response.result").String(); got != "alpha" {
@@ -471,7 +600,7 @@ func TestConvertClaudeRequestToGemini_ToolResultWithTrailingSystemReminderReorde
output, _ := ConvertClaudeRequestToGemini("gemini-3.8-flash", inputJSON, false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents turns, got %d: %s", len(contents), output)
}
@@ -554,15 +683,15 @@ func TestConvertClaudeRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T
}`, tt.toolChoice)
result, _ := ConvertClaudeRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false)
- if gjson.GetBytes(result, "tools.0.functionDeclarations.0.strict").Exists() {
+ if ojson.GetBytesNoCopy(result, "tools.0.functionDeclarations.0.strict").Exists() {
t.Fatalf("strict must be removed from functionDeclarations: %s", result)
}
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != tt.expectedMode {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = %q, got %q. Output: %s", tt.expectedMode, mode, result)
}
if len(tt.allowedNames) > 0 {
- allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
+ allowed := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if len(allowed) != len(tt.allowedNames) {
t.Fatalf("expected %d allowedFunctionNames, got %d", len(tt.allowedNames), len(allowed))
}
@@ -595,7 +724,7 @@ func TestConvertClaudeRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T
]
}`)
result, _ := ConvertClaudeRequestToGemini("gemini-3.8-flash", inputJSON, false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "VALIDATED" {
t.Fatalf("expected mode = 'VALIDATED' for mixed tools, got %q", mode)
}
@@ -620,7 +749,7 @@ func TestConvertClaudeRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T
]
}`)
result, _ := ConvertClaudeRequestToGemini("gemini-3.8-flash", inputJSON, false)
- if gjson.GetBytes(result, "toolConfig").Exists() {
+ if ojson.GetBytesNoCopy(result, "toolConfig").Exists() {
t.Fatalf("expected toolConfig not to be set when no strict tools and no tool_choice, got: %s", result)
}
})
@@ -654,11 +783,11 @@ func TestConvertClaudeRequestToGemini_ParametersJsonSchema_PreservesAdditionalPr
}`)
output, _ := ConvertClaudeRequestToGemini("gemini-2.5-flash", inputJSON, false)
- schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
+ schema := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
if !schema.Exists() {
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
}
- if got := schema.Get("additionalProperties"); !got.Exists() || got.Type != gjson.False {
+ if got := schema.Get("additionalProperties"); !got.Exists() || got.Type != ojson.False {
t.Fatalf("additionalProperties should be preserved as false, got: %v. Schema: %s", got, schema.Raw)
}
if got := schema.Get("properties.recipient.pattern"); !got.Exists() || got.String() != "^(alice|bob)$" {
@@ -700,8 +829,8 @@ func TestConvertClaudeRequestToGemini_FunctionResponseJSONRef(t *testing.T) {
}`)
output, _ := ConvertClaudeRequestToGemini("gemini-3.8-flash", inputJSON, false)
- result := gjson.GetBytes(output, "contents.1.parts.0.functionResponse.response.result")
- if result.Type != gjson.String {
+ result := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse.response.result")
+ if result.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got %s (raw: %s)", result.Type, result.Raw)
}
if !strings.Contains(result.String(), "#/components/schemas/ErrorModel") {
@@ -730,14 +859,14 @@ func TestConvertClaudeRequestToGemini_Issue5960_DocumentPreservation(t *testing.
]
}`)
pdfOut, _ := ConvertClaudeRequestToGemini("gemini-2.5-pro", pdfInput, false)
- pdfParts := gjson.GetBytes(pdfOut, "contents.0.parts").Array()
+ pdfParts := ojson.GetBytesNoCopy(pdfOut, "contents.0.parts").Array()
if len(pdfParts) == 0 {
t.Fatalf("expected non-empty parts for base64 document, got: %s", string(pdfOut))
}
- if gotMime := gjson.GetBytes(pdfOut, "contents.0.parts.0.inline_data.mime_type").String(); gotMime != "application/pdf" {
+ if gotMime := ojson.GetBytesNoCopy(pdfOut, "contents.0.parts.0.inline_data.mime_type").String(); gotMime != "application/pdf" {
t.Fatalf("expected inline_data.mime_type application/pdf, got %q (output: %s)", gotMime, string(pdfOut))
}
- if gotData := gjson.GetBytes(pdfOut, "contents.0.parts.0.inline_data.data").String(); gotData != "JVBERi0xLjQK" {
+ if gotData := ojson.GetBytesNoCopy(pdfOut, "contents.0.parts.0.inline_data.data").String(); gotData != "JVBERi0xLjQK" {
t.Fatalf("expected inline_data.data JVBERi0xLjQK, got %q (output: %s)", gotData, string(pdfOut))
}
@@ -762,7 +891,7 @@ func TestConvertClaudeRequestToGemini_Issue5960_DocumentPreservation(t *testing.
]
}`)
mixedOut, _ := ConvertClaudeRequestToGemini("gemini-2.5-pro", mixedInput, false)
- mixedParts := gjson.GetBytes(mixedOut, "contents.0.parts").Array()
+ mixedParts := ojson.GetBytesNoCopy(mixedOut, "contents.0.parts").Array()
if len(mixedParts) != 2 {
t.Fatalf("expected 2 parts for mixed text + document, got %d (output: %s)", len(mixedParts), string(mixedOut))
}
@@ -802,7 +931,7 @@ func TestConvertClaudeRequestToGemini_Issue5960_DocumentPreservation(t *testing.
]
}`)
interleavedOut, _ := ConvertClaudeRequestToGemini("gemini-2.5-pro", interleavedInput, false)
- interleavedParts := gjson.GetBytes(interleavedOut, "contents.0.parts").Array()
+ interleavedParts := ojson.GetBytesNoCopy(interleavedOut, "contents.0.parts").Array()
if len(interleavedParts) != 3 {
t.Fatalf("expected 3 parts for interleaved documents, got %d (output: %s)", len(interleavedParts), string(interleavedOut))
}
@@ -823,7 +952,7 @@ func TestConvertClaudeRequestToGemini_Issue5960_DocumentPreservation(t *testing.
]
}`)
unsupportedOut, _ := ConvertClaudeRequestToGemini("gemini-2.5-pro", unsupportedInput, false)
- contents := gjson.GetBytes(unsupportedOut, "contents").Array()
+ contents := ojson.GetBytesNoCopy(unsupportedOut, "contents").Array()
if len(contents) != 0 {
t.Fatalf("expected 0 contents turns instead of empty parts turn, got: %s", string(unsupportedOut))
}
@@ -838,7 +967,7 @@ func TestConvertClaudeRequestToGemini_ContainerUploadKeepsOtherText(t *testing.T
]}]
}`)
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "keep me" {
t.Fatalf("text was dropped with the file: %s", output)
}
@@ -877,7 +1006,7 @@ func TestConvertClaudeRequestToGemini_ImageURLAndRedactedThinkingStaySkipped(t *
]}]
}`)
output, _ := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "keep me" {
t.Fatalf("old skip paths changed the turn: %s", output)
}
diff --git a/internal/translator/gemini/claude/gemini_claude_response.go b/internal/translator/gemini/claude/gemini_claude_response.go
index a84dc9e2093..eecb94028ef 100644
--- a/internal/translator/gemini/claude/gemini_claude_response.go
+++ b/internal/translator/gemini/claude/gemini_claude_response.go
@@ -13,10 +13,9 @@ import (
"strings"
"sync/atomic"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Params holds parameters for response conversion.
@@ -104,7 +103,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
p := (*param).(*Params)
flushPendingToolInput := func() {
if p.ResponseType == 3 && p.CurrentToolPendingEmpty && !p.CurrentToolInputEmitted {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, p.ResponseIndex)), "delta.partial_json", "{}")
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, p.ResponseIndex)), "delta.partial_json", "{}")
appendEvent("content_block_delta", string(data))
p.CurrentToolInputEmitted = true
p.CurrentToolPendingEmpty = false
@@ -155,13 +154,13 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
}
if !p.HasFinalEvents {
stopReason := resolveGeminiClaudeStopReason(p.FinishReason, p.SawToolCall)
- template := []byte(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, stopReason))
- template, _ = sjson.SetBytes(template, "usage.output_tokens", p.OutputTokens)
- template, _ = sjson.SetBytes(template, "usage.input_tokens", p.InputTokens)
+ templateDoc := ojson.NewDocString(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, stopReason))
+ _ = templateDoc.Set("usage.output_tokens", p.OutputTokens)
+ _ = templateDoc.Set("usage.input_tokens", p.InputTokens)
if p.CachedTokens > 0 {
- template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", p.CachedTokens)
+ _ = templateDoc.Set("usage.cache_read_input_tokens", p.CachedTokens)
}
- appendEvent("message_delta", string(template))
+ appendEvent("message_delta", templateDoc.String())
p.HasFinalEvents = true
}
appendEvent("message_stop", `{"type":"message_stop"}`)
@@ -178,7 +177,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
if signature == "" || (*param).(*Params).ResponseType != 2 {
return
}
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
}
@@ -195,7 +194,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
p.ResponseType = 0
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, p.ResponseIndex))
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, p.ResponseIndex)), "delta.signature", signature)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, p.ResponseIndex)), "delta.signature", signature)
appendEvent("content_block_delta", string(data))
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, p.ResponseIndex))
p.ResponseIndex++
@@ -208,23 +207,23 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
if !(*param).(*Params).HasFirstResponse {
// Create the initial message structure with default values
// This follows the Claude API specification for streaming message initialization
- messageStartTemplate := []byte(`{"type":"message_start","message":{"id":"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
+ messageStartDoc := ojson.NewDocString(`{"type":"message_start","message":{"id":"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
// Override default values with actual response metadata if available
- if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
- messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.model", modelVersionResult.String())
+ if modelVersionResult := ojson.GetBytesNoCopy(rawJSON, "modelVersion"); modelVersionResult.Exists() {
+ _ = messageStartDoc.Set("message.model", modelVersionResult.String())
}
- if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
- messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.id", responseIDResult.String())
+ if responseIDResult := ojson.GetBytesNoCopy(rawJSON, "responseId"); responseIDResult.Exists() {
+ _ = messageStartDoc.Set("message.id", responseIDResult.String())
}
- appendEvent("message_start", string(messageStartTemplate))
+ appendEvent("message_start", messageStartDoc.String())
(*param).(*Params).HasFirstResponse = true
}
// Process the response parts array from the backend client
// Each part can contain text content, thinking content, or function calls
- partsResult := gjson.GetBytes(rawJSON, "candidates.0.content.parts")
+ partsResult := ojson.GetBytesNoCopy(rawJSON, "candidates.0.content.parts")
if partsResult.IsArray() {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
@@ -239,7 +238,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
}
partSig := ""
if thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" {
- partSig = thoughtSignatureResult.String()
+ partSig = strings.Clone(thoughtSignatureResult.String())
}
hasThoughtSignature := partSig != ""
isThought := partResult.Get("thought").Bool()
@@ -277,7 +276,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
partText := partTextResult.String()
if (*param).(*Params).ResponseType == 2 {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText)
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
} else {
@@ -288,7 +287,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex))
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partText)
appendEvent("content_block_delta", string(data))
(*param).(*Params).ResponseType = 2
(*param).(*Params).HasContent = true
@@ -301,9 +300,9 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
// From here on, !isThought (visible text, functionCall, or standalone signature)
if functionCallResult.Exists() {
- rawToolName := strings.TrimSpace(functionCallResult.Get("name").String())
- upstreamToolName := util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, rawToolName)
- clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName)
+ rawToolName := strings.Clone(strings.TrimSpace(functionCallResult.Get("name").String()))
+ upstreamToolName := strings.Clone(util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, rawToolName))
+ clientToolName := strings.Clone(util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName))
if active := p.ActiveToolCall; active != nil && active.CanContinueWith(functionCallResult) {
p.SawToolCall = true
@@ -353,7 +352,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
abortActiveToolCall()
continue
}
- upstreamID := strings.TrimSpace(functionCallResult.Get("id").String())
+ upstreamID := strings.Clone(strings.TrimSpace(functionCallResult.Get("id").String()))
toolID := util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1)))
active := translatorcommon.NewVertexStreamingToolCall(rawToolName, clientToolName, upstreamID, toolID, 0)
if len(originalRequestRawJSON) > 0 {
@@ -386,10 +385,10 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
p.CurrentToolIsParameterless = util.ClaudeToolIsParameterless(clientToolName, originalRequestRawJSON)
p.CurrentToolInputEmitted = false
p.CurrentToolPendingEmpty = false
- data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, p.ResponseIndex))
- data, _ = sjson.SetBytes(data, "content_block.id", toolID)
- data, _ = sjson.SetBytes(data, "content_block.name", clientToolName)
- appendEvent("content_block_start", string(data))
+ dataDoc := ojson.NewDocString(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, p.ResponseIndex))
+ _ = dataDoc.Set("content_block.id", toolID)
+ _ = dataDoc.Set("content_block.name", clientToolName)
+ appendEvent("content_block_start", dataDoc.String())
p.ResponseType = 3
p.HasContent = true
@@ -439,7 +438,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).CurrentToolPendingEmpty = false
}
if partialJSON != "" || !(*param).(*Params).CurrentToolPendingEmpty {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON)
appendEvent("content_block_delta", string(data))
}
}
@@ -476,10 +475,10 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).CurrentToolIsParameterless = util.ClaudeToolIsParameterless(clientToolName, originalRequestRawJSON)
(*param).(*Params).CurrentToolInputEmitted = false
(*param).(*Params).CurrentToolPendingEmpty = false
- data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex))
- data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
- data, _ = sjson.SetBytes(data, "content_block.name", clientToolName)
- appendEvent("content_block_start", string(data))
+ dataDoc := ojson.NewDocString(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex))
+ _ = dataDoc.Set("content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
+ _ = dataDoc.Set("content_block.name", clientToolName)
+ appendEvent("content_block_start", dataDoc.String())
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
partialJSON := util.CleanClaudeToolArgumentsWithTranslated(fcArgsResult.Raw, clientToolName, originalRequestRawJSON, requestRawJSON)
@@ -496,7 +495,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).CurrentToolInputEmitted = true
}
if partialJSON != "" || !(*param).(*Params).CurrentToolPendingEmpty {
- data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", partialJSON)
appendEvent("content_block_delta", string(data))
}
}
@@ -529,7 +528,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
appendCarrierThinkingBlock(partSig)
}
if (*param).(*Params).ResponseType == 1 {
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText)
appendEvent("content_block_delta", string(data))
(*param).(*Params).HasContent = true
} else {
@@ -539,7 +538,7 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
(*param).(*Params).ResponseIndex++
}
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex))
- data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText)
+ data, _ := ojson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partText)
appendEvent("content_block_delta", string(data))
(*param).(*Params).ResponseType = 1
(*param).(*Params).HasContent = true
@@ -558,12 +557,12 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
}
}
- if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() != "" {
- (*param).(*Params).FinishReason = finish.String()
+ if finish := ojson.GetBytesNoCopy(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() != "" {
+ (*param).(*Params).FinishReason = strings.Clone(finish.String())
flushPendingToolInput()
}
- usageResult := gjson.GetBytes(rawJSON, "usageMetadata")
+ usageResult := ojson.GetBytesNoCopy(rawJSON, "usageMetadata")
if usageResult.Exists() {
cachedTokens := usageResult.Get("cachedContentTokenCount").Int()
promptTokens := usageResult.Get("promptTokenCount").Int() - cachedTokens
@@ -608,14 +607,14 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
}
stopReason := resolveGeminiClaudeStopReason((*param).(*Params).FinishReason, (*param).(*Params).SawToolCall)
- template := []byte(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, stopReason))
- template, _ = sjson.SetBytes(template, "usage.output_tokens", (*param).(*Params).OutputTokens)
- template, _ = sjson.SetBytes(template, "usage.input_tokens", (*param).(*Params).InputTokens)
+ templateDoc := ojson.NewDocString(fmt.Sprintf(`{"type":"message_delta","delta":{"stop_reason":"%s","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`, stopReason))
+ _ = templateDoc.Set("usage.output_tokens", (*param).(*Params).OutputTokens)
+ _ = templateDoc.Set("usage.input_tokens", (*param).(*Params).InputTokens)
if (*param).(*Params).CachedTokens > 0 {
- template, _ = sjson.SetBytes(template, "usage.cache_read_input_tokens", (*param).(*Params).CachedTokens)
+ _ = templateDoc.Set("usage.cache_read_input_tokens", (*param).(*Params).CachedTokens)
}
- appendEvent("message_delta", string(template))
+ appendEvent("message_delta", templateDoc.String())
(*param).(*Params).HasFinalEvents = true
}
}
@@ -636,13 +635,13 @@ func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestR
func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
toolNameMap := util.UnambiguousToolNameMapFromClaudeRequest(originalRequestRawJSON)
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
- out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
- out, _ = sjson.SetBytes(out, "id", root.Get("responseId").String())
- out, _ = sjson.SetBytes(out, "model", root.Get("modelVersion").String())
+ outDoc := ojson.NewDocString(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
+ _ = outDoc.Set("id", root.Get("responseId").String())
+ _ = outDoc.Set("model", root.Get("modelVersion").String())
cachedTokens := root.Get("usageMetadata.cachedContentTokenCount").Int()
inputTokens := root.Get("usageMetadata.promptTokenCount").Int() - cachedTokens
@@ -650,10 +649,10 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
inputTokens = 0
}
outputTokens := root.Get("usageMetadata.candidatesTokenCount").Int() + root.Get("usageMetadata.thoughtsTokenCount").Int()
- out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
+ _ = outDoc.Set("usage.input_tokens", inputTokens)
+ _ = outDoc.Set("usage.output_tokens", outputTokens)
if cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
+ _ = outDoc.Set("usage.cache_read_input_tokens", cachedTokens)
}
parts := root.Get("candidates.0.content.parts")
@@ -668,7 +667,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
return
}
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", textBuilder.String())
+ block, _ = ojson.SetBytes(block, "text", textBuilder.String())
blocks = append(blocks, block)
textBuilder.Reset()
}
@@ -677,12 +676,12 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
return
}
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":""}`)
+ _ = blockDoc.Set("thinking", thinkingBuilder.String())
if thinkingSignature != "" {
- block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
+ _ = blockDoc.Set("signature", thinkingSignature)
}
- blocks = append(blocks, block)
+ blocks = append(blocks, blockDoc.Bytes())
thinkingBuilder.Reset()
thinkingSignature = ""
}
@@ -692,7 +691,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
return
}
carrier := []byte(`{"type":"thinking","thinking":"","signature":""}`)
- carrier, _ = sjson.SetBytes(carrier, "signature", signature)
+ carrier, _ = ojson.SetBytes(carrier, "signature", signature)
blocks = append(blocks, carrier)
}
@@ -751,15 +750,15 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
upstreamToolName := functionCall.Get("name").String()
upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName)
clientToolName := util.MapToolName(toolNameMap, upstreamToolName)
- toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
- toolBlock, _ = sjson.SetBytes(toolBlock, "name", clientToolName)
+ toolBlockDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = toolBlockDoc.Set("id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
+ _ = toolBlockDoc.Set("name", clientToolName)
inputRaw := "{}"
- if args := functionCall.Get("args"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {
+ if args := functionCall.Get("args"); args.Exists() && ojson.Valid(args.Raw) && args.IsObject() {
inputRaw = util.CleanClaudeToolArgumentsWithTranslated(args.Raw, clientToolName, originalRequestRawJSON, requestRawJSON)
}
- toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
- blocks = append(blocks, toolBlock)
+ _ = toolBlockDoc.SetRaw("input", inputRaw)
+ blocks = append(blocks, toolBlockDoc.Bytes())
continue
}
@@ -784,7 +783,7 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
flushText()
if len(blocks) > 0 {
- out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
+ _ = outDoc.SetRawBytes("content", translatorcommon.JoinRawArray(blocks))
}
var finishReason string
@@ -794,13 +793,13 @@ func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, origina
if isGeminiMalformedFunctionCall(finishReason, hasToolCall) {
return []byte(geminiMalformedFunctionCallErrorPayload)
}
- out, _ = sjson.SetBytes(out, "stop_reason", resolveGeminiClaudeStopReason(finishReason, hasToolCall))
+ _ = outDoc.Set("stop_reason", resolveGeminiClaudeStopReason(finishReason, hasToolCall))
if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() {
- out, _ = sjson.DeleteBytes(out, "usage")
+ _ = outDoc.Delete("usage")
}
- return out
+ return outDoc.Bytes()
}
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
diff --git a/internal/translator/gemini/claude/gemini_claude_response_test.go b/internal/translator/gemini/claude/gemini_claude_response_test.go
index 59efd0a93a6..3bf45b52620 100644
--- a/internal/translator/gemini/claude/gemini_claude_response_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_response_test.go
@@ -7,7 +7,8 @@ import (
"sync/atomic"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertGeminiResponseToClaude_VertexStreamingPartialArgs(t *testing.T) {
@@ -32,8 +33,8 @@ func TestConvertGeminiResponseToClaude_VertexStreamingPartialArgs(t *testing.T)
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if gjson.Get(payload, "type").String() == "content_block_delta" && gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- piece := gjson.Get(payload, "delta.partial_json").String()
+ if ojson.Get(payload, "type").String() == "content_block_delta" && ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ piece := ojson.Get(payload, "delta.partial_json").String()
partialJSON.WriteString(piece)
if chunkIdx == 0 {
firstChunkDelta += piece
@@ -66,18 +67,18 @@ func TestConvertGeminiResponseToClaude_VertexStreamingPartialArgs(t *testing.T)
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- switch gjson.Get(payload, "type").String() {
+ switch ojson.Get(payload, "type").String() {
case "content_block_start":
- if gjson.Get(payload, "content_block.type").String() == "tool_use" {
- toolIDs = append(toolIDs, gjson.Get(payload, "content_block.id").String())
+ if ojson.Get(payload, "content_block.type").String() == "tool_use" {
+ toolIDs = append(toolIDs, ojson.Get(payload, "content_block.id").String())
}
case "content_block_delta":
- if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- idx := gjson.Get(payload, "index").Int()
+ if ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ idx := ojson.Get(payload, "index").Int()
if byBlock[idx] == nil {
byBlock[idx] = &strings.Builder{}
}
- byBlock[idx].WriteString(gjson.Get(payload, "delta.partial_json").String())
+ byBlock[idx].WriteString(ojson.Get(payload, "delta.partial_json").String())
}
}
}
@@ -123,24 +124,24 @@ func TestConvertGeminiResponseToClaude_VertexStreamingPartialArgs(t *testing.T)
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- switch gjson.Get(payload, "type").String() {
+ switch ojson.Get(payload, "type").String() {
case "content_block_delta":
- if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- if gotIdx := gjson.Get(payload, "index").Int(); gotIdx != 0 {
+ if ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ if gotIdx := ojson.Get(payload, "index").Int(); gotIdx != 0 {
t.Fatalf("input_json_delta index = %d, want 0; payload=%s", gotIdx, payload)
}
if toolStopped {
t.Fatalf("input_json_delta emitted after tool block stopped: %s", payload)
}
- block0JSON.WriteString(gjson.Get(payload, "delta.partial_json").String())
- } else if gjson.Get(payload, "delta.type").String() == "signature_delta" {
+ block0JSON.WriteString(ojson.Get(payload, "delta.partial_json").String())
+ } else if ojson.Get(payload, "delta.type").String() == "signature_delta" {
if !toolStopped {
t.Fatalf("signature_delta emitted before tool block finished: %s", payload)
}
- deferredSigs = append(deferredSigs, gjson.Get(payload, "delta.signature").String())
+ deferredSigs = append(deferredSigs, ojson.Get(payload, "delta.signature").String())
}
case "content_block_stop":
- if gjson.Get(payload, "index").Int() == 0 {
+ if ojson.Get(payload, "index").Int() == 0 {
toolStopped = true
}
}
@@ -174,8 +175,8 @@ func TestConvertGeminiResponseToClaude_VertexStreamingPartialArgs(t *testing.T)
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if gjson.Get(payload, "type").String() == "content_block_delta" && gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- d := gjson.Get(payload, "delta.partial_json").String()
+ if ojson.Get(payload, "type").String() == "content_block_delta" && ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ d := ojson.Get(payload, "delta.partial_json").String()
if chunkIdx == 0 {
chunk0Delta += d
}
@@ -305,7 +306,7 @@ func TestConvertGeminiResponseToClaudeNonStream_PreservesThoughtSignature(t *tes
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
@@ -351,7 +352,7 @@ func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutT
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
@@ -398,7 +399,7 @@ func TestConvertGeminiResponseToClaudeNonStream_TrailingSignatureOnlyPart(t *tes
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
@@ -454,7 +455,7 @@ func TestConvertGeminiResponseToClaude_UsageWithCachedContentTokenCount(t *testi
for _, line := range strings.Split(outputText, "\n") {
if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"message_delta"`) {
foundMessageDelta = true
- deltaJSON := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ deltaJSON := ojson.Parse(strings.TrimPrefix(line, "data: "))
inputTokens := deltaJSON.Get("usage.input_tokens").Int()
if inputTokens != 9 {
t.Fatalf("expected usage.input_tokens = 9 (100 - 91), got %d. Payload: %s", inputTokens, line)
@@ -494,7 +495,7 @@ func TestConvertGeminiResponseToClaudeNonStream_UsageWithCachedContentTokenCount
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
inputTokens := outputJSON.Get("usage.input_tokens").Int()
if inputTokens != 9 {
@@ -548,7 +549,7 @@ func TestConvertGeminiResponseToClaudeStream_PartlessSafetyClosesMessageWithRefu
for _, line := range strings.Split(outputText, "\n") {
if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"message_delta"`) {
foundDelta = true
- deltaJSON := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ deltaJSON := ojson.Parse(strings.TrimPrefix(line, "data: "))
if got := deltaJSON.Get("delta.stop_reason").String(); got != "refusal" {
t.Fatalf("stop_reason = %q, want refusal. Payload: %s", got, line)
}
@@ -604,7 +605,7 @@ func TestConvertGeminiResponseToClaudeStream_PartlessMalformedFunctionCallCloses
for _, line := range strings.Split(outputText, "\n") {
if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"error"`) {
foundError = true
- errJSON := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ errJSON := ojson.Parse(strings.TrimPrefix(line, "data: "))
if got := errJSON.Get("error.type").String(); got != "api_error" {
t.Fatalf("error.type = %q, want api_error. Payload: %s", got, line)
}
@@ -722,7 +723,7 @@ func TestConvertGeminiResponseToClaude_MalformedFunctionCallWithToolUsePreserves
ctx := context.Background()
nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "claude-opus-5-5", requestJSON, requestJSON, rawJSON, nil)
- nonStreamJSON := gjson.ParseBytes(nonStream)
+ nonStreamJSON := util.ParseGJSONBytesNoCopy(nonStream)
if got := nonStreamJSON.Get("stop_reason").String(); got != "tool_use" {
t.Fatalf("non-stream stop_reason = %q, want tool_use. Output: %s", got, string(nonStream))
}
@@ -777,7 +778,7 @@ func TestConvertGeminiResponseToClaudeStream_PartlessStopClosesMessageWithEndTur
for _, line := range strings.Split(outputText, "\n") {
if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"message_delta"`) {
foundDelta = true
- deltaJSON := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ deltaJSON := ojson.Parse(strings.TrimPrefix(line, "data: "))
if got := deltaJSON.Get("delta.stop_reason").String(); got != "end_turn" {
t.Fatalf("stop_reason = %q, want end_turn. Payload: %s", got, line)
}
@@ -822,7 +823,7 @@ func TestConvertGeminiResponseToClaudeNonStream_SafetyAndMalformedFunctionCallRe
"responseId": "resp-test"
}`)
output := ConvertGeminiResponseToClaudeNonStream(ctx, "claude-opus-5-5", requestJSON, requestJSON, rawJSON, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
if got := outputJSON.Get("stop_reason").String(); got != tc.wantReason {
t.Fatalf("stop_reason = %q, want %q. Output: %s", got, tc.wantReason, string(output))
}
@@ -847,7 +848,7 @@ func TestConvertGeminiResponseToClaudeNonStream_MalformedFunctionCallReturnsAPIE
}`)
output := ConvertGeminiResponseToClaudeNonStream(context.Background(), "claude-opus-5-5", requestJSON, requestJSON, rawJSON, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
if got := outputJSON.Get("stop_reason").String(); got == "refusal" {
t.Fatalf("unexpected stop_reason = %q. Output: %s", got, string(output))
}
@@ -885,7 +886,7 @@ func TestConvertGeminiResponseToClaudeNonStream_Issue6409_SignedVisibleText(t *t
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 2 {
@@ -928,7 +929,7 @@ func TestConvertGeminiResponseToClaudeNonStream_Issue6409_ThinkingFollowedBySign
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 3 {
@@ -965,7 +966,7 @@ func TestConvertGeminiResponseToClaudeNonStream_Issue6409_ConsecutiveSignedVisib
ctx := context.Background()
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3.5-flash-lite", requestJSON, requestJSON, geminiResponse, nil)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
blocks := outputJSON.Get("content").Array()
if len(blocks) != 4 {
@@ -1270,7 +1271,7 @@ func TestConvertGeminiResponseToClaudeNonStream_UniqueToolUseIDsAcrossTurns(t *t
var streamToolID string
for _, line := range strings.Split(string(streamBytes), "\n") {
if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"tool_use"`) {
- streamToolID = gjson.Get(strings.TrimPrefix(line, "data: "), "content_block.id").String()
+ streamToolID = ojson.Get(strings.TrimPrefix(line, "data: "), "content_block.id").String()
break
}
}
@@ -1279,13 +1280,13 @@ func TestConvertGeminiResponseToClaudeNonStream_UniqueToolUseIDsAcrossTurns(t *t
}
nonStream1 := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-4-argon", requestJSON, requestJSON, rawResponse, nil)
- nonStreamID1 := gjson.GetBytes(nonStream1, "content.0.id").String()
+ nonStreamID1 := ojson.GetBytesNoCopy(nonStream1, "content.0.id").String()
if nonStreamID1 == "" {
t.Fatalf("expected tool_use id in first non-stream response, got: %s", string(nonStream1))
}
nonStream2 := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-4-argon", requestJSON, requestJSON, rawResponse, nil)
- nonStreamID2 := gjson.GetBytes(nonStream2, "content.0.id").String()
+ nonStreamID2 := ojson.GetBytesNoCopy(nonStream2, "content.0.id").String()
if nonStreamID2 == "" {
t.Fatalf("expected tool_use id in second non-stream response, got: %s", string(nonStream2))
}
@@ -1306,8 +1307,8 @@ func geminiClaudeInputJSONDeltas(t *testing.T, streamText string) string {
continue
}
payload := strings.TrimPrefix(line, "data: ")
- if gjson.Get(payload, "delta.type").String() == "input_json_delta" {
- builder.WriteString(gjson.Get(payload, "delta.partial_json").String())
+ if ojson.Get(payload, "delta.type").String() == "input_json_delta" {
+ builder.WriteString(ojson.Get(payload, "delta.partial_json").String())
}
}
return builder.String()
@@ -1320,7 +1321,7 @@ func TestConvertGeminiResponseToClaude_SanitizesParameterlessToolArguments(t *te
ctx := context.Background()
nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, origRequest, rawResponse, nil)
- if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != "{}" {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.input").Raw; got != "{}" {
t.Fatalf("expected non-streaming input {}, got %s", got)
}
@@ -1339,10 +1340,10 @@ func TestConvertGeminiResponseToClaude_CanonicalNameCollisionKeepsExactToolArgum
ctx := context.Background()
nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, origRequest, readWithArgs, nil)
- if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"path":"/tmp/a"}` {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.input").Raw; got != `{"path":"/tmp/a"}` {
t.Fatalf("expected exact read tool args preserved, got %s (%s)", got, string(nonStream))
}
- if got := gjson.GetBytes(nonStream, "content.0.name").String(); got != "read" {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.name").String(); got != "read" {
t.Fatalf("expected exact tool name read, got %q (%s)", got, string(nonStream))
}
@@ -1353,7 +1354,7 @@ func TestConvertGeminiResponseToClaude_CanonicalNameCollisionKeepsExactToolArgum
}
nonStreamParameterless := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, origRequest, readParameterless, nil)
- if got := gjson.GetBytes(nonStreamParameterless, "content.0.input").Raw; got != "{}" {
+ if got := ojson.GetBytesNoCopy(nonStreamParameterless, "content.0.input").Raw; got != "{}" {
t.Fatalf("expected exact Read tool normalized to {}, got %s", got)
}
}
@@ -1363,10 +1364,10 @@ func TestConvertGeminiResponseToClaude_AmbiguousCanonicalNameKeepsUpstreamNameAn
ambiguousResponse := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"READ","args":{"path":"/tmp/a"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-ambiguous"}`)
nonStream := ConvertGeminiResponseToClaudeNonStream(context.Background(), "gemini-3-flash", origRequest, origRequest, ambiguousResponse, nil)
- if got := gjson.GetBytes(nonStream, "content.0.name").String(); got != "READ" {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.name").String(); got != "READ" {
t.Fatalf("expected ambiguous upstream name READ preserved, got %q (%s)", got, string(nonStream))
}
- if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"path":"/tmp/a"}` {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.input").Raw; got != `{"path":"/tmp/a"}` {
t.Fatalf("expected ambiguous tool args preserved, got %s", got)
}
}
@@ -1381,7 +1382,7 @@ func TestConvertGeminiResponseToClaude_KeepsReasonWhenTranslatedSchemaHasNoPlace
rawResponse := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"Bash","args":{"command":"ls","reason":"why"}}}],"role":"model"},"finishReason":"STOP"}],"modelVersion":"gemini-3-flash","responseId":"resp-bash-reason"}`)
nonStream := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-3-flash", origRequest, translatedRequest, rawResponse, nil)
- if got := gjson.GetBytes(nonStream, "content.0.input").Raw; got != `{"command":"ls","reason":"why"}` {
+ if got := ojson.GetBytesNoCopy(nonStream, "content.0.input").Raw; got != `{"command":"ls","reason":"why"}` {
t.Fatalf("expected non-streaming input preserved, got %s (translated: %s)", got, string(translatedRequest))
}
}
diff --git a/internal/translator/gemini/claude/gemini_claude_tool_result_error_test.go b/internal/translator/gemini/claude/gemini_claude_tool_result_error_test.go
index 956995f9569..4eff59a3337 100644
--- a/internal/translator/gemini/claude/gemini_claude_tool_result_error_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_tool_result_error_test.go
@@ -4,7 +4,7 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeRequestToGemini_PreservesToolResultErrors(t *testing.T) {
@@ -74,26 +74,26 @@ func TestConvertClaudeRequestToGemini_PreservesToolResultErrors(t *testing.T) {
if errTranslate != nil {
t.Fatal(errTranslate)
}
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
t.Fatalf("invalid translated JSON: %s", output)
}
- response := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
+ response := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse")
if response.Get("id").String() != "call_1" || response.Get("name").String() != "Read" {
t.Fatalf("tool result lost its call identity: %s", response.Raw)
}
if got := response.Get("response").Raw; got != tt.wantResponse {
t.Fatalf("functionResponse.response = %s, want %s", got, tt.wantResponse)
}
- parts := gjson.GetBytes(output, "contents.1.parts").Array()
- wantParts := 1
+ parts := ojson.GetBytesNoCopy(output, "contents.1.parts").Array()
+ if len(parts) != 1 {
+ t.Fatalf("tool result parts = %d, want 1: %s", len(parts), ojson.GetBytesNoCopy(output, "contents.1").Raw)
+ }
if tt.wantImage {
- wantParts++
- if got := gjson.GetBytes(output, "contents.1.parts.1.inline_data").Raw; got != `{"mime_type":"image/png","data":"aGVsbG8="}` {
- t.Fatalf("tool image changed: %s", got)
+ if got := response.Get("parts.0.inlineData").Raw; got != `{"mimeType":"image/png","data":"aGVsbG8="}` {
+ t.Fatalf("functionResponse.parts[0].inlineData = %s", got)
}
- }
- if len(parts) != wantParts {
- t.Fatalf("tool result parts = %d, want %d", len(parts), wantParts)
+ } else if response.Get("parts").Exists() {
+ t.Fatalf("unexpected functionResponse.parts: %s", response.Get("parts").Raw)
}
})
}
diff --git a/internal/translator/gemini/claude/gemini_claude_user_turn_test.go b/internal/translator/gemini/claude/gemini_claude_user_turn_test.go
index b6240375023..b73212810f7 100644
--- a/internal/translator/gemini/claude/gemini_claude_user_turn_test.go
+++ b/internal/translator/gemini/claude/gemini_claude_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const userTurnUpload = `{"type":"container_upload","file_id":"file-1"}`
@@ -49,7 +49,7 @@ func TestConvertClaudeRequestToGemini_RefusesAnyEmptiedUserTurn(t *testing.T) {
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -62,7 +62,7 @@ func TestConvertClaudeRequestToGemini_KeepsTurnWithTextBesideAttachment(t *testi
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -73,7 +73,7 @@ func TestConvertClaudeRequestToGemini_Base64DocumentAfterHistoryStaysInlineData(
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "contents.2.parts.0.inline_data")
+ part := ojson.GetBytesNoCopy(body, "contents.2.parts.0.inline_data")
if part.Get("mime_type").String() != "application/pdf" || part.Get("data").String() != "JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -119,7 +119,7 @@ func TestClaudeToGeminiUnrepresentableImageBesideTextStillSucceeds(t *testing.T)
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- parts := gjson.GetBytes(envelope.Body, "contents.2.parts").Array()
+ parts := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "keep me" {
t.Fatalf("text was lost or an image leaked: %s", envelope.Body)
}
@@ -135,7 +135,7 @@ func TestClaudeToGeminiBase64ImageOnlyTurnStaysInlineData(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- inlineData := gjson.GetBytes(envelope.Body, "contents.2.parts.0.inline_data")
+ inlineData := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts.0.inline_data")
if inlineData.Get("mime_type").String() != "image/png" || inlineData.Get("data").String() != "aGVsbG8=" {
t.Fatalf("inlineData = %s, body = %s", inlineData.Raw, envelope.Body)
}
@@ -154,7 +154,7 @@ func TestClaudeToGeminiRealTextBesideWhitespaceAndAnImageStillSucceeds(t *testin
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
found := false
- for _, part := range gjson.GetBytes(envelope.Body, "contents.2.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts").Array() {
if part.Get("text").String() == "keep me" {
found = true
}
diff --git a/internal/translator/gemini/common/safety.go b/internal/translator/gemini/common/safety.go
index e4b14293826..e457d81e55c 100644
--- a/internal/translator/gemini/common/safety.go
+++ b/internal/translator/gemini/common/safety.go
@@ -1,8 +1,7 @@
package common
import (
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// DefaultSafetySettings returns the default Gemini safety configuration we attach to requests.
@@ -34,14 +33,22 @@ func DefaultSafetySettings() []map[string]string {
// AttachDefaultSafetySettings ensures the default safety settings are present when absent.
// The caller must provide the target JSON path (e.g. "safetySettings" or "request.safetySettings").
func AttachDefaultSafetySettings(rawJSON []byte, path string) []byte {
- if gjson.GetBytes(rawJSON, path).Exists() {
+ if ojson.GetBytesNoCopy(rawJSON, path).Exists() {
return rawJSON
}
- out, err := sjson.SetBytes(rawJSON, path, DefaultSafetySettings())
+ out, err := ojson.SetBytes(rawJSON, path, DefaultSafetySettings())
if err != nil {
return rawJSON
}
return out
}
+
+// AttachDefaultSafetySettingsDoc ensures the default safety settings are present on doc when absent.
+func AttachDefaultSafetySettingsDoc(doc *ojson.Doc, path string) {
+ if doc == nil || doc.Get(path).Exists() {
+ return
+ }
+ _ = doc.Set(path, DefaultSafetySettings())
+}
diff --git a/internal/translator/gemini/gemini/gemini_gemini_request.go b/internal/translator/gemini/gemini/gemini_gemini_request.go
index 330ac46ec8a..984d58471aa 100644
--- a/internal/translator/gemini/gemini/gemini_gemini_request.go
+++ b/internal/translator/gemini/gemini/gemini_gemini_request.go
@@ -7,13 +7,12 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ConvertGeminiRequestToGemini normalizes Gemini v1beta requests.
@@ -29,44 +28,61 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) ([]byte
return common.AttachDefaultSafetySettings(rawJSON, "safetySettings"), nil
}
- toolsResult := gjson.GetBytes(rawJSON, "tools")
+ toolsResult := ojson.GetBytesNoCopy(rawJSON, "tools")
if toolsResult.Exists() && toolsResult.IsArray() {
var toolItems [][]byte
toolsChanged := false
- toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
- tool := []byte(toolResult.Raw)
- toolChanged := false
- if declarations := toolResult.Get("functionDeclarations"); declarations.Exists() {
- tool, _ = sjson.SetRawBytes(tool, "function_declarations", []byte(declarations.Raw))
- tool, _ = sjson.DeleteBytes(tool, "functionDeclarations")
- toolChanged = true
+ toolsResult.ForEach(func(_, toolResult ojson.Result) bool {
+ var toolDoc *ojson.Doc
+ ensureToolDoc := func() *ojson.Doc {
+ if toolDoc == nil {
+ toolDoc = ojson.NewDocString(toolResult.Raw)
+ }
+ return toolDoc
+ }
+ declarations := toolResult.Get("function_declarations")
+ if camelDecls := toolResult.Get("functionDeclarations"); camelDecls.Exists() {
+ d := ensureToolDoc()
+ _ = d.SetRaw("function_declarations", camelDecls.Raw)
+ _ = d.Delete("functionDeclarations")
+ declarations = camelDecls
}
- declarations := gjson.GetBytes(tool, "function_declarations")
if declarations.IsArray() {
var declarationItems [][]byte
declarationsChanged := false
- declarations.ForEach(func(_, declarationResult gjson.Result) bool {
+ declarations.ForEach(func(_, declarationResult ojson.Result) bool {
declaration := []byte(declarationResult.Raw)
if parameters := declarationResult.Get("parameters"); parameters.Exists() {
- declaration, _ = sjson.SetRawBytes(declaration, "parametersJsonSchema", []byte(parameters.Raw))
- declaration, _ = sjson.DeleteBytes(declaration, "parameters")
+ normalizedParams := util.NormalizeJSONSchemaNumericConstraints(parameters.Raw)
+ declDoc := ojson.NewDocString(declarationResult.Raw)
+ _ = declDoc.SetRaw("parametersJsonSchema", normalizedParams)
+ _ = declDoc.Delete("parameters")
+ declaration = declDoc.Bytes()
declarationsChanged = true
+ } else if parametersJSONSchema := declarationResult.Get("parametersJsonSchema"); parametersJSONSchema.Exists() {
+ if normalizedParams := util.NormalizeJSONSchemaNumericConstraints(parametersJSONSchema.Raw); normalizedParams != parametersJSONSchema.Raw {
+ declaration, _ = ojson.SetRawBytes(declaration, "parametersJsonSchema", []byte(normalizedParams))
+ declarationsChanged = true
+ }
}
declarationItems = append(declarationItems, declaration)
return true
})
if declarationsChanged {
- tool, _ = sjson.SetRawBytes(tool, "function_declarations", translatorcommon.JoinRawArray(declarationItems))
- toolChanged = true
+ _ = ensureToolDoc().SetRawBytes("function_declarations", translatorcommon.JoinRawArray(declarationItems))
}
}
- toolsChanged = toolsChanged || toolChanged
- toolItems = append(toolItems, tool)
+ if toolDoc != nil {
+ toolsChanged = true
+ toolItems = append(toolItems, toolDoc.Bytes())
+ } else {
+ toolItems = append(toolItems, []byte(toolResult.Raw))
+ }
return true
})
if toolsChanged {
- rawJSON, _ = sjson.SetRawBytes(rawJSON, "tools", translatorcommon.JoinRawArray(toolItems))
+ rawJSON, _ = ojson.SetRawBytes(rawJSON, "tools", translatorcommon.JoinRawArray(toolItems))
}
}
@@ -75,7 +91,7 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) ([]byte
prevRole := ""
if contents.IsArray() {
rolesChanged := false
- contents.ForEach(func(_, value gjson.Result) bool {
+ contents.ForEach(func(_, value ojson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
if translatorcommon.ContentHasGeminiFunctionResponse([]byte(value.Raw)) {
@@ -91,7 +107,7 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) ([]byte
if rolesChanged {
prevRole = ""
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
- contents.ForEach(func(_, value gjson.Result) bool {
+ contents.ForEach(func(_, value ojson.Result) bool {
role := value.Get("role").String()
item := []byte(value.Raw)
if role != "user" && role != "model" {
@@ -100,17 +116,18 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) ([]byte
} else {
role = nextGeminiRole(prevRole)
}
- item, _ = sjson.SetBytes(item, "role", role)
+ item, _ = ojson.SetBytes(item, "role", role)
}
prevRole = role
contentItems = append(contentItems, item)
return true
})
- out, _ = sjson.SetRawBytes(out, "contents", translatorcommon.JoinRawArray(contentItems))
+ out, _ = ojson.SetRawBytes(out, "contents", translatorcommon.JoinRawArray(contentItems))
}
} else {
idx := 0
- contents.ForEach(func(_ gjson.Result, value gjson.Result) bool {
+ var outDoc *ojson.Doc
+ contents.ForEach(func(_ ojson.Result, value ojson.Result) bool {
role := value.Get("role").String()
if role != "user" && role != "model" {
if translatorcommon.ContentHasGeminiFunctionResponse([]byte(value.Raw)) {
@@ -118,19 +135,32 @@ func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) ([]byte
} else {
role = nextGeminiRole(prevRole)
}
- out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.role", idx), role)
+ if outDoc == nil {
+ outDoc = ojson.NewDoc(out)
+ }
+ _ = outDoc.Set(fmt.Sprintf("contents.%d.role", idx), role)
}
prevRole = role
idx++
return true
})
+ if outDoc != nil {
+ out = outDoc.Bytes()
+ }
}
out = signature.SanitizeGeminiRequestThoughtSignatures(out, "contents")
- if gjson.GetBytes(rawJSON, "generationConfig.responseSchema").Exists() {
- strJson, _ := util.RenameKey(string(out), "generationConfig.responseSchema", "generationConfig.responseJsonSchema")
- out = []byte(strJson)
+ if responseSchema := ojson.GetBytesNoCopy(out, "generationConfig.responseSchema"); responseSchema.Exists() {
+ normalizedSchema := util.NormalizeJSONSchemaNumericConstraints(responseSchema.Raw)
+ outDoc := ojson.NewDoc(out)
+ _ = outDoc.SetRaw("generationConfig.responseJsonSchema", normalizedSchema)
+ _ = outDoc.Delete("generationConfig.responseSchema")
+ out = outDoc.Bytes()
+ } else if responseJSONSchema := ojson.GetBytesNoCopy(out, "generationConfig.responseJsonSchema"); responseJSONSchema.Exists() {
+ if normalizedSchema := util.NormalizeJSONSchemaNumericConstraints(responseJSONSchema.Raw); normalizedSchema != responseJSONSchema.Raw {
+ out, _ = ojson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(normalizedSchema))
+ }
}
// Backfill empty functionResponse.name from the preceding functionCall.name.
@@ -152,7 +182,7 @@ func backfillEmptyFunctionResponseNames(data []byte) []byte {
}
canBatch := contents.IsArray()
if canBatch {
- contents.ForEach(func(_, content gjson.Result) bool {
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
if parts.Exists() && !parts.IsArray() {
canBatch = false
@@ -176,14 +206,14 @@ func backfillEmptyFunctionResponseNames(data []byte) []byte {
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
var pendingCallNames []string
- contents.ForEach(func(contentIdx, content gjson.Result) bool {
+ contents.ForEach(func(contentIdx, content ojson.Result) bool {
role := content.Get("role").String()
contentRaw := []byte(content.Raw)
// Collect functionCall names from model turns.
if role == "model" {
var names []string
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
@@ -199,13 +229,13 @@ func backfillEmptyFunctionResponseNames(data []byte) []byte {
responseIndex := 0
partsChanged := false
partItems := make([][]byte, 0, 4)
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
partRaw := []byte(part.Raw)
if part.Get("functionResponse").Exists() {
name := part.Get("functionResponse.name").String()
if strings.TrimSpace(name) == "" {
if responseIndex < len(pendingCallNames) {
- partRaw, _ = sjson.SetBytes(partRaw, "functionResponse.name", pendingCallNames[responseIndex])
+ partRaw, _ = ojson.SetBytes(partRaw, "functionResponse.name", pendingCallNames[responseIndex])
partsChanged = true
} else {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
@@ -217,7 +247,7 @@ func backfillEmptyFunctionResponseNames(data []byte) []byte {
return true
})
if partsChanged {
- contentRaw, _ = sjson.SetRawBytes(contentRaw, "parts", translatorcommon.JoinRawArray(partItems))
+ contentRaw, _ = ojson.SetRawBytes(contentRaw, "parts", translatorcommon.JoinRawArray(partItems))
changed = true
}
pendingCallNames = nil
@@ -230,21 +260,21 @@ func backfillEmptyFunctionResponseNames(data []byte) []byte {
if !changed {
return data
}
- out, errSetContents := sjson.SetRawBytes(data, "contents", translatorcommon.JoinRawArray(contentItems))
+ out, errSetContents := ojson.SetRawBytes(data, "contents", translatorcommon.JoinRawArray(contentItems))
if errSetContents != nil {
return data
}
return out
}
-func geminiFunctionResponseNamesNeedBackfill(contents gjson.Result) (bool, []int64) {
+func geminiFunctionResponseNamesNeedBackfill(contents ojson.Result) (bool, []int64) {
var pendingCallNames []string
var excessResponseIndexes []int64
needsBackfill := false
- contents.ForEach(func(contentIdx, content gjson.Result) bool {
+ contents.ForEach(func(contentIdx, content ojson.Result) bool {
if content.Get("role").String() == "model" {
var names []string
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
@@ -257,7 +287,7 @@ func geminiFunctionResponseNamesNeedBackfill(contents gjson.Result) (bool, []int
return true
}
responseIndex := 0
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionResponse").Exists() {
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
if responseIndex < len(pendingCallNames) {
@@ -276,13 +306,13 @@ func geminiFunctionResponseNamesNeedBackfill(contents gjson.Result) (bool, []int
return needsBackfill, excessResponseIndexes
}
-func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents gjson.Result) []byte {
- out := data
+func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents ojson.Result) []byte {
+ var outDoc *ojson.Doc
var pendingCallNames []string
- contents.ForEach(func(contentIdx, content gjson.Result) bool {
+ contents.ForEach(func(contentIdx, content ojson.Result) bool {
if content.Get("role").String() == "model" {
var names []string
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
names = append(names, part.Get("functionCall.name").String())
}
@@ -293,12 +323,15 @@ func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents gjson.Result
}
if len(pendingCallNames) > 0 {
responseIndex := 0
- content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(partIdx, part ojson.Result) bool {
if part.Get("functionResponse").Exists() {
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
if responseIndex < len(pendingCallNames) {
path := fmt.Sprintf("contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int())
- out, _ = sjson.SetBytes(out, path, pendingCallNames[responseIndex])
+ if outDoc == nil {
+ outDoc = ojson.NewDoc(data)
+ }
+ _ = outDoc.Set(path, pendingCallNames[responseIndex])
} else {
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
}
@@ -311,7 +344,10 @@ func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents gjson.Result
}
return true
})
- return out
+ if outDoc != nil {
+ return outDoc.Bytes()
+ }
+ return data
}
func nextGeminiRole(previousRole string) string {
diff --git a/internal/translator/gemini/gemini/gemini_gemini_request_test.go b/internal/translator/gemini/gemini/gemini_gemini_request_test.go
index d70128a3ee0..e3f42050c5b 100644
--- a/internal/translator/gemini/gemini/gemini_gemini_request_test.go
+++ b/internal/translator/gemini/gemini/gemini_gemini_request_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const largeInlineDataSize = 20 << 20
@@ -88,7 +88,7 @@ func TestBackfillEmptyFunctionResponseNames_Single(t *testing.T) {
out := backfillEmptyFunctionResponseNames(input)
- name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
+ name := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionResponse.name").String()
if name != "Bash" {
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
}
@@ -116,8 +116,8 @@ func TestBackfillEmptyFunctionResponseNames_Parallel(t *testing.T) {
out := backfillEmptyFunctionResponseNames(input)
- name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
- name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
+ name0 := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionResponse.name").String()
+ name1 := ojson.GetBytesNoCopy(out, "contents.1.parts.1.functionResponse.name").String()
if name0 != "Read" {
t.Errorf("Expected first name 'Read', got '%s'", name0)
}
@@ -146,7 +146,7 @@ func TestBackfillEmptyFunctionResponseNames_PreservesExisting(t *testing.T) {
out := backfillEmptyFunctionResponseNames(input)
- name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
+ name := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionResponse.name").String()
if name != "Bash" {
t.Errorf("Expected preserved name 'Bash', got '%s'", name)
}
@@ -172,7 +172,7 @@ func TestConvertGeminiRequestToGemini_BackfillsEmptyName(t *testing.T) {
out, _ := ConvertGeminiRequestToGemini("", input, false)
- name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
+ name := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionResponse.name").String()
if name != "Bash" {
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
}
@@ -200,12 +200,12 @@ func TestBackfillEmptyFunctionResponseNames_MoreResponsesThanCalls(t *testing.T)
out := backfillEmptyFunctionResponseNames(input)
- name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
+ name0 := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionResponse.name").String()
if name0 != "Bash" {
t.Errorf("Expected first name 'Bash', got '%s'", name0)
}
// Second response has no matching call, should remain empty
- name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
+ name1 := ojson.GetBytesNoCopy(out, "contents.1.parts.1.functionResponse.name").String()
if name1 != "" {
t.Errorf("Expected second name to remain empty, got '%s'", name1)
}
@@ -244,8 +244,8 @@ func TestBackfillEmptyFunctionResponseNames_MultipleGroups(t *testing.T) {
out := backfillEmptyFunctionResponseNames(input)
- name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
- name1 := gjson.GetBytes(out, "contents.3.parts.0.functionResponse.name").String()
+ name0 := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionResponse.name").String()
+ name1 := ojson.GetBytesNoCopy(out, "contents.3.parts.0.functionResponse.name").String()
if name0 != "Read" {
t.Errorf("Expected first group name 'Read', got '%s'", name0)
}
@@ -262,7 +262,7 @@ func TestConvertGeminiRequestToGemini_FunctionResponseWithInvalidRoleNormalizesT
]
}`)
out, _ := ConvertGeminiRequestToGemini("gemini-3-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
if len(contents) != 2 {
t.Fatalf("expected 2 contents, got %d", len(contents))
}
@@ -270,3 +270,177 @@ func TestConvertGeminiRequestToGemini_FunctionResponseWithInvalidRoleNormalizesT
t.Fatalf("functionResponse invalid role should normalize to user, got %q", got)
}
}
+
+func TestConvertGeminiRequestToGemini_NormalizesParametersNumericConstraints_Issue7149(t *testing.T) {
+ inputJSON := []byte(`{
+ "contents": [
+ {"role": "user", "parts": [{"text": "run command"}]}
+ ],
+ "tools": [
+ {
+ "functionDeclarations": [
+ {
+ "name": "run_command",
+ "parameters": {
+ "type": "OBJECT",
+ "minProperties": "1",
+ "maxProperties": "10",
+ "properties": {
+ "Message": {
+ "type": "STRING",
+ "minLength": "1",
+ "maxLength": "200",
+ "enum": ["1", "2"],
+ "default": "1",
+ "example": "1",
+ "examples": ["1", {"minLength": "1"}]
+ },
+ "Args": {
+ "type": "ARRAY",
+ "minItems": "1",
+ "maxItems": "5",
+ "items": {
+ "type": "STRING",
+ "minLength": "2",
+ "maxLength": "50"
+ }
+ },
+ "Filter": {
+ "anyOf": [
+ {"type": "STRING", "minLength": "3"},
+ {"type": "ARRAY", "minItems": "4", "items": {"type": "STRING"}}
+ ]
+ },
+ "minLength": {
+ "type": "STRING",
+ "minLength": "6",
+ "default": "6"
+ },
+ "properties": {
+ "type": "STRING",
+ "minLength": "7"
+ },
+ "default": {
+ "type": "STRING",
+ "minLength": "8",
+ "default": "8"
+ },
+ "a\\b": {
+ "type": "STRING",
+ "minLength": "11"
+ }
+ },
+ "patternProperties": {
+ "^\\d+$": {
+ "type": "STRING",
+ "maxLength": "12"
+ }
+ },
+ "x-metadata": {
+ "minLength": "2",
+ "maxItems": "9"
+ },
+ "schema": {
+ "minLength": "10"
+ }
+ }
+ }
+ ]
+ }
+ ],
+ "generationConfig": {
+ "responseSchema": {
+ "type": "OBJECT",
+ "minProperties": "1",
+ "properties": {
+ "Summary": {
+ "type": "STRING",
+ "minLength": "5"
+ }
+ }
+ }
+ }
+ }`)
+
+ out, err := ConvertGeminiRequestToGemini("gemini-2.5-pro", inputJSON, false)
+ if err != nil {
+ t.Fatalf("ConvertGeminiRequestToGemini returned error: %v", err)
+ }
+
+ decl := ojson.GetBytesNoCopy(out, "tools.0.function_declarations.0")
+ if decl.Get("parameters").Exists() {
+ t.Fatalf("expected parameters to be converted to parametersJsonSchema, got: %s", decl.Raw)
+ }
+ schema := decl.Get("parametersJsonSchema")
+ if !schema.Exists() {
+ t.Fatalf("expected parametersJsonSchema to exist, got: %s", decl.Raw)
+ }
+
+ numericChecks := []struct {
+ path string
+ want int64
+ }{
+ {"minProperties", 1},
+ {"maxProperties", 10},
+ {"properties.Message.minLength", 1},
+ {"properties.Message.maxLength", 200},
+ {"properties.Args.minItems", 1},
+ {"properties.Args.maxItems", 5},
+ {"properties.Args.items.minLength", 2},
+ {"properties.Args.items.maxLength", 50},
+ {"properties.Filter.anyOf.0.minLength", 3},
+ {"properties.Filter.anyOf.1.minItems", 4},
+ {"properties.minLength.minLength", 6},
+ {"properties.properties.minLength", 7},
+ {"properties.default.minLength", 8},
+ }
+ for _, tc := range numericChecks {
+ got := schema.Get(tc.path)
+ if got.Type != ojson.Number || got.Int() != tc.want {
+ t.Errorf("parametersJsonSchema.%s = %v (type %v), want number %d; schema=%s", tc.path, got.Raw, got.Type, tc.want, schema.Raw)
+ }
+ }
+
+ stringChecks := []struct {
+ path string
+ want string
+ }{
+ {"properties.Message.enum.0", "1"},
+ {"properties.Message.enum.1", "2"},
+ {"properties.Message.default", "1"},
+ {"properties.Message.example", "1"},
+ {"properties.Message.examples.0", "1"},
+ {"properties.Message.examples.1.minLength", "1"},
+ {"properties.minLength.default", "6"},
+ {"properties.default.default", "8"},
+ {"x-metadata.minLength", "2"},
+ {"x-metadata.maxItems", "9"},
+ {"schema.minLength", "10"},
+ }
+ for _, tc := range stringChecks {
+ got := schema.Get(tc.path)
+ if got.Type != ojson.String || got.String() != tc.want {
+ t.Errorf("parametersJsonSchema.%s = %v (type %v), want string %q", tc.path, got.Raw, got.Type, tc.want)
+ }
+ }
+ if got := schema.Get("properties").Map()[`a\b`].Get("minLength"); got.Type != ojson.Number || got.Int() != 11 {
+ t.Errorf(`parametersJsonSchema.properties["a\\b"].minLength = %s (type %v), want number 11`, got.Raw, got.Type)
+ }
+ if _, bogus := schema.Get("properties").Map()["ab"]; bogus {
+ t.Errorf(`unexpected fabricated property "ab" in parametersJsonSchema.properties`)
+ }
+ if got := schema.Get("patternProperties").Map()[`^\d+$`].Get("maxLength"); got.Type != ojson.Number || got.Int() != 12 {
+ t.Errorf(`parametersJsonSchema.patternProperties["^\\d+$"].maxLength = %s (type %v), want number 12`, got.Raw, got.Type)
+ }
+
+ respSchema := ojson.GetBytesNoCopy(out, "generationConfig.responseJsonSchema")
+ if !respSchema.Exists() {
+ t.Fatalf("expected generationConfig.responseJsonSchema to exist, got: %s", out)
+ }
+ if got := respSchema.Get("minProperties"); got.Type != ojson.Number || got.Int() != 1 {
+ t.Errorf("responseJsonSchema.minProperties = %v (type %v), want number 1", got.Raw, got.Type)
+ }
+ if got := respSchema.Get("properties.Summary.minLength"); got.Type != ojson.Number || got.Int() != 5 {
+ t.Errorf("responseJsonSchema.properties.Summary.minLength = %v (type %v), want number 5", got.Raw, got.Type)
+ }
+}
diff --git a/internal/translator/gemini/interactions/interactions_gemini_common.go b/internal/translator/gemini/interactions/interactions_gemini_common.go
index 2c6dfbec629..8523c0b90f0 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_common.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_common.go
@@ -8,9 +8,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type StreamState struct {
@@ -19,7 +19,7 @@ type StreamState struct {
Completed bool
Done bool
ActiveStepOpen bool
- PendingUsage gjson.Result
+ PendingUsage ojson.Result
HasPendingUsage bool
UsageAfterSeen bool
ID string
@@ -37,21 +37,21 @@ func ConvertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, s
// convertInteractionsRequestToGemini also reports a user turn that was left empty
// because its only attachment has no Gemini equivalent.
func convertInteractionsRequestToGemini(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","contents":[]}`)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","contents":[]}`)
if modelName != "" && root.Get("model").Exists() {
- out, _ = sjson.SetBytes(out, "model", modelName)
- }
- out = copyInteractionsSystemInstruction(out, root)
- out = copyInteractionsGenerationConfig(out, root)
- out = copyInteractionsResponseModalities(out, root)
- out = copyInteractionsTools(out, root)
- out = copyInteractionsToolChoice(out, root)
- out = copyInteractionsServiceTier(out, root)
+ _ = outDoc.Set("model", modelName)
+ }
+ copyInteractionsSystemInstructionDoc(outDoc, root)
+ copyInteractionsGenerationConfigDoc(outDoc, root)
+ copyInteractionsResponseModalitiesDoc(outDoc, root)
+ copyInteractionsToolsDoc(outDoc, root)
+ copyInteractionsToolChoiceDoc(outDoc, root)
+ copyInteractionsServiceTierDoc(outDoc, root)
contentItems := translatorcommon.NewRawArrayItems(root.Get("input.#").Int())
errInput := appendInteractionsInput(&contentItems, root.Get("input"))
- out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
- return out, errInput
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "contents", contentItems)
+ return outDoc.Bytes(), errInput
}
func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -63,18 +63,18 @@ func ConvertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s
// because its only part could not be carried over to Interactions.
func convertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
var drops translatorcommon.UserTurnDrops
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","input":[]}`)
- out, _ = sjson.SetBytes(out, "model", modelName)
- out = copyGeminiSystemInstructionToInteractions(out, root)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","input":[]}`)
+ _ = outDoc.Set("model", modelName)
+ copyGeminiSystemInstructionToInteractionsDoc(outDoc, root)
if root.Get("generationConfig").Exists() {
converted := convertCamelCaseKeysToSnakeCase([]byte(root.Get("generationConfig").Raw))
- out, _ = sjson.SetRawBytes(out, "generation_config", converted)
- out = normalizeGeminiThinkingConfigForInteractions(out)
+ _ = outDoc.SetRawBytes("generation_config", converted)
+ normalizeGeminiThinkingConfigForInteractionsDoc(outDoc, util.ParseGJSONBytesNoCopy(converted))
}
- out = copyGeminiToolsToInteractions(out, root)
+ copyGeminiToolsToInteractionsDoc(outDoc, root)
inputItems := translatorcommon.NewRawArrayItems(root.Get("contents.#").Int())
- root.Get("contents").ForEach(func(_, content gjson.Result) bool {
+ root.Get("contents").ForEach(func(_, content ojson.Result) bool {
role := content.Get("role").String()
stepType := "user_input"
if role == "model" {
@@ -82,7 +82,7 @@ func convertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s
}
// Counts what this turn sends; empty text is not sendable.
sendable := 0
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if fc := part.Get("functionCall"); fc.Exists() {
for _, step := range geminiPartToInteractionsSteps(part) {
if len(step) > 0 {
@@ -120,10 +120,10 @@ func convertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s
if part.Get("thought").Bool() && role == "model" {
currentStepType = "thought"
}
- step := []byte(`{"type":"","content":[]}`)
- step, _ = sjson.SetBytes(step, "type", currentStepType)
- step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item})
- inputItems = append(inputItems, step)
+ stepDoc := ojson.NewDocString(`{"type":"","content":[]}`)
+ _ = stepDoc.Set("type", currentStepType)
+ translatorcommon.SetRawArrayItemsDoc(stepDoc, "content", [][]byte{item})
+ inputItems = append(inputItems, stepDoc.Bytes())
sendable++
return true
})
@@ -132,32 +132,31 @@ func convertGeminiRequestToInteractions(modelName string, inputRawJSON []byte, s
}
return true
})
- out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
- out, _ = sjson.SetBytes(out, "stream", stream)
- return out, drops.Err()
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "input", inputItems)
+ _ = outDoc.Set("stream", stream)
+ return outDoc.Bytes(), drops.Err()
}
-func copyGeminiSystemInstructionToInteractions(out []byte, root gjson.Result) []byte {
+func copyGeminiSystemInstructionToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result) {
sys := root.Get("systemInstruction")
if !sys.Exists() {
sys = root.Get("system_instruction")
}
text := geminiSystemInstructionText(sys)
if text == "" {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "system_instruction", text)
- return out
+ _ = outDoc.Set("system_instruction", text)
}
-func geminiSystemInstructionText(sys gjson.Result) string {
+func geminiSystemInstructionText(sys ojson.Result) string {
if !sys.Exists() {
return ""
}
- if sys.Type == gjson.String {
+ if sys.Type == ojson.String {
return sys.String()
}
- if text := sys.Get("text"); text.Exists() && text.Type == gjson.String {
+ if text := sys.Get("text"); text.Exists() && text.Type == ojson.String {
return text.String()
}
parts := sys.Get("parts")
@@ -165,7 +164,7 @@ func geminiSystemInstructionText(sys gjson.Result) string {
return ""
}
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
text := part.Get("text").String()
if text == "" {
return true
@@ -179,58 +178,57 @@ func geminiSystemInstructionText(sys gjson.Result) string {
return builder.String()
}
-func normalizeGeminiThinkingConfigForInteractions(out []byte) []byte {
- if level := firstExistingPath(gjson.ParseBytes(out), []string{
- "generation_config.thinking_config.thinking_level",
- "generation_config.thinkingConfig.thinkingLevel",
- "generation_config.thinkingConfig.thinking_level",
+func normalizeGeminiThinkingConfigForInteractionsDoc(outDoc *ojson.Doc, genCfg ojson.Result) {
+ if level := firstExistingPath(genCfg, []string{
+ "thinking_config.thinking_level",
+ "thinkingConfig.thinkingLevel",
+ "thinkingConfig.thinking_level",
}); level.Exists() {
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(level.String())))
+ _ = outDoc.Set("generation_config.thinking_level", strings.ToLower(strings.TrimSpace(level.String())))
}
- if budget := firstExistingPath(gjson.ParseBytes(out), []string{
- "generation_config.thinking_config.thinking_budget",
- "generation_config.thinkingConfig.thinkingBudget",
- "generation_config.thinkingConfig.thinking_budget",
+ if budget := firstExistingPath(genCfg, []string{
+ "thinking_config.thinking_budget",
+ "thinkingConfig.thinkingBudget",
+ "thinkingConfig.thinking_budget",
}); budget.Exists() {
- out, _ = sjson.SetRawBytes(out, "generation_config.thinking_budget", []byte(budget.Raw))
- }
- if !gjson.GetBytes(out, "generation_config.thinking_summaries").Exists() {
- if include := firstExistingPath(gjson.ParseBytes(out), []string{
- "generation_config.thinking_config.include_thoughts",
- "generation_config.thinking_config.includeThoughts",
- "generation_config.thinkingConfig.include_thoughts",
- "generation_config.thinkingConfig.includeThoughts",
+ _ = outDoc.SetRaw("generation_config.thinking_budget", budget.Raw)
+ }
+ if !genCfg.Get("thinking_summaries").Exists() {
+ if include := firstExistingPath(genCfg, []string{
+ "thinking_config.include_thoughts",
+ "thinking_config.includeThoughts",
+ "thinkingConfig.include_thoughts",
+ "thinkingConfig.includeThoughts",
}); include.Exists() {
summary := "none"
if include.Bool() {
summary = "auto"
}
- out, _ = sjson.SetBytes(out, "generation_config.thinking_summaries", summary)
+ _ = outDoc.Set("generation_config.thinking_summaries", summary)
}
}
- return out
}
-func firstExistingPath(root gjson.Result, paths []string) gjson.Result {
+func firstExistingPath(root ojson.Result, paths []string) ojson.Result {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func copyGeminiToolsToInteractions(out []byte, root gjson.Result) []byte {
+func copyGeminiToolsToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result) {
tools := root.Get("tools")
if !tools.Exists() {
- return out
+ return
}
if !tools.IsArray() {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("tools", tools.Raw)
+ return
}
normalized := make([]map[string]any, 0)
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if uc := tool.Get("urlContext"); uc.Exists() {
entry := map[string]any{"type": "url_context"}
if uc.IsObject() && len(uc.Map()) > 0 {
@@ -279,9 +277,9 @@ func copyGeminiToolsToInteractions(out []byte, root gjson.Result) []byte {
entry["description"] = desc.String()
}
if params := tool.Get("parameters"); params.Exists() {
- entry["parameters"] = json.RawMessage(params.Raw)
+ entry["parameters"] = json.RawMessage(util.NormalizeJSONSchemaNumericConstraints(params.Raw))
} else if params := tool.Get("parametersJsonSchema"); params.Exists() {
- entry["parameters"] = json.RawMessage(params.Raw)
+ entry["parameters"] = json.RawMessage(util.NormalizeJSONSchemaNumericConstraints(params.Raw))
}
normalized = append(normalized, entry)
return true
@@ -290,7 +288,7 @@ func copyGeminiToolsToInteractions(out []byte, root gjson.Result) []byte {
if !decls.Exists() {
decls = tool.Get("function_declarations")
}
- decls.ForEach(func(_, decl gjson.Result) bool {
+ decls.ForEach(func(_, decl ojson.Result) bool {
if name := decl.Get("name"); name.Exists() {
entry := map[string]any{
"type": "function",
@@ -300,9 +298,9 @@ func copyGeminiToolsToInteractions(out []byte, root gjson.Result) []byte {
entry["description"] = desc.String()
}
if params := decl.Get("parameters"); params.Exists() {
- entry["parameters"] = json.RawMessage(params.Raw)
+ entry["parameters"] = json.RawMessage(util.NormalizeJSONSchemaNumericConstraints(params.Raw))
} else if params := decl.Get("parametersJsonSchema"); params.Exists() {
- entry["parameters"] = json.RawMessage(params.Raw)
+ entry["parameters"] = json.RawMessage(util.NormalizeJSONSchemaNumericConstraints(params.Raw))
}
normalized = append(normalized, entry)
}
@@ -311,22 +309,21 @@ func copyGeminiToolsToInteractions(out []byte, root gjson.Result) []byte {
return true
})
if len(normalized) == 0 {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("tools", tools.Raw)
+ return
}
raw, errMarshal := json.Marshal(normalized)
if errMarshal != nil {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("tools", tools.Raw)
+ return
}
- out, _ = sjson.SetRawBytes(out, "tools", raw)
- return out
+ _ = outDoc.SetRawBytes("tools", raw)
}
-func geminiPartToInteractionsContent(part gjson.Result) []byte {
+func geminiPartToInteractionsContent(part ojson.Result) []byte {
if text := part.Get("text"); text.Exists() {
item := []byte(`{"type":"text","text":""}`)
- item, _ = sjson.SetBytes(item, "text", text.String())
+ item, _ = ojson.SetBytes(item, "text", text.String())
return item
}
if inline := part.Get("inlineData"); inline.Exists() {
@@ -346,7 +343,7 @@ func geminiPartToInteractionsContent(part gjson.Result) []byte {
}
// geminiPartFileData returns the fileData object of a Gemini part in either spelling.
-func geminiPartFileData(part gjson.Result) gjson.Result {
+func geminiPartFileData(part ojson.Result) ojson.Result {
if fileData := part.Get("fileData"); fileData.Exists() {
return fileData
}
@@ -371,7 +368,7 @@ func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName st
var out [][]byte
if !st.Completed {
out = appendInteractionsStepStop(out, st)
- completedRoot := gjson.Result{}
+ completedRoot := ojson.Result{}
if st.HasPendingUsage {
completedRoot = st.PendingUsage
}
@@ -379,14 +376,14 @@ func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName st
}
return appendInteractionsDone(out, st)
}
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
var out [][]byte
if !st.Started {
out = appendInteractionsCreated(out, st, modelName)
out = appendInteractionsStatusUpdate(out, st)
st.Started = true
}
- root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool {
+ root.Get("candidates.0.content.parts").ForEach(func(_, part ojson.Result) bool {
out = appendGeminiPartToInteractionsStream(out, st, part)
return true
})
@@ -395,7 +392,7 @@ func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName st
root = restoreInteractionsGeminiUsageMetadata(root)
frameHasUsage := hasInteractionsGeminiStreamUsage(root)
if frameHasUsage {
- st.PendingUsage = root
+ st.PendingUsage = ojson.Parse(strings.Clone(root.Raw))
st.HasPendingUsage = true
if st.Finished {
st.UsageAfterSeen = true
@@ -422,18 +419,19 @@ func ConvertGeminiResponseToInteractionsStream(ctx context.Context, modelName st
// restoreInteractionsGeminiUsageMetadata renames cpaUsageMetadata back to
// usageMetadata. Executors rename non-terminal usage before translation, so
// the filtered spelling must be restored for usage detection and output.
-func restoreInteractionsGeminiUsageMetadata(root gjson.Result) gjson.Result {
+func restoreInteractionsGeminiUsageMetadata(root ojson.Result) ojson.Result {
if !root.Get("usageMetadata").Exists() && !root.Get("usage_metadata").Exists() {
if cpaUsage := root.Get("cpaUsageMetadata"); cpaUsage.Exists() {
- raw, _ := sjson.SetRawBytes([]byte(root.Raw), "usageMetadata", []byte(cpaUsage.Raw))
- raw, _ = sjson.DeleteBytes(raw, "cpaUsageMetadata")
- return gjson.ParseBytes(raw)
+ doc := ojson.NewDocString(root.Raw)
+ _ = doc.SetRaw("usageMetadata", cpaUsage.Raw)
+ _ = doc.Delete("cpaUsageMetadata")
+ return util.ParseGJSONBytesNoCopy(doc.Bytes())
}
}
return root
}
-func hasInteractionsGeminiStreamUsage(root gjson.Result) bool {
+func hasInteractionsGeminiStreamUsage(root ojson.Result) bool {
usage := root.Get("usageMetadata")
if !usage.Exists() {
usage = root.Get("usage_metadata")
@@ -464,29 +462,29 @@ func hasInteractionsGeminiStreamUsage(root gjson.Result) bool {
}
func appendInteractionsCreated(out [][]byte, st *StreamState, modelName string) [][]byte {
- created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
- created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
- created, _ = sjson.SetBytes(created, "interaction.model", modelName)
- return append(out, translatorcommon.SSEEventData("interaction.created", created))
+ createdDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
+ _ = createdDoc.Set("interaction.id", st.ID)
+ _ = createdDoc.Set("interaction.model", modelName)
+ return append(out, translatorcommon.SSEEventData("interaction.created", createdDoc.Bytes()))
}
func appendInteractionsStatusUpdate(out [][]byte, st *StreamState) [][]byte {
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
- statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
+ statusUpdate, _ = ojson.SetBytes(statusUpdate, "interaction_id", st.ID)
return append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
}
-func appendInteractionsCompleted(out [][]byte, st *StreamState, modelName string, root gjson.Result) [][]byte {
+func appendInteractionsCompleted(out [][]byte, st *StreamState, modelName string, root ojson.Result) [][]byte {
now := time.Now().UTC().Format(time.RFC3339)
- completed := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
- completed, _ = sjson.SetBytes(completed, "interaction.id", st.ID)
- completed, _ = sjson.SetBytes(completed, "interaction.created", now)
- completed, _ = sjson.SetBytes(completed, "interaction.updated", now)
- completed, _ = sjson.SetBytes(completed, "interaction.model", modelName)
+ completedDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
+ _ = completedDoc.Set("interaction.id", st.ID)
+ _ = completedDoc.Set("interaction.created", now)
+ _ = completedDoc.Set("interaction.updated", now)
+ _ = completedDoc.Set("interaction.model", modelName)
if root.Exists() {
- completed = setInteractionsStreamUsageFromGemini(completed, "interaction.usage", root)
+ setInteractionsStreamUsageFromGeminiDoc(completedDoc, "interaction.usage", root)
}
- out = append(out, translatorcommon.SSEEventData("interaction.completed", completed))
+ out = append(out, translatorcommon.SSEEventData("interaction.completed", completedDoc.Bytes()))
st.Completed = true
return out
}
@@ -503,16 +501,16 @@ func appendInteractionsDone(out [][]byte, st *StreamState) [][]byte {
func convertGeminiResponseToInteractionsNonStreamDirect(modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte) []byte {
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
- out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
+ outDoc := ojson.NewDocString(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
id := root.Get("responseId").String()
if id == "" {
id = fmt.Sprintf("interaction_%d", time.Now().UnixNano())
}
- out, _ = sjson.SetBytes(out, "id", id)
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("id", id)
+ _ = outDoc.Set("model", modelName)
var steps [][]byte
- root.Get("candidates.0.content.parts").ForEach(func(_, part gjson.Result) bool {
+ root.Get("candidates.0.content.parts").ForEach(func(_, part ojson.Result) bool {
for _, step := range geminiPartToInteractionsSteps(part) {
if len(step) > 0 {
steps = append(steps, step)
@@ -521,76 +519,74 @@ func convertGeminiResponseToInteractionsNonStreamDirect(modelName string, origin
return true
})
if len(steps) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "steps", steps)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "steps", steps)
}
- out = setInteractionsUsageFromGemini(out, "usage", root)
- return out
+ setInteractionsUsageFromGeminiDoc(outDoc, "usage", root)
+ return outDoc.Bytes()
}
-func copyInteractionsSystemInstruction(out []byte, root gjson.Result) []byte {
+func copyInteractionsSystemInstructionDoc(outDoc *ojson.Doc, root ojson.Result) {
sys := root.Get("system_instruction")
if !sys.Exists() {
- return out
+ return
}
- if sys.Type == gjson.String {
+ if sys.Type == ojson.String {
instr := []byte(`{"parts":[{"text":""}]}`)
- instr, _ = sjson.SetBytes(instr, "parts.0.text", sys.String())
- out, _ = sjson.SetRawBytes(out, "systemInstruction", instr)
- return out
+ instr, _ = ojson.SetBytes(instr, "parts.0.text", sys.String())
+ _ = outDoc.SetRawBytes("systemInstruction", instr)
+ return
}
if text := sys.Get("text"); text.Exists() && !sys.Get("parts").Exists() {
instr := []byte(`{"parts":[{"text":""}]}`)
- instr, _ = sjson.SetBytes(instr, "parts.0.text", text.String())
- out, _ = sjson.SetRawBytes(out, "systemInstruction", instr)
- return out
+ instr, _ = ojson.SetBytes(instr, "parts.0.text", text.String())
+ _ = outDoc.SetRawBytes("systemInstruction", instr)
+ return
}
- out, _ = sjson.SetRawBytes(out, "systemInstruction", []byte(sys.Raw))
- return out
+ _ = outDoc.SetRaw("systemInstruction", sys.Raw)
}
-func copyInteractionsGenerationConfig(out []byte, root gjson.Result) []byte {
+func copyInteractionsGenerationConfigDoc(outDoc *ojson.Doc, root ojson.Result) {
cfg := root.Get("generation_config")
if !cfg.Exists() {
cfg = root.Get("generationConfig")
if !cfg.Exists() {
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(cfg.Raw))
- return normalizeInteractionsGenerationConfig(out)
+ _ = outDoc.SetRaw("generationConfig", cfg.Raw)
+ normalizeInteractionsGenerationConfigDoc(outDoc, cfg)
+ return
}
converted := convertSnakeCaseKeysToCamelCase([]byte(cfg.Raw))
- out, _ = sjson.SetRawBytes(out, "generationConfig", converted)
- out = normalizeInteractionsGenerationConfig(out)
- return out
+ _ = outDoc.SetRawBytes("generationConfig", converted)
+ normalizeInteractionsGenerationConfigDoc(outDoc, util.ParseGJSONBytesNoCopy(converted))
}
-func normalizeInteractionsGenerationConfig(out []byte) []byte {
- if toolChoice := gjson.GetBytes(out, "generationConfig.toolChoice"); toolChoice.Exists() {
- out, _ = sjson.DeleteBytes(out, "generationConfig.toolChoice")
+func normalizeInteractionsGenerationConfigDoc(outDoc *ojson.Doc, genCfg ojson.Result) {
+ if toolChoice := genCfg.Get("toolChoice"); toolChoice.Exists() {
+ _ = outDoc.Delete("generationConfig.toolChoice")
}
- if thinkingLevel := gjson.GetBytes(out, "generationConfig.thinkingLevel"); thinkingLevel.Exists() {
- out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig.thinkingLevel", []byte(thinkingLevel.Raw))
- out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingLevel")
+ if thinkingLevel := genCfg.Get("thinkingLevel"); thinkingLevel.Exists() {
+ _ = outDoc.SetRaw("generationConfig.thinkingConfig.thinkingLevel", thinkingLevel.Raw)
+ _ = outDoc.Delete("generationConfig.thinkingLevel")
}
- if thinkingBudget := gjson.GetBytes(out, "generationConfig.thinkingBudget"); thinkingBudget.Exists() {
- out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig.thinkingBudget", []byte(thinkingBudget.Raw))
- out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingBudget")
+ if thinkingBudget := genCfg.Get("thinkingBudget"); thinkingBudget.Exists() {
+ _ = outDoc.SetRaw("generationConfig.thinkingConfig.thinkingBudget", thinkingBudget.Raw)
+ _ = outDoc.Delete("generationConfig.thinkingBudget")
}
- if includeThoughts := gjson.GetBytes(out, "generationConfig.includeThoughts"); includeThoughts.Exists() {
- out, _ = sjson.SetRawBytes(out, "generationConfig.thinkingConfig.includeThoughts", []byte(includeThoughts.Raw))
- out, _ = sjson.DeleteBytes(out, "generationConfig.includeThoughts")
+ if includeThoughts := genCfg.Get("includeThoughts"); includeThoughts.Exists() {
+ _ = outDoc.SetRaw("generationConfig.thinkingConfig.includeThoughts", includeThoughts.Raw)
+ _ = outDoc.Delete("generationConfig.includeThoughts")
}
- if summaries := gjson.GetBytes(out, "generationConfig.thinkingSummaries"); summaries.Exists() {
+ if summaries := genCfg.Get("thinkingSummaries"); summaries.Exists() {
if includeThoughts, ok := interactionsThinkingSummariesIncludeThoughts(summaries); ok {
- out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", includeThoughts)
+ _ = outDoc.Set("generationConfig.thinkingConfig.includeThoughts", includeThoughts)
}
- out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingSummaries")
+ _ = outDoc.Delete("generationConfig.thinkingSummaries")
}
- return out
}
-func interactionsThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, bool) {
- if summary.Type != gjson.String {
+func interactionsThinkingSummariesIncludeThoughts(summary ojson.Result) (bool, bool) {
+ if summary.Type != ojson.String {
return false, false
}
switch strings.ToLower(strings.TrimSpace(summary.String())) {
@@ -603,16 +599,16 @@ func interactionsThinkingSummariesIncludeThoughts(summary gjson.Result) (bool, b
}
}
-func copyInteractionsResponseModalities(out []byte, root gjson.Result) []byte {
+func copyInteractionsResponseModalitiesDoc(outDoc *ojson.Doc, root ojson.Result) {
mods := root.Get("response_modalities")
if !mods.Exists() {
mods = root.Get("responseModalities")
}
if !mods.Exists() || !mods.IsArray() {
- return out
+ return
}
var responseMods []string
- mods.ForEach(func(_, mod gjson.Result) bool {
+ mods.ForEach(func(_, mod ojson.Result) bool {
switch strings.ToLower(strings.TrimSpace(mod.String())) {
case "text":
responseMods = append(responseMods, "TEXT")
@@ -624,12 +620,11 @@ func copyInteractionsResponseModalities(out []byte, root gjson.Result) []byte {
return true
})
if len(responseMods) > 0 {
- out, _ = sjson.SetBytes(out, "generationConfig.responseModalities", responseMods)
+ _ = outDoc.Set("generationConfig.responseModalities", responseMods)
}
- return out
}
-func copyInteractionsToolChoice(out []byte, root gjson.Result) []byte {
+func copyInteractionsToolChoiceDoc(outDoc *ojson.Doc, root ojson.Result) {
toolChoice := root.Get("tool_choice")
if !toolChoice.Exists() {
toolChoice = root.Get("generation_config.tool_choice")
@@ -638,11 +633,11 @@ func copyInteractionsToolChoice(out []byte, root gjson.Result) []byte {
toolChoice = root.Get("generationConfig.toolChoice")
}
if !toolChoice.Exists() {
- return out
+ return
}
mode := ""
var allowedNames []string
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
mode = "NONE"
@@ -673,53 +668,50 @@ func copyInteractionsToolChoice(out []byte, root gjson.Result) []byte {
}
}
if mode == "" {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", mode)
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", mode)
if len(allowedNames) > 0 {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
+ _ = outDoc.Set("toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
}
- return out
}
-func copyInteractionsServiceTier(out []byte, root gjson.Result) []byte {
+func copyInteractionsServiceTierDoc(outDoc *ojson.Doc, root ojson.Result) {
serviceTier := root.Get("service_tier")
- if !serviceTier.Exists() || serviceTier.Type != gjson.String {
- return out
+ if !serviceTier.Exists() || serviceTier.Type != ojson.String {
+ return
}
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
- return out
+ _ = outDoc.Set("service_tier", serviceTier.String())
}
func convertSnakeCaseKeysToCamelCase(raw []byte) []byte {
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
if !root.Exists() {
return raw
}
- out := []byte(`{}`)
- out = copySnakeCaseValueToCamelCase(out, "", root)
- return out
+ outDoc := ojson.NewDocString(`{}`)
+ copySnakeCaseValueToCamelCaseDoc(outDoc, "", root)
+ return outDoc.Bytes()
}
-func copySnakeCaseValueToCamelCase(out []byte, path string, node gjson.Result) []byte {
+func copySnakeCaseValueToCamelCaseDoc(outDoc *ojson.Doc, path string, node ojson.Result) {
if node.IsObject() {
- node.ForEach(func(key, value gjson.Result) bool {
+ node.ForEach(func(key, value ojson.Result) bool {
childPath := joinJSONPath(path, toCamelCase(key.String()))
- out = copySnakeCaseValueToCamelCase(out, childPath, value)
+ copySnakeCaseValueToCamelCaseDoc(outDoc, childPath, value)
return true
})
- return out
+ return
}
if node.IsArray() {
- node.ForEach(func(_, value gjson.Result) bool {
+ node.ForEach(func(_, value ojson.Result) bool {
childPath := path + ".-1"
- out = copySnakeCaseValueToCamelCase(out, childPath, value)
+ copySnakeCaseValueToCamelCaseDoc(outDoc, childPath, value)
return true
})
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw))
- return out
+ _ = outDoc.SetRaw(path, node.Raw)
}
func joinJSONPath(path, key string) string {
@@ -745,34 +737,33 @@ func toCamelCase(s string) string {
}
func convertCamelCaseKeysToSnakeCase(raw []byte) []byte {
- root := gjson.ParseBytes(raw)
+ root := util.ParseGJSONBytesNoCopy(raw)
if !root.Exists() {
return raw
}
- out := []byte(`{}`)
- out = copyCamelCaseValueToSnakeCase(out, "", root)
- return out
+ outDoc := ojson.NewDocString(`{}`)
+ copyCamelCaseValueToSnakeCaseDoc(outDoc, "", root)
+ return outDoc.Bytes()
}
-func copyCamelCaseValueToSnakeCase(out []byte, path string, node gjson.Result) []byte {
+func copyCamelCaseValueToSnakeCaseDoc(outDoc *ojson.Doc, path string, node ojson.Result) {
if node.IsObject() {
- node.ForEach(func(key, value gjson.Result) bool {
+ node.ForEach(func(key, value ojson.Result) bool {
childPath := joinJSONPath(path, toSnakeCase(key.String()))
- out = copyCamelCaseValueToSnakeCase(out, childPath, value)
+ copyCamelCaseValueToSnakeCaseDoc(outDoc, childPath, value)
return true
})
- return out
+ return
}
if node.IsArray() {
- node.ForEach(func(_, value gjson.Result) bool {
+ node.ForEach(func(_, value ojson.Result) bool {
childPath := path + ".-1"
- out = copyCamelCaseValueToSnakeCase(out, childPath, value)
+ copyCamelCaseValueToSnakeCaseDoc(outDoc, childPath, value)
return true
})
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, path, []byte(node.Raw))
- return out
+ _ = outDoc.SetRaw(path, node.Raw)
}
func toSnakeCase(s string) string {
@@ -786,19 +777,19 @@ func toSnakeCase(s string) string {
return strings.ToLower(out.String())
}
-func copyInteractionsTools(out []byte, root gjson.Result) []byte {
+func copyInteractionsToolsDoc(outDoc *ojson.Doc, root ojson.Result) {
tools := root.Get("tools")
if !tools.Exists() {
- return out
+ return
}
if !tools.IsArray() {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("tools", tools.Raw)
+ return
}
normalized := make([]map[string]any, 0)
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if tool.Get("functionDeclarations").Exists() {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
+ _ = outDoc.SetRaw("tools", tools.Raw)
normalized = nil
return false
}
@@ -874,19 +865,18 @@ func copyInteractionsTools(out []byte, root gjson.Result) []byte {
return true
})
if normalized == nil {
- return out
+ return
}
if len(normalized) == 0 {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("tools", tools.Raw)
+ return
}
raw, errMarshal := json.Marshal(normalized)
if errMarshal != nil {
- out, _ = sjson.SetRawBytes(out, "tools", []byte(tools.Raw))
- return out
+ _ = outDoc.SetRaw("tools", tools.Raw)
+ return
}
- out, _ = sjson.SetRawBytes(out, "tools", raw)
- return out
+ _ = outDoc.SetRawBytes("tools", raw)
}
type geminiInteractionsInputContext struct {
@@ -921,21 +911,21 @@ func (ctx *geminiInteractionsInputContext) appendText(role, text string) {
}
}
-func appendInteractionsInput(items *[][]byte, input gjson.Result) error {
+func appendInteractionsInput(items *[][]byte, input ojson.Result) error {
if !input.Exists() {
return nil
}
ctx := &geminiInteractionsInputContext{
items: *items,
}
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
appendGeminiTextContent(&ctx.items, "user", input.String())
ctx.lastStepType = "text"
*items = ctx.items
return nil
}
if input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
appendInteractionsStepToGemini(ctx, item, "user")
return true
})
@@ -945,7 +935,7 @@ func appendInteractionsInput(items *[][]byte, input gjson.Result) error {
defaultRole = "model"
}
ctx.instruction = translatorcommon.IsInteractionsInstructionStep(input, false)
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
appendInteractionsStepToGemini(ctx, step, defaultRole)
return true
})
@@ -959,11 +949,11 @@ func appendInteractionsInput(items *[][]byte, input gjson.Result) error {
return ctx.run.Err()
}
-func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gjson.Result, defaultRole string) {
+func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item ojson.Result, defaultRole string) {
inheritedInstruction := ctx.instruction
ctx.instruction = translatorcommon.IsInteractionsInstructionStep(item, inheritedInstruction)
defer func() { ctx.instruction = inheritedInstruction }()
- if item.Type == gjson.String {
+ if item.Type == ojson.String {
if ctx.inModelTurn {
flushPendingGeminiSignature(ctx)
ctx.inModelTurn = false
@@ -979,7 +969,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
} else if itemRole == "user" {
role = "user"
}
- steps.ForEach(func(_, child gjson.Result) bool {
+ steps.ForEach(func(_, child ojson.Result) bool {
appendInteractionsStepToGemini(ctx, child, role)
return true
})
@@ -991,9 +981,9 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
ctx.run.End()
if ctx.pendingSignature != "" {
carrier := geminiTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
ctx.pendingSignature = ""
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", [][]byte{carrier}))
@@ -1001,7 +991,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
}
partItems := extractInteractionsStepContentPartsToGemini(item, false)
if len(partItems) > 0 {
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentParts(ctx.items[len(ctx.items)-1], partItems)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", partItems))
@@ -1014,8 +1004,8 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
if sig := firstNonEmptyString(item.Get("signature").String(), item.Get("thought_signature").String(), item.Get("thoughtSignature").String()); sig != "" {
if ctx.pendingSignature != "" && ctx.pendingSignature != sig {
carrier := geminiTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", [][]byte{carrier}))
@@ -1025,7 +1015,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
}
partItems := extractInteractionsThoughtPartsToGemini(item)
if len(partItems) > 0 {
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentParts(ctx.items[len(ctx.items)-1], partItems)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", partItems))
@@ -1035,7 +1025,6 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
ctx.lastStepType = "thought"
case "function_call":
ctx.run.End()
- part := buildGeminiFunctionCallPart(item)
sig := firstNonEmptyString(item.Get("signature").String(), item.Get("thought_signature").String(), item.Get("thoughtSignature").String())
if sig == "" && ctx.pendingSignature != "" {
sig = ctx.pendingSignature
@@ -1045,19 +1034,17 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
ctx.pendingSignature = ""
} else {
carrier := geminiTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
ctx.pendingSignature = ""
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", [][]byte{carrier}))
}
}
}
- if sig != "" {
- part, _ = sjson.SetBytes(part, "thoughtSignature", sig)
- }
- if ctx.inModelTurn && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ part := buildGeminiFunctionCallPartWithSig(item, sig)
+ if ctx.inModelTurn && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], part)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", [][]byte{part}))
@@ -1072,7 +1059,7 @@ func appendInteractionsStepToGemini(ctx *geminiInteractionsInputContext, item gj
part := buildGeminiFunctionResultPart(item)
// A tool result is content the model reads, so it keeps the surrounding user turn.
ctx.run.Add()
- if ctx.lastStepType == "function_result" && len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "user" {
+ if ctx.lastStepType == "function_result" && len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "user" {
ctx.items[len(ctx.items)-1] = appendGeminiUserContentPart(ctx.items[len(ctx.items)-1], part)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("user", [][]byte{part}))
@@ -1110,9 +1097,9 @@ func flushPendingGeminiSignature(ctx *geminiInteractionsInputContext) {
return
}
carrier := geminiTextPartJSON("", false)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", ctx.pendingSignature)
ctx.pendingSignature = ""
- if len(ctx.items) > 0 && gjson.GetBytes(ctx.items[len(ctx.items)-1], "role").String() == "model" {
+ if len(ctx.items) > 0 && ojson.GetBytesNoCopy(ctx.items[len(ctx.items)-1], "role").String() == "model" {
ctx.items[len(ctx.items)-1] = appendGeminiContentPart(ctx.items[len(ctx.items)-1], carrier)
} else {
ctx.items = append(ctx.items, interactionsGeminiContent("model", [][]byte{carrier}))
@@ -1120,13 +1107,13 @@ func flushPendingGeminiSignature(ctx *geminiInteractionsInputContext) {
}
func appendGeminiContentPart(content []byte, part []byte) []byte {
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
rawParts := make([][]byte, 0, len(parts)+1)
for _, p := range parts {
rawParts = append(rawParts, []byte(p.Raw))
}
rawParts = append(rawParts, part)
- updated, err := sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
+ updated, err := ojson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
if err != nil {
return content
}
@@ -1137,13 +1124,13 @@ func appendGeminiContentParts(content []byte, newParts [][]byte) []byte {
if len(newParts) == 0 {
return content
}
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
rawParts := make([][]byte, 0, len(parts)+len(newParts))
for _, p := range parts {
rawParts = append(rawParts, []byte(p.Raw))
}
rawParts = append(rawParts, newParts...)
- updated, err := sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
+ updated, err := ojson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
if err != nil {
return content
}
@@ -1151,14 +1138,14 @@ func appendGeminiContentParts(content []byte, newParts [][]byte) []byte {
}
func appendGeminiUserContentPart(content []byte, part []byte) []byte {
- parts := gjson.GetBytes(content, "parts").Array()
+ parts := ojson.GetBytesNoCopy(content, "parts").Array()
rawParts := make([][]byte, 0, len(parts)+1)
for _, p := range parts {
rawParts = append(rawParts, []byte(p.Raw))
}
rawParts = append(rawParts, part)
rawParts = translatorcommon.ReorderGeminiUserParts(rawParts)
- updated, err := sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
+ updated, err := ojson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(rawParts))
if err != nil {
return content
}
@@ -1174,7 +1161,7 @@ func firstNonEmptyString(values ...string) string {
return ""
}
-func extractInteractionsThoughtPartsToGemini(step gjson.Result) [][]byte {
+func extractInteractionsThoughtPartsToGemini(step ojson.Result) [][]byte {
content := step.Get("content")
if !content.Exists() {
content = step.Get("summary")
@@ -1187,7 +1174,7 @@ func extractInteractionsThoughtPartsToGemini(step gjson.Result) [][]byte {
}
var partItems [][]byte
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if partJSON := interactionsContentPartToGeminiPart(part, true); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
@@ -1197,13 +1184,13 @@ func extractInteractionsThoughtPartsToGemini(step gjson.Result) [][]byte {
if partJSON := interactionsContentPartToGeminiPart(content, true); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
partItems = append(partItems, geminiTextPartJSON(content.String(), true))
}
return partItems
}
-func extractInteractionsStepContentPartsToGemini(step gjson.Result, thought bool) [][]byte {
+func extractInteractionsStepContentPartsToGemini(step ojson.Result, thought bool) [][]byte {
content := step.Get("content")
if !content.Exists() {
content = step.Get("text")
@@ -1213,7 +1200,7 @@ func extractInteractionsStepContentPartsToGemini(step gjson.Result, thought bool
}
var partItems [][]byte
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if partJSON := interactionsContentPartToGeminiPart(part, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
@@ -1223,41 +1210,48 @@ func extractInteractionsStepContentPartsToGemini(step gjson.Result, thought bool
if partJSON := interactionsContentPartToGeminiPart(content, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
partItems = append(partItems, geminiTextPartJSON(content.String(), thought))
}
return partItems
}
-func buildGeminiFunctionCallPart(item gjson.Result) []byte {
- part := []byte(`{"functionCall":{"name":"","args":{}}}`)
- part, _ = sjson.SetBytes(part, "functionCall.name", item.Get("name").String())
+func buildGeminiFunctionCallPart(item ojson.Result) []byte {
+ return buildGeminiFunctionCallPartWithSig(item, "")
+}
+
+func buildGeminiFunctionCallPartWithSig(item ojson.Result, sig string) []byte {
+ partDoc := ojson.NewDocString(`{"functionCall":{"name":"","args":{}}}`)
+ _ = partDoc.Set("functionCall.name", item.Get("name").String())
if callID := item.Get("call_id"); callID.Exists() {
- part, _ = sjson.SetBytes(part, "functionCall.id", callID.String())
+ _ = partDoc.Set("functionCall.id", callID.String())
} else if id := item.Get("id"); id.Exists() {
- part, _ = sjson.SetBytes(part, "functionCall.id", id.String())
+ _ = partDoc.Set("functionCall.id", id.String())
}
if args := item.Get("arguments"); args.Exists() {
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(args.Raw))
+ _ = partDoc.SetRaw("functionCall.args", args.Raw)
+ }
+ if sig != "" {
+ _ = partDoc.Set("thoughtSignature", sig)
}
- return part
+ return partDoc.Bytes()
}
-func buildGeminiFunctionResultPart(item gjson.Result) []byte {
- part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
- part, _ = sjson.SetBytes(part, "functionResponse.name", item.Get("name").String())
+func buildGeminiFunctionResultPart(item ojson.Result) []byte {
+ partDoc := ojson.NewDocString(`{"functionResponse":{"name":"","response":{}}}`)
+ _ = partDoc.Set("functionResponse.name", item.Get("name").String())
if callID := item.Get("call_id"); callID.Exists() {
- part, _ = sjson.SetBytes(part, "functionResponse.id", callID.String())
+ _ = partDoc.Set("functionResponse.id", callID.String())
} else if id := item.Get("id"); id.Exists() {
- part, _ = sjson.SetBytes(part, "functionResponse.id", id.String())
+ _ = partDoc.Set("functionResponse.id", id.String())
}
if result := item.Get("result"); result.Exists() {
- part = translatorcommon.SetGeminiFunctionResponseResult(part, "functionResponse.response", result)
+ translatorcommon.SetGeminiFunctionResponseResultDoc(partDoc, "functionResponse.response", result)
}
- return part
+ return partDoc.Bytes()
}
-func appendInteractionsNativeContent(ctx *geminiInteractionsInputContext, item gjson.Result, defaultRole string) {
+func appendInteractionsNativeContent(ctx *geminiInteractionsInputContext, item ojson.Result, defaultRole string) {
parts := item.Get("parts")
if !parts.Exists() || !parts.IsArray() {
return
@@ -1265,7 +1259,7 @@ func appendInteractionsNativeContent(ctx *geminiInteractionsInputContext, item g
role := interactionsGeminiContentRole(item.Get("role").String(), defaultRole)
run := ctx.userRun(role)
partItems := make([][]byte, 0, 4)
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
partJSON := interactionsNativeGeminiPart(part)
if len(partJSON) == 0 {
if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" {
@@ -1298,7 +1292,7 @@ func interactionsGeminiContentRole(role, defaultRole string) string {
return "user"
}
-func interactionsNativeGeminiPart(part gjson.Result) []byte {
+func interactionsNativeGeminiPart(part ojson.Result) []byte {
switch {
case part.Get("text").Exists(), part.Get("functionCall").Exists(), part.Get("functionResponse").Exists():
return []byte(part.Raw)
@@ -1316,7 +1310,7 @@ func interactionsNativeGeminiPart(part gjson.Result) []byte {
// appendInteractionsContentPart adds one content part. run is nil for model
// content; for user content it records what is sent and what cannot be.
-func appendInteractionsContentPart(items *[][]byte, role string, part gjson.Result, run *translatorcommon.UserRun) {
+func appendInteractionsContentPart(items *[][]byte, role string, part ojson.Result, run *translatorcommon.UserRun) {
partJSON := interactionsContentPartToGeminiPart(part, false)
if len(partJSON) == 0 {
if droppedType := translatorcommon.InteractionsAttachmentType(part); droppedType != "" {
@@ -1330,7 +1324,7 @@ func appendInteractionsContentPart(items *[][]byte, role string, part gjson.Resu
}
}
-func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte {
+func interactionsContentPartToGeminiPart(part ojson.Result, thought bool) []byte {
if text := part.Get("text"); text.Exists() {
return geminiTextPartJSON(text.String(), thought)
}
@@ -1354,7 +1348,7 @@ func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte
}
data := part.Get("data").String()
if data != "" {
- return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return geminiInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
// uri is the Interactions spelling of file_uri.
@@ -1363,7 +1357,7 @@ func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte
if mimeType == "" {
mimeType = part.Get("mimeType").String()
}
- return geminiFileDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI)))
+ return geminiFileDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mimeType":%q,"fileUri":%q}`, mimeType, fileURI)))
}
if url := part.Get("url"); url.Exists() {
return geminiInlineDataPartFromDataURL(url.String())
@@ -1372,27 +1366,30 @@ func interactionsContentPartToGeminiPart(part gjson.Result, thought bool) []byte
return geminiInlineDataPartFromDataURL(part.Get("image_url.url").String())
case "input_audio":
mimeType := interactionsInputAudioMimeType(part.Get("input_audio.format").String())
- return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, part.Get("input_audio.data").String())))
+ return geminiInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, part.Get("input_audio.data").String())))
case "file":
filename := part.Get("file.filename").String()
fileData := part.Get("file.file_data").String()
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
- return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return geminiInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
}
return nil
}
func geminiTextPartJSON(text string, thought bool) []byte {
- partJSON := []byte(`{"text":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", text)
- if thought {
- partJSON, _ = sjson.SetBytes(partJSON, "thought", true)
+ if !thought {
+ partJSON := []byte(`{"text":""}`)
+ partJSON, _ = ojson.SetBytes(partJSON, "text", text)
+ return partJSON
}
- return partJSON
+ partDoc := ojson.NewDocString(`{"text":""}`)
+ _ = partDoc.Set("text", text)
+ _ = partDoc.Set("thought", true)
+ return partDoc.Bytes()
}
-func geminiInlineDataPartJSON(inline gjson.Result) []byte {
+func geminiInlineDataPartJSON(inline ojson.Result) []byte {
mimeType := inline.Get("mimeType").String()
if mimeType == "" {
mimeType = inline.Get("mime_type").String()
@@ -1401,13 +1398,13 @@ func geminiInlineDataPartJSON(inline gjson.Result) []byte {
if mimeType == "" || data == "" {
return nil
}
- partJSON := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
- partJSON, _ = sjson.SetBytes(partJSON, "inlineData.mimeType", mimeType)
- partJSON, _ = sjson.SetBytes(partJSON, "inlineData.data", data)
- return partJSON
+ partDoc := ojson.NewDocString(`{"inlineData":{"mimeType":"","data":""}}`)
+ _ = partDoc.Set("inlineData.mimeType", mimeType)
+ _ = partDoc.Set("inlineData.data", data)
+ return partDoc.Bytes()
}
-func geminiFileDataPartJSON(fileData gjson.Result) []byte {
+func geminiFileDataPartJSON(fileData ojson.Result) []byte {
mimeType := fileData.Get("mimeType").String()
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
@@ -1419,10 +1416,10 @@ func geminiFileDataPartJSON(fileData gjson.Result) []byte {
if mimeType == "" || fileURI == "" {
return nil
}
- partJSON := []byte(`{"fileData":{"mimeType":"","fileUri":""}}`)
- partJSON, _ = sjson.SetBytes(partJSON, "fileData.mimeType", mimeType)
- partJSON, _ = sjson.SetBytes(partJSON, "fileData.fileUri", fileURI)
- return partJSON
+ partDoc := ojson.NewDocString(`{"fileData":{"mimeType":"","fileUri":""}}`)
+ _ = partDoc.Set("fileData.mimeType", mimeType)
+ _ = partDoc.Set("fileData.fileUri", fileURI)
+ return partDoc.Bytes()
}
func geminiInlineDataPartFromDataURL(dataURL string) []byte {
@@ -1436,7 +1433,7 @@ func geminiInlineDataPartFromDataURL(dataURL string) []byte {
}
mimeType := pieces[0]
data := pieces[1][7:]
- return geminiInlineDataPartJSON(gjson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
+ return geminiInlineDataPartJSON(ojson.Parse(fmt.Sprintf(`{"mime_type":%q,"data":%q}`, mimeType, data)))
}
func interactionsInputAudioMimeType(format string) string {
@@ -1457,17 +1454,17 @@ func interactionsInputAudioMimeType(format string) string {
}
func geminiInlineDataToInteractionsContent(mimeType, data string) []byte {
- item := []byte(`{"type":"","mime_type":"","data":""}`)
- item, _ = sjson.SetBytes(item, "type", geminiInteractionsMediaType(mimeType))
- item, _ = sjson.SetBytes(item, "mime_type", mimeType)
- item, _ = sjson.SetBytes(item, "data", data)
- return item
+ itemDoc := ojson.NewDocString(`{"type":"","mime_type":"","data":""}`)
+ _ = itemDoc.Set("type", geminiInteractionsMediaType(mimeType))
+ _ = itemDoc.Set("mime_type", mimeType)
+ _ = itemDoc.Set("data", data)
+ return itemDoc.Bytes()
}
// geminiFileDataToInteractionsContent maps a Gemini fileData part onto an
// Interactions media part that references the file by uri. It returns nil when
// the part names no uri, which leaves nothing to send.
-func geminiFileDataToInteractionsContent(fileData gjson.Result) []byte {
+func geminiFileDataToInteractionsContent(fileData ojson.Result) []byte {
fileURI := strings.TrimSpace(fileData.Get("fileUri").String())
if fileURI == "" {
fileURI = strings.TrimSpace(fileData.Get("file_uri").String())
@@ -1479,13 +1476,13 @@ func geminiFileDataToInteractionsContent(fileData gjson.Result) []byte {
if mimeType == "" {
mimeType = fileData.Get("mime_type").String()
}
- item := []byte(`{"type":"","uri":""}`)
- item, _ = sjson.SetBytes(item, "type", geminiInteractionsMediaType(mimeType))
- item, _ = sjson.SetBytes(item, "uri", fileURI)
+ itemDoc := ojson.NewDocString(`{"type":"","uri":""}`)
+ _ = itemDoc.Set("type", geminiInteractionsMediaType(mimeType))
+ _ = itemDoc.Set("uri", fileURI)
if mimeType != "" {
- item, _ = sjson.SetBytes(item, "mime_type", mimeType)
+ _ = itemDoc.Set("mime_type", mimeType)
}
- return item
+ return itemDoc.Bytes()
}
// geminiInteractionsMediaType picks the Interactions content type for a MIME type.
@@ -1502,13 +1499,13 @@ func geminiInteractionsMediaType(mimeType string) string {
return "document"
}
-func appendInteractionsContentList(ctx *geminiInteractionsInputContext, role string, content gjson.Result) {
+func appendInteractionsContentList(ctx *geminiInteractionsInputContext, role string, content ojson.Result) {
if !content.Exists() {
return
}
run := ctx.userRun(role)
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
appendInteractionsContentPart(&ctx.items, role, part, run)
return true
})
@@ -1516,19 +1513,19 @@ func appendInteractionsContentList(ctx *geminiInteractionsInputContext, role str
}
if content.IsObject() {
appendInteractionsContentPart(&ctx.items, role, content, run)
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
ctx.appendText(role, content.String())
}
}
-func appendInteractionsStepContent(items *[][]byte, role string, item gjson.Result, thought bool) {
+func appendInteractionsStepContent(items *[][]byte, role string, item ojson.Result, thought bool) {
content := item.Get("content")
if !content.Exists() {
return
}
partItems := make([][]byte, 0, 4)
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if partJSON := interactionsContentPartToGeminiPart(part, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
@@ -1538,7 +1535,7 @@ func appendInteractionsStepContent(items *[][]byte, role string, item gjson.Resu
if partJSON := interactionsContentPartToGeminiPart(content, thought); len(partJSON) > 0 {
partItems = append(partItems, partJSON)
}
- } else if content.Type == gjson.String {
+ } else if content.Type == ojson.String {
partItems = append(partItems, geminiTextPartJSON(content.String(), thought))
}
if len(partItems) > 0 {
@@ -1546,12 +1543,12 @@ func appendInteractionsStepContent(items *[][]byte, role string, item gjson.Resu
}
}
-func appendInteractionsFunctionCall(items *[][]byte, item gjson.Result) {
+func appendInteractionsFunctionCall(items *[][]byte, item ojson.Result) {
part := buildGeminiFunctionCallPart(item)
*items = append(*items, interactionsGeminiContent("model", [][]byte{part}))
}
-func appendInteractionsFunctionResult(items *[][]byte, item gjson.Result) {
+func appendInteractionsFunctionResult(items *[][]byte, item ojson.Result) {
part := buildGeminiFunctionResultPart(item)
*items = append(*items, interactionsGeminiContent("user", [][]byte{part}))
}
@@ -1561,22 +1558,22 @@ func appendGeminiTextContent(items *[][]byte, role, text string) {
}
func interactionsGeminiContent(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDocString(`{"role":"","parts":[]}`)
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
-func firstInteractionsGeminiUsage(usage gjson.Result, paths ...string) gjson.Result {
+func firstInteractionsGeminiUsage(usage ojson.Result, paths ...string) ojson.Result {
for _, path := range paths {
if value := usage.Get(path); value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func setInteractionsUsageFromGemini(out []byte, path string, root gjson.Result) []byte {
+func setInteractionsUsageFromGeminiDoc(outDoc *ojson.Doc, path string, root ojson.Result) {
usage := root.Get("usageMetadata")
if !usage.Exists() {
usage = root.Get("usage_metadata")
@@ -1585,23 +1582,22 @@ func setInteractionsUsageFromGemini(out []byte, path string, root gjson.Result)
usage = root.Get("cpaUsageMetadata")
}
if !usage.Exists() {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, path+".input_tokens", firstInteractionsGeminiUsage(usage, "promptTokenCount", "prompt_token_count").Int())
- out, _ = sjson.SetBytes(out, path+".output_tokens", firstInteractionsGeminiUsage(usage, "candidatesTokenCount", "candidates_token_count").Int())
+ _ = outDoc.Set(path+".input_tokens", firstInteractionsGeminiUsage(usage, "promptTokenCount", "prompt_token_count").Int())
+ _ = outDoc.Set(path+".output_tokens", firstInteractionsGeminiUsage(usage, "candidatesTokenCount", "candidates_token_count").Int())
if reasoning := firstInteractionsGeminiUsage(usage, "thoughtsTokenCount", "thoughts_token_count"); reasoning.Exists() {
- out, _ = sjson.SetBytes(out, path+".reasoning_tokens", reasoning.Int())
+ _ = outDoc.Set(path+".reasoning_tokens", reasoning.Int())
}
- out, _ = sjson.SetBytes(out, path+".total_tokens", firstInteractionsGeminiUsage(usage, "totalTokenCount", "total_token_count").Int())
+ _ = outDoc.Set(path+".total_tokens", firstInteractionsGeminiUsage(usage, "totalTokenCount", "total_token_count").Int())
if cached := usage.Get("cachedContentTokenCount"); cached.Exists() {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", cached.Int())
+ _ = outDoc.Set(path+".cached_tokens", cached.Int())
} else if cached := usage.Get("cached_content_token_count"); cached.Exists() {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", cached.Int())
+ _ = outDoc.Set(path+".cached_tokens", cached.Int())
}
- return out
}
-func setInteractionsStreamUsageFromGemini(out []byte, path string, root gjson.Result) []byte {
+func setInteractionsStreamUsageFromGeminiDoc(outDoc *ojson.Doc, path string, root ojson.Result) {
usage := root.Get("usageMetadata")
if !usage.Exists() {
usage = root.Get("usage_metadata")
@@ -1610,7 +1606,7 @@ func setInteractionsStreamUsageFromGemini(out []byte, path string, root gjson.Re
usage = root.Get("cpaUsageMetadata")
}
if !usage.Exists() {
- return out
+ return
}
inputTokens := firstInteractionsGeminiUsage(usage, "promptTokenCount", "prompt_token_count").Int()
outputTokens := firstInteractionsGeminiUsage(usage, "candidatesTokenCount", "candidates_token_count").Int()
@@ -1620,35 +1616,34 @@ func setInteractionsStreamUsageFromGemini(out []byte, path string, root gjson.Re
if cachedTokens == 0 {
cachedTokens = usage.Get("cached_content_token_count").Int()
}
- out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
- out, _ = sjson.SetBytes(out, path+".total_input_tokens", inputTokens)
- out, _ = sjson.SetRawBytes(out, path+".input_tokens_by_modality", []byte(fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens)))
- out, _ = sjson.SetBytes(out, path+".total_cached_tokens", cachedTokens)
- out, _ = sjson.SetBytes(out, path+".total_output_tokens", outputTokens)
- out, _ = sjson.SetBytes(out, path+".total_tool_use_tokens", 0)
- out, _ = sjson.SetBytes(out, path+".total_thought_tokens", thoughtTokens)
- return out
+ _ = outDoc.Set(path+".total_tokens", totalTokens)
+ _ = outDoc.Set(path+".total_input_tokens", inputTokens)
+ _ = outDoc.SetRaw(path+".input_tokens_by_modality", fmt.Sprintf(`[{"modality":"text","tokens":%d}]`, inputTokens))
+ _ = outDoc.Set(path+".total_cached_tokens", cachedTokens)
+ _ = outDoc.Set(path+".total_output_tokens", outputTokens)
+ _ = outDoc.Set(path+".total_tool_use_tokens", 0)
+ _ = outDoc.Set(path+".total_thought_tokens", thoughtTokens)
}
-func appendInteractionsStepStart(out [][]byte, st *StreamState, stepType string, part gjson.Result) [][]byte {
+func appendInteractionsStepStart(out [][]byte, st *StreamState, stepType string, part ojson.Result) [][]byte {
st.StepID = fmt.Sprintf("step_%d", time.Now().UnixNano())
st.ActiveStepIndex = st.StepIndex
st.StepIndex++
st.ActiveStepType = stepType
st.ActiveStepOpen = true
- stepStart := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
- stepStart, _ = sjson.SetBytes(stepStart, "index", st.ActiveStepIndex)
- stepStart, _ = sjson.SetBytes(stepStart, "step.type", stepType)
+ stepStartDoc := ojson.NewDocString(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
+ _ = stepStartDoc.Set("index", st.ActiveStepIndex)
+ _ = stepStartDoc.Set("step.type", stepType)
if stepType == "function_call" {
id := interactionsFunctionPartID(part)
if id == "" {
id = st.StepID
}
- stepStart, _ = sjson.SetBytes(stepStart, "step.id", id)
- stepStart, _ = sjson.SetBytes(stepStart, "step.name", part.Get("name").String())
- stepStart, _ = sjson.SetRawBytes(stepStart, "step.arguments", []byte(`{}`))
+ _ = stepStartDoc.Set("step.id", id)
+ _ = stepStartDoc.Set("step.name", part.Get("name").String())
+ _ = stepStartDoc.SetRaw("step.arguments", `{}`)
}
- return append(out, translatorcommon.SSEEventData("step.start", stepStart))
+ return append(out, translatorcommon.SSEEventData("step.start", stepStartDoc.Bytes()))
}
func appendInteractionsStepStop(out [][]byte, st *StreamState) [][]byte {
@@ -1656,14 +1651,14 @@ func appendInteractionsStepStop(out [][]byte, st *StreamState) [][]byte {
return out
}
stepStop := []byte(`{"index":0,"event_type":"step.stop"}`)
- stepStop, _ = sjson.SetBytes(stepStop, "index", st.ActiveStepIndex)
+ stepStop, _ = ojson.SetBytes(stepStop, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", stepStop))
st.ActiveStepOpen = false
st.ActiveStepType = ""
return out
}
-func ensureInteractionsStep(out [][]byte, st *StreamState, stepType string, part gjson.Result) [][]byte {
+func ensureInteractionsStep(out [][]byte, st *StreamState, stepType string, part ojson.Result) [][]byte {
if st.ActiveStepOpen && st.ActiveStepType == stepType {
return out
}
@@ -1671,45 +1666,45 @@ func ensureInteractionsStep(out [][]byte, st *StreamState, stepType string, part
return appendInteractionsStepStart(out, st, stepType, part)
}
-func appendGeminiPartToInteractionsStream(out [][]byte, st *StreamState, part gjson.Result) [][]byte {
+func appendGeminiPartToInteractionsStream(out [][]byte, st *StreamState, part ojson.Result) [][]byte {
if text := part.Get("text"); text.Exists() && text.String() != "" {
if part.Get("thought").Bool() {
- out = ensureInteractionsStep(out, st, "thought", gjson.Result{})
- delta := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.content.text", text.String())
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ out = ensureInteractionsStep(out, st, "thought", ojson.Result{})
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.content.text", text.String())
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendInteractionsThoughtSignature(out, st, part)
}
- out = ensureInteractionsStep(out, st, "model_output", gjson.Result{})
- delta := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.text", text.String())
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ out = ensureInteractionsStep(out, st, "model_output", ojson.Result{})
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.text", text.String())
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendInteractionsThoughtSignature(out, st, part)
}
if fc := part.Get("functionCall"); fc.Exists() {
out = appendInteractionsThoughtSignature(out, st, part)
out = ensureInteractionsStep(out, st, "function_call", fc)
- delta := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
arguments := `{}`
if args := fc.Get("args"); args.Exists() {
arguments = args.Raw
}
- delta, _ = sjson.SetBytes(delta, "delta.arguments", arguments)
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ _ = deltaDoc.Set("delta.arguments", arguments)
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendInteractionsStepStop(out, st)
}
if fr := part.Get("functionResponse"); fr.Exists() {
out = ensureInteractionsStep(out, st, "function_result", fr)
- delta := []byte(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`)
- delta, _ = sjson.SetBytes(delta, "index", st.ActiveStepIndex)
- delta, _ = sjson.SetBytes(delta, "delta.name", fr.Get("name").String())
+ deltaDoc := ojson.NewDocString(`{"index":0,"delta":{"type":"function_result","name":"","result":{}},"event_type":"step.delta"}`)
+ _ = deltaDoc.Set("index", st.ActiveStepIndex)
+ _ = deltaDoc.Set("delta.name", fr.Get("name").String())
if response := fr.Get("response"); response.Exists() {
- delta, _ = sjson.SetRawBytes(delta, "delta.result", []byte(response.Raw))
+ _ = deltaDoc.SetRaw("delta.result", response.Raw)
}
- out = append(out, translatorcommon.SSEEventData("step.delta", delta))
+ out = append(out, translatorcommon.SSEEventData("step.delta", deltaDoc.Bytes()))
return appendInteractionsStepStop(out, st)
}
if sig := interactionsThoughtSignature(part); sig != "" {
@@ -1718,18 +1713,18 @@ func appendGeminiPartToInteractionsStream(out [][]byte, st *StreamState, part gj
return out
}
-func appendInteractionsThoughtSignature(out [][]byte, st *StreamState, part gjson.Result) [][]byte {
+func appendInteractionsThoughtSignature(out [][]byte, st *StreamState, part ojson.Result) [][]byte {
if signature := interactionsThoughtSignature(part); signature != "" {
- out = ensureInteractionsStep(out, st, "thought", gjson.Result{})
- signatureDelta := []byte(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "index", st.ActiveStepIndex)
- signatureDelta, _ = sjson.SetBytes(signatureDelta, "delta.signature", signature)
- return append(out, translatorcommon.SSEEventData("step.delta", signatureDelta))
+ out = ensureInteractionsStep(out, st, "thought", ojson.Result{})
+ signatureDeltaDoc := ojson.NewDocString(`{"index":0,"delta":{"signature":"","type":"thought_signature"},"event_type":"step.delta"}`)
+ _ = signatureDeltaDoc.Set("index", st.ActiveStepIndex)
+ _ = signatureDeltaDoc.Set("delta.signature", signature)
+ return append(out, translatorcommon.SSEEventData("step.delta", signatureDeltaDoc.Bytes()))
}
return out
}
-func interactionsFunctionPartID(part gjson.Result) string {
+func interactionsFunctionPartID(part ojson.Result) string {
if id := part.Get("id"); id.Exists() {
return id.String()
}
@@ -1739,7 +1734,7 @@ func interactionsFunctionPartID(part gjson.Result) string {
return ""
}
-func interactionsThoughtSignature(part gjson.Result) string {
+func interactionsThoughtSignature(part ojson.Result) string {
for _, path := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} {
if signature := strings.TrimSpace(part.Get(path).String()); signature != "" {
return signature
@@ -1748,38 +1743,38 @@ func interactionsThoughtSignature(part gjson.Result) string {
return ""
}
-func geminiPartToInteractionsSteps(part gjson.Result) [][]byte {
+func geminiPartToInteractionsSteps(part ojson.Result) [][]byte {
sig := interactionsThoughtSignature(part)
if fc := part.Get("functionCall"); fc.Exists() {
var steps [][]byte
if sig != "" {
steps = append(steps, geminiThoughtStepJSON(sig, ""))
}
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
- step, _ = sjson.SetBytes(step, "name", fc.Get("name").String())
+ stepDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
+ _ = stepDoc.Set("name", fc.Get("name").String())
if id := fc.Get("id"); id.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", id.String())
+ _ = stepDoc.Set("call_id", id.String())
} else if callID := fc.Get("call_id"); callID.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", callID.String())
+ _ = stepDoc.Set("call_id", callID.String())
}
if args := fc.Get("args"); args.Exists() {
- step, _ = sjson.SetRawBytes(step, "arguments", []byte(args.Raw))
+ _ = stepDoc.SetRaw("arguments", args.Raw)
}
- steps = append(steps, step)
+ steps = append(steps, stepDoc.Bytes())
return steps
}
if fr := part.Get("functionResponse"); fr.Exists() {
- step := []byte(`{"type":"function_result","name":"","result":{}}`)
- step, _ = sjson.SetBytes(step, "name", fr.Get("name").String())
+ stepDoc := ojson.NewDocString(`{"type":"function_result","name":"","result":{}}`)
+ _ = stepDoc.Set("name", fr.Get("name").String())
if id := fr.Get("id"); id.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", id.String())
+ _ = stepDoc.Set("call_id", id.String())
} else if callID := fr.Get("call_id"); callID.Exists() {
- step, _ = sjson.SetBytes(step, "call_id", callID.String())
+ _ = stepDoc.Set("call_id", callID.String())
}
if response := fr.Get("response"); response.Exists() {
- step, _ = sjson.SetRawBytes(step, "result", []byte(response.Raw))
+ _ = stepDoc.SetRaw("result", response.Raw)
}
- return [][]byte{step}
+ return [][]byte{stepDoc.Bytes()}
}
if text := part.Get("text"); text.Exists() {
if part.Get("thought").Bool() {
@@ -1793,7 +1788,7 @@ func geminiPartToInteractionsSteps(part gjson.Result) [][]byte {
}
step := []byte(`{"type":"model_output","content":[]}`)
item := []byte(`{"text":""}`)
- item, _ = sjson.SetBytes(item, "text", text.String())
+ item, _ = ojson.SetBytes(item, "text", text.String())
step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item})
var steps [][]byte
steps = append(steps, step)
@@ -1835,25 +1830,25 @@ func geminiPartToInteractionsSteps(part gjson.Result) [][]byte {
}
func geminiThoughtStepJSON(sig, text string) []byte {
- step := []byte(`{"type":"thought"}`)
+ stepDoc := ojson.NewDocString(`{"type":"thought"}`)
if sig != "" {
- step, _ = sjson.SetBytes(step, "signature", sig)
+ _ = stepDoc.Set("signature", sig)
}
if text != "" {
item := []byte(`{"text":""}`)
- item, _ = sjson.SetBytes(item, "text", text)
- step = translatorcommon.SetRawArrayItems(step, "content", [][]byte{item})
+ item, _ = ojson.SetBytes(item, "text", text)
+ translatorcommon.SetRawArrayItemsDoc(stepDoc, "content", [][]byte{item})
}
- return step
+ return stepDoc.Bytes()
}
-func geminiPartToInteractionsStep(part gjson.Result) []byte {
+func geminiPartToInteractionsStep(part ojson.Result) []byte {
steps := geminiPartToInteractionsSteps(part)
if len(steps) == 0 {
return nil
}
for _, s := range steps {
- if gjson.GetBytes(s, "type").String() == "function_call" {
+ if ojson.GetBytesNoCopy(s, "type").String() == "function_call" {
return s
}
}
diff --git a/internal/translator/gemini/interactions/interactions_gemini_common_test.go b/internal/translator/gemini/interactions/interactions_gemini_common_test.go
index 177a08e6921..d38a54cb5e0 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_common_test.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_common_test.go
@@ -6,63 +6,63 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
)
func TestConvertInteractionsRequestToGeminiStringInput(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":"hello"}`), false)
- if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.role").String(); got != "user" {
t.Fatalf("role = %q, want user", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hello" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.text").String(); got != "hello" {
t.Fatalf("text = %q, want hello", got)
}
}
func TestConvertInteractionsRequestToGeminiSystemAndGenerationConfig(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":{"text":"be brief"},"generation_config":{"max_output_tokens":32,"top_p":0.8},"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
+ if got := ojson.GetBytesNoCopy(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
t.Fatalf("systemInstruction = %q, want be brief", got)
}
- if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
t.Fatalf("maxOutputTokens = %d, want 32", got)
}
- if got := gjson.GetBytes(out, "generationConfig.topP").Float(); got != 0.8 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.topP").Float(); got != 0.8 {
t.Fatalf("topP = %v, want 0.8", got)
}
}
func TestConvertInteractionsRequestToGeminiStringSystemInstruction(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":"be brief","input":"hi"}`), false)
- if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
+ if got := ojson.GetBytesNoCopy(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
t.Fatalf("systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToInteractionsStringSystemInstruction(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","systemInstruction":{"parts":[{"text":"be brief"},{"text":"answer directly"}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
- sys := gjson.GetBytes(out, "system_instruction")
- if sys.Type != gjson.String {
+ sys := ojson.GetBytesNoCopy(out, "system_instruction")
+ if sys.Type != ojson.String {
t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out))
}
if got := sys.String(); got != "be brief\nanswer directly" {
t.Fatalf("system_instruction = %q, want merged text. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "system_instruction.parts").Exists() {
+ if ojson.GetBytesNoCopy(out, "system_instruction.parts").Exists() {
t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out))
}
}
func TestConvertGeminiResponseToInteractionsNonStream(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`))
- if got := gjson.GetBytes(out, "steps.0.type").String(); got != "model_output" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.type").String(); got != "model_output" {
t.Fatalf("step type = %q, want model_output", got)
}
- if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.content.0.text").String(); got != "ok" {
t.Fatalf("text = %q, want ok", got)
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 3 {
t.Fatalf("total tokens = %d, want 3", got)
}
}
@@ -79,7 +79,7 @@ func TestConvertGeminiResponseToInteractionsNonStreamSnakeCaseUsage(t *testing.T
{"usage.total_tokens", 33},
{"usage.cached_tokens", 55},
} {
- if got := gjson.GetBytes(out, test.path).Int(); got != test.want {
+ if got := ojson.GetBytesNoCopy(out, test.path).Int(); got != test.want {
t.Fatalf("%s = %d, want %d. Output: %s", test.path, got, test.want, string(out))
}
}
@@ -99,35 +99,35 @@ func TestConvertInteractionsResponseToGeminiStreamFunctionCall(t *testing.T) {
if len(delta) != 1 {
t.Fatalf("delta output count = %d, want 1", len(delta))
}
- if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(delta[0], "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(delta[0]))
}
- if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
+ if got := ojson.GetBytesNoCopy(delta[0], "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(delta[0]))
}
- if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(delta[0], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_1" {
t.Fatalf("functionCall.id = %q, want call_1. Payload: %s", got, string(delta[0]))
}
- if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
+ if got := ojson.GetBytesNoCopy(delta[0], "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(delta[0]))
}
completed := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5,"total_thought_tokens":1,"total_cached_tokens":4},"service_tier":"standard","model":"gemini-3.1-flash-lite"},"event_type":"interaction.completed"}`), ¶m)
if len(completed) != 1 {
t.Fatalf("completed output count = %d, want 1", len(completed))
}
- if got := gjson.GetBytes(completed[0], "candidates.0.finishReason").String(); got != "STOP" {
+ if got := ojson.GetBytesNoCopy(completed[0], "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(completed[0]))
}
- if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(completed[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(completed[0]))
}
- if got := gjson.GetBytes(completed[0], "usageMetadata.candidatesTokenCount").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(completed[0], "usageMetadata.candidatesTokenCount").Int(); got != 3 {
t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(completed[0]))
}
- if got := gjson.GetBytes(completed[0], "usageMetadata.totalTokenCount").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(completed[0], "usageMetadata.totalTokenCount").Int(); got != 5 {
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(completed[0]))
}
- if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokensDetails.0.tokenCount").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(completed[0], "usageMetadata.promptTokensDetails.0.tokenCount").Int(); got != 2 {
t.Fatalf("promptTokensDetails.0.tokenCount = %d, want 2. Payload: %s", got, string(completed[0]))
}
done := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`event: done
@@ -143,22 +143,22 @@ func TestConvertInteractionsResponseToGeminiStreamFinishMetadataUsage(t *testing
if len(out) != 1 {
t.Fatalf("output count = %d, want 1", len(out))
}
- if got := gjson.GetBytes(out[0], "candidates.0.finishReason").String(); got != "STOP" {
+ if got := ojson.GetBytesNoCopy(out[0], "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(out[0]))
}
- if got := gjson.GetBytes(out[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(out[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(out[0]))
}
- if got := gjson.GetBytes(out[0], "usageMetadata.candidatesTokenCount").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(out[0], "usageMetadata.candidatesTokenCount").Int(); got != 6 {
t.Fatalf("candidatesTokenCount = %d, want 6. Payload: %s", got, string(out[0]))
}
- if got := gjson.GetBytes(out[0], "usageMetadata.thoughtsTokenCount").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(out[0], "usageMetadata.thoughtsTokenCount").Int(); got != 3 {
t.Fatalf("thoughtsTokenCount = %d, want 3. Payload: %s", got, string(out[0]))
}
- if got := gjson.GetBytes(out[0], "usageMetadata.cachedContentTokenCount").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out[0], "usageMetadata.cachedContentTokenCount").Int(); got != 1 {
t.Fatalf("cachedContentTokenCount = %d, want 1. Payload: %s", got, string(out[0]))
}
- if got := gjson.GetBytes(out[0], "usageMetadata.totalTokenCount").Int(); got != 11 {
+ if got := ojson.GetBytesNoCopy(out[0], "usageMetadata.totalTokenCount").Int(); got != 11 {
t.Fatalf("totalTokenCount = %d, want 11. Payload: %s", got, string(out[0]))
}
}
@@ -166,104 +166,104 @@ func TestConvertInteractionsResponseToGeminiStreamFinishMetadataUsage(t *testing
func TestConvertInteractionsResponseToGeminiNonStreamFunctionCall(t *testing.T) {
raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","call_id":"call_1","signature":"sig_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
out := ConvertInteractionsResponseToGeminiNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usageMetadata.totalTokenCount").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usageMetadata.totalTokenCount").Int(); got != 5 {
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiTurnInput(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]}}`), false)
- if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("text = %q, want hi", got)
}
}
func TestConvertInteractionsRequestToGeminiTurnArrayInput(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]},{"role":"assistant","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.role").String(); got != "user" {
t.Fatalf("contents.0.role = %q, want user. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
+ if got := ojson.GetBytesNoCopy(out, "contents.1.role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "contents.1.parts.0.text").String(); got != "ok" {
t.Fatalf("contents.1.parts.0.text = %q, want ok. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiPreservesExpressibleTopLevelFields(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false)
- if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
+ if got := ojson.GetBytesNoCopy(out, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want ANY. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != "lookup" {
t.Fatalf("allowedFunctionNames.0 = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generationConfig.responseModalities.0").String(); got != "TEXT" {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.responseModalities.0").String(); got != "TEXT" {
t.Fatalf("responseModalities.0 = %q, want TEXT. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generationConfig.responseModalities.1").String(); got != "IMAGE" {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.responseModalities.1").String(); got != "IMAGE" {
t.Fatalf("responseModalities.1 = %q, want IMAGE. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytesNoCopy(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiContentInput(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","parts":[{"text":"hi"}]}}`), false)
- if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.role").String(); got != "user" {
t.Fatalf("contents.0.role = %q, want user", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("contents.0.parts.0.text = %q, want hi", got)
}
}
func TestConvertInteractionsRequestToGeminiContentArrayInput(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","parts":[{"text":"hi"}]},{"role":"assistant","parts":[{"text":"ok"}]}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.role").String(); got != "user" {
t.Fatalf("contents.0.role = %q, want user", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("contents.0.parts.0.text = %q, want hi", got)
}
- if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
+ if got := ojson.GetBytesNoCopy(out, "contents.1.role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model", got)
}
- if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "contents.1.parts.0.text").String(); got != "ok" {
t.Fatalf("contents.1.parts.0.text = %q, want ok", got)
}
}
func TestConvertGeminiResponseToInteractionsNonStreamFunctionCall(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3,"cachedContentTokenCount":4}}`))
- if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call", got)
}
- if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.name").String(); got != "lookup" {
t.Fatalf("name = %q, want lookup", got)
}
- if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 4 {
+ if got := ojson.GetBytesNoCopy(out, "usage.cached_tokens").Int(); got != 4 {
t.Fatalf("cached tokens = %d, want 4", got)
}
}
func TestConvertGeminiResponseToInteractionsNonStreamFunctionCallPreservesCallID(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_response_1","args":{"q":"x"}}}]}}]}`))
- if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_response_1" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.call_id").String(); got != "call_response_1" {
t.Fatalf("steps.0.call_id = %q, want call_response_1", got)
}
}
@@ -276,10 +276,10 @@ func TestConvertGeminiResponseToInteractionsStreamFunctionCallCallID(t *testing.
t.Fatalf("step.delta payload not found")
}
startPayload := findEventPayload(out, "step.start")
- if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" {
+ if got := ojson.GetBytesNoCopy(startPayload, "step.id").String(); got != "call_stream_1" {
t.Fatalf("step.id = %q, want call_stream_1", got)
}
- if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
+ if got := ojson.GetBytesNoCopy(payload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string", got)
}
}
@@ -295,21 +295,21 @@ func TestConvertGeminiResponseToInteractionsStreamFunctionCallThoughtSignature(t
if len(signaturePayload) == 0 {
t.Fatalf("thought_signature step.delta payload not found. Events: %s", eventTypes(out))
}
- if got := gjson.GetBytes(signaturePayload, "delta.signature").String(); got != "sig-call" {
+ if got := ojson.GetBytesNoCopy(signaturePayload, "delta.signature").String(); got != "sig-call" {
t.Fatalf("delta.signature = %q, want sig-call. Payload: %s", got, string(signaturePayload))
}
- if got := gjson.GetBytes(signaturePayload, "index").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(signaturePayload, "index").Int(); got != 2 {
t.Fatalf("signature index = %d, want 2. Events: %s", got, eventTypes(out))
}
functionStartPayload := findNthEventPayload(out, "step.start", 3)
- if got := gjson.GetBytes(functionStartPayload, "step.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(functionStartPayload, "step.type").String(); got != "function_call" {
t.Fatalf("fourth step type = %q, want function_call. Events: %s", got, eventTypes(out))
}
- if got := gjson.GetBytes(functionStartPayload, "step.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(functionStartPayload, "step.id").String(); got != "call_1" {
t.Fatalf("function call id = %q, want call_1. Payload: %s", got, string(functionStartPayload))
}
argumentsPayload := findStepDeltaPayloadByType(out, "arguments_delta")
- if got := gjson.GetBytes(argumentsPayload, "delta.arguments").String(); got != `{"q":"x"}` {
+ if got := ojson.GetBytesNoCopy(argumentsPayload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(argumentsPayload))
}
}
@@ -324,29 +324,29 @@ func TestConvertGeminiResponseToInteractionsStreamStepLifecycle(t *testing.T) {
if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed,done")) {
t.Fatalf("event sequence = %s", got)
}
- if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 0), "step.type").String(); got != "thought" {
+ if got := ojson.GetBytesNoCopy(findNthEventPayload(out, "step.start", 0), "step.type").String(); got != "thought" {
t.Fatalf("first step type = %q, want thought", got)
}
- if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 1), "step.type").String(); got != "model_output" {
+ if got := ojson.GetBytesNoCopy(findNthEventPayload(out, "step.start", 1), "step.type").String(); got != "model_output" {
t.Fatalf("second step type = %q, want model_output", got)
}
- if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 2), "step.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(findNthEventPayload(out, "step.start", 2), "step.type").String(); got != "function_call" {
t.Fatalf("third step type = %q, want function_call", got)
}
- if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 0), "delta.type").String(); got != "thought_summary" {
+ if got := ojson.GetBytesNoCopy(findNthEventPayload(out, "step.delta", 0), "delta.type").String(); got != "thought_summary" {
t.Fatalf("thought delta type = %q, want thought_summary", got)
}
- if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 2), "delta.type").String(); got != "arguments_delta" {
+ if got := ojson.GetBytesNoCopy(findNthEventPayload(out, "step.delta", 2), "delta.type").String(); got != "arguments_delta" {
t.Fatalf("function delta type = %q, want arguments_delta", got)
}
completed := findCompletedPayload(out)
- if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 4 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_output_tokens").Int(); got != 4 {
t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "interaction.usage.total_thought_tokens").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_thought_tokens").Int(); got != 2 {
t.Fatalf("total_thought_tokens = %d, want 2. Payload: %s", got, string(completed))
}
}
@@ -368,7 +368,7 @@ func TestConvertGeminiResponseToInteractionsStreamSnakeCaseUsage(t *testing.T) {
{"interaction.usage.total_tokens", 33},
{"interaction.usage.total_cached_tokens", 55},
} {
- if got := gjson.GetBytes(completed, test.path).Int(); got != test.want {
+ if got := ojson.GetBytesNoCopy(completed, test.path).Int(); got != test.want {
t.Fatalf("%s = %d, want %d. Payload: %s", test.path, got, test.want, string(completed))
}
}
@@ -384,7 +384,7 @@ func TestConvertGeminiResponseToInteractionsStreamEmitsDoneAfterUsage(t *testing
t.Fatalf("done count = %d, want 1. Events: %s", got, eventTypes(out))
}
completed := findCompletedPayload(out)
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); got != 5 {
t.Fatalf("completed total_tokens = %d, want 5. Payload: %s", got, string(completed))
}
@@ -408,7 +408,7 @@ func TestConvertGeminiResponseToInteractionsStreamSplitFinishAndFilteredUsage(t
t.Fatalf("usage done count = %d, want 1. Events: %s", got, eventTypes(usageOut))
}
completed := findCompletedPayload(usageOut)
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 33 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); got != 33 {
t.Fatalf("completed total_tokens = %d, want 33. Payload: %s", got, string(completed))
}
}
@@ -431,7 +431,7 @@ func TestConvertGeminiResponseToInteractionsStreamWaitsForPostFinishUsage(t *tes
t.Fatalf("usage done count = %d, want 1. Events: %s", got, eventTypes(usageOut))
}
completed := findCompletedPayload(usageOut)
- if got := gjson.GetBytes(completed, "interaction.usage.total_tokens").Int(); got != 33 {
+ if got := ojson.GetBytesNoCopy(completed, "interaction.usage.total_tokens").Int(); got != 33 {
t.Fatalf("completed total_tokens = %d, want 33 (final usage, not the intermediate frame). Payload: %s", got, string(completed))
}
}
@@ -480,7 +480,7 @@ func TestConvertGeminiResponseToInteractionsStreamEmitsTerminalOnce(t *testing.T
t.Fatalf("literal [DONE] after completion emitted %q, want no events", doneOut)
}
payload := findCompletedPayload(usageOut)
- if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_tokens").Int(); got != 3 {
t.Fatalf("completed total_tokens = %d, want 3. Payload: %s", got, string(payload))
}
}
@@ -514,7 +514,7 @@ func TestConvertGeminiResponseToInteractionsStreamDoesNotCompleteOnNonTerminalUs
t.Fatalf("event sequence = %s", got)
}
payload := findCompletedPayload(out)
- if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 468 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_tokens").Int(); got != 468 {
t.Fatalf("completed total_tokens = %d, want 468. Payload: %s", got, string(payload))
}
}
@@ -548,71 +548,71 @@ func TestConvertGeminiResponseToInteractionsStreamCompletesOnDoneWithoutUsage(t
func TestConvertInteractionsRequestToGeminiImageContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
t.Fatalf("mimeType = %q, want image/png", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
t.Fatalf("data = %q, want aGVsbG8=", got)
}
}
func TestConvertInteractionsRequestToGeminiModelOutputTypedContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"model_output","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="},{"type":"document","mime_type":"application/pdf","file_uri":"gs://bucket/doc.pdf"}]}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.role").String(); got != "model" {
t.Fatalf("contents.0.role = %q, want model. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
t.Fatalf("image mimeType = %q, want image/png. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
t.Fatalf("image data = %q, want aGVsbG8=. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.mimeType").String(); got != "application/pdf" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.1.fileData.mimeType").String(); got != "application/pdf" {
t.Fatalf("document mimeType = %q, want application/pdf. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.fileUri").String(); got != "gs://bucket/doc.pdf" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.1.fileData.fileUri").String(); got != "gs://bucket/doc.pdf" {
t.Fatalf("document fileUri = %q, want gs://bucket/doc.pdf. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToGeminiThoughtTypedContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"thought","content":[{"type":"text","text":"thinking"},{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="}]}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.text").String(); got != "thinking" {
t.Fatalf("thought text = %q, want thinking. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.thought").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.thought").Bool(); !got {
t.Fatalf("thought flag = false, want true. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "contents.0.parts.1.inlineData.mimeType").String(); got != "audio/wav" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.1.inlineData.mimeType").String(); got != "audio/wav" {
t.Fatalf("audio mimeType = %q, want audio/wav. Output: %s", got, string(out))
}
}
func TestConvertGeminiResponseToInteractionsNonStreamImage(t *testing.T) {
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`))
- if got := gjson.GetBytes(out, "steps.0.content.0.type").String(); got != "image" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.content.0.type").String(); got != "image" {
t.Fatalf("content type = %q, want image", got)
}
}
func TestConvertInteractionsRequestToGeminiGenerationConfigAllFields(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"response_schema":{"type":"object"},"seed":42,"thinking_config":{"thinking_budget":1024,"include_thoughts":true},"context_window_compression":{"trigger_tokens":1000}},"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
t.Fatalf("maxOutputTokens = %d, want 32", got)
}
- if got := gjson.GetBytes(out, "generationConfig.responseSchema.type").String(); got != "object" {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.responseSchema.type").String(); got != "object" {
t.Fatalf("responseSchema.type = %q, want object", got)
}
- if got := gjson.GetBytes(out, "generationConfig.seed").Int(); got != 42 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.seed").Int(); got != 42 {
t.Fatalf("seed = %d, want 42", got)
}
- if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingBudget").Int(); got != 1024 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.thinkingBudget").Int(); got != 1024 {
t.Fatalf("thinkingBudget = %d, want 1024", got)
}
- if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("includeThoughts = false, want true")
}
- if got := gjson.GetBytes(out, "generationConfig.contextWindowCompression.triggerTokens").Int(); got != 1000 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.contextWindowCompression.triggerTokens").Int(); got != 1000 {
t.Fatalf("triggerTokens = %d, want 1000", got)
}
}
@@ -625,136 +625,136 @@ func TestConvertInteractionsRequestToGeminiGenerationConfigProtocolFields(t *tes
"generationConfig.thinkingLevel",
"generationConfig.thinkingSummaries",
} {
- if gjson.GetBytes(out, path).Exists() {
+ if ojson.GetBytesNoCopy(out, path).Exists() {
t.Fatalf("%s exists, want omitted. Output: %s", path, string(out))
}
}
- if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
+ if got := ojson.GetBytesNoCopy(out, "toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
t.Fatalf("includeThoughts = false, want true. Output: %s", string(out))
}
}
func TestConvertGeminiRequestToInteractionsFunctionCall(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"ok":true}}}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "function_call" {
t.Fatalf("input.0.type = %q, want function_call", got)
}
- if got := gjson.GetBytes(out, "input.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.name").String(); got != "lookup" {
t.Fatalf("input.0.name = %q, want lookup", got)
}
- if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.type").String(); got != "function_result" {
t.Fatalf("input.1.type = %q, want function_result", got)
}
}
func TestConvertGeminiRequestToInteractionsTextContentType(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "text" {
t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("content.0.text = %q, want hi. Output: %s", got, string(out))
}
}
func TestConvertGeminiRequestToInteractionsMultimodal(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"aGVsbG8="}}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input", got)
}
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "audio" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "audio" {
t.Fatalf("content.0.type = %q, want audio", got)
}
- if got := gjson.GetBytes(out, "input.0.content.0.mime_type").String(); got != "audio/wav" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.mime_type").String(); got != "audio/wav" {
t.Fatalf("mime_type = %q, want audio/wav", got)
}
}
func TestConvertGeminiRequestToInteractionsThought(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"text":"thinking","thought":true}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "thought" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "thought" {
t.Fatalf("input.0.type = %q, want thought", got)
}
}
func TestConvertInteractionsRequestToGeminiTurnWithModelRole(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"model","steps":[{"type":"user_input","content":[{"text":"hi"}]},{"type":"model_output","content":[{"text":"ok"}]}]}}`), false)
- if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.role").String(); got != "model" {
t.Fatalf("contents.0.role = %q, want model", got)
}
- if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
+ if got := ojson.GetBytesNoCopy(out, "contents.1.role").String(); got != "model" {
t.Fatalf("contents.1.role = %q, want model", got)
}
}
func TestConvertInteractionsRequestToGeminiGenerationConfigPreservesLargeIntegers(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"large_identity":9223372036854775807},"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
t.Fatalf("maxOutputTokens = %d, want 32", got)
}
- if got := gjson.GetBytes(out, "generationConfig.largeIdentity").String(); got != "9223372036854775807" {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.largeIdentity").String(); got != "9223372036854775807" {
t.Fatalf("largeIdentity = %q, want 9223372036854775807", got)
}
}
func TestConvertInteractionsRequestToGeminiFunctionCallPreservesCallID(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.functionCall.id").String(); got != "call_1" {
t.Fatalf("functionCall.id = %q, want call_1", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.functionCall.name").String(); got != "lookup" {
t.Fatalf("functionCall.name = %q, want lookup", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.args.q").String(); got != "x" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.functionCall.args.q").String(); got != "x" {
t.Fatalf("functionCall.args.q = %q, want x", got)
}
}
func TestConvertInteractionsRequestToGeminiFunctionResultPreservesCallID(t *testing.T) {
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false)
- if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.functionResponse.id").String(); got != "call_1" {
t.Fatalf("functionResponse.id = %q, want call_1", got)
}
- if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "contents.0.parts.0.functionResponse.name").String(); got != "lookup" {
t.Fatalf("functionResponse.name = %q, want lookup", got)
}
}
func TestConvertGeminiRequestToInteractionsFunctionCallPreservesID(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","id":"call_1","response":{"ok":true}}}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.call_id").String(); got != "call_1" {
t.Fatalf("input.0.call_id = %q, want call_1", got)
}
- if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("input.1.call_id = %q, want call_1", got)
}
}
func TestConvertGeminiRequestToInteractionsFunctionCallPreservesCallID(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_request_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","call_id":"call_request_1","response":{"ok":true}}}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_request_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.call_id").String(); got != "call_request_1" {
t.Fatalf("input.0.call_id = %q, want call_request_1", got)
}
- if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_request_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.call_id").String(); got != "call_request_1" {
t.Fatalf("input.1.call_id = %q, want call_request_1", got)
}
}
func TestConvertGeminiRequestToInteractionsGenerationConfig(t *testing.T) {
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generationConfig":{"maxOutputTokens":32,"topP":0.8,"thinkingConfig":{"thinkingBudget":1024}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
- if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 32 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.max_output_tokens").Int(); got != 32 {
t.Fatalf("max_output_tokens = %d, want 32", got)
}
- if got := gjson.GetBytes(out, "generation_config.top_p").Float(); got != 0.8 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.top_p").Float(); got != 0.8 {
t.Fatalf("top_p = %v, want 0.8", got)
}
- if got := gjson.GetBytes(out, "generation_config.thinking_config.thinking_budget").Int(); got != 1024 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.thinking_config.thinking_budget").Int(); got != 1024 {
t.Fatalf("thinking_budget = %d, want 1024", got)
}
}
@@ -766,7 +766,7 @@ func findStepDeltaPayload(events [][]byte) []byte {
func findStepDeltaPayloadByType(events [][]byte, deltaType string) []byte {
for _, event := range events {
payload := ssePayload(event)
- if eventName(event, payload) == "step.delta" && gjson.GetBytes(payload, "delta.type").String() == deltaType {
+ if eventName(event, payload) == "step.delta" && ojson.GetBytesNoCopy(payload, "delta.type").String() == deltaType {
return payload
}
}
@@ -822,7 +822,7 @@ func countEventType(events [][]byte, eventType string) int {
}
func eventName(event, payload []byte) string {
- if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
+ if eventType := ojson.GetBytesNoCopy(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
@@ -850,7 +850,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"type":"url_context"}]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d. Output: %s", len(tools), string(out))
}
@@ -869,7 +869,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"type":"code_execution"}]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d. Output: %s", len(tools), string(out))
}
@@ -888,7 +888,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"type":"google_search"}]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d. Output: %s", len(tools), string(out))
}
@@ -907,7 +907,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"urlContext":{}},{"codeExecution":{}},{"googleSearch":{}}]
}`)
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 3 {
t.Fatalf("expected 3 tools, got %d. Output: %s", len(tools), string(out))
}
@@ -929,7 +929,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"googleSearch":{"mode":"search"}},{"codeExecution":{"sandbox":true}}]
}`)
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d. Output: %s", len(tools), string(out))
}
@@ -952,7 +952,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 3 {
t.Fatalf("expected 3 tools, got %d. Output: %s", len(tools), string(out))
}
@@ -974,7 +974,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"googleSearch":{},"urlContext":{}}]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool node, got %d. Output: %s", len(tools), string(out))
}
@@ -992,7 +992,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"googleSearch":{},"urlContext":{}}]
}`)
interactionsOut, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", geminiReq, false)
- interTools := gjson.GetBytes(interactionsOut, "tools").Array()
+ interTools := ojson.GetBytesNoCopy(interactionsOut, "tools").Array()
if len(interTools) != 2 {
t.Fatalf("expected 2 converted tools from composite, got %d. Output: %s", len(interTools), string(interactionsOut))
}
@@ -1018,7 +1018,7 @@ func TestConvertInteractionsRequestToGeminiBuiltinTools(t *testing.T) {
"tools":[{"type":"file_search","file_search":{"max_results":5}}]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 1 {
t.Fatalf("expected 1 tool retained, got %d. Output: %s", len(tools), string(out))
}
@@ -1045,8 +1045,8 @@ func TestConvertInteractionsRequestToGemini_FunctionResponseJSONRef(t *testing.T
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- val := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.response.result")
- if val.Type != gjson.String {
+ val := ojson.GetBytesNoCopy(out, "contents.0.parts.0.functionResponse.response.result")
+ if val.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got %s (raw: %s)", val.Type, val.Raw)
}
if !strings.Contains(val.String(), "#/components/schemas/ErrorModel") {
@@ -1072,7 +1072,7 @@ func TestConvertInteractionsResponseToGemini_FunctionCallArgsPreservesRef(t *tes
]
}`)
out := ConvertInteractionsResponseToGeminiNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
- args := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args")
+ args := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.args")
if !args.IsObject() {
t.Fatalf("expected functionCall.args to remain an object, got %s (raw: %s)", args.Type, args.Raw)
}
@@ -1099,8 +1099,8 @@ func TestConvertInteractionsResponseToGemini_FunctionResultWithRef(t *testing.T)
]
}`)
out := ConvertInteractionsResponseToGeminiNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
- val := gjson.GetBytes(out, "candidates.0.content.parts.0.functionResponse.response.result")
- if val.Type != gjson.String {
+ val := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionResponse.response.result")
+ if val.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got %s (raw: %s)", val.Type, val.Raw)
}
if !strings.Contains(val.String(), "#/components/schemas/ErrorModel") {
@@ -1123,7 +1123,7 @@ func TestConvertInteractionsRequestToGemini_ParallelToolCallsHistory(t *testing.
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents (user, model, user), got %d: %s", len(contents), string(out))
}
@@ -1152,7 +1152,7 @@ func TestConvertInteractionsRequestToGemini_ThoughtSummaryAndSignature(t *testin
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(out))
}
@@ -1188,7 +1188,7 @@ func TestConvertGeminiResponseToInteractionsNonStream_ThoughtSignature(t *testin
}]
}`)
out := ConvertGeminiResponseToInteractionsNonStream(context.Background(), "gemini-3.5-flash", nil, nil, raw, nil)
- steps := gjson.GetBytes(out, "steps").Array()
+ steps := ojson.GetBytesNoCopy(out, "steps").Array()
if len(steps) != 3 {
t.Fatalf("expected 3 steps (thought, function_call, function_call), got %d: %s", len(steps), string(out))
}
@@ -1232,7 +1232,7 @@ func TestConvertGeminiRequestToInteractions_SignedFunctionCallPreserved(t *testi
]
}`)
out, _ := ConvertGeminiRequestToInteractions("gemini-3.5-flash", geminiReq, false)
- steps := gjson.GetBytes(out, "input").Array()
+ steps := ojson.GetBytesNoCopy(out, "input").Array()
if len(steps) != 2 {
t.Fatalf("expected 2 steps (thought, function_call), got %d: %s", len(steps), string(out))
}
@@ -1256,7 +1256,7 @@ func TestConvertInteractionsRequestToGemini_InterleavedModelTurnSteps(t *testing
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
if len(contents) != 2 {
t.Fatalf("expected 2 contents (model, user), got %d: %s", len(contents), string(out))
}
@@ -1289,17 +1289,17 @@ func TestConvertInteractionsRequestToGemini_ResponseToRequestRoundTrip(t *testin
}]
}`)
respOut := ConvertGeminiResponseToInteractionsNonStream(context.Background(), "gemini-3.5-flash", nil, nil, upstreamResponse, nil)
- steps := gjson.GetBytes(respOut, "steps").Raw
+ steps := ojson.GetBytesNoCopy(respOut, "steps").Raw
// Build Turn 2 input: user question + Turn 1 steps + tool results
turn2Input := `{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"text","text":"hello"}]}`
- for _, s := range gjson.Get(steps, "@this").Array() {
+ for _, s := range ojson.Get(steps, "@this").Array() {
turn2Input += "," + s.Raw
}
turn2Input += `,{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}},{"type":"function_result","name":"search","call_id":"call_2","result":{"found":true}}]}`
reqOut, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(turn2Input), false)
- contents := gjson.GetBytes(reqOut, "contents").Array()
+ contents := ojson.GetBytesNoCopy(reqOut, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents (user, model, user), got %d: %s", len(contents), string(reqOut))
}
@@ -1325,7 +1325,7 @@ func TestConvertInteractionsRequestToGemini_MultipleSignaturesPreserved(t *testi
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(out))
}
@@ -1357,11 +1357,11 @@ func TestConvertInteractionsRequestToGemini_TrailingSignatureRoundTrip(t *testin
}]
}`)
respOut := ConvertGeminiResponseToInteractionsNonStream(context.Background(), "gemini-3.5-flash", nil, nil, upstreamResponse, nil)
- steps := gjson.GetBytes(respOut, "steps").Raw
+ steps := ojson.GetBytesNoCopy(respOut, "steps").Raw
turn2Input := `{"model":"gemini-3.5-flash","input":` + steps + `}`
reqOut, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(turn2Input), false)
- contents := gjson.GetBytes(reqOut, "contents").Array()
+ contents := ojson.GetBytesNoCopy(reqOut, "contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(reqOut))
}
@@ -1390,7 +1390,7 @@ func TestConvertInteractionsRequestToGemini_ExplicitSignatureClearsPending(t *te
]
}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", inputJSON, false)
- contents := gjson.GetBytes(out, "contents").Array()
+ contents := ojson.GetBytesNoCopy(out, "contents").Array()
if len(contents) != 1 {
t.Fatalf("expected 1 model content, got %d: %s", len(contents), string(out))
}
@@ -1451,15 +1451,15 @@ func TestConvertInteractionsResponseToGemini_ResponseFailed(t *testing.T) {
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
- gotCode := gjson.GetBytes(events[0], "error.code").Int()
+ gotCode := ojson.GetBytesNoCopy(events[0], "error.code").Int()
if gotCode != tt.wantCode {
t.Errorf("error.code = %d, want %d", gotCode, tt.wantCode)
}
- gotStatus := gjson.GetBytes(events[0], "error.status").String()
+ gotStatus := ojson.GetBytesNoCopy(events[0], "error.status").String()
if gotStatus != tt.wantStatus {
t.Errorf("error.status = %q, want %q", gotStatus, tt.wantStatus)
}
- gotMsg := gjson.GetBytes(events[0], "error.message").String()
+ gotMsg := ojson.GetBytesNoCopy(events[0], "error.message").String()
if !strings.Contains(gotMsg, tt.wantMsg) {
t.Errorf("error.message = %q, want containing %q", gotMsg, tt.wantMsg)
}
diff --git a/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go b/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go
index 29629b02eb0..f1b6d0df3bd 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_file_data_test.go
@@ -3,14 +3,14 @@ package interactions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertInteractionsRequestToGeminiNormalizesOpenAIFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out, _ := ConvertInteractionsRequestToGemini("gemini-3.5-flash", input, false)
- inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(out, "contents.0.parts.0.inlineData")
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
}
diff --git a/internal/translator/gemini/interactions/interactions_gemini_response.go b/internal/translator/gemini/interactions/interactions_gemini_response.go
index 202ea0e1e54..00ac66258f9 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_response.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_response.go
@@ -8,9 +8,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type interactionsToGeminiStreamState struct {
@@ -44,7 +44,7 @@ func ConvertInteractionsResponseToGemini(_ context.Context, modelName string, _,
}
func ConvertInteractionsResponseToGeminiNonStream(_ context.Context, modelName string, _, _, rawJSON []byte, _ *any) []byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
interaction := root
if nested := root.Get("interaction"); nested.Exists() {
interaction = nested
@@ -59,7 +59,7 @@ func ConvertInteractionsResponseToGeminiNonStream(_ context.Context, modelName s
if !steps.Exists() {
steps = root.Get("steps")
}
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
parts = append(parts, interactionsStepToGeminiParts(step)...)
return true
})
@@ -89,15 +89,15 @@ func convertInteractionsEventToGemini(modelName string, rawJSON []byte, st *inte
if len(payload) == 0 {
return nil
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if !root.Exists() {
return nil
}
switch root.Get("event_type").String() {
case "interaction.created":
interaction := root.Get("interaction")
- st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String())
- st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName)
+ st.ID = strings.Clone(firstNonEmptyInteractionString(st.ID, interaction.Get("id").String()))
+ st.Model = strings.Clone(firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName))
case "step.start":
rememberInteractionsGeminiStep(root, st)
case "step.delta":
@@ -106,9 +106,9 @@ func convertInteractionsEventToGemini(modelName string, rawJSON []byte, st *inte
}
case "interaction.completed", "finish":
interaction := root.Get("interaction")
- st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String())
- st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName)
- st.ServiceTier = firstNonEmptyInteractionString(st.ServiceTier, interaction.Get("service_tier").String())
+ st.ID = strings.Clone(firstNonEmptyInteractionString(st.ID, interaction.Get("id").String()))
+ st.Model = strings.Clone(firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName))
+ st.ServiceTier = strings.Clone(firstNonEmptyInteractionString(st.ServiceTier, interaction.Get("service_tier").String()))
chunk := buildInteractionsGeminiChunk(st, modelName, nil, "STOP", translatorcommon.InteractionsUsage(root), true)
return [][]byte{chunk}
case "response.failed", "interaction.failed":
@@ -122,11 +122,11 @@ func convertInteractionsEventToGemini(modelName string, rawJSON []byte, st *inte
}
codeVal := firstNonEmptyInteractionString(errNode.Get("code").String(), root.Get("code").String(), errNode.Get("status").String())
statusCode, statusText := mapInteractionsErrorToGemini(codeVal)
- errorResponse := []byte(`{"error":{"code":500,"message":"","status":"INTERNAL"}}`)
- errorResponse, _ = sjson.SetBytes(errorResponse, "error.code", statusCode)
- errorResponse, _ = sjson.SetBytes(errorResponse, "error.message", msg)
- errorResponse, _ = sjson.SetBytes(errorResponse, "error.status", statusText)
- return [][]byte{errorResponse}
+ errorResponseDoc := ojson.NewDocString(`{"error":{"code":500,"message":"","status":"INTERNAL"}}`)
+ _ = errorResponseDoc.Set("error.code", statusCode)
+ _ = errorResponseDoc.Set("error.message", msg)
+ _ = errorResponseDoc.Set("error.status", statusText)
+ return [][]byte{errorResponseDoc.Bytes()}
}
return nil
}
@@ -163,58 +163,58 @@ func mapInteractionsErrorToGemini(codeStr string) (int, string) {
}
}
-func rememberInteractionsGeminiStep(root gjson.Result, st *interactionsToGeminiStreamState) {
+func rememberInteractionsGeminiStep(root ojson.Result, st *interactionsToGeminiStreamState) {
index := int(root.Get("index").Int())
step := root.Get("step")
- st.StepNames[index] = step.Get("name").String()
- st.StepIDs[index] = firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String())
- st.StepSignatures[index] = firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String())
+ st.StepNames[index] = strings.Clone(step.Get("name").String())
+ st.StepIDs[index] = strings.Clone(firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()))
+ st.StepSignatures[index] = strings.Clone(firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String()))
}
-func interactionsStepDeltaToGeminiChunk(modelName string, root gjson.Result, st *interactionsToGeminiStreamState) []byte {
+func interactionsStepDeltaToGeminiChunk(modelName string, root ojson.Result, st *interactionsToGeminiStreamState) []byte {
index := int(root.Get("index").Int())
delta := root.Get("delta")
switch delta.Get("type").String() {
case "arguments_delta":
- part := []byte(`{"functionCall":{"name":"","args":{}}}`)
- part, _ = sjson.SetBytes(part, "functionCall.name", firstNonEmptyInteractionString(st.StepNames[index], root.Get("step.name").String()))
+ partDoc := ojson.NewDocString(`{"functionCall":{"name":"","args":{}}}`)
+ _ = partDoc.Set("functionCall.name", firstNonEmptyInteractionString(st.StepNames[index], root.Get("step.name").String()))
if id := st.StepIDs[index]; id != "" {
- part, _ = sjson.SetBytes(part, "functionCall.id", id)
+ _ = partDoc.Set("functionCall.id", id)
}
if signature := st.StepSignatures[index]; signature != "" {
- part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
+ _ = partDoc.Set("thoughtSignature", signature)
}
arguments := strings.TrimSpace(delta.Get("arguments").String())
- if arguments != "" && gjson.Valid(arguments) {
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(arguments))
+ if arguments != "" && ojson.Valid(arguments) {
+ _ = partDoc.SetRaw("functionCall.args", arguments)
}
- return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false)
+ return buildInteractionsGeminiChunk(st, modelName, [][]byte{partDoc.Bytes()}, "", ojson.Result{}, false)
case "text":
text := firstNonEmptyInteractionString(delta.Get("text").String(), delta.Get("content.text").String())
if text == "" {
return nil
}
- return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, false)}, "", gjson.Result{}, false)
+ return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, false)}, "", ojson.Result{}, false)
case "thought_summary":
text := firstNonEmptyInteractionString(delta.Get("content.text").String(), delta.Get("text").String())
if text == "" {
return nil
}
- return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, true)}, "", gjson.Result{}, false)
+ return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, true)}, "", ojson.Result{}, false)
case "thought_signature":
- signature := firstNonEmptyInteractionString(delta.Get("signature").String(), delta.Get("thought_signature").String(), delta.Get("thoughtSignature").String())
+ signature := strings.Clone(firstNonEmptyInteractionString(delta.Get("signature").String(), delta.Get("thought_signature").String(), delta.Get("thoughtSignature").String()))
if signature == "" {
return nil
}
st.StepSignatures[index] = signature
- part := geminiTextPartJSON("", true)
- part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
- return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false)
+ partDoc := ojson.NewDocString(`{"text":"","thought":true}`)
+ _ = partDoc.Set("thoughtSignature", signature)
+ return buildInteractionsGeminiChunk(st, modelName, [][]byte{partDoc.Bytes()}, "", ojson.Result{}, false)
}
return nil
}
-func interactionsStepToGeminiParts(step gjson.Result) [][]byte {
+func interactionsStepToGeminiParts(step ojson.Result) [][]byte {
switch step.Get("type").String() {
case "function_call":
return [][]byte{interactionsFunctionCallStepToGeminiPart(step)}
@@ -227,12 +227,12 @@ func interactionsStepToGeminiParts(step gjson.Result) [][]byte {
}
}
-func interactionsContentToGeminiParts(content gjson.Result, thought bool) [][]byte {
+func interactionsContentToGeminiParts(content ojson.Result, thought bool) [][]byte {
var parts [][]byte
if !content.Exists() {
return parts
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return [][]byte{geminiTextPartJSON(content.String(), thought)}
}
if content.IsObject() {
@@ -242,7 +242,7 @@ func interactionsContentToGeminiParts(content gjson.Result, thought bool) [][]by
return parts
}
if content.IsArray() {
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
if part := interactionsContentPartToGeminiPart(item, thought); len(part) > 0 {
parts = append(parts, part)
}
@@ -252,31 +252,31 @@ func interactionsContentToGeminiParts(content gjson.Result, thought bool) [][]by
return parts
}
-func interactionsFunctionCallStepToGeminiPart(step gjson.Result) []byte {
- part := []byte(`{"functionCall":{"name":"","args":{}}}`)
- part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String())
+func interactionsFunctionCallStepToGeminiPart(step ojson.Result) []byte {
+ partDoc := ojson.NewDocString(`{"functionCall":{"name":"","args":{}}}`)
+ _ = partDoc.Set("functionCall.name", step.Get("name").String())
if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" {
- part, _ = sjson.SetBytes(part, "functionCall.id", id)
+ _ = partDoc.Set("functionCall.id", id)
}
if signature := firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String()); signature != "" {
- part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
+ _ = partDoc.Set("thoughtSignature", signature)
}
- part = setInteractionsGeminiRawObject(part, "functionCall.args", firstExistingInteractionResult(step, "arguments", "args"))
- return part
+ setInteractionsGeminiRawObjectDoc(partDoc, "functionCall.args", firstExistingInteractionResult(step, "arguments", "args"))
+ return partDoc.Bytes()
}
-func interactionsFunctionResponseStepToGeminiPart(step gjson.Result) []byte {
- part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
- part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String())
+func interactionsFunctionResponseStepToGeminiPart(step ojson.Result) []byte {
+ partDoc := ojson.NewDocString(`{"functionResponse":{"name":"","response":{}}}`)
+ _ = partDoc.Set("functionResponse.name", step.Get("name").String())
if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" {
- part, _ = sjson.SetBytes(part, "functionResponse.id", id)
+ _ = partDoc.Set("functionResponse.id", id)
}
- part = setInteractionsGeminiFunctionResponse(part, "functionResponse.response", firstExistingInteractionResult(step, "result", "response"))
- return part
+ setInteractionsGeminiFunctionResponseDoc(partDoc, "functionResponse.response", firstExistingInteractionResult(step, "result", "response"))
+ return partDoc.Bytes()
}
-func buildInteractionsGeminiChunk(st *interactionsToGeminiStreamState, modelName string, parts [][]byte, finishReason string, usage gjson.Result, includeEmptyPart bool) []byte {
- out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
+func buildInteractionsGeminiChunk(st *interactionsToGeminiStreamState, modelName string, parts [][]byte, finishReason string, usage ojson.Result, includeEmptyPart bool) []byte {
+ outDoc := ojson.NewDocString(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
if len(parts) == 0 && includeEmptyPart {
parts = append(parts, geminiTextPartJSON("", false))
}
@@ -287,49 +287,49 @@ func buildInteractionsGeminiChunk(st *interactionsToGeminiStreamState, modelName
}
}
if len(validParts) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "candidates.0.content.parts", validParts)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "candidates.0.content.parts", validParts)
}
if finishReason != "" {
- out, _ = sjson.SetBytes(out, "candidates.0.finishReason", finishReason)
+ _ = outDoc.Set("candidates.0.finishReason", finishReason)
}
if model := firstNonEmptyInteractionString(st.Model, modelName); model != "" {
- out, _ = sjson.SetBytes(out, "modelVersion", model)
+ _ = outDoc.Set("modelVersion", model)
}
if id := st.ID; id != "" {
- out, _ = sjson.SetBytes(out, "responseId", id)
+ _ = outDoc.Set("responseId", id)
}
if st.ServiceTier != "" {
- out, _ = sjson.SetBytes(out, "usageMetadata.serviceTier", st.ServiceTier)
+ _ = outDoc.Set("usageMetadata.serviceTier", st.ServiceTier)
}
- return setGeminiUsageMetadataFromInteractionsUsage(out, usage)
+ setGeminiUsageMetadataFromInteractionsUsageDoc(outDoc, usage)
+ return outDoc.Bytes()
}
-func setGeminiUsageMetadataFromInteractionsUsage(out []byte, usage gjson.Result) []byte {
+func setGeminiUsageMetadataFromInteractionsUsageDoc(outDoc *ojson.Doc, usage ojson.Result) {
if !usage.Exists() {
- return out
+ return
}
inputTokens, hasInputTokens := interactionsUsageInt(usage, "input_tokens", "total_input_tokens")
outputTokens, hasOutputTokens := interactionsUsageInt(usage, "output_tokens", "total_output_tokens")
totalTokens, hasTotalTokens := interactionsUsageInt(usage, "total_tokens")
if hasInputTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", inputTokens)
- out, _ = sjson.SetRawBytes(out, "usageMetadata.promptTokensDetails", []byte(fmt.Sprintf(`[{"modality":"TEXT","tokenCount":%d}]`, inputTokens)))
+ _ = outDoc.Set("usageMetadata.promptTokenCount", inputTokens)
+ _ = outDoc.SetRaw("usageMetadata.promptTokensDetails", fmt.Sprintf(`[{"modality":"TEXT","tokenCount":%d}]`, inputTokens))
}
if hasOutputTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", outputTokens)
+ _ = outDoc.Set("usageMetadata.candidatesTokenCount", outputTokens)
}
if hasTotalTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens)
+ _ = outDoc.Set("usageMetadata.totalTokenCount", totalTokens)
} else if hasInputTokens || hasOutputTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
+ _ = outDoc.Set("usageMetadata.totalTokenCount", inputTokens+outputTokens)
}
if thoughtTokens, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
- out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", thoughtTokens)
+ _ = outDoc.Set("usageMetadata.thoughtsTokenCount", thoughtTokens)
}
if cachedTokens, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
- out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens)
+ _ = outDoc.Set("usageMetadata.cachedContentTokenCount", cachedTokens)
}
- return out
}
func interactionsGeminiSSEPayload(rawJSON []byte) []byte {
@@ -358,7 +358,7 @@ func interactionsGeminiSSEPayload(rawJSON []byte) []byte {
return payload
}
-func interactionsUsageInt(usage gjson.Result, paths ...string) (int64, bool) {
+func interactionsUsageInt(usage ojson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if value := usage.Get(path); value.Exists() {
return value.Int(), true
@@ -367,48 +367,47 @@ func interactionsUsageInt(usage gjson.Result, paths ...string) (int64, bool) {
return 0, false
}
-func firstExistingInteractionResult(root gjson.Result, paths ...string) gjson.Result {
+func firstExistingInteractionResult(root ojson.Result, paths ...string) ojson.Result {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func setInteractionsGeminiRawObject(out []byte, path string, value gjson.Result) []byte {
+func setInteractionsGeminiRawObjectDoc(doc *ojson.Doc, path string, value ojson.Result) {
if !value.Exists() {
- out, _ = sjson.SetRawBytes(out, path, []byte(`{}`))
- return out
+ _ = doc.SetRaw(path, `{}`)
+ return
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
raw := strings.TrimSpace(value.String())
- if raw != "" && gjson.Valid(raw) {
- out, _ = sjson.SetRawBytes(out, path, []byte(raw))
- return out
+ if raw != "" && ojson.Valid(raw) {
+ _ = doc.SetRaw(path, raw)
+ return
}
}
if value.Raw != "" {
- out, _ = sjson.SetRawBytes(out, path, []byte(value.Raw))
+ _ = doc.SetRaw(path, value.Raw)
}
- return out
}
-func setInteractionsGeminiFunctionResponse(out []byte, path string, value gjson.Result) []byte {
+func setInteractionsGeminiFunctionResponseDoc(doc *ojson.Doc, path string, value ojson.Result) {
if !value.Exists() {
- out, _ = sjson.SetRawBytes(out, path, []byte(`{}`))
- return out
+ _ = doc.SetRaw(path, `{}`)
+ return
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
raw := strings.TrimSpace(value.String())
- if raw != "" && gjson.Valid(raw) {
- return translatorcommon.SetGeminiFunctionResponseRaw(out, path, raw)
+ if raw != "" && ojson.Valid(raw) {
+ translatorcommon.SetGeminiFunctionResponseRawDoc(doc, path, raw)
+ return
}
}
if value.Raw != "" {
- return translatorcommon.SetGeminiFunctionResponseResult(out, path, value)
+ translatorcommon.SetGeminiFunctionResponseResultDoc(doc, path, value)
}
- return out
}
func firstNonEmptyInteractionString(values ...string) string {
diff --git a/internal/translator/gemini/interactions/interactions_gemini_stream_lines_test.go b/internal/translator/gemini/interactions/interactions_gemini_stream_lines_test.go
index fb466134e61..f10f4f30643 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_stream_lines_test.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_stream_lines_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestGeminiInteractionsStreamSSEPayloadLines(t *testing.T) {
@@ -26,10 +26,10 @@ func TestGeminiInteractionsStreamSSEPayloadLines(t *testing.T) {
if got := string(eventTypes(out)); got != wantEvents {
t.Fatalf("events = %s, want %s", got, wantEvents)
}
- if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 0), "delta.text").String(); got != "OK" {
+ if got := ojson.GetBytesNoCopy(findNthEventPayload(out, "step.delta", 0), "delta.text").String(); got != "OK" {
t.Fatalf("text = %q, want OK", got)
}
- if got := gjson.GetBytes(findCompletedPayload(out), "interaction.usage.total_tokens").Int(); got != 15 {
+ if got := ojson.GetBytesNoCopy(findCompletedPayload(out), "interaction.usage.total_tokens").Int(); got != 15 {
t.Fatalf("total tokens = %d, want 15", got)
}
})
diff --git a/internal/translator/gemini/interactions/interactions_gemini_uri_test.go b/internal/translator/gemini/interactions/interactions_gemini_uri_test.go
index 8c13564b7ec..b654db11488 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_uri_test.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_uri_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const interactionsGeminiHistory = `{"type":"user_input","content":[{"type":"text","text":"a"}]},{"type":"model_output","content":[{"type":"text","text":"b"}]}`
@@ -35,7 +35,7 @@ func requireInteractionsToGeminiRefusal(t *testing.T, envelope sdktranslator.Req
if got, want := envelope.Err.Error(), "unsupported content part: "+wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
}
@@ -45,17 +45,17 @@ func TestGeminiFileDataSurvivesTheRoundTripThroughInteractions(t *testing.T) {
if interactions.Err != nil {
t.Fatalf("Gemini to Interactions: %v, body = %s", interactions.Err, interactions.Body)
}
- if got := gjson.GetBytes(interactions.Body, "input.2.content.0.uri").String(); got != "gs://b/a.png" {
- t.Fatalf("Interactions part = %s", gjson.GetBytes(interactions.Body, "input.2.content.0").Raw)
+ if got := ojson.GetBytesNoCopy(interactions.Body, "input.2.content.0.uri").String(); got != "gs://b/a.png" {
+ t.Fatalf("Interactions part = %s", ojson.GetBytesNoCopy(interactions.Body, "input.2.content.0").Raw)
}
envelope := interactionsToGeminiEnvelope(string(interactions.Body))
if envelope.Err != nil {
t.Fatalf("Interactions to Gemini: %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "contents.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "contents.#").Int(); got != 3 {
t.Fatalf("contents = %d, want 3: %s", got, envelope.Body)
}
- fileData := gjson.GetBytes(envelope.Body, "contents.2.parts.0.fileData")
+ fileData := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts.0.fileData")
if fileData.Get("fileUri").String() != "gs://b/a.png" || fileData.Get("mimeType").String() != "image/png" {
t.Fatalf("last turn lost the file: %s", envelope.Body)
}
@@ -68,7 +68,7 @@ func TestInteractionsToGeminiReadsURIAsFileURI(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- fileData := gjson.GetBytes(envelope.Body, "contents.2.parts.0.fileData")
+ fileData := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts.0.fileData")
if fileData.Get("fileUri").String() != "gs://b/a.pdf" || fileData.Get("mimeType").String() != "application/pdf" {
t.Fatalf("fileData = %s, body = %s", fileData.Raw, envelope.Body)
}
@@ -81,7 +81,7 @@ func TestInteractionsToGeminiInlineDataStillConverts(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- inlineData := gjson.GetBytes(envelope.Body, "contents.2.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts.0.inlineData")
if inlineData.Get("mimeType").String() != "image/png" || inlineData.Get("data").String() != "aGVsbG8=" {
t.Fatalf("inlineData = %s, body = %s", inlineData.Raw, envelope.Body)
}
@@ -122,10 +122,10 @@ func TestInteractionsToGeminiUnrepresentableAttachmentBesideTextStillSucceeds(t
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "contents.2.parts.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "contents.2.parts.#").Int(); got != 1 {
t.Fatalf("last turn has %d parts, want 1: %s", got, envelope.Body)
}
}
@@ -150,7 +150,7 @@ func TestInteractionsToGeminiNeighbouringStepKeepsTheTurnAlive(t *testing.T) {
func TestInteractionsToGeminiExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","input":[` + interactionsGeminiHistory + `,{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav"}]}]}`)
- if body, _ := ConvertInteractionsRequestToGemini("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertInteractionsRequestToGemini("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/gemini/interactions/interactions_gemini_user_turn_test.go b/internal/translator/gemini/interactions/interactions_gemini_user_turn_test.go
index 38006abb61a..4d8b62dbb8d 100644
--- a/internal/translator/gemini/interactions/interactions_gemini_user_turn_test.go
+++ b/internal/translator/gemini/interactions/interactions_gemini_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// geminiTurnToInteractions translates two history turns followed by a final user
@@ -30,7 +30,7 @@ func requireFileDataRefusal(t *testing.T, envelope sdktranslator.RequestEnvelope
if got, want := envelope.Err.Error(), "unsupported content part: fileData"; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(envelope.Body) {
+ if !ojson.ValidBytes(envelope.Body) {
t.Fatalf("body is not JSON: %q", envelope.Body)
}
}
@@ -77,10 +77,10 @@ func TestGeminiToInteractionsFileDataOnlyTurnKeepsTheFile(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.#").Int(); got != 3 {
t.Fatalf("input has %d steps, want 3: %s", got, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0")
if part.Get("type").String() != tc.wantType || part.Get("uri").String() != tc.wantURI || part.Get("mime_type").String() != tc.wantMIME {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -129,10 +129,10 @@ func TestGeminiToInteractionsFileDataWithoutURIBesideTextStillSucceeds(t *testin
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", envelope.Body)
}
- if got := gjson.GetBytes(envelope.Body, "input.#").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(envelope.Body, "input.#").Int(); got != 3 {
t.Fatalf("input has %d steps, want 3: %s", got, envelope.Body)
}
}
@@ -142,7 +142,7 @@ func TestGeminiToInteractionsInlineDataOnlyTurnStillConverts(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "input.2.content.0")
if part.Get("type").String() != "document" || part.Get("mime_type").String() != "application/pdf" || part.Get("data").String() != "JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -162,7 +162,7 @@ func TestGeminiToInteractionsModelFileDataWithoutURIIsNotARefusal(t *testing.T)
func TestGeminiToInteractionsExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"m","contents":[{"role":"user","parts":[{"fileData":{"mimeType":"application/pdf"}}]}]}`)
- if body, _ := ConvertGeminiRequestToInteractions("m", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertGeminiRequestToInteractions("m", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go
index 4259553d272..12a2d88f223 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_file_data_test.go
@@ -3,14 +3,14 @@ package chat_completions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIRequestToGeminiNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out, _ := ConvertOpenAIRequestToGemini("gemini-2.5-pro", input, false)
- inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
+ inlineData := ojson.GetBytesNoCopy(out, "contents.0.parts.0.inlineData")
if got := inlineData.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("inlineData.mime_type = %q, want application/pdf. Output: %s", got, out)
}
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_file_id_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_file_id_test.go
index 449bc61d895..d78f9712a2c 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_file_id_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_file_id_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestConvertOpenAIRequestToGemini_FileID(t *testing.T) {
@@ -39,7 +39,7 @@ func TestConvertOpenAIRequestToGemini_FileID(t *testing.T) {
if got.Err != nil {
t.Fatalf("err = %v", got.Err)
}
- if n := int(gjson.GetBytes(got.Body, "contents.0.parts.#").Int()); n != tc.wantParts {
+ if n := int(ojson.GetBytesNoCopy(got.Body, "contents.0.parts.#").Int()); n != tc.wantParts {
t.Fatalf("parts = %d, want %d. Output: %s", n, tc.wantParts, got.Body)
}
})
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_media_url_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_media_url_test.go
index 1773f167d9a..f5e69be8805 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_media_url_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_media_url_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -57,7 +57,7 @@ func TestConvertOpenAIRequestToGemini_RealTextBesideAnUnsendableAttachmentStillS
t.Fatalf("parts %s: err = %v, body = %s", parts, err, body)
}
found := false
- for _, part := range gjson.GetBytes(body, "contents.2.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(body, "contents.2.parts").Array() {
if part.Get("text").String() == "keep me" {
found = true
}
@@ -73,7 +73,7 @@ func TestConvertOpenAIRequestToGemini_DataURLImageStillConverts(t *testing.T) {
if err != nil {
t.Fatalf("err = %v, body = %s", err, body)
}
- part := gjson.GetBytes(body, "contents.2.parts.0.inlineData")
+ part := ojson.GetBytesNoCopy(body, "contents.2.parts.0.inlineData")
if part.Get("mime_type").String() != "image/png" || part.Get("data").String() != "iVBORw0KGgo=" {
t.Fatalf("data URL image was not inlined: %s", body)
}
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
index 46e16c3ca24..17dc7deeb88 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
@@ -5,19 +5,18 @@ package chat_completions
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const geminiFunctionThoughtSignature = "skip_thought_signature_validator"
// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)
-// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson.
+// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use ojson.
//
// Parameters:
// - modelName: The name of the model to use for the request
@@ -37,62 +36,62 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
rawJSON := inputRawJSON
var drops translatorcommon.UserTurnDrops
// Base envelope (no default thinkingConfig)
- out := []byte(`{"contents":[]}`)
+ outDoc := ojson.NewDocString(`{"contents":[]}`)
// Model
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Let user-provided generationConfig pass through
- if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(genConfig.Raw))
+ if genConfig := ojson.GetBytesNoCopy(rawJSON, "generationConfig"); genConfig.Exists() {
+ _ = outDoc.SetRaw("generationConfig", genConfig.Raw)
}
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini thinkingConfig.
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
- re := gjson.GetBytes(rawJSON, "reasoning_effort")
+ re := ojson.GetBytesNoCopy(rawJSON, "reasoning_effort")
if re.Exists() {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if effort != "" {
thinkingPath := "generationConfig.thinkingConfig"
if effort == "auto" {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
+ _ = outDoc.Set(thinkingPath+".thinkingBudget", -1)
} else {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
+ _ = outDoc.Set(thinkingPath+".thinkingLevel", effort)
}
}
}
// Temperature/top_p/top_k
- if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.temperature", tr.Num)
+ if tr := ojson.GetBytesNoCopy(rawJSON, "temperature"); tr.Exists() && tr.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.temperature", tr.Num)
}
- if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.topP", tpr.Num)
+ if tpr := ojson.GetBytesNoCopy(rawJSON, "top_p"); tpr.Exists() && tpr.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.topP", tpr.Num)
}
- if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.topK", tkr.Num)
+ if tkr := ojson.GetBytesNoCopy(rawJSON, "top_k"); tkr.Exists() && tkr.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.topK", tkr.Num)
}
// OpenAI max_tokens / max_completion_tokens -> Gemini generationConfig.maxOutputTokens
- if mt := gjson.GetBytes(rawJSON, "max_tokens"); mt.Exists() && mt.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mt.Num)
- } else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
- out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mct.Num)
+ if mt := ojson.GetBytesNoCopy(rawJSON, "max_tokens"); mt.Exists() && mt.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.maxOutputTokens", mt.Num)
+ } else if mct := ojson.GetBytesNoCopy(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == ojson.Number {
+ _ = outDoc.Set("generationConfig.maxOutputTokens", mct.Num)
}
// Candidate count (OpenAI 'n' parameter)
- if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number {
+ if n := ojson.GetBytesNoCopy(rawJSON, "n"); n.Exists() && n.Type == ojson.Number {
if val := n.Int(); val > 1 {
- out, _ = sjson.SetBytes(out, "generationConfig.candidateCount", val)
+ _ = outDoc.Set("generationConfig.candidateCount", val)
}
}
// Map OpenAI response_format to Gemini structured output settings.
- out = applyOpenAIResponseFormatToGemini(out, rawJSON)
+ applyOpenAIResponseFormatToGeminiDoc(outDoc, rawJSON)
// Map OpenAI modalities -> Gemini generationConfig.responseModalities
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
- if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
+ if mods := ojson.GetBytesNoCopy(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
var responseMods []string
for _, m := range mods.Array() {
switch strings.ToLower(m.String()) {
@@ -103,23 +102,23 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
}
if len(responseMods) > 0 {
- out, _ = sjson.SetBytes(out, "generationConfig.responseModalities", responseMods)
+ _ = outDoc.Set("generationConfig.responseModalities", responseMods)
}
}
// OpenRouter-style image_config support
// If the input uses top-level image_config.aspect_ratio, map it into generationConfig.imageConfig.aspectRatio.
- if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
- if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.aspectRatio", ar.Str)
+ if imgCfg := ojson.GetBytesNoCopy(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
+ if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == ojson.String {
+ _ = outDoc.Set("generationConfig.imageConfig.aspectRatio", ar.Str)
}
- if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.imageSize", size.Str)
+ if size := imgCfg.Get("image_size"); size.Exists() && size.Type == ojson.String {
+ _ = outDoc.Set("generationConfig.imageConfig.imageSize", size.Str)
}
}
// messages -> systemInstruction + contents
- messages := gjson.GetBytes(rawJSON, "messages")
+ messages := ojson.GetBytesNoCopy(rawJSON, "messages")
if messages.IsArray() {
arr := messages.Array()
systemParts := make([][]byte, 0, 2)
@@ -133,7 +132,7 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
if (role == "system" || role == "developer") && len(arr) > 1 && !hasEncounteredConversation {
// system -> systemInstruction as a user message style
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
systemParts = append(systemParts, geminiTextPart(content.String()))
} else if content.IsObject() && content.Get("type").String() == "text" {
systemParts = append(systemParts, geminiTextPart(content.Get("text").String()))
@@ -148,7 +147,7 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
isDemotedSystem := role == "system" || role == "developer"
// Build single user content node to avoid splitting into multiple contents.
partItems := make([][]byte, 0, 4)
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
partItems = append(partItems, geminiTextPart(geminiDemotedSystemText(content.String(), isDemotedSystem)))
} else if content.IsObject() && content.Get("type").String() == "text" {
partItems = append(partItems, geminiTextPart(geminiDemotedSystemText(content.Get("text").String(), isDemotedSystem)))
@@ -200,12 +199,12 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
} else if role == "assistant" {
hasEncounteredConversation = true
partItems := make([][]byte, 0, 4)
- if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
+ if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == ojson.String && reasoningContent.String() != "" {
part := geminiTextPart(reasoningContent.String())
- part, _ = sjson.SetBytes(part, "thought", true)
+ part, _ = ojson.SetBytes(part, "thought", true)
partItems = append(partItems, part)
}
- if content.Type == gjson.String && content.String() != "" {
+ if content.Type == ojson.String && content.String() != "" {
partItems = append(partItems, geminiTextPart(content.String()))
} else if content.IsArray() {
// Assistant multimodal content (e.g. text + image) -> single model content with parts.
@@ -244,11 +243,11 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
if functionName == "" {
continue
}
- part := []byte(`{"functionCall":{"name":""}}`)
- part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(tc.Get("function.arguments").String()))
- part, _ = sjson.SetBytes(part, "thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
- partItems = append(partItems, part)
+ partDoc := ojson.NewDocString(`{"functionCall":{"name":""}}`)
+ _ = partDoc.Set("functionCall.name", functionName)
+ _ = partDoc.SetRaw("functionCall.args", tc.Get("function.arguments").String())
+ _ = partDoc.Set("thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
+ partItems = append(partItems, partDoc.Bytes())
toolCalls = append(toolCalls, assistantToolCall{
id: functionID,
name: functionName,
@@ -277,14 +276,14 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
// Append a single tool content combining name + response per function.
responseParts := make([][]byte, 0, len(toolCalls))
for _, call := range toolCalls {
- part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
- part, _ = sjson.SetBytes(part, "functionResponse.name", call.name)
+ partDoc := ojson.NewDocString(`{"functionResponse":{"name":"","response":{"result":""}}}`)
+ _ = partDoc.Set("functionResponse.name", call.name)
response := turnToolResponses[call.id]
if response == "" {
response = "{}"
}
- part, _ = sjson.SetBytes(part, "functionResponse.response.result", []byte(response))
- responseParts = append(responseParts, part)
+ _ = partDoc.Set("functionResponse.response.result", []byte(response))
+ responseParts = append(responseParts, partDoc.Bytes())
}
if len(responseParts) > 0 {
contentItems = append(contentItems, geminiContentNode("user", responseParts))
@@ -297,19 +296,19 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
if len(systemParts) > 0 {
systemInstruction := geminiContentNode("user", systemParts)
- out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
+ _ = outDoc.SetRawBytes("systemInstruction", systemInstruction)
}
- if len(contentItems) > 0 && gjson.GetBytes(contentItems[len(contentItems)-1], "role").String() == "model" {
+ if len(contentItems) > 0 && ojson.GetBytesNoCopy(contentItems[len(contentItems)-1], "role").String() == "model" {
contentItems = contentItems[:len(contentItems)-1]
}
- out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "contents", contentItems)
}
// tools -> tools[].functionDeclarations + tools[].googleSearch/codeExecution/urlContext passthrough
allowedToolNames := make(map[string]struct{})
isAllowedTools := false
allowedMode := "auto"
- if toolChoice := gjson.GetBytes(rawJSON, "tool_choice"); toolChoice.Exists() && toolChoice.IsObject() && toolChoice.Get("type").String() == "allowed_tools" {
+ if toolChoice := ojson.GetBytesNoCopy(rawJSON, "tool_choice"); toolChoice.Exists() && toolChoice.IsObject() && toolChoice.Get("type").String() == "allowed_tools" {
isAllowedTools = true
toolList := toolChoice.Get("allowed_tools.tools").Array()
if len(toolList) == 0 {
@@ -336,8 +335,9 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
declaredOriginalToSanitized := make(map[string]string)
sanitizedToOriginalCounts := make(map[string]int)
var functionDeclarations [][]byte
+ var declaredSanitizedNames []string
hasStrictTool := false
- tools := gjson.GetBytes(rawJSON, "tools")
+ tools := ojson.GetBytesNoCopy(rawJSON, "tools")
toolResults := tools.Array()
if tools.IsArray() && len(toolResults) > 0 {
functionDeclarations = make([][]byte, 0, len(toolResults))
@@ -359,73 +359,59 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
sanitizedToOriginalCounts[sanitizedName]++
declaredOriginalToSanitized[originalName] = sanitizedName
fnRaw := fn.Raw
+ setDefaultSchema := false
if fn.Get("parameters").Exists() {
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
if errRename != nil {
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
- var errSet error
- fnRawBytes := []byte(fnRaw)
- fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
- if errSet != nil {
- log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
- continue
- }
- fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema.properties", []byte(`{}`))
- if errSet != nil {
- log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
- continue
- }
- fnRaw = string(fnRawBytes)
+ setDefaultSchema = true
} else {
fnRaw = renamed
}
} else {
- var errSet error
- fnRawBytes := []byte(fnRaw)
- fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
- if errSet != nil {
+ setDefaultSchema = true
+ }
+ fnDoc := ojson.NewDocString(fnRaw)
+ if setDefaultSchema {
+ if errSet := fnDoc.Set("parametersJsonSchema.type", "object"); errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
- fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema.properties", []byte(`{}`))
- if errSet != nil {
+ if errSet := fnDoc.SetRaw("parametersJsonSchema.properties", `{}`); errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
- fnRaw = string(fnRawBytes)
}
- fnRawBytes := []byte(fnRaw)
- if nameResult.Type != gjson.String || sanitizedName != originalName {
- fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", sanitizedName)
+ if nameResult.Type != ojson.String || sanitizedName != originalName {
+ _ = fnDoc.Set("name", sanitizedName)
}
- if parameters := gjson.GetBytes(fnRawBytes, "parametersJsonSchema"); parameters.Exists() {
+ if parameters := fn.Get("parameters"); parameters.Exists() {
cleanedParameters := util.CleanJSONSchemaForGeminiJSONSchema(parameters.Raw)
if cleanedParameters != parameters.Raw {
- fnRawBytes, _ = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema", []byte(cleanedParameters))
+ _ = fnDoc.SetRaw("parametersJsonSchema", cleanedParameters)
}
}
- strictVal := gjson.GetBytes(fnRawBytes, "strict")
+ strictVal := fn.Get("strict")
+ hasFnStrict := strictVal.Exists()
if !strictVal.Exists() {
- strictVal = fn.Get("strict")
- if !strictVal.Exists() {
- strictVal = t.Get("strict")
- }
+ strictVal = t.Get("strict")
}
if strictVal.Exists() {
- if strictVal.Type == gjson.True {
+ if strictVal.Type == ojson.True {
hasStrictTool = true
}
- if gjson.GetBytes(fnRawBytes, "strict").Exists() {
- fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict")
+ if hasFnStrict {
+ _ = fnDoc.Delete("strict")
}
}
- functionDeclarations = append(functionDeclarations, fnRawBytes)
+ functionDeclarations = append(functionDeclarations, fnDoc.Bytes())
+ declaredSanitizedNames = append(declaredSanitizedNames, sanitizedName)
}
}
if gs := t.Get("google_search"); gs.Exists() {
googleToolNode := []byte(`{}`)
var errSet error
- googleToolNode, errSet = sjson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
+ googleToolNode, errSet = ojson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
if errSet != nil {
log.Warnf("Failed to set googleSearch tool: %v", errSet)
continue
@@ -435,7 +421,7 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
if ce := t.Get("code_execution"); ce.Exists() {
codeToolNode := []byte(`{}`)
var errSet error
- codeToolNode, errSet = sjson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
+ codeToolNode, errSet = ojson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
if errSet != nil {
log.Warnf("Failed to set codeExecution tool: %v", errSet)
continue
@@ -445,7 +431,7 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
if uc := t.Get("url_context"); uc.Exists() {
urlToolNode := []byte(`{}`)
var errSet error
- urlToolNode, errSet = sjson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
+ urlToolNode, errSet = ojson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
if errSet != nil {
log.Warnf("Failed to set urlContext tool: %v", errSet)
continue
@@ -457,17 +443,17 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
if len(functionDeclarations) > 0 {
functionToolNode := []byte(`{"functionDeclarations":[]}`)
- functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
+ functionToolNode, _ = ojson.SetRawBytes(functionToolNode, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
toolItems = append(toolItems, functionToolNode)
}
toolItems = append(toolItems, googleSearchNodes...)
toolItems = append(toolItems, codeExecutionNodes...)
toolItems = append(toolItems, urlContextNodes...)
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
// Gemini rejects built-in tools combined with functionDeclarations
// unless server-side tool invocations are declared.
if len(functionDeclarations) > 0 && (len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0) {
- out, _ = sjson.SetBytes(out, "toolConfig.includeServerSideToolInvocations", true)
+ _ = outDoc.Set("toolConfig.includeServerSideToolInvocations", true)
}
}
}
@@ -483,27 +469,23 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
// tool_choice mapping
if hasSanitizedCollision {
// Ambiguous collision in function names: fail-closed to prevent invoking unintended tools.
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
} else if isAllowedTools {
if len(functionDeclarations) == 0 {
// Fail-closed when no allowed tools match or subset is empty.
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
} else if allowedMode == "required" || allowedMode == "any" {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
- allowedList := make([]string, 0, len(functionDeclarations))
- for _, fnRaw := range functionDeclarations {
- allowedList = append(allowedList, gjson.GetBytes(fnRaw, "name").String())
- }
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", allowedList)
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.allowedFunctionNames", declaredSanitizedNames)
} else if hasStrictTool {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "VALIDATED")
} else {
// Mode AUTO: functionDeclarations contains only allowed tools, mode is AUTO without allowedFunctionNames.
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "AUTO")
}
- } else if toolChoice := gjson.GetBytes(rawJSON, "tool_choice"); toolChoice.Exists() && toolChoice.Type != gjson.Null {
+ } else if toolChoice := ojson.GetBytesNoCopy(rawJSON, "tool_choice"); toolChoice.Exists() && toolChoice.Type != ojson.Null {
toolChoiceType := ""
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
toolChoiceType = strings.ToLower(strings.TrimSpace(toolChoice.String()))
} else if toolChoice.IsObject() {
toolChoiceType = strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String()))
@@ -512,14 +494,14 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
switch toolChoiceType {
case "auto":
if hasStrictTool {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "VALIDATED")
} else {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "AUTO")
}
case "none":
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
case "required", "any":
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "ANY")
case "function", "tool":
fnName := strings.TrimSpace(toolChoice.Get("function.name").String())
if fnName == "" {
@@ -527,58 +509,58 @@ func convertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
sanitized, declared := declaredOriginalToSanitized[fnName]
if declared && sanitizedToOriginalCounts[sanitized] == 1 {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{sanitized})
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "ANY")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.allowedFunctionNames", []string{sanitized})
} else {
// Missing, undeclared, or ambiguous: fail-closed.
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
}
default:
// Unrecognized tool_choice type: fail-closed.
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
}
} else if hasStrictTool && len(functionDeclarations) > 0 {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "VALIDATED")
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "VALIDATED")
}
// parallel_tool_calls handling:
// Gemini function calling has no parameter to disable parallel tool calls while keeping tools enabled.
// As a safe fail-closed measure when explicit restrictions cannot be faithfully expressed,
// when parallel_tool_calls is explicitly false, mode is set to NONE.
- if parallelToolCalls := gjson.GetBytes(rawJSON, "parallel_tool_calls"); parallelToolCalls.Type == gjson.False {
- out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
- out, _ = sjson.DeleteBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames")
+ if parallelToolCalls := ojson.GetBytesNoCopy(rawJSON, "parallel_tool_calls"); parallelToolCalls.Type == ojson.False {
+ _ = outDoc.Set("toolConfig.functionCallingConfig.mode", "NONE")
+ _ = outDoc.Delete("toolConfig.functionCallingConfig.allowedFunctionNames")
}
- out = common.AttachDefaultSafetySettings(out, "safetySettings")
+ common.AttachDefaultSafetySettingsDoc(outDoc, "safetySettings")
- return out, drops.Err()
+ return outDoc.Bytes(), drops.Err()
}
func geminiTextPart(text string) []byte {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
return part
}
func geminiInlineDataPart(mimeType, data, thoughtSignature string) []byte {
- part := []byte(`{"inlineData":{"mime_type":"","data":""}}`)
- part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
- part, _ = sjson.SetBytes(part, "inlineData.data", data)
+ partDoc := ojson.NewDocString(`{"inlineData":{"mime_type":"","data":""}}`)
+ _ = partDoc.Set("inlineData.mime_type", mimeType)
+ _ = partDoc.Set("inlineData.data", data)
if thoughtSignature != "" {
- part, _ = sjson.SetBytes(part, "thoughtSignature", thoughtSignature)
+ _ = partDoc.Set("thoughtSignature", thoughtSignature)
}
- return part
+ return partDoc.Bytes()
}
func geminiContentNode(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDocString(`{"role":"","parts":[]}`)
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
-func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string {
+func openAIToolCallGeminiThoughtSignature(toolCall ojson.Result) string {
for _, path := range []string{
"extra_content.google.thought_signature",
"function.extra_content.google.thought_signature",
@@ -615,26 +597,24 @@ func openAIInputAudioMimeType(audioFormat string) string {
}
}
-// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions structured output settings to Gemini.
+// applyOpenAIResponseFormatToGeminiDoc maps OpenAI Chat Completions structured output settings to Gemini.
// Response schemas pass through unchanged because the tool schema cleaner removes supported response fields.
-func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte {
- responseFormat := gjson.GetBytes(rawJSON, "response_format")
+func applyOpenAIResponseFormatToGeminiDoc(outDoc *ojson.Doc, rawJSON []byte) {
+ responseFormat := ojson.GetBytesNoCopy(rawJSON, "response_format")
if !responseFormat.Exists() {
- return out
+ return
}
switch strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())) {
case "json_object":
- out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
+ _ = outDoc.Set("generationConfig.responseMimeType", "application/json")
case "json_schema":
- out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
- out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
+ _ = outDoc.Set("generationConfig.responseMimeType", "application/json")
+ _ = outDoc.Delete("generationConfig.responseSchema")
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
- out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
+ _ = outDoc.SetRaw("generationConfig.responseJsonSchema", schema.Raw)
}
}
-
- return out
}
// geminiDemotedSystemText wraps a demoted mid-session system or developer
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go
index fbad6a13b7f..25ed8227c4d 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request_test.go
@@ -5,8 +5,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) {
@@ -19,7 +20,7 @@ func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
contents := resultJSON.Get("contents").Array()
if len(contents) != 1 {
@@ -45,7 +46,7 @@ func TestConvertOpenAIRequestToGeminiPreservesInputAudio(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
parts := resultJSON.Get("contents.0.parts").Array()
if len(parts) != 2 {
@@ -77,7 +78,7 @@ func TestConvertOpenAIRequestToGeminiPreservesVideoURL(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
parts := resultJSON.Get("contents.0.parts").Array()
if len(parts) != 2 {
@@ -120,22 +121,22 @@ func TestConvertOpenAIRequestToGeminiSkipsEmptyTextPartsWithoutNulls(t *testing.
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
- userParts := gjson.GetBytes(result, "contents.0.parts").Array()
+ userParts := ojson.GetBytesNoCopy(result, "contents.0.parts").Array()
if len(userParts) != 1 {
t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result)
}
- if userParts[0].Type == gjson.Null {
+ if userParts[0].Type == ojson.Null {
t.Fatalf("user parts.0 is null. Output: %s", result)
}
if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result)
}
- assistantParts := gjson.GetBytes(result, "contents.1.parts").Array()
+ assistantParts := ojson.GetBytesNoCopy(result, "contents.1.parts").Array()
if len(assistantParts) != 1 {
t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result)
}
- if assistantParts[0].Type == gjson.Null {
+ if assistantParts[0].Type == ojson.Null {
t.Fatalf("assistant parts.0 is null. Output: %s", result)
}
if !assistantParts[0].Get("functionCall").Exists() {
@@ -154,7 +155,7 @@ func TestConvertOpenAIRequestToGeminiPreservesReasoningContent(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
- contents := gjson.GetBytes(result, "contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "contents").Array()
if len(contents) != 3 {
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
}
@@ -185,7 +186,7 @@ func TestConvertOpenAIRequestToGeminiPreservesReasoningBeforeVisibleContentAndTo
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
- contents := gjson.GetBytes(result, "contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "contents").Array()
if len(contents) != 4 {
t.Fatalf("contents length = %d, want 4. Output: %s", len(contents), result)
}
@@ -224,7 +225,7 @@ func TestConvertOpenAIRequestToGeminiSkipsEmptyAssistantMessages(t *testing.T) {
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
- contents := gjson.GetBytes(result, "contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
}
@@ -243,7 +244,7 @@ func TestConvertOpenAIRequestToGemini_MidSessionDeveloperMessageDoesNotMutateSys
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
- output := gjson.ParseBytes(result)
+ output := util.ParseGJSONBytesNoCopy(result)
// systemInstruction must contain only original system prompt
sysParts := output.Get("systemInstruction.parts").Array()
@@ -287,7 +288,7 @@ func TestConvertOpenAIRequestToGemini_MidSessionSystemReminderEnvelope(t *testin
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
- output := gjson.ParseBytes(result)
+ output := util.ParseGJSONBytesNoCopy(result)
contents := output.Get("contents").Array()
if len(contents) != 4 {
@@ -325,8 +326,8 @@ func TestConvertOpenAIRequestToGemini_MidSessionTransientSystemInstructionPreser
outWith, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(turnWithTransient), false)
outWithout, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(turnWithoutTransient), false)
- contentsWith := gjson.GetBytes(outWith, "contents").Array()
- contentsWithout := gjson.GetBytes(outWithout, "contents").Array()
+ contentsWith := ojson.GetBytesNoCopy(outWith, "contents").Array()
+ contentsWithout := ojson.GetBytesNoCopy(outWithout, "contents").Array()
// Ensure demoted system instruction is standalone and not merged into adjacent user turn
if len(contentsWith) != 4 {
@@ -365,7 +366,7 @@ func TestConvertOpenAIRequestToGemini_MidSessionSystemReminderObjectAndArrayCont
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
- output := gjson.ParseBytes(result)
+ output := util.ParseGJSONBytesNoCopy(result)
contents := output.Get("contents").Array()
if len(contents) != 4 {
@@ -407,7 +408,7 @@ func TestConvertOpenAIRequestToGeminiMapsMaxTokens(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(tt.body), false)
- if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
+ if got := ojson.GetBytesNoCopy(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
t.Fatalf("generationConfig.maxOutputTokens = %d, want %d. Output: %s", got, tt.want, out)
}
})
@@ -437,7 +438,7 @@ func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T
}`
output, _ := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false)
- schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
+ schema := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
if !schema.Exists() {
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
@@ -481,7 +482,7 @@ func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchema(t *testing.T) {
}`
output, _ := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
- generationConfig := gjson.GetBytes(output, "generationConfig")
+ generationConfig := ojson.GetBytesNoCopy(output, "generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
@@ -510,7 +511,7 @@ func TestConvertOpenAIRequestToGeminiResponseFormatJSONObject(t *testing.T) {
}`
output, _ := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
- generationConfig := gjson.GetBytes(output, "generationConfig")
+ generationConfig := ojson.GetBytesNoCopy(output, "generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
@@ -531,7 +532,7 @@ func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchemaWithoutSchema(t *te
}`
output, _ := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
- generationConfig := gjson.GetBytes(output, "generationConfig")
+ generationConfig := ojson.GetBytesNoCopy(output, "generationConfig")
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
@@ -559,7 +560,7 @@ func TestConvertOpenAIRequestToGeminiResponseFormatNoOp(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, _ := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(tt.body), false)
- generationConfig := gjson.GetBytes(output, "generationConfig")
+ generationConfig := ojson.GetBytesNoCopy(output, "generationConfig")
if generationConfig.Get("responseMimeType").Exists() {
t.Fatalf("responseMimeType should not be set. Output: %s", output)
}
@@ -604,9 +605,9 @@ func TestConvertOpenAIRequestToGemini_MultiTurnRepeatedToolCallID_Issue5933(t *t
// In Turn 1 (contents[1] = model functionCall, contents[2] = user functionResponse):
// functionCall.name must be "glob", and functionResponse.name must be "glob".
- call1Name := gjson.GetBytes(out, "contents.1.parts.0.functionCall.name").String()
- resp1Name := gjson.GetBytes(out, "contents.2.parts.0.functionResponse.name").String()
- resp1Result := gjson.GetBytes(out, "contents.2.parts.0.functionResponse.response.result").String()
+ call1Name := ojson.GetBytesNoCopy(out, "contents.1.parts.0.functionCall.name").String()
+ resp1Name := ojson.GetBytesNoCopy(out, "contents.2.parts.0.functionResponse.name").String()
+ resp1Result := ojson.GetBytesNoCopy(out, "contents.2.parts.0.functionResponse.response.result").String()
if call1Name != "glob" {
t.Fatalf("turn 1 functionCall.name = %q, want glob", call1Name)
@@ -620,9 +621,9 @@ func TestConvertOpenAIRequestToGemini_MultiTurnRepeatedToolCallID_Issue5933(t *t
// In Turn 2 (contents[4] = model functionCall, contents[5] = user functionResponse):
// functionCall.name must be "read", and functionResponse.name must be "read".
- call2Name := gjson.GetBytes(out, "contents.4.parts.0.functionCall.name").String()
- resp2Name := gjson.GetBytes(out, "contents.5.parts.0.functionResponse.name").String()
- resp2Result := gjson.GetBytes(out, "contents.5.parts.0.functionResponse.response.result").String()
+ call2Name := ojson.GetBytesNoCopy(out, "contents.4.parts.0.functionCall.name").String()
+ resp2Name := ojson.GetBytesNoCopy(out, "contents.5.parts.0.functionResponse.name").String()
+ resp2Result := ojson.GetBytesNoCopy(out, "contents.5.parts.0.functionResponse.response.result").String()
if call2Name != "read" {
t.Fatalf("turn 2 functionCall.name = %q, want read", call2Name)
@@ -659,10 +660,10 @@ func TestConvertOpenAIRequestToGemini_ParallelAndOutOfOrderToolResponses(t *test
out, _ := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
- resp0Name := gjson.GetBytes(out, "contents.2.parts.0.functionResponse.name").String()
- resp0Result := gjson.GetBytes(out, "contents.2.parts.0.functionResponse.response.result").String()
- resp1Name := gjson.GetBytes(out, "contents.2.parts.1.functionResponse.name").String()
- resp1Result := gjson.GetBytes(out, "contents.2.parts.1.functionResponse.response.result").String()
+ resp0Name := ojson.GetBytesNoCopy(out, "contents.2.parts.0.functionResponse.name").String()
+ resp0Result := ojson.GetBytesNoCopy(out, "contents.2.parts.0.functionResponse.response.result").String()
+ resp1Name := ojson.GetBytesNoCopy(out, "contents.2.parts.1.functionResponse.name").String()
+ resp1Result := ojson.GetBytesNoCopy(out, "contents.2.parts.1.functionResponse.response.result").String()
if resp0Name != "tool_a" || resp0Result != `"res_a"` {
t.Fatalf("part 0 want tool_a / \"res_a\", got %s / %s", resp0Name, resp0Result)
@@ -687,8 +688,8 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
- allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
+ allowed := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if mode != "ANY" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'ANY', got %q. Output: %s", mode, result)
}
@@ -707,7 +708,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'NONE', got %q. Output: %s", mode, result)
}
@@ -723,7 +724,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "AUTO" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'AUTO', got %q. Output: %s", mode, result)
}
@@ -739,7 +740,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "ANY" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'ANY', got %q. Output: %s", mode, result)
}
@@ -756,7 +757,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'NONE', got %q. Output: %s", mode, result)
}
@@ -773,7 +774,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'NONE', got %q. Output: %s", mode, result)
}
@@ -790,7 +791,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "AUTO" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'AUTO', got %q. Output: %s", mode, result)
}
@@ -807,7 +808,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "AUTO" {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = 'AUTO', got %q. Output: %s", mode, result)
}
@@ -830,15 +831,15 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
- allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames")
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
+ allowed := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.allowedFunctionNames")
if mode != "AUTO" {
t.Fatalf("expected mode = 'AUTO', got %q. Output: %s", mode, result)
}
if allowed.Exists() {
t.Fatalf("expected allowedFunctionNames to not be set for AUTO mode, got %v", allowed.Value())
}
- decls := gjson.GetBytes(result, "tools.0.functionDeclarations").Array()
+ decls := ojson.GetBytesNoCopy(result, "tools.0.functionDeclarations").Array()
if len(decls) != 1 || decls[0].Get("name").String() != "tool_b" {
t.Fatalf("expected functionDeclarations to contain only tool_b, got %v", decls)
}
@@ -861,8 +862,8 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
- allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
+ allowed := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if mode != "ANY" {
t.Fatalf("expected mode = 'ANY', got %q. Output: %s", mode, result)
}
@@ -886,7 +887,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected mode = 'NONE', got %q. Output: %s", mode, result)
}
@@ -902,7 +903,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected mode = 'NONE', got %q. Output: %s", mode, result)
}
@@ -923,7 +924,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected mode = 'NONE' when exact original name not found, got %q. Output: %s", mode, result)
}
@@ -940,7 +941,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected mode = 'NONE' on name collision, got %q. Output: %s", mode, result)
}
@@ -956,7 +957,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "NONE" {
t.Fatalf("expected mode = 'NONE' for undeclared function, got %q. Output: %s", mode, result)
}
@@ -979,11 +980,11 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "AUTO" {
t.Fatalf("expected mode = 'AUTO', got %q. Output: %s", mode, result)
}
- decls := gjson.GetBytes(result, "tools.0.functionDeclarations").Array()
+ decls := ojson.GetBytesNoCopy(result, "tools.0.functionDeclarations").Array()
if len(decls) != 1 || decls[0].Get("name").String() != "_1tool" {
t.Fatalf("expected only _1tool to remain in functionDeclarations, got: %v", decls)
}
@@ -999,7 +1000,7 @@ func TestConvertOpenAIRequestToGemini_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- if gjson.GetBytes(result, "toolConfig").Exists() {
+ if ojson.GetBytesNoCopy(result, "toolConfig").Exists() {
t.Fatalf("expected toolConfig not to be set when tool_choice is null, got: %s", result)
}
})
@@ -1075,15 +1076,15 @@ func TestConvertOpenAIRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T
]
}`, tt.toolChoice)
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- if gjson.GetBytes(result, "tools.0.functionDeclarations.0.strict").Exists() {
+ if ojson.GetBytesNoCopy(result, "tools.0.functionDeclarations.0.strict").Exists() {
t.Fatalf("tools.0.functionDeclarations.0.strict should be removed from function declaration: %s", result)
}
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != tt.expectedMode {
t.Fatalf("expected toolConfig.functionCallingConfig.mode = %q, got %q. Output: %s", tt.expectedMode, mode, result)
}
if len(tt.allowedNames) > 0 {
- allowed := gjson.GetBytes(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
+ allowed := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
if len(allowed) != len(tt.allowedNames) {
t.Fatalf("expected %d allowedFunctionNames, got %d", len(tt.allowedNames), len(allowed))
}
@@ -1122,7 +1123,7 @@ func TestConvertOpenAIRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- mode := gjson.GetBytes(result, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(result, "toolConfig.functionCallingConfig.mode").String()
if mode != "VALIDATED" {
t.Fatalf("expected mode = 'VALIDATED' for mixed tools, got %q", mode)
}
@@ -1153,7 +1154,7 @@ func TestConvertOpenAIRequestToGemini_ToolStrictMapsToValidatedMode(t *testing.T
]
}`
result, _ := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- if gjson.GetBytes(result, "toolConfig").Exists() {
+ if ojson.GetBytesNoCopy(result, "toolConfig").Exists() {
t.Fatalf("expected toolConfig not to be set when no strict tools and no tool_choice, got: %s", result)
}
})
@@ -1190,11 +1191,11 @@ func TestConvertOpenAIRequestToGemini_ParametersJsonSchema_PreservesAdditionalPr
}`)
output, _ := ConvertOpenAIRequestToGemini("gemini-2.5-flash", inputJSON, false)
- schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
+ schema := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
if !schema.Exists() {
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
}
- if got := schema.Get("additionalProperties"); !got.Exists() || got.Type != gjson.False {
+ if got := schema.Get("additionalProperties"); !got.Exists() || got.Type != ojson.False {
t.Fatalf("additionalProperties should be preserved as false, got: %v. Schema: %s", got, schema.Raw)
}
if got := schema.Get("properties.recipient.pattern"); !got.Exists() || got.String() != "^(alice|bob)$" {
@@ -1223,16 +1224,16 @@ func TestConvertOpenAIRequestToGemini_MixedBuiltinToolsAndFunctions(t *testing.T
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if !gjson.GetBytes(out, "toolConfig.includeServerSideToolInvocations").Bool() {
+ if !ojson.GetBytesNoCopy(out, "toolConfig.includeServerSideToolInvocations").Bool() {
t.Fatalf("expected includeServerSideToolInvocations for mixed tools, got: %s", out)
}
- if mode := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); mode != "AUTO" {
+ if mode := ojson.GetBytesNoCopy(out, "toolConfig.functionCallingConfig.mode").String(); mode != "AUTO" {
t.Fatalf("functionCallingConfig.mode = %q, want AUTO, got: %s", mode, out)
}
- if !gjson.GetBytes(out, "tools.0.functionDeclarations.0.name").Exists() {
+ if !ojson.GetBytesNoCopy(out, "tools.0.functionDeclarations.0.name").Exists() {
t.Fatalf("expected functionDeclarations, got: %s", out)
}
- if !gjson.GetBytes(out, "tools.1."+builtin.output).Exists() {
+ if !ojson.GetBytesNoCopy(out, "tools.1."+builtin.output).Exists() {
t.Fatalf("expected built-in tool %s, got: %s", builtin.output, out)
}
})
@@ -1243,7 +1244,7 @@ func TestConvertOpenAIRequestToGemini_MixedBuiltinToolsAndFunctions(t *testing.T
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if gjson.GetBytes(out, "toolConfig.includeServerSideToolInvocations").Exists() {
+ if ojson.GetBytesNoCopy(out, "toolConfig.includeServerSideToolInvocations").Exists() {
t.Fatalf("includeServerSideToolInvocations must only be set for mixed tools, got: %s", out)
}
@@ -1252,7 +1253,7 @@ func TestConvertOpenAIRequestToGemini_MixedBuiltinToolsAndFunctions(t *testing.T
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if gjson.GetBytes(out, "toolConfig.includeServerSideToolInvocations").Exists() {
+ if ojson.GetBytesNoCopy(out, "toolConfig.includeServerSideToolInvocations").Exists() {
t.Fatalf("includeServerSideToolInvocations must only be set for mixed tools, got: %s", out)
}
}
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
index 3af4c5b6e8f..088399b67db 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
@@ -14,11 +14,10 @@ import (
"sync/atomic"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.
@@ -115,92 +114,104 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
for _, idx := range indices {
p.SawFinishReason[idx] = true
finishReason, nativeFinishReason := resolveGeminiOpenAIFinishReason(p.UpstreamFinishReason[idx], p.SawToolCall[idx])
- template := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
- template, _ = sjson.SetBytes(template, "created", p.UnixTimestamp)
+ templateDoc := ojson.NewDocString(`{"id":"","object":"chat.completion.chunk","created":0,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
+ _ = templateDoc.Set("created", p.UnixTimestamp)
if p.ModelVersion != "" {
- template, _ = sjson.SetBytes(template, "model", p.ModelVersion)
+ _ = templateDoc.Set("model", p.ModelVersion)
}
if p.ResponseID != "" {
- template, _ = sjson.SetBytes(template, "id", p.ResponseID)
+ _ = templateDoc.Set("id", p.ResponseID)
}
if len(p.PendingUsageMetadata) > 0 {
- template = applyGeminiOpenAIUsageMetadata(template, gjson.ParseBytes(p.PendingUsageMetadata))
+ applyGeminiOpenAIUsageMetadataDoc(templateDoc, util.ParseGJSONBytesNoCopy(p.PendingUsageMetadata))
}
- template, _ = sjson.SetBytes(template, "choices.0.index", idx)
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
- template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
- doneChunks = append(doneChunks, template)
+ _ = templateDoc.Set("choices.0.index", idx)
+ _ = templateDoc.Set("choices.0.finish_reason", finishReason)
+ _ = templateDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
+ doneChunks = append(doneChunks, templateDoc.Bytes())
}
return doneChunks
}
// Initialize the OpenAI SSE base template.
// We use a base template and clone it for each candidate to support multiple candidates.
- baseTemplate := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
+ baseDoc := ojson.NewDocString(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
// Extract and set the model version.
- if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
- p.ModelVersion = modelVersionResult.String()
- baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", p.ModelVersion)
+ if modelVersionResult := ojson.GetBytesNoCopy(rawJSON, "modelVersion"); modelVersionResult.Exists() {
+ p.ModelVersion = strings.Clone(modelVersionResult.String())
+ _ = baseDoc.Set("model", p.ModelVersion)
} else if p.ModelVersion != "" {
- baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", p.ModelVersion)
+ _ = baseDoc.Set("model", p.ModelVersion)
}
// Extract and set the creation timestamp.
- if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
+ if createTimeResult := ojson.GetBytesNoCopy(rawJSON, "createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
p.UnixTimestamp = t.Unix()
}
- baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
+ _ = baseDoc.Set("created", p.UnixTimestamp)
} else {
- baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
+ _ = baseDoc.Set("created", p.UnixTimestamp)
}
// Extract and set the response ID.
- if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
- p.ResponseID = responseIDResult.String()
- baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", p.ResponseID)
+ if responseIDResult := ojson.GetBytesNoCopy(rawJSON, "responseId"); responseIDResult.Exists() {
+ p.ResponseID = strings.Clone(responseIDResult.String())
+ _ = baseDoc.Set("id", p.ResponseID)
} else if p.ResponseID != "" {
- baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", p.ResponseID)
+ _ = baseDoc.Set("id", p.ResponseID)
}
// Extract and set usage metadata (token counts).
// Usage is applied to the base template so it appears in the chunks.
- if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
- baseTemplate = applyGeminiOpenAIUsageMetadata(baseTemplate, usageResult)
+ usageResult := ojson.GetBytesNoCopy(rawJSON, "usageMetadata")
+ usageExists := usageResult.Exists()
+ if usageExists {
+ applyGeminiOpenAIUsageMetadataDoc(baseDoc, usageResult)
p.PendingUsageMetadata = nil
- } else if pendingUsage := gjson.GetBytes(rawJSON, "cpaUsageMetadata"); pendingUsage.Exists() {
+ } else if pendingUsage := ojson.GetBytesNoCopy(rawJSON, "cpaUsageMetadata"); pendingUsage.Exists() {
p.PendingUsageMetadata = append(p.PendingUsageMetadata[:0], pendingUsage.Raw...)
}
+ baseTemplate := baseDoc.Bytes()
+
var responseStrings [][]byte
- candidates := gjson.GetBytes(rawJSON, "candidates")
+ candidates := ojson.GetBytesNoCopy(rawJSON, "candidates")
// Iterate over all candidates to support candidate_count > 1.
if candidates.IsArray() {
- candidates.ForEach(func(_, candidate gjson.Result) bool {
- // Clone the template for the current candidate.
- template := append([]byte(nil), baseTemplate...)
+ candidates.ForEach(func(_, candidate ojson.Result) bool {
+ // Create a Doc from baseTemplate for the current candidate.
+ templateDoc := ojson.NewDoc(baseTemplate)
// Set the specific index for this candidate.
candidateIndex := int(candidate.Get("index").Int())
- template, _ = sjson.SetBytes(template, "choices.0.index", candidateIndex)
+ _ = templateDoc.Set("choices.0.index", candidateIndex)
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
- p.UpstreamFinishReason[candidateIndex] = strings.ToUpper(finishReasonResult.String())
+ p.UpstreamFinishReason[candidateIndex] = strings.Clone(strings.ToUpper(finishReasonResult.String()))
}
partsResult := candidate.Get("content.parts")
assistantRoleSet := false
sawNoopFunctionCall := false
+ toolCallsInitialized := false
+ imagesCount := 0
setAssistantRole := func() {
if assistantRoleSet {
return
}
- template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
+ _ = templateDoc.Set("choices.0.delta.role", "assistant")
assistantRoleSet = true
}
+ ensureToolCallsArray := func() {
+ if !toolCallsInitialized {
+ _ = templateDoc.SetRaw("choices.0.delta.tool_calls", `[]`)
+ toolCallsInitialized = true
+ }
+ }
if partsResult.IsArray() {
partResults := partsResult.Array()
@@ -245,9 +256,9 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
setAssistantRole()
// Handle text content, distinguishing between regular content and reasoning/thoughts.
if partResult.Get("thought").Bool() {
- template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", text)
+ _ = templateDoc.Set("choices.0.delta.reasoning_content", text)
} else {
- template, _ = sjson.SetBytes(template, "choices.0.delta.content", text)
+ _ = templateDoc.Set("choices.0.delta.content", text)
}
} else if functionCallResult.Exists() {
// Handle function call content.
@@ -264,15 +275,12 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
p.ActiveToolCall[candidateIndex] = nil
}
if delta != "" {
- toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
- if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- }
- functionCallTemplate := []byte(`{"index":0,"function":{"arguments":""}}`)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", active.Index)
- functionCallTemplate, _ = translatorcommon.SetStringWithoutHTMLEscape(functionCallTemplate, "function.arguments", delta)
+ ensureToolCallsArray()
+ fcDoc := ojson.NewDocString(`{"index":0,"function":{"arguments":""}}`)
+ _ = fcDoc.Set("index", active.Index)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoc, "function.arguments", delta)
setAssistantRole()
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls.-1", fcDoc.Bytes())
} else {
sawNoopFunctionCall = true
}
@@ -281,14 +289,14 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
p.HasIncompleteToolCall[candidateIndex] = true
p.ActiveToolCall[candidateIndex] = nil
}
- rawName := strings.TrimSpace(functionCallResult.Get("name").String())
+ rawName := strings.Clone(strings.TrimSpace(functionCallResult.Get("name").String()))
if rawName == "" {
p.HasIncompleteToolCall[candidateIndex] = true
sawNoopFunctionCall = true
continue
}
- fcName := util.RestoreSanitizedToolName(p.SanitizedNameMap, rawName)
- upstreamID := strings.TrimSpace(functionCallResult.Get("id").String())
+ fcName := strings.Clone(util.RestoreSanitizedToolName(p.SanitizedNameMap, rawName))
+ upstreamID := strings.Clone(strings.TrimSpace(functionCallResult.Get("id").String()))
fcID := upstreamID
if fcID == "" {
fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))
@@ -307,18 +315,15 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
p.ActiveToolCall[candidateIndex] = active
}
- toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
- if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- }
+ ensureToolCallsArray()
- functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fcID)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
- functionCallTemplate, _ = translatorcommon.SetStringWithoutHTMLEscape(functionCallTemplate, "function.arguments", delta)
+ fcDoc := ojson.NewDocString(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`)
+ _ = fcDoc.Set("id", fcID)
+ _ = fcDoc.Set("index", functionCallIndex)
+ _ = fcDoc.Set("function.name", fcName)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoc, "function.arguments", delta)
setAssistantRole()
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls.-1", fcDoc.Bytes())
} else {
if p.ActiveToolCall[candidateIndex] != nil {
p.HasIncompleteToolCall[candidateIndex] = true
@@ -330,30 +335,27 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
continue
}
p.SawToolCall[candidateIndex] = true
- toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
// Retrieve the function index for this specific candidate.
functionCallIndex := p.FunctionIndex[candidateIndex]
p.FunctionIndex[candidateIndex]++
- if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
- }
+ ensureToolCallsArray()
- functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`)
+ fcDoc := ojson.NewDocString(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`)
fcName := util.RestoreSanitizedToolName(p.SanitizedNameMap, rawName)
fcID := strings.TrimSpace(functionCallResult.Get("id").String())
if fcID == "" {
fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))
}
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fcID)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
+ _ = fcDoc.Set("id", fcID)
+ _ = fcDoc.Set("index", functionCallIndex)
+ _ = fcDoc.Set("function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
+ _ = fcDoc.Set("function.arguments", fcArgsResult.Raw)
}
setAssistantRole()
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
+ _ = templateDoc.SetRawBytes("choices.0.delta.tool_calls.-1", fcDoc.Bytes())
}
} else if inlineDataResult.Exists() {
if p.ActiveToolCall[candidateIndex] != nil {
@@ -372,50 +374,52 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
- imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
- if !imagesResult.Exists() || !imagesResult.IsArray() {
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
+ if imagesCount == 0 {
+ _ = templateDoc.SetRaw("choices.0.delta.images", `[]`)
}
- imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
- imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
- imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
+ imageIndex := imagesCount
+ imagesCount++
+ imageDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = imageDoc.Set("index", imageIndex)
+ _ = imageDoc.Set("image_url.url", imageURL)
setAssistantRole()
- template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
+ _ = templateDoc.SetRawBytes("choices.0.delta.images.-1", imageDoc.Bytes())
}
}
}
upstreamFinishReason := p.UpstreamFinishReason[candidateIndex]
sawToolCall := p.SawToolCall[candidateIndex]
- usageExists := gjson.GetBytes(rawJSON, "usageMetadata").Exists()
isFinalChunk := upstreamFinishReason != "" && usageExists && p.ActiveToolCall[candidateIndex] == nil && !p.HasIncompleteToolCall[candidateIndex]
if isFinalChunk {
p.SawFinishReason[candidateIndex] = true
finishReason, nativeFinishReason := resolveGeminiOpenAIFinishReason(upstreamFinishReason, sawToolCall)
- template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
- template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", nativeFinishReason)
+ _ = templateDoc.Set("choices.0.finish_reason", finishReason)
+ _ = templateDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
}
if sawNoopFunctionCall && !assistantRoleSet && !isFinalChunk && !usageExists {
return true
}
- responseStrings = append(responseStrings, template)
+ responseStrings = append(responseStrings, templateDoc.Bytes())
return true // continue loop
})
} else {
// If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present.
- if gjson.GetBytes(rawJSON, "usageMetadata").Exists() && len(responseStrings) == 0 {
- chunk := append([]byte(nil), baseTemplate...)
+ if usageExists && len(responseStrings) == 0 {
if upstreamFinishReason := p.UpstreamFinishReason[0]; upstreamFinishReason != "" && !p.SawFinishReason[0] && p.ActiveToolCall[0] == nil && !p.HasIncompleteToolCall[0] {
p.SawFinishReason[0] = true
finishReason, nativeFinishReason := resolveGeminiOpenAIFinishReason(upstreamFinishReason, p.SawToolCall[0])
- chunk, _ = sjson.SetBytes(chunk, "choices.0.finish_reason", finishReason)
- chunk, _ = sjson.SetBytes(chunk, "choices.0.native_finish_reason", nativeFinishReason)
+ chunkDoc := ojson.NewDoc(baseTemplate)
+ _ = chunkDoc.Set("choices.0.finish_reason", finishReason)
+ _ = chunkDoc.Set("choices.0.native_finish_reason", nativeFinishReason)
+ responseStrings = append(responseStrings, chunkDoc.Bytes())
+ } else {
+ chunk := append([]byte(nil), baseTemplate...)
+ responseStrings = append(responseStrings, chunk)
}
- responseStrings = append(responseStrings, chunk)
}
}
@@ -439,58 +443,56 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
var unixTimestamp int64
// Initialize template with an empty choices array to support multiple candidates.
- template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`)
+ templateDoc := ojson.NewDocString(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`)
- if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
- template, _ = sjson.SetBytes(template, "model", modelVersionResult.String())
+ if modelVersionResult := ojson.GetBytesNoCopy(rawJSON, "modelVersion"); modelVersionResult.Exists() {
+ _ = templateDoc.Set("model", modelVersionResult.String())
}
- if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
+ if createTimeResult := ojson.GetBytesNoCopy(rawJSON, "createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
unixTimestamp = t.Unix()
}
- template, _ = sjson.SetBytes(template, "created", unixTimestamp)
+ _ = templateDoc.Set("created", unixTimestamp)
} else {
- template, _ = sjson.SetBytes(template, "created", unixTimestamp)
+ _ = templateDoc.Set("created", unixTimestamp)
}
- if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
- template, _ = sjson.SetBytes(template, "id", responseIDResult.String())
+ if responseIDResult := ojson.GetBytesNoCopy(rawJSON, "responseId"); responseIDResult.Exists() {
+ _ = templateDoc.Set("id", responseIDResult.String())
}
- if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int())
+ if usageResult := ojson.GetBytesNoCopy(rawJSON, "usageMetadata"); usageResult.Exists() {
+ _ = templateDoc.Set("usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int())
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
+ _ = templateDoc.Set("usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
+ _ = templateDoc.Set("usage.prompt_tokens", promptTokenCount)
if thoughtsTokenCount > 0 {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
+ _ = templateDoc.Set("usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
// Include cached token count if present (indicates prompt caching is working)
if cachedTokenCount > 0 {
- var err error
- template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
- if err != nil {
+ if err := templateDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokenCount); err != nil {
log.Warnf("gemini openai response: failed to set cached_tokens in non-streaming: %v", err)
}
}
}
// Process the main content part of the response for all candidates.
- candidates := gjson.GetBytes(rawJSON, "candidates")
+ candidates := ojson.GetBytesNoCopy(rawJSON, "candidates")
if candidates.IsArray() {
var choicesList [][]byte
- candidates.ForEach(func(_, candidate gjson.Result) bool {
+ candidates.ForEach(func(_, candidate ojson.Result) bool {
// Construct a single Choice object.
- choiceTemplate := []byte(`{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}`)
+ choiceDoc := ojson.NewDocString(`{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}`)
// Set the index for this choice.
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "index", candidate.Get("index").Int())
+ _ = choiceDoc.Set("index", candidate.Get("index").Int())
// Set finish reason.
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
@@ -498,8 +500,8 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
if isGeminiContentFilterFinishReason(finishReasonResult.String()) {
finishReason = "content_filter"
}
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", finishReason)
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", strings.ToLower(finishReasonResult.String()))
+ _ = choiceDoc.Set("finish_reason", finishReason)
+ _ = choiceDoc.Set("native_finish_reason", strings.ToLower(finishReasonResult.String()))
}
partsResult := candidate.Get("content.parts")
@@ -541,18 +543,18 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
} else if functionCallResult.Exists() {
// Append function call content to the tool_calls array.
hasFunctionCall = true
- functionCallItemTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ fcItemDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
fcName := util.RestoreSanitizedToolName(sanitizedNameMap, functionCallResult.Get("name").String())
fcID := strings.TrimSpace(functionCallResult.Get("id").String())
if fcID == "" {
fcID = fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1))
}
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fcID)
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", fcName)
+ _ = fcItemDoc.Set("id", fcID)
+ _ = fcItemDoc.Set("function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
+ _ = fcItemDoc.Set("function.arguments", fcArgsResult.Raw)
}
- toolCalls = append(toolCalls, functionCallItemTemplate)
+ toolCalls = append(toolCalls, fcItemDoc.Bytes())
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data != "" {
@@ -564,43 +566,43 @@ func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, origina
mimeType = "image/png"
}
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
- imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
- imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
- images = append(images, imagePayload)
+ imageDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = imageDoc.Set("index", len(images))
+ _ = imageDoc.Set("image_url.url", imageURL)
+ images = append(images, imageDoc.Bytes())
}
}
}
if hasTextContent {
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", textContent.String())
+ _ = choiceDoc.Set("message.content", textContent.String())
}
if hasReasoningContent {
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.reasoning_content", reasoningContent.String())
+ _ = choiceDoc.Set("message.reasoning_content", reasoningContent.String())
}
if len(toolCalls) > 0 {
- choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
+ _ = choiceDoc.SetRawBytes("message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
}
if len(images) > 0 {
- choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.images", translatorcommon.JoinRawArray(images))
+ _ = choiceDoc.SetRawBytes("message.images", translatorcommon.JoinRawArray(images))
}
}
if hasFunctionCall {
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", "tool_calls")
- choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", "tool_calls")
+ _ = choiceDoc.Set("finish_reason", "tool_calls")
+ _ = choiceDoc.Set("native_finish_reason", "tool_calls")
}
// Append the constructed choice to the main choices array.
- choicesList = append(choicesList, choiceTemplate)
+ choicesList = append(choicesList, choiceDoc.Bytes())
return true
})
if len(choicesList) > 0 {
- template = translatorcommon.SetRawArrayItems(template, "choices", choicesList)
+ translatorcommon.SetRawArrayItemsDoc(templateDoc, "choices", choicesList)
}
}
- return template
+ return templateDoc.Bytes()
}
func resolveGeminiOpenAIFinishReason(upstreamFinishReason string, sawToolCall bool) (finishReason, nativeFinishReason string) {
@@ -621,26 +623,23 @@ func resolveGeminiOpenAIFinishReason(upstreamFinishReason string, sawToolCall bo
return finishReason, nativeFinishReason
}
-func applyGeminiOpenAIUsageMetadata(template []byte, usageResult gjson.Result) []byte {
+func applyGeminiOpenAIUsageMetadataDoc(templateDoc *ojson.Doc, usageResult ojson.Result) {
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
- template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int())
+ _ = templateDoc.Set("usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int()+usageResult.Get("thoughtsTokenCount").Int())
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
- template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
+ _ = templateDoc.Set("usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
- template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
+ _ = templateDoc.Set("usage.prompt_tokens", promptTokenCount)
if thoughtsTokenCount > 0 {
- template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
+ _ = templateDoc.Set("usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
if cachedTokenCount > 0 {
- var err error
- template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
- if err != nil {
+ if err := templateDoc.Set("usage.prompt_tokens_details.cached_tokens", cachedTokenCount); err != nil {
log.Warnf("gemini openai response: failed to set cached_tokens in streaming: %v", err)
}
}
- return template
}
func isGeminiContentFilterFinishReason(reason string) bool {
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go
index bc05bdb3011..9bb578da756 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertGeminiResponseToOpenAICompletionTokensIncludeThoughts(t *testing.T) {
@@ -16,7 +16,7 @@ func TestConvertGeminiResponseToOpenAICompletionTokensIncludeThoughts(t *testing
if len(result) != 1 {
t.Fatalf("expected 1 result, got %d", len(result))
}
- completionTokens := gjson.GetBytes(result[0], "usage.completion_tokens")
+ completionTokens := ojson.GetBytesNoCopy(result[0], "usage.completion_tokens")
if !completionTokens.Exists() || completionTokens.Int() != 47 {
t.Fatalf("completion_tokens = %s, want present with value 47. Output: %s", completionTokens.Raw, result[0])
}
@@ -26,7 +26,7 @@ func TestConvertGeminiResponseToOpenAINonStreamCompletionTokensIncludeThoughts(t
response := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
- completionTokens := gjson.GetBytes(result, "usage.completion_tokens")
+ completionTokens := ojson.GetBytesNoCopy(result, "usage.completion_tokens")
if !completionTokens.Exists() || completionTokens.Int() != 42 {
t.Fatalf("completion_tokens = %s, want present with value 42. Output: %s", completionTokens.Raw, result)
}
@@ -41,7 +41,7 @@ func TestGeminiFinishReasonOnlyOnFinalChunk(t *testing.T) {
if len(result1) != 1 {
t.Fatalf("expected 1 result from chunk1, got %d", len(result1))
}
- fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
+ fr1 := ojson.GetBytesNoCopy(result1[0], "choices.0.finish_reason")
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
t.Fatalf("expected null finish_reason on tool chunk, got %v", fr1.String())
}
@@ -54,11 +54,11 @@ func TestGeminiFinishReasonOnlyOnFinalChunk(t *testing.T) {
if len(result3) != 1 {
t.Fatalf("expected 1 result from chunk3, got %d", len(result3))
}
- fr3 := gjson.GetBytes(result3[0], "choices.0.finish_reason").String()
+ fr3 := ojson.GetBytesNoCopy(result3[0], "choices.0.finish_reason").String()
if fr3 != "tool_calls" {
t.Fatalf("expected finish_reason tool_calls, got %s", fr3)
}
- nfr3 := gjson.GetBytes(result3[0], "choices.0.native_finish_reason").String()
+ nfr3 := ojson.GetBytesNoCopy(result3[0], "choices.0.native_finish_reason").String()
if nfr3 != "stop" {
t.Fatalf("expected native_finish_reason stop, got %s", nfr3)
}
@@ -68,13 +68,13 @@ func TestConvertGeminiResponseToOpenAINonStream_EmptyTextProducesEmptyString(t *
response := []byte(`{"candidates":[{"content":{"parts":[{"text":""},{"text":"","thought":true}]},"finishReason":"STOP"}]}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
- content := gjson.GetBytes(result, "choices.0.message.content")
- if !content.Exists() || content.String() != "" || content.Type == gjson.Null {
+ content := ojson.GetBytesNoCopy(result, "choices.0.message.content")
+ if !content.Exists() || content.String() != "" || content.Type == ojson.Null {
t.Fatalf("expected content to be empty string \"\", got %v (type %v)", content.Value(), content.Type)
}
- reasoning := gjson.GetBytes(result, "choices.0.message.reasoning_content")
- if !reasoning.Exists() || reasoning.String() != "" || reasoning.Type == gjson.Null {
+ reasoning := ojson.GetBytesNoCopy(result, "choices.0.message.reasoning_content")
+ if !reasoning.Exists() || reasoning.String() != "" || reasoning.Type == ojson.Null {
t.Fatalf("expected reasoning_content to be empty string \"\", got %v (type %v)", reasoning.Value(), reasoning.Type)
}
}
@@ -86,7 +86,7 @@ func TestConvertGeminiResponseToOpenAINonStream_AudioTranscriptionPartProducesCo
response := []byte(`{"candidates":[{"content":{"parts":[{"text":""},{"audioTranscription":{"text":"Hello world"}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":185,"totalTokenCount":185}}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
- content := gjson.GetBytes(result, "choices.0.message.content")
+ content := ojson.GetBytesNoCopy(result, "choices.0.message.content")
if !content.Exists() || content.String() != "Hello world" {
t.Fatalf("expected content to carry the transcript, got %v. Output: %s", content.Raw, result)
}
@@ -96,7 +96,7 @@ func TestConvertGeminiResponseToOpenAINonStream_SingleAudioTranscriptionPart(t *
response := []byte(`{"candidates":[{"content":{"parts":[{"audioTranscription":{"text":"Single transcription part"}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":185,"totalTokenCount":185}}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
- content := gjson.GetBytes(result, "choices.0.message.content")
+ content := ojson.GetBytesNoCopy(result, "choices.0.message.content")
if !content.Exists() || content.String() != "Single transcription part" {
t.Fatalf("expected content to carry the transcript, got %v. Output: %s", content.Raw, result)
}
@@ -112,7 +112,7 @@ func TestConvertGeminiResponseToOpenAI_AudioTranscriptionPartStreamsContent(t *t
t.Fatalf("expected 1 result, got %d", len(result))
}
- content := gjson.GetBytes(result[0], "choices.0.delta.content")
+ content := ojson.GetBytesNoCopy(result[0], "choices.0.delta.content")
if !content.Exists() || content.String() != "Testing one two three." {
t.Fatalf("expected delta.content to carry the transcript, got %v. Output: %s", content.Raw, result[0])
}
@@ -122,7 +122,7 @@ func TestConvertGeminiResponseToOpenAINonStream_TextPrecedenceOverAudioTranscrip
response := []byte(`{"candidates":[{"content":{"parts":[{"text":"explicit text","audioTranscription":{"text":"ignored transcription"}}]},"finishReason":"STOP"}]}`)
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
- content := gjson.GetBytes(result, "choices.0.message.content")
+ content := ojson.GetBytesNoCopy(result, "choices.0.message.content")
if !content.Exists() || content.String() != "explicit text" {
t.Fatalf("expected content to prioritize explicit text, got %v. Output: %s", content.Raw, result)
}
@@ -142,7 +142,7 @@ func TestConvertGeminiResponseToOpenAI_PinsSanitizedNameMapPerStream(t *testing.
if len(out) != 1 {
t.Fatalf("expected 1 chunk output, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "mcp/search" {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "mcp/search" {
t.Fatalf("restored tool name = %q, want mcp/search", got)
}
@@ -154,7 +154,7 @@ func TestConvertGeminiResponseToOpenAI_PinsSanitizedNameMapPerStream(t *testing.
if len(outNil) != 1 {
t.Fatalf("expected 1 chunk output, got %d", len(outNil))
}
- if got := gjson.GetBytes(outNil[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "mcp_search" {
+ if got := ojson.GetBytesNoCopy(outNil[0], "choices.0.delta.tool_calls.0.function.name").String(); got != "mcp_search" {
t.Fatalf("tool name = %q, want mcp_search (must not rebuild nil SanitizedNameMap on later chunks)", got)
}
}
@@ -208,10 +208,10 @@ func TestConvertGeminiResponseToOpenAI_NonStopFinishReasonsMapToContentFilter(t
if len(out) != 1 {
t.Fatalf("expected 1 chunk, got %d", len(out))
}
- if got := gjson.GetBytes(out[0], "choices.0.finish_reason").String(); got != tc.wantFinishReason {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.finish_reason").String(); got != tc.wantFinishReason {
t.Fatalf("stream finish_reason = %q, want %q; payload=%s", got, tc.wantFinishReason, out[0])
}
- if got := gjson.GetBytes(out[0], "choices.0.native_finish_reason").String(); got != tc.wantNativeReason {
+ if got := ojson.GetBytesNoCopy(out[0], "choices.0.native_finish_reason").String(); got != tc.wantNativeReason {
t.Fatalf("stream native_finish_reason = %q, want %q; payload=%s", got, tc.wantNativeReason, out[0])
}
})
@@ -219,10 +219,10 @@ func TestConvertGeminiResponseToOpenAI_NonStopFinishReasonsMapToContentFilter(t
t.Run("nonstream/"+tc.name, func(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"role":"model"},"finishReason":"` + tc.upstreamReason + `"}],"usageMetadata":{"promptTokenCount":481701,"candidatesTokenCount":16,"totalTokenCount":482881},"modelVersion":"gemini-4-argon","responseId":"EAjaaoCDC_Ooq8YP3f-2wAQ"}`)
out := ConvertGeminiResponseToOpenAINonStream(ctx, "gemini-4-argon", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != tc.wantFinishReason {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String(); got != tc.wantFinishReason {
t.Fatalf("non-stream finish_reason = %q, want %q; payload=%s", got, tc.wantFinishReason, out)
}
- if got := gjson.GetBytes(out, "choices.0.native_finish_reason").String(); got != tc.wantNativeReason {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.native_finish_reason").String(); got != tc.wantNativeReason {
t.Fatalf("non-stream native_finish_reason = %q, want %q; payload=%s", got, tc.wantNativeReason, out)
}
})
@@ -248,7 +248,7 @@ func TestConvertGeminiResponseToOpenAI_FinishReasonWithoutInlineUsage(t *testing
var terminals [][]byte
for _, c := range all {
- if gjson.GetBytes(c, "choices.0.finish_reason").Value() != nil {
+ if ojson.GetBytesNoCopy(c, "choices.0.finish_reason").Value() != nil {
terminals = append(terminals, c)
}
}
@@ -256,13 +256,13 @@ func TestConvertGeminiResponseToOpenAI_FinishReasonWithoutInlineUsage(t *testing
t.Fatalf("expected exactly 1 chunk with non-null finish_reason, got %d; chunks=%q", len(terminals), all)
}
terminal := terminals[0]
- if got := gjson.GetBytes(terminal, "choices.0.finish_reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(terminal, "choices.0.finish_reason").String(); got != "content_filter" {
t.Fatalf("finish_reason = %q, want content_filter; payload=%s", got, terminal)
}
- if got := gjson.GetBytes(terminal, "choices.0.native_finish_reason").String(); got != "malformed_response" {
+ if got := ojson.GetBytesNoCopy(terminal, "choices.0.native_finish_reason").String(); got != "malformed_response" {
t.Fatalf("native_finish_reason = %q, want malformed_response; payload=%s", got, terminal)
}
- if got := gjson.GetBytes(terminal, "usage.prompt_tokens").Int(); got != 100 {
+ if got := ojson.GetBytesNoCopy(terminal, "usage.prompt_tokens").Int(); got != 100 {
t.Fatalf("usage.prompt_tokens = %d, want 100; payload=%s", got, terminal)
}
})
@@ -280,7 +280,7 @@ func TestConvertGeminiResponseToOpenAI_FinishReasonWithoutInlineUsage(t *testing
var terminals [][]byte
for _, c := range all {
- if gjson.GetBytes(c, "choices.0.finish_reason").Value() != nil {
+ if ojson.GetBytesNoCopy(c, "choices.0.finish_reason").Value() != nil {
terminals = append(terminals, c)
}
}
@@ -288,13 +288,13 @@ func TestConvertGeminiResponseToOpenAI_FinishReasonWithoutInlineUsage(t *testing
t.Fatalf("expected exactly 1 chunk with non-null finish_reason on [DONE], got %d; chunks=%q", len(terminals), all)
}
terminal := terminals[0]
- if got := gjson.GetBytes(terminal, "choices.0.finish_reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(terminal, "choices.0.finish_reason").String(); got != "content_filter" {
t.Fatalf("finish_reason = %q, want content_filter; payload=%s", got, terminal)
}
- if got := gjson.GetBytes(terminal, "choices.0.native_finish_reason").String(); got != "malformed_response" {
+ if got := ojson.GetBytesNoCopy(terminal, "choices.0.native_finish_reason").String(); got != "malformed_response" {
t.Fatalf("native_finish_reason = %q, want malformed_response; payload=%s", got, terminal)
}
- if got := gjson.GetBytes(terminal, "usage.prompt_tokens").Int(); got != 200 {
+ if got := ojson.GetBytesNoCopy(terminal, "usage.prompt_tokens").Int(); got != 200 {
t.Fatalf("usage.prompt_tokens = %d, want 200; payload=%s", got, terminal)
}
if extraDone := ConvertGeminiResponseToOpenAI(ctx, "gemini-4-argon", nil, nil, []byte("[DONE]"), ¶m); len(extraDone) != 0 {
@@ -311,10 +311,10 @@ func TestConvertGeminiResponseToOpenAI_FinishReasonWithoutInlineUsage(t *testing
if len(done) != 1 {
t.Fatalf("expected 1 terminal chunk on [DONE], got %d (out1=%q done=%q)", len(done), out1, done)
}
- if got := gjson.GetBytes(done[0], "choices.0.finish_reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(done[0], "choices.0.finish_reason").String(); got != "content_filter" {
t.Fatalf("finish_reason = %q, want content_filter; payload=%s", got, done[0])
}
- if got := gjson.GetBytes(done[0], "choices.0.native_finish_reason").String(); got != "safety" {
+ if got := ojson.GetBytesNoCopy(done[0], "choices.0.native_finish_reason").String(); got != "safety" {
t.Fatalf("native_finish_reason = %q, want safety; payload=%s", got, done[0])
}
})
@@ -330,7 +330,7 @@ func TestConvertGeminiResponseToOpenAI_ParallelToolCallsAcrossChunks(t *testing.
var ids []string
for _, chunk := range [][]byte{chunk1, chunk2} {
for _, out := range ConvertGeminiResponseToOpenAI(ctx, "gemini-3-flash", nil, nil, chunk, ¶m) {
- for _, tc := range gjson.GetBytes(out, "choices.0.delta.tool_calls").Array() {
+ for _, tc := range ojson.GetBytesNoCopy(out, "choices.0.delta.tool_calls").Array() {
indices = append(indices, tc.Get("index").Int())
ids = append(ids, tc.Get("id").String())
}
@@ -362,7 +362,7 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
for chunkIdx, chunk := range chunks {
outs := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, chunk, ¶m)
for _, out := range outs {
- tcs := gjson.GetBytes(out, "choices.0.delta.tool_calls")
+ tcs := ojson.GetBytesNoCopy(out, "choices.0.delta.tool_calls")
if !tcs.Exists() || !tcs.IsArray() {
continue
}
@@ -413,10 +413,10 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
var finishReason string
for _, ch := range streamChunks {
for _, out := range ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, ch, &p) {
- if fr := gjson.GetBytes(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != gjson.Null {
+ if fr := ojson.GetBytesNoCopy(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != ojson.Null {
finishReason = fr.String()
}
- for _, tc := range gjson.GetBytes(out, "choices.0.delta.tool_calls").Array() {
+ for _, tc := range ojson.GetBytesNoCopy(out, "choices.0.delta.tool_calls").Array() {
idx := tc.Get("index").Int()
if id := tc.Get("id").String(); id != "" {
idsByIndex[idx] = id
@@ -446,14 +446,14 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
var p1 any
cPing := []byte(`{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"ping","willContinue":false}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"totalTokenCount":7},"modelVersion":"gemini-2.5-pro","responseId":"resp-ping"}`)
outs1 := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, cPing, &p1)
- if got := gjson.GetBytes(outs1[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "{}" {
+ if got := ojson.GetBytesNoCopy(outs1[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != "{}" {
t.Fatalf("ping arguments = %q, want {}", got)
}
var p2 any
cArgs := []byte(`{"candidates":[{"index":0,"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"Paris"},"partialArgs":[]}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2,"totalTokenCount":7},"modelVersion":"gemini-2.5-pro","responseId":"resp-args"}`)
outs2 := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, cArgs, &p2)
- if got := gjson.GetBytes(outs2[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"city":"Paris"}` {
+ if got := ojson.GetBytesNoCopy(outs2[0], "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"city":"Paris"}` {
t.Fatalf("args fallback = %q", got)
}
})
@@ -470,13 +470,13 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
var idCount int
for _, ch := range chunks {
for _, out := range ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, ch, &p) {
- for _, tc := range gjson.GetBytes(out, "choices.0.delta.tool_calls").Array() {
+ for _, tc := range ojson.GetBytesNoCopy(out, "choices.0.delta.tool_calls").Array() {
if tc.Get("id").String() != "" {
idCount++
}
argBuf.WriteString(tc.Get("function.arguments").String())
}
- if fr := gjson.GetBytes(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != gjson.Null {
+ if fr := ojson.GetBytesNoCopy(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != ojson.Null {
finishReason = fr.String()
}
}
@@ -505,10 +505,10 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
var finishReason string
for _, ch := range chunks {
for _, out := range ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, ch, &p) {
- for _, tc := range gjson.GetBytes(out, "choices.0.delta.tool_calls").Array() {
+ for _, tc := range ojson.GetBytesNoCopy(out, "choices.0.delta.tool_calls").Array() {
argBuf.WriteString(tc.Get("function.arguments").String())
}
- if fr := gjson.GetBytes(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != gjson.Null {
+ if fr := ojson.GetBytesNoCopy(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != ojson.Null {
finishReason = fr.String()
}
}
@@ -530,7 +530,7 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
all = append(all, ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, c2, &p)...)
all = append(all, ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, []byte("[DONE]"), &p)...)
for _, out := range all {
- if fr := gjson.GetBytes(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != gjson.Null {
+ if fr := ojson.GetBytesNoCopy(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != ojson.Null {
t.Fatalf("unexpected finish_reason when call_a never completed: %s", out)
}
}
@@ -543,13 +543,36 @@ func TestConvertGeminiResponseToOpenAI_VertexStreamingPartialArgs(t *testing.T)
doneOuts := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-pro", nil, nil, []byte("[DONE]"), &p)
all := append(outs, doneOuts...)
for _, out := range all {
- if fr := gjson.GetBytes(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != gjson.Null {
+ if fr := ojson.GetBytesNoCopy(out, "choices.0.finish_reason"); fr.Exists() && fr.Type != ojson.Null {
t.Fatalf("unexpected finish_reason on interrupted tool call stream: %s", out)
}
}
})
}
+func TestConvertGeminiResponseToOpenAI_InputBufferReuse(t *testing.T) {
+ ctx := context.Background()
+ var param any
+ buf := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"hello"}]},"finishReason":"STOP"}],"modelVersion":"gemini-2.5-flash","responseId":"resp_buf_1"}`)
+ ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-flash", nil, nil, buf, ¶m)
+ for i := range buf {
+ buf[i] = 'X'
+ }
+ done := ConvertGeminiResponseToOpenAI(ctx, "gemini-2.5-flash", nil, nil, []byte("[DONE]"), ¶m)
+ if len(done) != 1 {
+ t.Fatalf("expected 1 terminal chunk on [DONE], got %d", len(done))
+ }
+ if got := ojson.GetBytesNoCopy(done[0], "id").String(); got != "resp_buf_1" {
+ t.Fatalf("id = %q, want resp_buf_1", got)
+ }
+ if got := ojson.GetBytesNoCopy(done[0], "model").String(); got != "gemini-2.5-flash" {
+ t.Fatalf("model = %q, want gemini-2.5-flash", got)
+ }
+ if got := ojson.GetBytesNoCopy(done[0], "choices.0.finish_reason").String(); got != "stop" {
+ t.Fatalf("finish_reason = %q, want stop; payload=%s", got, done[0])
+ }
+}
+
func BenchmarkConvertGeminiResponseToOpenAI_TextDelta(b *testing.B) {
request := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"` + strings.Repeat("x", 1024*1024) + `"}]}`)
delta := []byte(`{"candidates":[{"content":{"parts":[{"text":"token "}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_bench"}`)
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go
index 5b2de083d4b..febbf1b0f2b 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_signature_test.go
@@ -3,8 +3,8 @@ package chat_completions
import (
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
)
const capturedGeminiToolCallThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
@@ -43,7 +43,7 @@ func TestConvertOpenAIRequestToGemini_ToolCallSignatureCompatibility(t *testing.
}`)
output, _ := ConvertOpenAIRequestToGemini("gemini-3.5-flash", input, false)
- if got := gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature {
+ if got := ojson.GetBytesNoCopy(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature {
t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output)
}
})
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_user_turn_test.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_user_turn_test.go
index f92dd1953ee..ba2879dd83f 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_user_turn_test.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_user_turn_test.go
@@ -4,8 +4,8 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
)
const (
@@ -46,7 +46,7 @@ func TestConvertOpenAIRequestToGemini_RefusesAnyEmptiedUserTurn(t *testing.T) {
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -59,7 +59,7 @@ func TestConvertOpenAIRequestToGemini_KeepsTurnWithTextBesideAttachment(t *testi
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -70,7 +70,7 @@ func TestConvertOpenAIRequestToGemini_InlineFileAfterHistoryStaysInlineData(t *t
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "contents.2.parts.0.inlineData.mime_type").String(); got != "application/pdf" {
+ if got := ojson.GetBytesNoCopy(body, "contents.2.parts.0.inlineData.mime_type").String(); got != "application/pdf" {
t.Fatalf("inline file was not kept: %s", body)
}
}
diff --git a/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go b/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go
index 817424b8f1a..31dfa4d7695 100644
--- a/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go
+++ b/internal/translator/gemini/openai/chat-completions/noop_optimization_test.go
@@ -4,7 +4,7 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIRequestToGeminiNormalizesToolNameAndStrict(t *testing.T) {
@@ -12,11 +12,11 @@ func TestConvertOpenAIRequestToGeminiNormalizesToolNameAndStrict(t *testing.T) {
output, _ := ConvertOpenAIRequestToGemini("gemini-test", input, false)
- name := gjson.GetBytes(output, "tools.0.functionDeclarations.0.name")
- if name.Type != gjson.String || name.String() != "true" {
+ name := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.name")
+ if name.Type != ojson.String || name.String() != "true" {
t.Fatalf("tool name = %s, want string true", name.Raw)
}
- if gjson.GetBytes(output, "tools.0.functionDeclarations.0.strict").Exists() {
+ if ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.strict").Exists() {
t.Fatal("strict should be removed")
}
}
@@ -26,7 +26,7 @@ func TestConvertGeminiResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T)
output := ConvertGeminiResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil)
- if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" {
+ if role := ojson.GetBytesNoCopy(output, "choices.0.message.role").String(); role != "assistant" {
t.Fatalf("role = %q, want assistant", role)
}
}
@@ -40,16 +40,16 @@ func TestConvertGeminiResponseToOpenAIStreamingSetsAssistantRoleOnce(t *testing.
if len(outputs) != 1 {
t.Fatalf("output count = %d, want 1", len(outputs))
}
- if role := gjson.GetBytes(outputs[0], "choices.0.delta.role").String(); role != "assistant" {
+ if role := ojson.GetBytesNoCopy(outputs[0], "choices.0.delta.role").String(); role != "assistant" {
t.Fatalf("role = %q, want assistant", role)
}
- if got := gjson.GetBytes(outputs[0], "choices.0.delta.content").String(); got != "hello" {
+ if got := ojson.GetBytesNoCopy(outputs[0], "choices.0.delta.content").String(); got != "hello" {
t.Fatalf("content = %q, want hello", got)
}
- if !gjson.GetBytes(outputs[0], "choices.0.delta.tool_calls.0").Exists() {
+ if !ojson.GetBytesNoCopy(outputs[0], "choices.0.delta.tool_calls.0").Exists() {
t.Fatal("tool call should be present")
}
- if !gjson.GetBytes(outputs[0], "choices.0.delta.images.0").Exists() {
+ if !ojson.GetBytesNoCopy(outputs[0], "choices.0.delta.images.0").Exists() {
t.Fatal("image should be present")
}
}
diff --git a/internal/translator/gemini/openai/responses/apply_patch_review_test.go b/internal/translator/gemini/openai/responses/apply_patch_review_test.go
index 2bbf5b416e9..09e6860fb88 100644
--- a/internal/translator/gemini/openai/responses/apply_patch_review_test.go
+++ b/internal/translator/gemini/openai/responses/apply_patch_review_test.go
@@ -6,7 +6,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const geminiMixedPatchReviewRequest = `{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`
@@ -23,9 +24,9 @@ func geminiPatchReviewFrame(parts []string, terminal bool) []byte {
return []byte(`{"candidates":[{"content":{"parts":[` + strings.Join(parts, ",") + `]}` + finish + `}]}`)
}
-func geminiPatchReviewEvents(t *testing.T, chunks [][]byte) []gjson.Result {
+func geminiPatchReviewEvents(t *testing.T, chunks [][]byte) []ojson.Result {
t.Helper()
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range chunks {
_, event := parseSSEEvent(t, chunk)
events = append(events, event)
@@ -68,10 +69,10 @@ func TestGeminiApplyPatchCrossKeyEvidenceConflict(t *testing.T) {
}
return
}
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return geminiPatchReviewEvents(t, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", request, nil, raw, ¶m))
}
- var events []gjson.Result
+ var events []ojson.Result
if mode == "stream single frame" {
events = send(geminiPatchReviewFrame(parts, true))
} else {
@@ -143,13 +144,13 @@ func TestGeminiApplyPatchCrossKeyOrdinaryLegacy(t *testing.T) {
names = append(names, name)
arguments = append(arguments, `{"x":3}`)
var param any
- var events []gjson.Result
+ var events []ojson.Result
for i, part := range parts {
events = append(events, geminiPatchReviewEvents(t, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(request.request), nil, geminiPatchReviewFrame([]string{part}, i == len(parts)-1), ¶m))...)
}
events = append(events, geminiPatchReviewEvents(t, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(request.request), nil, []byte("[DONE]"), ¶m))...)
counts := map[string]int{}
- var final gjson.Result
+ var final ojson.Result
for _, event := range events {
counts[event.Get("type").String()]++
if event.Get("type").String() == "response.completed" {
@@ -161,7 +162,7 @@ func TestGeminiApplyPatchCrossKeyOrdinaryLegacy(t *testing.T) {
}
var nonStream any
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(request.request), nil, geminiPatchReviewFrame(parts, true), &nonStream)
- for _, output := range []gjson.Result{final, gjson.ParseBytes(out)} {
+ for _, output := range []ojson.Result{final, util.ParseGJSONBytesNoCopy(out)} {
if output.Get("output.#").Int() != 3 {
t.Fatalf("ordinary calls consolidated or rejected: %s", output.Raw)
}
diff --git a/internal/translator/gemini/openai/responses/apply_patch_test.go b/internal/translator/gemini/openai/responses/apply_patch_test.go
index cce958ec5d4..c9476e5acc4 100644
--- a/internal/translator/gemini/openai/responses/apply_patch_test.go
+++ b/internal/translator/gemini/openai/responses/apply_patch_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const geminiPatchRequest = `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}]}`
@@ -19,7 +19,7 @@ func TestGeminiApplyPatchCompleteArgumentsLifecycle(t *testing.T) {
out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.1-pro", []byte(geminiPatchRequest), nil, raw, ¶m)
out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.1-pro", []byte(geminiPatchRequest), nil, []byte(`[DONE]`), ¶m)...)
var delta strings.Builder
- var done, item, final gjson.Result
+ var done, item, final ojson.Result
for _, chunk := range out {
kind, ev := parseSSEEvent(t, chunk)
switch kind {
@@ -98,7 +98,7 @@ func TestGeminiApplyPatchCompleteSnapshotMatrix(t *testing.T) {
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
for _, input := range test.inputs {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, input, ¶m) {
_, ev := parseSSEEvent(t, chunk)
@@ -147,7 +147,7 @@ func TestGeminiApplyPatchWinningDeclarationAndNegativeCompatibility(t *testing.T
t.Run(test.name, func(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"` + test.upstream + `","args":` + test.args + `}}]},"finishReason":"STOP"}]}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini", []byte(test.request), nil, raw, nil)
- item := gjson.GetBytes(out, "output.0")
+ item := ojson.GetBytesNoCopy(out, "output.0")
if item.Get("type").String() != test.wantType || item.Get("input").String() != test.wantInput {
t.Fatalf("compatibility changed: %s", out)
}
@@ -199,9 +199,9 @@ func TestGeminiApplyPatchHistoryPreservesWhitespaceAndToolPair(t *testing.T) {
request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]}],"input":[{"type":"custom_tool_call","call_id":"c1","namespace":"functions","name":"apply_patch","input":" *** Begin Patch\n*** Add File: 中.txt\n+😀\n*** End Patch\n "},{"type":"custom_tool_call_output","call_id":"c1","output":"ok"},{"role":"user","type":"message","content":"continue"}]}`)
out, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-pro-preview", request, false)
want := " *** Begin Patch\n*** Add File: 中.txt\n+😀\n*** End Patch\n "
- var call, result gjson.Result
- gjson.GetBytes(out, "contents").ForEach(func(_, content gjson.Result) bool {
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ var call, result ojson.Result
+ ojson.GetBytesNoCopy(out, "contents").ForEach(func(_, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
call = part.Get("functionCall")
}
@@ -222,7 +222,7 @@ func TestGeminiApplyPatchDoesNotRebindOrdinaryFunctionCalls(t *testing.T) {
request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"first"},{"type":"function","name":"second"}]}`)
for _, name := range []string{"first", "second"} {
chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", request, nil, []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"id":"reused","name":"`+name+`","args":{"n":1}}}]}}]}`), ¶m)
- var item gjson.Result
+ var item ojson.Result
for _, chunk := range chunks {
kind, ev := parseSSEEvent(t, chunk)
if kind == "response.output_item.done" {
@@ -239,7 +239,7 @@ func TestGeminiApplyPatchDistinctUnkeyedCompleteCalls(t *testing.T) {
var param any
raw := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"functions__apply_patch","args":{"input":"a"}}},{"functionCall":{"name":"functions__apply_patch","args":{"input":"b"}}}]},"finishReason":"STOP"}]}`)
inputs := map[string]string{}
- var final gjson.Result
+ var final ojson.Result
streamEvents := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, raw, ¶m)
streamEvents = append(streamEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini", []byte(geminiPatchRequest), nil, []byte("[DONE]"), ¶m)...)
for _, chunk := range streamEvents {
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
index 62239c88070..fb2d4382412 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
@@ -1,19 +1,18 @@
package responses
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"net/url"
"path/filepath"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const geminiResponsesThoughtSignature = "skip_thought_signature_validator"
@@ -31,9 +30,9 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
_ = stream // Unused but required by interface
// Base Gemini API template (do not include thinkingConfig by default)
- out := []byte(`{"contents":[]}`)
+ outDoc := ojson.NewDocString(`{"contents":[]}`)
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Extract tools and forward map early so request contents and toolDeclarations use the exact same forward map
functionDeclarations, forwardMap, _ := util.BuildGeminiFunctionDeclarations(root)
@@ -43,7 +42,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
googleSearchBlock := []byte(`{"googleSearch":{}}`)
if allowedDomains := ExtractResponsesWebSearchAllowedDomains(root); len(allowedDomains) > 0 {
if domainsJSON, errMarshal := json.Marshal(allowedDomains); errMarshal == nil {
- googleSearchBlock, _ = sjson.SetRawBytes(googleSearchBlock, "googleSearch.includedDomains", domainsJSON)
+ googleSearchBlock, _ = ojson.SetRawBytes(googleSearchBlock, "googleSearch.includedDomains", domainsJSON)
}
}
toolBlocks = append(toolBlocks, googleSearchBlock)
@@ -51,32 +50,32 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
if len(functionDeclarations) > 0 {
fnBlock := []byte(`{"functionDeclarations":[]}`)
- fnBlock, _ = sjson.SetRawBytes(fnBlock, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
+ fnBlock, _ = ojson.SetRawBytes(fnBlock, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
toolBlocks = append(toolBlocks, fnBlock)
}
if len(toolBlocks) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolBlocks))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolBlocks))
}
// Handle tool_choice if present (only configure function calling when function declarations exist)
if len(functionDeclarations) > 0 {
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
if toolConfig, ok := util.ConvertResponsesToolChoiceToGemini(toolChoice, forwardMap); ok {
- out, _ = sjson.SetRawBytes(out, "toolConfig.functionCallingConfig", toolConfig)
+ _ = outDoc.SetRawBytes("toolConfig.functionCallingConfig", toolConfig)
}
}
}
// Gemini rejects built-in googleSearch combined with functionDeclarations
// unless server-side tool invocations are declared.
if hasGoogleSearch && len(functionDeclarations) > 0 {
- out, _ = sjson.SetBytes(out, "toolConfig.includeServerSideToolInvocations", true)
+ _ = outDoc.Set("toolConfig.includeServerSideToolInvocations", true)
}
// Extract system instruction from OpenAI "instructions" field.
systemParts := make([][]byte, 0, 2)
if instructions := root.Get("instructions"); instructions.Exists() {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", instructions.String())
+ part, _ = ojson.SetBytes(part, "text", instructions.String())
systemParts = append(systemParts, part)
}
@@ -133,15 +132,15 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
pendingFunctionCallIDs = nil
if contentArray := item.Get("content"); contentArray.Exists() {
if contentArray.IsArray() {
- contentArray.ForEach(func(_, contentItem gjson.Result) bool {
+ contentArray.ForEach(func(_, contentItem ojson.Result) bool {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", contentItem.Get("text").String())
+ part, _ = ojson.SetBytes(part, "text", contentItem.Get("text").String())
systemParts = append(systemParts, part)
return true
})
- } else if contentArray.Type == gjson.String {
+ } else if contentArray.Type == ojson.String {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", contentArray.String())
+ part, _ = ojson.SetBytes(part, "text", contentArray.String())
systemParts = append(systemParts, part)
}
}
@@ -152,9 +151,9 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
if contentArray := item.Get("content"); contentArray.Exists() {
if contentArray.IsArray() {
var texts []string
- contentArray.ForEach(func(_, contentItem gjson.Result) bool {
+ contentArray.ForEach(func(_, contentItem ojson.Result) bool {
text := contentItem.Get("text").String()
- if text == "" && contentItem.Type == gjson.String {
+ if text == "" && contentItem.Type == ojson.String {
text = contentItem.String()
}
if text != "" {
@@ -166,15 +165,15 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
joined := strings.Join(texts, "\n")
if strings.TrimSpace(joined) != "" {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", translatorcommon.SystemReminderText(joined))
+ part, _ = ojson.SetBytes(part, "text", translatorcommon.SystemReminderText(joined))
devParts = append(devParts, part)
}
}
- } else if contentArray.Type == gjson.String && contentArray.String() != "" {
+ } else if contentArray.Type == ojson.String && contentArray.String() != "" {
text := contentArray.String()
if strings.TrimSpace(text) != "" {
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", translatorcommon.SystemReminderText(text))
+ part, _ = ojson.SetBytes(part, "text", translatorcommon.SystemReminderText(text))
devParts = append(devParts, part)
}
}
@@ -221,7 +220,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
// even when the message.role is "user". We split such items into distinct Gemini messages
// with roles derived from the content type to match docs/convert-2.md.
contentArray := item.Get("content")
- var partsToProcess []gjson.Result
+ var partsToProcess []ojson.Result
if contentArray.Exists() && contentArray.IsArray() {
partsToProcess = contentArray.Array()
} else if isResponsesContentPartType(item.Get("type").String()) {
@@ -290,7 +289,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
case "input_text", "output_text", "text":
if text := contentItem.Get("text"); text.Exists() {
partJSON = []byte(`{"text":""}`)
- partJSON, _ = sjson.SetBytes(partJSON, "text", text.String())
+ partJSON, _ = ojson.SetBytes(partJSON, "text", text.String())
sendsContent = text.String() != ""
}
default:
@@ -314,7 +313,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
flush()
drops.EndTurn(userSendable)
- } else if contentArray.Type == gjson.String {
+ } else if contentArray.Type == ojson.String {
effRole := "user"
if itemRole != "" {
switch strings.ToLower(itemRole) {
@@ -326,7 +325,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", contentArray.String())
+ part, _ = ojson.SetBytes(part, "text", contentArray.String())
contentItems = append(contentItems, geminiContent(effRole, [][]byte{part}))
}
@@ -350,14 +349,17 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
case "function_call_output", "custom_tool_call_output":
hasEncounteredConversation = true
orderedOutputs, consumedIndexes, _ := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs)
+ end := i + 1
for consumedIndex := range consumedIndexes {
consumedFunctionOutputIndexes[consumedIndex] = true
+ if consumedIndex >= end {
+ end = consumedIndex + 1
+ }
}
- end := i + len(consumedIndexes)
hasSubsequent := responsesHasSubsequentTurn(normalized[end:])
- outputByCallID := make(map[string]gjson.Result)
- var extraOutputs []gjson.Result
+ outputByCallID := make(map[string]ojson.Result)
+ var extraOutputs []ojson.Result
anyMatched := false
for _, out := range orderedOutputs {
id := extractOpenAIResponsesCallID(out)
@@ -389,7 +391,7 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
var standaloneOutputContents [][]byte
- appendStandaloneOutput := func(out gjson.Result) {
+ appendStandaloneOutput := func(out ojson.Result) {
// Orphan outputs (no matching function_call, e.g. Codex
// send_message_to_thread cards) must not become unpaired
// functionResponse parts. Surface them as user text instead.
@@ -469,45 +471,45 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
contentItems = coalesceAdjacentOpenAIResponsesModelContents(contentItems)
contentItems = translatorcommon.MergeAdjacentGeminiUserContents(contentItems)
- out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
- } else if input.Exists() && input.Type == gjson.String {
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "contents", contentItems)
+ } else if input.Exists() && input.Type == ojson.String {
// Simple string input conversion to user message.
part := []byte(`{"text":""}`)
- part, _ = sjson.SetBytes(part, "text", input.String())
- out = translatorcommon.SetRawArrayItems(out, "contents", [][]byte{geminiContent("user", [][]byte{part})})
+ part, _ = ojson.SetBytes(part, "text", input.String())
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "contents", [][]byte{geminiContent("user", [][]byte{part})})
}
if len(systemParts) > 0 {
- out, _ = sjson.SetRawBytes(out, "systemInstruction", geminiSystemInstruction(systemParts))
+ _ = outDoc.SetRawBytes("systemInstruction", geminiSystemInstruction(systemParts))
}
// Handle generation config from OpenAI format
if maxOutputTokens := root.Get("max_output_tokens"); maxOutputTokens.Exists() {
genConfig := []byte(`{"maxOutputTokens":0}`)
- genConfig, _ = sjson.SetBytes(genConfig, "maxOutputTokens", maxOutputTokens.Int())
- out, _ = sjson.SetRawBytes(out, "generationConfig", genConfig)
+ genConfig, _ = ojson.SetBytes(genConfig, "maxOutputTokens", maxOutputTokens.Int())
+ _ = outDoc.SetRawBytes("generationConfig", genConfig)
}
// Handle temperature if present
if temperature := root.Get("temperature"); temperature.Exists() {
- out, _ = sjson.SetBytes(out, "generationConfig.temperature", temperature.Float())
+ _ = outDoc.Set("generationConfig.temperature", temperature.Float())
}
// Handle top_p if present
if topP := root.Get("top_p"); topP.Exists() {
- out, _ = sjson.SetBytes(out, "generationConfig.topP", topP.Float())
+ _ = outDoc.Set("generationConfig.topP", topP.Float())
}
// Handle stop sequences
if stopSequences := root.Get("stop_sequences"); stopSequences.Exists() && stopSequences.IsArray() {
var sequences []string
- stopSequences.ForEach(func(_, seq gjson.Result) bool {
+ stopSequences.ForEach(func(_, seq ojson.Result) bool {
sequences = append(sequences, seq.String())
return true
})
- out, _ = sjson.SetBytes(out, "generationConfig.stopSequences", sequences)
+ _ = outDoc.Set("generationConfig.stopSequences", sequences)
}
- out = applyOpenAIResponsesTextFormatToGemini(out, root)
+ applyOpenAIResponsesTextFormatToGeminiDoc(outDoc, root)
// Apply thinking configuration: convert OpenAI Responses API reasoning.effort to Gemini thinkingConfig.
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
@@ -517,15 +519,15 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
if effort != "" {
thinkingPath := "generationConfig.thinkingConfig"
if effort == "auto" {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
+ _ = outDoc.Set(thinkingPath+".thinkingBudget", -1)
} else {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
+ _ = outDoc.Set(thinkingPath+".thinkingLevel", effort)
}
}
}
- result := out
- result = common.AttachDefaultSafetySettings(result, "safetySettings")
+ common.AttachDefaultSafetySettingsDoc(outDoc, "safetySettings")
+ result := outDoc.Bytes()
if useGeminiNativeReasoningLayout {
result = sigcompat.SanitizeGeminiRequestThoughtSignatures(result, "contents")
}
@@ -533,22 +535,22 @@ func convertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
func geminiContent(role string, parts [][]byte) []byte {
- content := []byte(`{"role":"","parts":[]}`)
- content, _ = sjson.SetBytes(content, "role", role)
- content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
- return content
+ contentDoc := ojson.NewDocString(`{"role":"","parts":[]}`)
+ _ = contentDoc.Set("role", role)
+ _ = contentDoc.SetRawBytes("parts", translatorcommon.JoinRawArray(parts))
+ return contentDoc.Bytes()
}
func coalesceAdjacentOpenAIResponsesModelContents(contents [][]byte) [][]byte {
coalesced := make([][]byte, 0, len(contents))
for _, content := range contents {
- contentResult := gjson.ParseBytes(content)
+ contentResult := util.ParseGJSONBytesNoCopy(content)
if !strings.EqualFold(strings.TrimSpace(contentResult.Get("role").String()), "model") || len(coalesced) == 0 {
coalesced = append(coalesced, content)
continue
}
lastIndex := len(coalesced) - 1
- lastResult := gjson.ParseBytes(coalesced[lastIndex])
+ lastResult := util.ParseGJSONBytesNoCopy(coalesced[lastIndex])
if !strings.EqualFold(strings.TrimSpace(lastResult.Get("role").String()), "model") {
coalesced = append(coalesced, content)
continue
@@ -560,13 +562,13 @@ func coalesceAdjacentOpenAIResponsesModelContents(contents [][]byte) [][]byte {
continue
}
var extraParts [][]byte
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
extraParts = append(extraParts, []byte(part.Raw))
return true
})
if len(extraParts) > 0 {
var existingParts [][]byte
- gjson.GetBytes(merged, "parts").ForEach(func(_, p gjson.Result) bool {
+ ojson.GetBytesNoCopy(merged, "parts").ForEach(func(_, p ojson.Result) bool {
existingParts = append(existingParts, []byte(p.Raw))
return true
})
@@ -579,12 +581,12 @@ func coalesceAdjacentOpenAIResponsesModelContents(contents [][]byte) [][]byte {
func geminiSystemInstruction(parts [][]byte) []byte {
systemInstruction := []byte(`{"parts":[]}`)
- systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(parts))
+ systemInstruction, _ = ojson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(parts))
return systemInstruction
}
func stripTrailingOpenAIResponsesModelPrefill(payload []byte) []byte {
- contents := gjson.GetBytes(payload, "contents")
+ contents := ojson.GetBytesNoCopy(payload, "contents")
if !contents.IsArray() {
return payload
}
@@ -597,7 +599,7 @@ func stripTrailingOpenAIResponsesModelPrefill(payload []byte) []byte {
items = append(items, []byte(content.Raw))
}
if len(items) == 0 {
- updated, errSet := sjson.SetRawBytes(payload, "contents", []byte("[]"))
+ updated, errSet := ojson.SetRawBytes(payload, "contents", []byte("[]"))
if errSet == nil {
return updated
}
@@ -606,7 +608,7 @@ func stripTrailingOpenAIResponsesModelPrefill(payload []byte) []byte {
return translatorcommon.SetRawArrayItems(payload, "contents", items)
}
-func shouldStripTrailingOpenAIResponsesModelPrefill(lastContent gjson.Result) bool {
+func shouldStripTrailingOpenAIResponsesModelPrefill(lastContent ojson.Result) bool {
if lastContent.Get("role").String() != "model" {
return false
}
@@ -622,7 +624,7 @@ func shouldStripTrailingOpenAIResponsesModelPrefill(lastContent gjson.Result) bo
return true
}
-func isTrailingOpenAIResponsesAssistantPrefill(items []gjson.Result, assistantIndex int) bool {
+func isTrailingOpenAIResponsesAssistantPrefill(items []ojson.Result, assistantIndex int) bool {
if assistantIndex < 0 || assistantIndex >= len(items) {
return false
}
@@ -646,7 +648,7 @@ func isTrailingOpenAIResponsesAssistantPrefill(items []gjson.Result, assistantIn
return ok
}
-func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) {
+func openAIResponsesAssistantVisibleText(item ojson.Result) (string, bool) {
itemType := item.Get("type").String()
itemRole := item.Get("role").String()
if itemType == "" && itemRole != "" {
@@ -660,7 +662,7 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) {
if !content.Exists() {
return "", false
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
switch strings.ToLower(strings.TrimSpace(itemRole)) {
case "assistant", "model":
return content.String(), true
@@ -674,7 +676,7 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) {
var textParts []string
hasOutputText := false
- content.ForEach(func(_, contentItem gjson.Result) bool {
+ content.ForEach(func(_, contentItem ojson.Result) bool {
contentType := contentItem.Get("type").String()
if contentType == "" {
contentType = "input_text"
@@ -693,17 +695,17 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) {
return strings.Join(textParts, "\n"), true
}
-func isOpenAIResponsesToolCall(item gjson.Result) bool {
+func isOpenAIResponsesToolCall(item ojson.Result) bool {
t := item.Get("type").String()
return t == "function_call" || t == "custom_tool_call"
}
-func isOpenAIResponsesToolOutput(item gjson.Result) bool {
+func isOpenAIResponsesToolOutput(item ojson.Result) bool {
t := item.Get("type").String()
return t == "function_call_output" || t == "custom_tool_call_output"
}
-func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson.Result {
+func pairOpenAIResponsesReasoningWithFunctionCalls(items []ojson.Result) []ojson.Result {
isDetachedCarrier := isOpenAIResponsesDetachedCarrier
postCallSignature := make(map[int]string)
postCallCarrier := make(map[int]bool)
@@ -762,13 +764,13 @@ func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson
groupStart = outputEnd
}
- paired := make([]gjson.Result, 0, len(items))
+ paired := make([]ojson.Result, 0, len(items))
for index := 0; index < len(items); index++ {
item := items[index]
if signature := postCallSignature[index]; signature != "" {
functionCall := []byte(item.Raw)
- functionCall, _ = sjson.SetBytes(functionCall, "_cpa_reasoning_signature", signature)
- paired = append(paired, gjson.ParseBytes(functionCall))
+ functionCall, _ = ojson.SetBytes(functionCall, "_cpa_reasoning_signature", signature)
+ paired = append(paired, util.ParseGJSONBytesNoCopy(functionCall))
continue
}
if consumedPostCallCarrier[index] {
@@ -780,12 +782,12 @@ func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson
if item.Get("type").String() == "reasoning" && !postCallCarrier[index] && canBindFollowingCall && !strings.Contains(item.Get("id").String(), "_detached_after_") && index+1 < len(items) && isOpenAIResponsesToolCall(items[index+1]) {
rawSignature := strings.TrimSpace(item.Get("encrypted_content").String())
if rawSignature != "" {
- functionCall := []byte(items[index+1].Raw)
- functionCall, _ = sjson.SetBytes(functionCall, "_cpa_reasoning_signature", rawSignature)
+ fcDoc := ojson.NewDocString(items[index+1].Raw)
+ _ = fcDoc.Set("_cpa_reasoning_signature", rawSignature)
if summary := item.Get("summary.0.text").String(); summary != "" {
- functionCall, _ = sjson.SetBytes(functionCall, "_cpa_reasoning_summary", summary)
+ _ = fcDoc.Set("_cpa_reasoning_summary", summary)
}
- paired = append(paired, gjson.ParseBytes(functionCall))
+ paired = append(paired, util.ParseGJSONBytesNoCopy(fcDoc.Bytes()))
index++
continue
}
@@ -795,8 +797,8 @@ func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson
return paired
}
-func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Result {
- reordered := make([]gjson.Result, 0, len(items))
+func reorderOpenAIResponsesDetachedReasoning(items []ojson.Result) []ojson.Result {
+ reordered := make([]ojson.Result, 0, len(items))
for itemIndex, item := range items {
isReasoningCarrier := isOpenAIResponsesDetachedCarrier(item)
markedDetached := strings.Contains(item.Get("id").String(), "_detached_after_")
@@ -827,8 +829,8 @@ func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Resul
bindPreviousMessage := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny) && isAssistantMessage && !alreadyPairedText
bindPreviousFunction := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierFunction || targetKind == geminiResponsesCarrierAny) && (previousType == "function_call" || previousType == "custom_tool_call") && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "" && !alreadyPairedFunction
if bindPreviousMessage || bindPreviousFunction {
- movedItemJSON, _ := sjson.SetBytes([]byte(item.Raw), geminiResponsesCarrierDirectionField, geminiResponsesCarrierNext)
- reordered[len(reordered)-1] = gjson.ParseBytes(movedItemJSON)
+ movedItemJSON, _ := ojson.SetBytes([]byte(item.Raw), geminiResponsesCarrierDirectionField, geminiResponsesCarrierNext)
+ reordered[len(reordered)-1] = util.ParseGJSONBytesNoCopy(movedItemJSON)
reordered = append(reordered, previous)
continue
}
@@ -856,54 +858,54 @@ func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Resul
return reordered
}
-func buildOpenAIResponsesFunctionCallPart(item gjson.Result, signature string, forwardMap map[string]string) []byte {
+func buildOpenAIResponsesFunctionCallPart(item ojson.Result, signature string, forwardMap map[string]string) []byte {
name := item.Get("name").String()
if ns := item.Get("namespace").String(); ns != "" {
name = util.QualifyResponsesNamespaceToolName(ns, name)
}
name = util.MapResponsesToolName(forwardMap, name)
- functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`)
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name)
- functionCall, _ = sjson.SetBytes(functionCall, "thoughtSignature", signature)
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", extractOpenAIResponsesCallID(item))
+ fcDoc := ojson.NewDocString(`{"functionCall":{"name":"","args":{}}}`)
+ _ = fcDoc.Set("functionCall.name", name)
+ _ = fcDoc.Set("thoughtSignature", signature)
+ _ = fcDoc.Set("functionCall.id", extractOpenAIResponsesCallID(item))
if item.Get("type").String() == "custom_tool_call" {
inputVal := item.Get("input")
if inputVal.Exists() {
- if inputVal.Type == gjson.String {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.args.input", inputVal.String())
+ if inputVal.Type == ojson.String {
+ _ = fcDoc.Set("functionCall.args.input", inputVal.String())
} else {
- functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args.input", []byte(inputVal.Raw))
+ _ = fcDoc.SetRaw("functionCall.args.input", inputVal.Raw)
}
} else {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.args.input", "")
+ _ = fcDoc.Set("functionCall.args.input", "")
}
} else {
arguments := item.Get("arguments").String()
if arguments != "" {
- argsResult := gjson.Parse(arguments)
+ argsResult := ojson.Parse(arguments)
if argsResult.IsObject() || argsResult.IsArray() {
- functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsResult.Raw))
+ _ = fcDoc.SetRaw("functionCall.args", argsResult.Raw)
} else {
- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.args.arguments", arguments)
+ _ = fcDoc.Set("functionCall.args.arguments", arguments)
}
}
}
- return functionCall
+ return fcDoc.Bytes()
}
func geminiResponsesInlineDataPart(mimeType, data string) []byte {
- partJSON := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
- partJSON, _ = sjson.SetBytes(partJSON, "inline_data.mime_type", mimeType)
- partJSON, _ = sjson.SetBytes(partJSON, "inline_data.data", data)
- return partJSON
+ partDoc := ojson.NewDocString(`{"inline_data":{"mime_type":"","data":""}}`)
+ _ = partDoc.Set("inline_data.mime_type", mimeType)
+ _ = partDoc.Set("inline_data.data", data)
+ return partDoc.Bytes()
}
func geminiResponsesFileDataPart(mimeType, fileURI string) []byte {
- partJSON := []byte(`{"file_data":{"mime_type":"","file_uri":""}}`)
- partJSON, _ = sjson.SetBytes(partJSON, "file_data.mime_type", mimeType)
- partJSON, _ = sjson.SetBytes(partJSON, "file_data.file_uri", fileURI)
- return partJSON
+ partDoc := ojson.NewDocString(`{"file_data":{"mime_type":"","file_uri":""}}`)
+ _ = partDoc.Set("file_data.mime_type", mimeType)
+ _ = partDoc.Set("file_data.file_uri", fileURI)
+ return partDoc.Bytes()
}
func firstNonEmpty(values ...string) string {
@@ -1074,7 +1076,7 @@ func openAIResponsesVideoMimeType(format string) string {
}
}
-func openAIResponsesAudioFromBlock(block gjson.Result) (mimeType string, data string, ok bool) {
+func openAIResponsesAudioFromBlock(block ojson.Result) (mimeType string, data string, ok bool) {
bType := strings.ToLower(strings.TrimSpace(block.Get("type").String()))
if bType != "input_audio" && bType != "audio" {
return "", "", false
@@ -1169,7 +1171,7 @@ func openAIResponsesAudioFromBlock(block gjson.Result) (mimeType string, data st
return mType, audioData, true
}
-func openAIResponsesVideoFromBlock(block gjson.Result) (mimeType string, data string, ok bool) {
+func openAIResponsesVideoFromBlock(block ojson.Result) (mimeType string, data string, ok bool) {
bType := strings.ToLower(strings.TrimSpace(block.Get("type").String()))
if bType != "input_video" && bType != "video_url" && bType != "video" {
return "", "", false
@@ -1295,7 +1297,7 @@ func normalizeFormatToMIME(format string) string {
return ""
}
-func openAIResponsesFileFromBlock(block gjson.Result) (mimeType string, data string, ok bool) {
+func openAIResponsesFileFromBlock(block ojson.Result) (mimeType string, data string, ok bool) {
bType := strings.ToLower(strings.TrimSpace(block.Get("type").String()))
if bType != "input_file" && bType != "file" {
return "", "", false
@@ -1361,7 +1363,7 @@ func openAIResponsesFileFromBlock(block gjson.Result) (mimeType string, data str
return translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIME, fileData)
}
-func openAIResponsesMediaFromBlock(block gjson.Result) (mimeType string, data string, ok bool) {
+func openAIResponsesMediaFromBlock(block ojson.Result) (mimeType string, data string, ok bool) {
if mType, d, ok := openAIResponsesImageFromBlock(block); ok {
return mType, d, true
}
@@ -1377,7 +1379,7 @@ func openAIResponsesMediaFromBlock(block gjson.Result) (mimeType string, data st
return "", "", false
}
-func openAIResponsesPartFromBlock(block gjson.Result) ([]byte, bool) {
+func openAIResponsesPartFromBlock(block ojson.Result) ([]byte, bool) {
bType := strings.ToLower(strings.TrimSpace(block.Get("type").String()))
// 1. Check for remote URLs (http://, https://, gs://)
@@ -1500,7 +1502,7 @@ func openAIResponsesImageMimeType(format, filename string) string {
return "image/png"
}
-func openAIResponsesImageFromBlock(block gjson.Result) (mimeType string, data string, ok bool) {
+func openAIResponsesImageFromBlock(block ojson.Result) (mimeType string, data string, ok bool) {
blockType := strings.ToLower(strings.TrimSpace(block.Get("type").String()))
switch blockType {
case "input_image", "image_url", "image":
@@ -1578,13 +1580,13 @@ type openAIResponsesOutputBlock struct {
raw string
}
-func parseOpenAIResponsesArrayOutput(outputResult gjson.Result) (result string, isRaw bool, images [][]byte) {
+func parseOpenAIResponsesArrayOutput(outputResult ojson.Result) (result string, isRaw bool, images [][]byte) {
var imageParts [][]byte
var nonImageEntries []openAIResponsesOutputBlock
var hasContentBlock bool
var hasNonTextBlock bool
- outputResult.ForEach(func(_, block gjson.Result) bool {
+ outputResult.ForEach(func(_, block ojson.Result) bool {
if mimeType, data, ok := openAIResponsesMediaFromBlock(block); ok {
hasContentBlock = true
imageParts = append(imageParts, geminiResponsesInlineDataPart(mimeType, data))
@@ -1598,7 +1600,7 @@ func parseOpenAIResponsesArrayOutput(outputResult gjson.Result) (result string,
isText: true,
raw: block.Raw,
})
- } else if block.Type == gjson.String {
+ } else if block.Type == ojson.String {
nonImageEntries = append(nonImageEntries, openAIResponsesOutputBlock{
text: block.String(),
isText: true,
@@ -1643,7 +1645,7 @@ func parseOpenAIResponsesArrayOutput(outputResult gjson.Result) (result string,
}
}
-func extractOpenAIResponsesCallID(node gjson.Result) string {
+func extractOpenAIResponsesCallID(node ojson.Result) string {
return translatorcommon.ExtractResponsesCallID(node)
}
@@ -1652,15 +1654,15 @@ func buildOpenAIResponsesSynthesizedFunctionResponsePart(callID string, function
if matchedName, ok := functionNamesByCallID[callID]; ok && matchedName != "" {
functionName = matchedName
}
- functionResponse := []byte(`{"functionResponse":{"name":"","response":{"result":"call interrupted, no output"}}}`)
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", util.SanitizeFunctionName(functionName))
+ frDoc := ojson.NewDocString(`{"functionResponse":{"name":"","response":{"result":"call interrupted, no output"}}}`)
+ _ = frDoc.Set("functionResponse.name", util.SanitizeFunctionName(functionName))
if callID != "" {
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID)
+ _ = frDoc.Set("functionResponse.id", callID)
}
- return functionResponse
+ return frDoc.Bytes()
}
-func responsesHasMatchingOutput(items []gjson.Result, callID string) bool {
+func responsesHasMatchingOutput(items []ojson.Result, callID string) bool {
if callID == "" {
return false
}
@@ -1675,7 +1677,7 @@ func responsesHasMatchingOutput(items []gjson.Result, callID string) bool {
return false
}
-func responsesHasSubsequentTurn(items []gjson.Result) bool {
+func responsesHasSubsequentTurn(items []ojson.Result) bool {
for _, item := range items {
typ := item.Get("type").String()
role := item.Get("role").String()
@@ -1689,20 +1691,20 @@ func responsesHasSubsequentTurn(items []gjson.Result) bool {
return false
}
-func buildOpenAIResponsesStandaloneToolOutputTextParts(item gjson.Result) [][]byte {
+func buildOpenAIResponsesStandaloneToolOutputTextParts(item ojson.Result) [][]byte {
output := item.Get("output")
if !output.Exists() {
return nil
}
if output.IsArray() {
var parts [][]byte
- output.ForEach(func(_, part gjson.Result) bool {
+ output.ForEach(func(_, part ojson.Result) bool {
text := part.Get("text").String()
if strings.TrimSpace(text) == "" {
return true
}
textPart := []byte(`{"text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", text)
+ textPart, _ = ojson.SetBytes(textPart, "text", text)
parts = append(parts, textPart)
return true
})
@@ -1713,11 +1715,11 @@ func buildOpenAIResponsesStandaloneToolOutputTextParts(item gjson.Result) [][]by
return nil
}
textPart := []byte(`{"text":""}`)
- textPart, _ = sjson.SetBytes(textPart, "text", text)
+ textPart, _ = ojson.SetBytes(textPart, "text", text)
return [][]byte{textPart}
}
-func buildOpenAIResponsesFunctionResponseParts(item gjson.Result, functionNamesByCallID map[string]string) [][]byte {
+func buildOpenAIResponsesFunctionResponseParts(item ojson.Result, functionNamesByCallID map[string]string) [][]byte {
callID := extractOpenAIResponsesCallID(item)
functionName := "unknown"
if matchedName, ok := functionNamesByCallID[callID]; ok {
@@ -1725,20 +1727,20 @@ func buildOpenAIResponsesFunctionResponseParts(item gjson.Result, functionNamesB
} else if name := strings.TrimSpace(item.Get("name").String()); name != "" {
functionName = name
}
- functionResponse := []byte(`{"functionResponse":{"name":"","response":{}}}`)
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", util.SanitizeFunctionName(functionName))
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID)
+ frDoc := ojson.NewDocString(`{"functionResponse":{"name":"","response":{}}}`)
+ _ = frDoc.Set("functionResponse.name", util.SanitizeFunctionName(functionName))
+ _ = frDoc.Set("functionResponse.id", callID)
outputResult := item.Get("output")
- if outputResult.Type == gjson.String {
+ if outputResult.Type == ojson.String {
str := outputResult.String()
if str == "" || str == "null" {
- return [][]byte{functionResponse}
+ return [][]byte{frDoc.Bytes()}
}
// Keep it as a string instead of parsing it into JSON.
// Parsing it as JSON, similar to reading a JSON file with readFile, may trigger an upstream 400 error.
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.response.result", str)
- return [][]byte{functionResponse}
+ _ = frDoc.Set("functionResponse.response.result", str)
+ return [][]byte{frDoc.Bytes()}
}
var imageParts [][]byte
@@ -1747,53 +1749,67 @@ func buildOpenAIResponsesFunctionResponseParts(item gjson.Result, functionNamesB
result, isRaw, images := parseOpenAIResponsesArrayOutput(outputResult)
imageParts = images
if isRaw {
- functionResponse = translatorcommon.SetGeminiFunctionResponseRaw(functionResponse, "functionResponse.response.result", result)
+ translatorcommon.SetGeminiFunctionResponseRawDoc(frDoc, "functionResponse.response.result", result)
} else {
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.response.result", result)
+ _ = frDoc.Set("functionResponse.response.result", result)
}
case outputResult.IsObject():
if mimeType, data, ok := openAIResponsesMediaFromBlock(outputResult); ok {
imageParts = append(imageParts, geminiResponsesInlineDataPart(mimeType, data))
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.response.result", "")
+ _ = frDoc.Set("functionResponse.response.result", "")
} else {
- functionResponse = translatorcommon.SetGeminiFunctionResponseResult(functionResponse, "functionResponse.response.result", outputResult)
+ translatorcommon.SetGeminiFunctionResponseResultDoc(frDoc, "functionResponse.response.result", outputResult)
}
case outputResult.Raw != "" && outputResult.Raw != "null":
- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.response.result", outputResult.String())
+ _ = frDoc.Set("functionResponse.response.result", outputResult.String())
}
for _, part := range imageParts {
- if inline := gjson.GetBytes(part, "inline_data"); inline.Exists() {
- inlineData := []byte(`{"inlineData":{"mimeType":"","data":""}}`)
- inlineData, _ = sjson.SetBytes(inlineData, "inlineData.mimeType", inline.Get("mime_type").String())
- inlineData, _ = sjson.SetBytes(inlineData, "inlineData.data", inline.Get("data").String())
- functionResponse, _ = sjson.SetRawBytes(functionResponse, "functionResponse.parts.-1", inlineData)
- } else if fileData := gjson.GetBytes(part, "file_data"); fileData.Exists() {
- fileDataObj := []byte(`{"fileData":{"mimeType":"","fileUri":""}}`)
- fileDataObj, _ = sjson.SetBytes(fileDataObj, "fileData.mimeType", fileData.Get("mime_type").String())
- fileDataObj, _ = sjson.SetBytes(fileDataObj, "fileData.fileUri", fileData.Get("file_uri").String())
- functionResponse, _ = sjson.SetRawBytes(functionResponse, "functionResponse.parts.-1", fileDataObj)
- }
- }
- return [][]byte{functionResponse}
+ if inline := ojson.GetBytesNoCopy(part, "inline_data"); inline.Exists() {
+ inlineDataDoc := ojson.NewDocString(`{"inlineData":{"mimeType":"","data":""}}`)
+ _ = inlineDataDoc.Set("inlineData.mimeType", inline.Get("mime_type").String())
+ _ = inlineDataDoc.Set("inlineData.data", inline.Get("data").String())
+ _ = frDoc.SetRawBytes("functionResponse.parts.-1", inlineDataDoc.Bytes())
+ } else if fileData := ojson.GetBytesNoCopy(part, "file_data"); fileData.Exists() {
+ fileDataDoc := ojson.NewDocString(`{"fileData":{"mimeType":"","fileUri":""}}`)
+ _ = fileDataDoc.Set("fileData.mimeType", fileData.Get("mime_type").String())
+ _ = fileDataDoc.Set("fileData.fileUri", fileData.Get("file_uri").String())
+ _ = frDoc.SetRawBytes("functionResponse.parts.-1", fileDataDoc.Bytes())
+ }
+ }
+ return [][]byte{frDoc.Bytes()}
}
-func collectOpenAIResponsesFunctionCallOutputs(items []gjson.Result, start int, pendingCallIDs []string) ([]gjson.Result, map[int]bool, []string) {
- end := start + 1
- for end < len(items) && (items[end].Get("type").String() == "function_call_output" || items[end].Get("type").String() == "custom_tool_call_output") {
- end++
+func collectOpenAIResponsesFunctionCallOutputs(items []ojson.Result, start int, pendingCallIDs []string) ([]ojson.Result, map[int]bool, []string) {
+ var outputs []ojson.Result
+ consumed := make(map[int]bool)
+ pending := make(map[string]bool, len(pendingCallIDs))
+ for _, callID := range pendingCallIDs {
+ pending[callID] = true
+ }
+ for itemIndex := start; itemIndex < len(items); itemIndex++ {
+ item := items[itemIndex]
+ itemType := item.Get("type").String()
+ if itemType == "function_call_output" || itemType == "custom_tool_call_output" {
+ outputs = append(outputs, item)
+ consumed[itemIndex] = true
+ delete(pending, extractOpenAIResponsesCallID(item))
+ continue
+ }
+ // Keep one parallel result turn across notices; the caller preserves the notices.
+ role := item.Get("role").String()
+ if len(pending) > 0 && (itemType == "message" || itemType == "") &&
+ (strings.EqualFold(role, "developer") || strings.EqualFold(role, "system")) {
+ continue
+ }
+ break
}
- outputs := items[start:end]
ordered, remainingPending := orderOpenAIResponsesFunctionCallOutputs(outputs, pendingCallIDs)
- consumed := make(map[int]bool, len(outputs))
- for itemIndex := start; itemIndex < end; itemIndex++ {
- consumed[itemIndex] = true
- }
return ordered, consumed, remainingPending
}
-func orderOpenAIResponsesFunctionCallOutputs(outputs []gjson.Result, pendingCallIDs []string) ([]gjson.Result, []string) {
- ordered := make([]gjson.Result, 0, len(outputs))
+func orderOpenAIResponsesFunctionCallOutputs(outputs []ojson.Result, pendingCallIDs []string) ([]ojson.Result, []string) {
+ ordered := make([]ojson.Result, 0, len(outputs))
used := make([]bool, len(outputs))
remainingPending := make([]string, 0, len(pendingCallIDs))
for _, pendingID := range pendingCallIDs {
@@ -1819,31 +1835,31 @@ func orderOpenAIResponsesFunctionCallOutputs(outputs []gjson.Result, pendingCall
return ordered, remainingPending
}
-func buildOpenAIResponsesFunctionCallModelContent(item gjson.Result, signature string, forwardMap map[string]string) []byte {
+func buildOpenAIResponsesFunctionCallModelContent(item ojson.Result, signature string, forwardMap map[string]string) []byte {
modelContent := []byte(`{"role":"model","parts":[]}`)
- modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray([][]byte{buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap)}))
+ modelContent, _ = ojson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray([][]byte{buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap)}))
return modelContent
}
-func buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item gjson.Result, signature string, forwardMap map[string]string) []byte {
+func buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item ojson.Result, signature string, forwardMap map[string]string) []byte {
thought := []byte(`{"text":"","thought":true,"thoughtSignature":""}`)
- thought, _ = sjson.SetBytes(thought, "thoughtSignature", signature)
+ thought, _ = ojson.SetBytes(thought, "thoughtSignature", signature)
parts := [][]byte{thought, buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap)}
modelContent := []byte(`{"role":"model","parts":[]}`)
- modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts))
+ modelContent, _ = ojson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts))
return modelContent
}
-func buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText string, item gjson.Result, signature string, forwardMap map[string]string) []byte {
+func buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText string, item ojson.Result, signature string, forwardMap map[string]string) []byte {
parts := make([][]byte, 0, 2)
if thoughtText != "" {
thought := []byte(`{"text":"","thought":true}`)
- thought, _ = sjson.SetBytes(thought, "text", thoughtText)
+ thought, _ = ojson.SetBytes(thought, "text", thoughtText)
parts = append(parts, thought)
}
parts = append(parts, buildOpenAIResponsesFunctionCallPart(item, signature, forwardMap))
modelContent := []byte(`{"role":"model","parts":[]}`)
- modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts))
+ modelContent, _ = ojson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts))
return modelContent
}
@@ -1856,35 +1872,35 @@ func buildOpenAIResponsesReasoningModelContent(thoughtText, visibleText, signatu
return nil
}
carrier := []byte(`{"text":"","thoughtSignature":""}`)
- carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", signature)
+ carrier, _ = ojson.SetBytes(carrier, "thoughtSignature", signature)
return translatorcommon.SetRawArrayItems(modelContent, "parts", [][]byte{carrier})
}
var parts [][]byte
if thoughtText != "" {
- thought := []byte(`{"text":"","thought":true}`)
- thought, _ = sjson.SetBytes(thought, "text", thoughtText)
+ thoughtDoc := ojson.NewDocString(`{"text":"","thought":true}`)
+ _ = thoughtDoc.Set("text", thoughtText)
if visibleText == "" && hasRealSignature {
- thought, _ = sjson.SetBytes(thought, "thoughtSignature", signature)
+ _ = thoughtDoc.Set("thoughtSignature", signature)
}
- parts = append(parts, thought)
+ parts = append(parts, thoughtDoc.Bytes())
}
if visibleText != "" {
- visible := []byte(`{"text":""}`)
- visible, _ = sjson.SetBytes(visible, "text", visibleText)
+ visibleDoc := ojson.NewDocString(`{"text":""}`)
+ _ = visibleDoc.Set("text", visibleText)
if hasRealSignature {
- visible, _ = sjson.SetBytes(visible, "thoughtSignature", signature)
+ _ = visibleDoc.Set("thoughtSignature", signature)
}
- parts = append(parts, visible)
+ parts = append(parts, visibleDoc.Bytes())
}
return translatorcommon.SetRawArrayItems(modelContent, "parts", parts)
}
- thought := []byte(`{"text":"","thought":true}`)
- thought, _ = sjson.SetBytes(thought, "text", thoughtText)
+ thoughtDoc := ojson.NewDocString(`{"text":"","thought":true}`)
+ _ = thoughtDoc.Set("text", thoughtText)
if hasRealSignature {
- thought, _ = sjson.SetBytes(thought, "thoughtSignature", signature)
+ _ = thoughtDoc.Set("thoughtSignature", signature)
}
- return translatorcommon.SetRawArrayItems(modelContent, "parts", [][]byte{thought})
+ return translatorcommon.SetRawArrayItems(modelContent, "parts", [][]byte{thoughtDoc.Bytes()})
}
func openAIResponsesGeminiThoughtSignature(rawSignature string) string {
@@ -1894,27 +1910,25 @@ func openAIResponsesGeminiThoughtSignature(rawSignature string) string {
return ""
}
-func applyOpenAIResponsesTextFormatToGemini(out []byte, root gjson.Result) []byte {
+func applyOpenAIResponsesTextFormatToGeminiDoc(outDoc *ojson.Doc, root ojson.Result) {
textFormat := root.Get("text.format")
if !textFormat.Exists() {
- return out
+ return
}
formatType := strings.ToLower(strings.TrimSpace(textFormat.Get("type").String()))
switch formatType {
case "json_object":
- out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
+ _ = outDoc.Set("generationConfig.responseMimeType", "application/json")
case "json_schema":
- out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
+ _ = outDoc.Set("generationConfig.responseMimeType", "application/json")
schema := textFormat.Get("schema")
if !schema.Exists() {
schema = textFormat.Get("json_schema.schema")
}
if schema.Exists() {
- out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
+ _ = outDoc.SetRaw("generationConfig.responseJsonSchema", schema.Raw)
}
}
-
- return out
}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
index 1def0a3677c..2d132bac870 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go
@@ -1,18 +1,21 @@
package responses
import (
- "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
internalsignature "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const testResponsesGeminiThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
func TestReorderOpenAIResponsesDetachedReasoningDoesNotCrossUserMessage(t *testing.T) {
- items := gjson.Parse(`[
+ items := ojson.Parse(`[
{"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]},
{"id":"rs_test_detached_after_1","type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]},
{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"}
@@ -37,7 +40,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesReasoningAndSignatureTo
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.1.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.1.parts").Array()
if len(parts) != 2 || !parts[0].Get("thought").Bool() {
t.Fatalf("reasoning/function parts malformed: %s", result)
}
@@ -59,7 +62,7 @@ func TestConvertOpenAIResponsesRequestToGemini_SyntheticParallelCallsOnlyFirstGe
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.0.parts").Array()
if len(parts) != 2 {
t.Fatalf("parts = %d, want 2 parallel calls; result=%s", len(parts), result)
}
@@ -83,8 +86,8 @@ func TestConvertOpenAIResponsesRequestToGemini_NativeParallelCallsPreserveUnsign
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- var calls []gjson.Result
- for _, content := range gjson.GetBytes(result, "contents").Array() {
+ var calls []ojson.Result
+ for _, content := range ojson.GetBytesNoCopy(result, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("functionCall").Exists() {
calls = append(calls, part)
@@ -123,7 +126,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesMultipleLeadingToolSigna
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
var signatures, sequence []string
- for _, content := range gjson.GetBytes(result, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(result, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("functionCall").Exists() {
signatures = append(signatures, part.Get("thoughtSignature").String())
@@ -159,7 +162,7 @@ func TestConvertOpenAIResponsesRequestToGemini_GroupsReversedParallelToolOutputs
if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(result); errValidate != nil {
t.Fatalf("parallel tool history is invalid: %v; result=%s", errValidate, result)
}
- contents := gjson.GetBytes(result, "contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" {
t.Fatalf("parallel tool roles malformed; result=%s", result)
}
@@ -193,7 +196,7 @@ func TestConvertOpenAIResponsesRequestToGemini_GroupsNonContiguousParallelToolOu
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- contents := gjson.GetBytes(result, "contents").Array()
+ contents := ojson.GetBytesNoCopy(result, "contents").Array()
if len(contents) != 4 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" || contents[2].Get("role").String() != "user" || contents[3].Get("role").String() != "user" {
t.Fatalf("non-contiguous tool output roles malformed; result=%s", result)
}
@@ -221,7 +224,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesReasoningBeforePairedFun
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
var signatures []string
- for _, content := range gjson.GetBytes(result, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(result, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if signature := part.Get("thoughtSignature").String(); signature != "" {
signatures = append(signatures, signature)
@@ -246,7 +249,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesFunctionOutputOrderAcros
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
var sequence []string
- for _, content := range gjson.GetBytes(result, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(result, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if id := part.Get("functionCall.id").String(); id != "" {
sequence = append(sequence, "call:"+id)
@@ -272,7 +275,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesTrailingDetachedSignatu
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.1.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.1.parts").Array()
if len(parts) != 1 {
t.Fatalf("model parts = %d, want one signed visible part; result=%s", len(parts), result)
}
@@ -298,7 +301,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesUnmarkedTrailingSignatu
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.1.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.1.parts").Array()
if len(parts) != 1 {
t.Fatalf("model parts = %d, want one signed visible part after client rewrites carrier ID; result=%s", len(parts), result)
}
@@ -322,7 +325,7 @@ func TestConvertOpenAIResponsesRequestToGemini_UnmarkedReasoningBeforeFunctionCa
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- modelParts := gjson.GetBytes(result, "contents.1.parts").Array()
+ modelParts := ojson.GetBytesNoCopy(result, "contents.1.parts").Array()
if len(modelParts) != 2 {
t.Fatalf("model parts = %d, want unsigned preamble plus signed call; result=%s", len(modelParts), result)
}
@@ -345,7 +348,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesDetachedSignatureToFunc
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- functionParts := gjson.GetBytes(result, "contents.#(role==\"model\")#.parts").Array()
+ functionParts := ojson.GetBytesNoCopy(result, "contents.#(role==\"model\")#.parts").Array()
found := false
for _, partArray := range functionParts {
for _, part := range partArray.Array() {
@@ -373,7 +376,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesUnmarkedPostCallSignatu
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != testResponsesGeminiThoughtSignature {
+ if got := ojson.GetBytesNoCopy(result, "contents.0.parts.0.thoughtSignature").String(); got != testResponsesGeminiThoughtSignature {
t.Fatalf("unmarked post-call signature = %q, want native signature; result=%s", got, result)
}
}
@@ -403,7 +406,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesDirectionalFunctionCarr
carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, testCase.direction, geminiResponsesCarrierFunction)
inputJSON := []byte(`{"model":"gemini-3.6-flash-high","input":` + testCase.input(carrier) + `}`)
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", inputJSON, false)
- if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != testResponsesGeminiThoughtSignature {
+ if got := ojson.GetBytesNoCopy(result, "contents.0.parts.0.thoughtSignature").String(); got != testResponsesGeminiThoughtSignature {
t.Fatalf("directional function signature = %q, want native signature; result=%s", got, result)
}
if strings.Contains(string(result), geminiResponsesCarrierPrefix) {
@@ -419,7 +422,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotRetargetExtraPreviousCarri
name string
targetKind string
input func(string, string) string
- assert func(*testing.T, []gjson.Result)
+ assert func(*testing.T, []ojson.Result)
}{
{
name: "text",
@@ -427,7 +430,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotRetargetExtraPreviousCarri
input: func(first, extra string) string {
return `[{"type":"reasoning","encrypted_content":"` + first + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"signed"}]},{"type":"reasoning","encrypted_content":"` + extra + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"unsigned"}]}]`
},
- assert: func(t *testing.T, parts []gjson.Result) {
+ assert: func(t *testing.T, parts []ojson.Result) {
if len(parts) != 3 || parts[0].Get("text").String() != "signed" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("text").String() != "unsigned" || parts[2].Get("thoughtSignature").String() != "" {
t.Fatalf("extra previous text carrier retargeted: %v", parts)
}
@@ -439,7 +442,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotRetargetExtraPreviousCarri
input: func(first, extra string) string {
return `[{"type":"reasoning","encrypted_content":"` + first + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"},{"type":"reasoning","encrypted_content":"` + extra + `","summary":[]},{"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{}"}]`
},
- assert: func(t *testing.T, parts []gjson.Result) {
+ assert: func(t *testing.T, parts []ojson.Result) {
if len(parts) != 3 || parts[0].Get("functionCall.id").String() != "call-1" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("functionCall.id").String() != "call-2" || parts[2].Get("thoughtSignature").String() != "skip_thought_signature_validator" {
// call-2 follows a text part, so it gets the bypass sentinel rather than signature2.
t.Fatalf("extra previous function carrier retargeted: %v", parts)
@@ -452,7 +455,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotRetargetExtraPreviousCarri
extra := encodeGeminiResponsesCarrier(signature2, geminiResponsesCarrierPrevious, testCase.targetKind)
request := []byte(`{"model":"gemini-3.6-flash-high","input":` + testCase.input(first, extra) + `}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- testCase.assert(t, gjson.GetBytes(translated, "contents.0.parts").Array())
+ testCase.assert(t, ojson.GetBytesNoCopy(translated, "contents.0.parts").Array())
})
}
}
@@ -461,7 +464,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotBindStandaloneFunctionCarr
carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierStandalone, geminiResponsesCarrierFunction)
inputJSON := []byte(`{"model":"gemini-3.6-flash-high","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"}]}`)
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", inputJSON, false)
- parts := gjson.GetBytes(result, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("thoughtSignature").String() != geminiResponsesThoughtSignature {
t.Fatalf("standalone carrier was bound to function call: %s", result)
}
@@ -479,7 +482,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesUnmarkedParallelPostCal
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != geminiResponsesThoughtSignature || parts[1].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("parallel post-call signature was not attached to call-2: %s", result)
}
@@ -499,7 +502,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReattachesAlternatingParallelPost
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("functionCall.id").String() != "call-1" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("functionCall.id").String() != "call-2" || parts[1].Get("thoughtSignature").String() != signature2 {
t.Fatalf("alternating parallel post-call signatures shifted: %s", result)
}
@@ -517,7 +520,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesExtraConsecutivePostCall
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- parts := gjson.GetBytes(result, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(result, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("functionCall.id").String() != "call-1" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("thoughtSignature").String() != signature2 {
t.Fatalf("consecutive post-call carriers malformed: %s", result)
}
@@ -533,7 +536,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotPairUnmarkedPostCallSignat
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != geminiResponsesThoughtSignature {
+ if got := ojson.GetBytesNoCopy(result, "contents.0.parts.0.thoughtSignature").String(); got != geminiResponsesThoughtSignature {
t.Fatalf("mismatched output paired signature %q; result=%s", got, result)
}
}
@@ -549,7 +552,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DoesNotPairUnmarkedPostCallSignat
]
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != geminiResponsesThoughtSignature {
+ if got := ojson.GetBytesNoCopy(result, "contents.0.parts.0.thoughtSignature").String(); got != geminiResponsesThoughtSignature {
t.Fatalf("user-boundary carrier paired signature %q; result=%s", got, result)
}
}
@@ -572,7 +575,7 @@ func TestConvertOpenAIResponsesRequestToGemini_StripsTrailingAssistantPrefill(t
}`
result, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
contents := resultJSON.Get("contents").Array()
if len(contents) != 1 {
@@ -620,7 +623,7 @@ func TestConvertOpenAIResponsesRequestToGemini_TextFormatJSONSchema(t *testing.T
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
genConfig := result.Get("generationConfig")
if got := genConfig.Get("responseMimeType").String(); got != "application/json" {
@@ -659,7 +662,7 @@ func TestConvertOpenAIResponsesRequestToGemini_TextFormatJSONObject(t *testing.T
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
genConfig := result.Get("generationConfig")
if got := genConfig.Get("responseMimeType").String(); got != "application/json" {
@@ -681,8 +684,8 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesReasoningOnlyHistory(t *
}`)
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", input, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
- if got := gjson.GetBytes(output, "contents").Array(); len(got) != 1 {
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
+ if got := ojson.GetBytesNoCopy(output, "contents").Array(); len(got) != 1 {
t.Fatalf("contents length = %d, want 1. Output: %s", len(got), output)
}
if len(parts) != 1 {
@@ -706,7 +709,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DropsEmptyUnsignedReasoningCarrie
}`)
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", input, false)
- if got := gjson.GetBytes(output, "contents.#").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(output, "contents.#").Int(); got != 0 {
t.Fatalf("contents = %d, want no empty unsigned model content; output=%s", got, output)
}
}
@@ -723,7 +726,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesUnboundDetachedCarrierWi
}`)
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", input, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("unbound carrier parts = %d, want one signed carrier; output=%s", len(parts), output)
}
@@ -758,7 +761,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesReasoningBeforeTrailingA
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), output)
}
@@ -805,7 +808,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t
}`)
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", input, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 1 {
t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), output)
}
@@ -842,7 +845,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MergesReasoningWithAssistantVisib
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 2 {
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), output)
}
@@ -881,7 +884,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MergesReasoningWithUserRoleOutput
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 1 {
t.Fatalf("contents length = %d, want 1. Output: %s", len(contents), output)
}
@@ -907,7 +910,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MergesReasoningWithAssistantStrin
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- if got := gjson.GetBytes(output, "contents.0.parts.1.text").String(); got != "string visible answer" {
+ if got := ojson.GetBytesNoCopy(output, "contents.0.parts.1.text").String(); got != "string visible answer" {
t.Fatalf("visible text = %q", got)
}
}
@@ -934,7 +937,7 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesWhitespaceWhenMergingRea
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- if got := gjson.GetBytes(output, "contents.0.parts.1.text").String(); got != " lead trail " {
+ if got := ojson.GetBytesNoCopy(output, "contents.0.parts.1.text").String(); got != " lead trail " {
t.Fatalf("visible text = %q, want preserved whitespace", got)
}
}
@@ -985,7 +988,7 @@ func TestConvertOpenAIResponsesRequestToGemini_SystemAndDeveloperRoles(t *testin
}`)
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", input, false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
systemInstruction := result.Get("systemInstruction")
if !systemInstruction.Exists() {
@@ -1002,7 +1005,7 @@ func TestConvertOpenAIResponsesRequestToGemini_SystemAndDeveloperRoles(t *testin
t.Fatalf("second systemInstruction part = %q, want %q. Output: %s", got, tt.wantText, output)
}
- result.Get("contents").ForEach(func(_, value gjson.Result) bool {
+ result.Get("contents").ForEach(func(_, value ojson.Result) bool {
if role := value.Get("role").String(); role == tt.role {
t.Fatalf("role %q leaked into contents array. Output: %s", tt.role, output)
}
@@ -1047,7 +1050,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MidSessionDeveloperMessageDoesNot
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
// systemInstruction must remain strictly unchanged (only original instructions, not developer notice)
systemInstruction := result.Get("systemInstruction")
@@ -1117,7 +1120,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MidSessionSystemReminderEnvelope(
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
contents := result.Get("contents").Array()
if len(contents) != 3 {
@@ -1156,7 +1159,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MidSessionDeveloperMultiPartConte
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
contents := result.Get("contents").Array()
if len(contents) != 3 {
@@ -1212,7 +1215,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MultipleMidSessionDeveloperMessag
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
// systemInstruction only contains original instructions
parts := result.Get("systemInstruction.parts").Array()
@@ -1272,7 +1275,7 @@ func TestConvertOpenAIResponsesRequestToGemini_InterveningDeveloperMessagePreser
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
// Validate function call pairing passes strictly (no content turn before pending functionResponse)
if errPair := internalsignature.ValidateGeminiFunctionCallPairing(output); errPair != nil {
@@ -1331,7 +1334,7 @@ func TestConvertOpenAIResponsesRequestToGemini_InterveningDeveloperAndUserMessag
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false)
- result := gjson.ParseBytes(output)
+ result := util.ParseGJSONBytesNoCopy(output)
// Pairing should be valid
if errPair := internalsignature.ValidateGeminiFunctionCallPairing(output); errPair != nil {
@@ -1381,7 +1384,7 @@ func TestConvertOpenAIResponsesRequestToGeminiCleansToolSchemaRequiredFields(t *
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false)
- schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
+ schema := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
if !schema.Exists() {
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
@@ -1450,7 +1453,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputWithImages(t *t
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.2")
+ userContent := ojson.GetBytesNoCopy(output, "contents.2")
if userContent.Get("role").String() != "user" {
t.Fatalf("expected role user in third content, got %s", userContent.Raw)
}
@@ -1505,7 +1508,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1534,7 +1537,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1566,7 +1569,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1598,7 +1601,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1629,7 +1632,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1661,7 +1664,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1691,7 +1694,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputVariations(t *t
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.1")
+ userContent := ojson.GetBytesNoCopy(output, "contents.1")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 part (functionResponse with nested inlineData), got %d; raw: %s", len(parts), userContent.Raw)
@@ -1755,7 +1758,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ParallelFunctionCallOutputsWithIm
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.2")
+ userContent := ojson.GetBytesNoCopy(output, "contents.2")
if userContent.Get("role").String() != "user" {
t.Fatalf("expected role user in tool response content, got %s", userContent.Raw)
}
@@ -1765,7 +1768,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ParallelFunctionCallOutputsWithIm
t.Fatalf("expected 2 functionResponse parts, got %d; raw: %s", len(parts), userContent.Raw)
}
- gotByID := make(map[string]gjson.Result)
+ gotByID := make(map[string]ojson.Result)
for _, part := range parts {
fr := part.Get("functionResponse")
if !fr.Exists() {
@@ -1825,7 +1828,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputWithMultipleIma
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- userContent := gjson.GetBytes(output, "contents.2")
+ userContent := ojson.GetBytesNoCopy(output, "contents.2")
parts := userContent.Get("parts").Array()
if len(parts) != 1 {
t.Fatalf("expected 1 functionResponse part, got %d; raw: %s", len(parts), userContent.Raw)
@@ -1911,7 +1914,7 @@ func TestConvertOpenAIResponsesRequestToGemini_AdditionalToolsNamespaceAndCustom
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- decls := gjson.GetBytes(output, "tools.0.functionDeclarations").Array()
+ decls := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations").Array()
if len(decls) != 2 {
t.Fatalf("expected 2 functionDeclarations, got %d; raw: %s", len(decls), output)
}
@@ -1929,11 +1932,11 @@ func TestConvertOpenAIResponsesRequestToGemini_AdditionalToolsNamespaceAndCustom
t.Fatalf("decl 1 name = %q, want functions__continuity_probe", got)
}
- mode := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String()
+ mode := ojson.GetBytesNoCopy(output, "toolConfig.functionCallingConfig.mode").String()
if mode != "ANY" {
t.Fatalf("toolConfig mode = %q, want ANY", mode)
}
- allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
+ allowed := ojson.GetBytesNoCopy(output, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String()
if allowed != "functions__continuity_probe" {
t.Fatalf("allowedFunctionNames = %q, want functions__continuity_probe", allowed)
}
@@ -1971,7 +1974,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ReplaysCustomToolCallAndOutput(t
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) < 2 {
t.Fatalf("expected at least 2 contents, got %d; raw: %s", len(contents), output)
}
@@ -2031,7 +2034,7 @@ func TestConvertOpenAIResponsesRequestToGemini_TwoTurnCustomToolRoundtripWithRea
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents (user, model, user), got %d; raw: %s", len(contents), output)
}
@@ -2125,7 +2128,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputAlternateIDsAnd
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d; output=%s", len(contents), string(output))
}
@@ -2173,7 +2176,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ParallelFunctionCallOutputsAltern
t.Fatalf("parallel tool pairing validation failed: %v; output=%s", errValidate, string(output))
}
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents (user, model, user), got %d; output=%s", len(contents), string(output))
}
@@ -2217,7 +2220,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DedicatedCallIDTakesPrecedenceOve
t.Fatalf("pairing validation failed: %v; output=%s", errValidate, string(output))
}
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d; output=%s", len(contents), string(output))
}
@@ -2262,7 +2265,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ExplicitUnmatchedCallIDNotRebound
}
unmatchedTextFound := false
- for _, content := range gjson.GetBytes(output, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(output, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if fr := part.Get("functionResponse"); fr.Exists() {
if fr.Get("id").String() == "call_other" || fr.Get("response.result").String() == "other_result" {
@@ -2298,7 +2301,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MixedMissingAndExplicitParallelOu
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
resultMap := make(map[string]string)
for _, c := range contents {
@@ -2337,7 +2340,7 @@ func TestConvertOpenAIResponsesRequestToGemini_AllPendingCallsReservedByFutureEx
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.7-flash-high", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
var responseIDs []string
var responseResults []string
@@ -2389,12 +2392,12 @@ func TestConvertOpenAIResponsesRequestToGemini_InterruptedFunctionCallPreservesP
}
// Verify synthesized response for c1
- c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(result, "contents.2.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected synthesized response for c1: %s", c1Resp.Raw)
}
// Verify real response for c2
- c2Resp := gjson.GetBytes(result, "contents.5.parts.0.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(result, "contents.5.parts.0.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected real response for c2: %s", c2Resp.Raw)
}
@@ -2420,11 +2423,11 @@ func TestConvertOpenAIResponsesRequestToGemini_ParallelInterruptedFunctionCallPr
}
// In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real)
- c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(result, "contents.2.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(result, "contents.2.parts.1.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(result, "contents.2.parts.1.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw)
}
@@ -2447,11 +2450,11 @@ func TestConvertOpenAIResponsesRequestToGemini_TrailingPartialParallelCallsPrese
}
// In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real)
- c1Resp := gjson.GetBytes(result, "contents.2.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(result, "contents.2.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(result, "contents.2.parts.1.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(result, "contents.2.parts.1.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw)
}
@@ -2475,16 +2478,16 @@ func TestConvertOpenAIResponsesRequestToGemini_InterruptedMessageBeforeRealOutpu
}
// The user message precedes the completed tool response turn
- stopText := gjson.GetBytes(result, "contents.2.parts.0.text").String()
+ stopText := ojson.GetBytesNoCopy(result, "contents.2.parts.0.text").String()
if stopText != "Stop, do something else instead." {
t.Fatalf("unexpected text in contents[2]: %q", stopText)
}
// In the response turn for [c1, c2], c1 must be first (synthesized) and c2 must be second (real)
- c1Resp := gjson.GetBytes(result, "contents.3.parts.0.functionResponse")
+ c1Resp := ojson.GetBytesNoCopy(result, "contents.3.parts.0.functionResponse")
if c1Resp.Get("id").String() != "c1" || c1Resp.Get("name").String() != "shell" || c1Resp.Get("response.result").String() != "call interrupted, no output" {
t.Fatalf("unexpected response part 0 for c1: %s", c1Resp.Raw)
}
- c2Resp := gjson.GetBytes(result, "contents.3.parts.1.functionResponse")
+ c2Resp := ojson.GetBytesNoCopy(result, "contents.3.parts.1.functionResponse")
if c2Resp.Get("id").String() != "c2" || c2Resp.Get("name").String() != "shell" || c2Resp.Get("response.result").String() != "/tmp\n" {
t.Fatalf("unexpected response part 1 for c2: %s", c2Resp.Raw)
}
@@ -2505,7 +2508,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionCallOutputWithFCOItemID(t
t.Fatalf("pairing validation failed: %v; output=%s", errValidate, string(output))
}
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d; output=%s", len(contents), string(output))
}
@@ -2544,7 +2547,7 @@ func TestConvertOpenAIResponsesRequestToGemini_OrphanFunctionCallOutputBecomesUs
delegationFound := false
bashCallID := ""
bashResponseID := ""
- for _, content := range gjson.GetBytes(output, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(output, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if fr := part.Get("functionResponse"); fr.Exists() {
if fr.Get("id").String() == "" {
@@ -2591,7 +2594,7 @@ func TestConvertOpenAIResponsesRequestToGemini_UnpairedExplicitCallIDBecomesUser
delegationFound := false
bashResponseID := ""
- for _, content := range gjson.GetBytes(output, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(output, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if fr := part.Get("functionResponse"); fr.Exists() {
if fr.Get("id").String() == "call_missing" {
@@ -2651,18 +2654,18 @@ func TestConvertOpenAIResponsesRequestToGemini_ParametersJsonSchema_PreservesAdd
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
+ schema := ojson.GetBytesNoCopy(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
if !schema.Exists() {
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
}
- if got := schema.Get("additionalProperties"); !got.Exists() || got.Type != gjson.False {
+ if got := schema.Get("additionalProperties"); !got.Exists() || got.Type != ojson.False {
t.Fatalf("root additionalProperties should be preserved as false, got: %v. Schema: %s", got, schema.Raw)
}
if got := schema.Get("properties.recipient.pattern"); !got.Exists() || got.String() != "^(alice|bob)$" {
t.Fatalf("pattern should be preserved, got: %v. Schema: %s", got, schema.Raw)
}
- if got := schema.Get("properties.nested.additionalProperties"); !got.Exists() || got.Type != gjson.False {
+ if got := schema.Get("properties.nested.additionalProperties"); !got.Exists() || got.Type != ojson.False {
t.Fatalf("nested additionalProperties should be preserved as false, got: %v. Schema: %s", got, schema.Raw)
}
if got := schema.Get("properties.nested.properties.tag.pattern"); !got.Exists() || got.String() != "^[a-z]+$" {
@@ -2785,7 +2788,7 @@ func TestConvertOpenAIResponsesRequestToGemini_AudioInput(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(tc.inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) == 0 {
t.Fatalf("expected at least 1 content, got 0. Output: %s", output)
}
@@ -2907,7 +2910,7 @@ func TestConvertOpenAIResponsesRequestToGemini_VideoInput(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(tc.inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) == 0 {
t.Fatalf("expected at least 1 content, got 0. Output: %s", output)
}
@@ -2941,7 +2944,7 @@ func TestConvertOpenAIResponsesRequestToGemini_TopLevelVideoAndTextCombined(t *t
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) != 1 {
t.Fatalf("expected exactly 1 content, got %d. Output: %s", len(contents), output)
}
@@ -2988,7 +2991,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FallbackMIMEOnGenericDataURL(t *t
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(imgInput), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 9 {
t.Fatalf("expected 9 parts, got %d. Output: %s", len(parts), output)
}
@@ -3038,7 +3041,7 @@ func TestConvertOpenAIResponsesRequestToGemini_RemoteURLDoesNotOverrideExplicitF
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 2 {
t.Fatalf("expected 2 parts, got %d. Output: %s", len(parts), output)
}
@@ -3067,7 +3070,7 @@ func TestConvertOpenAIResponsesRequestToGemini_UnknownExtensionFallsBackToDefaul
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 2 {
t.Fatalf("expected 2 parts, got %d. Output: %s", len(parts), output)
}
@@ -3096,7 +3099,7 @@ func TestConvertOpenAIResponsesRequestToGemini_InlineExplicitFormatNotOverridden
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 2 {
t.Fatalf("expected 2 parts, got %d. Output: %s", len(parts), output)
}
@@ -3128,7 +3131,7 @@ func TestConvertOpenAIResponsesRequestToGemini_RemoteNestedFormatNotOverriddenBy
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 5 {
t.Fatalf("expected 5 parts, got %d. Output: %s", len(parts), output)
}
@@ -3167,7 +3170,7 @@ func TestConvertOpenAIResponsesRequestToGemini_GenericMIMEWithNestedFormatNotOve
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 3 {
t.Fatalf("expected 3 parts, got %d. Output: %s", len(parts), output)
}
@@ -3203,7 +3206,7 @@ func TestConvertOpenAIResponsesRequestToGemini_RemoteMedia(t *testing.T) {
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
if len(contents) == 0 {
t.Fatalf("expected at least 1 content, got 0. Output: %s", output)
}
@@ -3362,7 +3365,7 @@ func TestConvertOpenAIResponsesRequestToGemini_InvalidDataURLsRejected(t *testin
t.Run(tc.name, func(t *testing.T) {
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-flash", []byte(tc.inputJSON), false)
// No inline_data should be created for invalid data URLs
- contents := gjson.GetBytes(output, "contents").Array()
+ contents := ojson.GetBytesNoCopy(output, "contents").Array()
for _, content := range contents {
for _, part := range content.Get("parts").Array() {
if part.Get("inline_data").Exists() {
@@ -3405,8 +3408,8 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionResponseJSONRef(t *testin
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false)
- result := gjson.GetBytes(output, "contents.1.parts.0.functionResponse.response.result")
- if result.Type != gjson.String {
+ result := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse.response.result")
+ if result.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got type %s (raw: %s)", result.Type, result.Raw)
}
if !strings.Contains(result.String(), "#/components/schemas/ErrorModel") {
@@ -3438,8 +3441,8 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionResponseJSONRef(t *testin
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false)
- result := gjson.GetBytes(output, "contents.1.parts.0.functionResponse.response.result")
- if result.Type != gjson.String {
+ result := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse.response.result")
+ if result.Type != ojson.String {
t.Fatalf("expected functionResponse.response.result to be string, got type %s (raw: %s)", result.Type, result.Raw)
}
if !strings.Contains(result.String(), "#/components/schemas/ErrorModel") {
@@ -3468,7 +3471,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionResponseJSONRef(t *testin
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false)
- result := gjson.GetBytes(output, "contents.1.parts.0.functionResponse.response.result")
+ result := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse.response.result")
if !result.IsObject() {
t.Fatalf("expected functionResponse.response.result to remain JSON object, got type %s (raw: %s)", result.Type, result.Raw)
}
@@ -3505,7 +3508,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionResponseJSONRef(t *testin
]
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false)
- fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
+ fr := ojson.GetBytesNoCopy(output, "contents.1.parts.0.functionResponse")
if !fr.Exists() {
t.Fatalf("expected functionResponse part, got %s", output)
}
@@ -3517,7 +3520,7 @@ func TestConvertOpenAIResponsesRequestToGemini_FunctionResponseJSONRef(t *testin
t.Fatalf("expected mimeType 'image/png', got %q", got)
}
result := fr.Get("response.result")
- if result.Type != gjson.String {
+ if result.Type != ojson.String {
t.Fatalf("expected string result, got %s (raw: %s)", result.Type, result.Raw)
}
if !strings.Contains(result.String(), "#/components/schemas/ErrorModel") {
@@ -3546,7 +3549,7 @@ func TestConvertOpenAIResponsesRequestToGemini_UnsignedModelTextDoesNotSynthesiz
}`
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash", []byte(inputJSON), false)
- contents := gjson.GetBytes(output, "contents")
+ contents := ojson.GetBytesNoCopy(output, "contents")
if len(contents.Array()) != 1 {
t.Fatalf("expected 1 content, got %d (raw: %s)", len(contents.Array()), output)
}
@@ -3574,7 +3577,7 @@ func TestConvertOpenAIResponsesRequestToGemini_UnsignedModelTextDoesNotSynthesiz
// Verify with sanitizer: this payload must NOT trigger any drop or bypass replacement
sanitized := internalsignature.SanitizeGeminiRequestThoughtSignatures(output, "contents")
- sanitizedParts := gjson.GetBytes(sanitized, "contents.0.parts").Array()
+ sanitizedParts := ojson.GetBytesNoCopy(sanitized, "contents.0.parts").Array()
if len(sanitizedParts) != 2 {
t.Fatalf("sanitizer altered parts count: %s", sanitized)
}
@@ -3582,3 +3585,108 @@ func TestConvertOpenAIResponsesRequestToGemini_UnsignedModelTextDoesNotSynthesiz
t.Fatalf("sanitizer left unexpected thoughtSignature on text part: %s", sanitizedParts[1].Raw)
}
}
+
+func TestConvertOpenAIResponsesRequestToGemini_ParallelOutputsAcrossNotices(t *testing.T) {
+ for _, role := range []string{"developer", "system"} {
+ for _, image := range []bool{false, true} {
+ for _, reverse := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/image=%t/reverse=%t", role, image, reverse), func(t *testing.T) {
+ items := []map[string]any{{"type": "message", "role": "user", "content": "Inspect files."}}
+ for i := 0; i < 4; i++ {
+ items = append(items, map[string]any{"type": "function_call", "call_id": fmt.Sprintf("call-%d", i), "name": "view_image", "arguments": "{}"})
+ }
+ for i := 0; i < 4; i++ {
+ id := i
+ if reverse {
+ id = 3 - i
+ }
+ var output any = fmt.Sprintf("result-%d", id)
+ if image {
+ output = []map[string]any{
+ {"type": "input_text", "text": fmt.Sprintf("result-%d", id)},
+ {"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="},
+ }
+ }
+ items = append(items, map[string]any{"type": "function_call_output", "call_id": fmt.Sprintf("call-%d", id), "output": output})
+ // Codex also emits role-only messages without an explicit type.
+ note := map[string]any{"role": role, "content": fmt.Sprintf("notice-%d ", i)}
+ if i%2 == 0 {
+ note["type"] = "message"
+ }
+ items = append(items, note)
+ }
+ input, errMarshal := json.Marshal(map[string]any{"model": "gemini-4-argon", "input": items})
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ result, errConvert := ConvertOpenAIResponsesRequestToGemini("gemini-4-argon", input, false)
+ if errConvert != nil {
+ t.Fatal(errConvert)
+ }
+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(result); errValidate != nil {
+ t.Fatalf("invalid parallel results: %v; result=%s", errValidate, result)
+ }
+ contents := ojson.GetBytesNoCopy(result, "contents").Array()
+ if len(contents) != 4 {
+ t.Fatalf("got %d turns, want user/calls/results/notices; result=%s", len(contents), result)
+ }
+ results := contents[2].Get("parts").Array()
+ if len(results) != 4 {
+ t.Fatalf("got %d responses, want 4", len(results))
+ }
+ for i, part := range results {
+ response := part.Get("functionResponse")
+ if got := response.Get("id").String(); got != fmt.Sprintf("call-%d", i) {
+ t.Fatalf("response %d has id %q", i, got)
+ }
+ if got := response.Get("response.result").String(); got != fmt.Sprintf("result-%d", i) {
+ t.Fatalf("response %d lost its text: %q", i, got)
+ }
+ if image && response.Get("parts.0.inlineData.data").String() != "aGVsbG8=" {
+ t.Fatalf("response %d lost its image: %s", i, part.Raw)
+ }
+ }
+ for i := 0; i < 4; i++ {
+ if strings.Count(contents[3].Raw, fmt.Sprintf("notice-%d", i)) != 1 {
+ t.Fatalf("notice %d missing or duplicated: %s", i, contents[3].Raw)
+ }
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestCollectOpenAIResponsesFunctionCallOutputs_PreservesTurnBoundaries(t *testing.T) {
+ for _, boundary := range []string{
+ `{"type":"message","role":"user","content":"new request"}`,
+ `{"type":"message","role":"assistant","content":"next response"}`,
+ `{"type":"function_call","call_id":"new","name":"next","arguments":"{}"}`,
+ `{"type":"reasoning","summary":[]}`,
+ } {
+ items := ojson.Parse(`[
+ {"type":"function_call_output","call_id":"one","output":"first"},
+ {"type":"message","role":"developer","content":"notice"},
+ ` + boundary + `,
+ {"type":"function_call_output","call_id":"two","output":"second"}
+ ]`).Array()
+ outputs, consumed, remaining := collectOpenAIResponsesFunctionCallOutputs(items, 0, []string{"one", "two"})
+ if len(outputs) != 1 || len(consumed) != 1 || !consumed[0] || len(remaining) != 1 || remaining[0] != "two" {
+ t.Fatalf("crossed boundary %s: outputs=%v consumed=%v remaining=%v", boundary, outputs, consumed, remaining)
+ }
+ }
+}
+
+func TestCollectOpenAIResponsesFunctionCallOutputs_DoesNotMoveOrphansAcrossNotices(t *testing.T) {
+ items := ojson.Parse(`[
+ {"type":"function_call_output","call_id":"one","output":"first"},
+ {"type":"message","role":"developer","content":"notice"},
+ {"type":"function_call_output","call_id":"orphan","output":"unrelated"}
+ ]`).Array()
+ for _, pending := range [][]string{{"one"}, nil} {
+ outputs, consumed, remaining := collectOpenAIResponsesFunctionCallOutputs(items, 0, pending)
+ if len(outputs) != 1 || len(consumed) != 1 || !consumed[0] || len(remaining) != 0 {
+ t.Fatalf("moved orphan across notice: outputs=%v consumed=%v remaining=%v", outputs, consumed, remaining)
+ }
+ }
+}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
index 613523749e7..22c32f675d6 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
@@ -9,10 +9,9 @@ import (
"time"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type geminiDetachedReasoningItem struct {
@@ -56,7 +55,7 @@ type geminiResponsesUsage struct {
Cached int64
}
-func (usage *geminiResponsesUsage) Merge(root gjson.Result) bool {
+func (usage *geminiResponsesUsage) Merge(root ojson.Result) bool {
metadata := root.Get("usageMetadata")
if !metadata.Exists() {
metadata = root.Get("cpaUsageMetadata")
@@ -83,13 +82,13 @@ func (usage *geminiResponsesUsage) Merge(root gjson.Result) bool {
}
func (usage geminiResponsesUsage) JSON() []byte {
- out := []byte(`{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`)
- out, _ = sjson.SetBytes(out, "input_tokens", usage.Prompt)
- out, _ = sjson.SetBytes(out, "input_tokens_details.cached_tokens", usage.Cached)
- out, _ = sjson.SetBytes(out, "output_tokens", usage.Candidates+usage.Thoughts)
- out, _ = sjson.SetBytes(out, "output_tokens_details.reasoning_tokens", usage.Thoughts)
- out, _ = sjson.SetBytes(out, "total_tokens", usage.Total)
- return out
+ outDoc := ojson.NewDocString(`{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}`)
+ _ = outDoc.Set("input_tokens", usage.Prompt)
+ _ = outDoc.Set("input_tokens_details.cached_tokens", usage.Cached)
+ _ = outDoc.Set("output_tokens", usage.Candidates+usage.Thoughts)
+ _ = outDoc.Set("output_tokens_details.reasoning_tokens", usage.Thoughts)
+ _ = outDoc.Set("total_tokens", usage.Total)
+ return outDoc.Bytes()
}
func geminiResponsesTerminalState(finishReason string) (eventType, status string, incompleteDetails []byte) {
@@ -172,7 +171,7 @@ type geminiToResponsesState struct {
WebSearchAnnotationsAttached bool
WebSearchBufferedDeltas []string
WebSearchBufferedParts []geminiStreamBufferedPart
- RawGroundingMetadata gjson.Result
+ RawGroundingMetadata ojson.Result
PartMappings []GeminiPartMapping
StreamPartIndex int
CurrentLogicalPartIndex int
@@ -195,16 +194,16 @@ var responseIDCounter uint64
var funcCallIDCounter uint64
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
- if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
+ if len(originalRequestRawJSON) > 0 && ojson.ValidBytes(originalRequestRawJSON) {
return originalRequestRawJSON
}
- if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
+ if len(requestRawJSON) > 0 && ojson.ValidBytes(requestRawJSON) {
return requestRawJSON
}
return nil
}
-func unwrapRequestRoot(root gjson.Result) gjson.Result {
+func unwrapRequestRoot(root ojson.Result) ojson.Result {
req := root.Get("request")
if !req.Exists() {
return root
@@ -215,18 +214,73 @@ func unwrapRequestRoot(root gjson.Result) gjson.Result {
return root
}
-func unwrapGeminiResponseRoot(root gjson.Result) gjson.Result {
+func unwrapGeminiResponseRoot(root ojson.Result) ojson.Result {
resp := root.Get("response")
- if !resp.Exists() {
- return root
+ if resp.Exists() {
+ // Vertex-style Gemini responses wrap the actual payload in a "response" object.
+ if resp.Get("candidates").Exists() || resp.Get("responseId").Exists() || resp.Get("usageMetadata").Exists() || resp.Get("cpaUsageMetadata").Exists() {
+ return resp
+ }
}
- // Vertex-style Gemini responses wrap the actual payload in a "response" object.
- if resp.Get("candidates").Exists() || resp.Get("responseId").Exists() || resp.Get("usageMetadata").Exists() || resp.Get("cpaUsageMetadata").Exists() {
- return resp
+ if !root.Get("candidates.0.content.parts").Exists() && !ojson.Valid(root.Raw) {
+ if repaired := closeTruncatedJSON(root.Raw); repaired != "" {
+ if repairedRoot := ojson.Parse(repaired); repairedRoot.Get("candidates.0.content.parts").Exists() {
+ return repairedRoot
+ }
+ }
}
return root
}
+func closeTruncatedJSON(raw string) string {
+ var stack []byte
+ inString := false
+ escaped := false
+ for i := 0; i < len(raw); i++ {
+ c := raw[i]
+ if inString {
+ if escaped {
+ escaped = false
+ continue
+ }
+ if c == '\\' {
+ escaped = true
+ } else if c == '"' {
+ inString = false
+ }
+ continue
+ }
+ switch c {
+ case '"':
+ inString = true
+ case '{':
+ stack = append(stack, '}')
+ case '[':
+ stack = append(stack, ']')
+ case '}', ']':
+ if len(stack) > 0 && stack[len(stack)-1] == c {
+ stack = stack[:len(stack)-1]
+ }
+ }
+ }
+ if !inString && len(stack) == 0 {
+ return ""
+ }
+ var b strings.Builder
+ b.Grow(len(raw) + len(stack) + 2)
+ b.WriteString(raw)
+ if inString {
+ if escaped {
+ b.WriteByte('\\')
+ }
+ b.WriteByte('"')
+ }
+ for i := len(stack) - 1; i >= 0; i-- {
+ b.WriteByte(stack[i])
+ }
+ return b.String()
+}
+
func emitEvent(event string, payload []byte) []byte {
return translatorcommon.SSEEventData(event, payload)
}
@@ -235,11 +289,11 @@ func hasEffectiveGoogleSearchTool(rawJSON []byte) bool {
if len(rawJSON) == 0 {
return false
}
- if gjson.GetBytes(rawJSON, "requestType").String() == "web_search" {
+ if ojson.GetBytesNoCopy(rawJSON, "requestType").String() == "web_search" {
return true
}
for _, path := range []string{"request.tools", "tools"} {
- tools := gjson.GetBytes(rawJSON, path)
+ tools := ojson.GetBytesNoCopy(rawJSON, path)
if !tools.IsArray() {
continue
}
@@ -256,11 +310,11 @@ func isUpstreamGeminiRequest(rawJSON []byte) bool {
if len(rawJSON) == 0 {
return false
}
- if gjson.GetBytes(rawJSON, "requestType").Exists() {
+ if ojson.GetBytesNoCopy(rawJSON, "requestType").Exists() {
return true
}
for _, path := range []string{"contents", "request.contents"} {
- if gjson.GetBytes(rawJSON, path).Exists() {
+ if ojson.GetBytesNoCopy(rawJSON, path).Exists() {
return true
}
}
@@ -269,13 +323,13 @@ func isUpstreamGeminiRequest(rawJSON []byte) bool {
func determineWebSearchStreamMode(modelName, requestModelName string, originalRequestRawJSON, requestRawJSON, reqJSON []byte) bool {
if len(originalRequestRawJSON) > 0 {
- origRoot := unwrapRequestRoot(gjson.ParseBytes(originalRequestRawJSON))
+ origRoot := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(originalRequestRawJSON))
if !AllowsResponsesWebSearchToolChoice(origRoot) {
return false
}
}
if len(requestRawJSON) > 0 {
- reqRoot := unwrapRequestRoot(gjson.ParseBytes(requestRawJSON))
+ reqRoot := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(requestRawJSON))
if reqRoot.Get("tool_choice").Exists() && !AllowsResponsesWebSearchToolChoice(reqRoot) {
return false
}
@@ -287,7 +341,7 @@ func determineWebSearchStreamMode(modelName, requestModelName string, originalRe
reqJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON)
}
if len(reqJSON) > 0 {
- reqRoot := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
+ reqRoot := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(reqJSON))
return HasResponsesWebSearchTool(reqRoot) &&
AllowsResponsesWebSearchToolChoice(reqRoot) &&
(ModelSupportsWebSearch(modelName) || ModelSupportsWebSearch(requestModelName))
@@ -298,7 +352,7 @@ func determineWebSearchStreamMode(modelName, requestModelName string, originalRe
// ConvertGeminiResponseToOpenAIResponses converts Gemini SSE chunks into OpenAI Responses SSE events.
func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
if *param == nil {
- reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ reqJSON := bytes.Clone(pickRequestJSON(originalRequestRawJSON, requestRawJSON))
*param = &geminiToResponsesState{
FuncArgsBuf: make(map[int]*strings.Builder),
FuncInputBuf: make(map[int]string),
@@ -322,7 +376,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
}
st := (*param).(*geminiToResponsesState)
if !st.RequestResolved {
- st.RequestJSON = pickRequestJSON(originalRequestRawJSON, requestRawJSON)
+ st.RequestJSON = bytes.Clone(pickRequestJSON(originalRequestRawJSON, requestRawJSON))
st.RequestResolved = true
}
reqJSON := st.RequestJSON
@@ -394,7 +448,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
rawJSON = []byte(`{}`)
}
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
if !root.Exists() {
return [][]byte{}
}
@@ -419,23 +473,23 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.WebSearchQuery = st.WebSearchQueries[0]
}
if st.WebSearchQuery == "" && len(reqJSON) > 0 {
- reqRoot := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
- st.WebSearchQuery = ExtractResponsesWebSearchQuery(reqRoot)
+ reqRoot := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(reqJSON))
+ st.WebSearchQuery = strings.Clone(ExtractResponsesWebSearchQuery(reqRoot))
}
- completed := []byte(`{"type":"response.web_search_call.completed","sequence_number":0,"output_index":0,"item_id":""}`)
- completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq())
- completed, _ = sjson.SetBytes(completed, "output_index", st.WebSearchIndex)
- completed, _ = sjson.SetBytes(completed, "item_id", st.WebSearchItemID)
- out = append(out, emitEvent("response.web_search_call.completed", completed))
+ completedDoc := ojson.NewDocString(`{"type":"response.web_search_call.completed","sequence_number":0,"output_index":0,"item_id":""}`)
+ _ = completedDoc.Set("sequence_number", nextSeq())
+ _ = completedDoc.Set("output_index", st.WebSearchIndex)
+ _ = completedDoc.Set("item_id", st.WebSearchItemID)
+ out = append(out, emitEvent("response.web_search_call.completed", completedDoc.Bytes()))
doneItem := BuildResponsesWebSearchCallItem(st.WebSearchItemID, st.WebSearchQuery, st.WebSearchQueries, st.WebSearchSources)
st.WebSearchDoneItem = doneItem
- doneEvent := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0}`)
- doneEvent, _ = sjson.SetBytes(doneEvent, "sequence_number", nextSeq())
- doneEvent, _ = sjson.SetBytes(doneEvent, "output_index", st.WebSearchIndex)
- doneEvent, _ = sjson.SetRawBytes(doneEvent, "item", doneItem)
- out = append(out, emitEvent("response.output_item.done", doneEvent))
+ doneEventDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0}`)
+ _ = doneEventDoc.Set("sequence_number", nextSeq())
+ _ = doneEventDoc.Set("output_index", st.WebSearchIndex)
+ _ = doneEventDoc.SetRawBytes("item", doneItem)
+ out = append(out, emitEvent("response.output_item.done", doneEventDoc.Bytes()))
st.WebSearchDone = true
}
openReasoning := func() {
@@ -447,24 +501,24 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.ReasoningIndex = st.NextIndex
st.NextIndex++
st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, st.ReasoningIndex)
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
- item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID)
- item, _ = sjson.SetBytes(item, "item.encrypted_content", reasoningEncryptedContent())
- out = append(out, emitEvent("response.output_item.added", item))
- partAdded := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- partAdded, _ = sjson.SetBytes(partAdded, "sequence_number", nextSeq())
- partAdded, _ = sjson.SetBytes(partAdded, "item_id", st.ReasoningItemID)
- partAdded, _ = sjson.SetBytes(partAdded, "output_index", st.ReasoningIndex)
- out = append(out, emitEvent("response.reasoning_summary_part.added", partAdded))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", st.ReasoningIndex)
+ _ = itemDoc.Set("item.id", st.ReasoningItemID)
+ _ = itemDoc.Set("item.encrypted_content", reasoningEncryptedContent())
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
+ partAddedDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partAddedDoc.Set("sequence_number", nextSeq())
+ _ = partAddedDoc.Set("item_id", st.ReasoningItemID)
+ _ = partAddedDoc.Set("output_index", st.ReasoningIndex)
+ out = append(out, emitEvent("response.reasoning_summary_part.added", partAddedDoc.Bytes()))
for _, delta := range st.ReasoningPendingDeltas {
- msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningItemID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
- msg, _ = sjson.SetBytes(msg, "delta", delta)
- out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.ReasoningItemID)
+ _ = msgDoc.Set("output_index", st.ReasoningIndex)
+ _ = msgDoc.Set("delta", delta)
+ out = append(out, emitEvent("response.reasoning_summary_text.delta", msgDoc.Bytes()))
}
st.ReasoningPendingDeltas = nil
}
@@ -478,27 +532,27 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
return
}
full := st.ReasoningBuf.String()
- textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
- textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq())
- textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningItemID)
- textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex)
- textDone, _ = sjson.SetBytes(textDone, "text", full)
- out = append(out, emitEvent("response.reasoning_summary_text.done", textDone))
-
- partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
- partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningItemID)
- partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
- partDone, _ = sjson.SetBytes(partDone, "part.text", full)
- out = append(out, emitEvent("response.reasoning_summary_part.done", partDone))
-
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID)
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", reasoningEncryptedContent())
- itemDone, _ = sjson.SetBytes(itemDone, "item.summary.0.text", full)
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ textDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
+ _ = textDoneDoc.Set("sequence_number", nextSeq())
+ _ = textDoneDoc.Set("item_id", st.ReasoningItemID)
+ _ = textDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = textDoneDoc.Set("text", full)
+ out = append(out, emitEvent("response.reasoning_summary_text.done", textDoneDoc.Bytes()))
+
+ partDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextSeq())
+ _ = partDoneDoc.Set("item_id", st.ReasoningItemID)
+ _ = partDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = partDoneDoc.Set("part.text", full)
+ out = append(out, emitEvent("response.reasoning_summary_part.done", partDoneDoc.Bytes()))
+
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("item.id", st.ReasoningItemID)
+ _ = itemDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = itemDoneDoc.Set("item.encrypted_content", reasoningEncryptedContent())
+ _ = itemDoneDoc.Set("item.summary.0.text", full)
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.CompletedReasoning[st.ReasoningIndex] = geminiCompletedReasoningItem{
ID: st.ReasoningItemID,
@@ -533,22 +587,22 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.WebSearchQuery = st.WebSearchQueries[0]
}
if st.WebSearchQuery == "" && len(reqJSON) > 0 {
- reqRoot := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
- st.WebSearchQuery = ExtractResponsesWebSearchQuery(reqRoot)
+ reqRoot := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(reqJSON))
+ st.WebSearchQuery = strings.Clone(ExtractResponsesWebSearchQuery(reqRoot))
}
- added := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextSeq())
- added, _ = sjson.SetBytes(added, "output_index", st.WebSearchIndex)
- added, _ = sjson.SetBytes(added, "item.id", st.WebSearchItemID)
- added, _ = sjson.SetBytes(added, "item.action.query", st.WebSearchQuery)
- out = append(out, emitEvent("response.output_item.added", added))
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}}`)
+ _ = addedDoc.Set("sequence_number", nextSeq())
+ _ = addedDoc.Set("output_index", st.WebSearchIndex)
+ _ = addedDoc.Set("item.id", st.WebSearchItemID)
+ _ = addedDoc.Set("item.action.query", st.WebSearchQuery)
+ out = append(out, emitEvent("response.output_item.added", addedDoc.Bytes()))
- searching := []byte(`{"type":"response.web_search_call.searching","sequence_number":0,"output_index":0,"item_id":""}`)
- searching, _ = sjson.SetBytes(searching, "sequence_number", nextSeq())
- searching, _ = sjson.SetBytes(searching, "output_index", st.WebSearchIndex)
- searching, _ = sjson.SetBytes(searching, "item_id", st.WebSearchItemID)
- out = append(out, emitEvent("response.web_search_call.searching", searching))
+ searchingDoc := ojson.NewDocString(`{"type":"response.web_search_call.searching","sequence_number":0,"output_index":0,"item_id":""}`)
+ _ = searchingDoc.Set("sequence_number", nextSeq())
+ _ = searchingDoc.Set("output_index", st.WebSearchIndex)
+ _ = searchingDoc.Set("item_id", st.WebSearchItemID)
+ out = append(out, emitEvent("response.web_search_call.searching", searchingDoc.Bytes()))
}
flushWebSearchBufferedText := func() {
@@ -567,27 +621,27 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.MsgIndex = st.NextIndex
st.NextIndex++
st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, st.MsgIndex)
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", st.MsgIndex)
- item, _ = sjson.SetBytes(item, "item.id", st.CurrentMsgID)
- out = append(out, emitEvent("response.output_item.added", item))
- partAdded := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- partAdded, _ = sjson.SetBytes(partAdded, "sequence_number", nextSeq())
- partAdded, _ = sjson.SetBytes(partAdded, "item_id", st.CurrentMsgID)
- partAdded, _ = sjson.SetBytes(partAdded, "output_index", st.MsgIndex)
- out = append(out, emitEvent("response.content_part.added", partAdded))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", st.MsgIndex)
+ _ = itemDoc.Set("item.id", st.CurrentMsgID)
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
+ partAddedDoc := ojson.NewDocString(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partAddedDoc.Set("sequence_number", nextSeq())
+ _ = partAddedDoc.Set("item_id", st.CurrentMsgID)
+ _ = partAddedDoc.Set("output_index", st.MsgIndex)
+ out = append(out, emitEvent("response.content_part.added", partAddedDoc.Bytes()))
st.ItemTextBuf.Reset()
st.CurrentMsgRuneOffset = 0
}
for _, delta := range st.WebSearchBufferedDeltas {
st.ItemTextBuf.WriteString(delta)
- msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.CurrentMsgID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.MsgIndex)
- msg, _ = sjson.SetBytes(msg, "delta", delta)
- out = append(out, emitEvent("response.output_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.CurrentMsgID)
+ _ = msgDoc.Set("output_index", st.MsgIndex)
+ _ = msgDoc.Set("delta", delta)
+ out = append(out, emitEvent("response.output_text.delta", msgDoc.Bytes()))
}
for _, bp := range st.WebSearchBufferedParts {
n := len(st.PartMappings)
@@ -610,15 +664,15 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
emitNewCitationAnnotations := func(msgIndex int, itemID string, annotations [][]byte) {
emitted := st.EmittedAnnotationCount[msgIndex]
for annIdx := emitted; annIdx < len(annotations); annIdx++ {
- annEvent := []byte(`{"type":"response.output_text.annotation.added","sequence_number":0,"response_id":"","item_id":"","output_index":0,"content_index":0,"annotation_index":0}`)
- annEvent, _ = sjson.SetBytes(annEvent, "sequence_number", nextSeq())
- annEvent, _ = sjson.SetBytes(annEvent, "response_id", st.ResponseID)
- annEvent, _ = sjson.SetBytes(annEvent, "item_id", itemID)
- annEvent, _ = sjson.SetBytes(annEvent, "output_index", msgIndex)
- annEvent, _ = sjson.SetBytes(annEvent, "content_index", 0)
- annEvent, _ = sjson.SetBytes(annEvent, "annotation_index", annIdx)
- annEvent, _ = sjson.SetRawBytes(annEvent, "annotation", annotations[annIdx])
- out = append(out, emitEvent("response.output_text.annotation.added", annEvent))
+ annEventDoc := ojson.NewDocString(`{"type":"response.output_text.annotation.added","sequence_number":0,"response_id":"","item_id":"","output_index":0,"content_index":0,"annotation_index":0}`)
+ _ = annEventDoc.Set("sequence_number", nextSeq())
+ _ = annEventDoc.Set("response_id", st.ResponseID)
+ _ = annEventDoc.Set("item_id", itemID)
+ _ = annEventDoc.Set("output_index", msgIndex)
+ _ = annEventDoc.Set("content_index", 0)
+ _ = annEventDoc.Set("annotation_index", annIdx)
+ _ = annEventDoc.SetRawBytes("annotation", annotations[annIdx])
+ out = append(out, emitEvent("response.output_text.annotation.added", annEventDoc.Bytes()))
}
if len(annotations) > emitted {
st.EmittedAnnotationCount[msgIndex] = len(annotations)
@@ -648,32 +702,32 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.WebSearchAnnotations = msgCitations
}
emitNewCitationAnnotations(st.MsgIndex, st.CurrentMsgID, msgCitations)
- done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
- done, _ = sjson.SetBytes(done, "item_id", st.CurrentMsgID)
- done, _ = sjson.SetBytes(done, "output_index", st.MsgIndex)
- done, _ = sjson.SetBytes(done, "text", fullText)
- out = append(out, emitEvent("response.output_text.done", done))
- partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
- partDone, _ = sjson.SetBytes(partDone, "item_id", st.CurrentMsgID)
- partDone, _ = sjson.SetBytes(partDone, "output_index", st.MsgIndex)
- partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
+ _ = doneDoc.Set("sequence_number", nextSeq())
+ _ = doneDoc.Set("item_id", st.CurrentMsgID)
+ _ = doneDoc.Set("output_index", st.MsgIndex)
+ _ = doneDoc.Set("text", fullText)
+ out = append(out, emitEvent("response.output_text.done", doneDoc.Bytes()))
+ partDoneDoc := ojson.NewDocString(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextSeq())
+ _ = partDoneDoc.Set("item_id", st.CurrentMsgID)
+ _ = partDoneDoc.Set("output_index", st.MsgIndex)
+ _ = partDoneDoc.Set("part.text", fullText)
if len(msgCitations) > 0 {
- partDone, _ = sjson.SetRawBytes(partDone, "part.annotations", translatorcommon.JoinRawArray(msgCitations))
- }
- out = append(out, emitEvent("response.content_part.done", partDone))
- final := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
- final, _ = sjson.SetBytes(final, "sequence_number", nextSeq())
- final, _ = sjson.SetBytes(final, "output_index", st.MsgIndex)
- final, _ = sjson.SetBytes(final, "item.id", st.CurrentMsgID)
- final, _ = sjson.SetBytes(final, "item.status", messageStatus)
- final, _ = sjson.SetBytes(final, "item.content.0.text", fullText)
+ _ = partDoneDoc.SetRawBytes("part.annotations", translatorcommon.JoinRawArray(msgCitations))
+ }
+ out = append(out, emitEvent("response.content_part.done", partDoneDoc.Bytes()))
+ finalDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
+ _ = finalDoc.Set("sequence_number", nextSeq())
+ _ = finalDoc.Set("output_index", st.MsgIndex)
+ _ = finalDoc.Set("item.id", st.CurrentMsgID)
+ _ = finalDoc.Set("item.status", messageStatus)
+ _ = finalDoc.Set("item.content.0.text", fullText)
if len(msgCitations) > 0 {
- final, _ = sjson.SetRawBytes(final, "item.content.0.annotations", translatorcommon.JoinRawArray(msgCitations))
+ _ = finalDoc.SetRawBytes("item.content.0.annotations", translatorcommon.JoinRawArray(msgCitations))
st.WebSearchAnnotationsAttached = true
}
- out = append(out, emitEvent("response.output_item.done", final))
+ out = append(out, emitEvent("response.output_item.done", finalDoc.Bytes()))
st.CompletedMessages[st.MsgIndex] = geminiCompletedMessageItem{
ID: st.CurrentMsgID,
@@ -733,19 +787,19 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
itemID := fmt.Sprintf("rs_%s_detached_%s_%d", st.ResponseID, placement, idx)
carrierSignature := encodeGeminiResponsesCarrier(signature, direction, targetKind)
- added := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextSeq())
- added, _ = sjson.SetBytes(added, "output_index", idx)
- added, _ = sjson.SetBytes(added, "item.id", itemID)
- added, _ = sjson.SetBytes(added, "item.encrypted_content", carrierSignature)
- out = append(out, emitEvent("response.output_item.added", added))
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
+ _ = addedDoc.Set("sequence_number", nextSeq())
+ _ = addedDoc.Set("output_index", idx)
+ _ = addedDoc.Set("item.id", itemID)
+ _ = addedDoc.Set("item.encrypted_content", carrierSignature)
+ out = append(out, emitEvent("response.output_item.added", addedDoc.Bytes()))
- done := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[]}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
- done, _ = sjson.SetBytes(done, "output_index", idx)
- done, _ = sjson.SetBytes(done, "item.id", itemID)
- done, _ = sjson.SetBytes(done, "item.encrypted_content", carrierSignature)
- out = append(out, emitEvent("response.output_item.done", done))
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[]}}`)
+ _ = doneDoc.Set("sequence_number", nextSeq())
+ _ = doneDoc.Set("output_index", idx)
+ _ = doneDoc.Set("item.id", itemID)
+ _ = doneDoc.Set("item.encrypted_content", carrierSignature)
+ out = append(out, emitEvent("response.output_item.done", doneDoc.Bytes()))
st.DetachedReasoning[idx] = geminiDetachedReasoningItem{Index: idx, ID: itemID, Signature: carrierSignature}
st.SeenReasoningSignatures[signature] = true
@@ -787,7 +841,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// Initialize per-response fields and emit created/in_progress once
if !st.Started {
- st.ResponseID = root.Get("responseId").String()
+ st.ResponseID = strings.Clone(root.Get("responseId").String())
if st.ResponseID == "" {
st.ResponseID = fmt.Sprintf("resp_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))
}
@@ -803,10 +857,10 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.CreatedAt = time.Now().Unix()
}
- created := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
- created, _ = sjson.SetBytes(created, "sequence_number", nextSeq())
- created, _ = sjson.SetBytes(created, "response.id", st.ResponseID)
- created, _ = sjson.SetBytes(created, "response.created_at", st.CreatedAt)
+ createdDoc := ojson.NewDocString(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
+ _ = createdDoc.Set("sequence_number", nextSeq())
+ _ = createdDoc.Set("response.id", st.ResponseID)
+ _ = createdDoc.Set("response.created_at", st.CreatedAt)
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" && len(reqJSON) > 0 {
requestModelName = translatorcommon.RequestModelName(reqJSON, nil)
@@ -815,18 +869,18 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
requestModelName = modelName
}
if requestModelName != "" {
- created, _ = sjson.SetBytes(created, "response.model", requestModelName)
+ _ = createdDoc.Set("response.model", requestModelName)
}
- out = append(out, emitEvent("response.created", created))
+ out = append(out, emitEvent("response.created", createdDoc.Bytes()))
- inprog := []byte(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
- inprog, _ = sjson.SetBytes(inprog, "sequence_number", nextSeq())
- inprog, _ = sjson.SetBytes(inprog, "response.id", st.ResponseID)
- inprog, _ = sjson.SetBytes(inprog, "response.created_at", st.CreatedAt)
+ inprogDoc := ojson.NewDocString(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
+ _ = inprogDoc.Set("sequence_number", nextSeq())
+ _ = inprogDoc.Set("response.id", st.ResponseID)
+ _ = inprogDoc.Set("response.created_at", st.CreatedAt)
if requestModelName != "" {
- inprog, _ = sjson.SetBytes(inprog, "response.model", requestModelName)
+ _ = inprogDoc.Set("response.model", requestModelName)
}
- out = append(out, emitEvent("response.in_progress", inprog))
+ out = append(out, emitEvent("response.in_progress", inprogDoc.Bytes()))
st.Started = true
st.NextIndex = 0
@@ -838,7 +892,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if st.RawGroundingMetadata.Exists() {
st.RawGroundingMetadata = MergeGroundingMetadata(st.RawGroundingMetadata, gm)
} else {
- st.RawGroundingMetadata = MergeGroundingMetadata(gjson.Result{}, gm)
+ st.RawGroundingMetadata = MergeGroundingMetadata(ojson.Result{}, gm)
}
mergedGM := st.RawGroundingMetadata
queries := ExtractGroundingQueries(mergedGM)
@@ -867,7 +921,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// Handle parts (text/thought/functionCall)
if parts := root.Get("candidates.0.content.parts"); parts.Exists() && parts.IsArray() {
- parts.ForEach(func(partIdxInChunk, part gjson.Result) bool {
+ parts.ForEach(func(partIdxInChunk, part ojson.Result) bool {
explicitPartIndex := -1
if p := part.Get("partIndex"); p.Exists() {
explicitPartIndex = int(p.Int())
@@ -875,9 +929,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
explicitPartIndex = int(p.Int())
}
- signature := strings.TrimSpace(part.Get("thoughtSignature").String())
+ signature := strings.Clone(strings.TrimSpace(part.Get("thoughtSignature").String()))
if signature == "" {
- signature = strings.TrimSpace(part.Get("thought_signature").String())
+ signature = strings.Clone(strings.TrimSpace(part.Get("thought_signature").String()))
}
functionCall := part.Get("functionCall")
text := part.Get("text")
@@ -1051,14 +1105,14 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.LastSemanticKind = geminiResponsesCarrierText
st.ReasoningBuf.WriteString(t.String())
if st.ReasoningOpened {
- msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningItemID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
- msg, _ = sjson.SetBytes(msg, "delta", t.String())
- out = append(out, emitEvent("response.reasoning_summary_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.ReasoningItemID)
+ _ = msgDoc.Set("output_index", st.ReasoningIndex)
+ _ = msgDoc.Set("delta", t.String())
+ out = append(out, emitEvent("response.reasoning_summary_text.delta", msgDoc.Bytes()))
} else {
- st.ReasoningPendingDeltas = append(st.ReasoningPendingDeltas, t.String())
+ st.ReasoningPendingDeltas = append(st.ReasoningPendingDeltas, strings.Clone(t.String()))
}
}
if !st.ReasoningOpened && st.ReasoningEnc != "" {
@@ -1089,15 +1143,16 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// (stream end / a later output item) so the completed search item includes
// incremental sources and strictly precedes the message.
if st.WebSearchStreamMode && !st.WebSearchDone {
+ textStr := strings.Clone(t.String())
st.LastSemanticKind = geminiResponsesCarrierText
- st.WebSearchBufferedDeltas = append(st.WebSearchBufferedDeltas, t.String())
+ st.WebSearchBufferedDeltas = append(st.WebSearchBufferedDeltas, textStr)
n := len(st.WebSearchBufferedParts)
if n > 0 && st.WebSearchBufferedParts[n-1].PartIndex == currentPartIndex {
- st.WebSearchBufferedParts[n-1].Text += t.String()
+ st.WebSearchBufferedParts[n-1].Text += textStr
} else {
st.WebSearchBufferedParts = append(st.WebSearchBufferedParts, geminiStreamBufferedPart{
PartIndex: currentPartIndex,
- Text: t.String(),
+ Text: textStr,
})
}
st.TextPartRunActive = true
@@ -1109,16 +1164,16 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.MsgIndex = st.NextIndex
st.NextIndex++
st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, st.MsgIndex)
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", st.MsgIndex)
- item, _ = sjson.SetBytes(item, "item.id", st.CurrentMsgID)
- out = append(out, emitEvent("response.output_item.added", item))
- partAdded := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- partAdded, _ = sjson.SetBytes(partAdded, "sequence_number", nextSeq())
- partAdded, _ = sjson.SetBytes(partAdded, "item_id", st.CurrentMsgID)
- partAdded, _ = sjson.SetBytes(partAdded, "output_index", st.MsgIndex)
- out = append(out, emitEvent("response.content_part.added", partAdded))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", st.MsgIndex)
+ _ = itemDoc.Set("item.id", st.CurrentMsgID)
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
+ partAddedDoc := ojson.NewDocString(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partAddedDoc.Set("sequence_number", nextSeq())
+ _ = partAddedDoc.Set("item_id", st.CurrentMsgID)
+ _ = partAddedDoc.Set("output_index", st.MsgIndex)
+ out = append(out, emitEvent("response.content_part.added", partAddedDoc.Bytes()))
st.ItemTextBuf.Reset()
st.CurrentMsgRuneOffset = 0
}
@@ -1132,16 +1187,16 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
PartIndex: currentPartIndex,
MessageIndex: st.MsgIndex,
StartRuneInMsg: st.CurrentMsgRuneOffset,
- PartText: t.String(),
+ PartText: strings.Clone(t.String()),
})
}
st.CurrentMsgRuneOffset += int64(utf8.RuneCountInString(t.String()))
- msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.CurrentMsgID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.MsgIndex)
- msg, _ = sjson.SetBytes(msg, "delta", t.String())
- out = append(out, emitEvent("response.output_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.CurrentMsgID)
+ _ = msgDoc.Set("output_index", st.MsgIndex)
+ _ = msgDoc.Set("delta", t.String())
+ out = append(out, emitEvent("response.output_text.delta", msgDoc.Bytes()))
st.TextPartRunActive = true
return true
}
@@ -1165,18 +1220,18 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.ActiveStreamingFunc = nil
st.ActiveStreamingPatch = nil
}
- rawName := strings.TrimSpace(fc.Get("name").String())
+ rawName := strings.Clone(strings.TrimSpace(fc.Get("name").String()))
if rawName == "" {
st.HasFailedStreamingFunc = true
return true
}
identity, hasIdentity := st.ToolIdentityMap[rawName]
if !hasIdentity {
- restored := util.RestoreSanitizedToolName(st.SanitizedNameMap, rawName)
+ restored := strings.Clone(util.RestoreSanitizedToolName(st.SanitizedNameMap, rawName))
identity = util.ResponsesToolIdentity{Name: restored}
}
- name := identity.Name
- namespace := identity.Namespace
+ name := strings.Clone(identity.Name)
+ namespace := strings.Clone(identity.Namespace)
isCustom := identity.Custom
isToolSearch := identity.ToolSearch
@@ -1185,7 +1240,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if st.FuncArgsBuf[idx] == nil {
st.FuncArgsBuf[idx] = &strings.Builder{}
}
- upstreamID := strings.TrimSpace(fc.Get("id").String())
+ upstreamID := strings.Clone(strings.TrimSpace(fc.Get("id").String()))
callID := upstreamID
if callID == "" {
callID = fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))
@@ -1199,30 +1254,30 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if isToolSearch {
itemID := openAIResponsesToolSearchCallItemID(callID)
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", idx)
- item, _ = sjson.SetRawBytes(item, "item", buildOpenAIResponsesToolSearchCallItem(itemID, callID, "in_progress", "{}"))
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", idx)
+ _ = itemDoc.SetRawBytes("item", buildOpenAIResponsesToolSearchCallItem(itemID, callID, "in_progress", "{}"))
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
} else if isCustom {
if identity.ApplyPatch {
st.ActiveStreamingPatch = &translatorcommon.ApplyPatchCallState{ItemID: fmt.Sprintf("ctc_%s", callID), CallID: callID, Name: name, Namespace: namespace, OutputIndex: idx}
}
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", idx)
- item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("ctc_%s", callID))
- item, _ = sjson.SetBytes(item, "item.call_id", callID)
- item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item")
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", idx)
+ _ = itemDoc.Set("item.id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoc.Set("item.call_id", callID)
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, name, namespace, "item")
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
} else {
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", idx)
- item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", callID))
- item, _ = sjson.SetBytes(item, "item.call_id", callID)
- item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item")
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", idx)
+ _ = itemDoc.Set("item.id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoc.Set("item.call_id", callID)
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, name, namespace, "item")
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
}
}
@@ -1257,12 +1312,12 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
}
}
} else if !st.FuncToolSearch[idx] {
- ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
- ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
- ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- ad, _ = sjson.SetBytes(ad, "output_index", idx)
- ad, _ = translatorcommon.SetStringWithoutHTMLEscape(ad, "delta", delta)
- out = append(out, emitEvent("response.function_call_arguments.delta", ad))
+ adDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
+ _ = adDoc.Set("sequence_number", nextSeq())
+ _ = adDoc.Set("item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = adDoc.Set("output_index", idx)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(adDoc, "delta", delta)
+ out = append(out, emitEvent("response.function_call_arguments.delta", adDoc.Bytes()))
}
}
@@ -1277,11 +1332,11 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
_, itemStatus, _ = geminiResponsesTerminalState(st.FinishReason)
}
itemID := openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx])
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], itemStatus, fullArgs))
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.SetRawBytes("item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], itemStatus, fullArgs))
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
} else if st.FuncCustom[idx] {
var inputStr string
if st.ActiveStreamingPatch != nil {
@@ -1297,44 +1352,47 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
}
inputStr = fullInput
} else {
- inputStr = util.UnwrapResponsesCustomToolInput(fullArgs)
+ inputStr = strings.Clone(util.UnwrapResponsesCustomToolInput(fullArgs))
}
- st.FuncInputBuf[idx] = inputStr
- inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
- inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
- inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- inputDone, _ = sjson.SetBytes(inputDone, "output_index", idx)
- inputDone, _ = sjson.SetBytes(inputDone, "input", inputStr)
+ st.FuncInputBuf[idx] = strings.Clone(inputStr)
+ var inputDone []byte
if st.ActiveStreamingPatch != nil {
- inputDone = translatorcommon.ApplyPatchInputDone(st.ActiveStreamingPatch, inputStr, st.Seq)
+ inputDone = translatorcommon.ApplyPatchInputDone(st.ActiveStreamingPatch, inputStr, nextSeq())
st.ActiveStreamingPatch = nil
+ } else {
+ inputDoneDoc := ojson.NewDocString(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
+ _ = inputDoneDoc.Set("sequence_number", nextSeq())
+ _ = inputDoneDoc.Set("item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = inputDoneDoc.Set("output_index", idx)
+ _ = inputDoneDoc.Set("input", inputStr)
+ inputDone = inputDoneDoc.Bytes()
}
out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone))
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- itemDone, _ = sjson.SetBytes(itemDone, "item.input", inputStr)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx])
- itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = itemDoneDoc.Set("item.input", inputStr)
+ _ = itemDoneDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoneDoc, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
} else {
- fcDoneEvent := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
- fcDoneEvent, _ = sjson.SetBytes(fcDoneEvent, "sequence_number", nextSeq())
- fcDoneEvent, _ = sjson.SetBytes(fcDoneEvent, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- fcDoneEvent, _ = sjson.SetBytes(fcDoneEvent, "output_index", idx)
- fcDoneEvent, _ = translatorcommon.SetStringWithoutHTMLEscape(fcDoneEvent, "arguments", fullArgs)
- out = append(out, emitEvent("response.function_call_arguments.done", fcDoneEvent))
-
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- itemDone, _ = translatorcommon.SetStringWithoutHTMLEscape(itemDone, "item.arguments", fullArgs)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx])
- itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ fcDoneEventDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
+ _ = fcDoneEventDoc.Set("sequence_number", nextSeq())
+ _ = fcDoneEventDoc.Set("item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = fcDoneEventDoc.Set("output_index", idx)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoneEventDoc, "arguments", fullArgs)
+ out = append(out, emitEvent("response.function_call_arguments.done", fcDoneEventDoc.Bytes()))
+
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoneDoc, "item.arguments", fullArgs)
+ _ = itemDoneDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoneDoc, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
}
st.FuncDone[idx] = true
if st.PendingReasoningSignature != "" {
@@ -1351,7 +1409,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.ActiveStreamingPatch = nil
}
- evidence := geminiRecordFunctionEvidence(st, fc, explicitPartIndex, gjson.ValidBytes(rawJSON))
+ evidence := geminiRecordFunctionEvidence(st, fc, explicitPartIndex, ojson.ValidBytes(rawJSON))
if evidence.ApplyPatch && evidence.Err != nil {
st.SetToolInputError(evidence.Err)
st.Completed = true
@@ -1361,17 +1419,17 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if evidence.RawName == "" {
return true
}
- rawName := fc.Get("name").String()
+ rawName := strings.Clone(fc.Get("name").String())
if evidence.ApplyPatch {
rawName = evidence.RawName
}
identity, hasIdentity := st.ToolIdentityMap[rawName]
if !hasIdentity {
- restored := util.RestoreSanitizedToolName(st.SanitizedNameMap, rawName)
+ restored := strings.Clone(util.RestoreSanitizedToolName(st.SanitizedNameMap, rawName))
identity = util.ResponsesToolIdentity{Name: restored}
}
- name := identity.Name
- namespace := identity.Namespace
+ name := strings.Clone(identity.Name)
+ namespace := strings.Clone(identity.Namespace)
isCustom := identity.Custom
isToolSearch := identity.ToolSearch
if evidence.ApplyPatch && evidence.PatchCall != nil {
@@ -1391,7 +1449,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
st.FuncArgsBuf[idx] = &strings.Builder{}
}
if identity.ApplyPatch || isToolSearch {
- st.FuncCallIDs[idx] = evidence.UpstreamID
+ st.FuncCallIDs[idx] = strings.Clone(evidence.UpstreamID)
}
if st.FuncCallIDs[idx] == "" {
st.FuncCallIDs[idx] = fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))
@@ -1417,28 +1475,28 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
_, itemStatus, _ = geminiResponsesTerminalState(st.FinishReason)
}
itemID := openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx])
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", idx)
- item, _ = sjson.SetRawBytes(item, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], "in_progress", argsJSON))
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", idx)
+ _ = itemDoc.SetRawBytes("item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], "in_progress", argsJSON))
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
if !st.FuncDone[idx] {
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], itemStatus, argsJSON))
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.SetRawBytes("item", buildOpenAIResponsesToolSearchCallItem(itemID, st.FuncCallIDs[idx], itemStatus, argsJSON))
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.FuncDone[idx] = true
}
} else if isCustom {
- inputStr := util.UnwrapResponsesCustomToolInput(argsJSON)
+ inputStr := strings.Clone(util.UnwrapResponsesCustomToolInput(argsJSON))
var patchCall *translatorcommon.ApplyPatchCallState
if identity.ApplyPatch {
patchCall = &translatorcommon.ApplyPatchCallState{ItemID: fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]), CallID: st.FuncCallIDs[idx], Name: name, Namespace: namespace, OutputIndex: idx}
_, input, errFinishArguments := patchCall.FinishArguments(argsJSON)
- if !gjson.ValidBytes(rawJSON) {
+ if !ojson.ValidBytes(rawJSON) {
errFinishArguments = fmt.Errorf("invalid Gemini apply_patch response JSON")
}
if errFinishArguments != nil {
@@ -1447,19 +1505,19 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
out = append(out, emitEvent("response.failed", translatorcommon.ApplyPatchFailure(st.ResponseID, nextSeq())))
return false
}
- inputStr = input
+ inputStr = strings.Clone(input)
evidence.PatchCall = patchCall
}
- st.FuncInputBuf[idx] = inputStr
+ st.FuncInputBuf[idx] = strings.Clone(inputStr)
// Emit item.added for custom tool call
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", idx)
- item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- item, _ = sjson.SetBytes(item, "item.call_id", st.FuncCallIDs[idx])
- item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item")
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", idx)
+ _ = itemDoc.Set("item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = itemDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, name, namespace, "item")
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
// Gemini delivers complete arguments; this delta is not an early preview.
if patchCall != nil && inputStr != "" {
@@ -1467,65 +1525,68 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
}
// Emit custom tool call input.done
if !st.FuncDone[idx] {
- inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
- inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
- inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- inputDone, _ = sjson.SetBytes(inputDone, "output_index", idx)
- inputDone, _ = sjson.SetBytes(inputDone, "input", inputStr)
+ var inputDone []byte
if patchCall != nil {
- inputDone = translatorcommon.ApplyPatchInputDone(patchCall, inputStr, st.Seq)
+ inputDone = translatorcommon.ApplyPatchInputDone(patchCall, inputStr, nextSeq())
+ } else {
+ inputDoneDoc := ojson.NewDocString(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
+ _ = inputDoneDoc.Set("sequence_number", nextSeq())
+ _ = inputDoneDoc.Set("item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = inputDoneDoc.Set("output_index", idx)
+ _ = inputDoneDoc.Set("input", inputStr)
+ inputDone = inputDoneDoc.Bytes()
}
out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone))
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- itemDone, _ = sjson.SetBytes(itemDone, "item.input", inputStr)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx])
- itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, name, namespace, "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = itemDoneDoc.Set("item.input", inputStr)
+ _ = itemDoneDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoneDoc, name, namespace, "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.FuncDone[idx] = true
}
} else {
// Emit item.added for function call
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", idx)
- item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- item, _ = sjson.SetBytes(item, "item.call_id", st.FuncCallIDs[idx])
- item = translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, "item")
- out = append(out, emitEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", idx)
+ _ = itemDoc.Set("item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = itemDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, name, namespace, "item")
+ out = append(out, emitEvent("response.output_item.added", itemDoc.Bytes()))
// Emit arguments delta (full args in one chunk).
// When Gemini omits args, emit "{}" to keep Responses streaming event order consistent.
if argsJSON != "" {
- ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
- ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
- ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- ad, _ = sjson.SetBytes(ad, "output_index", idx)
- ad, _ = translatorcommon.SetStringWithoutHTMLEscape(ad, "delta", argsJSON)
- out = append(out, emitEvent("response.function_call_arguments.delta", ad))
+ adDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
+ _ = adDoc.Set("sequence_number", nextSeq())
+ _ = adDoc.Set("item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = adDoc.Set("output_index", idx)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(adDoc, "delta", argsJSON)
+ out = append(out, emitEvent("response.function_call_arguments.delta", adDoc.Bytes()))
}
// Gemini emits the full function call payload at once, so we can finalize it immediately.
if !st.FuncDone[idx] {
- fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
- fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
- fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- fcDone, _ = sjson.SetBytes(fcDone, "output_index", idx)
- fcDone, _ = translatorcommon.SetStringWithoutHTMLEscape(fcDone, "arguments", argsJSON)
- out = append(out, emitEvent("response.function_call_arguments.done", fcDone))
-
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- itemDone, _ = translatorcommon.SetStringWithoutHTMLEscape(itemDone, "item.arguments", argsJSON)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx])
- itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, name, namespace, "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ fcDoneDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
+ _ = fcDoneDoc.Set("sequence_number", nextSeq())
+ _ = fcDoneDoc.Set("item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = fcDoneDoc.Set("output_index", idx)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoneDoc, "arguments", argsJSON)
+ out = append(out, emitEvent("response.function_call_arguments.done", fcDoneDoc.Bytes()))
+
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoneDoc, "item.arguments", argsJSON)
+ _ = itemDoneDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoneDoc, name, namespace, "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.FuncDone[idx] = true
}
@@ -1544,7 +1605,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// Preserve the first source finish, including across a usage-only tail or [DONE].
if fr := root.Get("candidates.0.finishReason").String(); fr != "" && st.FinishReason == "" {
- st.FinishReason = fr
+ st.FinishReason = strings.Clone(fr)
}
if st.FinishReason != "" && st.ActiveStreamingFunc == nil && !st.HasFailedStreamingFunc {
if errIdentity := geminiPendingIdentityError(st); errIdentity != nil {
@@ -1590,48 +1651,48 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
args = b.String()
}
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetRawBytes(itemDone, "item", buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx]), st.FuncCallIDs[idx], status, args))
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.SetRawBytes("item", buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(st.FuncCallIDs[idx]), st.FuncCallIDs[idx], status, args))
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
} else if st.FuncCustom[idx] {
inputStr := st.FuncInputBuf[idx]
- inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
- inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
- inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- inputDone, _ = sjson.SetBytes(inputDone, "output_index", idx)
- inputDone, _ = sjson.SetBytes(inputDone, "input", inputStr)
- out = append(out, emitEvent("response.custom_tool_call_input.done", inputDone))
-
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
- itemDone, _ = sjson.SetBytes(itemDone, "item.input", inputStr)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx])
- itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ inputDoneDoc := ojson.NewDocString(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
+ _ = inputDoneDoc.Set("sequence_number", nextSeq())
+ _ = inputDoneDoc.Set("item_id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = inputDoneDoc.Set("output_index", idx)
+ _ = inputDoneDoc.Set("input", inputStr)
+ out = append(out, emitEvent("response.custom_tool_call_input.done", inputDoneDoc.Bytes()))
+
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("ctc_%s", st.FuncCallIDs[idx]))
+ _ = itemDoneDoc.Set("item.input", inputStr)
+ _ = itemDoneDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoneDoc, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
} else {
args := "{}"
if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
args = b.String()
}
- fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
- fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
- fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- fcDone, _ = sjson.SetBytes(fcDone, "output_index", idx)
- fcDone, _ = translatorcommon.SetStringWithoutHTMLEscape(fcDone, "arguments", args)
- out = append(out, emitEvent("response.function_call_arguments.done", fcDone))
-
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", idx)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
- itemDone, _ = translatorcommon.SetStringWithoutHTMLEscape(itemDone, "item.arguments", args)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", st.FuncCallIDs[idx])
- itemDone = translatorcommon.SetResponsesToolCallIdentity(itemDone, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
- out = append(out, emitEvent("response.output_item.done", itemDone))
+ fcDoneDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
+ _ = fcDoneDoc.Set("sequence_number", nextSeq())
+ _ = fcDoneDoc.Set("item_id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = fcDoneDoc.Set("output_index", idx)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoneDoc, "arguments", args)
+ out = append(out, emitEvent("response.function_call_arguments.done", fcDoneDoc.Bytes()))
+
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", idx)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("fc_%s", st.FuncCallIDs[idx]))
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoneDoc, "item.arguments", args)
+ _ = itemDoneDoc.Set("item.call_id", st.FuncCallIDs[idx])
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoneDoc, st.FuncNames[idx], st.FuncNamespaces[idx], "item")
+ out = append(out, emitEvent("response.output_item.done", itemDoneDoc.Bytes()))
}
st.FuncDone[idx] = true
}
@@ -1640,77 +1701,77 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// Reasoning already finalized above if present
// Build the terminal response with aggregated outputs and request echo fields
- completed := []byte(`{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`)
- completed, _ = sjson.SetBytes(completed, "type", eventType)
- completed, _ = sjson.SetBytes(completed, "response.status", status)
+ completedDoc := ojson.NewDocString(`{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`)
+ _ = completedDoc.Set("type", eventType)
+ _ = completedDoc.Set("response.status", status)
if incompleteDetails != nil {
- completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails)
+ _ = completedDoc.SetRawBytes("response.incomplete_details", incompleteDetails)
}
- completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq())
- completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID)
- completed, _ = sjson.SetBytes(completed, "response.created_at", st.CreatedAt)
+ _ = completedDoc.Set("sequence_number", nextSeq())
+ _ = completedDoc.Set("response.id", st.ResponseID)
+ _ = completedDoc.Set("response.created_at", st.CreatedAt)
if len(reqJSON) > 0 {
- req := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
+ req := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(reqJSON))
if v := req.Get("instructions"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.instructions", v.String())
+ _ = completedDoc.Set("response.instructions", v.String())
}
if v := req.Get("max_output_tokens"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.max_output_tokens", v.Int())
+ _ = completedDoc.Set("response.max_output_tokens", v.Int())
}
if v := req.Get("max_tool_calls"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.max_tool_calls", v.Int())
+ _ = completedDoc.Set("response.max_tool_calls", v.Int())
}
if v := req.Get("model"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.model", v.String())
+ _ = completedDoc.Set("response.model", v.String())
}
if v := req.Get("parallel_tool_calls"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.parallel_tool_calls", v.Bool())
+ _ = completedDoc.Set("response.parallel_tool_calls", v.Bool())
}
if v := req.Get("previous_response_id"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.previous_response_id", v.String())
+ _ = completedDoc.Set("response.previous_response_id", v.String())
}
if v := req.Get("prompt_cache_key"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.prompt_cache_key", v.String())
+ _ = completedDoc.Set("response.prompt_cache_key", v.String())
}
if v := req.Get("reasoning"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.reasoning", v.Value())
+ _ = completedDoc.Set("response.reasoning", v.Value())
}
if v := req.Get("safety_identifier"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.safety_identifier", v.String())
+ _ = completedDoc.Set("response.safety_identifier", v.String())
}
if v := req.Get("service_tier"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.service_tier", v.String())
+ _ = completedDoc.Set("response.service_tier", v.String())
}
if v := req.Get("store"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.store", v.Bool())
+ _ = completedDoc.Set("response.store", v.Bool())
}
if v := req.Get("temperature"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.temperature", v.Float())
+ _ = completedDoc.Set("response.temperature", v.Float())
}
if v := req.Get("text"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.text", v.Value())
+ _ = completedDoc.Set("response.text", v.Value())
}
if v := req.Get("tool_choice"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.tool_choice", v.Value())
+ _ = completedDoc.Set("response.tool_choice", v.Value())
}
if v := req.Get("tools"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.tools", v.Value())
+ _ = completedDoc.Set("response.tools", v.Value())
}
if v := req.Get("top_logprobs"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.top_logprobs", v.Int())
+ _ = completedDoc.Set("response.top_logprobs", v.Int())
}
if v := req.Get("top_p"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.top_p", v.Float())
+ _ = completedDoc.Set("response.top_p", v.Float())
}
if v := req.Get("truncation"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.truncation", v.String())
+ _ = completedDoc.Set("response.truncation", v.String())
}
if v := req.Get("user"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.user", v.Value())
+ _ = completedDoc.Set("response.user", v.Value())
}
if v := req.Get("metadata"); v.Exists() {
- completed, _ = sjson.SetBytes(completed, "response.metadata", v.Value())
+ _ = completedDoc.Set("response.metadata", v.Value())
}
}
@@ -1724,29 +1785,29 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
continue
}
if completedReasoning, ok := st.CompletedReasoning[idx]; ok {
- item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]}`)
- item, _ = sjson.SetBytes(item, "id", completedReasoning.ID)
- item, _ = sjson.SetBytes(item, "encrypted_content", completedReasoning.Signature)
- item, _ = sjson.SetBytes(item, "summary.0.text", completedReasoning.Text)
- outputs = append(outputs, item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]}`)
+ _ = itemDoc.Set("id", completedReasoning.ID)
+ _ = itemDoc.Set("encrypted_content", completedReasoning.Signature)
+ _ = itemDoc.Set("summary.0.text", completedReasoning.Text)
+ outputs = append(outputs, itemDoc.Bytes())
continue
}
if completedMessage, ok := st.CompletedMessages[idx]; ok {
- item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
- item, _ = sjson.SetBytes(item, "id", completedMessage.ID)
- item, _ = sjson.SetBytes(item, "status", completedMessage.Status)
- item, _ = sjson.SetBytes(item, "content.0.text", completedMessage.Text)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
+ _ = itemDoc.Set("id", completedMessage.ID)
+ _ = itemDoc.Set("status", completedMessage.Status)
+ _ = itemDoc.Set("content.0.text", completedMessage.Text)
if len(completedMessage.Annotations) > 0 {
- item, _ = sjson.SetRawBytes(item, "content.0.annotations", translatorcommon.JoinRawArray(completedMessage.Annotations))
+ _ = itemDoc.SetRawBytes("content.0.annotations", translatorcommon.JoinRawArray(completedMessage.Annotations))
}
- outputs = append(outputs, item)
+ outputs = append(outputs, itemDoc.Bytes())
continue
}
if detached, ok := st.DetachedReasoning[idx]; ok {
- item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
- item, _ = sjson.SetBytes(item, "id", detached.ID)
- item, _ = sjson.SetBytes(item, "encrypted_content", detached.Signature)
- outputs = append(outputs, item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
+ _ = itemDoc.Set("id", detached.ID)
+ _ = itemDoc.Set("encrypted_content", detached.Signature)
+ outputs = append(outputs, itemDoc.Bytes())
continue
}
@@ -1759,38 +1820,38 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
outputs = append(outputs, buildOpenAIResponsesToolSearchCallItem(openAIResponsesToolSearchCallItemID(callID), callID, status, args))
} else if st.FuncCustom[idx] {
inputStr := st.FuncInputBuf[idx]
- item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
- item, _ = sjson.SetBytes(item, "input", inputStr)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = translatorcommon.SetResponsesToolCallIdentity(item, st.FuncNames[idx], st.FuncNamespaces[idx], "")
- outputs = append(outputs, item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoc.Set("input", inputStr)
+ _ = itemDoc.Set("call_id", callID)
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, st.FuncNames[idx], st.FuncNamespaces[idx], "")
+ outputs = append(outputs, itemDoc.Bytes())
} else {
args := "{}"
if b := st.FuncArgsBuf[idx]; b != nil && b.Len() > 0 {
args = b.String()
}
- item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
- item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = translatorcommon.SetResponsesToolCallIdentity(item, st.FuncNames[idx], st.FuncNamespaces[idx], "")
- outputs = append(outputs, item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("fc_%s", callID))
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", args)
+ _ = itemDoc.Set("call_id", callID)
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, st.FuncNames[idx], st.FuncNamespaces[idx], "")
+ outputs = append(outputs, itemDoc.Bytes())
}
}
}
if len(outputs) > 0 {
- completed, _ = sjson.SetRawBytes(completed, "response.output", translatorcommon.JoinRawArray(outputs))
+ _ = completedDoc.SetRawBytes("response.output", translatorcommon.JoinRawArray(outputs))
}
if st.WebSearchDone {
- completed, _ = sjson.SetBytes(completed, "response.tool_usage.web_search.num_requests", 1)
+ _ = completedDoc.Set("response.tool_usage.web_search.num_requests", 1)
}
if st.Usage.Present {
- completed, _ = sjson.SetRawBytes(completed, "response.usage", st.Usage.JSON())
+ _ = completedDoc.SetRawBytes("response.usage", st.Usage.JSON())
}
- out = append(out, emitEvent(eventType, completed))
+ out = append(out, emitEvent(eventType, completedDoc.Bytes()))
st.Completed = true
}
@@ -1799,18 +1860,18 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// ConvertGeminiResponseToOpenAIResponsesNonStream aggregates Gemini response JSON into a single OpenAI Responses JSON object.
func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
root = unwrapGeminiResponseRoot(root)
reqJSON := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
toolIdentityMap := util.ResponsesToolReverseIdentityMap(reqJSON)
// Base response scaffold
- resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
+ respDoc := ojson.NewDocString(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
_, status, incompleteDetails := geminiResponsesTerminalState(root.Get("candidates.0.finishReason").String())
- resp, _ = sjson.SetBytes(resp, "status", status)
+ _ = respDoc.Set("status", status)
if incompleteDetails != nil {
- resp, _ = sjson.SetRawBytes(resp, "incomplete_details", incompleteDetails)
+ _ = respDoc.SetRawBytes("incomplete_details", incompleteDetails)
}
// id: prefer provider responseId, otherwise synthesize
@@ -1822,7 +1883,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
if !strings.HasPrefix(id, "resp_") {
id = fmt.Sprintf("resp_%s", id)
}
- resp, _ = sjson.SetBytes(resp, "id", id)
+ _ = respDoc.Set("id", id)
// created_at: map from createTime if available
createdAt := time.Now().Unix()
@@ -1831,75 +1892,75 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
createdAt = t.Unix()
}
}
- resp, _ = sjson.SetBytes(resp, "created_at", createdAt)
+ _ = respDoc.Set("created_at", createdAt)
// Echo request fields when present; fallback model from response modelVersion
if len(reqJSON) > 0 {
- req := unwrapRequestRoot(gjson.ParseBytes(reqJSON))
+ req := unwrapRequestRoot(util.ParseGJSONBytesNoCopy(reqJSON))
if v := req.Get("instructions"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "instructions", v.String())
+ _ = respDoc.Set("instructions", v.String())
}
if v := req.Get("max_output_tokens"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "max_output_tokens", v.Int())
+ _ = respDoc.Set("max_output_tokens", v.Int())
}
if v := req.Get("max_tool_calls"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "max_tool_calls", v.Int())
+ _ = respDoc.Set("max_tool_calls", v.Int())
}
if v := req.Get("model"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "model", v.String())
+ _ = respDoc.Set("model", v.String())
} else if v = root.Get("modelVersion"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "model", v.String())
+ _ = respDoc.Set("model", v.String())
}
if v := req.Get("parallel_tool_calls"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "parallel_tool_calls", v.Bool())
+ _ = respDoc.Set("parallel_tool_calls", v.Bool())
}
if v := req.Get("previous_response_id"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "previous_response_id", v.String())
+ _ = respDoc.Set("previous_response_id", v.String())
}
if v := req.Get("prompt_cache_key"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "prompt_cache_key", v.String())
+ _ = respDoc.Set("prompt_cache_key", v.String())
}
if v := req.Get("reasoning"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "reasoning", v.Value())
+ _ = respDoc.Set("reasoning", v.Value())
}
if v := req.Get("safety_identifier"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "safety_identifier", v.String())
+ _ = respDoc.Set("safety_identifier", v.String())
}
if v := req.Get("service_tier"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "service_tier", v.String())
+ _ = respDoc.Set("service_tier", v.String())
}
if v := req.Get("store"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "store", v.Bool())
+ _ = respDoc.Set("store", v.Bool())
}
if v := req.Get("temperature"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "temperature", v.Float())
+ _ = respDoc.Set("temperature", v.Float())
}
if v := req.Get("text"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "text", v.Value())
+ _ = respDoc.Set("text", v.Value())
}
if v := req.Get("tool_choice"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "tool_choice", v.Value())
+ _ = respDoc.Set("tool_choice", v.Value())
}
if v := req.Get("tools"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "tools", v.Value())
+ _ = respDoc.Set("tools", v.Value())
}
if v := req.Get("top_logprobs"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "top_logprobs", v.Int())
+ _ = respDoc.Set("top_logprobs", v.Int())
}
if v := req.Get("top_p"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "top_p", v.Float())
+ _ = respDoc.Set("top_p", v.Float())
}
if v := req.Get("truncation"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "truncation", v.String())
+ _ = respDoc.Set("truncation", v.String())
}
if v := req.Get("user"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "user", v.Value())
+ _ = respDoc.Set("user", v.Value())
}
if v := req.Get("metadata"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "metadata", v.Value())
+ _ = respDoc.Set("metadata", v.Value())
}
} else if v := root.Get("modelVersion"); v.Exists() {
- resp, _ = sjson.SetBytes(resp, "model", v.String())
+ _ = respDoc.Set("model", v.String())
}
// Build outputs from candidates[0].content.parts
@@ -1984,15 +2045,15 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
if direction == geminiResponsesCarrierPrevious {
placement = "after"
}
- itemJSON := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
- itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("rs_%s_detached_%s_%d", strings.TrimPrefix(id, "resp_"), placement, detachedOutputIndex))
- itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", encodeGeminiResponsesCarrier(signature, direction, targetKind))
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("rs_%s_detached_%s_%d", strings.TrimPrefix(id, "resp_"), placement, detachedOutputIndex))
+ _ = itemDoc.Set("encrypted_content", encodeGeminiResponsesCarrier(signature, direction, targetKind))
detachedOutputIndex++
- appendOutput(itemJSON)
+ appendOutput(itemDoc.Bytes())
}
if parts := root.Get("candidates.0.content.parts"); parts.Exists() && parts.IsArray() {
- parts.ForEach(func(key, p gjson.Result) bool {
+ parts.ForEach(func(key, p ojson.Result) bool {
partIdx := int(key.Int())
if pIdx := p.Get("partIndex"); pIdx.Exists() {
partIdx = int(pIdx.Int())
@@ -2066,7 +2127,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
} else if p.Get("index").Exists() {
explicitIndex = int(p.Get("index").Int())
}
- evidence := geminiRecordFunctionEvidence(evidenceState, fc, explicitIndex, gjson.ValidBytes(rawJSON))
+ evidence := geminiRecordFunctionEvidence(evidenceState, fc, explicitIndex, ojson.ValidBytes(rawJSON))
if evidence.ApplyPatch && evidence.Err != nil {
toolInputError = evidence.Err
return false
@@ -2112,7 +2173,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
if identity.ApplyPatch {
var patchCall translatorcommon.ApplyPatchCallState
_, input, errFinishArguments := patchCall.FinishArguments(argsStr)
- if !gjson.ValidBytes(rawJSON) {
+ if !ojson.ValidBytes(rawJSON) {
errFinishArguments = fmt.Errorf("invalid Gemini apply_patch response JSON")
}
if errFinishArguments != nil {
@@ -2122,17 +2183,19 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
inputStr = input
evidence.PatchCall = &patchCall
}
- itemJSON = []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
- itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("ctc_%s", callID))
- itemJSON, _ = sjson.SetBytes(itemJSON, "call_id", callID)
- itemJSON, _ = sjson.SetBytes(itemJSON, "input", inputStr)
- itemJSON = translatorcommon.SetResponsesToolCallIdentity(itemJSON, name, namespace, "")
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoc.Set("call_id", callID)
+ _ = itemDoc.Set("input", inputStr)
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, name, namespace, "")
+ itemJSON = itemDoc.Bytes()
} else {
- itemJSON = []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
- itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("fc_%s", callID))
- itemJSON, _ = sjson.SetBytes(itemJSON, "call_id", callID)
- itemJSON, _ = translatorcommon.SetStringWithoutHTMLEscape(itemJSON, "arguments", argsStr)
- itemJSON = translatorcommon.SetResponsesToolCallIdentity(itemJSON, name, namespace, "")
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoc.Set("call_id", callID)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", argsStr)
+ translatorcommon.SetResponsesToolCallIdentityDoc(itemDoc, name, namespace, "")
+ itemJSON = itemDoc.Bytes()
}
functionIndex := len(functionOutputs)
functionOutputs = append(functionOutputs, nonStreamFunctionOutput{item: itemJSON, signature: signature})
@@ -2206,7 +2269,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
query = queries[0]
}
if query == "" && len(reqJSON) > 0 {
- query = ExtractResponsesWebSearchQuery(unwrapRequestRoot(gjson.ParseBytes(reqJSON)))
+ query = ExtractResponsesWebSearchQuery(unwrapRequestRoot(util.ParseGJSONBytesNoCopy(reqJSON)))
}
sources := ExtractGroundingSources(groundingMetadata)
wsID := fmt.Sprintf("ws_%s", strings.TrimPrefix(id, "resp_"))
@@ -2240,19 +2303,19 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
if len(reasoningOutputs) > 1 {
reasoningID = fmt.Sprintf("rs_%s_%d", rid, outputItem.index)
}
- itemJSON := []byte(`{"id":"","type":"reasoning","encrypted_content":""}`)
- itemJSON, _ = sjson.SetBytes(itemJSON, "id", reasoningID)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","encrypted_content":""}`)
+ _ = itemDoc.Set("id", reasoningID)
encryptedContent := reasoningOutput.signature
if encryptedContent != "" && reasoningOutput.direction != "" {
encryptedContent = encodeGeminiResponsesCarrier(encryptedContent, reasoningOutput.direction, reasoningOutput.targetKind)
}
- itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", encryptedContent)
+ _ = itemDoc.Set("encrypted_content", encryptedContent)
if reasoningOutput.text != "" {
summaryJSON := []byte(`{"type":"summary_text","text":""}`)
- summaryJSON, _ = sjson.SetBytes(summaryJSON, "text", reasoningOutput.text)
- itemJSON, _ = sjson.SetRawBytes(itemJSON, "summary", translatorcommon.JoinRawArray([][]byte{summaryJSON}))
+ summaryJSON, _ = ojson.SetBytes(summaryJSON, "text", reasoningOutput.text)
+ _ = itemDoc.SetRawBytes("summary", translatorcommon.JoinRawArray([][]byte{summaryJSON}))
}
- appendOutput(itemJSON)
+ appendOutput(itemDoc.Bytes())
case "message":
if hasGrounding && !wsAppended {
appendOutput(wsItem)
@@ -2267,16 +2330,16 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
appendDetachedOutput(signature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
}
}
- itemJSON := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
- itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("msg_%s_%d", strings.TrimPrefix(id, "resp_"), outputItem.index))
- itemJSON, _ = sjson.SetBytes(itemJSON, "content.0.text", messageOutput.text)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("msg_%s_%d", strings.TrimPrefix(id, "resp_"), outputItem.index))
+ _ = itemDoc.Set("content.0.text", messageOutput.text)
if outputItem.index == activeMessageIndex {
- itemJSON, _ = sjson.SetBytes(itemJSON, "status", status)
+ _ = itemDoc.Set("status", status)
}
if c := messageCitations[outputItem.index]; len(c) > 0 {
- itemJSON, _ = sjson.SetRawBytes(itemJSON, "content.0.annotations", translatorcommon.JoinRawArray(c))
+ _ = itemDoc.SetRawBytes("content.0.annotations", translatorcommon.JoinRawArray(c))
}
- appendOutput(itemJSON)
+ appendOutput(itemDoc.Bytes())
case "function":
if outputItem.index < 0 || outputItem.index >= len(functionOutputs) {
continue
@@ -2293,24 +2356,24 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
}
if len(outputs) > 0 {
- resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputs))
+ _ = respDoc.SetRawBytes("output", translatorcommon.JoinRawArray(outputs))
}
if hasGrounding {
- resp, _ = sjson.SetBytes(resp, "tool_usage.web_search.num_requests", 1)
+ _ = respDoc.Set("tool_usage.web_search.num_requests", 1)
}
var usage geminiResponsesUsage
if usage.Merge(root) {
- resp, _ = sjson.SetRawBytes(resp, "usage", usage.JSON())
+ _ = respDoc.SetRawBytes("usage", usage.JSON())
}
- return resp
+ return respDoc.Bytes()
}
// Full snapshots are evidence, never source prefixes. Stable IDs and explicit
// part indexes identify repeated snapshots without merging distinct unkeyed calls.
-func geminiRecordFunctionEvidence(st *geminiToResponsesState, fc gjson.Result, partIndex int, validJSON bool) *geminiFunctionCallEvidence {
+func geminiRecordFunctionEvidence(st *geminiToResponsesState, fc ojson.Result, partIndex int, validJSON bool) *geminiFunctionCallEvidence {
if st.FunctionEvidence == nil {
st.FunctionEvidence = make(map[string]*geminiFunctionCallEvidence)
}
@@ -2373,14 +2436,14 @@ func geminiRecordFunctionEvidence(st *geminiToResponsesState, fc gjson.Result, p
if evidence.RawName != "" && evidence.RawName != name {
recordError(fmt.Errorf("conflicting apply_patch call name"))
} else {
- evidence.RawName = name
+ evidence.RawName = strings.Clone(name)
}
}
if id := fc.Get("id").String(); id != "" {
if evidence.UpstreamID != "" && evidence.UpstreamID != id {
recordError(fmt.Errorf("conflicting apply_patch call ID"))
} else {
- evidence.UpstreamID = id
+ evidence.UpstreamID = strings.Clone(id)
}
}
var snapshot translatorcommon.ApplyPatchCallState
@@ -2395,7 +2458,7 @@ func geminiRecordFunctionEvidence(st *geminiToResponsesState, fc gjson.Result, p
recordError(fmt.Errorf("conflicting apply_patch complete snapshots"))
}
evidence.HasInput = true
- evidence.Input = input
+ evidence.Input = strings.Clone(input)
}
return evidence
}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go
index 4b4c29b86a1..92218e2ef7b 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go
@@ -2,15 +2,15 @@ package responses
import (
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
-func parseSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
+func parseSSEEvent(t *testing.T, chunk []byte) (string, ojson.Result) {
t.Helper()
lines := strings.Split(string(chunk), "\n")
@@ -20,10 +20,10 @@ func parseSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
event := strings.TrimSpace(strings.TrimPrefix(lines[0], "event:"))
dataLine := strings.TrimSpace(strings.TrimPrefix(lines[1], "data:"))
- if !gjson.Valid(dataLine) {
+ if !ojson.Valid(dataLine) {
t.Fatalf("invalid SSE data JSON: %q", dataLine)
}
- return event, gjson.Parse(dataLine)
+ return event, ojson.Parse(dataLine)
}
func TestConvertGeminiResponseToOpenAIResponses_VertexStreamingPartialArgs(t *testing.T) {
@@ -234,7 +234,7 @@ func TestConvertGeminiResponseToOpenAIResponsesOutputTokensIncludeThoughts(t *te
chunk := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":16,"candidatesTokenCount":5,"thoughtsTokenCount":42,"totalTokenCount":63},"modelVersion":"gemini-3.6-flash","responseId":"resp_usage"}`)
result := ConvertGeminiResponseToOpenAIResponses(context.Background(), "model", nil, nil, chunk, ¶m)
- var completed gjson.Result
+ var completed ojson.Result
for _, event := range result {
name, data := parseSSEEvent(t, event)
if name == "response.completed" {
@@ -254,7 +254,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStreamOutputTokensIncludeThoug
response := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58},"modelVersion":"gemini-3.6-flash","responseId":"resp_usage"}`)
result := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "model", nil, nil, response, nil)
- outputTokens := gjson.GetBytes(result, "usage.output_tokens")
+ outputTokens := ojson.GetBytesNoCopy(result, "usage.output_tokens")
if !outputTokens.Exists() || outputTokens.Int() != 42 {
t.Fatalf("output_tokens = %s, want present with value 42. Output: %s", outputTokens.Raw, result)
}
@@ -390,14 +390,14 @@ func TestConvertGeminiResponseToOpenAIResponses_UnwrapAndAggregateText(t *testin
if funcName != "mcp__serena__list_dir" {
t.Fatalf("unexpected function name: got %q", funcName)
}
- if !gjson.Valid(funcArgs) {
+ if !ojson.Valid(funcArgs) {
t.Fatalf("invalid function arguments JSON: %q", funcArgs)
}
- if gjson.Get(funcArgs, "recursive").Bool() != false {
- t.Fatalf("unexpected recursive arg: %v", gjson.Get(funcArgs, "recursive").Value())
+ if ojson.Get(funcArgs, "recursive").Bool() != false {
+ t.Fatalf("unexpected recursive arg: %v", ojson.Get(funcArgs, "recursive").Value())
}
- if gjson.Get(funcArgs, "relative_path").String() != "internal" {
- t.Fatalf("unexpected relative_path arg: %q", gjson.Get(funcArgs, "relative_path").String())
+ if ojson.Get(funcArgs, "relative_path").String() != "internal" {
+ t.Fatalf("unexpected relative_path arg: %q", ojson.Get(funcArgs, "relative_path").String())
}
}
@@ -431,7 +431,7 @@ func TestConvertGeminiResponseToOpenAIResponses_ConsecutiveSignedVisibleTextPres
var param any
added := make(map[string]string)
done := make(map[string]string)
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -453,10 +453,10 @@ func TestConvertGeminiResponseToOpenAIResponses_ConsecutiveSignedVisibleTextPres
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- var visibleParts []gjson.Result
- for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() {
+ var visibleParts []ojson.Result
+ for _, part := range ojson.GetBytesNoCopy(translated, "contents.0.parts").Array() {
if !part.Get("thought").Bool() && part.Get("text").String() != "" {
visibleParts = append(visibleParts, part)
}
@@ -470,19 +470,20 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_ConsecutiveSignedVisibl
signature2 := differentResponsesGeminiThoughtSignature(t)
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"a"},{"text":"b","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"c","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-text-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- output := gjson.GetBytes(out, "output")
+ output := ojson.GetBytesNoCopy(out, "output")
if decodedResponsesCarrierSignature(t, output.Get("0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || output.Get("1.content.0.text").String() != "ab" || decodedResponsesCarrierSignature(t, output.Get("2.encrypted_content").String()) != signature2 || output.Get("3.content.0.text").String() != "c" {
t.Fatalf("non-stream signed visible text was not segmented: %s", out)
}
- outputWithoutIDs := []byte(output.Raw)
- outputWithoutIDs, _ = sjson.DeleteBytes(outputWithoutIDs, "0.id")
- outputWithoutIDs, _ = sjson.DeleteBytes(outputWithoutIDs, "2.id")
+ outputWithoutIDsDoc := ojson.NewDocString(output.Raw)
+ _ = outputWithoutIDsDoc.Delete("0.id")
+ _ = outputWithoutIDsDoc.Delete("2.id")
+ outputWithoutIDs := outputWithoutIDsDoc.Bytes()
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", outputWithoutIDs)
+ request, _ = ojson.SetRawBytes(request, "input", outputWithoutIDs)
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- var visibleParts []gjson.Result
- for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() {
+ var visibleParts []ojson.Result
+ for _, part := range ojson.GetBytesNoCopy(translated, "contents.0.parts").Array() {
if !part.Get("thought").Bool() && part.Get("text").String() != "" {
visibleParts = append(visibleParts, part)
}
@@ -499,7 +500,7 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedVisibleThenUnsignedPreserv
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range lines {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -509,9 +510,9 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedVisibleThenUnsignedPreserv
}
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "signed" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("text").String() != "unsigned" || parts[1].Get("thoughtSignature").String() != "" {
t.Fatalf("signed/unsigned visible boundary changed: output=%s translated=%s", completed.Raw, translated)
}
@@ -529,7 +530,7 @@ func TestConvertGeminiResponseToOpenAIResponses_LeadingCarrierDoesNotCrossSigned
`data: [DONE]`,
}
var param any
- var streamOutput gjson.Result
+ var streamOutput ojson.Result
for _, line := range lines {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -541,11 +542,11 @@ func TestConvertGeminiResponseToOpenAIResponses_LeadingCarrierDoesNotCrossSigned
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"reason","thought":true,"thoughtSignature":"` + signature2 + `"},{"text":"answer"}]},"finishReason":"STOP"}],"responseId":"leading-before-signed-thought-nonstream"}`)
nonStream := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- for name, output := range map[string]gjson.Result{"stream": streamOutput, "non-stream": gjson.GetBytes(nonStream, "output")} {
+ for name, output := range map[string]ojson.Result{"stream": streamOutput, "non-stream": ojson.GetBytesNoCopy(nonStream, "output")} {
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(output.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(output.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 3 || !parts[0].Get("text").Exists() || parts[0].Get("text").String() != "" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("text").String() != "reason" || !parts[1].Get("thought").Bool() || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("text").String() != "answer" || parts[2].Get("thoughtSignature").String() != "" {
t.Fatalf("%s leading carrier crossed signed thought: output=%s translated=%s", name, output.Raw, translated)
}
@@ -556,11 +557,11 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_SignedVisibleThenUnsign
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"signed","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"unsigned"}]},"finishReason":"STOP"}],"responseId":"signed-then-unsigned-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(gjson.GetBytes(out, "output").Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(ojson.GetBytesNoCopy(out, "output").Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "signed" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("text").String() != "unsigned" || parts[1].Get("thoughtSignature").String() != "" {
- t.Fatalf("non-stream signed/unsigned visible boundary changed: output=%s translated=%s", gjson.GetBytes(out, "output").Raw, translated)
+ t.Fatalf("non-stream signed/unsigned visible boundary changed: output=%s translated=%s", ojson.GetBytesNoCopy(out, "output").Raw, translated)
}
}
@@ -569,11 +570,11 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_TrailingCarrierDirectio
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"trailing-direction-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(gjson.GetBytes(out, "output").Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(ojson.GetBytesNoCopy(out, "output").Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "answer" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 {
- t.Fatalf("non-stream trailing carrier changed direction: output=%s translated=%s", gjson.GetBytes(out, "output").Raw, translated)
+ t.Fatalf("non-stream trailing carrier changed direction: output=%s translated=%s", ojson.GetBytesNoCopy(out, "output").Raw, translated)
}
}
@@ -585,7 +586,7 @@ func TestConvertGeminiResponseToOpenAIResponses_CachedTrailingCarrierPreservesDi
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range lines {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -595,9 +596,9 @@ func TestConvertGeminiResponseToOpenAIResponses_CachedTrailingCarrierPreservesDi
}
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "answer" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 {
t.Fatalf("Cached trailing carrier changed direction: output=%s translated=%s", completed.Raw, translated)
}
@@ -616,7 +617,7 @@ func TestConvertGeminiResponseToOpenAIResponses_VisibleSignatureDoesNotOverwrite
var param any
added := make(map[string]string)
done := make(map[string]string)
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -642,11 +643,11 @@ func TestConvertGeminiResponseToOpenAIResponses_VisibleSignatureDoesNotOverwrite
t.Fatalf("Visible signature changed the reasoning timeline: %s", completed.Raw)
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
var signatures []string
visibleSignature := ""
- for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(translated, "contents.0.parts").Array() {
if signature := part.Get("thoughtSignature").String(); signature != "" {
signatures = append(signatures, signature)
if part.Get("text").String() == "answer" {
@@ -669,7 +670,7 @@ func TestConvertGeminiResponseToOpenAIResponses_FlushesVisibleSignatureBeforeLat
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -682,7 +683,7 @@ func TestConvertGeminiResponseToOpenAIResponses_FlushesVisibleSignatureBeforeLat
t.Fatalf("visible signature crossed later thought: %s", completed.Raw)
}
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(`{"input":`+completed.Raw+`}`), false)
- if gjson.GetBytes(translated, "contents.0.parts.1.text").String() != "answer" || gjson.GetBytes(translated, "contents.0.parts.1.thoughtSignature").String() != signature2 || gjson.GetBytes(translated, "contents.0.parts.2.text").String() != "thought-c" {
+ if ojson.GetBytesNoCopy(translated, "contents.0.parts.1.text").String() != "answer" || ojson.GetBytesNoCopy(translated, "contents.0.parts.1.thoughtSignature").String() != signature2 || ojson.GetBytesNoCopy(translated, "contents.0.parts.2.text").String() != "thought-c" {
t.Fatalf("cached visible signature crossed later thought on replay: %s", translated)
}
}
@@ -695,7 +696,7 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionAndTrailingSignaturesRou
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -705,10 +706,10 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionAndTrailingSignaturesRou
}
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
var signatures []string
- for _, content := range gjson.GetBytes(translated, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(translated, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if signature := part.Get("thoughtSignature").String(); signature != "" {
signatures = append(signatures, signature)
@@ -724,7 +725,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_FunctionAndTrailingSign
signature2 := differentResponsesGeminiThoughtSignature(t)
raw := []byte(`{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"function-trailing-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || gjson.GetBytes(out, "output.1.type").String() != "function_call" || decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.2.encrypted_content").String()) != signature2 {
+ if decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || ojson.GetBytesNoCopy(out, "output.1.type").String() != "function_call" || decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.2.encrypted_content").String()) != signature2 {
t.Fatalf("non-stream function/trailing order malformed: %s", out)
}
}
@@ -733,7 +734,7 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionThenTrailingSignatureHas
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"preamble"},{"functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"responseId":"function-trailing-parity"}`)
var param any
- var streamOutput gjson.Result
+ var streamOutput ojson.Result
streamEvents := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, append([]byte("data: "), raw...), ¶m)
streamEvents = append(streamEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m)...)
for _, chunk := range streamEvents {
@@ -743,8 +744,8 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionThenTrailingSignatureHas
}
}
nonStream := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- nonStreamOutput := gjson.GetBytes(nonStream, "output")
- for name, output := range map[string]gjson.Result{"stream": streamOutput, "non-stream": nonStreamOutput} {
+ nonStreamOutput := ojson.GetBytesNoCopy(nonStream, "output")
+ for name, output := range map[string]ojson.Result{"stream": streamOutput, "non-stream": nonStreamOutput} {
items := output.Array()
if len(items) != 3 || items[0].Get("type").String() != "message" || items[1].Get("type").String() != "function_call" || items[2].Get("type").String() != "reasoning" {
t.Fatalf("%s function/trailing order malformed: %s", name, output.Raw)
@@ -754,9 +755,9 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionThenTrailingSignatureHas
t.Fatalf("%s function/trailing carrier malformed: %s", name, output.Raw)
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(output.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(output.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "preamble" || parts[1].Get("functionCall.name").String() != "run_command" || parts[1].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("%s trailing function signature did not replay: %s", name, translated)
}
@@ -767,7 +768,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_TrailingSignatureFollow
signature2 := differentResponsesGeminiThoughtSignature(t)
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"thought","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"reasoning-trailing-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.1.encrypted_content").String()) != signature2 {
+ if decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.1.encrypted_content").String()) != signature2 {
t.Fatalf("non-stream reasoning/trailing order malformed: %s", out)
}
}
@@ -775,7 +776,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_TrailingSignatureFollow
func TestConvertGeminiResponseToOpenAIResponsesNonStream_UnsignedThoughtDoesNotStealFunctionSignature(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"later thought","thought":true}]},"finishReason":"STOP"}],"responseId":"function-unsigned-thought"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || gjson.GetBytes(out, "output.1.type").String() != "function_call" || gjson.GetBytes(out, "output.2.summary.0.text").String() != "later thought" || gjson.GetBytes(out, "output.2.encrypted_content").String() != "" {
+ if decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || ojson.GetBytesNoCopy(out, "output.1.type").String() != "function_call" || ojson.GetBytesNoCopy(out, "output.2.summary.0.text").String() != "later thought" || ojson.GetBytesNoCopy(out, "output.2.encrypted_content").String() != "" {
t.Fatalf("unsigned thought stole function signature: %s", out)
}
}
@@ -785,7 +786,7 @@ func TestConvertGeminiResponseToOpenAIResponses_InterleavedThoughtAndTextPreserv
line := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"thought-a","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"answer-a"},{"text":"thought-b","thought":true,"thoughtSignature":"` + signature2 + `"},{"text":"answer-b"}]},"finishReason":"STOP"}],"responseId":"interleaved"}}`)
var param any
var doneTypes []string
- var completed gjson.Result
+ var completed ojson.Result
streamEvents := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, line, ¶m)
streamEvents = append(streamEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m)...)
for _, chunk := range streamEvents {
@@ -809,10 +810,10 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_InterleavedThoughtAndTe
signature2 := differentResponsesGeminiThoughtSignature(t)
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"thought-a","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"answer-a"},{"text":"thought-b","thought":true,"thoughtSignature":"` + signature2 + `"},{"text":"answer-b"}]},"finishReason":"STOP"}],"responseId":"interleaved-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "output.#").Int(); got != 4 {
+ if got := ojson.GetBytesNoCopy(out, "output.#").Int(); got != 4 {
t.Fatalf("interleaved non-stream output count = %d; output=%s", got, out)
}
- if gjson.GetBytes(out, "output.0.type").String() != "reasoning" || gjson.GetBytes(out, "output.1.type").String() != "message" || gjson.GetBytes(out, "output.2.type").String() != "reasoning" || gjson.GetBytes(out, "output.3.type").String() != "message" {
+ if ojson.GetBytesNoCopy(out, "output.0.type").String() != "reasoning" || ojson.GetBytesNoCopy(out, "output.1.type").String() != "message" || ojson.GetBytesNoCopy(out, "output.2.type").String() != "reasoning" || ojson.GetBytesNoCopy(out, "output.3.type").String() != "message" {
t.Fatalf("interleaved non-stream order malformed: %s", out)
}
}
@@ -825,7 +826,7 @@ func TestConvertGeminiResponseToOpenAIResponses_LeadingEmptyAndSignedTextRoundTr
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -835,11 +836,11 @@ func TestConvertGeminiResponseToOpenAIResponses_LeadingEmptyAndSignedTextRoundTr
}
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
var signatures []string
visibleSignature := ""
- for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(translated, "contents.0.parts").Array() {
if signature := part.Get("thoughtSignature").String(); signature != "" {
signatures = append(signatures, signature)
if part.Get("text").String() == "answer" {
@@ -860,7 +861,7 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedTextAndTrailingSignatureRo
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -873,10 +874,10 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedTextAndTrailingSignatureRo
t.Fatalf("signed text/trailing completed order malformed: %s", completed.Raw)
}
request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ request, _ = ojson.SetRawBytes(request, "input", []byte(completed.Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
var signatures []string
- for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() {
+ for _, part := range ojson.GetBytesNoCopy(translated, "contents.0.parts").Array() {
if signature := part.Get("thoughtSignature").String(); signature != "" {
signatures = append(signatures, signature)
}
@@ -890,7 +891,7 @@ func TestConvertGeminiResponseToOpenAIResponses_PreservesMultipleLeadingEmptySig
signature2 := differentResponsesGeminiThoughtSignature(t)
line := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"leading-empty-signatures"}}`)
var param any
- var completed gjson.Result
+ var completed ojson.Result
streamEvents := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, line, ¶m)
streamEvents = append(streamEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m)...)
for _, chunk := range streamEvents {
@@ -908,7 +909,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_SignedTextAndTrailingSi
signature2 := differentResponsesGeminiThoughtSignature(t)
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-text-trailing-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || gjson.GetBytes(out, "output.1.type").String() != "message" || decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.2.encrypted_content").String()) != signature2 {
+ if decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || ojson.GetBytesNoCopy(out, "output.1.type").String() != "message" || decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.2.encrypted_content").String()) != signature2 {
t.Fatalf("non-stream signed text/trailing order malformed: %s", out)
}
}
@@ -923,7 +924,7 @@ func TestConvertGeminiResponseToOpenAIResponses_DistinctSignedThoughtsUseDistinc
var param any
added := make(map[string]string)
done := make(map[string]string)
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -960,13 +961,13 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_DistinctSignedThoughtsU
signature2 := differentResponsesGeminiThoughtSignature(t)
raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"one","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"two","thought":true,"thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-thoughts-nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "output.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(out, "output.#").Int(); got != 2 {
t.Fatalf("reasoning output count = %d, want 2; output=%s", got, out)
}
- if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature {
+ if got := decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature {
t.Fatalf("first signature = %q; output=%s", got, out)
}
- if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.1.encrypted_content").String()); got != signature2 {
+ if got := decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.1.encrypted_content").String()); got != signature2 {
t.Fatalf("second signature = %q; output=%s", got, out)
}
}
@@ -1103,13 +1104,13 @@ func TestConvertGeminiResponseToOpenAIResponses_BareDoneBeforeStartEmitsNothing(
func TestConvertGeminiResponseToOpenAIResponsesNonStream_VisibleSignatureCompletesReasoning(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"hidden thought","thought":true},{"text":"visible answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_active"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "output.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytesNoCopy(out, "output.0.type").String(); got != "reasoning" {
t.Fatalf("output.0.type = %q, want reasoning; output=%s", got, out)
}
- if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature {
+ if got := decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature {
t.Fatalf("reasoning signature = %q, want %q; output=%s", got, testResponsesGeminiThoughtSignature, out)
}
- if got := gjson.GetBytes(out, "output.1.type").String(); got != "message" {
+ if got := ojson.GetBytesNoCopy(out, "output.1.type").String(); got != "message" {
t.Fatalf("output.1.type = %q, want message; output=%s", got, out)
}
}
@@ -1127,7 +1128,7 @@ func TestConvertGeminiResponseToOpenAIResponses_PreservesTextAroundFunction(t *t
out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m)...)
}
var doneTypes []string
- var completed gjson.Result
+ var completed ojson.Result
for _, chunk := range out {
event, data := parseSSEEvent(t, chunk)
if event == "response.output_item.done" {
@@ -1147,13 +1148,14 @@ func TestConvertGeminiResponseToOpenAIResponses_PreservesTextAroundFunction(t *t
t.Fatalf("completed trailing message = %q", got)
}
- request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ requestDoc := ojson.NewDocString(`{"model":"gemini-3.6-flash-high","input":[]}`)
+ _ = requestDoc.SetRawBytes("input", []byte(completed.Raw))
functionOutput := []byte(`{"type":"function_call_output","call_id":"","output":"ok"}`)
- functionOutput, _ = sjson.SetBytes(functionOutput, "call_id", completed.Get("1.call_id").String())
- request, _ = sjson.SetRawBytes(request, "input.-1", functionOutput)
+ functionOutput, _ = ojson.SetBytes(functionOutput, "call_id", completed.Get("1.call_id").String())
+ _ = requestDoc.SetRawBytes("input.-1", functionOutput)
+ request := requestDoc.Bytes()
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
- contents := gjson.GetBytes(translated, "contents").Array()
+ contents := ojson.GetBytesNoCopy(translated, "contents").Array()
if len(contents) != 2 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" {
t.Fatalf("mixed turn round-trip roles malformed: %s", translated)
}
@@ -1173,7 +1175,7 @@ func TestConvertGeminiResponseToOpenAIResponses_PendingSignatureBeforeFunctionRo
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -1186,7 +1188,7 @@ func TestConvertGeminiResponseToOpenAIResponses_PendingSignatureBeforeFunctionRo
t.Fatal("stream did not emit response.completed output")
}
callID := ""
- completed.ForEach(func(_, item gjson.Result) bool {
+ completed.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "function_call" {
callID = item.Get("call_id").String()
}
@@ -1196,16 +1198,17 @@ func TestConvertGeminiResponseToOpenAIResponses_PendingSignatureBeforeFunctionRo
t.Fatalf("completed output has no function call: %s", completed.Raw)
}
- request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ requestDoc := ojson.NewDocString(`{"model":"gemini-3.6-flash-high","input":[]}`)
+ _ = requestDoc.SetRawBytes("input", []byte(completed.Raw))
functionOutput := []byte(`{"type":"function_call_output","call_id":"","output":"ok"}`)
- functionOutput, _ = sjson.SetBytes(functionOutput, "call_id", callID)
- request, _ = sjson.SetRawBytes(request, "input.-1", functionOutput)
+ functionOutput, _ = ojson.SetBytes(functionOutput, "call_id", callID)
+ _ = requestDoc.SetRawBytes("input.-1", functionOutput)
+ request := requestDoc.Bytes()
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
functionSignature := ""
detachedSignatures := 0
- gjson.GetBytes(translated, "contents.0.parts").ForEach(func(_, part gjson.Result) bool {
+ ojson.GetBytesNoCopy(translated, "contents.0.parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
functionSignature = part.Get("thoughtSignature").String()
}
@@ -1233,7 +1236,7 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedTextBeforeSignedFunctionRo
`data: [DONE]`,
}
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -1242,16 +1245,17 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedTextBeforeSignedFunctionRo
}
}
}
- request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`)
- request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw))
+ requestDoc := ojson.NewDocString(`{"model":"gemini-3.6-flash-high","input":[]}`)
+ _ = requestDoc.SetRawBytes("input", []byte(completed.Raw))
callID := completed.Get("3.call_id").String()
functionOutput := []byte(`{"type":"function_call_output","call_id":"","output":"ok"}`)
- functionOutput, _ = sjson.SetBytes(functionOutput, "call_id", callID)
- request, _ = sjson.SetRawBytes(request, "input.-1", functionOutput)
+ functionOutput, _ = ojson.SetBytes(functionOutput, "call_id", callID)
+ _ = requestDoc.SetRawBytes("input.-1", functionOutput)
+ request := requestDoc.Bytes()
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false)
var textSignature, functionSignature string
- for _, content := range gjson.GetBytes(translated, "contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(translated, "contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("functionCall").Exists() {
functionSignature = part.Get("thoughtSignature").String()
@@ -1271,19 +1275,19 @@ func TestConvertGeminiResponseToOpenAIResponses_SignedTextBeforeSignedFunctionRo
func TestConvertGeminiResponseToOpenAIResponsesNonStream_PreservesTextAroundSignedFunction(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"preface"},{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"after"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_order"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "output.0.type").String(); got != "message" {
+ if got := ojson.GetBytesNoCopy(out, "output.0.type").String(); got != "message" {
t.Fatalf("output.0.type = %q, want message; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "output.1.type").String(); got != "reasoning" {
+ if got := ojson.GetBytesNoCopy(out, "output.1.type").String(); got != "reasoning" {
t.Fatalf("output.1.type = %q, want reasoning; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "output.2.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "output.2.type").String(); got != "function_call" {
t.Fatalf("output.2.type = %q, want function_call; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "output.3.type").String(); got != "message" {
+ if got := ojson.GetBytesNoCopy(out, "output.3.type").String(); got != "message" {
t.Fatalf("output.3.type = %q, want trailing message; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "output.3.content.0.text").String(); got != "after" {
+ if got := ojson.GetBytesNoCopy(out, "output.3.content.0.text").String(); got != "after" {
t.Fatalf("trailing message = %q, want after; output=%s", got, out)
}
}
@@ -1295,7 +1299,7 @@ func TestConvertGeminiResponseToOpenAIResponses_CachesSignatureAfterVisibleText(
}
var param any
var doneTypes []string
- var completedOutput gjson.Result
+ var completedOutput ojson.Result
for _, line := range in {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) {
event, data := parseSSEEvent(t, chunk)
@@ -1311,7 +1315,7 @@ func TestConvertGeminiResponseToOpenAIResponses_CachesSignatureAfterVisibleText(
t.Fatalf("unexpected client timeline: done=%s output=%s", got, completedOutput.Raw)
}
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(`{"input":`+completedOutput.Raw+`}`), false)
- if gjson.GetBytes(translated, "contents.0.parts.0.thoughtSignature").String() != testResponsesGeminiThoughtSignature {
+ if ojson.GetBytesNoCopy(translated, "contents.0.parts.0.thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("cached signature was not replayed: %s", translated)
}
}
@@ -1343,13 +1347,13 @@ func TestConvertGeminiResponseToOpenAIResponses_GeminiToolSignature(t *testing.T
func TestConvertGeminiResponseToOpenAIResponsesNonStream_DetachedSignature(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"visible answer"},{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_detached"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "output.0.type").String(); got != "reasoning" {
+ if got := ojson.GetBytesNoCopy(out, "output.0.type").String(); got != "reasoning" {
t.Fatalf("output.0.type = %q, want reasoning; output=%s", got, out)
}
- if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature {
+ if got := decodedResponsesCarrierSignature(t, ojson.GetBytesNoCopy(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature {
t.Fatalf("detached signature = %q, want %q; output=%s", got, testResponsesGeminiThoughtSignature, out)
}
- if got := gjson.GetBytes(out, "output.1.type").String(); got != "message" {
+ if got := ojson.GetBytesNoCopy(out, "output.1.type").String(); got != "message" {
t.Fatalf("output.1.type = %q, want message; output=%s", got, out)
}
}
@@ -1466,7 +1470,7 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionCallEventOrder(t *testin
if data.Get("response.output.2.name").String() != "tool2" {
t.Fatalf("unexpected output[2] name: %s", data.Get("response.output.2").Raw)
}
- if !gjson.Valid(data.Get("response.output.2.arguments").String()) {
+ if !ojson.Valid(data.Get("response.output.2.arguments").String()) {
t.Fatalf("unexpected output[2] arguments: %q", data.Get("response.output.2.arguments").String())
}
}
@@ -1493,7 +1497,7 @@ func TestConvertGeminiResponseToOpenAIResponses_FunctionCallEventOrder(t *testin
if deltaByIndex[1] != "{}" {
t.Fatalf("unexpected delta for output_index 1: got %q", deltaByIndex[1])
}
- if deltaByIndex[2] == "" || !gjson.Valid(deltaByIndex[2]) || gjson.Get(deltaByIndex[2], "a").Int() != 1 {
+ if deltaByIndex[2] == "" || !ojson.Valid(deltaByIndex[2]) || ojson.Get(deltaByIndex[2], "a").Int() != 1 {
t.Fatalf("unexpected delta for output_index 2: got %q", deltaByIndex[2])
}
if !(posItemDone[2] < posCompleted) {
@@ -1567,7 +1571,7 @@ func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceCusto
}
var param any
- var added, inputDone, done, completed gjson.Result
+ var added, inputDone, done, completed ojson.Result
functionEvents := 0
for _, chunk := range chunks {
for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", originalRequest, nil, chunk, ¶m) {
@@ -1599,7 +1603,7 @@ func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceCusto
}
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
}{
{label: "added", item: added.Get("item")},
{label: "done", item: done.Get("item")},
@@ -1636,7 +1640,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_RestoresAdditionalNames
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__exec","args":{"input":"pwd"}}}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_custom_nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-2.5-flash", originalRequest, nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("non-stream output type = %q, want custom_tool_call; raw: %s", got, out)
@@ -1665,7 +1669,7 @@ func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceFunct
}
var param any
- var added, argDone, done, completed gjson.Result
+ var added, argDone, done, completed ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", originalRequest, nil, chunk, ¶m) {
event, data := parseSSEEvent(t, output)
@@ -1691,7 +1695,7 @@ func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceFunct
}
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
}{
{label: "added", item: added.Get("item")},
{label: "done", item: done.Get("item")},
@@ -1707,7 +1711,7 @@ func TestConvertGeminiResponseToOpenAIResponses_RestoresAdditionalNamespaceFunct
if got := completed.Get("response.output.0.type").String(); got != "function_call" {
t.Fatalf("completed output type = %q, want function_call", got)
}
- if got := gjson.Get(completed.Get("response.output.0.arguments").String(), "value").String(); got != "PROBE" {
+ if got := ojson.Get(completed.Get("response.output.0.arguments").String(), "value").String(); got != "PROBE" {
t.Fatalf("completed value = %q, want PROBE", got)
}
}
@@ -1722,7 +1726,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_RestoresAdditionalNames
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"functions__continuity_probe","args":{"value":"PROBE"}}}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_func_nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-2.5-flash", originalRequest, nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("output.0.type").String(); got != "function_call" {
t.Fatalf("non-stream output type = %q, want function_call; raw: %s", got, out)
@@ -1733,7 +1737,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_RestoresAdditionalNames
if got := root.Get("output.0.namespace").String(); got != "functions" {
t.Fatalf("non-stream output namespace = %q, want functions", got)
}
- if got := gjson.Get(root.Get("output.0.arguments").String(), "value").String(); got != "PROBE" {
+ if got := ojson.Get(root.Get("output.0.arguments").String(), "value").String(); got != "PROBE" {
t.Fatalf("non-stream output value = %q, want PROBE", got)
}
}
@@ -1787,7 +1791,7 @@ func TestConvertGeminiResponseToOpenAIResponses_PinsRequestPerStream(t *testing.
}
var param any
- var added, completed gjson.Result
+ var added, completed ojson.Result
for i, chunk := range chunks {
var requestForChunk []byte
if i == 0 {
@@ -1824,7 +1828,7 @@ func TestConvertGeminiResponseToOpenAIResponses_PinsRequestPerStream(t *testing.
firstReqNoTools := []byte(`{"model":"gemini-2.5-flash","instructions":"pinned-no-tools"}`)
laterReqWithTool := []byte(`{"model":"gemini-2.5-flash","instructions":"later-instructions","tools":[{"type":"function","name":"mcp/search","parameters":{"type":"object"}}]}`)
ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", firstReqNoTools, nil, chunks[0], &nilMapParam)
- var funcAdded, noToolsCompleted gjson.Result
+ var funcAdded, noToolsCompleted ojson.Result
laterChunk := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"mcp_search","args":{}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"gemini-2.5-flash","responseId":"resp_pinned_nil"}`)
for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-flash", laterReqWithTool, nil, laterChunk, &nilMapParam) {
event, data := parseSSEEvent(t, output)
@@ -1876,16 +1880,74 @@ func TestConvertGeminiResponseToOpenAIResponses_NonNormalFinishReasonsMapToIncom
t.Run("nonstream/"+reason, func(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"` + reason + `"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1,"totalTokenCount":11},"modelVersion":"gemini-4-argon","responseId":"resp_1"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(ctx, "gemini-4-argon", req, nil, raw, nil)
- if got := gjson.GetBytes(out, "status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "incomplete" {
t.Fatalf("non-stream status = %q, want incomplete; payload=%s", got, out)
}
- if got := gjson.GetBytes(out, "incomplete_details.reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(out, "incomplete_details.reason").String(); got != "content_filter" {
t.Fatalf("non-stream incomplete_details.reason = %q, want content_filter; payload=%s", got, out)
}
})
}
}
+func TestConvertGeminiResponseToOpenAIResponses_InputBufferReuse(t *testing.T) {
+ ctx := context.Background()
+ req := []byte(`{"model":"gemini-2.5-flash","tools":[{"googleSearch":{}}]}`)
+ chunks := []string{
+ `data: {"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"plan","thoughtSignature":"sig-thought-1"}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_reused_buf"}`,
+ `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"buffered search answer"}]}}],"modelVersion":"gemini-2.5-flash","responseId":"resp_reused_buf"}`,
+ `data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP","groundingMetadata":{"webSearchQueries":["go memory"]}}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":4,"totalTokenCount":9},"modelVersion":"gemini-2.5-flash","responseId":"resp_reused_buf"}`,
+ }
+
+ var param any
+ var buf []byte
+ var all [][]byte
+ for _, chunk := range chunks {
+ buf = append(buf[:0], chunk...)
+ outs := ConvertGeminiResponseToOpenAIResponses(ctx, "gemini-2.5-flash", nil, req, buf, ¶m)
+ all = append(all, outs...)
+ for i := range buf {
+ buf[i] = 'X'
+ }
+ }
+
+ var completed ojson.Result
+ for _, out := range all {
+ event, data := parseSSEEvent(t, out)
+ if event == "response.completed" {
+ completed = data
+ }
+ }
+ if !completed.Exists() {
+ t.Fatalf("missing response.completed event")
+ }
+ if got := completed.Get("response.id").String(); got != "resp_reused_buf" {
+ t.Fatalf("response.id = %q, want resp_reused_buf", got)
+ }
+ if got := completed.Get("response.status").String(); got != "completed" {
+ t.Fatalf("response.status = %q, want completed", got)
+ }
+ var sawReasoningSig, sawSearchText bool
+ for _, item := range completed.Get("response.output").Array() {
+ switch item.Get("type").String() {
+ case "reasoning":
+ if item.Get("encrypted_content").String() == encodeGeminiResponsesCarrier("sig-thought-1", geminiResponsesCarrierStandalone, geminiResponsesCarrierText) {
+ sawReasoningSig = true
+ }
+ case "message":
+ if item.Get("content.0.text").String() == "buffered search answer" {
+ sawSearchText = true
+ }
+ }
+ }
+ if !sawReasoningSig {
+ t.Fatalf("reasoning signature corrupted after buffer reuse: %s", completed.Raw)
+ }
+ if !sawSearchText {
+ t.Fatalf("buffered web search text corrupted after buffer reuse: %s", completed.Raw)
+ }
+}
+
func BenchmarkConvertGeminiResponseToOpenAIResponses_TextDelta(b *testing.B) {
request := []byte(`{"model":"gemini-2.5-flash","instructions":"` + strings.Repeat("x", 1024*1024) + `"}`)
delta := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"text":"token "}]} }],"modelVersion":"gemini-2.5-flash","responseId":"resp_bench"}`)
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go
index ff2ab57d2ca..ff96e21185a 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search.go
@@ -3,10 +3,9 @@ package responses
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const openAIResponsesToolSearchIDPrefix = "tsc_"
@@ -14,8 +13,8 @@ const openAIResponsesToolSearchIDPrefix = "tsc_"
// normalizeOpenAIResponsesToolSearchItems rewrites replayed client-executed
// tool_search history into the function_call / function_call_output shape that
// the Gemini request conversion already pairs and serializes.
-func normalizeOpenAIResponsesToolSearchItems(items []gjson.Result, forwardMap map[string]string) []gjson.Result {
- normalized := make([]gjson.Result, 0, len(items))
+func normalizeOpenAIResponsesToolSearchItems(items []ojson.Result, forwardMap map[string]string) []ojson.Result {
+ normalized := make([]ojson.Result, 0, len(items))
for _, item := range items {
switch item.Get("type").String() {
case util.ResponsesToolSearchCallType:
@@ -33,48 +32,48 @@ func normalizeOpenAIResponsesToolSearchItems(items []gjson.Result, forwardMap ma
return normalized
}
-func openAIResponsesToolSearchCallAsFunctionCall(item gjson.Result, forwardMap map[string]string) gjson.Result {
- call := []byte(`{"type":"function_call","name":"","call_id":"","arguments":"{}"}`)
- call, _ = sjson.SetBytes(call, "name", util.MapResponsesClientToolSearchName(forwardMap))
- call, _ = sjson.SetBytes(call, "call_id", extractOpenAIResponsesCallID(item))
+func openAIResponsesToolSearchCallAsFunctionCall(item ojson.Result, forwardMap map[string]string) ojson.Result {
+ callDoc := ojson.NewDocString(`{"type":"function_call","name":"","call_id":"","arguments":"{}"}`)
+ _ = callDoc.Set("name", util.MapResponsesClientToolSearchName(forwardMap))
+ _ = callDoc.Set("call_id", extractOpenAIResponsesCallID(item))
if id := item.Get("id"); id.Exists() {
- call, _ = sjson.SetBytes(call, "id", id.String())
+ _ = callDoc.Set("id", id.String())
}
if status := item.Get("status"); status.Exists() {
- call, _ = sjson.SetBytes(call, "status", status.String())
+ _ = callDoc.Set("status", status.String())
}
if arguments := item.Get("arguments"); arguments.Exists() {
- if arguments.Type == gjson.String {
- call, _ = translatorcommon.SetStringWithoutHTMLEscape(call, "arguments", arguments.String())
+ if arguments.Type == ojson.String {
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(callDoc, "arguments", arguments.String())
} else if arguments.IsObject() || arguments.IsArray() {
- call, _ = translatorcommon.SetStringWithoutHTMLEscape(call, "arguments", arguments.Raw)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(callDoc, "arguments", arguments.Raw)
}
}
- return gjson.ParseBytes(call)
+ return util.ParseGJSONBytesNoCopy(callDoc.Bytes())
}
-func openAIResponsesToolSearchOutputAsFunctionCallOutput(item gjson.Result, forwardMap map[string]string) gjson.Result {
- output := []byte(`{"type":"function_call_output","name":"","call_id":"","output":""}`)
- output, _ = sjson.SetBytes(output, "name", util.MapResponsesClientToolSearchName(forwardMap))
- output, _ = sjson.SetBytes(output, "call_id", extractOpenAIResponsesCallID(item))
+func openAIResponsesToolSearchOutputAsFunctionCallOutput(item ojson.Result, forwardMap map[string]string) ojson.Result {
+ outputDoc := ojson.NewDocString(`{"type":"function_call_output","name":"","call_id":"","output":""}`)
+ _ = outputDoc.Set("name", util.MapResponsesClientToolSearchName(forwardMap))
+ _ = outputDoc.Set("call_id", extractOpenAIResponsesCallID(item))
if id := item.Get("id"); id.Exists() {
- output, _ = sjson.SetBytes(output, "id", id.String())
+ _ = outputDoc.Set("id", id.String())
}
- summary := []byte(`{"status":"completed","tools":[]}`)
+ summaryDoc := ojson.NewDocString(`{"status":"completed","tools":[]}`)
if status := strings.TrimSpace(item.Get("status").String()); status != "" {
- summary, _ = sjson.SetBytes(summary, "status", status)
+ _ = summaryDoc.Set("status", status)
}
- if errorResult := item.Get("error"); errorResult.Exists() && errorResult.Type != gjson.Null {
- summary, _ = sjson.SetRawBytes(summary, "error", []byte(errorResult.Raw))
+ if errorResult := item.Get("error"); errorResult.Exists() && errorResult.Type != ojson.Null {
+ _ = summaryDoc.SetRaw("error", errorResult.Raw)
}
names := openAIResponsesDiscoveredToolNames(item.Get("tools"), forwardMap)
- summary, _ = sjson.SetBytes(summary, "tools", names)
- output, _ = translatorcommon.SetStringWithoutHTMLEscape(output, "output", string(summary))
- return gjson.ParseBytes(output)
+ _ = summaryDoc.Set("tools", names)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(outputDoc, "output", summaryDoc.String())
+ return util.ParseGJSONBytesNoCopy(outputDoc.Bytes())
}
-func openAIResponsesDiscoveredToolNames(tools gjson.Result, forwardMap map[string]string) []string {
+func openAIResponsesDiscoveredToolNames(tools ojson.Result, forwardMap map[string]string) []string {
names := make([]string, 0)
if !tools.Exists() || !tools.IsArray() {
return names
@@ -92,7 +91,7 @@ func openAIResponsesDiscoveredToolNames(tools gjson.Result, forwardMap map[strin
seen[mapped] = struct{}{}
names = append(names, mapped)
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
switch strings.TrimSpace(tool.Get("type").String()) {
case "", "function", "custom":
name := strings.TrimSpace(tool.Get("name").String())
@@ -106,7 +105,7 @@ func openAIResponsesDiscoveredToolNames(tools gjson.Result, forwardMap map[strin
if !children.Exists() || !children.IsArray() {
children = tool.Get("children")
}
- children.ForEach(func(_, child gjson.Result) bool {
+ children.ForEach(func(_, child ojson.Result) bool {
childName := strings.TrimSpace(child.Get("name").String())
if childName == "" {
childName = strings.TrimSpace(child.Get("function.name").String())
@@ -125,7 +124,7 @@ func openAIResponsesDiscoveredToolNames(tools gjson.Result, forwardMap map[strin
func openAIResponsesToolSearchCallStatus(status, argsJSON string) string {
if status == "completed" {
trimmed := strings.TrimSpace(argsJSON)
- if !gjson.Valid(trimmed) || !gjson.Parse(trimmed).IsObject() {
+ if !ojson.Valid(trimmed) || !ojson.Parse(trimmed).IsObject() {
return "incomplete"
}
}
@@ -134,16 +133,16 @@ func openAIResponsesToolSearchCallStatus(status, argsJSON string) string {
func buildOpenAIResponsesToolSearchCallItem(id, callID, status, argsJSON string) []byte {
status = openAIResponsesToolSearchCallStatus(status, argsJSON)
- item := []byte(`{"id":"","type":"tool_search_call","status":"","execution":"client","call_id":"","arguments":{}}`)
- item, _ = sjson.SetBytes(item, "id", id)
- item, _ = sjson.SetBytes(item, "status", status)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- if trimmed := strings.TrimSpace(argsJSON); gjson.Valid(trimmed) {
- if args := gjson.Parse(trimmed); args.IsObject() {
- item, _ = sjson.SetRawBytes(item, "arguments", []byte(args.Raw))
+ itemDoc := ojson.NewDocString(`{"id":"","type":"tool_search_call","status":"","execution":"client","call_id":"","arguments":{}}`)
+ _ = itemDoc.Set("id", id)
+ _ = itemDoc.Set("status", status)
+ _ = itemDoc.Set("call_id", callID)
+ if trimmed := strings.TrimSpace(argsJSON); ojson.Valid(trimmed) {
+ if args := ojson.Parse(trimmed); args.IsObject() {
+ _ = itemDoc.SetRaw("arguments", args.Raw)
}
}
- return item
+ return itemDoc.Bytes()
}
func openAIResponsesToolSearchCallItemID(callID string) string {
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go
index 7fad8a625a2..006e69da943 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_tool_search_test.go
@@ -4,7 +4,8 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const toolSearchFixtureRequest = `{
@@ -33,11 +34,11 @@ func TestConvertOpenAIResponsesRequestToGemini_ToolSearchRoundTrip(t *testing.T)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
declarations := root.Get("tools.0.functionDeclarations")
names := make([]string, 0)
- declarations.ForEach(func(_, declaration gjson.Result) bool {
+ declarations.ForEach(func(_, declaration ojson.Result) bool {
names = append(names, declaration.Get("name").String())
return true
})
@@ -85,7 +86,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ToolSearchRoundTrip(t *testing.T)
if got := searchOutput.Get("parts.0.functionResponse.id").String(); got != "search_1" {
t.Fatalf("search output id = %q, want search_1", got)
}
- result := gjson.Parse(searchOutput.Get("parts.0.functionResponse.response.result").String())
+ result := ojson.Parse(searchOutput.Get("parts.0.functionResponse.response.result").String())
if got := result.Get("status").String(); got != "completed" {
t.Fatalf("search output status = %q; raw result: %s", got, result.Raw)
}
@@ -108,7 +109,7 @@ func TestConvertGeminiResponseToOpenAIResponses_ToolSearchCallStream(t *testing.
}
var param any
- var added, done, completed gjson.Result
+ var added, done, completed ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), nil, chunk, ¶m) {
event, data := parseSSEEvent(t, output)
@@ -129,7 +130,7 @@ func TestConvertGeminiResponseToOpenAIResponses_ToolSearchCallStream(t *testing.
}
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
status string
}{
{label: "added", item: added.Get("item"), status: "in_progress"},
@@ -151,7 +152,7 @@ func TestConvertGeminiResponseToOpenAIResponses_ToolSearchCallStream(t *testing.
}
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
}{
{label: "done", item: done.Get("item")},
{label: "completed", item: completed.Get("response.output.0")},
@@ -172,7 +173,7 @@ func TestConvertGeminiResponseToOpenAIResponses_ToolSearchCallStream(t *testing.
wantItemID := "tsc_" + added.Get("item.call_id").String()
for _, test := range []struct {
label string
- item gjson.Result
+ item ojson.Result
}{
{label: "added", item: added.Get("item")},
{label: "done", item: done.Get("item")},
@@ -194,7 +195,7 @@ func TestOpenAIResponsesToolSearchCallItemID(t *testing.T) {
func TestConvertGeminiResponseToOpenAIResponsesNonStream_ToolSearchCall(t *testing.T) {
raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"tool_search","args":{"query":"fixture tools"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.8-flash-high","responseId":"resp_tool_search_nonstream"}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), nil, raw, nil)
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
item := root.Get("output.0")
if got := item.Get("type").String(); got != "tool_search_call" {
@@ -233,7 +234,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ToolSearchCallWithStringArguments
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
contents := root.Get("contents").Array()
if len(contents) != 3 {
t.Fatalf("expected 3 contents, got %d: %s", len(contents), root.Get("contents").Raw)
@@ -241,7 +242,7 @@ func TestConvertOpenAIResponsesRequestToGemini_ToolSearchCallWithStringArguments
if got := contents[1].Get("parts.0.functionCall.args.query").String(); got != "nothing" {
t.Fatalf("string arguments must be parsed into args, got %s", contents[1].Raw)
}
- result := gjson.Parse(contents[2].Get("parts.0.functionResponse.response.result").String())
+ result := ojson.Parse(contents[2].Get("parts.0.functionResponse.response.result").String())
if !result.Get("tools").IsArray() || len(result.Get("tools").Array()) != 0 {
t.Fatalf("empty discovery must produce an empty tools list, got %s", result.Raw)
}
@@ -253,7 +254,7 @@ func TestConvertGeminiResponseToOpenAIResponses_DiscoveredToolCallRestoresNamesp
[]byte(`data: [DONE]`),
}
var param any
- var done gjson.Result
+ var done ojson.Result
for _, chunk := range chunks {
for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", []byte(toolSearchFixtureRequest), nil, chunk, ¶m) {
event, data := parseSSEEvent(t, output)
@@ -327,7 +328,7 @@ func TestConvertGeminiResponses_SameNamedUserFunctionAndClientToolSearchCoexist(
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- geminiReq := gjson.ParseBytes(geminiReqBytes)
+ geminiReq := util.ParseGJSONBytesNoCopy(geminiReqBytes)
decls := geminiReq.Get("tools.0.functionDeclarations").Array()
if len(decls) != 2 {
t.Fatalf("expected 2 functionDeclarations (user tool_search + client shim, with deferred hidden), got %d: %s", len(decls), geminiReqBytes)
@@ -365,7 +366,7 @@ func TestConvertGeminiResponses_SameNamedUserFunctionAndClientToolSearchCoexist(
// Response restoration: calling shimGeminiName -> tool_search_call; calling "tool_search" -> function_call.
streamChunk := []byte(`data: {"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"c_shim","name":"` + shimGeminiName + `","args":{"query":"agents"}}},{"functionCall":{"id":"c_user","name":"tool_search","args":{"x":"abc"}}}]},"finishReason":"STOP"}],"responseId":"resp_coexist"}`)
var param any
- var doneItems []gjson.Result
+ var doneItems []ojson.Result
for _, frame := range [][]byte{streamChunk, []byte("data: [DONE]")} {
for _, output := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", request, nil, frame, ¶m) {
event, data := parseSSEEvent(t, output)
@@ -426,7 +427,7 @@ func TestConvertOpenAIResponsesRequestToGemini_HistoryOnlyWithNamespacedToolSear
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
- root := gjson.ParseBytes(out)
+ root := util.ParseGJSONBytesNoCopy(out)
if got := root.Get("tools.0.functionDeclarations.0.name").String(); got != "fixture_tools__tool_search" {
t.Fatalf("discovered declaration name = %q, want fixture_tools__tool_search; raw: %s", got, out)
}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_user_turn_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_user_turn_test.go
index e743f86daa0..5a8dbc4e5cf 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_user_turn_test.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_user_turn_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -71,7 +71,7 @@ func TestConvertOpenAIResponsesRequestToGemini_RefusesAnyEmptiedUserTurn(t *test
if got, want := err.Error(), "unsupported content part: input_file"; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
@@ -90,7 +90,7 @@ func TestConvertOpenAIResponsesRequestToGemini_KeepsTurnWithTextBesideFileID(t *
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "contents.2.parts.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "contents.2.parts.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -111,7 +111,7 @@ func TestConvertOpenAIResponsesRequestToGemini_InlineAndRemoteFilesStayParts(t *
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, tc.path).String(); got != tc.want {
+ if got := ojson.GetBytesNoCopy(body, tc.path).String(); got != tc.want {
t.Fatalf("%s = %q, want %q; body = %s", tc.path, got, tc.want, body)
}
})
@@ -120,7 +120,7 @@ func TestConvertOpenAIResponsesRequestToGemini_InlineAndRemoteFilesStayParts(t *
func TestConvertOpenAIResponsesRequestToGemini_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := geminiTurnPayload("", geminiUserTurn(geminiTurnFileID))
- if body, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3-pro", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3-pro", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search.go
index 896942f1389..0ca135fb000 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search.go
@@ -7,10 +7,9 @@ import (
"strings"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ModelSupportsWebSearch checks whether the given model supports native web search,
@@ -59,7 +58,7 @@ func isResponsesWebSearchToolType(toolType string) bool {
}
// HasResponsesWebSearchTool checks if the request tools array contains an OpenAI web search tool.
-func HasResponsesWebSearchTool(root gjson.Result) bool {
+func HasResponsesWebSearchTool(root ojson.Result) bool {
tools := root.Get("tools")
if !tools.IsArray() {
return false
@@ -73,7 +72,7 @@ func HasResponsesWebSearchTool(root gjson.Result) bool {
}
// HasOnlyResponsesWebSearchTools checks if every tool in the tools array is a web search tool.
-func HasOnlyResponsesWebSearchTools(root gjson.Result) bool {
+func HasOnlyResponsesWebSearchTools(root ojson.Result) bool {
tools := root.Get("tools")
if !tools.IsArray() {
return false
@@ -90,12 +89,12 @@ func HasOnlyResponsesWebSearchTools(root gjson.Result) bool {
}
// AllowsResponsesWebSearchToolChoice checks whether tool_choice permits web search execution.
-func AllowsResponsesWebSearchToolChoice(root gjson.Result) bool {
+func AllowsResponsesWebSearchToolChoice(root ojson.Result) bool {
toolChoice := root.Get("tool_choice")
if !toolChoice.Exists() {
return true
}
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
switch toolChoice.String() {
case "", "auto", "required":
return true
@@ -130,9 +129,9 @@ func AllowsResponsesWebSearchToolChoice(root gjson.Result) bool {
// ExtractResponsesWebSearchQuery extracts the search query from OpenAI Responses input.
// It handles simple string inputs, array of conversation messages, direct content part arrays, and instruction fallbacks.
-func ExtractResponsesWebSearchQuery(root gjson.Result) string {
+func ExtractResponsesWebSearchQuery(root ojson.Result) string {
input := root.Get("input")
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
return strings.TrimSpace(input.String())
}
if input.IsArray() {
@@ -161,7 +160,7 @@ func ExtractResponsesWebSearchQuery(root gjson.Result) string {
continue
}
content := item.Get("content")
- if content.Type == gjson.String && strings.TrimSpace(content.String()) != "" {
+ if content.Type == ojson.String && strings.TrimSpace(content.String()) != "" {
return strings.TrimSpace(content.String())
}
if content.IsArray() {
@@ -187,7 +186,7 @@ func ExtractResponsesWebSearchQuery(root gjson.Result) string {
}
// ExtractResponsesWebSearchAllowedDomains extracts allowed domains from tools[].filters.allowed_domains.
-func ExtractResponsesWebSearchAllowedDomains(root gjson.Result) []string {
+func ExtractResponsesWebSearchAllowedDomains(root ojson.Result) []string {
tools := root.Get("tools")
if !tools.IsArray() {
return nil
@@ -212,23 +211,23 @@ func ExtractResponsesWebSearchAllowedDomains(root gjson.Result) []string {
}
// ExtractGroundingMetadata locates groundingMetadata in either direct or wrapped Gemini response.
-func ExtractGroundingMetadata(root gjson.Result) gjson.Result {
+func ExtractGroundingMetadata(root ojson.Result) ojson.Result {
if gm := root.Get("candidates.0.groundingMetadata"); gm.Exists() {
return gm
}
if gm := root.Get("response.candidates.0.groundingMetadata"); gm.Exists() {
return gm
}
- return gjson.Result{}
+ return ojson.Result{}
}
// ExtractGroundingQueries retrieves the list of search queries from groundingMetadata.
-func ExtractGroundingQueries(groundingMetadata gjson.Result) []string {
+func ExtractGroundingQueries(groundingMetadata ojson.Result) []string {
var queries []string
if webSearchQueries := groundingMetadata.Get("webSearchQueries"); webSearchQueries.IsArray() {
for _, q := range webSearchQueries.Array() {
if str := strings.TrimSpace(q.String()); str != "" {
- queries = append(queries, str)
+ queries = append(queries, strings.Clone(str))
}
}
}
@@ -236,7 +235,7 @@ func ExtractGroundingQueries(groundingMetadata gjson.Result) []string {
}
// ExtractGroundingSources converts groundingMetadata groundingChunks into OpenAI Responses source objects.
-func ExtractGroundingSources(groundingMetadata gjson.Result) [][]byte {
+func ExtractGroundingSources(groundingMetadata ojson.Result) [][]byte {
chunks := groundingMetadata.Get("groundingChunks").Array()
if len(chunks) == 0 {
return nil
@@ -250,7 +249,7 @@ func ExtractGroundingSources(groundingMetadata gjson.Result) [][]byte {
}
seenURLs[uri] = true
src := []byte(`{"type":"url","url":""}`)
- src, _ = sjson.SetBytes(src, "url", uri)
+ src, _ = ojson.SetBytes(src, "url", uri)
sources = append(sources, src)
}
return sources
@@ -258,21 +257,21 @@ func ExtractGroundingSources(groundingMetadata gjson.Result) [][]byte {
// BuildResponsesWebSearchCallItem formats an OpenAI Responses web_search_call output item.
func BuildResponsesWebSearchCallItem(id string, query string, queries []string, sources [][]byte) []byte {
- item := []byte(`{"id":"","type":"web_search_call","status":"completed","action":{"type":"search","query":""}}`)
- item, _ = sjson.SetBytes(item, "id", id)
- item, _ = sjson.SetBytes(item, "action.query", query)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"web_search_call","status":"completed","action":{"type":"search","query":""}}`)
+ _ = itemDoc.Set("id", id)
+ _ = itemDoc.Set("action.query", query)
if len(queries) > 0 {
- item, _ = sjson.SetBytes(item, "action.queries", queries)
+ _ = itemDoc.Set("action.queries", queries)
}
if len(sources) > 0 {
- item, _ = sjson.SetRawBytes(item, "action.sources", translatorcommon.JoinRawArray(sources))
+ _ = itemDoc.SetRawBytes("action.sources", translatorcommon.JoinRawArray(sources))
}
- return item
+ return itemDoc.Bytes()
}
// HasValidWebGrounding checks whether groundingMetadata contains actual web search queries
// or web grounding chunks with a valid non-empty URI.
-func HasValidWebGrounding(groundingMetadata gjson.Result) bool {
+func HasValidWebGrounding(groundingMetadata ojson.Result) bool {
if !groundingMetadata.Exists() {
return false
}
@@ -295,19 +294,19 @@ func HasValidWebGrounding(groundingMetadata gjson.Result) bool {
// MergeGroundingMetadata merges incremental groundingMetadata into existing groundingMetadata,
// combining webSearchQueries, merging groundingChunks with index remapping, and merging groundingSupports.
-func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
+func MergeGroundingMetadata(existingGM, newGM ojson.Result) ojson.Result {
if (!existingGM.Exists() || len(strings.TrimSpace(existingGM.Raw)) == 0) &&
(!newGM.Exists() || len(strings.TrimSpace(newGM.Raw)) == 0) {
return existingGM
}
if !existingGM.Exists() || len(strings.TrimSpace(existingGM.Raw)) == 0 {
- existingGM = gjson.Parse("{}")
+ existingGM = ojson.Parse("{}")
}
if !newGM.Exists() || len(strings.TrimSpace(newGM.Raw)) == 0 {
return existingGM
}
- merged := existingGM.Raw
+ mergedDoc := ojson.NewDocString(existingGM.Raw)
// 1. Merge webSearchQueries (preserving order, avoiding duplicates)
existingQueries := ExtractGroundingQueries(existingGM)
@@ -327,7 +326,7 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
mergedQueries = append(mergedQueries, q)
}
}
- merged, _ = sjson.Set(merged, "webSearchQueries", mergedQueries)
+ _ = mergedDoc.Set("webSearchQueries", mergedQueries)
}
// 2. Merge groundingChunks with index remapping
@@ -381,9 +380,9 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
cumulativeRemap[i] = existingIdx
}
if title != "" {
- existingChunk := gjson.Parse(mergedChunksRaw[existingIdx])
+ existingChunk := ojson.Parse(mergedChunksRaw[existingIdx])
if strings.TrimSpace(existingChunk.Get("web.title").String()) == "" {
- updated, _ := sjson.Set(mergedChunksRaw[existingIdx], "web.title", title)
+ updated, _ := ojson.Set(mergedChunksRaw[existingIdx], "web.title", title)
mergedChunksRaw[existingIdx] = updated
}
}
@@ -411,7 +410,7 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
if len(mergedChunksRaw) > 0 {
chunksJSON := "[" + strings.Join(mergedChunksRaw, ",") + "]"
- merged, _ = sjson.SetRaw(merged, "groundingChunks", chunksJSON)
+ _ = mergedDoc.SetRaw("groundingChunks", chunksJSON)
}
totalRawCount := prevRawCount + len(newChunks)
@@ -425,8 +424,8 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
for _, k := range keys {
remapPairs = append(remapPairs, fmt.Sprintf("%q:%d", strconv.Itoa(k), cumulativeRemap[k]))
}
- merged, _ = sjson.SetRaw(merged, "_chunkIndexRemap", "{"+strings.Join(remapPairs, ",")+"}")
- merged, _ = sjson.Set(merged, "_rawChunkCount", totalRawCount)
+ _ = mergedDoc.SetRaw("_chunkIndexRemap", "{"+strings.Join(remapPairs, ",")+"}")
+ _ = mergedDoc.Set("_rawChunkCount", totalRawCount)
}
// 3. Merge groundingSupports with remapped chunk indices
@@ -445,7 +444,7 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
existingChunkCount := len(existingChunks)
- remapSupportIndices := func(origIndices []gjson.Result, isExisting bool) ([]int64, bool) {
+ remapSupportIndices := func(origIndices []ojson.Result, isExisting bool) ([]int64, bool) {
var remappedIndices []int64
needRewrite := false
for _, idxRes := range origIndices {
@@ -512,7 +511,7 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
for i, v := range remappedIndices {
indicesStrs[i] = strconv.FormatInt(v, 10)
}
- rawSupport, _ = sjson.SetRaw(rawSupport, "groundingChunkIndices", "["+strings.Join(indicesStrs, ",")+"]")
+ rawSupport, _ = ojson.SetRaw(rawSupport, "groundingChunkIndices", "["+strings.Join(indicesStrs, ",")+"]")
}
mergedSupportsRaw = append(mergedSupportsRaw, rawSupport)
}
@@ -538,27 +537,27 @@ func MergeGroundingMetadata(existingGM, newGM gjson.Result) gjson.Result {
for i, v := range remappedIndices {
indicesStrs[i] = strconv.FormatInt(v, 10)
}
- rawSupport, _ = sjson.SetRaw(rawSupport, "groundingChunkIndices", "["+strings.Join(indicesStrs, ",")+"]")
+ rawSupport, _ = ojson.SetRaw(rawSupport, "groundingChunkIndices", "["+strings.Join(indicesStrs, ",")+"]")
}
mergedSupportsRaw = append(mergedSupportsRaw, rawSupport)
}
if len(mergedSupportsRaw) > 0 {
supportsJSON := "[" + strings.Join(mergedSupportsRaw, ",") + "]"
- merged, _ = sjson.SetRaw(merged, "groundingSupports", supportsJSON)
+ _ = mergedDoc.SetRaw("groundingSupports", supportsJSON)
}
// 4. Preserve/update searchEntryPoint if present
if sep := newGM.Get("searchEntryPoint"); sep.Exists() {
- merged, _ = sjson.SetRaw(merged, "searchEntryPoint", sep.Raw)
+ _ = mergedDoc.SetRaw("searchEntryPoint", sep.Raw)
}
// 5. Preserve/update retrievalQueries if present
if rq := newGM.Get("retrievalQueries"); rq.Exists() && !existingGM.Get("retrievalQueries").Exists() {
- merged, _ = sjson.SetRaw(merged, "retrievalQueries", rq.Raw)
+ _ = mergedDoc.SetRaw("retrievalQueries", rq.Raw)
}
- return gjson.Parse(merged)
+ return ojson.Parse(mergedDoc.String())
}
// MergeCitationAnnotations merges two slices of URL citation annotations,
@@ -573,9 +572,9 @@ func MergeCitationAnnotations(existing, late [][]byte) [][]byte {
result := make([][]byte, 0, len(existing)+len(late))
keyToIndex := make(map[string]int)
for _, a := range existing {
- url := gjson.GetBytes(a, "url").String()
- start := gjson.GetBytes(a, "start_index").Int()
- end := gjson.GetBytes(a, "end_index").Int()
+ url := ojson.GetBytesNoCopy(a, "url").String()
+ start := ojson.GetBytesNoCopy(a, "start_index").Int()
+ end := ojson.GetBytesNoCopy(a, "end_index").Int()
key := fmt.Sprintf("%s:%d:%d", url, start, end)
if _, exists := keyToIndex[key]; !exists {
keyToIndex[key] = len(result)
@@ -583,13 +582,13 @@ func MergeCitationAnnotations(existing, late [][]byte) [][]byte {
}
}
for _, a := range late {
- url := gjson.GetBytes(a, "url").String()
- start := gjson.GetBytes(a, "start_index").Int()
- end := gjson.GetBytes(a, "end_index").Int()
+ url := ojson.GetBytesNoCopy(a, "url").String()
+ start := ojson.GetBytesNoCopy(a, "start_index").Int()
+ end := ojson.GetBytesNoCopy(a, "end_index").Int()
key := fmt.Sprintf("%s:%d:%d", url, start, end)
if idx, exists := keyToIndex[key]; exists {
- if gjson.GetBytes(result[idx], "title").String() == "" {
- if lateTitle := gjson.GetBytes(a, "title").String(); lateTitle != "" {
+ if ojson.GetBytesNoCopy(result[idx], "title").String() == "" {
+ if lateTitle := ojson.GetBytesNoCopy(a, "title").String(); lateTitle != "" {
result[idx] = a
}
}
@@ -718,7 +717,7 @@ func mapByteOffsetsToRuneRange(mappings []GeminiPartMapping, startByte, endByte
// BuildResponsesURLCitationsForMessages extracts url_citation annotations from groundingMetadata,
// correctly translating part-level byte offsets to message-level rune offsets based on GeminiPartMapping.
// It returns a map from MessageIndex to the slice of citation items for that message.
-func BuildResponsesURLCitationsForMessages(groundingMetadata gjson.Result, mappings []GeminiPartMapping, messageTexts []string) map[int][][]byte {
+func BuildResponsesURLCitationsForMessages(groundingMetadata ojson.Result, mappings []GeminiPartMapping, messageTexts []string) map[int][][]byte {
chunks := groundingMetadata.Get("groundingChunks").Array()
supports := groundingMetadata.Get("groundingSupports").Array()
if len(supports) == 0 || len(chunks) == 0 {
@@ -802,12 +801,12 @@ func BuildResponsesURLCitationsForMessages(groundingMetadata gjson.Result, mappi
}
seen[key] = true
- cite := []byte(`{"type":"url_citation","url":"","title":"","start_index":0,"end_index":0}`)
- cite, _ = sjson.SetBytes(cite, "url", uri)
- cite, _ = sjson.SetBytes(cite, "title", title)
- cite, _ = sjson.SetBytes(cite, "start_index", r.StartIndex)
- cite, _ = sjson.SetBytes(cite, "end_index", r.EndIndex)
- result[r.MessageIndex] = append(result[r.MessageIndex], cite)
+ citeDoc := ojson.NewDocString(`{"type":"url_citation","url":"","title":"","start_index":0,"end_index":0}`)
+ _ = citeDoc.Set("url", uri)
+ _ = citeDoc.Set("title", title)
+ _ = citeDoc.Set("start_index", r.StartIndex)
+ _ = citeDoc.Set("end_index", r.EndIndex)
+ result[r.MessageIndex] = append(result[r.MessageIndex], citeDoc.Bytes())
}
}
}
@@ -816,7 +815,7 @@ func BuildResponsesURLCitationsForMessages(groundingMetadata gjson.Result, mappi
// BuildResponsesURLCitations extracts url_citation annotations from groundingMetadata groundingSupports.
// Optional text parameter enables converting UTF-8 byte offsets to Unicode character (rune) offsets.
-func BuildResponsesURLCitations(groundingMetadata gjson.Result, text ...string) [][]byte {
+func BuildResponsesURLCitations(groundingMetadata ojson.Result, text ...string) [][]byte {
var fullText string
if len(text) > 0 {
fullText = text[0]
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search_test.go
index 0c271bd3f61..7ec23ae5bbc 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search_test.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_web_search_test.go
@@ -5,9 +5,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func registerTestWebSearchModel(t *testing.T, clientID, provider, modelID string, supportsSearch bool) {
@@ -24,8 +24,8 @@ func registerTestWebSearchModel(t *testing.T, clientID, provider, modelID string
})
}
-func collectResponsesStreamEvents(events [][]byte) (eventTypes []string, byType map[string][]gjson.Result) {
- byType = make(map[string][]gjson.Result)
+func collectResponsesStreamEvents(events [][]byte) (eventTypes []string, byType map[string][]ojson.Result) {
+ byType = make(map[string][]ojson.Result)
for _, ev := range events {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -35,29 +35,29 @@ func collectResponsesStreamEvents(events [][]byte) (eventTypes []string, byType
eventTypes = append(eventTypes, currentType)
}
if strings.HasPrefix(line, "data: ") && currentType != "" {
- byType[currentType] = append(byType[currentType], gjson.Parse(strings.TrimPrefix(line, "data: ")))
+ byType[currentType] = append(byType[currentType], ojson.Parse(strings.TrimPrefix(line, "data: ")))
}
}
}
return eventTypes, byType
}
-func findWebSearchCallDone(payloads []gjson.Result) gjson.Result {
+func findWebSearchCallDone(payloads []ojson.Result) ojson.Result {
for _, p := range payloads {
if p.Get("item.type").String() == "web_search_call" {
return p
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func findMessageOutputItemDone(payloads []gjson.Result) gjson.Result {
+func findMessageOutputItemDone(payloads []ojson.Result) ojson.Result {
for _, p := range payloads {
if p.Get("item.type").String() == "message" {
return p
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
func TestConvertOpenAIResponsesRequestToGemini_WebSearchCapabilityGate(t *testing.T) {
@@ -75,7 +75,7 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchCapabilityGate(t *testin
// Capable model should receive googleSearch tool
capableOut, _ := ConvertOpenAIResponsesRequestToGemini(capableModel, req, false)
- toolsCapable := gjson.GetBytes(capableOut, "tools").Array()
+ toolsCapable := ojson.GetBytesNoCopy(capableOut, "tools").Array()
foundGoogleSearch := false
for _, tool := range toolsCapable {
if tool.Get("googleSearch").Exists() {
@@ -89,7 +89,7 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchCapabilityGate(t *testin
// Incapable model must NOT receive googleSearch tool
incapableOut, _ := ConvertOpenAIResponsesRequestToGemini(incapableModel, req, false)
- toolsIncapable := gjson.GetBytes(incapableOut, "tools")
+ toolsIncapable := ojson.GetBytesNoCopy(incapableOut, "tools")
if toolsIncapable.Exists() {
for _, tool := range toolsIncapable.Array() {
if tool.Get("googleSearch").Exists() {
@@ -115,7 +115,7 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchAllowedDomains(t *testin
}`)
out, _ := ConvertOpenAIResponsesRequestToGemini(modelID, req, false)
- domains := gjson.GetBytes(out, "tools.0.googleSearch.includedDomains").Array()
+ domains := ojson.GetBytesNoCopy(out, "tools.0.googleSearch.includedDomains").Array()
if len(domains) != 2 {
t.Fatalf("expected 2 includedDomains, got %d: %s", len(domains), out)
}
@@ -136,7 +136,7 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchToolChoiceNoneSuppresses
}`)
out, _ := ConvertOpenAIResponsesRequestToGemini(modelID, req, false)
- if tools := gjson.GetBytes(out, "tools"); tools.Exists() {
+ if tools := ojson.GetBytesNoCopy(out, "tools"); tools.Exists() {
for _, tool := range tools.Array() {
if tool.Get("googleSearch").Exists() {
t.Fatalf("tool_choice: none should suppress googleSearch, got: %s", out)
@@ -178,7 +178,7 @@ func TestConvertGeminiResponseToOpenAIResponsesNonStream_GroundingMetadata(t *te
}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-test", nil, nil, geminiResp, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
// Check output array has 2 items: web_search_call and message
outputs := parsed.Get("output").Array()
@@ -370,14 +370,14 @@ func TestBuildResponsesURLCitations_RuneOffsetConversion(t *testing.T) {
}
]
}`
- gm := gjson.Parse(rawJSON)
+ gm := ojson.Parse(rawJSON)
citations := BuildResponsesURLCitations(gm, fullText)
if len(citations) != 1 {
t.Fatalf("expected exactly 1 citation (inverted one dropped), got %d", len(citations))
}
- cite := gjson.ParseBytes(citations[0])
+ cite := util.ParseGJSONBytesNoCopy(citations[0])
startRune := cite.Get("start_index").Int()
endRune := cite.Get("end_index").Int()
@@ -401,7 +401,7 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchOnlyToolChoiceRequiredNo
}`)
out, _ := ConvertOpenAIResponsesRequestToGemini(modelID, req, false)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
// googleSearch should be present
if !parsed.Get("tools.0.googleSearch").Exists() {
@@ -414,22 +414,22 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchOnlyToolChoiceRequiredNo
}
func TestHasValidWebGrounding(t *testing.T) {
- empty := gjson.Parse(`{}`)
+ empty := ojson.Parse(`{}`)
if HasValidWebGrounding(empty) {
t.Fatal("empty metadata should not be valid web grounding")
}
- noWeb := gjson.Parse(`{"groundingChunks": [{"other": "something"}]}`)
+ noWeb := ojson.Parse(`{"groundingChunks": [{"other": "something"}]}`)
if HasValidWebGrounding(noWeb) {
t.Fatal("metadata without web uri should not be valid web grounding")
}
- supportsOnly := gjson.Parse(`{"groundingSupports": [{"groundingChunkIndices": [0]}]}`)
+ supportsOnly := ojson.Parse(`{"groundingSupports": [{"groundingChunkIndices": [0]}]}`)
if HasValidWebGrounding(supportsOnly) {
t.Fatal("metadata with only groundingSupports should not be valid web grounding")
}
- ragRetrieval := gjson.Parse(`{
+ ragRetrieval := ojson.Parse(`{
"groundingChunks": [{"retrievedContext": {"uri": "rag-doc-1", "title": "Internal Doc"}}],
"groundingSupports": [{"groundingChunkIndices": [0], "segment": {"startIndex": 0, "endIndex": 10}}]
}`)
@@ -437,7 +437,7 @@ func TestHasValidWebGrounding(t *testing.T) {
t.Fatal("metadata with only retrievedContext chunks and supports should not be valid web grounding")
}
- emptyWebURI := gjson.Parse(`{
+ emptyWebURI := ojson.Parse(`{
"groundingChunks": [{"web": {"uri": ""}}],
"groundingSupports": [{"groundingChunkIndices": [0]}]
}`)
@@ -445,17 +445,17 @@ func TestHasValidWebGrounding(t *testing.T) {
t.Fatal("metadata with empty web uri should not be valid web grounding")
}
- withQuery := gjson.Parse(`{"webSearchQueries": ["query"]}`)
+ withQuery := ojson.Parse(`{"webSearchQueries": ["query"]}`)
if !HasValidWebGrounding(withQuery) {
t.Fatal("metadata with webSearchQueries should be valid web grounding")
}
- withChunk := gjson.Parse(`{"groundingChunks": [{"web": {"uri": "https://example.com"}}]}`)
+ withChunk := ojson.Parse(`{"groundingChunks": [{"web": {"uri": "https://example.com"}}]}`)
if !HasValidWebGrounding(withChunk) {
t.Fatal("metadata with web chunk should be valid web grounding")
}
- ragWithWebQuery := gjson.Parse(`{
+ ragWithWebQuery := ojson.Parse(`{
"groundingChunks": [{"retrievedContext": {"uri": "rag-doc-1"}}],
"webSearchQueries": ["actual query"]
}`)
@@ -540,9 +540,9 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateGroundingMetadataAndCJ
allEvents := append(events1, events2...)
var eventTypes []string
- var completedJSON gjson.Result
- var partDoneJSON gjson.Result
- var itemDoneJSON gjson.Result
+ var completedJSON ojson.Result
+ var partDoneJSON ojson.Result
+ var itemDoneJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
@@ -554,7 +554,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateGroundingMetadataAndCJ
}
if strings.HasPrefix(line, "data: ") {
evBody := strings.TrimPrefix(line, "data: ")
- parsed := gjson.Parse(evBody)
+ parsed := ojson.Parse(evBody)
if currentType == "response.completed" {
completedJSON = parsed
}
@@ -651,7 +651,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateGroundingMetadataAndCJ
func TestAllowsResponsesWebSearchToolChoice_AllowedTools(t *testing.T) {
// 1. allowed_tools containing web_search should permit search
- withSearch := gjson.Parse(`{
+ withSearch := ojson.Parse(`{
"tool_choice": {
"type": "allowed_tools",
"mode": "auto",
@@ -663,7 +663,7 @@ func TestAllowsResponsesWebSearchToolChoice_AllowedTools(t *testing.T) {
}
// 2. allowed_tools without web_search should NOT permit search
- withoutSearch := gjson.Parse(`{
+ withoutSearch := ojson.Parse(`{
"tool_choice": {
"type": "allowed_tools",
"mode": "auto",
@@ -675,7 +675,7 @@ func TestAllowsResponsesWebSearchToolChoice_AllowedTools(t *testing.T) {
}
// 3. allowed_tools containing the versioned preview alias should permit search
- withPreview := gjson.Parse(`{
+ withPreview := ojson.Parse(`{
"tool_choice": {
"type": "allowed_tools",
"mode": "auto",
@@ -690,11 +690,11 @@ func TestAllowsResponsesWebSearchToolChoice_AllowedTools(t *testing.T) {
func TestResponsesWebSearchToolTypeAliases(t *testing.T) {
aliases := []string{"web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11"}
for _, toolType := range aliases {
- decl := gjson.Parse(`{"tools":[{"type":"` + toolType + `"}]}`)
+ decl := ojson.Parse(`{"tools":[{"type":"` + toolType + `"}]}`)
if !HasResponsesWebSearchTool(decl) {
t.Fatalf("HasResponsesWebSearchTool(%q) = false, want true", toolType)
}
- choice := gjson.Parse(`{"tool_choice":{"type":"` + toolType + `"}}`)
+ choice := ojson.Parse(`{"tool_choice":{"type":"` + toolType + `"}}`)
if !AllowsResponsesWebSearchToolChoice(choice) {
t.Fatalf("AllowsResponsesWebSearchToolChoice(%q) = false, want true", toolType)
}
@@ -713,14 +713,14 @@ func TestConvertOpenAIResponsesRequestToGemini_WebSearchPreview20250311(t *testi
}`)
out, _ := ConvertOpenAIResponsesRequestToGemini(modelID, req, false)
- if !gjson.GetBytes(out, "tools.0.googleSearch").Exists() {
+ if !ojson.GetBytesNoCopy(out, "tools.0.googleSearch").Exists() {
t.Fatalf("expected googleSearch tool for web_search_preview_2025_03_11 declaration and tool_choice, got: %s", out)
}
}
func TestExtractResponsesWebSearchQuery_MultipleTextParts(t *testing.T) {
// 1. Array of input_text parts
- flatInput := gjson.Parse(`{
+ flatInput := ojson.Parse(`{
"input": [
{"type": "input_text", "text": "Who is"},
{"type": "input_text", "text": "the current Go release lead?"}
@@ -733,7 +733,7 @@ func TestExtractResponsesWebSearchQuery_MultipleTextParts(t *testing.T) {
}
// 2. Message with multiple content parts
- nestedInput := gjson.Parse(`{
+ nestedInput := ojson.Parse(`{
"input": [
{
"role": "user",
@@ -802,7 +802,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ModelAliasUsesEffectiveReq
allEvents := append(events1, events2...)
allEvents = append(allEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), resolvedModel, originalReq, effectiveReq, []byte("[DONE]"), ¶m)...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -811,7 +811,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ModelAliasUsesEffectiveReq
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -887,7 +887,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_InterleavedTextAndFunction
allEvents := append(append(append(e1, e2...), e3...), e4...)
var textBDeltas []string
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -898,13 +898,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_InterleavedTextAndFunction
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
if currentType == "response.output_text.delta" {
- delta := gjson.Get(dataStr, "delta").String()
+ delta := ojson.Get(dataStr, "delta").String()
if strings.Contains(delta, "Text B") {
textBDeltas = append(textBDeltas, delta)
}
}
if currentType == "response.completed" {
- completedJSON = gjson.Parse(dataStr)
+ completedJSON = ojson.Parse(dataStr)
}
}
}
@@ -1005,7 +1005,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_OutputIndexMatchesComplete
itemID string
}
var addedItems []itemAddedInfo
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
@@ -1017,13 +1017,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_OutputIndexMatchesComplete
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
if currentType == "response.output_item.added" {
- idx := int(gjson.Get(dataStr, "output_index").Int())
- typ := gjson.Get(dataStr, "item.type").String()
- id := gjson.Get(dataStr, "item.id").String()
+ idx := int(ojson.Get(dataStr, "output_index").Int())
+ typ := ojson.Get(dataStr, "item.type").String()
+ id := ojson.Get(dataStr, "item.id").String()
addedItems = append(addedItems, itemAddedInfo{outputIndex: idx, itemType: typ, itemID: id})
}
if currentType == "response.completed" {
- completedJSON = gjson.Parse(dataStr)
+ completedJSON = ojson.Parse(dataStr)
}
}
}
@@ -1077,7 +1077,7 @@ func TestConvertGeminiResponseToOpenAIResponses_CitationsRespectPartIndexAndMess
}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-test", nil, nil, geminiResp, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
msg := parsed.Get("response.output.#(type=message)")
if !msg.Exists() {
@@ -1131,11 +1131,11 @@ func TestConvertGeminiResponseToOpenAIResponses_CitationsRespectPartIndexAndMess
}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-test", nil, nil, geminiResp, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
outputs := parsed.Get("output").Array()
// outputs: [web_search_call, message_0, function_call, message_1]
- var firstMsg, secondMsg gjson.Result
+ var firstMsg, secondMsg ojson.Result
for _, item := range outputs {
if item.Get("type").String() == "message" {
if !firstMsg.Exists() {
@@ -1196,7 +1196,7 @@ func TestConvertGeminiResponseToOpenAIResponses_CitationsRespectPartIndexAndMess
}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-test", nil, nil, geminiResp, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
msg := parsed.Get("output.#(type=message)")
citations := msg.Get("content.0.annotations").Array()
@@ -1243,10 +1243,10 @@ func TestConvertGeminiResponseToOpenAIResponses_CitationsRespectPartIndexAndMess
}`)
out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-test", nil, nil, geminiResp, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
outputs := parsed.Get("output").Array()
- var firstMsg, secondMsg gjson.Result
+ var firstMsg, secondMsg ojson.Result
for _, item := range outputs {
if item.Get("type").String() == "message" {
if !firstMsg.Exists() {
@@ -1287,7 +1287,7 @@ func TestConvertGeminiResponseToOpenAIResponses_CitationsRespectPartIndexAndMess
func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
t.Run("citation span covering two messages", func(t *testing.T) {
- gm := gjson.Parse(`{
+ gm := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/span", "title": "Span Title"}}
],
@@ -1323,7 +1323,7 @@ func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
if len(c0) != 1 {
t.Fatalf("expected 1 citation in message 0, got %d", len(c0))
}
- if gjson.GetBytes(c0[0], "start_index").Int() != 0 || gjson.GetBytes(c0[0], "end_index").Int() != 6 {
+ if ojson.GetBytesNoCopy(c0[0], "start_index").Int() != 0 || ojson.GetBytesNoCopy(c0[0], "end_index").Int() != 6 {
t.Fatalf("expected [0, 6) in message 0, got: %s", string(c0[0]))
}
@@ -1331,13 +1331,13 @@ func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
if len(c1) != 1 {
t.Fatalf("expected 1 citation in message 1, got %d", len(c1))
}
- if gjson.GetBytes(c1[0], "start_index").Int() != 0 || gjson.GetBytes(c1[0], "end_index").Int() != 5 {
+ if ojson.GetBytesNoCopy(c1[0], "start_index").Int() != 0 || ojson.GetBytesNoCopy(c1[0], "end_index").Int() != 5 {
t.Fatalf("expected [0, 5) in message 1, got: %s", string(c1[0]))
}
})
t.Run("citation partial overlap across two messages", func(t *testing.T) {
- gm := gjson.Parse(`{
+ gm := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/span", "title": "Span Title"}}
],
@@ -1373,7 +1373,7 @@ func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
if len(c0) != 1 {
t.Fatalf("expected 1 citation in message 0, got %d", len(c0))
}
- if gjson.GetBytes(c0[0], "start_index").Int() != 2 || gjson.GetBytes(c0[0], "end_index").Int() != 6 {
+ if ojson.GetBytesNoCopy(c0[0], "start_index").Int() != 2 || ojson.GetBytesNoCopy(c0[0], "end_index").Int() != 6 {
t.Fatalf("expected [2, 6) in message 0, got: %s", string(c0[0]))
}
@@ -1381,13 +1381,13 @@ func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
if len(c1) != 1 {
t.Fatalf("expected 1 citation in message 1, got %d", len(c1))
}
- if gjson.GetBytes(c1[0], "start_index").Int() != 0 || gjson.GetBytes(c1[0], "end_index").Int() != 3 {
+ if ojson.GetBytesNoCopy(c1[0], "start_index").Int() != 0 || ojson.GetBytesNoCopy(c1[0], "end_index").Int() != 3 {
t.Fatalf("expected [0, 3) in message 1, got: %s", string(c1[0]))
}
})
t.Run("citation span with non-ASCII CJK runes across two messages", func(t *testing.T) {
- gm := gjson.Parse(`{
+ gm := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/cjk", "title": "CJK Title"}}
],
@@ -1423,7 +1423,7 @@ func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
if len(c0) != 1 {
t.Fatalf("expected 1 citation in message 0, got %d", len(c0))
}
- if gjson.GetBytes(c0[0], "start_index").Int() != 0 || gjson.GetBytes(c0[0], "end_index").Int() != 3 {
+ if ojson.GetBytesNoCopy(c0[0], "start_index").Int() != 0 || ojson.GetBytesNoCopy(c0[0], "end_index").Int() != 3 {
t.Fatalf("expected [0, 3) in message 0, got: %s", string(c0[0]))
}
@@ -1431,7 +1431,7 @@ func TestBuildResponsesURLCitationsForMessages_MultiMessageSpan(t *testing.T) {
if len(c1) != 1 {
t.Fatalf("expected 1 citation in message 1, got %d", len(c1))
}
- if gjson.GetBytes(c1[0], "start_index").Int() != 0 || gjson.GetBytes(c1[0], "end_index").Int() != 2 {
+ if ojson.GetBytesNoCopy(c1[0], "start_index").Int() != 0 || ojson.GetBytesNoCopy(c1[0], "end_index").Int() != 2 {
t.Fatalf("expected [0, 2) in message 1, got: %s", string(c1[0]))
}
})
@@ -1587,7 +1587,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
allEvents := append(append(append(e1, e2...), e3...), e4...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -1596,7 +1596,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -1681,7 +1681,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
allEvents := append(append(append(e1, e2...), e3...), e4...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -1690,7 +1690,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -1773,7 +1773,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
allEvents := append(append(append(e1, e2...), e3...), e4...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -1782,7 +1782,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -1861,7 +1861,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
allEvents := append(append(append(e1, e2...), e3...), e4...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -1870,7 +1870,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -1975,7 +1975,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
allEvents := append(append(append(append(e1, e2...), e3...), e4...), e5...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -1984,7 +1984,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultiChunkCitationsAndExpl
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -2070,7 +2070,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_InterleavedTextAndThought(
itemType string
}
var addedItems []itemAddedInfo
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
@@ -2082,12 +2082,12 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_InterleavedTextAndThought(
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
if currentType == "response.output_item.added" {
- idx := int(gjson.Get(dataStr, "output_index").Int())
- typ := gjson.Get(dataStr, "item.type").String()
+ idx := int(ojson.Get(dataStr, "output_index").Int())
+ typ := ojson.Get(dataStr, "item.type").String()
addedItems = append(addedItems, itemAddedInfo{outputIndex: idx, itemType: typ})
}
if currentType == "response.completed" {
- completedJSON = gjson.Parse(dataStr)
+ completedJSON = ojson.Parse(dataStr)
}
}
}
@@ -2207,7 +2207,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_InterleavedTextAndThought(
itemType string
}
var addedItems []itemAddedInfo
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
@@ -2219,12 +2219,12 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_InterleavedTextAndThought(
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
if currentType == "response.output_item.added" {
- idx := int(gjson.Get(dataStr, "output_index").Int())
- typ := gjson.Get(dataStr, "item.type").String()
+ idx := int(ojson.Get(dataStr, "output_index").Int())
+ typ := ojson.Get(dataStr, "item.type").String()
addedItems = append(addedItems, itemAddedInfo{outputIndex: idx, itemType: typ})
}
if currentType == "response.completed" {
- completedJSON = gjson.Parse(dataStr)
+ completedJSON = ojson.Parse(dataStr)
}
}
}
@@ -2380,7 +2380,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveBufferedTextChu
allEvents := append(append(append(append(append(e1, e2...), e3...), e4...), e5...), e6...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -2389,7 +2389,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveBufferedTextChu
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -2517,7 +2517,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveBufferedTextChu
allEvents := append(append(append(append(append(e1, e2...), e3...), e4...), e5...), e6...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -2526,7 +2526,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveBufferedTextChu
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -2644,7 +2644,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveFunctionCallsAd
allEvents := append(append(append(append(e1, e2...), e3...), e4...), e5...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -2653,7 +2653,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveFunctionCallsAd
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -2663,7 +2663,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveFunctionCallsAd
t.Fatalf("expected outputs in completed response, got none: %s", completedJSON.Raw)
}
- var trailingMsg gjson.Result
+ var trailingMsg ojson.Result
foundTrailing := false
for _, out := range outputs {
if out.Get("type").String() == "message" && out.Get("content.0.text").String() == "Trailing citation text." {
@@ -2690,7 +2690,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_ConsecutiveFunctionCallsAd
func TestMergeGroundingMetadata(t *testing.T) {
t.Run("merging disjoint chunks and supports with index remapping", func(t *testing.T) {
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"webSearchQueries": ["query1"],
"groundingChunks": [
{"web": {"uri": "https://example.com/1", "title": "Title 1"}}
@@ -2702,7 +2702,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}
]
}`)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"webSearchQueries": ["query2"],
"groundingChunks": [
{"web": {"uri": "https://example.com/2", "title": "Title 2"}}
@@ -2743,13 +2743,13 @@ func TestMergeGroundingMetadata(t *testing.T) {
})
t.Run("incremental supports without chunks referencing previously seen chunk", func(t *testing.T) {
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"webSearchQueries": ["query1"],
"groundingChunks": [
{"web": {"uri": "https://example.com/1", "title": "Title 1"}}
]
}`)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingSupports": [
{
"segment": {"startIndex": 0, "endIndex": 5, "partIndex": 0},
@@ -2771,12 +2771,12 @@ func TestMergeGroundingMetadata(t *testing.T) {
})
t.Run("overlapping chunks deduplication and remapping", func(t *testing.T) {
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/1", "title": ""}}
]
}`)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/1", "title": "Title 1"}},
{"web": {"uri": "https://example.com/2", "title": "Title 2"}}
@@ -2808,7 +2808,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
})
t.Run("deduplication of identical supports", func(t *testing.T) {
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingChunks": [{"web": {"uri": "https://example.com/1"}}],
"groundingSupports": [
{
@@ -2817,7 +2817,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}
]
}`)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingSupports": [
{
"segment": {"startIndex": 0, "endIndex": 5, "partIndex": 0},
@@ -2838,10 +2838,10 @@ func TestMergeGroundingMetadata(t *testing.T) {
})
t.Run("incremental grounding queries then supports then chunks preserves indices", func(t *testing.T) {
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"webSearchQueries": ["query1"]
}`)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingSupports": [
{
"segment": {"startIndex": 0, "endIndex": 5, "partIndex": 0},
@@ -2849,7 +2849,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}
]
}`)
- gm3 := gjson.Parse(`{
+ gm3 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/1", "title": "Title 1"}}
]
@@ -2875,13 +2875,13 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 1 {
t.Fatalf("expected 1 citation, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/1" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/1" {
t.Fatalf("expected url https://example.com/1, got: %s", string(citations[0]))
}
})
t.Run("supports arrive first with index 1 then identical duplicate URL chunks arrive and deduplicate", func(t *testing.T) {
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingSupports": [
{
"segment": {"startIndex": 0, "endIndex": 11, "partIndex": 0},
@@ -2889,7 +2889,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}
]
}`)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/dup", "title": "Dup 1"}},
{"web": {"uri": "https://example.com/dup", "title": "Dup 2"}}
@@ -2918,19 +2918,19 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 1 {
t.Fatalf("expected 1 citation, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/dup" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/dup" {
t.Fatalf("expected citation url https://example.com/dup, got %s", citations[0])
}
})
t.Run("queries in frame 1 then duplicate chunks [A, A, B] in frame 2 then supports [2] in frame 3", func(t *testing.T) {
// Frame 1: queries only
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"webSearchQueries": ["test query"]
}`)
// Frame 2: duplicate chunks [A, A, B] (indices 0, 1, 2)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/A", "title": "Title A1"}},
{"web": {"uri": "https://example.com/A", "title": "Title A2"}},
@@ -2948,7 +2948,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}
// Frame 3: supports only with index [2], pointing to B in Gemini stream
- gm3 := gjson.Parse(`{
+ gm3 := ojson.Parse(`{
"groundingSupports": [
{
"segment": {"startIndex": 0, "endIndex": 5, "partIndex": 0},
@@ -2983,17 +2983,17 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 2 {
t.Fatalf("expected 2 citations, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/B" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/B" {
t.Fatalf("expected citation 0 url https://example.com/B, got %s", citations[0])
}
- if gjson.GetBytes(citations[1], "url").String() != "https://example.com/A" {
+ if ojson.GetBytesNoCopy(citations[1], "url").String() != "https://example.com/A" {
t.Fatalf("expected citation 1 url https://example.com/A, got %s", citations[1])
}
})
t.Run("existing pending support resolves to cumulative raw index rather than subsequent frame local chunk index", func(t *testing.T) {
// Frame 1: groundingChunks = [A], support with groundingChunkIndices = [1]
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/A", "title": "Chunk A"}}
],
@@ -3006,14 +3006,14 @@ func TestMergeGroundingMetadata(t *testing.T) {
}`)
// Frame 2: groundingChunks = [B, C]
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/B", "title": "Chunk B"}},
{"web": {"uri": "https://example.com/C", "title": "Chunk C"}}
]
}`)
- merged1 := MergeGroundingMetadata(gjson.Result{}, gm1)
+ merged1 := MergeGroundingMetadata(ojson.Result{}, gm1)
merged2 := MergeGroundingMetadata(merged1, gm2)
chunks := merged2.Get("groundingChunks").Array()
@@ -3041,14 +3041,14 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 1 {
t.Fatalf("expected 1 citation, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/B" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/B" {
t.Fatalf("expected citation url https://example.com/B, got %s", citations[0])
}
})
t.Run("existing pending support and new frame support with same numeric index resolve correctly", func(t *testing.T) {
// Frame 1: groundingChunks = [A], support with groundingChunkIndices = [1]
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/A", "title": "Chunk A"}}
],
@@ -3061,7 +3061,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}`)
// Frame 2: groundingChunks = [B, C], and new support with cumulative index [1] (pointing to B)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/B", "title": "Chunk B"}},
{"web": {"uri": "https://example.com/C", "title": "Chunk C"}}
@@ -3074,7 +3074,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
]
}`)
- merged1 := MergeGroundingMetadata(gjson.Result{}, gm1)
+ merged1 := MergeGroundingMetadata(ojson.Result{}, gm1)
merged2 := MergeGroundingMetadata(merged1, gm2)
supports := merged2.Get("groundingSupports").Array()
@@ -3098,17 +3098,17 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 2 {
t.Fatalf("expected 2 citations, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/B" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/B" {
t.Fatalf("expected citation 0 url https://example.com/B, got %s", citations[0])
}
- if gjson.GetBytes(citations[1], "url").String() != "https://example.com/B" {
+ if ojson.GetBytesNoCopy(citations[1], "url").String() != "https://example.com/B" {
t.Fatalf("expected citation 1 url https://example.com/B, got %s", citations[1])
}
})
t.Run("deduplicated frame 1 chunks resolve frame 2 cumulative support index correctly", func(t *testing.T) {
// Frame 1: groundingChunks = [A, A] (deduplicated to [A])
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/A", "title": "Chunk A"}},
{"web": {"uri": "https://example.com/A", "title": "Chunk A"}}
@@ -3116,7 +3116,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}`)
// Frame 2: groundingChunks = [B], with new support referencing groundingChunkIndices = [1] (pointing to A)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/B", "title": "Chunk B"}}
],
@@ -3128,7 +3128,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
]
}`)
- merged1 := MergeGroundingMetadata(gjson.Result{}, gm1)
+ merged1 := MergeGroundingMetadata(ojson.Result{}, gm1)
merged2 := MergeGroundingMetadata(merged1, gm2)
chunks := merged2.Get("groundingChunks").Array()
@@ -3156,14 +3156,14 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 1 {
t.Fatalf("expected 1 citation, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/A" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/A" {
t.Fatalf("expected citation url https://example.com/A, got %s", citations[0])
}
})
t.Run("deduplicated frame 1 chunks [A, A] resolve frame 2 chunks [B, C] cumulative support [1] to A not C", func(t *testing.T) {
// Frame 1: groundingChunks = [A, A] (deduplicated to [A], prevRawCount = 2, cumulativeRemap has {0: 0, 1: 0})
- gm1 := gjson.Parse(`{
+ gm1 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/A", "title": "Chunk A"}},
{"web": {"uri": "https://example.com/A", "title": "Chunk A"}}
@@ -3171,7 +3171,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
}`)
// Frame 2: groundingChunks = [B, C], and support with groundingChunkIndices = [1] (referencing cumulative raw chunk 1 = A)
- gm2 := gjson.Parse(`{
+ gm2 := ojson.Parse(`{
"groundingChunks": [
{"web": {"uri": "https://example.com/B", "title": "Chunk B"}},
{"web": {"uri": "https://example.com/C", "title": "Chunk C"}}
@@ -3184,7 +3184,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
]
}`)
- merged1 := MergeGroundingMetadata(gjson.Result{}, gm1)
+ merged1 := MergeGroundingMetadata(ojson.Result{}, gm1)
merged2 := MergeGroundingMetadata(merged1, gm2)
chunks := merged2.Get("groundingChunks").Array()
@@ -3213,7 +3213,7 @@ func TestMergeGroundingMetadata(t *testing.T) {
if len(citations) != 1 {
t.Fatalf("expected 1 citation, got %d", len(citations))
}
- if gjson.GetBytes(citations[0], "url").String() != "https://example.com/A" {
+ if ojson.GetBytesNoCopy(citations[0], "url").String() != "https://example.com/A" {
t.Fatalf("expected citation url https://example.com/A, got %s", citations[0])
}
})
@@ -3233,10 +3233,10 @@ func TestMergeCitationAnnotations(t *testing.T) {
if len(merged) != 2 {
t.Fatalf("expected 2 citations, got %d", len(merged))
}
- if title := gjson.GetBytes(merged[0], "title").String(); title != "Title 1" {
+ if title := ojson.GetBytesNoCopy(merged[0], "title").String(); title != "Title 1" {
t.Fatalf("expected upgraded title 'Title 1', got %q", title)
}
- if url := gjson.GetBytes(merged[1], "url").String(); url != "https://example.com/2" {
+ if url := ojson.GetBytesNoCopy(merged[1], "url").String(); url != "https://example.com/2" {
t.Fatalf("expected url 'https://example.com/2', got %q", url)
}
}
@@ -3324,8 +3324,8 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_MultipleGroundingUpdates(t
completedJSON := byType["response.completed"][0]
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
- var wsItem gjson.Result
+ var msgItem ojson.Result
+ var wsItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -3423,7 +3423,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_IncrementalSupportsWithout
allEvents := append(e1, e2...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -3432,13 +3432,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_IncrementalSupportsWithout
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
+ var msgItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -3538,8 +3538,8 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_IncrementalGroundingQuerie
completedJSON := byType["response.completed"][0]
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
- var wsItem gjson.Result
+ var msgItem ojson.Result
+ var wsItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -3672,7 +3672,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateSourcesAfterInterleave
}
completedJSON := byType["response.completed"][0]
- var wsItem gjson.Result
+ var wsItem ojson.Result
for _, out := range completedJSON.Get("response.output").Array() {
if out.Get("type").String() == "web_search_call" {
wsItem = out
@@ -3778,7 +3778,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_CitationsSpanAcrossMessage
allEvents := append(append(append(e1, e2...), e3...), e4...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -3787,13 +3787,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_CitationsSpanAcrossMessage
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var messages []gjson.Result
+ var messages []ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
messages = append(messages, out)
@@ -3885,7 +3885,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_SupportsArriveFirstThenDup
allEvents := append(append(e1, e2...), e3...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -3894,13 +3894,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_SupportsArriveFirstThenDup
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
+ var msgItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -3996,7 +3996,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_SignatureBoundaryContinuat
allEvents := append(append(append(e1, e2...), e3...), e4...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -4005,13 +4005,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_SignatureBoundaryContinuat
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var messages []gjson.Result
+ var messages []ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
messages = append(messages, out)
@@ -4103,7 +4103,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_Frame2DuplicateChunksThenF
allEvents := append(append(e1, e2...), e3...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -4112,13 +4112,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_Frame2DuplicateChunksThenF
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
+ var msgItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -4195,7 +4195,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_PendingSupportResolvesToCu
allEvents := append(e1, e2...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -4204,13 +4204,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_PendingSupportResolvesToCu
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
+ var msgItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -4288,7 +4288,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_DeduplicatedChunksResolveF
allEvents := append(e1, e2...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -4297,13 +4297,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_DeduplicatedChunksResolveF
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
+ var msgItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -4382,7 +4382,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_DeduplicatedFrame1ChunksFr
allEvents := append(e1, e2...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -4391,13 +4391,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_DeduplicatedFrame1ChunksFr
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var msgItem gjson.Result
+ var msgItem ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgItem = out
@@ -4484,7 +4484,7 @@ func TestConvertGeminiResponseToOpenAIResponses_NonWebGrounding_NoWebSearchCallO
allEvents := append(append(e1, e2...), e3...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
evStr := string(ev)
if strings.Contains(evStr, "web_search_call") {
@@ -4497,7 +4497,7 @@ func TestConvertGeminiResponseToOpenAIResponses_NonWebGrounding_NoWebSearchCallO
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
@@ -4554,7 +4554,7 @@ func TestConvertGeminiResponseToOpenAIResponses_NonWebGrounding_NoWebSearchCallO
var param any
outBytes := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), modelID, req, req, respJSON, ¶m)
- outParsed := gjson.ParseBytes(outBytes)
+ outParsed := util.ParseGJSONBytesNoCopy(outBytes)
if outParsed.Get("tool_usage.web_search").Exists() {
t.Fatalf("did not expect tool_usage.web_search in non-stream response for non-web grounding: %s", string(outBytes))
@@ -4639,7 +4639,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateCitations_AnnotationAd
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.content_part.done" {
- partDone := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ partDone := ojson.Parse(strings.TrimPrefix(line, "data: "))
if len(partDone.Get("part.annotations").Array()) == 0 {
foundMsgDoneWithoutCitations = true
}
@@ -4653,8 +4653,8 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateCitations_AnnotationAd
e3 := ConvertGeminiResponseToOpenAIResponses(context.Background(), modelID, req, req, chunk3, ¶m)
allEvents := append(append(e1, e2...), e3...)
- var annotationAddedEvents []gjson.Result
- var completedJSON gjson.Result
+ var annotationAddedEvents []ojson.Result
+ var completedJSON ojson.Result
var eventSequence []string
for _, ev := range allEvents {
@@ -4668,10 +4668,10 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateCitations_AnnotationAd
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
if currentType == "response.output_text.annotation.added" {
- annotationAddedEvents = append(annotationAddedEvents, gjson.Parse(dataStr))
+ annotationAddedEvents = append(annotationAddedEvents, ojson.Parse(dataStr))
}
if currentType == "response.completed" {
- completedJSON = gjson.Parse(dataStr)
+ completedJSON = ojson.Parse(dataStr)
}
}
}
@@ -4819,8 +4819,8 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateCitations_AnnotationAd
allEvents := append(append(e1, e2...), e3...)
- var annotationAddedEvents []gjson.Result
- var completedJSON gjson.Result
+ var annotationAddedEvents []ojson.Result
+ var completedJSON ojson.Result
var eventSequence []string
for _, ev := range allEvents {
@@ -4834,10 +4834,10 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateCitations_AnnotationAd
if strings.HasPrefix(line, "data: ") {
dataStr := strings.TrimPrefix(line, "data: ")
if currentType == "response.output_text.annotation.added" {
- annotationAddedEvents = append(annotationAddedEvents, gjson.Parse(dataStr))
+ annotationAddedEvents = append(annotationAddedEvents, ojson.Parse(dataStr))
}
if currentType == "response.completed" {
- completedJSON = gjson.Parse(dataStr)
+ completedJSON = ojson.Parse(dataStr)
}
}
}
@@ -4873,7 +4873,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_LateCitations_AnnotationAd
// Verify output in completed JSON matches the two annotations
outputs := completedJSON.Get("response.output").Array()
- var msgOutput gjson.Result
+ var msgOutput ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
msgOutput = out
@@ -4963,7 +4963,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_WebSearchBufferingSignatur
allEvents := append(append(e1, e2...), e3...)
- var completedJSON gjson.Result
+ var completedJSON ojson.Result
for _, ev := range allEvents {
lines := strings.Split(string(ev), "\n")
var currentType string
@@ -4972,13 +4972,13 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_WebSearchBufferingSignatur
currentType = strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") && currentType == "response.completed" {
- completedJSON = gjson.Parse(strings.TrimPrefix(line, "data: "))
+ completedJSON = ojson.Parse(strings.TrimPrefix(line, "data: "))
}
}
}
outputs := completedJSON.Get("response.output").Array()
- var messages []gjson.Result
+ var messages []ojson.Result
for _, out := range outputs {
if out.Get("type").String() == "message" {
messages = append(messages, out)
@@ -4999,11 +4999,11 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_WebSearchBufferingSignatur
// Verify that chunk A message has the thought signature, and chunk B message does not.
// Translate the completed response outputs back to Gemini request to verify signature binding.
replayReq := []byte(`{"model":"gemini-search-sig-boundary-buffering","input":[]}`)
- replayReq, _ = sjson.SetRawBytes(replayReq, "input", []byte(completedJSON.Get("response.output").Raw))
+ replayReq, _ = ojson.SetRawBytes(replayReq, "input", []byte(completedJSON.Get("response.output").Raw))
translated, _ := ConvertOpenAIResponsesRequestToGemini("gemini-search-sig-boundary-buffering", replayReq, false)
- var visibleParts []gjson.Result
- for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() {
+ var visibleParts []ojson.Result
+ for _, part := range ojson.GetBytesNoCopy(translated, "contents.0.parts").Array() {
if !part.Get("thought").Bool() && part.Get("text").String() != "" {
visibleParts = append(visibleParts, part)
}
@@ -5024,7 +5024,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_CitationAnnotationAddedTim
modelID := "gemini-search-ann-added-timings"
registerTestWebSearchModel(t, "client-ann-added-timings", "antigravity", modelID, true)
- assertAnnotationMatches := func(t *testing.T, eventTypes []string, byType map[string][]gjson.Result, wantURL, wantTitle string, wantStart, wantEnd int64, late bool) {
+ assertAnnotationMatches := func(t *testing.T, eventTypes []string, byType map[string][]ojson.Result, wantURL, wantTitle string, wantStart, wantEnd int64, late bool) {
t.Helper()
annEvents := byType["response.output_text.annotation.added"]
if len(annEvents) != 1 {
@@ -5081,7 +5081,7 @@ func TestConvertGeminiResponseToOpenAIResponsesStream_CitationAnnotationAddedTim
t.Fatal("expected response.completed")
}
completedJSON := byType["response.completed"][0]
- var completedMsg gjson.Result
+ var completedMsg ojson.Result
for _, out := range completedJSON.Get("response.output").Array() {
if out.Get("type").String() == "message" {
completedMsg = out
@@ -5282,16 +5282,16 @@ func TestConvertOpenAIResponsesRequestToGemini_MixedWebSearchAndFunctions(t *tes
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if !gjson.GetBytes(out, "toolConfig.includeServerSideToolInvocations").Bool() {
+ if !ojson.GetBytesNoCopy(out, "toolConfig.includeServerSideToolInvocations").Bool() {
t.Fatalf("expected includeServerSideToolInvocations for mixed tools, got: %s", out)
}
- if mode := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); mode != "AUTO" {
+ if mode := ojson.GetBytesNoCopy(out, "toolConfig.functionCallingConfig.mode").String(); mode != "AUTO" {
t.Fatalf("functionCallingConfig.mode = %q, want AUTO, got: %s", mode, out)
}
- if !gjson.GetBytes(out, "tools.0.googleSearch").Exists() {
+ if !ojson.GetBytesNoCopy(out, "tools.0.googleSearch").Exists() {
t.Fatalf("expected googleSearch tool, got: %s", out)
}
- if !gjson.GetBytes(out, "tools.1.functionDeclarations.0.name").Exists() {
+ if !ojson.GetBytesNoCopy(out, "tools.1.functionDeclarations.0.name").Exists() {
t.Fatalf("expected functionDeclarations, got: %s", out)
}
@@ -5304,7 +5304,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MixedWebSearchAndFunctions(t *tes
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if gjson.GetBytes(searchOnlyOut, "toolConfig.includeServerSideToolInvocations").Exists() {
+ if ojson.GetBytesNoCopy(searchOnlyOut, "toolConfig.includeServerSideToolInvocations").Exists() {
t.Fatalf("includeServerSideToolInvocations must only be set for mixed tools, got: %s", searchOnlyOut)
}
@@ -5319,7 +5319,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MixedWebSearchAndFunctions(t *tes
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if gjson.GetBytes(functionsOnlyOut, "toolConfig.includeServerSideToolInvocations").Exists() {
+ if ojson.GetBytesNoCopy(functionsOnlyOut, "toolConfig.includeServerSideToolInvocations").Exists() {
t.Fatalf("includeServerSideToolInvocations must only be set for mixed tools, got: %s", functionsOnlyOut)
}
@@ -5336,7 +5336,7 @@ func TestConvertOpenAIResponsesRequestToGemini_MixedWebSearchAndFunctions(t *tes
if err != nil {
t.Fatalf("convert error: %v", err)
}
- if gjson.GetBytes(choiceNoneOut, "toolConfig.includeServerSideToolInvocations").Exists() {
+ if ojson.GetBytesNoCopy(choiceNoneOut, "toolConfig.includeServerSideToolInvocations").Exists() {
t.Fatalf("includeServerSideToolInvocations must not be set when googleSearch is suppressed by tool_choice=none, got: %s", choiceNoneOut)
}
}
diff --git a/internal/translator/gemini/openai/responses/issue6258_terminal_test.go b/internal/translator/gemini/openai/responses/issue6258_terminal_test.go
index 374d18d03a3..015718e4403 100644
--- a/internal/translator/gemini/openai/responses/issue6258_terminal_test.go
+++ b/internal/translator/gemini/openai/responses/issue6258_terminal_test.go
@@ -5,14 +5,15 @@ import (
"fmt"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const issue6258Usage = `{"promptTokenCount":100,"candidatesTokenCount":10,"thoughtsTokenCount":50,"totalTokenCount":160,"cachedContentTokenCount":7}`
-func issue6258Translate(t *testing.T, param *any, frame string) []gjson.Result {
+func issue6258Translate(t *testing.T, param *any, frame string) []ojson.Result {
t.Helper()
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.7-flash", nil, nil, []byte(frame), param) {
_, event := parseSSEEvent(t, chunk)
events = append(events, event)
@@ -20,12 +21,12 @@ func issue6258Translate(t *testing.T, param *any, frame string) []gjson.Result {
return events
}
-func issue6258Terminal(t *testing.T, events []gjson.Result) gjson.Result {
+func issue6258Terminal(t *testing.T, events []ojson.Result) ojson.Result {
t.Helper()
- var terminal gjson.Result
+ var terminal ojson.Result
terminals, created, inProgress := 0, 0, 0
previous := int64(-1)
- added := map[string]gjson.Result{}
+ added := map[string]ojson.Result{}
done := map[string]int{}
responseID := ""
for _, event := range events {
@@ -79,7 +80,7 @@ func issue6258Terminal(t *testing.T, events []gjson.Result) gjson.Result {
return terminal.Get("response")
}
-func issue6258AssertUsage(t *testing.T, response gjson.Result, input, output, reasoning, total, cached int64) {
+func issue6258AssertUsage(t *testing.T, response ojson.Result, input, output, reasoning, total, cached int64) {
t.Helper()
for path, want := range map[string]int64{
"input_tokens": input, "output_tokens": output, "output_tokens_details.reasoning_tokens": reasoning,
@@ -174,13 +175,13 @@ func TestIssue6258GeminiResponsesMaxTokens(t *testing.T) {
}
t.Run("nonstream", func(t *testing.T) {
raw := `{"responseId":"max","candidates":[{"content":{"parts":[{"text":"partial"}]},"finishReason":"MAX_TOKENS"}],"usageMetadata":` + issue6258Usage + `}`
- response := gjson.ParseBytes(ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.7-flash", nil, nil, []byte(raw), nil))
+ response := util.ParseGJSONBytesNoCopy(ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.7-flash", nil, nil, []byte(raw), nil))
issue6258AssertIncomplete(t, response)
issue6258AssertUsage(t, response, 100, 60, 50, 160, 7)
})
}
-func issue6258AssertIncomplete(t *testing.T, response gjson.Result) {
+func issue6258AssertIncomplete(t *testing.T, response ojson.Result) {
t.Helper()
if response.Get("status").String() != "incomplete" || response.Get("incomplete_details.reason").String() != "max_output_tokens" {
t.Errorf("MAX_TOKENS status=%q details=%s, want incomplete/max_output_tokens", response.Get("status").String(), response.Get("incomplete_details").Raw)
@@ -200,7 +201,7 @@ func TestIssue6258GeminiResponsesUsageSnapshots(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
for _, frame := range []string{
`{"responseId":"snapshots","candidates":[{"content":{"parts":[{"text":"answer"}]}}],"usageMetadata":` + issue6258Usage + `}`,
`{"usageMetadata":` + issue6258Usage + `}`,
@@ -217,7 +218,7 @@ func TestIssue6258GeminiResponsesUsageSnapshots(t *testing.T) {
func TestIssue6258GeminiResponsesMaxTokensOnlyActiveMessageIncomplete(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
for _, frame := range []string{
`{"responseId":"mixed","candidates":[{"content":{"parts":[{"text":"preface"}]}}]}`,
`{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"lookup","args":{"key":"value"}}}]}}]}`,
@@ -247,7 +248,7 @@ func TestIssue6258GeminiResponsesDoneUsesUsageSnapshot(t *testing.T) {
for _, usageKey := range []string{"usageMetadata", "cpaUsageMetadata"} {
t.Run(usageKey, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
for _, frame := range []string{
`{"responseId":"snapshot-done","candidates":[{"content":{"parts":[{"text":"answer"}]}}],"` + usageKey + `":` + issue6258Usage + `}`,
`{"` + usageKey + `":` + issue6258Usage + `}`,
diff --git a/internal/translator/gemini/openai/responses/noop_optimization_test.go b/internal/translator/gemini/openai/responses/noop_optimization_test.go
index e78bd85c66b..45c665dcea7 100644
--- a/internal/translator/gemini/openai/responses/noop_optimization_test.go
+++ b/internal/translator/gemini/openai/responses/noop_optimization_test.go
@@ -3,7 +3,7 @@ package responses
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponsesRequestToGeminiBuildsGenerationConfigWithoutIntermediateObject(t *testing.T) {
@@ -11,22 +11,22 @@ func TestConvertOpenAIResponsesRequestToGeminiBuildsGenerationConfigWithoutInter
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-test", input, false)
- if got := gjson.GetBytes(output, "generationConfig.temperature").Float(); got != 0.5 {
+ if got := ojson.GetBytesNoCopy(output, "generationConfig.temperature").Float(); got != 0.5 {
t.Fatalf("temperature = %v, want 0.5", got)
}
- if got := gjson.GetBytes(output, "generationConfig.topP").Float(); got != 0.9 {
+ if got := ojson.GetBytesNoCopy(output, "generationConfig.topP").Float(); got != 0.9 {
t.Fatalf("topP = %v, want 0.9", got)
}
- if got := gjson.GetBytes(output, "generationConfig.stopSequences.0").String(); got != "done" {
+ if got := ojson.GetBytesNoCopy(output, "generationConfig.stopSequences.0").String(); got != "done" {
t.Fatalf("stop sequence = %q, want done", got)
}
- if got := gjson.GetBytes(output, "generationConfig.responseMimeType").String(); got != "application/json" {
+ if got := ojson.GetBytesNoCopy(output, "generationConfig.responseMimeType").String(); got != "application/json" {
t.Fatalf("responseMimeType = %q, want application/json", got)
}
- if !gjson.GetBytes(output, "generationConfig.responseJsonSchema").Exists() {
+ if !ojson.GetBytesNoCopy(output, "generationConfig.responseJsonSchema").Exists() {
t.Fatal("responseJsonSchema should be present")
}
- if gjson.GetBytes(output, "generationConfig.responseSchema").Exists() {
+ if ojson.GetBytesNoCopy(output, "generationConfig.responseSchema").Exists() {
t.Fatal("responseSchema should not be present")
}
}
diff --git a/internal/translator/gemini/openai/responses/signature_carrier.go b/internal/translator/gemini/openai/responses/signature_carrier.go
index 14f0f11348f..96ef0db7f33 100644
--- a/internal/translator/gemini/openai/responses/signature_carrier.go
+++ b/internal/translator/gemini/openai/responses/signature_carrier.go
@@ -1,13 +1,13 @@
package responses
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const (
@@ -76,7 +76,7 @@ func compatibleGeminiResponsesCarrierSignature(rawSignature, targetKind string)
return normalized, true
}
-func geminiResponsesCarrierSemanticTarget(item gjson.Result) string {
+func geminiResponsesCarrierSemanticTarget(item ojson.Result) string {
switch item.Get("type").String() {
case "function_call", "custom_tool_call":
return geminiResponsesCarrierFunction
@@ -91,7 +91,7 @@ func geminiResponsesCarrierSemanticTarget(item gjson.Result) string {
return ""
}
-func geminiResponsesCarrierMatchesAdjacent(items []gjson.Result, index int, direction, targetKind string) bool {
+func geminiResponsesCarrierMatchesAdjacent(items []ojson.Result, index int, direction, targetKind string) bool {
step := 1
if direction == geminiResponsesCarrierPrevious {
step = -1
@@ -107,7 +107,7 @@ func geminiResponsesCarrierMatchesAdjacent(items []gjson.Result, index int, dire
return false
}
-func hasInternalCarrierFields(item gjson.Result) bool {
+func hasInternalCarrierFields(item ojson.Result) bool {
return item.Get(geminiResponsesCarrierDirectionField).Exists() ||
item.Get(geminiResponsesCarrierTargetField).Exists() ||
item.Get(geminiResponsesCarrierSignatureField).Exists() ||
@@ -130,8 +130,8 @@ func stripGeminiResponsesCarrierMetadata(rawJSON string) ([]byte, bool) {
return stripped, true
}
-func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, bool) {
- normalized := make([]gjson.Result, 0, len(items))
+func normalizeGeminiResponsesCarriers(items []ojson.Result) ([]ojson.Result, bool) {
+ normalized := make([]ojson.Result, 0, len(items))
hasValidCarrier := false
for itemIndex, originalItem := range items {
item := originalItem
@@ -140,7 +140,7 @@ func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, boo
stripped, ok := stripGeminiResponsesCarrierMetadata(originalItem.Raw)
if ok {
itemJSON = stripped
- item = gjson.ParseBytes(itemJSON)
+ item = util.ParseGJSONBytesNoCopy(itemJSON)
}
}
if item.Get("type").String() != "reasoning" {
@@ -173,27 +173,28 @@ func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, boo
if strings.TrimSpace(item.Get("summary.0.text").String()) == "" {
continue
}
- itemJSON, _ = sjson.DeleteBytes(itemJSON, "encrypted_content")
- normalized = append(normalized, gjson.ParseBytes(itemJSON))
+ itemJSON, _ = ojson.DeleteBytes(itemJSON, "encrypted_content")
+ normalized = append(normalized, util.ParseGJSONBytesNoCopy(itemJSON))
continue
}
hasValidCarrier = true
- itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", signature)
- itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierDirectionField, direction)
- itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierTargetField, targetKind)
- normalized = append(normalized, gjson.ParseBytes(itemJSON))
+ itemDoc := ojson.NewDoc(itemJSON)
+ _ = itemDoc.Set("encrypted_content", signature)
+ _ = itemDoc.Set(geminiResponsesCarrierDirectionField, direction)
+ _ = itemDoc.Set(geminiResponsesCarrierTargetField, targetKind)
+ normalized = append(normalized, util.ParseGJSONBytesNoCopy(itemDoc.Bytes()))
}
return normalized, hasValidCarrier
}
-func geminiResponsesCarrierDirection(item gjson.Result) string {
+func geminiResponsesCarrierDirection(item ojson.Result) string {
return item.Get(geminiResponsesCarrierDirectionField).String()
}
-func geminiResponsesCarrierTarget(item gjson.Result) string {
+func geminiResponsesCarrierTarget(item ojson.Result) string {
return item.Get(geminiResponsesCarrierTargetField).String()
}
-func isOpenAIResponsesDetachedCarrier(item gjson.Result) bool {
+func isOpenAIResponsesDetachedCarrier(item ojson.Result) bool {
return item.Get("type").String() == "reasoning" && strings.TrimSpace(item.Get("encrypted_content").String()) != "" && strings.TrimSpace(item.Get("summary.0.text").String()) == ""
}
diff --git a/internal/translator/gemini/openai/responses/signature_carrier_test.go b/internal/translator/gemini/openai/responses/signature_carrier_test.go
index 515047cbd2d..858e2c98ca7 100644
--- a/internal/translator/gemini/openai/responses/signature_carrier_test.go
+++ b/internal/translator/gemini/openai/responses/signature_carrier_test.go
@@ -2,12 +2,12 @@ package responses
import (
"context"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strconv"
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"google.golang.org/protobuf/encoding/protowire"
)
@@ -29,7 +29,7 @@ func TestGeminiResponsesCarrierRoundTrip(t *testing.T) {
}
func TestNormalizeGeminiResponsesCarriersDropsMalformedEnvelope(t *testing.T) {
- items := gjson.Parse(`[{"type":"reasoning","encrypted_content":"` + geminiResponsesCarrierPrefix + `previous:text:not-base64!","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"safe"}]}]`).Array()
+ items := ojson.Parse(`[{"type":"reasoning","encrypted_content":"` + geminiResponsesCarrierPrefix + `previous:text:not-base64!","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"safe"}]}]`).Array()
normalized, hasCarrier := normalizeGeminiResponsesCarriers(items)
if hasCarrier || len(normalized) != 1 || normalized[0].Get("type").String() != "message" || strings.Contains(normalized[0].Raw, geminiResponsesCarrierPrefix) {
t.Fatalf("malformed carrier was preserved: %v", normalized)
@@ -40,7 +40,7 @@ func TestConvertOpenAIResponsesRequestToGemini_DecodesCarrierForAliasModel(t *te
carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
- part := gjson.GetBytes(translated, "contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(translated, "contents.0.parts.0")
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
t.Fatalf("alias model did not decode carrier: %s", translated)
}
@@ -84,9 +84,9 @@ func TestGeminiResponsesWrappedUUIDFunctionSignatureRoundTrip(t *testing.T) {
request := []byte(`{"model":"alias-without-provider-name","input":[` + strings.Join(clientItems, ",") + `]}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
- var functionPart gjson.Result
- gjson.GetBytes(translated, "contents").ForEach(func(_, content gjson.Result) bool {
- content.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ var functionPart ojson.Result
+ ojson.GetBytesNoCopy(translated, "contents").ForEach(func(_, content ojson.Result) bool {
+ content.Get("parts").ForEach(func(_, part ojson.Result) bool {
if part.Get("functionCall").Exists() {
functionPart = part
return false
@@ -109,7 +109,7 @@ func TestGeminiResponsesWrappedUUIDFunctionSignatureRoundTrip(t *testing.T) {
func TestConvertOpenAIResponsesRequestToGemini_DecodesLegacyRawCarrierForAliasModel(t *testing.T) {
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"}]}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
- part := gjson.GetBytes(translated, "contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(translated, "contents.0.parts.0")
if part.Get("functionCall.id").String() != "call-1" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("alias model did not preserve legacy raw carrier: %s", translated)
}
@@ -134,7 +134,7 @@ func TestConvertOpenAIResponsesRequestToGemini_IgnoresSpoofedCarrierMetadata(t *
reasoning := `{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[],"` + geminiResponsesCarrierDirectionField + `":"next","` + geminiResponsesCarrierDirectionField + `":"standalone","` + geminiResponsesCarrierTargetField + `":"text","` + geminiResponsesCarrierTargetField + `":"function"}`
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
- part := gjson.GetBytes(translated, "contents.0.parts.0")
+ part := ojson.GetBytesNoCopy(translated, "contents.0.parts.0")
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierDirectionField) {
t.Fatalf("spoofed carrier metadata affected binding: %s", translated)
}
@@ -143,7 +143,7 @@ func TestConvertOpenAIResponsesRequestToGemini_IgnoresSpoofedCarrierMetadata(t *
func TestConvertOpenAIResponsesRequestToGemini_StripsSpoofedInternalPairingFields(t *testing.T) {
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_summary":"spoofed thought","_cpa_reasoning_summary":"spoofed thought again"}]}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || parts[0].Get("thought").Bool() || strings.Contains(string(translated), "spoofed thought") || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
t.Fatalf("spoofed internal pairing fields reached Gemini: %s", translated)
}
@@ -153,7 +153,7 @@ func TestConvertOpenAIResponsesRequestToGemini_StripsUnicodeEscapedSpoofedIntern
// Unicode-escaped field name "_cpa_reason\u0069ng_signature" should also be detected and stripped
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reason\u0069ng_signature":"` + testResponsesGeminiThoughtSignature + `"}]}`)
translated, _ := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
- parts := gjson.GetBytes(translated, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(translated, "contents.0.parts").Array()
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
t.Fatalf("unicode-escaped spoofed internal pairing fields reached Gemini: %s", translated)
}
diff --git a/internal/translator/gemini/openai/responses/trailing_signature.go b/internal/translator/gemini/openai/responses/trailing_signature.go
index 77a96fb7376..10f4f5c8892 100644
--- a/internal/translator/gemini/openai/responses/trailing_signature.go
+++ b/internal/translator/gemini/openai/responses/trailing_signature.go
@@ -5,10 +5,10 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// Keep text signature carriers off the client-visible reasoning timeline. The
@@ -23,16 +23,16 @@ func cacheGeminiResponsesTextSignatures(modelName, messageID, text string, signa
if _, ok := compatibleGeminiResponsesCarrierSignature(signature, geminiResponsesCarrierText); !ok {
return false
}
- item := []byte(`{"type":"thought_signature","targetKind":"text"}`)
- item, _ = sjson.SetBytes(item, "thoughtSignature", signature)
- item, _ = sjson.SetBytes(item, "targetHash", textHash)
- items = append(items, item)
+ itemDoc := ojson.NewDocString(`{"type":"thought_signature","targetKind":"text"}`)
+ _ = itemDoc.Set("thoughtSignature", signature)
+ _ = itemDoc.Set("targetHash", textHash)
+ items = append(items, itemDoc.Bytes())
}
return cache.CacheAntigravityReasoningReplayItems(thinking.ParseSuffix(modelName).ModelName, "gemini-responses-text:"+messageID, items)
}
-func restoreGeminiResponsesTextSignatures(modelName string, items []gjson.Result) []gjson.Result {
- var restored []gjson.Result
+func restoreGeminiResponsesTextSignatures(modelName string, items []ojson.Result) []ojson.Result {
+ var restored []ojson.Result
skip := make(map[int]bool)
for index, item := range items {
if skip[index] {
@@ -54,14 +54,14 @@ func restoreGeminiResponsesTextSignatures(modelName string, items []gjson.Result
replayed := make(map[string]bool)
textHash := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))
for _, raw := range cached {
- entry := gjson.ParseBytes(raw)
+ entry := util.ParseGJSONBytesNoCopy(raw)
signature := entry.Get("thoughtSignature").String()
if entry.Get("targetHash").String() != textHash {
continue
}
carrier := []byte(`{"type":"reasoning","summary":[]}`)
- carrier, _ = sjson.SetBytes(carrier, "encrypted_content", encodeGeminiResponsesCarrier(signature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText))
- restored = append(restored, gjson.ParseBytes(carrier))
+ carrier, _ = ojson.SetBytes(carrier, "encrypted_content", encodeGeminiResponsesCarrier(signature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText))
+ restored = append(restored, util.ParseGJSONBytesNoCopy(carrier))
replayed[signature] = true
}
for adjacent := index + 1; adjacent < len(items) && isOpenAIResponsesDetachedCarrier(items[adjacent]); adjacent++ {
diff --git a/internal/translator/gemini/openai/responses/trailing_signature_test.go b/internal/translator/gemini/openai/responses/trailing_signature_test.go
index d883420981f..68f10030ed6 100644
--- a/internal/translator/gemini/openai/responses/trailing_signature_test.go
+++ b/internal/translator/gemini/openai/responses/trailing_signature_test.go
@@ -5,9 +5,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
homekv "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func TestGeminiResponsesLateSignaturePreservesSummary(t *testing.T) {
@@ -18,7 +17,7 @@ func TestGeminiResponsesLateSignaturePreservesSummary(t *testing.T) {
`data: [DONE]`,
}
var state any
- var completed gjson.Result
+ var completed ojson.Result
for _, chunk := range chunks {
for _, event := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", nil, nil, []byte(chunk), &state) {
name, data := parseSSEEvent(t, event)
@@ -34,7 +33,7 @@ func TestGeminiResponsesLateSignaturePreservesSummary(t *testing.T) {
t.Fatalf("unexpected completed output: %s", completed.Raw)
}
replayed, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(`{"input":`+completed.Raw+`}`), false)
- if gjson.GetBytes(replayed, "contents.0.parts.1.thoughtSignature").String() != testResponsesGeminiThoughtSignature {
+ if ojson.GetBytesNoCopy(replayed, "contents.0.parts.1.thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("late text signature was not restored: %s", replayed)
}
}
@@ -69,7 +68,7 @@ func TestGeminiResponsesCachedTextSignatureReplayBoundaries(t *testing.T) {
})
}
carrier := []byte(`{"type":"reasoning","summary":[]}`)
- carrier, _ = sjson.SetBytes(carrier, "encrypted_content", encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText))
+ carrier, _ = ojson.SetBytes(carrier, "encrypted_content", encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText))
request := []byte(`{"input":[` + message + `,` + string(carrier) + `]}`)
output, _ := ConvertOpenAIResponsesRequestToGemini(model, request, false)
if strings.Count(string(output), testResponsesGeminiThoughtSignature) != 1 {
@@ -83,7 +82,7 @@ func TestGeminiResponsesTextSignatureCacheRejectsInvalidSignature(t *testing.T)
}
var state any
line := []byte(`data: {"candidates":[{"content":{"parts":[{"text":"answer"},{"text":"","thoughtSignature":"invalid"}]},"finishReason":"STOP"}],"responseId":"invalid-cache-fallback"}`)
- var completed gjson.Result
+ var completed ojson.Result
streamEvents := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-test", nil, nil, line, &state)
streamEvents = append(streamEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-test", nil, nil, []byte("[DONE]"), &state)...)
for _, chunk := range streamEvents {
@@ -100,7 +99,7 @@ func TestGeminiResponsesTextSignatureCacheRejectsInvalidSignature(t *testing.T)
func TestGeminiResponsesLateSignatureReplayWithThinkingSuffix(t *testing.T) {
var state any
line := []byte(`data: {"candidates":[{"content":{"parts":[{"text":"suffix answer"},{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"responseId":"issue-5513-suffix"}`)
- var completed gjson.Result
+ var completed ojson.Result
streamEvents := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-pro(8192)", nil, nil, line, &state)
streamEvents = append(streamEvents, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-2.5-pro(8192)", nil, nil, []byte("[DONE]"), &state)...)
for _, chunk := range streamEvents {
@@ -110,7 +109,7 @@ func TestGeminiResponsesLateSignatureReplayWithThinkingSuffix(t *testing.T) {
}
}
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-2.5-pro", []byte(`{"input":`+completed.Raw+`}`), false)
- if gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String() != testResponsesGeminiThoughtSignature {
+ if ojson.GetBytesNoCopy(output, "contents.0.parts.0.thoughtSignature").String() != testResponsesGeminiThoughtSignature {
t.Fatalf("executor base model could not restore suffixed response signature: %s", output)
}
}
@@ -124,10 +123,10 @@ func TestGeminiResponsesCachedSignaturesMergeExplicitPrefixInOrder(t *testing.T)
}
for _, explicitSignature := range []string{testResponsesGeminiThoughtSignature, signature2} {
carrier := []byte(`{"type":"reasoning","summary":[]}`)
- carrier, _ = sjson.SetBytes(carrier, "encrypted_content", encodeGeminiResponsesCarrier(explicitSignature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText))
+ carrier, _ = ojson.SetBytes(carrier, "encrypted_content", encodeGeminiResponsesCarrier(explicitSignature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText))
request := []byte(`{"input":[{"id":"` + messageID + `","type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]},` + string(carrier) + `]}`)
output, _ := ConvertOpenAIResponsesRequestToGemini(model, request, false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("text").String() != "answer" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("thoughtSignature").String() != signature2 {
t.Fatalf("partial explicit carrier changed cached signature order: %s", output)
}
@@ -143,7 +142,7 @@ func TestGeminiResponsesLateThoughtSignatureDoesNotBindEarlierMessage(t *testing
`data: [DONE]`,
}
var state any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range lines {
for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.8-flash-high", nil, nil, []byte(line), &state) {
name, data := parseSSEEvent(t, chunk)
@@ -153,7 +152,7 @@ func TestGeminiResponsesLateThoughtSignatureDoesNotBindEarlierMessage(t *testing
}
}
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(`{"input":`+completed.Raw+`}`), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) < 2 || parts[0].Get("thoughtSignature").String() != "" || parts[1].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !strings.Contains(string(output), signature2) {
t.Fatalf("late thought signature rebound to the earlier message: %s", output)
}
@@ -170,7 +169,7 @@ func TestGeminiResponsesCacheRecoveryPreservesFallbackSignatureOrder(t *testing.
`data: [DONE]`,
}
var state any
- var completed gjson.Result
+ var completed ojson.Result
for index, line := range lines {
// A disabled Home client rejects the first cache write. Returning to
// local storage makes the next write succeed without timers or mocks.
@@ -190,7 +189,7 @@ func TestGeminiResponsesCacheRecoveryPreservesFallbackSignatureOrder(t *testing.
t.Fatalf("expected only the first write's fallback carrier: %s", completed.Raw)
}
output, _ := ConvertOpenAIResponsesRequestToGemini("gemini-3.8-flash-high", []byte(`{"input":`+completed.Raw+`}`), false)
- parts := gjson.GetBytes(output, "contents.0.parts").Array()
+ parts := ojson.GetBytesNoCopy(output, "contents.0.parts").Array()
if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("thoughtSignature").String() != signature2 {
t.Fatalf("cache recovery changed fallback signature order: %s", output)
}
diff --git a/internal/translator/interactions/claude/interactions_claude_compat_test.go b/internal/translator/interactions/claude/interactions_claude_compat_test.go
index 5d70ebda141..f58dcda51ed 100644
--- a/internal/translator/interactions/claude/interactions_claude_compat_test.go
+++ b/internal/translator/interactions/claude/interactions_claude_compat_test.go
@@ -3,19 +3,19 @@ package claude
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeRequestToInteractionsWithCompatPreservesEmptyThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":""}]}]}`)
withoutCompat, _ := ConvertClaudeRequestToInteractions("deepseek-v4", payload, false)
- if gjson.GetBytes(withoutCompat, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(withoutCompat, "input.#").Int() != 0 {
t.Fatalf("default translation preserved empty thinking: %s", withoutCompat)
}
withCompat, _ := ConvertClaudeRequestToInteractionsWithCompat("deepseek-v4", payload, false)
- if gjson.GetBytes(withCompat, "input.0.type").String() != "thought" {
+ if ojson.GetBytesNoCopy(withCompat, "input.0.type").String() != "thought" {
t.Fatalf("compat translation missing thought step: %s", withCompat)
}
}
diff --git a/internal/translator/interactions/claude/interactions_claude_request.go b/internal/translator/interactions/claude/interactions_claude_request.go
index a47356e8d4a..e3296ab358a 100644
--- a/internal/translator/interactions/claude/interactions_claude_request.go
+++ b/internal/translator/interactions/claude/interactions_claude_request.go
@@ -3,10 +3,10 @@ package claude
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func ConvertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -24,20 +24,20 @@ func convertClaudeRequestToInteractions(modelName string, inputRawJSON []byte, s
// The internal commentary marker must be restored as assistant text before
// any thinking handling, so it is never dropped or mapped to thought.
restoredRawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON)
- root := gjson.ParseBytes(restoredRawJSON)
- out := []byte(`{"model":"","input":[]}`)
- out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String()))
+ root := util.ParseGJSONBytesNoCopy(restoredRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","input":[]}`)
+ _ = outDoc.Set("model", firstNonEmpty(modelName, root.Get("model").String()))
if streamValue, ok := claudeRequestStreamValue(root, stream); ok {
- out, _ = sjson.SetBytes(out, "stream", streamValue)
+ _ = outDoc.Set("stream", streamValue)
}
- out = copyClaudeSystemToInteractions(out, root)
- out = copyClaudeGenerationConfigToInteractions(out, root)
- out, errDrops := appendClaudeMessagesToInteractions(out, root.Get("messages"), preserveEmptyThinkingBlocks)
- out = copyClaudeToolsToInteractions(out, root)
- return out, errDrops
+ copyClaudeSystemToInteractionsDoc(outDoc, root)
+ copyClaudeGenerationConfigToInteractionsDoc(outDoc, root)
+ errDrops := appendClaudeMessagesToInteractionsDoc(outDoc, root.Get("messages"), preserveEmptyThinkingBlocks)
+ copyClaudeToolsToInteractionsDoc(outDoc, root)
+ return outDoc.Bytes(), errDrops
}
-func claudeRequestStreamValue(root gjson.Result, stream bool) (bool, bool) {
+func claudeRequestStreamValue(root ojson.Result, stream bool) (bool, bool) {
if value := root.Get("stream"); value.Exists() {
return value.Bool(), true
}
@@ -47,89 +47,85 @@ func claudeRequestStreamValue(root gjson.Result, stream bool) (bool, bool) {
return false, false
}
-func copyClaudeSystemToInteractions(out []byte, root gjson.Result) []byte {
+func copyClaudeSystemToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result) {
text := claudeText(root.Get("system"))
if text == "" {
- return out
+ return
}
- out, _ = sjson.SetBytes(out, "system_instruction", text)
- return out
+ _ = outDoc.Set("system_instruction", text)
}
-func copyClaudeGenerationConfigToInteractions(out []byte, root gjson.Result) []byte {
- out = copyClaudeJSONField(out, root, "max_tokens", "generation_config.max_output_tokens")
- out = copyClaudeJSONField(out, root, "temperature", "generation_config.temperature")
- out = copyClaudeJSONField(out, root, "top_p", "generation_config.top_p")
- out = copyClaudeJSONField(out, root, "stop_sequences", "generation_config.stop_sequences")
- out = copyClaudeThinkingToInteractions(out, root)
- return copyClaudeToolChoiceToInteractions(out, root.Get("tool_choice"))
+func copyClaudeGenerationConfigToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result) {
+ copyClaudeJSONFieldDoc(outDoc, root, "max_tokens", "generation_config.max_output_tokens")
+ copyClaudeJSONFieldDoc(outDoc, root, "temperature", "generation_config.temperature")
+ copyClaudeJSONFieldDoc(outDoc, root, "top_p", "generation_config.top_p")
+ copyClaudeJSONFieldDoc(outDoc, root, "stop_sequences", "generation_config.stop_sequences")
+ copyClaudeThinkingToInteractionsDoc(outDoc, root)
+ copyClaudeToolChoiceToInteractionsDoc(outDoc, root.Get("tool_choice"))
}
-func copyClaudeJSONField(out []byte, root gjson.Result, from, to string) []byte {
+func copyClaudeJSONFieldDoc(outDoc *ojson.Doc, root ojson.Result, from, to string) {
value := root.Get(from)
if !value.Exists() {
- return out
+ return
}
- out, _ = sjson.SetRawBytes(out, to, []byte(value.Raw))
- return out
+ _ = outDoc.SetRaw(to, value.Raw)
}
-func copyClaudeThinkingToInteractions(out []byte, root gjson.Result) []byte {
+func copyClaudeThinkingToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result) {
thinking := root.Get("thinking")
if thinking.Exists() {
switch strings.ToLower(strings.TrimSpace(thinking.Get("type").String())) {
case "disabled":
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", "none")
+ _ = outDoc.Set("generation_config.thinking_level", "none")
case "enabled":
if budget := thinking.Get("budget_tokens"); budget.Exists() {
- out, _ = sjson.SetRawBytes(out, "generation_config.thinking_config.thinking_budget", []byte(budget.Raw))
+ _ = outDoc.SetRaw("generation_config.thinking_config.thinking_budget", budget.Raw)
} else {
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", "high")
+ _ = outDoc.Set("generation_config.thinking_level", "high")
}
case "adaptive":
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", "auto")
+ _ = outDoc.Set("generation_config.thinking_level", "auto")
}
}
- if effort := root.Get("output_config.effort"); effort.Exists() && effort.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
+ if effort := root.Get("output_config.effort"); effort.Exists() && effort.Type == ojson.String {
+ _ = outDoc.Set("generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
}
- return out
}
-func copyClaudeToolChoiceToInteractions(out []byte, toolChoice gjson.Result) []byte {
+func copyClaudeToolChoiceToInteractionsDoc(outDoc *ojson.Doc, toolChoice ojson.Result) {
if !toolChoice.Exists() {
- return out
+ return
}
switch toolChoice.Type {
- case gjson.String:
+ case ojson.String:
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "auto":
- out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "auto")
+ _ = outDoc.Set("generation_config.tool_choice", "auto")
case "any", "required":
- out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "required")
+ _ = outDoc.Set("generation_config.tool_choice", "required")
}
- case gjson.JSON:
+ case ojson.JSON:
toolType := strings.ToLower(strings.TrimSpace(toolChoice.Get("type").String()))
switch toolType {
case "auto":
- out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "auto")
+ _ = outDoc.Set("generation_config.tool_choice", "auto")
case "any", "required":
- out, _ = sjson.SetBytes(out, "generation_config.tool_choice", "required")
+ _ = outDoc.Set("generation_config.tool_choice", "required")
case "tool":
name := strings.TrimSpace(toolChoice.Get("name").String())
if name != "" {
choice := []byte(`{"type":"function","name":""}`)
- choice, _ = sjson.SetBytes(choice, "name", name)
- out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", choice)
+ choice, _ = ojson.SetBytes(choice, "name", name)
+ _ = outDoc.SetRawBytes("generation_config.tool_choice", choice)
}
}
}
- return out
}
-func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, preserveEmptyThinkingBlocks bool) ([]byte, error) {
+func appendClaudeMessagesToInteractionsDoc(outDoc *ojson.Doc, messages ojson.Result, preserveEmptyThinkingBlocks bool) error {
if !messages.Exists() || !messages.IsArray() {
- return out, nil
+ return nil
}
var drops translatorcommon.UserTurnDrops
inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int())
@@ -137,13 +133,13 @@ func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, prese
var pendingSystemReminders [][]byte
toolNamesByID := make(map[string]string)
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
content := message.Get("content")
if role == "system" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(content); ok {
step := []byte(`{"type":"user_input","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "content.0.text", reminderText)
+ step, _ = ojson.SetBytes(step, "content.0.text", reminderText)
if len(pendingToolUseIDs) > 0 {
pendingSystemReminders = append(pendingSystemReminders, step)
} else {
@@ -171,24 +167,24 @@ func appendClaudeMessagesToInteractions(out []byte, messages gjson.Result, prese
if len(pendingSystemReminders) > 0 {
inputItems = append(inputItems, pendingSystemReminders...)
}
- out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
- return out, drops.Err()
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "input", inputItems)
+ return drops.Err()
}
-func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]string, pendingSystemReminders *[][]byte, toolNamesByID map[string]string, role string, content gjson.Result, preserveEmptyThinkingBlocks bool, drops *translatorcommon.UserTurnDrops) int {
+func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]string, pendingSystemReminders *[][]byte, toolNamesByID map[string]string, role string, content ojson.Result, preserveEmptyThinkingBlocks bool, drops *translatorcommon.UserTurnDrops) int {
defaultStepType := "user_input"
if role == "assistant" {
defaultStepType = "model_output"
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
if pendingSystemReminders != nil && len(*pendingSystemReminders) > 0 {
*items = append(*items, *pendingSystemReminders...)
*pendingSystemReminders = nil
}
- step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "type", defaultStepType)
- step, _ = sjson.SetBytes(step, "content.0.text", content.String())
- *items = append(*items, step)
+ stepDoc := ojson.NewDocString(`{"type":"","content":[{"type":"text","text":""}]}`)
+ _ = stepDoc.Set("type", defaultStepType)
+ _ = stepDoc.Set("content.0.text", content.String())
+ *items = append(*items, stepDoc.Bytes())
return 1
}
if !content.IsArray() {
@@ -202,13 +198,13 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str
if len(stepContent) == 0 {
return
}
- step := []byte(`{"type":"","content":[]}`)
- step, _ = sjson.SetBytes(step, "type", defaultStepType)
- step, _ = sjson.SetRawBytes(step, "content", translatorcommon.JoinRawArray(stepContent))
- *items = append(*items, step)
+ stepDoc := ojson.NewDocString(`{"type":"","content":[]}`)
+ _ = stepDoc.Set("type", defaultStepType)
+ _ = stepDoc.SetRawBytes("content", translatorcommon.JoinRawArray(stepContent))
+ *items = append(*items, stepDoc.Bytes())
stepContent = stepContent[:0]
}
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
switch partType {
case "text":
@@ -219,7 +215,7 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str
*pendingSystemReminders = nil
}
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", text)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", text)
stepContent = append(stepContent, contentPart)
if strings.TrimSpace(text) != "" {
sendable++
@@ -230,7 +226,7 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str
text := part.Get("thinking").String()
if text != "" || preserveEmptyThinkingBlocks {
step := []byte(`{"type":"thought","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "content.0.text", text)
+ step, _ = ojson.SetBytes(step, "content.0.text", text)
*items = append(*items, step)
sendable++
}
@@ -271,7 +267,7 @@ func appendClaudeMessageToInteractions(items *[][]byte, pendingToolUseIDs *[]str
return sendable
}
-func claudeMediaPartToInteractions(part gjson.Result, partType string) ([]byte, bool) {
+func claudeMediaPartToInteractions(part ojson.Result, partType string) ([]byte, bool) {
source := part.Get("source")
mimeType := source.Get("media_type").String()
data := source.Get("data").String()
@@ -284,56 +280,56 @@ func claudeMediaPartToInteractions(part gjson.Result, partType string) ([]byte,
if mimeType == "" || data == "" {
return nil, false
}
- out := []byte(`{"type":"","mime_type":"","data":""}`)
- out, _ = sjson.SetBytes(out, "type", partType)
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
- out, _ = sjson.SetBytes(out, "data", data)
- return out, true
+ outDoc := ojson.NewDocString(`{"type":"","mime_type":"","data":""}`)
+ _ = outDoc.Set("type", partType)
+ _ = outDoc.Set("mime_type", mimeType)
+ _ = outDoc.Set("data", data)
+ return outDoc.Bytes(), true
}
-func claudeToolUseToInteractions(part gjson.Result) []byte {
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
- step, _ = sjson.SetBytes(step, "name", part.Get("name").String())
+func claudeToolUseToInteractions(part ojson.Result) []byte {
+ stepDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
+ _ = stepDoc.Set("name", part.Get("name").String())
if id := part.Get("id").String(); id != "" {
- step, _ = sjson.SetBytes(step, "id", id)
+ _ = stepDoc.Set("id", id)
}
input := part.Get("input")
if input.Exists() && input.IsObject() {
- step, _ = sjson.SetRawBytes(step, "arguments", []byte(input.Raw))
+ _ = stepDoc.SetRaw("arguments", input.Raw)
}
- return step
+ return stepDoc.Bytes()
}
-func claudeToolResultToInteractions(part gjson.Result, toolNamesByID map[string]string) []byte {
- step := []byte(`{"type":"function_result","call_id":"","result":""}`)
+func claudeToolResultToInteractions(part ojson.Result, toolNamesByID map[string]string) []byte {
+ stepDoc := ojson.NewDocString(`{"type":"function_result","call_id":"","result":""}`)
id := part.Get("tool_use_id").String()
if id != "" {
- step, _ = sjson.SetBytes(step, "call_id", id)
+ _ = stepDoc.Set("call_id", id)
}
name := part.Get("name").String()
if name == "" && id != "" && toolNamesByID != nil {
name = toolNamesByID[id]
}
if name != "" {
- step, _ = sjson.SetBytes(step, "name", name)
+ _ = stepDoc.Set("name", name)
}
if isError := part.Get("is_error"); isError.Exists() && isError.Bool() {
- step, _ = sjson.SetBytes(step, "is_error", true)
+ _ = stepDoc.Set("is_error", true)
}
result := part.Get("content")
if result.Exists() {
switch {
- case result.Type == gjson.String:
- step, _ = sjson.SetBytes(step, "result", result.String())
+ case result.Type == ojson.String:
+ _ = stepDoc.Set("result", result.String())
case result.IsArray():
contentItems := make([][]byte, 0, 4)
- result.ForEach(func(_, item gjson.Result) bool {
+ result.ForEach(func(_, item ojson.Result) bool {
itemType := item.Get("type").String()
switch itemType {
case "text":
isPureText := true
if item.IsObject() {
- item.ForEach(func(k, _ gjson.Result) bool {
+ item.ForEach(func(k, _ ojson.Result) bool {
key := k.String()
if key != "type" && key != "text" && key != "cache_control" {
isPureText = false
@@ -344,7 +340,7 @@ func claudeToolResultToInteractions(part gjson.Result, toolNamesByID map[string]
}
if isPureText {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", item.Get("text").String())
+ contentPart, _ = ojson.SetBytes(contentPart, "text", item.Get("text").String())
contentItems = append(contentItems, contentPart)
} else if raw := strings.TrimSpace(item.Raw); raw != "" {
contentItems = append(contentItems, []byte(raw))
@@ -362,47 +358,46 @@ func claudeToolResultToInteractions(part gjson.Result, toolNamesByID map[string]
}
return true
})
- step, _ = sjson.SetRawBytes(step, "result", translatorcommon.JoinRawArray(contentItems))
+ _ = stepDoc.SetRawBytes("result", translatorcommon.JoinRawArray(contentItems))
default:
- step, _ = sjson.SetRawBytes(step, "result", []byte(result.Raw))
+ _ = stepDoc.SetRaw("result", result.Raw)
}
}
- return step
+ return stepDoc.Bytes()
}
-func copyClaudeToolsToInteractions(out []byte, root gjson.Result) []byte {
+func copyClaudeToolsToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result) {
tools := root.Get("tools")
if !tools.Exists() || !tools.IsArray() {
- return out
+ return
}
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
name := strings.TrimSpace(tool.Get("name").String())
if name == "" {
return true
}
- item := []byte(`{"type":"function","name":"","parameters":{}}`)
- item, _ = sjson.SetBytes(item, "name", name)
+ itemDoc := ojson.NewDocString(`{"type":"function","name":"","parameters":{}}`)
+ _ = itemDoc.Set("name", name)
if desc := tool.Get("description"); desc.Exists() {
- item, _ = sjson.SetBytes(item, "description", desc.String())
+ _ = itemDoc.Set("description", desc.String())
}
if schema := tool.Get("input_schema"); schema.Exists() && schema.IsObject() {
- item, _ = sjson.SetRawBytes(item, "parameters", []byte(schema.Raw))
+ _ = itemDoc.SetRaw("parameters", schema.Raw)
}
- toolItems = append(toolItems, item)
+ toolItems = append(toolItems, itemDoc.Bytes())
return true
})
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func claudeText(value gjson.Result) string {
+func claudeText(value ojson.Result) string {
if !value.Exists() {
return ""
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
return value.String()
}
if text := value.Get("text"); text.Exists() {
@@ -410,7 +405,7 @@ func claudeText(value gjson.Result) string {
}
if value.IsArray() {
var builder strings.Builder
- value.ForEach(func(_, item gjson.Result) bool {
+ value.ForEach(func(_, item ojson.Result) bool {
text := claudeText(item)
if text == "" {
return true
diff --git a/internal/translator/interactions/claude/interactions_claude_response.go b/internal/translator/interactions/claude/interactions_claude_response.go
index 8ff03364e1d..1aeffa93edb 100644
--- a/internal/translator/interactions/claude/interactions_claude_response.go
+++ b/internal/translator/interactions/claude/interactions_claude_response.go
@@ -7,9 +7,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type interactionsToClaudeStreamState struct {
@@ -48,66 +48,66 @@ func ConvertInteractionsResponseToClaude(_ context.Context, modelName string, or
func ConvertInteractionsResponseToClaudeNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
interaction := root
if nested := root.Get("interaction"); nested.Exists() {
interaction = nested
}
- out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
- out, _ = sjson.SetBytes(out, "id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("msg_%d", time.Now().UnixNano())))
- out, _ = sjson.SetBytes(out, "model", firstNonEmpty(interaction.Get("model").String(), modelName))
+ outDoc := ojson.NewDocString(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
+ _ = outDoc.Set("id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("msg_%d", time.Now().UnixNano())))
+ _ = outDoc.Set("model", firstNonEmpty(interaction.Get("model").String(), modelName))
steps := interaction.Get("steps")
if !steps.Exists() {
steps = root.Get("steps")
}
sawToolCall := false
var contentBlocks [][]byte
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
switch step.Get("type").String() {
case "thought":
for _, text := range interactionsContentTexts(step.Get("content")) {
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", text)
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":""}`)
+ _ = blockDoc.Set("thinking", text)
if signature := interactionsSignature(step); signature != "" {
- block, _ = sjson.SetBytes(block, "signature", signature)
+ _ = blockDoc.Set("signature", signature)
}
- contentBlocks = append(contentBlocks, block)
+ contentBlocks = append(contentBlocks, blockDoc.Bytes())
}
case "function_call":
sawToolCall = true
- block := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- block, _ = sjson.SetBytes(block, "id", interactionsToolID(step))
- block, _ = sjson.SetBytes(block, "name", step.Get("name").String())
+ blockDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = blockDoc.Set("id", interactionsToolID(step))
+ _ = blockDoc.Set("name", step.Get("name").String())
if signature := interactionsSignature(step); signature != "" {
- block, _ = sjson.SetBytes(block, "signature", signature)
+ _ = blockDoc.Set("signature", signature)
}
args := firstExisting(step, "arguments", "args")
if args.Exists() && args.IsObject() {
- block, _ = sjson.SetRawBytes(block, "input", []byte(args.Raw))
+ _ = blockDoc.SetRaw("input", args.Raw)
}
- contentBlocks = append(contentBlocks, block)
+ contentBlocks = append(contentBlocks, blockDoc.Bytes())
default:
for _, text := range interactionsContentTexts(step.Get("content")) {
block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", text)
+ block, _ = ojson.SetBytes(block, "text", text)
contentBlocks = append(contentBlocks, block)
}
}
return true
})
if len(contentBlocks) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "content", contentBlocks)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "content", contentBlocks)
}
if sawToolCall {
- out, _ = sjson.SetBytes(out, "stop_reason", "tool_use")
+ _ = outDoc.Set("stop_reason", "tool_use")
}
status := firstNonEmpty(interaction.Get("status").String(), root.Get("status").String())
finishReason := firstNonEmpty(interaction.Get("finish_reason").String(), root.Get("finish_reason").String())
if status == "incomplete" || finishReason == "length" || finishReason == "max_tokens" {
- out, _ = sjson.SetBytes(out, "stop_reason", "max_tokens")
+ _ = outDoc.Set("stop_reason", "max_tokens")
}
- out = setClaudeUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root))
- return out
+ setClaudeUsageFromInteractionsDoc(outDoc, "usage", translatorcommon.InteractionsUsage(root))
+ return outDoc.Bytes()
}
func convertInteractionsEventToClaude(modelName string, rawJSON []byte, st *interactionsToClaudeStreamState) [][]byte {
@@ -118,15 +118,15 @@ func convertInteractionsEventToClaude(modelName string, rawJSON []byte, st *inte
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return appendClaudeMessageStop(nil, st)
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if !root.Exists() {
return nil
}
switch root.Get("event_type").String() {
case "interaction.created":
interaction := root.Get("interaction")
- st.ID = firstNonEmpty(interaction.Get("id").String(), st.ID)
- st.Model = firstNonEmpty(interaction.Get("model").String(), st.Model, modelName)
+ st.ID = strings.Clone(firstNonEmpty(interaction.Get("id").String(), st.ID))
+ st.Model = strings.Clone(firstNonEmpty(interaction.Get("model").String(), st.Model, modelName))
return appendClaudeMessageStart(nil, st)
case "step.start":
return interactionsStepStartToClaude(modelName, root, st)
@@ -144,19 +144,19 @@ func convertInteractionsEventToClaude(modelName string, rawJSON []byte, st *inte
return nil
}
-func interactionsStepStartToClaude(modelName string, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte {
+func interactionsStepStartToClaude(modelName string, root ojson.Result, st *interactionsToClaudeStreamState) [][]byte {
out := appendClaudeMessageStart(nil, st)
out = appendClaudeContentBlockStop(out, st)
index := int(root.Get("index").Int())
step := root.Get("step")
stepType := step.Get("type").String()
- st.StepTypes[index] = stepType
+ st.StepTypes[index] = strings.Clone(stepType)
switch stepType {
case "function_call":
st.SawToolCall = true
- st.ToolNames[index] = step.Get("name").String()
- st.ToolIDs[index] = interactionsToolID(step)
- st.ToolSignatures[index] = interactionsSignature(step)
+ st.ToolNames[index] = strings.Clone(step.Get("name").String())
+ st.ToolIDs[index] = strings.Clone(interactionsToolID(step))
+ st.ToolSignatures[index] = strings.Clone(interactionsSignature(step))
return appendClaudeToolBlockStart(out, index, st)
case "thought":
return appendClaudeContentBlockStart(out, "thinking", st)
@@ -166,7 +166,7 @@ func interactionsStepStartToClaude(modelName string, root gjson.Result, st *inte
}
}
-func interactionsStepDeltaToClaude(modelName string, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte {
+func interactionsStepDeltaToClaude(modelName string, root ojson.Result, st *interactionsToClaudeStreamState) [][]byte {
index := int(root.Get("index").Int())
delta := root.Get("delta")
switch delta.Get("type").String() {
@@ -184,7 +184,7 @@ func interactionsStepDeltaToClaude(modelName string, root gjson.Result, st *inte
if !st.ActiveBlock || st.ActiveBlockType != "tool_use" {
out = appendClaudeContentBlockStop(out, st)
if st.ToolNames[index] == "" {
- st.ToolNames[index] = root.Get("step.name").String()
+ st.ToolNames[index] = strings.Clone(root.Get("step.name").String())
}
if st.ToolIDs[index] == "" {
st.ToolIDs[index] = fmt.Sprintf("toolu_%d", index)
@@ -205,11 +205,11 @@ func appendClaudeMessageStart(out [][]byte, st *interactionsToClaudeStreamState)
if st.Started {
return out
}
- msg := []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","content":[],"model":"","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
- msg, _ = sjson.SetBytes(msg, "message.id", firstNonEmpty(st.ID, fmt.Sprintf("msg_%d", time.Now().UnixNano())))
- msg, _ = sjson.SetBytes(msg, "message.model", st.Model)
+ msgDoc := ojson.NewDocString(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","content":[],"model":"","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
+ _ = msgDoc.Set("message.id", firstNonEmpty(st.ID, fmt.Sprintf("msg_%d", time.Now().UnixNano())))
+ _ = msgDoc.Set("message.model", st.Model)
st.Started = true
- return append(out, translatorcommon.AppendSSEEventBytes(nil, "message_start", msg, 3))
+ return append(out, translatorcommon.AppendSSEEventBytes(nil, "message_start", msgDoc.Bytes(), 3))
}
func appendClaudeContentBlockStart(out [][]byte, blockType string, st *interactionsToClaudeStreamState) [][]byte {
@@ -223,7 +223,7 @@ func appendClaudeContentBlockStart(out [][]byte, blockType string, st *interacti
} else {
block = []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
}
- block, _ = sjson.SetBytes(block, "index", st.BlockIndex)
+ block, _ = ojson.SetBytes(block, "index", st.BlockIndex)
st.ActiveBlock = true
st.ActiveBlockType = blockType
return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", block, 3))
@@ -231,16 +231,16 @@ func appendClaudeContentBlockStart(out [][]byte, blockType string, st *interacti
func appendClaudeToolBlockStart(out [][]byte, stepIndex int, st *interactionsToClaudeStreamState) [][]byte {
out = appendClaudeContentBlockStop(out, st)
- block := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
- block, _ = sjson.SetBytes(block, "index", st.BlockIndex)
- block, _ = sjson.SetBytes(block, "content_block.id", firstNonEmpty(st.ToolIDs[stepIndex], fmt.Sprintf("toolu_%d", stepIndex)))
- block, _ = sjson.SetBytes(block, "content_block.name", st.ToolNames[stepIndex])
+ blockDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
+ _ = blockDoc.Set("index", st.BlockIndex)
+ _ = blockDoc.Set("content_block.id", firstNonEmpty(st.ToolIDs[stepIndex], fmt.Sprintf("toolu_%d", stepIndex)))
+ _ = blockDoc.Set("content_block.name", st.ToolNames[stepIndex])
if signature := st.ToolSignatures[stepIndex]; signature != "" {
- block, _ = sjson.SetBytes(block, "content_block.signature", signature)
+ _ = blockDoc.Set("content_block.signature", signature)
}
st.ActiveBlock = true
st.ActiveBlockType = "tool_use"
- return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", block, 3))
+ return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", blockDoc.Bytes(), 3))
}
func ensureClaudeContentBlock(out [][]byte, blockType string, st *interactionsToClaudeStreamState) [][]byte {
@@ -254,11 +254,11 @@ func appendClaudeContentDelta(out [][]byte, deltaType, field, value string, st *
if value == "" && deltaType != "input_json_delta" {
return out
}
- delta := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":""}}`)
- delta, _ = sjson.SetBytes(delta, "index", st.BlockIndex)
- delta, _ = sjson.SetBytes(delta, "delta.type", deltaType)
- delta, _ = sjson.SetBytes(delta, "delta."+field, value)
- return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", delta, 3))
+ deltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":""}}`)
+ _ = deltaDoc.Set("index", st.BlockIndex)
+ _ = deltaDoc.Set("delta.type", deltaType)
+ _ = deltaDoc.Set("delta."+field, value)
+ return append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", deltaDoc.Bytes(), 3))
}
func appendClaudeContentBlockStop(out [][]byte, st *interactionsToClaudeStreamState) [][]byte {
@@ -266,7 +266,7 @@ func appendClaudeContentBlockStop(out [][]byte, st *interactionsToClaudeStreamSt
return out
}
stop := []byte(`{"type":"content_block_stop","index":0}`)
- stop, _ = sjson.SetBytes(stop, "index", st.BlockIndex)
+ stop, _ = ojson.SetBytes(stop, "index", st.BlockIndex)
out = append(out, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", stop, 3))
st.ActiveBlock = false
st.ActiveBlockType = ""
@@ -274,24 +274,24 @@ func appendClaudeContentBlockStop(out [][]byte, st *interactionsToClaudeStreamSt
return out
}
-func appendClaudeMessageDelta(out [][]byte, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte {
+func appendClaudeMessageDelta(out [][]byte, root ojson.Result, st *interactionsToClaudeStreamState) [][]byte {
if st.Completed {
return out
}
out = appendClaudeMessageStart(out, st)
out = appendClaudeContentBlockStop(out, st)
- payload := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
+ payloadDoc := ojson.NewDocString(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
if st.SawToolCall {
- payload, _ = sjson.SetBytes(payload, "delta.stop_reason", "tool_use")
+ _ = payloadDoc.Set("delta.stop_reason", "tool_use")
}
interaction := root.Get("interaction")
status := firstNonEmpty(interaction.Get("status").String(), root.Get("status").String())
finishReason := firstNonEmpty(interaction.Get("finish_reason").String(), root.Get("finish_reason").String())
if status == "incomplete" || finishReason == "length" || finishReason == "max_tokens" {
- payload, _ = sjson.SetBytes(payload, "delta.stop_reason", "max_tokens")
+ _ = payloadDoc.Set("delta.stop_reason", "max_tokens")
}
- payload = setClaudeUsageFromInteractions(payload, "usage", translatorcommon.InteractionsUsage(root))
- out = append(out, translatorcommon.AppendSSEEventBytes(nil, "message_delta", payload, 3))
+ setClaudeUsageFromInteractionsDoc(payloadDoc, "usage", translatorcommon.InteractionsUsage(root))
+ out = append(out, translatorcommon.AppendSSEEventBytes(nil, "message_delta", payloadDoc.Bytes(), 3))
st.Completed = true
return out
}
@@ -302,7 +302,7 @@ func appendClaudeMessageStop(out [][]byte, st *interactionsToClaudeStreamState)
}
out = appendClaudeContentBlockStop(out, st)
if !st.Completed {
- out = appendClaudeMessageDelta(out, gjson.Result{}, st)
+ out = appendClaudeMessageDelta(out, ojson.Result{}, st)
}
if !st.Stopped {
out = append(out, translatorcommon.AppendSSEEventString(nil, "message_stop", `{"type":"message_stop"}`, 3))
@@ -312,7 +312,7 @@ func appendClaudeMessageStop(out [][]byte, st *interactionsToClaudeStreamState)
return out
}
-func appendClaudeError(out [][]byte, root gjson.Result, st *interactionsToClaudeStreamState) [][]byte {
+func appendClaudeError(out [][]byte, root ojson.Result, st *interactionsToClaudeStreamState) [][]byte {
out = appendClaudeContentBlockStop(out, st)
errNode := root.Get("error")
if !errNode.Exists() {
@@ -326,15 +326,15 @@ func appendClaudeError(out [][]byte, root gjson.Result, st *interactionsToClaude
if errType == "" {
errType = "api_error"
}
- payload := []byte(`{"type":"error","error":{"type":"","message":""}}`)
- payload, _ = sjson.SetBytes(payload, "error.type", errType)
- payload, _ = sjson.SetBytes(payload, "error.message", msg)
- return append(out, translatorcommon.AppendSSEEventBytes(nil, "error", payload, 3))
+ payloadDoc := ojson.NewDocString(`{"type":"error","error":{"type":"","message":""}}`)
+ _ = payloadDoc.Set("error.type", errType)
+ _ = payloadDoc.Set("error.message", msg)
+ return append(out, translatorcommon.AppendSSEEventBytes(nil, "error", payloadDoc.Bytes(), 3))
}
-func setClaudeUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte {
+func setClaudeUsageFromInteractionsDoc(doc *ojson.Doc, path string, usage ojson.Result) {
if !usage.Exists() {
- return out
+ return
}
outputTokens, hasOutput := firstUsageInt(usage, "output_tokens", "total_output_tokens")
cachedTokens, hasCached := firstUsageInt(usage, "cache_read_input_tokens", "cache_read_tokens", "cached_tokens", "total_cached_tokens")
@@ -364,18 +364,17 @@ func setClaudeUsageFromInteractions(out []byte, path string, usage gjson.Result)
}
if hasInput {
- out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens)
+ _ = doc.Set(path+".input_tokens", inputTokens)
}
if hasOutput {
- out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens)
+ _ = doc.Set(path+".output_tokens", outputTokens)
}
if hasCached && cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, path+".cache_read_input_tokens", cachedTokens)
+ _ = doc.Set(path+".cache_read_input_tokens", cachedTokens)
}
if hasCacheWrite && cacheWriteTokens > 0 {
- out, _ = sjson.SetBytes(out, path+".cache_creation_input_tokens", cacheWriteTokens)
+ _ = doc.Set(path+".cache_creation_input_tokens", cacheWriteTokens)
}
- return out
}
func interactionsSSEPayload(rawJSON []byte) []byte {
@@ -399,15 +398,15 @@ func interactionsSSEPayload(rawJSON []byte) []byte {
return trimmed
}
-func interactionsContentTexts(content gjson.Result) []string {
+func interactionsContentTexts(content ojson.Result) []string {
if !content.Exists() {
return nil
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return []string{content.String()}
}
var out []string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" {
out = append(out, text)
}
@@ -416,11 +415,11 @@ func interactionsContentTexts(content gjson.Result) []string {
return out
}
-func interactionsToolID(root gjson.Result) string {
+func interactionsToolID(root ojson.Result) string {
return firstNonEmpty(root.Get("call_id").String(), root.Get("id").String(), root.Get("tool_use_id").String(), "toolu_interactions")
}
-func interactionsSignature(root gjson.Result) string {
+func interactionsSignature(root ojson.Result) string {
return firstNonEmpty(
root.Get("signature").String(),
root.Get("thought_signature").String(),
@@ -429,16 +428,16 @@ func interactionsSignature(root gjson.Result) string {
)
}
-func firstExisting(root gjson.Result, paths ...string) gjson.Result {
+func firstExisting(root ojson.Result, paths ...string) ojson.Result {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func firstUsageInt(root gjson.Result, paths ...string) (int64, bool) {
+func firstUsageInt(root ojson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value.Int(), true
diff --git a/internal/translator/interactions/claude/interactions_claude_test.go b/internal/translator/interactions/claude/interactions_claude_test.go
index 2c1494ab901..d222634cc6a 100644
--- a/internal/translator/interactions/claude/interactions_claude_test.go
+++ b/internal/translator/interactions/claude/interactions_claude_test.go
@@ -6,31 +6,31 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeRequestToInteractionsMapsMessagesToolsAndStream(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"max_tokens":1024,"tools":[{"name":"get_weather","description":"Weather","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}],"messages":[{"role":"user","content":[{"type":"text","text":"今天北京的天气怎么样?"}]}]}`)
out, _ := ConvertClaudeRequestToInteractions("gemini-3.1-flash-lite", raw, true)
- if got := gjson.GetBytes(out, "model").String(); got != "gemini-3.1-flash-lite" {
+ if got := ojson.GetBytesNoCopy(out, "model").String(); got != "gemini-3.1-flash-lite" {
t.Fatalf("model = %q, want gemini-3.1-flash-lite. Output: %s", got, string(out))
}
- if !gjson.GetBytes(out, "stream").Bool() {
+ if !ojson.GetBytesNoCopy(out, "stream").Bool() {
t.Fatalf("stream should be true. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 1024 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.max_output_tokens").Int(); got != 1024 {
t.Fatalf("max_output_tokens = %d, want 1024. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("input text = %q. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.parameters.properties.location.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.parameters.properties.location.type").String(); got != "string" {
t.Fatalf("tool schema was not mapped. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
}
@@ -38,28 +38,28 @@ func TestConvertClaudeRequestToInteractionsMapsMessagesToolsAndStream(t *testing
func TestConvertClaudeRequestToInteractionsMapsToolUseAndResult(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"location":"北京"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"晴"}]}]}`)
out, _ := ConvertClaudeRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "function_call" {
t.Fatalf("input.0.type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.id").String(); got != "toolu_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.id").String(); got != "toolu_1" {
t.Fatalf("id = %q, want toolu_1. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "input.0.call_id").Exists() {
+ if ojson.GetBytesNoCopy(out, "input.0.call_id").Exists() {
t.Fatalf("function_call should not have call_id parameter. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.type").String(); got != "function_result" {
t.Fatalf("input.1.type = %q, want function_result. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.name").String(); got != "get_weather" {
t.Fatalf("name = %q, want get_weather. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "toolu_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.call_id").String(); got != "toolu_1" {
t.Fatalf("call_id = %q, want toolu_1. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "input.1.id").Exists() {
+ if ojson.GetBytesNoCopy(out, "input.1.id").Exists() {
t.Fatalf("function_result should not have id parameter. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "input.1.result").String(); got != "晴" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.result").String(); got != "晴" {
t.Fatalf("result = %q, want 晴. Output: %s", got, string(out))
}
}
@@ -90,16 +90,16 @@ func TestConvertClaudeRequestToInteractionsInfersToolNamesForOutOfOrderResults(t
// input.1: function_call toolu_2 (weather)
// input.2: function_result toolu_1 (lookup)
// input.3: function_result toolu_2 (weather)
- if got := gjson.GetBytes(out, "input.2.call_id").String(); got != "toolu_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.call_id").String(); got != "toolu_1" {
t.Fatalf("input.2.call_id = %q, want toolu_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.name").String(); got != "lookup" {
t.Fatalf("input.2.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.3.call_id").String(); got != "toolu_2" {
+ if got := ojson.GetBytesNoCopy(out, "input.3.call_id").String(); got != "toolu_2" {
t.Fatalf("input.3.call_id = %q, want toolu_2. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.3.name").String(); got != "weather" {
+ if got := ojson.GetBytesNoCopy(out, "input.3.name").String(); got != "weather" {
t.Fatalf("input.3.name = %q, want weather. Output: %s", got, string(out))
}
}
@@ -107,13 +107,13 @@ func TestConvertClaudeRequestToInteractionsInfersToolNamesForOutOfOrderResults(t
func TestConvertClaudeRequestToInteractionsPropagatesIsError(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_err","content":"command failed","is_error":true}]}]}`)
out, _ := ConvertClaudeRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if !gjson.GetBytes(out, "input.0.is_error").Bool() {
+ if !ojson.GetBytesNoCopy(out, "input.0.is_error").Bool() {
t.Fatalf("expected input.0.is_error = true. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "toolu_err" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.call_id").String(); got != "toolu_err" {
t.Fatalf("call_id = %q, want toolu_err. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "input.0.id").Exists() {
+ if ojson.GetBytesNoCopy(out, "input.0.id").Exists() {
t.Fatalf("input.0.id must not exist on function_result. Output: %s", string(out))
}
}
@@ -142,7 +142,7 @@ func TestConvertClaudeRequestToInteractions_PreservesToolAdjacencyWithIntervenin
]
}`)
out, _ := ConvertClaudeRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- inputs := gjson.GetBytes(out, "input").Array()
+ inputs := ojson.GetBytesNoCopy(out, "input").Array()
// Expected types:
// 0: user_input ("Execute tools")
@@ -200,16 +200,16 @@ data: [DONE]`),
for _, chunk := range chunks {
out = append(out, ConvertInteractionsResponseToClaude(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, ¶m)...)
}
- if payload := findClaudeEventPayload(out, "message_start"); gjson.GetBytes(payload, "message.model").String() != "gemini-3.1-flash-lite" {
+ if payload := findClaudeEventPayload(out, "message_start"); ojson.GetBytesNoCopy(payload, "message.model").String() != "gemini-3.1-flash-lite" {
t.Fatalf("message_start payload = %s", payload)
}
- if payload := findClaudeEventPayload(out, "content_block_delta"); gjson.GetBytes(payload, "delta.text").String() != "北京今天晴" {
+ if payload := findClaudeEventPayload(out, "content_block_delta"); ojson.GetBytesNoCopy(payload, "delta.text").String() != "北京今天晴" {
t.Fatalf("content_block_delta payload = %s", payload)
}
- if payload := findClaudeEventPayload(out, "message_delta"); gjson.GetBytes(payload, "usage.output_tokens").Int() != 4 {
+ if payload := findClaudeEventPayload(out, "message_delta"); ojson.GetBytesNoCopy(payload, "usage.output_tokens").Int() != 4 {
t.Fatalf("message_delta payload = %s", payload)
}
- if payload := findClaudeEventPayload(out, "message_stop"); gjson.GetBytes(payload, "type").String() != "message_stop" {
+ if payload := findClaudeEventPayload(out, "message_stop"); ojson.GetBytesNoCopy(payload, "type").String() != "message_stop" {
t.Fatalf("message_stop payload = %s", payload)
}
}
@@ -227,16 +227,16 @@ func TestConvertInteractionsResponseToClaudeStreamToolCall(t *testing.T) {
for _, chunk := range chunks {
out = append(out, ConvertInteractionsResponseToClaude(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, ¶m)...)
}
- if payload := findClaudeEventPayload(out, "content_block_start"); gjson.GetBytes(payload, "content_block.type").String() != "tool_use" {
+ if payload := findClaudeEventPayload(out, "content_block_start"); ojson.GetBytesNoCopy(payload, "content_block.type").String() != "tool_use" {
t.Fatalf("content_block_start payload = %s", payload)
}
- if payload := findClaudeEventPayload(out, "content_block_start"); gjson.GetBytes(payload, "content_block.signature").String() != "sig_1" {
+ if payload := findClaudeEventPayload(out, "content_block_start"); ojson.GetBytesNoCopy(payload, "content_block.signature").String() != "sig_1" {
t.Fatalf("content_block_start signature payload = %s", payload)
}
- if payload := findClaudeEventPayload(out, "content_block_delta"); gjson.GetBytes(payload, "delta.partial_json").String() != `{"location":"北京"}` {
+ if payload := findClaudeEventPayload(out, "content_block_delta"); ojson.GetBytesNoCopy(payload, "delta.partial_json").String() != `{"location":"北京"}` {
t.Fatalf("content_block_delta payload = %s", payload)
}
- if payload := findClaudeEventPayload(out, "message_delta"); gjson.GetBytes(payload, "delta.stop_reason").String() != "tool_use" {
+ if payload := findClaudeEventPayload(out, "message_delta"); ojson.GetBytesNoCopy(payload, "delta.stop_reason").String() != "tool_use" {
t.Fatalf("message_delta payload = %s", payload)
}
}
@@ -248,10 +248,10 @@ func TestConvertInteractionsResponseToClaudeStreamFinishMetadataUsage(t *testing
if len(payload) == 0 {
t.Fatalf("message_delta payload not found")
}
- if got := gjson.GetBytes(payload, "usage.input_tokens").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.input_tokens").Int(); got != 2 {
t.Fatalf("input_tokens = %d, want 2. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "usage.output_tokens").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(payload, "usage.output_tokens").Int(); got != 6 {
t.Fatalf("output_tokens = %d, want 6. Payload: %s", got, string(payload))
}
}
@@ -259,19 +259,19 @@ func TestConvertInteractionsResponseToClaudeStreamFinishMetadataUsage(t *testing
func TestConvertInteractionsResponseToClaudeNonStream(t *testing.T) {
raw := []byte(`{"id":"interaction_1","model":"gemini-3.1-flash-lite","steps":[{"type":"model_output","content":[{"type":"text","text":"ok"}]},{"type":"function_call","call_id":"toolu_1","signature":"sig_1","name":"lookup","arguments":{"q":"x"}}],"usage":{"total_input_tokens":3,"total_output_tokens":4}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "content.0.text").String(); got != "ok" {
t.Fatalf("text = %q, want ok. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "content.1.type").String(); got != "tool_use" {
+ if got := ojson.GetBytesNoCopy(out, "content.1.type").String(); got != "tool_use" {
t.Fatalf("tool block type = %q, want tool_use. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "content.1.signature").String(); got != "sig_1" {
+ if got := ojson.GetBytesNoCopy(out, "content.1.signature").String(); got != "sig_1" {
t.Fatalf("tool signature = %q, want sig_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "stop_reason").String(); got != "tool_use" {
+ if got := ojson.GetBytesNoCopy(out, "stop_reason").String(); got != "tool_use" {
t.Fatalf("stop_reason = %q, want tool_use. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.input_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(out, "usage.input_tokens").Int(); got != 3 {
t.Fatalf("input_tokens = %d, want 3. Output: %s", got, string(out))
}
}
@@ -280,7 +280,7 @@ func TestConvertInteractionsResponseToClaude_IncompleteMaxTokens(t *testing.T) {
// Non-stream
raw := []byte(`{"id":"interaction_1","model":"devin/swe-2","status":"incomplete","finish_reason":"length","steps":[{"type":"model_output","content":[{"type":"text","text":"cut short"}]}],"usage":{"total_input_tokens":3,"total_output_tokens":4}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "stop_reason").String(); got != "max_tokens" {
+ if got := ojson.GetBytesNoCopy(out, "stop_reason").String(); got != "max_tokens" {
t.Fatalf("stop_reason = %q, want max_tokens. Output: %s", got, string(out))
}
@@ -302,7 +302,7 @@ func TestConvertInteractionsResponseToClaude_IncompleteMaxTokens(t *testing.T) {
if msgDelta == nil {
t.Fatalf("missing message_delta event")
}
- if got := gjson.GetBytes(msgDelta, "delta.stop_reason").String(); got != "max_tokens" {
+ if got := ojson.GetBytesNoCopy(msgDelta, "delta.stop_reason").String(); got != "max_tokens" {
t.Fatalf("delta.stop_reason = %q, want max_tokens. Payload: %s", got, string(msgDelta))
}
}
@@ -342,7 +342,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
if msgDelta == nil {
t.Fatalf("missing message_delta event")
}
- usage := gjson.GetBytes(msgDelta, "usage")
+ usage := ojson.GetBytesNoCopy(msgDelta, "usage")
if got := usage.Get("input_tokens").Int(); got != 71 {
t.Fatalf("usage.input_tokens = %d, want 71. Payload: %s", got, string(msgDelta))
}
@@ -357,7 +357,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("non_streaming_cache_hit", func(t *testing.T) {
raw := []byte(`{"id":"i2","model":"devin/glm-5-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"total_input_tokens":96724,"total_output_tokens":269,"total_cached_tokens":30784,"total_tokens":96993}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/glm-5-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 65940 {
t.Fatalf("usage.input_tokens = %d, want 65940. Output: %s", got, string(out))
}
@@ -372,7 +372,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("zero_cache_tokens", func(t *testing.T) {
raw := []byte(`{"id":"i3","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"total_input_tokens":100,"total_output_tokens":50,"total_cached_tokens":0,"total_tokens":150}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 100 {
t.Fatalf("usage.input_tokens = %d, want 100. Output: %s", got, string(out))
}
@@ -387,7 +387,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_and_cache_creation", func(t *testing.T) {
raw := []byte(`{"id":"i4","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":71,"total_input_tokens":10411,"total_output_tokens":76,"cache_read_input_tokens":10340,"cache_creation_input_tokens":25,"total_tokens":10487}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 71 {
t.Fatalf("usage.input_tokens = %d, want 71. Output: %s", got, string(out))
}
@@ -406,7 +406,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
// Total input (10436) = uncached (71) + read (10340) + write (25)
raw := []byte(`{"id":"i5","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":71,"total_input_tokens":10436,"total_output_tokens":76,"cache_read_input_tokens":10340,"cache_creation_input_tokens":25,"total_tokens":10512}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 71 {
t.Fatalf("usage.input_tokens = %d, want 71. Output: %s", got, string(out))
}
@@ -421,7 +421,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("full_cache_hit", func(t *testing.T) {
raw := []byte(`{"id":"i6","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"total_input_tokens":500,"total_output_tokens":50,"total_cached_tokens":500,"total_tokens":550}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 0 {
t.Fatalf("usage.input_tokens = %d, want 0. Output: %s", got, string(out))
}
@@ -433,7 +433,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("cached_tokens_exceeds_input", func(t *testing.T) {
raw := []byte(`{"id":"i7","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"total_input_tokens":50,"total_output_tokens":50,"total_cached_tokens":100,"total_tokens":150}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 0 {
t.Fatalf("usage.input_tokens = %d, want 0. Output: %s", got, string(out))
}
@@ -445,7 +445,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_without_total_input_tokens", func(t *testing.T) {
raw := []byte(`{"id":"i8","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":71,"output_tokens":76,"cache_read_input_tokens":10340}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 71 {
t.Fatalf("usage.input_tokens = %d, want 71. Output: %s", got, string(out))
}
@@ -457,7 +457,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_without_total_input_tokens_greater", func(t *testing.T) {
raw := []byte(`{"id":"i8_gt","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":100,"output_tokens":30,"cache_read_input_tokens":80}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 100 {
t.Fatalf("usage.input_tokens = %d, want 100. Output: %s", got, string(out))
}
@@ -469,7 +469,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_without_total_input_tokens_equal", func(t *testing.T) {
raw := []byte(`{"id":"i8_eq","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":80,"output_tokens":30,"cache_read_input_tokens":80}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 80 {
t.Fatalf("usage.input_tokens = %d, want 80. Output: %s", got, string(out))
}
@@ -481,7 +481,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_zero_with_total", func(t *testing.T) {
raw := []byte(`{"id":"i10","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":0,"total_input_tokens":500,"output_tokens":30,"cache_read_input_tokens":500}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 0 {
t.Fatalf("usage.input_tokens = %d, want 0. Output: %s", got, string(out))
}
@@ -493,7 +493,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_equal_to_total_input", func(t *testing.T) {
raw := []byte(`{"id":"i11","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":100,"total_input_tokens":100,"output_tokens":30,"cache_read_input_tokens":20}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 100 {
t.Fatalf("usage.input_tokens = %d, want 100. Output: %s", got, string(out))
}
@@ -505,7 +505,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("explicit_uncached_zero_with_partial_cache", func(t *testing.T) {
raw := []byte(`{"id":"i12","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"input_tokens":0,"total_input_tokens":500,"output_tokens":30,"cache_read_input_tokens":200}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 0 {
t.Fatalf("usage.input_tokens = %d, want 0. Output: %s", got, string(out))
}
@@ -517,7 +517,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("inclusive_total_without_input_tokens_large_scale", func(t *testing.T) {
raw := []byte(`{"id":"i13","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"total_input_tokens":10436,"output_tokens":76,"cache_read_input_tokens":10340,"cache_creation_input_tokens":25}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 71 {
t.Fatalf("usage.input_tokens = %d, want 71. Output: %s", got, string(out))
}
@@ -532,7 +532,7 @@ func TestConvertInteractionsResponseToClaude_PreservesCacheReadUsage(t *testing.
t.Run("prompt_tokens_fallback", func(t *testing.T) {
raw := []byte(`{"id":"i14","model":"devin/swe-2","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"response text"}]}],"usage":{"prompt_tokens":100,"output_tokens":20,"cached_tokens":30}}`)
out := ConvertInteractionsResponseToClaudeNonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- usage := gjson.GetBytes(out, "usage")
+ usage := ojson.GetBytesNoCopy(out, "usage")
if got := usage.Get("input_tokens").Int(); got != 70 {
t.Fatalf("usage.input_tokens = %d, want 70. Output: %s", got, string(out))
}
@@ -582,7 +582,7 @@ func TestConvertClaudeRequestToInteractionsPreservesImagesInToolResult(t *testin
}`)
out, _ := ConvertClaudeRequestToInteractions("devin/swe-2", raw, false)
- res := gjson.GetBytes(out, "input.1.result")
+ res := ojson.GetBytesNoCopy(out, "input.1.result")
if !res.IsArray() {
t.Fatalf("expected input.1.result to be array, got: %s", res.Raw)
}
@@ -631,7 +631,7 @@ func TestConvertClaudeRequestToInteractionsPreservesBusinessObjectsInToolResultA
}`)
out, _ := ConvertClaudeRequestToInteractions("devin/swe-2", raw, false)
- res := gjson.GetBytes(out, "input.0.result")
+ res := ojson.GetBytesNoCopy(out, "input.0.result")
if !res.IsArray() {
t.Fatalf("expected input.0.result to be array, got: %s", res.Raw)
}
@@ -679,7 +679,7 @@ func TestConvertInteractionsResponseToClaude_ResponseFailed(t *testing.T) {
if len(payload) == 0 {
t.Fatalf("expected error event payload, got: %s", string(bytes.Join(events, []byte("\n"))))
}
- if got := gjson.GetBytes(payload, "error.message").String(); !strings.Contains(got, tt.wantMsg) {
+ if got := ojson.GetBytesNoCopy(payload, "error.message").String(); !strings.Contains(got, tt.wantMsg) {
t.Fatalf("error.message = %q, want containing %q", got, tt.wantMsg)
}
})
diff --git a/internal/translator/interactions/claude/interactions_claude_user_turn_test.go b/internal/translator/interactions/claude/interactions_claude_user_turn_test.go
index dd0af55807c..bbc8c6996aa 100644
--- a/internal/translator/interactions/claude/interactions_claude_user_turn_test.go
+++ b/internal/translator/interactions/claude/interactions_claude_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const userTurnUpload = `{"type":"container_upload","file_id":"file-1"}`
@@ -49,7 +49,7 @@ func TestConvertClaudeRequestToInteractions_RefusesAnyEmptiedUserTurn(t *testing
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -62,7 +62,7 @@ func TestConvertClaudeRequestToInteractions_KeepsTurnWithTextBesideAttachment(t
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -73,7 +73,7 @@ func TestConvertClaudeRequestToInteractions_Base64DocumentAfterHistoryStaysMedia
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "input.2.content.0")
if part.Get("type").String() != "document" || part.Get("mime_type").String() != "application/pdf" || part.Get("data").String() != "JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -108,7 +108,7 @@ func TestConvertClaudeRequestToInteractions_RealTextBesideWhitespaceAndAnImageSt
t.Fatalf("err = %v, body = %s", err, body)
}
found := false
- gjson.GetBytes(body, "input.0.content").ForEach(func(_, part gjson.Result) bool {
+ ojson.GetBytesNoCopy(body, "input.0.content").ForEach(func(_, part ojson.Result) bool {
if part.Get("text").String() == "keep me" {
found = true
}
diff --git a/internal/translator/openai/claude/openai_claude_compat_test.go b/internal/translator/openai/claude/openai_claude_compat_test.go
index b79580ed98d..ed908049f9e 100644
--- a/internal/translator/openai/claude/openai_claude_compat_test.go
+++ b/internal/translator/openai/claude/openai_claude_compat_test.go
@@ -3,19 +3,19 @@ package claude
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeRequestToOpenAIWithCompatPreservesEmptySignatureThinking(t *testing.T) {
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
withoutCompat, _ := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false)
- if gjson.GetBytes(withoutCompat, "messages.0.reasoning_content").Exists() {
+ if ojson.GetBytesNoCopy(withoutCompat, "messages.0.reasoning_content").Exists() {
t.Fatalf("default translation preserved empty-signature reasoning: %s", withoutCompat)
}
withCompat, _ := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
- if gjson.GetBytes(withCompat, "messages.0.reasoning_content").String() != "reason" {
+ if ojson.GetBytesNoCopy(withCompat, "messages.0.reasoning_content").String() != "reason" {
t.Fatalf("compat translation missing reasoning_content: %s", withCompat)
}
}
@@ -24,7 +24,7 @@ func TestConvertClaudeRequestToOpenAIWithCompatPreservesThinkingWithToolCalls(t
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""},{"type":"text","text":"Reading files."},{"type":"tool_use","id":"call_1","name":"Read","input":{"path":"main.go"}}]}]}`)
result, _ := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
- assistant := gjson.GetBytes(result, "messages.0")
+ assistant := ojson.GetBytesNoCopy(result, "messages.0")
if got := assistant.Get("reasoning_content").String(); got != "reason" {
t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result)
}
@@ -37,7 +37,7 @@ func TestConvertClaudeRequestToOpenAIWithCompatDoesNotAddReasoningWithoutThinkin
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
result, _ := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
- assistant := gjson.GetBytes(result, "messages.0")
+ assistant := ojson.GetBytesNoCopy(result, "messages.0")
if assistant.Get("reasoning_content").Exists() {
t.Fatalf("compatible translation added reasoning_content without thinking: %s", result)
}
@@ -50,7 +50,7 @@ func TestConvertClaudeRequestToOpenAIWithCompatPreservesIncompatibleThinking(t *
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#opaque"},{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
result, _ := ConvertClaudeRequestToOpenAIWithCompat("deepseek-v4", payload, false)
- assistant := gjson.GetBytes(result, "messages.0")
+ assistant := ojson.GetBytesNoCopy(result, "messages.0")
if got := assistant.Get("reasoning_content").String(); got != "reason" {
t.Fatalf("reasoning_content = %q, want %q; output: %s", got, "reason", result)
}
@@ -63,7 +63,7 @@ func TestConvertClaudeRequestToOpenAIWithoutCompatDoesNotAddReasoningForToolCall
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Read","input":{}}]}]}`)
result, _ := ConvertClaudeRequestToOpenAI("deepseek-v4", payload, false)
- if gjson.GetBytes(result, "messages.0.reasoning_content").Exists() {
+ if ojson.GetBytesNoCopy(result, "messages.0.reasoning_content").Exists() {
t.Fatalf("default translation added reasoning_content: %s", result)
}
}
diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go
index 00ecf40fd03..31db99aa49c 100644
--- a/internal/translator/openai/claude/openai_claude_request.go
+++ b/internal/translator/openai/claude/openai_claude_request.go
@@ -6,15 +6,14 @@
package claude
import (
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"strings"
+ "github.com/MoeForge/moego/ojson"
sigcompat "github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ConvertClaudeRequestToOpenAI parses and transforms an Anthropic API request into OpenAI Chat Completions API format.
@@ -37,41 +36,41 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
rawJSON, _ := sigcompat.RestoreCodexCommentaryMarkers(inputRawJSON)
var drops translatorcommon.UserTurnDrops
// Base OpenAI Chat Completions API template
- out := []byte(`{"model":"","messages":[]}`)
+ outDoc := ojson.NewDocString(`{"model":"","messages":[]}`)
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Model mapping
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Max tokens
if maxTokens := root.Get("max_tokens"); maxTokens.Exists() {
- out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
+ _ = outDoc.Set("max_tokens", maxTokens.Int())
}
// Temperature
if temp := root.Get("temperature"); temp.Exists() {
- out, _ = sjson.SetBytes(out, "temperature", temp.Float())
+ _ = outDoc.Set("temperature", temp.Float())
} else if topP := root.Get("top_p"); topP.Exists() { // Top P
- out, _ = sjson.SetBytes(out, "top_p", topP.Float())
+ _ = outDoc.Set("top_p", topP.Float())
}
// Stop sequences -> stop
if stopSequences := root.Get("stop_sequences"); stopSequences.Exists() {
if stopSequences.IsArray() {
var stops []string
- stopSequences.ForEach(func(_, value gjson.Result) bool {
+ stopSequences.ForEach(func(_, value ojson.Result) bool {
stops = append(stops, value.String())
return true
})
if len(stops) > 0 {
- out, _ = sjson.SetBytes(out, "stop", stops)
+ _ = outDoc.Set("stop", stops)
}
}
}
// Stream
- out, _ = sjson.SetBytes(out, "stream", stream)
+ _ = outDoc.Set("stream", stream)
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
if thinkingConfig := root.Get("thinking"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
@@ -81,33 +80,33 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
if budgetTokens := thinkingConfig.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := thinking.ConvertBudgetToLevel(budget); ok && effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
- } else if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String && strings.TrimSpace(v.String()) != "" {
+ } else if v := root.Get("output_config.effort"); v.Exists() && v.Type == ojson.String && strings.TrimSpace(v.String()) != "" {
// Some Claude-compatible clients pair manual thinking with output_config.effort.
// Preserve that explicit level when there is no legacy token budget to map.
- out, _ = sjson.SetBytes(out, "reasoning_effort", strings.ToLower(strings.TrimSpace(v.String())))
+ _ = outDoc.Set("reasoning_effort", strings.ToLower(strings.TrimSpace(v.String())))
} else {
// No budget_tokens or explicit effort specified; preserve the enabled-thinking default.
if effort, ok := thinking.ConvertBudgetToLevel(-1); ok && effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
}
case "adaptive", "auto":
// Adaptive thinking can carry an explicit effort in output_config.effort (Claude 4.6).
// Pass through directly; ApplyThinking handles clamping to target model's levels.
effort := ""
- if v := root.Get("output_config.effort"); v.Exists() && v.Type == gjson.String {
+ if v := root.Get("output_config.effort"); v.Exists() && v.Type == ojson.String {
effort = strings.ToLower(strings.TrimSpace(v.String()))
}
if effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
} else {
- out, _ = sjson.SetBytes(out, "reasoning_effort", string(thinking.LevelXHigh))
+ _ = outDoc.Set("reasoning_effort", string(thinking.LevelXHigh))
}
case "disabled":
if effort, ok := thinking.ConvertBudgetToLevel(0); ok && effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
}
}
@@ -122,21 +121,21 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Handle system message first.
systemContentItems := make([][]byte, 0, 2)
- appendSystemContent := func(content gjson.Result) {
+ appendSystemContent := func(content ojson.Result) {
if !content.Exists() {
return
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
if content.String() == "" || util.IsClaudeCodeAttributionSystemText(content.String()) {
return
}
oldSystem := []byte(`{"type":"text","text":""}`)
- oldSystem, _ = sjson.SetBytes(oldSystem, "text", content.String())
+ oldSystem, _ = ojson.SetBytes(oldSystem, "text", content.String())
systemContentItems = append(systemContentItems, oldSystem)
return
}
if content.IsArray() {
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
if contentItem, ok := convertClaudeContentPart(item); ok {
systemContentItems = append(systemContentItems, []byte(contentItem))
}
@@ -151,24 +150,24 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Only add system message if it has content.
if len(systemContentItems) > 0 {
systemMessage := []byte(`{"role":"system","content":[]}`)
- systemMessage, _ = sjson.SetRawBytes(systemMessage, "content", translatorcommon.JoinRawArray(systemContentItems))
+ systemMessage, _ = ojson.SetRawBytes(systemMessage, "content", translatorcommon.JoinRawArray(systemContentItems))
messageItems = append(messageItems, systemMessage)
}
// Process Anthropic messages
- var midConversationToolDefs []gjson.Result
+ var midConversationToolDefs []ojson.Result
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
var pendingToolUseIDs []string
var pendingSystemReminders [][]byte
toolNameByID := make(map[string]string)
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
role := message.Get("role").String()
contentResult := message.Get("content")
if role == "system" {
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentResult); ok {
msgJSON := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
- msgJSON, _ = sjson.SetBytes(msgJSON, "content.0.text", reminderText)
+ msgJSON, _ = ojson.SetBytes(msgJSON, "content.0.text", reminderText)
if len(pendingToolUseIDs) > 0 {
pendingSystemReminders = append(pendingSystemReminders, msgJSON)
} else {
@@ -176,7 +175,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
}
if contentResult.IsArray() {
- contentResult.ForEach(func(_, part gjson.Result) bool {
+ contentResult.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() != "tool_addition" {
return true
}
@@ -206,7 +205,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
toolResults := make([][]byte, 0) // Collect tool_result messages to emit after the main message
relayedToolImages := make([][]byte, 0) // Images pulled out of tool_result content for user-message relay
- contentResult.ForEach(func(_, part gjson.Result) bool {
+ contentResult.ForEach(func(_, part ojson.Result) bool {
partType := part.Get("type").String()
switch partType {
@@ -245,32 +244,32 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
toolNameByID[toolUseID] = toolName
}
}
- toolCallJSON := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
- toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "id", toolUseID)
- toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.name", toolName)
+ toolCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ _ = toolCallDoc.Set("id", toolUseID)
+ _ = toolCallDoc.Set("function.name", toolName)
// Convert input to arguments JSON string
if input := part.Get("input"); input.Exists() {
- toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.arguments", input.Raw)
+ _ = toolCallDoc.Set("function.arguments", input.Raw)
} else {
- toolCallJSON, _ = sjson.SetBytes(toolCallJSON, "function.arguments", "{}")
+ _ = toolCallDoc.Set("function.arguments", "{}")
}
- toolCalls = append(toolCalls, gjson.ParseBytes(toolCallJSON).Value())
+ toolCalls = append(toolCalls, util.ParseGJSONBytesNoCopy(toolCallDoc.Bytes()).Value())
}
case "tool_result":
// Collect tool_result to emit after the main message (ensures tool results follow tool_calls)
toolUseID := part.Get("tool_use_id").String()
- toolResultJSON := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
- toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "tool_call_id", toolUseID)
+ toolResultDoc := ojson.NewDocString(`{"role":"tool","tool_call_id":"","content":""}`)
+ _ = toolResultDoc.Set("tool_call_id", toolUseID)
if toolName := toolNameByID[toolUseID]; toolName != "" {
- toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "name", toolName)
+ _ = toolResultDoc.Set("name", toolName)
}
toolResultContent, toolResultImages := convertClaudeToolResultContent(part.Get("content"))
- toolResultJSON, _ = sjson.SetBytes(toolResultJSON, "content", toolResultContent)
+ _ = toolResultDoc.Set("content", toolResultContent)
relayedToolImages = append(relayedToolImages, toolResultImages...)
- toolResults = append(toolResults, toolResultJSON)
+ toolResults = append(toolResults, toolResultDoc.Bytes())
}
return true
})
@@ -305,7 +304,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
if len(relayedToolImages) > 0 {
relayItems := make([][]byte, 0, len(relayedToolImages)+1)
noticeJSON := []byte(`{"type":"text","text":""}`)
- noticeJSON, _ = sjson.SetBytes(noticeJSON, "text", toolResultImageRelayNotice)
+ noticeJSON, _ = ojson.SetBytes(noticeJSON, "text", toolResultImageRelayNotice)
relayItems = append(relayItems, noticeJSON)
relayItems = append(relayItems, relayedToolImages...)
@@ -314,7 +313,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
contentItems = append(relayItems, contentItems...)
} else {
relayJSON := []byte(`{"role":"user"}`)
- relayJSON, _ = sjson.SetRawBytes(relayJSON, "content", translatorcommon.JoinRawArray(relayItems))
+ relayJSON, _ = ojson.SetRawBytes(relayJSON, "content", translatorcommon.JoinRawArray(relayItems))
messageItems = append(messageItems, relayJSON)
}
}
@@ -328,48 +327,48 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// This avoids splitting into multiple assistant messages which breaks OpenAI tool-call adjacency
if role == "assistant" {
if hasContent || hasReasoning || hasToolCalls {
- msgJSON := []byte(`{"role":"assistant"}`)
+ msgDoc := ojson.NewDocString(`{"role":"assistant"}`)
// Add content (as array if we have items, empty string if reasoning-only)
if hasContent {
- msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems))
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
} else {
// Ensure content field exists for OpenAI compatibility
- msgJSON, _ = sjson.SetBytes(msgJSON, "content", "")
+ _ = msgDoc.Set("content", "")
}
// Add reasoning_content if present
if hasReasoning {
- msgJSON, _ = sjson.SetBytes(msgJSON, "reasoning_content", reasoningContent)
+ _ = msgDoc.Set("reasoning_content", reasoningContent)
}
// Add tool_calls if present (in same message as content)
if hasToolCalls {
- msgJSON, _ = sjson.SetBytes(msgJSON, "tool_calls", toolCalls)
+ _ = msgDoc.Set("tool_calls", toolCalls)
}
- messageItems = append(messageItems, msgJSON)
+ messageItems = append(messageItems, msgDoc.Bytes())
}
} else {
// For non-assistant roles: emit content message if we have content
// If the message only contains tool_results (no text/image), we still processed them above
if hasContent {
- msgJSON := []byte(`{"role":""}`)
- msgJSON, _ = sjson.SetBytes(msgJSON, "role", role)
+ msgDoc := ojson.NewDocString(`{"role":""}`)
+ _ = msgDoc.Set("role", role)
- msgJSON, _ = sjson.SetRawBytes(msgJSON, "content", translatorcommon.JoinRawArray(contentItems))
- messageItems = append(messageItems, msgJSON)
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
+ messageItems = append(messageItems, msgDoc.Bytes())
} else if hasToolResults && !hasContent {
// tool_results already emitted above, no additional user message needed
}
}
- } else if contentResult.Exists() && contentResult.Type == gjson.String {
+ } else if contentResult.Exists() && contentResult.Type == ojson.String {
// Simple string content
- msgJSON := []byte(`{"role":"","content":""}`)
- msgJSON, _ = sjson.SetBytes(msgJSON, "role", role)
- msgJSON, _ = sjson.SetBytes(msgJSON, "content", contentResult.String())
- messageItems = append(messageItems, msgJSON)
+ msgDoc := ojson.NewDocString(`{"role":"","content":""}`)
+ _ = msgDoc.Set("role", role)
+ _ = msgDoc.Set("content", contentResult.String())
+ messageItems = append(messageItems, msgDoc.Bytes())
}
return true
@@ -382,7 +381,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Set messages.
if len(messageItems) > 0 {
messageItems = translatorcommon.AlignOpenAIToolCallMessages(messageItems)
- out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "messages", messageItems)
}
// Process tools - convert Anthropic tools (and mid-conversation tool_addition definitions) to OpenAI functions
@@ -392,7 +391,7 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
toolIndexByName = make(map[string]int)
}
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
openAIToolJSON := convertClaudeToolToOpenAI(tool)
if toolIndexByName != nil {
if name := tool.Get("name").String(); name != "" {
@@ -414,62 +413,62 @@ func convertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
}
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
// Tool choice mapping - convert Anthropic tool_choice to OpenAI format
- if toolChoice := root.Get("tool_choice"); toolChoice.Exists() && toolChoice.Type != gjson.Null {
+ if toolChoice := root.Get("tool_choice"); toolChoice.Exists() && toolChoice.Type != ojson.Null {
choiceType := toolChoice.Get("type").String()
- if choiceType == "" && toolChoice.Type == gjson.String {
+ if choiceType == "" && toolChoice.Type == ojson.String {
choiceType = toolChoice.String()
}
switch choiceType {
case "auto":
- out, _ = sjson.SetBytes(out, "tool_choice", "auto")
+ _ = outDoc.Set("tool_choice", "auto")
case "any":
- out, _ = sjson.SetBytes(out, "tool_choice", "required")
+ _ = outDoc.Set("tool_choice", "required")
case "none":
- out, _ = sjson.SetBytes(out, "tool_choice", "none")
+ _ = outDoc.Set("tool_choice", "none")
case "tool":
// Specific tool choice
toolName := toolChoice.Get("name").String()
if toolName != "" {
toolChoiceJSON := []byte(`{"type":"function","function":{"name":""}}`)
- toolChoiceJSON, _ = sjson.SetBytes(toolChoiceJSON, "function.name", toolName)
- out, _ = sjson.SetRawBytes(out, "tool_choice", toolChoiceJSON)
+ toolChoiceJSON, _ = ojson.SetBytes(toolChoiceJSON, "function.name", toolName)
+ _ = outDoc.SetRawBytes("tool_choice", toolChoiceJSON)
} else {
- out, _ = sjson.SetBytes(out, "tool_choice", "none")
+ _ = outDoc.Set("tool_choice", "none")
}
default:
// Fail closed: unrecognized tool_choice values must not turn into permission
- out, _ = sjson.SetBytes(out, "tool_choice", "none")
+ _ = outDoc.Set("tool_choice", "none")
}
- if disableParallel := toolChoice.Get("disable_parallel_tool_use"); disableParallel.Type == gjson.True {
- out, _ = sjson.SetBytes(out, "parallel_tool_calls", false)
+ if disableParallel := toolChoice.Get("disable_parallel_tool_use"); disableParallel.Type == ojson.True {
+ _ = outDoc.Set("parallel_tool_calls", false)
}
}
// Handle user parameter (for tracking)
if user := root.Get("user"); user.Exists() {
- out, _ = sjson.SetBytes(out, "user", user.String())
+ _ = outDoc.Set("user", user.String())
}
- return out, drops.Err()
+ return outDoc.Bytes(), drops.Err()
}
-func convertClaudeToolToOpenAI(tool gjson.Result) []byte {
- openAIToolJSON := []byte(`{"type":"function","function":{"name":"","description":""}}`)
- openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.name", tool.Get("name").String())
- openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.description", tool.Get("description").String())
+func convertClaudeToolToOpenAI(tool ojson.Result) []byte {
+ openAIToolDoc := ojson.NewDocString(`{"type":"function","function":{"name":"","description":""}}`)
+ _ = openAIToolDoc.Set("function.name", tool.Get("name").String())
+ _ = openAIToolDoc.Set("function.description", tool.Get("description").String())
// Convert Anthropic input_schema to OpenAI function parameters
- if inputSchema := tool.Get("input_schema"); inputSchema.Exists() && inputSchema.Type != gjson.Null {
- openAIToolJSON, _ = sjson.SetBytes(openAIToolJSON, "function.parameters", normalizeObjectSchemaProperties(inputSchema.Value()))
+ if inputSchema := tool.Get("input_schema"); inputSchema.Exists() && inputSchema.Type != ojson.Null {
+ _ = openAIToolDoc.Set("function.parameters", normalizeObjectSchemaProperties(inputSchema.Value()))
} else {
- openAIToolJSON, _ = sjson.SetRawBytes(openAIToolJSON, "function.parameters", []byte(`{"type":"object","properties":{}}`))
+ _ = openAIToolDoc.SetRaw("function.parameters", `{"type":"object","properties":{}}`)
}
- return openAIToolJSON
+ return openAIToolDoc.Bytes()
}
func normalizeObjectSchemaProperties(schema any) any {
@@ -544,7 +543,7 @@ func normalizeObjectSchemaProperties(schema any) any {
}
}
-func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveThinkingBlocks ...bool) bool {
+func shouldMapClaudeThinkingToGPTReasoning(part ojson.Result, preserveThinkingBlocks ...bool) bool {
preserveThinking := len(preserveThinkingBlocks) > 0 && preserveThinkingBlocks[0]
if preserveThinking {
return true
@@ -558,7 +557,7 @@ func shouldMapClaudeThinkingToGPTReasoning(part gjson.Result, preserveThinkingBl
return ok
}
-func convertClaudeContentPart(part gjson.Result) (string, bool) {
+func convertClaudeContentPart(part ojson.Result) (string, bool) {
partType := part.Get("type").String()
switch partType {
@@ -568,7 +567,7 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) {
return "", false
}
textContent := []byte(`{"type":"text","text":""}`)
- textContent, _ = sjson.SetBytes(textContent, "text", text)
+ textContent, _ = ojson.SetBytes(textContent, "text", text)
return string(textContent), true
case "image":
@@ -600,7 +599,7 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) {
}
imageContent := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- imageContent, _ = sjson.SetBytes(imageContent, "image_url.url", imageURL)
+ imageContent, _ = ojson.SetBytes(imageContent, "image_url.url", imageURL)
return string(imageContent), true
@@ -614,7 +613,7 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) {
// convertClaudeFilePartToOpenAI emits an OpenAI file part for inline base64 bytes.
// A file id carries none, so it stays unconverted and the caller reports it.
-func convertClaudeFilePartToOpenAI(part gjson.Result) (string, bool) {
+func convertClaudeFilePartToOpenAI(part ojson.Result) (string, bool) {
source := part.Get("source")
mimeType := source.Get("media_type").String()
@@ -634,10 +633,10 @@ func convertClaudeFilePartToOpenAI(part gjson.Result) (string, bool) {
mimeType = "application/octet-stream"
}
- fileContent := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
- fileContent, _ = sjson.SetBytes(fileContent, "file.filename", part.Get("filename").String())
- fileContent, _ = sjson.SetBytes(fileContent, "file.file_data", "data:"+mimeType+";base64,"+base64.StdEncoding.EncodeToString(data))
- return string(fileContent), true
+ fileContentDoc := ojson.NewDocString(`{"type":"file","file":{"filename":"","file_data":""}}`)
+ _ = fileContentDoc.Set("file.filename", part.Get("filename").String())
+ _ = fileContentDoc.Set("file.file_data", base64.EncodeDataURL(mimeType, data))
+ return fileContentDoc.String(), true
}
// toolResultImagePlaceholder keeps the OpenAI tool message non-empty when a Claude
@@ -647,21 +646,21 @@ const toolResultImagePlaceholder = "[Tool returned image content; the images fol
// toolResultImageRelayNotice labels the user message that carries relayed tool images.
const toolResultImageRelayNotice = "Images returned by the preceding tool call(s):"
-func convertClaudeToolResultContent(content gjson.Result) (string, [][]byte) {
+func convertClaudeToolResultContent(content ojson.Result) (string, [][]byte) {
if !content.Exists() {
return "", nil
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String(), nil
}
if content.IsArray() {
var parts []string
var images [][]byte
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
switch {
- case item.Type == gjson.String:
+ case item.Type == ojson.String:
parts = append(parts, item.String())
case item.IsObject() && item.Get("type").String() == "text":
parts = append(parts, item.Get("text").String())
@@ -671,7 +670,7 @@ func convertClaudeToolResultContent(content gjson.Result) (string, [][]byte) {
} else {
parts = append(parts, item.Raw)
}
- case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == gjson.String:
+ case item.IsObject() && item.Get("text").Exists() && item.Get("text").Type == ojson.String:
parts = append(parts, item.Get("text").String())
default:
parts = append(parts, item.Raw)
@@ -695,7 +694,7 @@ func convertClaudeToolResultContent(content gjson.Result) (string, [][]byte) {
return toolResultImagePlaceholder, [][]byte{[]byte(contentItem)}
}
}
- if text := content.Get("text"); text.Exists() && text.Type == gjson.String {
+ if text := content.Get("text"); text.Exists() && text.Type == ojson.String {
return text.String(), nil
}
return content.Raw, nil
diff --git a/internal/translator/openai/claude/openai_claude_request_test.go b/internal/translator/openai/claude/openai_claude_request_test.go
index 932338a2db1..3112f6ada59 100644
--- a/internal/translator/openai/claude/openai_claude_request_test.go
+++ b/internal/translator/openai/claude/openai_claude_request_test.go
@@ -2,13 +2,14 @@ package claude
import (
"context"
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
// TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent tests the mapping
@@ -183,7 +184,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
// Find the relevant message
messages := resultJSON.Get("messages").Array()
@@ -195,7 +196,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
}
// Check the last non-system message
- var targetMsg gjson.Result
+ var targetMsg ojson.Result
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Get("role").String() != "system" {
targetMsg = messages[i]
@@ -222,7 +223,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
switch {
case content.IsArray():
gotHasContent = len(content.Array()) > 0
- case content.Type == gjson.String:
+ case content.Type == ojson.String:
gotHasContent = content.String() != ""
default:
gotHasContent = false
@@ -235,7 +236,7 @@ func TestConvertClaudeRequestToOpenAI_ThinkingToReasoningContent(t *testing.T) {
if tt.wantHasContent && tt.wantContentText != "" {
// Find text content
var foundText string
- content.ForEach(func(_, v gjson.Result) bool {
+ content.ForEach(func(_, v ojson.Result) bool {
if v.Get("type").String() == "text" {
foundText = v.Get("text").String()
return false
@@ -297,7 +298,7 @@ func TestConvertClaudeRequestToOpenAI_SignedThinkingCompatibility(t *testing.T)
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
- assistantMsg := gjson.GetBytes(result, "messages.0")
+ assistantMsg := ojson.GetBytesNoCopy(result, "messages.0")
gotReasoningContent := assistantMsg.Get("reasoning_content").String()
gotHasReasoningContent := assistantMsg.Get("reasoning_content").Exists()
@@ -336,7 +337,7 @@ func TestConvertClaudeRequestToOpenAI_UnsignedThinkingOnlyMessageDropped(t *test
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
@@ -374,7 +375,7 @@ func TestConvertClaudeRequestToOpenAI_MessageSystemRoleWrapsAsUserReminder(t *te
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 6 {
@@ -429,7 +430,7 @@ func TestConvertClaudeRequestToOpenAI_PreservesToolAdjacencyWithInterveningSyste
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5", []byte(inputJSON), false)
- messages := gjson.GetBytes(result, "messages").Array()
+ messages := ojson.GetBytesNoCopy(result, "messages").Array()
// Expected roles order:
// 0: user ("Execute tools")
@@ -524,11 +525,11 @@ func TestConvertClaudeRequestToOpenAI_SystemMessageScenarios(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
hasSys := false
- var sysMsg gjson.Result
+ var sysMsg ojson.Result
if len(messages) > 0 && messages[0].Get("role").String() == "system" {
hasSys = true
sysMsg = messages[0]
@@ -588,7 +589,7 @@ func TestConvertClaudeRequestToOpenAI_ToolSchemaAddsMissingObjectProperties(t *t
}`)
output, _ := ConvertClaudeRequestToOpenAI("test-model", inputJSON, false)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
if got := outputJSON.Get("tools.0.function.parameters.properties"); !got.Exists() || !got.IsObject() {
t.Fatalf("root object properties missing or invalid: %s", outputJSON.Get("tools.0.function.parameters").Raw)
@@ -623,7 +624,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultOrderAndContent(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
// OpenAI requires: tool messages MUST immediately follow assistant(tool_calls).
@@ -680,7 +681,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
// assistant(tool_calls) + tool(result)
@@ -693,7 +694,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultObjectContent(t *testing.T) {
}
toolContent := messages[1].Get("content").String()
- parsed := gjson.Parse(toolContent)
+ parsed := ojson.Parse(toolContent)
if parsed.Get("foo").String() != "bar" {
t.Fatalf("Expected tool content JSON foo=bar, got %q", toolContent)
}
@@ -733,7 +734,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultTextAndImageContent(t *testing.T
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 3 {
@@ -801,7 +802,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultURLImageOnly(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
if len(messages) != 3 {
@@ -859,7 +860,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultImageMergesIntoUserText(t *testi
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
// The relayed image joins the user text instead of adding a second user turn.
@@ -931,7 +932,7 @@ func TestConvertClaudeRequestToOpenAI_MultipleToolResultsWithImages(t *testing.T
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
// Expected: assistant(2 tool calls), tool(call_1), tool(call_2), user(relay 2 images)
@@ -984,7 +985,7 @@ func TestConvertClaudeRequestToOpenAI_AssistantTextToolUseTextOrder(t *testing.T
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
// New behavior: content + tool_calls unified in single assistant message
@@ -1036,7 +1037,7 @@ func TestConvertClaudeRequestToOpenAI_AssistantThinkingToolUseThinkingSplit(t *t
}`
result, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
// Unsigned thinking is dropped, while text and tool_calls remain unified.
@@ -1078,9 +1079,9 @@ func TestConvertClaudeRequestToOpenAI_StripsClaudeCodeAttribution(t *testing.T)
}`)
output, _ := ConvertClaudeRequestToOpenAI("gpt-5", inputJSON, false)
- messages := gjson.GetBytes(output, "messages").Array()
+ messages := ojson.GetBytesNoCopy(output, "messages").Array()
if len(messages) == 0 || messages[0].Get("role").String() != "system" {
- t.Fatalf("Expected first message to be system, got: %s", gjson.GetBytes(output, "messages").Raw)
+ t.Fatalf("Expected first message to be system, got: %s", ojson.GetBytesNoCopy(output, "messages").Raw)
}
content := messages[0].Get("content").Array()
@@ -1121,7 +1122,7 @@ func TestConvertClaudeRequestToOpenAI_StopSequences(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output, _ := ConvertClaudeRequestToOpenAI("gpt-4o", []byte(tt.inputJSON), false)
- stopRes := gjson.GetBytes(output, "stop")
+ stopRes := ojson.GetBytesNoCopy(output, "stop")
if !stopRes.Exists() {
t.Fatalf("expected 'stop' field in output, got: %s", string(output))
}
@@ -1162,7 +1163,7 @@ func TestConvertClaudeRequestToOpenAI_ToolWithoutInputSchemaDefaultsParameters(t
}`)
output, _ := ConvertClaudeRequestToOpenAI("test-model", inputJSON, false)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
for i, toolName := range []string{"web_search", "no_schema_custom", "null_schema_custom"} {
path := fmt.Sprintf("tools.%d.function", i)
@@ -1211,7 +1212,7 @@ func TestConvertClaudeRequestToOpenAI_StripsUnsupportedUnicodePropertyEscapePatt
}`)
output, _ := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
params := outputJSON.Get("tools.0.function.parameters")
if !params.Exists() {
@@ -1263,7 +1264,7 @@ func TestConvertClaudeRequestToOpenAI_PreservesNonSchemaPatternKeys(t *testing.T
}`)
output, _ := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
params := outputJSON.Get("tools.0.function.parameters")
if !params.Exists() {
@@ -1305,7 +1306,7 @@ func TestConvertClaudeRequestToOpenAI_StripsPatternPropertiesIncompatibleKeys(t
}`)
output, _ := ConvertClaudeRequestToOpenAI("gpt-5.6", inputJSON, false)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
params := outputJSON.Get("tools.0.function.parameters")
if !params.Exists() {
@@ -1365,10 +1366,10 @@ func TestConvertClaudeRequestToOpenAI_ToolResultPreservesFunctionName(t *testing
}`
result, _ := ConvertClaudeRequestToOpenAI("gemini-3.8-flash", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
messages := resultJSON.Get("messages").Array()
- var toolMessages []gjson.Result
+ var toolMessages []ojson.Result
for _, msg := range messages {
if msg.Get("role").String() == "tool" {
toolMessages = append(toolMessages, msg)
@@ -1414,7 +1415,7 @@ func TestConvertClaudeRequestToOpenAI_ToolResultUnknownIDNoName(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToOpenAI("gemini-3.8-flash", []byte(inputJSON), false)
- resultJSON := gjson.ParseBytes(result)
+ resultJSON := util.ParseGJSONBytesNoCopy(result)
toolMsg := resultJSON.Get("messages.0")
if got := toolMsg.Get("role").String(); got != "tool" {
@@ -1464,7 +1465,7 @@ func TestConvertClaudeRequestToOpenAI_ToolCallPairingByID(t *testing.T) {
}`
result, _ := ConvertClaudeRequestToOpenAI("deepseek-v4.1-flash", []byte(inputJSON), false)
- messages := gjson.GetBytes(result, "messages").Array()
+ messages := ojson.GetBytesNoCopy(result, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -1523,7 +1524,7 @@ func TestConvertClaudeRequestToOpenAI_ToolCallPairing_OrphanAndIncompletePreserv
}`
result, _ := ConvertClaudeRequestToOpenAI("deepseek-v4.1-flash", []byte(inputJSON), false)
- messages := gjson.GetBytes(result, "messages").Array()
+ messages := ojson.GetBytesNoCopy(result, "messages").Array()
// Incomplete history (call_alpha and call_beta are unanswered) and orphan result
// (call_unmatched has no matching assistant tool_calls) must remain untouched.
@@ -1556,7 +1557,7 @@ func TestConvertClaudeRequestToOpenAI_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5.4", []byte(inputJSON), false)
- gotToolChoice := gjson.GetBytes(result, "tool_choice").String()
+ gotToolChoice := ojson.GetBytesNoCopy(result, "tool_choice").String()
if gotToolChoice != "none" {
t.Fatalf("expected tool_choice to be 'none', got %q. Output: %s", gotToolChoice, result)
}
@@ -1573,7 +1574,7 @@ func TestConvertClaudeRequestToOpenAI_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5.4", []byte(inputJSON), false)
- parallel := gjson.GetBytes(result, "parallel_tool_calls")
+ parallel := ojson.GetBytesNoCopy(result, "parallel_tool_calls")
if !parallel.Exists() || parallel.Bool() {
t.Fatalf("expected parallel_tool_calls to be false, got %v. Output: %s", parallel.Value(), result)
}
@@ -1590,7 +1591,7 @@ func TestConvertClaudeRequestToOpenAI_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5.4", []byte(inputJSON), false)
- gotToolChoice := gjson.GetBytes(result, "tool_choice").String()
+ gotToolChoice := ojson.GetBytesNoCopy(result, "tool_choice").String()
if gotToolChoice != "none" {
t.Fatalf("expected tool_choice to fail closed to 'none', got %q. Output: %s", gotToolChoice, result)
}
@@ -1607,7 +1608,7 @@ func TestConvertClaudeRequestToOpenAI_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5.4", []byte(inputJSON), false)
- gotToolChoice := gjson.GetBytes(result, "tool_choice").String()
+ gotToolChoice := ojson.GetBytesNoCopy(result, "tool_choice").String()
if gotToolChoice != "none" {
t.Fatalf("expected tool_choice to fail closed to 'none', got %q. Output: %s", gotToolChoice, result)
}
@@ -1624,7 +1625,7 @@ func TestConvertClaudeRequestToOpenAI_ToolChoice(t *testing.T) {
]
}`
result, _ := ConvertClaudeRequestToOpenAI("gpt-5.4", []byte(inputJSON), false)
- if gjson.GetBytes(result, "tool_choice").Exists() {
+ if ojson.GetBytesNoCopy(result, "tool_choice").Exists() {
t.Fatalf("expected tool_choice not to be set when tool_choice is null, got: %s", result)
}
})
@@ -1671,7 +1672,7 @@ func TestConvertClaudeRequestToOpenAI_EnabledThinkingEffort(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertClaudeRequestToOpenAI("test-model", []byte(tt.inputJSON), false)
- if got := gjson.GetBytes(out, "reasoning_effort").String(); got != tt.wantEffort {
+ if got := ojson.GetBytesNoCopy(out, "reasoning_effort").String(); got != tt.wantEffort {
t.Fatalf("reasoning_effort = %q, want %q; body=%s", got, tt.wantEffort, out)
}
})
@@ -1710,7 +1711,7 @@ func TestConvertClaudeRequestToOpenAI_NormalizesBooleanSubschemas(t *testing.T)
}`)
output, _ := ConvertClaudeRequestToOpenAI("test-model", inputJSON, false)
- outputJSON := gjson.ParseBytes(output)
+ outputJSON := util.ParseGJSONBytesNoCopy(output)
params := outputJSON.Get("tools.0.function.parameters")
if !params.Exists() {
@@ -1719,7 +1720,7 @@ func TestConvertClaudeRequestToOpenAI_NormalizesBooleanSubschemas(t *testing.T)
// items: true -> items: {}
patchItems := params.Get("properties.patch.items")
- if !patchItems.Exists() || patchItems.Type == gjson.True || patchItems.Raw == "true" {
+ if !patchItems.Exists() || patchItems.Type == ojson.True || patchItems.Raw == "true" {
t.Fatalf("array items boolean subschema not normalized: %s", params.Get("properties.patch").Raw)
}
if !patchItems.IsObject() || patchItems.Raw != "{}" {
@@ -1745,28 +1746,28 @@ func TestConvertClaudeRequestToOpenAI_NormalizesBooleanSubschemas(t *testing.T)
}
// additionalProperties: false must remain boolean false
- if addProps := params.Get("additionalProperties"); !addProps.Exists() || addProps.Type != gjson.False {
+ if addProps := params.Get("additionalProperties"); !addProps.Exists() || addProps.Type != ojson.False {
t.Fatalf("additionalProperties: false must be preserved as boolean false, got: %s", addProps.Raw)
}
// nested_obj.additionalProperties: true must remain boolean true
- if nestedAddProps := params.Get("properties.nested_obj.additionalProperties"); !nestedAddProps.Exists() || nestedAddProps.Type != gjson.True {
+ if nestedAddProps := params.Get("properties.nested_obj.additionalProperties"); !nestedAddProps.Exists() || nestedAddProps.Type != ojson.True {
t.Fatalf("nested additionalProperties: true must be preserved as boolean true, got: %s", nestedAddProps.Raw)
}
// disabled: false must remain boolean false
- if disabled := params.Get("properties.disabled"); !disabled.Exists() || disabled.Type != gjson.False {
+ if disabled := params.Get("properties.disabled"); !disabled.Exists() || disabled.Type != ojson.False {
t.Fatalf("disabled: false must be preserved as boolean false, got: %s", disabled.Raw)
}
// enabled_flag data values default: true and enum: [true, false] must remain boolean data values
- if defVal := params.Get("properties.enabled_flag.default"); !defVal.Exists() || defVal.Type != gjson.True {
+ if defVal := params.Get("properties.enabled_flag.default"); !defVal.Exists() || defVal.Type != ojson.True {
t.Fatalf("enabled_flag.default should remain boolean true, got: %s", defVal.Raw)
}
- if enum0 := params.Get("properties.enabled_flag.enum.0"); !enum0.Exists() || enum0.Type != gjson.True {
+ if enum0 := params.Get("properties.enabled_flag.enum.0"); !enum0.Exists() || enum0.Type != ojson.True {
t.Fatalf("enabled_flag.enum.0 should remain boolean true, got: %s", enum0.Raw)
}
- if enum1 := params.Get("properties.enabled_flag.enum.1"); !enum1.Exists() || enum1.Type != gjson.False {
+ if enum1 := params.Get("properties.enabled_flag.enum.1"); !enum1.Exists() || enum1.Type != ojson.False {
t.Fatalf("enabled_flag.enum.1 should remain boolean false, got: %s", enum1.Raw)
}
}
@@ -1811,7 +1812,7 @@ func TestClaudeBase64DocumentBecomesFilePart(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("err = %v", envelope.Err)
}
- part := gjson.GetBytes(envelope.Body, "messages.0.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "messages.0.content.0")
if part.Get("type").String() != "file" || part.Get("file.file_data").String() != "data:application/pdf;base64,JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -1868,9 +1869,9 @@ func TestConvertClaudeRequestToOpenAI_MidConversationToolAdditionAppendsToTools(
if err != nil {
t.Fatalf("ConvertClaudeRequestToOpenAI returned error: %v", err)
}
- tools := gjson.GetBytes(output, "tools").Array()
+ tools := ojson.GetBytesNoCopy(output, "tools").Array()
if len(tools) != 2 {
- t.Fatalf("expected 2 tools in OpenAI request, got %d: %s", len(tools), gjson.GetBytes(output, "tools").Raw)
+ t.Fatalf("expected 2 tools in OpenAI request, got %d: %s", len(tools), ojson.GetBytesNoCopy(output, "tools").Raw)
}
if got := tools[1].Get("function.name").String(); got != "SubagentHandback" {
t.Fatalf("tools[1].function.name = %q, want SubagentHandback", got)
@@ -1881,7 +1882,7 @@ func TestConvertClaudeRequestToOpenAI_MidConversationToolAdditionAppendsToTools(
openAIResp := []byte(`{"id":"chatcmpl_1","model":"gpt-5","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"subagenthandback","arguments":"{\"summary\":\"done\"}"}}]}}]}`)
claudeResp := ConvertOpenAIResponseToClaudeNonStream(context.Background(), "gpt-5", inputJSON, output, openAIResp, nil)
- if got := gjson.GetBytes(claudeResp, "content.0.name").String(); got != "SubagentHandback" {
+ if got := ojson.GetBytesNoCopy(claudeResp, "content.0.name").String(); got != "SubagentHandback" {
t.Fatalf("ConvertOpenAIResponseToClaudeNonStream tool name = %q, want SubagentHandback; resp=%s", got, claudeResp)
}
}
diff --git a/internal/translator/openai/claude/openai_claude_response.go b/internal/translator/openai/claude/openai_claude_response.go
index 7cdf707bafb..14e18b7a73d 100644
--- a/internal/translator/openai/claude/openai_claude_response.go
+++ b/internal/translator/openai/claude/openai_claude_response.go
@@ -13,10 +13,9 @@ import (
"sort"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
var (
@@ -156,8 +155,8 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR
}
if !state.StreamResolved {
- streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
- state.StreamRequested = streamResult.Exists() && streamResult.Type != gjson.False
+ streamResult := ojson.GetBytesNoCopy(originalRequestRawJSON, "stream")
+ state.StreamRequested = streamResult.Exists() && streamResult.Type != ojson.False
state.StreamResolved = true
}
if !state.StreamRequested {
@@ -188,7 +187,7 @@ func hasValidToolCallArguments(param *ConvertOpenAIResponseToAnthropicParams) bo
continue
}
fixed := util.FixJSON(argsStr)
- if !gjson.Valid(fixed) || !gjson.Parse(fixed).IsObject() {
+ if !ojson.Valid(fixed) || !ojson.Parse(fixed).IsObject() {
return false
}
}
@@ -220,15 +219,15 @@ func terminalOpenAIFinishReason(param *ConvertOpenAIResponseToAnthropicParams) s
// convertOpenAIStreamingChunkToAnthropic converts OpenAI streaming chunk to Anthropic streaming events
func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAIResponseToAnthropicParams) [][]byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
var results [][]byte
// Initialize parameters if needed
if param.MessageID == "" {
- param.MessageID = root.Get("id").String()
+ param.MessageID = strings.Clone(root.Get("id").String())
}
if param.Model == "" {
- param.Model = root.Get("model").String()
+ param.Model = strings.Clone(root.Get("model").String())
}
if param.CreatedAt == 0 {
param.CreatedAt = root.Get("created").Int()
@@ -239,10 +238,10 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
if delta := root.Get("choices.0.delta"); delta.Exists() {
if !param.MessageStarted {
// Send message_start event
- messageStartJSON := []byte(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
- messageStartJSON, _ = sjson.SetBytes(messageStartJSON, "message.id", param.MessageID)
- messageStartJSON, _ = sjson.SetBytes(messageStartJSON, "message.model", param.Model)
- results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_start", messageStartJSON, 2))
+ messageStartDoc := ojson.NewDocString(`{"type":"message_start","message":{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
+ _ = messageStartDoc.Set("message.id", param.MessageID)
+ _ = messageStartDoc.Set("message.model", param.Model)
+ results = append(results, translatorcommon.AppendSSEEventBytes(nil, "message_start", messageStartDoc.Bytes(), 2))
param.MessageStarted = true
// Don't send content_block_start for text here - wait for actual content
@@ -250,77 +249,29 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// Handle reasoning content delta
for _, reasoningText := range collectOpenAIObjectReasoningTexts(delta) {
- if reasoningText == "" {
- continue
- }
- if param.OpenToolCallIndex != -1 {
- if n := len(param.InterleavedContentChunks); n > 0 && param.InterleavedContentChunks[n-1].Type == "thinking" {
- param.InterleavedContentChunks[n-1].Text += reasoningText
- } else {
- param.InterleavedContentChunks = append(param.InterleavedContentChunks, InterleavedContentChunk{
- Type: "thinking",
- Text: reasoningText,
- })
- }
- } else {
- stopTextContentBlock(param, &results)
- if !param.ThinkingContentBlockStarted {
- if param.ThinkingContentBlockIndex == -1 {
- param.ThinkingContentBlockIndex = param.NextContentBlockIndex
- param.NextContentBlockIndex++
- }
- contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`
- contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
- contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.ThinkingContentBlockIndex)
- results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
- param.ThinkingContentBlockStarted = true
- }
-
- thinkingDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`
- thinkingDeltaJSONBytes := []byte(thinkingDeltaJSON)
- thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "index", param.ThinkingContentBlockIndex)
- thinkingDeltaJSONBytes, _ = sjson.SetBytes(thinkingDeltaJSONBytes, "delta.thinking", reasoningText)
- results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", thinkingDeltaJSONBytes, 2))
- }
+ emitStreamingThinkingDelta(param, reasoningText, &results)
}
// Handle content delta
- if content := delta.Get("content"); content.Exists() && content.String() != "" {
- if param.OpenToolCallIndex != -1 {
- // Tool call content block is currently active on the wire.
- // Buffer this text so content blocks remain strictly sequential.
- if n := len(param.InterleavedContentChunks); n > 0 && param.InterleavedContentChunks[n-1].Type == "text" {
- param.InterleavedContentChunks[n-1].Text += content.String()
- } else {
- param.InterleavedContentChunks = append(param.InterleavedContentChunks, InterleavedContentChunk{
- Type: "text",
- Text: content.String(),
- })
- }
- param.ContentAccumulator.WriteString(content.String())
- } else {
- // Send content_block_start for text if not already sent
- if !param.TextContentBlockStarted {
- stopThinkingContentBlock(param, &results)
- if param.TextContentBlockIndex == -1 {
- param.TextContentBlockIndex = param.NextContentBlockIndex
- param.NextContentBlockIndex++
+ if content := delta.Get("content"); content.Exists() {
+ if content.IsArray() {
+ content.ForEach(func(_, part ojson.Result) bool {
+ if part.Type == ojson.String {
+ emitStreamingTextDelta(param, part.String(), &results)
+ return true
}
- contentBlockStartJSON := `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
- contentBlockStartJSONBytes := []byte(contentBlockStartJSON)
- contentBlockStartJSONBytes, _ = sjson.SetBytes(contentBlockStartJSONBytes, "index", param.TextContentBlockIndex)
- results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSONBytes, 2))
- param.TextContentBlockStarted = true
- }
-
- contentDeltaJSON := `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`
- contentDeltaJSONBytes := []byte(contentDeltaJSON)
- contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "index", param.TextContentBlockIndex)
- contentDeltaJSONBytes, _ = sjson.SetBytes(contentDeltaJSONBytes, "delta.text", content.String())
- results = append(results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaJSONBytes, 2))
-
- // Accumulate content
- param.ContentAccumulator.WriteString(content.String())
+ switch part.Get("type").String() {
+ case "thinking", "reasoning":
+ emitStreamingThinkingDelta(param, extractOpenAIContentPartThinkingText(part), &results)
+ case "text":
+ emitStreamingTextDelta(param, part.Get("text").String(), &results)
+ default:
+ emitStreamingTextDelta(param, part.String(), &results)
+ }
+ return true
+ })
+ } else if content.Type == ojson.String && content.String() != "" {
+ emitStreamingTextDelta(param, content.String(), &results)
}
}
@@ -330,9 +281,9 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
param.ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
- toolCalls.ForEach(func(arrayIndex, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(arrayIndex, toolCall ojson.Result) bool {
var incomingID string
- if id := toolCall.Get("id"); id.Exists() && id.Type == gjson.String {
+ if id := toolCall.Get("id"); id.Exists() && id.Type == ojson.String {
incomingID = id.String()
}
@@ -371,7 +322,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// values so malformed upstream fields do not overwrite a
// valid ID or coerce into a content_block.id.
if incomingID != "" && !accumulator.StartEmitted {
- accumulator.ID = incomingID
+ accumulator.ID = strings.Clone(incomingID)
}
// Handle function name and arguments
@@ -381,8 +332,8 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// field across chunks; reassigning after start could drift
// from what was already announced.
if !accumulator.StartEmitted {
- if name := function.Get("name"); name.Exists() && name.Type == gjson.String && name.String() != "" {
- accumulator.Name = util.MapToolName(param.ToolNameMap, name.String())
+ if name := function.Get("name"); name.Exists() && name.Type == ojson.String && name.String() != "" {
+ accumulator.Name = strings.Clone(util.MapToolName(param.ToolNameMap, name.String()))
}
}
@@ -428,7 +379,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
case reason == "tool_calls":
param.FinishReason = "stop"
default:
- param.FinishReason = reason
+ param.FinishReason = strings.Clone(reason)
}
finalizeOpenAIAnthropicContentBlocks(param, &results)
@@ -438,7 +389,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// Cache usage information whenever present
usage := root.Get("usage")
- hasUsage := usage.Exists() && usage.Type != gjson.Null
+ hasUsage := usage.Exists() && usage.Type != ojson.Null
if hasUsage {
param.UsageInputTokens, param.UsageOutputTokens, param.UsageCachedTokens, param.UsageCacheWriteTokens = extractOpenAIUsage(usage)
}
@@ -477,11 +428,11 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams)
// convertOpenAINonStreamingToAnthropic converts OpenAI non-streaming response to Anthropic format
func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
- out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
- out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
- out, _ = sjson.SetBytes(out, "model", root.Get("model").String())
+ outDoc := ojson.NewDocString(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
+ _ = outDoc.Set("id", root.Get("id").String())
+ _ = outDoc.Set("model", root.Get("model").String())
// Process message content and tool calls
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() && len(choices.Array()) > 0 {
@@ -492,68 +443,63 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) [][]byte {
if reasoningText == "" {
continue
}
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", reasoningText)
- contentBlocks = append(contentBlocks, block)
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":""}`)
+ _ = blockDoc.Set("thinking", reasoningText)
+ contentBlocks = append(contentBlocks, blockDoc.Bytes())
}
- // Handle text content
- if content := choice.Get("message.content"); content.Exists() && content.String() != "" {
- block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", content.String())
- contentBlocks = append(contentBlocks, block)
- }
+ hasToolCall := false
+ appendOpenAINonStreamingMessageContent(choice.Get("message.content"), nil, &contentBlocks, &hasToolCall)
// Handle tool calls
- hasToolCall := false
if toolCalls := choice.Get("message.tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
hasToolCall = true
- toolUseBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
- toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "name", toolCall.Get("function.name").String())
+ toolUseDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = toolUseDoc.Set("id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
+ _ = toolUseDoc.Set("name", toolCall.Get("function.name").String())
argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
- if argsStr != "" && gjson.Valid(argsStr) {
- argsJSON := gjson.Parse(argsStr)
+ if argsStr != "" && ojson.Valid(argsStr) {
+ argsJSON := ojson.Parse(argsStr)
if argsJSON.IsObject() {
- toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(argsJSON.Raw))
+ _ = toolUseDoc.SetRaw("input", argsJSON.Raw)
} else {
- toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
+ _ = toolUseDoc.SetRaw("input", `{}`)
}
} else {
- toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
+ _ = toolUseDoc.SetRaw("input", `{}`)
}
- contentBlocks = append(contentBlocks, toolUseBlock)
+ contentBlocks = append(contentBlocks, toolUseDoc.Bytes())
return true
})
}
if len(contentBlocks) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "content", contentBlocks)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "content", contentBlocks)
}
// Set stop reason
if finishReason := choice.Get("finish_reason"); finishReason.Exists() || hasToolCall {
- out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAINonStreamFinishReasonToAnthropic(finishReason.String(), hasToolCall))
+ _ = outDoc.Set("stop_reason", mapOpenAINonStreamFinishReasonToAnthropic(finishReason.String(), hasToolCall))
}
}
// Set usage information
if usage := root.Get("usage"); usage.Exists() {
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(usage)
- out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
+ _ = outDoc.Set("usage.input_tokens", inputTokens)
+ _ = outDoc.Set("usage.output_tokens", outputTokens)
if cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
+ _ = outDoc.Set("usage.cache_read_input_tokens", cachedTokens)
}
if cacheWriteTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens)
+ _ = outDoc.Set("usage.cache_creation_input_tokens", cacheWriteTokens)
}
}
- return [][]byte{out}
+ return [][]byte{outDoc.Bytes()}
}
// mapOpenAIFinishReasonToAnthropic maps OpenAI finish reasons to Anthropic equivalents
@@ -591,7 +537,7 @@ func (p *ConvertOpenAIResponseToAnthropicParams) toolContentBlockIndex(openAIToo
return idx
}
-func collectOpenAIObjectReasoningTexts(obj gjson.Result) []string {
+func collectOpenAIObjectReasoningTexts(obj ojson.Result) []string {
if !obj.Exists() {
return nil
}
@@ -604,14 +550,181 @@ func collectOpenAIObjectReasoningTexts(obj gjson.Result) []string {
return nil
}
-func collectOpenAIReasoningTexts(node gjson.Result) []string {
+func extractOpenAIContentPartThinkingText(part ojson.Result) string {
+ for _, field := range []string{"thinking", "reasoning", "reasoning_content", "text"} {
+ if texts := collectOpenAIReasoningTexts(part.Get(field)); len(texts) > 0 {
+ return strings.Join(texts, "")
+ }
+ }
+ return ""
+}
+
+func emitStreamingThinkingDelta(param *ConvertOpenAIResponseToAnthropicParams, reasoningText string, results *[][]byte) {
+ if reasoningText == "" {
+ return
+ }
+ if param.OpenToolCallIndex != -1 {
+ if n := len(param.InterleavedContentChunks); n > 0 && param.InterleavedContentChunks[n-1].Type == "thinking" {
+ param.InterleavedContentChunks[n-1].Text += reasoningText
+ } else {
+ param.InterleavedContentChunks = append(param.InterleavedContentChunks, InterleavedContentChunk{
+ Type: "thinking",
+ Text: strings.Clone(reasoningText),
+ })
+ }
+ return
+ }
+ stopTextContentBlock(param, results)
+ if !param.ThinkingContentBlockStarted {
+ if param.ThinkingContentBlockIndex == -1 {
+ param.ThinkingContentBlockIndex = param.NextContentBlockIndex
+ param.NextContentBlockIndex++
+ }
+ contentBlockStartDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
+ _ = contentBlockStartDoc.Set("index", param.ThinkingContentBlockIndex)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartDoc.Bytes(), 2))
+ param.ThinkingContentBlockStarted = true
+ }
+
+ thinkingDeltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
+ _ = thinkingDeltaDoc.Set("index", param.ThinkingContentBlockIndex)
+ _ = thinkingDeltaDoc.Set("delta.thinking", reasoningText)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", thinkingDeltaDoc.Bytes(), 2))
+}
+
+func emitStreamingTextDelta(param *ConvertOpenAIResponseToAnthropicParams, text string, results *[][]byte) {
+ if text == "" {
+ return
+ }
+ if param.OpenToolCallIndex != -1 {
+ // Tool call content block is currently active on the wire.
+ // Buffer this text so content blocks remain strictly sequential.
+ if n := len(param.InterleavedContentChunks); n > 0 && param.InterleavedContentChunks[n-1].Type == "text" {
+ param.InterleavedContentChunks[n-1].Text += text
+ } else {
+ param.InterleavedContentChunks = append(param.InterleavedContentChunks, InterleavedContentChunk{
+ Type: "text",
+ Text: strings.Clone(text),
+ })
+ }
+ param.ContentAccumulator.WriteString(text)
+ return
+ }
+ // Send content_block_start for text if not already sent
+ if !param.TextContentBlockStarted {
+ stopThinkingContentBlock(param, results)
+ if param.TextContentBlockIndex == -1 {
+ param.TextContentBlockIndex = param.NextContentBlockIndex
+ param.NextContentBlockIndex++
+ }
+ contentBlockStartDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
+ _ = contentBlockStartDoc.Set("index", param.TextContentBlockIndex)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartDoc.Bytes(), 2))
+ param.TextContentBlockStarted = true
+ }
+
+ contentDeltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
+ _ = contentDeltaDoc.Set("index", param.TextContentBlockIndex)
+ _ = contentDeltaDoc.Set("delta.text", text)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", contentDeltaDoc.Bytes(), 2))
+
+ // Accumulate content
+ param.ContentAccumulator.WriteString(text)
+}
+
+func appendOpenAINonStreamingMessageContent(contentResult ojson.Result, toolNameMap map[string]string, blocks *[][]byte, hasToolCall *bool) {
+ if !contentResult.Exists() {
+ return
+ }
+ if contentResult.IsArray() {
+ var textBuilder strings.Builder
+ var thinkingBuilder strings.Builder
+
+ flushText := func() {
+ if textBuilder.Len() == 0 {
+ return
+ }
+ blockDoc := ojson.NewDocString(`{"type":"text","text":""}`)
+ _ = blockDoc.Set("text", textBuilder.String())
+ *blocks = append(*blocks, blockDoc.Bytes())
+ textBuilder.Reset()
+ }
+
+ flushThinking := func() {
+ if thinkingBuilder.Len() == 0 {
+ return
+ }
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":""}`)
+ _ = blockDoc.Set("thinking", thinkingBuilder.String())
+ *blocks = append(*blocks, blockDoc.Bytes())
+ thinkingBuilder.Reset()
+ }
+
+ for _, item := range contentResult.Array() {
+ switch item.Get("type").String() {
+ case "text":
+ if text := item.Get("text").String(); text != "" {
+ flushThinking()
+ textBuilder.WriteString(text)
+ }
+ case "tool_calls":
+ flushThinking()
+ flushText()
+ toolCalls := item.Get("tool_calls")
+ if toolCalls.IsArray() {
+ toolCalls.ForEach(func(_, tc ojson.Result) bool {
+ *hasToolCall = true
+ toolUseDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = toolUseDoc.Set("id", util.SanitizeClaudeToolID(tc.Get("id").String()))
+ _ = toolUseDoc.Set("name", util.MapToolName(toolNameMap, tc.Get("function.name").String()))
+
+ argsStr := util.FixJSON(tc.Get("function.arguments").String())
+ if argsStr != "" && ojson.Valid(argsStr) {
+ argsJSON := ojson.Parse(argsStr)
+ if argsJSON.IsObject() {
+ _ = toolUseDoc.SetRaw("input", argsJSON.Raw)
+ } else {
+ _ = toolUseDoc.SetRaw("input", `{}`)
+ }
+ } else {
+ _ = toolUseDoc.SetRaw("input", `{}`)
+ }
+
+ *blocks = append(*blocks, toolUseDoc.Bytes())
+ return true
+ })
+ }
+ case "reasoning", "thinking":
+ if thinkingText := extractOpenAIContentPartThinkingText(item); thinkingText != "" {
+ flushText()
+ thinkingBuilder.WriteString(thinkingText)
+ }
+ default:
+ flushThinking()
+ flushText()
+ }
+ }
+
+ flushThinking()
+ flushText()
+ } else if contentResult.Type == ojson.String {
+ textContent := contentResult.String()
+ if textContent != "" {
+ blockDoc := ojson.NewDocString(`{"type":"text","text":""}`)
+ _ = blockDoc.Set("text", textContent)
+ *blocks = append(*blocks, blockDoc.Bytes())
+ }
+ }
+}
+
+func collectOpenAIReasoningTexts(node ojson.Result) []string {
var texts []string
if !node.Exists() {
return texts
}
if node.IsArray() {
- node.ForEach(func(_, value gjson.Result) bool {
+ node.ForEach(func(_, value ojson.Result) bool {
texts = append(texts, collectOpenAIReasoningTexts(value)...)
return true
})
@@ -619,11 +732,11 @@ func collectOpenAIReasoningTexts(node gjson.Result) []string {
}
switch node.Type {
- case gjson.String:
+ case ojson.String:
if text := node.String(); text != "" {
texts = append(texts, text)
}
- case gjson.JSON:
+ case ojson.JSON:
if text := node.Get("text"); text.Exists() {
if textStr := text.String(); textStr != "" {
texts = append(texts, textStr)
@@ -641,7 +754,7 @@ func stopThinkingContentBlock(param *ConvertOpenAIResponseToAnthropicParams, res
return
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
- contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
+ contentBlockStopJSON, _ = ojson.SetBytes(contentBlockStopJSON, "index", param.ThinkingContentBlockIndex)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
param.ThinkingContentBlockStarted = false
param.ThinkingContentBlockIndex = -1
@@ -660,7 +773,7 @@ func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results
return
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
- contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", param.TextContentBlockIndex)
+ contentBlockStopJSON, _ = ojson.SetBytes(contentBlockStopJSON, "index", param.TextContentBlockIndex)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
param.TextContentBlockStarted = false
param.TextContentBlockIndex = -1
@@ -671,11 +784,11 @@ func emitToolUseStart(param *ConvertOpenAIResponseToAnthropicParams, openAIToolI
stopTextContentBlock(param, results)
blockIndex := param.toolContentBlockIndex(openAIToolIndex)
- contentBlockStartJSON := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
- contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "index", blockIndex)
- contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.id", util.SanitizeClaudeToolID(accumulator.ID))
- contentBlockStartJSON, _ = sjson.SetBytes(contentBlockStartJSON, "content_block.name", accumulator.Name)
- *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartJSON, 2))
+ contentBlockStartDoc := ojson.NewDocString(`{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`)
+ _ = contentBlockStartDoc.Set("index", blockIndex)
+ _ = contentBlockStartDoc.Set("content_block.id", util.SanitizeClaudeToolID(accumulator.ID))
+ _ = contentBlockStartDoc.Set("content_block.name", accumulator.Name)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", contentBlockStartDoc.Bytes(), 2))
accumulator.StartEmitted = true
param.SawToolCall = true
param.OpenToolCallIndex = openAIToolIndex
@@ -718,14 +831,14 @@ func finalizeSingleToolCall(param *ConvertOpenAIResponseToAnthropicParams, openA
// Send complete input_json_delta with all accumulated arguments
if accumulator.Arguments.Len() > 0 {
- inputDeltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
- inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "index", blockIndex)
- inputDeltaJSON, _ = sjson.SetBytes(inputDeltaJSON, "delta.partial_json", util.FixJSON(accumulator.Arguments.String()))
- *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", inputDeltaJSON, 2))
+ inputDeltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":""}}`)
+ _ = inputDeltaDoc.Set("index", blockIndex)
+ _ = inputDeltaDoc.Set("delta.partial_json", util.FixJSON(accumulator.Arguments.String()))
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", inputDeltaDoc.Bytes(), 2))
}
contentBlockStopJSON := []byte(`{"type":"content_block_stop","index":0}`)
- contentBlockStopJSON, _ = sjson.SetBytes(contentBlockStopJSON, "index", blockIndex)
+ contentBlockStopJSON, _ = ojson.SetBytes(contentBlockStopJSON, "index", blockIndex)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", contentBlockStopJSON, 2))
delete(param.ToolCallBlockIndexes, openAIToolIndex)
param.OpenToolCallIndex = -1
@@ -745,29 +858,29 @@ func emitBufferedInterleavedContent(param *ConvertOpenAIResponseToAnthropicParam
switch chunk.Type {
case "thinking":
startJSON := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}`)
- startJSON, _ = sjson.SetBytes(startJSON, "index", idx)
+ startJSON, _ = ojson.SetBytes(startJSON, "index", idx)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", startJSON, 2))
- deltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
- deltaJSON, _ = sjson.SetBytes(deltaJSON, "index", idx)
- deltaJSON, _ = sjson.SetBytes(deltaJSON, "delta.thinking", chunk.Text)
- *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", deltaJSON, 2))
+ deltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}`)
+ _ = deltaDoc.Set("index", idx)
+ _ = deltaDoc.Set("delta.thinking", chunk.Text)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", deltaDoc.Bytes(), 2))
stopJSON := []byte(`{"type":"content_block_stop","index":0}`)
- stopJSON, _ = sjson.SetBytes(stopJSON, "index", idx)
+ stopJSON, _ = ojson.SetBytes(stopJSON, "index", idx)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", stopJSON, 2))
case "text":
startJSON := []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`)
- startJSON, _ = sjson.SetBytes(startJSON, "index", idx)
+ startJSON, _ = ojson.SetBytes(startJSON, "index", idx)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_start", startJSON, 2))
- deltaJSON := []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
- deltaJSON, _ = sjson.SetBytes(deltaJSON, "index", idx)
- deltaJSON, _ = sjson.SetBytes(deltaJSON, "delta.text", chunk.Text)
- *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", deltaJSON, 2))
+ deltaDoc := ojson.NewDocString(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":""}}`)
+ _ = deltaDoc.Set("index", idx)
+ _ = deltaDoc.Set("delta.text", chunk.Text)
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_delta", deltaDoc.Bytes(), 2))
stopJSON := []byte(`{"type":"content_block_stop","index":0}`)
- stopJSON, _ = sjson.SetBytes(stopJSON, "index", idx)
+ stopJSON, _ = ojson.SetBytes(stopJSON, "index", idx)
*results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "content_block_stop", stopJSON, 2))
}
}
@@ -803,17 +916,17 @@ func emitAnthropicMessageDelta(param *ConvertOpenAIResponseToAnthropicParams, re
if param == nil || param.MessageDeltaSent {
return
}
- messageDeltaJSON := []byte(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
- messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "delta.stop_reason", mapOpenAIFinishReasonToAnthropic(terminalOpenAIFinishReason(param)))
- messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.input_tokens", inputTokens)
- messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.output_tokens", outputTokens)
+ messageDeltaDoc := ojson.NewDocString(`{"type":"message_delta","delta":{"stop_reason":"","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
+ _ = messageDeltaDoc.Set("delta.stop_reason", mapOpenAIFinishReasonToAnthropic(terminalOpenAIFinishReason(param)))
+ _ = messageDeltaDoc.Set("usage.input_tokens", inputTokens)
+ _ = messageDeltaDoc.Set("usage.output_tokens", outputTokens)
if cachedTokens > 0 {
- messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_read_input_tokens", cachedTokens)
+ _ = messageDeltaDoc.Set("usage.cache_read_input_tokens", cachedTokens)
}
if cacheWriteTokens > 0 {
- messageDeltaJSON, _ = sjson.SetBytes(messageDeltaJSON, "usage.cache_creation_input_tokens", cacheWriteTokens)
+ _ = messageDeltaDoc.Set("usage.cache_creation_input_tokens", cacheWriteTokens)
}
- *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaJSON, 2))
+ *results = append(*results, translatorcommon.AppendSSEEventBytes(nil, "message_delta", messageDeltaDoc.Bytes(), 2))
param.MessageDeltaSent = true
}
@@ -839,11 +952,11 @@ func toolCallAccumulatorIndexes(accumulators map[int]*ToolCallAccumulator) []int
func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
- out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
- out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
- out, _ = sjson.SetBytes(out, "model", root.Get("model").String())
+ outDoc := ojson.NewDocString(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
+ _ = outDoc.Set("id", root.Get("id").String())
+ _ = outDoc.Set("model", root.Get("model").String())
hasToolCall := false
finishReason := ""
@@ -854,115 +967,37 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
finishReason = choice.Get("finish_reason").String()
if message := choice.Get("message"); message.Exists() {
- if contentResult := message.Get("content"); contentResult.Exists() {
- if contentResult.IsArray() {
- var textBuilder strings.Builder
- var thinkingBuilder strings.Builder
-
- flushText := func() {
- if textBuilder.Len() == 0 {
- return
- }
- block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", textBuilder.String())
- blocks = append(blocks, block)
- textBuilder.Reset()
- }
-
- flushThinking := func() {
- if thinkingBuilder.Len() == 0 {
- return
- }
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
- blocks = append(blocks, block)
- thinkingBuilder.Reset()
- }
-
- for _, item := range contentResult.Array() {
- switch item.Get("type").String() {
- case "text":
- flushThinking()
- textBuilder.WriteString(item.Get("text").String())
- case "tool_calls":
- flushThinking()
- flushText()
- toolCalls := item.Get("tool_calls")
- if toolCalls.IsArray() {
- toolCalls.ForEach(func(_, tc gjson.Result) bool {
- hasToolCall = true
- toolUse := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUse, _ = sjson.SetBytes(toolUse, "id", util.SanitizeClaudeToolID(tc.Get("id").String()))
- toolUse, _ = sjson.SetBytes(toolUse, "name", util.MapToolName(toolNameMap, tc.Get("function.name").String()))
-
- argsStr := util.FixJSON(tc.Get("function.arguments").String())
- if argsStr != "" && gjson.Valid(argsStr) {
- argsJSON := gjson.Parse(argsStr)
- if argsJSON.IsObject() {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(argsJSON.Raw))
- } else {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(`{}`))
- }
- } else {
- toolUse, _ = sjson.SetRawBytes(toolUse, "input", []byte(`{}`))
- }
-
- blocks = append(blocks, toolUse)
- return true
- })
- }
- case "reasoning":
- flushText()
- if thinking := item.Get("text"); thinking.Exists() {
- thinkingBuilder.WriteString(thinking.String())
- }
- default:
- flushThinking()
- flushText()
- }
- }
-
- flushThinking()
- flushText()
- } else if contentResult.Type == gjson.String {
- textContent := contentResult.String()
- if textContent != "" {
- block := []byte(`{"type":"text","text":""}`)
- block, _ = sjson.SetBytes(block, "text", textContent)
- blocks = append(blocks, block)
- }
- }
- }
-
for _, reasoningText := range collectOpenAIObjectReasoningTexts(message) {
if reasoningText == "" {
continue
}
- block := []byte(`{"type":"thinking","thinking":""}`)
- block, _ = sjson.SetBytes(block, "thinking", reasoningText)
- blocks = append(blocks, block)
+ blockDoc := ojson.NewDocString(`{"type":"thinking","thinking":""}`)
+ _ = blockDoc.Set("thinking", reasoningText)
+ blocks = append(blocks, blockDoc.Bytes())
}
+ appendOpenAINonStreamingMessageContent(message.Get("content"), toolNameMap, &blocks, &hasToolCall)
+
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
hasToolCall = true
- toolUseBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
- toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
- toolUseBlock, _ = sjson.SetBytes(toolUseBlock, "name", util.MapToolName(toolNameMap, toolCall.Get("function.name").String()))
+ toolUseDoc := ojson.NewDocString(`{"type":"tool_use","id":"","name":"","input":{}}`)
+ _ = toolUseDoc.Set("id", util.SanitizeClaudeToolID(toolCall.Get("id").String()))
+ _ = toolUseDoc.Set("name", util.MapToolName(toolNameMap, toolCall.Get("function.name").String()))
argsStr := util.FixJSON(toolCall.Get("function.arguments").String())
- if argsStr != "" && gjson.Valid(argsStr) {
- argsJSON := gjson.Parse(argsStr)
+ if argsStr != "" && ojson.Valid(argsStr) {
+ argsJSON := ojson.Parse(argsStr)
if argsJSON.IsObject() {
- toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(argsJSON.Raw))
+ _ = toolUseDoc.SetRaw("input", argsJSON.Raw)
} else {
- toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
+ _ = toolUseDoc.SetRaw("input", `{}`)
}
} else {
- toolUseBlock, _ = sjson.SetRawBytes(toolUseBlock, "input", []byte(`{}`))
+ _ = toolUseDoc.SetRaw("input", `{}`)
}
- blocks = append(blocks, toolUseBlock)
+ blocks = append(blocks, toolUseDoc.Bytes())
return true
})
}
@@ -970,32 +1005,32 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
}
if len(blocks) > 0 {
- out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
+ _ = outDoc.SetRawBytes("content", translatorcommon.JoinRawArray(blocks))
}
if respUsage := root.Get("usage"); respUsage.Exists() {
inputTokens, outputTokens, cachedTokens, cacheWriteTokens := extractOpenAIUsage(respUsage)
- out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
+ _ = outDoc.Set("usage.input_tokens", inputTokens)
+ _ = outDoc.Set("usage.output_tokens", outputTokens)
if cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_read_input_tokens", cachedTokens)
+ _ = outDoc.Set("usage.cache_read_input_tokens", cachedTokens)
}
if cacheWriteTokens > 0 {
- out, _ = sjson.SetBytes(out, "usage.cache_creation_input_tokens", cacheWriteTokens)
+ _ = outDoc.Set("usage.cache_creation_input_tokens", cacheWriteTokens)
}
}
- out, _ = sjson.SetBytes(out, "stop_reason", mapOpenAINonStreamFinishReasonToAnthropic(finishReason, hasToolCall))
+ _ = outDoc.Set("stop_reason", mapOpenAINonStreamFinishReasonToAnthropic(finishReason, hasToolCall))
- return out
+ return outDoc.Bytes()
}
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.ClaudeInputTokensJSON(count)
}
-func extractOpenAIUsage(usage gjson.Result) (int64, int64, int64, int64) {
- if !usage.Exists() || usage.Type == gjson.Null {
+func extractOpenAIUsage(usage ojson.Result) (int64, int64, int64, int64) {
+ if !usage.Exists() || usage.Type == ojson.Null {
return 0, 0, 0, 0
}
diff --git a/internal/translator/openai/claude/openai_claude_response_test.go b/internal/translator/openai/claude/openai_claude_response_test.go
index 48b865207df..457667d16cb 100644
--- a/internal/translator/openai/claude/openai_claude_response_test.go
+++ b/internal/translator/openai/claude/openai_claude_response_test.go
@@ -7,7 +7,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type sseEvent struct {
@@ -76,7 +77,7 @@ func toolUseStarts(events []sseEvent) []sseEvent {
if e.Type != "content_block_start" {
continue
}
- if gjson.Get(e.Payload, "content_block.type").String() == "tool_use" {
+ if ojson.Get(e.Payload, "content_block.type").String() == "tool_use" {
out = append(out, e)
}
}
@@ -87,7 +88,7 @@ func blockIndices(events []sseEvent) []int64 {
var idx []int64
for _, e := range events {
if e.Type == "content_block_start" {
- idx = append(idx, gjson.Get(e.Payload, "index").Int())
+ idx = append(idx, ojson.Get(e.Payload, "index").Int())
}
}
return idx
@@ -96,7 +97,7 @@ func blockIndices(events []sseEvent) []int64 {
func lastStopReason(events []sseEvent) string {
for i := len(events) - 1; i >= 0; i-- {
if events[i].Type == "message_delta" {
- return gjson.Get(events[i].Payload, "delta.stop_reason").String()
+ return ojson.Get(events[i].Payload, "delta.stop_reason").String()
}
}
return ""
@@ -176,21 +177,21 @@ func TestStreaming_SplitUsageFinishReasonThenTrailingUsageChunk_PreservesCachedT
t.Fatalf("missing message_delta event in %+v", events)
}
- if gotStop := gjson.Get(deltaPayload, "delta.stop_reason").String(); gotStop != tt.wantStopType {
+ if gotStop := ojson.Get(deltaPayload, "delta.stop_reason").String(); gotStop != tt.wantStopType {
t.Fatalf("stop_reason = %q, want %q", gotStop, tt.wantStopType)
}
- cached := gjson.Get(deltaPayload, "usage.cache_read_input_tokens").Int()
+ cached := ojson.Get(deltaPayload, "usage.cache_read_input_tokens").Int()
if cached != 139136 {
t.Fatalf("expected cache_read_input_tokens = 139136, got %d (payload=%s)", cached, deltaPayload)
}
- input := gjson.Get(deltaPayload, "usage.input_tokens").Int()
+ input := ojson.Get(deltaPayload, "usage.input_tokens").Int()
if input != 889 {
t.Fatalf("expected deducted input_tokens = 889, got %d (payload=%s)", input, deltaPayload)
}
- output := gjson.Get(deltaPayload, "usage.output_tokens").Int()
+ output := ojson.Get(deltaPayload, "usage.output_tokens").Int()
if output != 741 {
t.Fatalf("expected output_tokens = 741, got %d (payload=%s)", output, deltaPayload)
}
@@ -243,10 +244,10 @@ func TestStreamingTool_EmptyNameThroughout(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected one tool_use content_block_start with synthetic name, got %d (events=%+v)", len(starts), events)
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
}
if got := countByType(events, "content_block_delta"); got != 1 {
@@ -269,10 +270,10 @@ func TestStreamingTool_NullName(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("null name with id should belated-emit synthetic tool name; got %d", len(starts))
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_a" {
t.Fatalf("announced tool id = %q, want %q", id, "call_a")
}
if got := countByType(events, "content_block_stop"); got != 1 {
@@ -292,7 +293,7 @@ func TestStreamingTool_NonStringName(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("non-string name with id should belated-emit synthetic tool name; got %d", len(starts))
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
}
@@ -309,7 +310,7 @@ func TestStreamingTool_RepeatedName(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start, got %d", len(starts))
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
}
if got := countByType(events, "content_block_stop"); got != 1 {
@@ -334,10 +335,10 @@ func TestStreamingTool_MixedEmptyNameAndValid(t *testing.T) {
t.Fatalf("expected two tool_use starts (valid mid-stream + synthetic empty-name), got %d", len(starts))
}
// Valid name+id is emitted mid-stream first; empty-name is belated at finish.
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("first tool name = %q, want %q", name, "do_it")
}
- if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "tool_0" {
+ if name := ojson.Get(starts[1].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("second tool name = %q, want %q", name, "tool_0")
}
if got := countByType(events, "content_block_stop"); got != 2 {
@@ -374,7 +375,7 @@ func TestStreamingTool_EmptyIDDeferStart(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start once id arrived, got %d", len(starts))
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
t.Fatalf("announced tool id = %q, want %q", id, "call_real")
}
}
@@ -391,10 +392,10 @@ func TestStreamingTool_IDInDeltaWithoutFunction(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected exactly one tool_use start when id arrives in a function-less delta, got %d", len(starts))
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_real" {
t.Fatalf("announced tool id = %q, want %q", id, "call_real")
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
}
if got := countByType(events, "content_block_stop"); got != 1 {
@@ -423,11 +424,11 @@ func TestStreamingTool_StopReasonWhenIDNeverArrives(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected one belated tool_use start with synthetic id, got %d", len(starts))
}
- id := gjson.Get(starts[0].Payload, "content_block.id").String()
+ id := ojson.Get(starts[0].Payload, "content_block.id").String()
if !strings.HasPrefix(id, "toolu_") {
t.Fatalf("synthetic id should match toolu__, got %q", id)
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "do_it" {
t.Fatalf("announced tool name = %q, want %q", name, "do_it")
}
if got := lastStopReason(events); got != "tool_use" {
@@ -452,10 +453,10 @@ func TestStreamingTool_BelatedStartsUseOpenAIToolIndexOrder(t *testing.T) {
wantNames := []string{"first_tool", "second_tool", "third_tool"}
for i, wantName := range wantNames {
- if name := gjson.Get(starts[i].Payload, "content_block.name").String(); name != wantName {
+ if name := ojson.Get(starts[i].Payload, "content_block.name").String(); name != wantName {
t.Fatalf("tool_use start %d name = %q, want %q (starts=%+v)", i, name, wantName, starts)
}
- if blockIndex := gjson.Get(starts[i].Payload, "index").Int(); blockIndex != int64(i) {
+ if blockIndex := ojson.Get(starts[i].Payload, "index").Int(); blockIndex != int64(i) {
t.Fatalf("tool_use start %d block index = %d, want %d", i, blockIndex, i)
}
}
@@ -513,10 +514,10 @@ func TestStreamingTool_EmptyNameArgsOnlyNoID(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected one belated tool_use start for empty-name args-only call, got %d", len(starts))
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "tool_0" {
t.Fatalf("announced tool name = %q, want %q", name, "tool_0")
}
- id := gjson.Get(starts[0].Payload, "content_block.id").String()
+ id := ojson.Get(starts[0].Payload, "content_block.id").String()
if !strings.HasPrefix(id, "toolu_") {
t.Fatalf("synthetic id should match toolu__, got %q", id)
}
@@ -582,10 +583,10 @@ func TestStreamingTool_UsageWithoutFinishReasonEmitsMessageDelta(t *testing.T) {
if deltaEvent == nil {
t.Fatalf("missing message_delta event")
}
- if input := gjson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != 10 {
+ if input := ojson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != 10 {
t.Fatalf("input_tokens = %d, want 10", input)
}
- if output := gjson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != 5 {
+ if output := ojson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != 5 {
t.Fatalf("output_tokens = %d, want 5", output)
}
if got := countByType(events, "message_stop"); got != 1 {
@@ -604,16 +605,16 @@ func TestStreamingTool_PerChunkUsagePreservesToolArguments(t *testing.T) {
if len(starts) != 1 {
t.Fatalf("expected one tool_use start, got %d (starts=%+v)", len(starts), starts)
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "Skill" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "Skill" {
t.Fatalf("tool name = %q, want %q", name, "Skill")
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_1" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_1" {
t.Fatalf("tool id = %q, want %q", id, "call_1")
}
var deltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
deltas = append(deltas, e)
}
}
@@ -623,7 +624,7 @@ func TestStreamingTool_PerChunkUsagePreservesToolArguments(t *testing.T) {
var mergedArgs strings.Builder
for _, d := range deltas {
- mergedArgs.WriteString(gjson.Get(d.Payload, "delta.partial_json").String())
+ mergedArgs.WriteString(ojson.Get(d.Payload, "delta.partial_json").String())
}
if merged := mergedArgs.String(); merged != `{"skill": "stop-slop"}` {
t.Fatalf("merged arguments = %q, want %q", merged, `{"skill": "stop-slop"}`)
@@ -649,10 +650,10 @@ func TestStreamingTool_PerChunkUsagePreservesToolArguments(t *testing.T) {
if deltaEvent == nil {
t.Fatalf("missing message_delta event")
}
- if input := gjson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != 191 {
+ if input := ojson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != 191 {
t.Fatalf("input_tokens = %d, want 191", input)
}
- if output := gjson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != 15 {
+ if output := ojson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != 15 {
t.Fatalf("output_tokens = %d, want 15", output)
}
}
@@ -668,13 +669,13 @@ func TestStreamingTool_PerChunkUsageOmittedFinishReasonPreservesToolArguments(t
if len(starts) != 1 {
t.Fatalf("expected one tool_use start, got %d (starts=%+v)", len(starts), starts)
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "Skill" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "Skill" {
t.Fatalf("tool name = %q, want %q", name, "Skill")
}
var deltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
deltas = append(deltas, e)
}
}
@@ -684,7 +685,7 @@ func TestStreamingTool_PerChunkUsageOmittedFinishReasonPreservesToolArguments(t
var mergedArgs strings.Builder
for _, d := range deltas {
- mergedArgs.WriteString(gjson.Get(d.Payload, "delta.partial_json").String())
+ mergedArgs.WriteString(ojson.Get(d.Payload, "delta.partial_json").String())
}
if merged := mergedArgs.String(); merged != `{"skill": "stop-slop"}` {
t.Fatalf("merged arguments = %q, want %q", merged, `{"skill": "stop-slop"}`)
@@ -710,10 +711,10 @@ func TestStreamingTool_PerChunkUsageOmittedFinishReasonPreservesToolArguments(t
if deltaEvent == nil {
t.Fatalf("missing message_delta event")
}
- if input := gjson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != 191 {
+ if input := ojson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != 191 {
t.Fatalf("input_tokens = %d, want 191", input)
}
- if output := gjson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != 15 {
+ if output := ojson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != 15 {
t.Fatalf("output_tokens = %d, want 15", output)
}
}
@@ -731,22 +732,22 @@ func TestStreamingTool_OmittedToolCallIndexPreservesParallelCalls(t *testing.T)
t.Fatalf("expected two tool_use starts, got %d (starts=%+v)", len(starts), starts)
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_weather" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_weather" {
t.Fatalf("first tool id = %q, want %q", id, "call_weather")
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "get_weather" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "get_weather" {
t.Fatalf("first tool name = %q, want %q", name, "get_weather")
}
- if id := gjson.Get(starts[1].Payload, "content_block.id").String(); id != "call_time" {
+ if id := ojson.Get(starts[1].Payload, "content_block.id").String(); id != "call_time" {
t.Fatalf("second tool id = %q, want %q", id, "call_time")
}
- if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "get_time" {
+ if name := ojson.Get(starts[1].Payload, "content_block.name").String(); name != "get_time" {
t.Fatalf("second tool name = %q, want %q", name, "get_time")
}
var deltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
deltas = append(deltas, e)
}
}
@@ -754,20 +755,20 @@ func TestStreamingTool_OmittedToolCallIndexPreservesParallelCalls(t *testing.T)
t.Fatalf("expected two input_json_delta events, got %d (deltas=%+v)", len(deltas), deltas)
}
- firstJSON := gjson.Get(deltas[0].Payload, "delta.partial_json").String()
- secondJSON := gjson.Get(deltas[1].Payload, "delta.partial_json").String()
+ firstJSON := ojson.Get(deltas[0].Payload, "delta.partial_json").String()
+ secondJSON := ojson.Get(deltas[1].Payload, "delta.partial_json").String()
- if !gjson.Valid(firstJSON) {
+ if !ojson.Valid(firstJSON) {
t.Fatalf("first input_json_delta is not valid JSON: %q", firstJSON)
}
- if !gjson.Valid(secondJSON) {
+ if !ojson.Valid(secondJSON) {
t.Fatalf("second input_json_delta is not valid JSON: %q", secondJSON)
}
- if gotCity := gjson.Get(firstJSON, "city").String(); gotCity != "Paris" {
+ if gotCity := ojson.Get(firstJSON, "city").String(); gotCity != "Paris" {
t.Fatalf("first tool args city = %q, want %q", gotCity, "Paris")
}
- if gotTz := gjson.Get(secondJSON, "tz").String(); gotTz != "UTC" {
+ if gotTz := ojson.Get(secondJSON, "tz").String(); gotTz != "UTC" {
t.Fatalf("second tool args tz = %q, want %q", gotTz, "UTC")
}
@@ -856,20 +857,20 @@ func TestStreamingUsage_PreservesCacheWriteTokens(t *testing.T) {
if deltaEvent == nil {
t.Fatalf("missing message_delta event")
}
- if input := gjson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != tt.wantInputTokens {
+ if input := ojson.Get(deltaEvent.Payload, "usage.input_tokens").Int(); input != tt.wantInputTokens {
t.Fatalf("input_tokens = %d, want %d", input, tt.wantInputTokens)
}
- if output := gjson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != tt.wantOutputTokens {
+ if output := ojson.Get(deltaEvent.Payload, "usage.output_tokens").Int(); output != tt.wantOutputTokens {
t.Fatalf("output_tokens = %d, want %d", output, tt.wantOutputTokens)
}
- if cacheRead := gjson.Get(deltaEvent.Payload, "usage.cache_read_input_tokens").Int(); cacheRead != tt.wantCacheReadTokens {
+ if cacheRead := ojson.Get(deltaEvent.Payload, "usage.cache_read_input_tokens").Int(); cacheRead != tt.wantCacheReadTokens {
t.Fatalf("cache_read_input_tokens = %d, want %d", cacheRead, tt.wantCacheReadTokens)
}
if tt.wantCacheWriteTokens == 0 {
- if gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Exists() {
- t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Raw)
+ if ojson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Exists() {
+ t.Fatalf("cache_creation_input_tokens should not be emitted when zero; got %v", ojson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Raw)
}
- } else if cacheWrite := gjson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Int(); cacheWrite != tt.wantCacheWriteTokens {
+ } else if cacheWrite := ojson.Get(deltaEvent.Payload, "usage.cache_creation_input_tokens").Int(); cacheWrite != tt.wantCacheWriteTokens {
t.Fatalf("cache_creation_input_tokens = %d, want %d", cacheWrite, tt.wantCacheWriteTokens)
}
})
@@ -950,7 +951,7 @@ func TestNonStreamingUsage_PreservesCacheWriteTokens(t *testing.T) {
reqJSON := []byte(`{"model":"claude-3-5-sonnet-20241022","messages":[{"role":"user","content":"Hello"}]}`)
out := ConvertOpenAIResponseToClaudeNonStream(ctx, "", reqJSON, reqJSON, rawJSON, nil)
- parsed := gjson.ParseBytes(out)
+ parsed := util.ParseGJSONBytesNoCopy(out)
usage := parsed.Get("usage")
if got := usage.Get("input_tokens").Int(); got != tt.wantInputTokens {
@@ -981,11 +982,11 @@ func assertSequentialContentBlocks(t *testing.T, events []sseEvent) {
case "content_block_start":
if activeBlockIndex != -1 {
t.Fatalf("content_block_start emitted for index %d while block %d is still open (events=%+v)",
- gjson.Get(e.Payload, "index").Int(), activeBlockIndex, events)
+ ojson.Get(e.Payload, "index").Int(), activeBlockIndex, events)
}
- activeBlockIndex = gjson.Get(e.Payload, "index").Int()
+ activeBlockIndex = ojson.Get(e.Payload, "index").Int()
case "content_block_delta":
- idx := gjson.Get(e.Payload, "index").Int()
+ idx := ojson.Get(e.Payload, "index").Int()
if activeBlockIndex == -1 {
t.Fatalf("content_block_delta emitted for index %d but no block is open (events=%+v)", idx, events)
}
@@ -993,7 +994,7 @@ func assertSequentialContentBlocks(t *testing.T, events []sseEvent) {
t.Fatalf("content_block_delta emitted for index %d but active block is %d (events=%+v)", idx, activeBlockIndex, events)
}
case "content_block_stop":
- idx := gjson.Get(e.Payload, "index").Int()
+ idx := ojson.Get(e.Payload, "index").Int()
if activeBlockIndex == -1 {
t.Fatalf("content_block_stop emitted for index %d but no block is open (events=%+v)", idx, events)
}
@@ -1021,15 +1022,15 @@ func TestStreaming_InterleavedContentAndToolUse_StrictSequentialBlocks(t *testin
// Ensure tool call arguments were preserved and complete
var toolDeltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
toolDeltas = append(toolDeltas, e)
}
}
if len(toolDeltas) != 1 {
t.Fatalf("expected 1 tool input_json_delta, got %d", len(toolDeltas))
}
- partialJSON := gjson.Get(toolDeltas[0].Payload, "delta.partial_json").String()
- if gotCmd := gjson.Get(partialJSON, "command").String(); gotCmd != "ls" {
+ partialJSON := ojson.Get(toolDeltas[0].Payload, "delta.partial_json").String()
+ if gotCmd := ojson.Get(partialJSON, "command").String(); gotCmd != "ls" {
t.Fatalf("expected command 'ls', got %q", gotCmd)
}
}
@@ -1048,32 +1049,32 @@ func TestStreaming_ParallelToolCalls_StrictSequentialBlocks(t *testing.T) {
if len(starts) != 2 {
t.Fatalf("expected 2 tool_use starts, got %d", len(starts))
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_1" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_1" {
t.Fatalf("first tool id = %q, want %q", id, "call_1")
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "Bash" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "Bash" {
t.Fatalf("first tool name = %q, want %q", name, "Bash")
}
- if id := gjson.Get(starts[1].Payload, "content_block.id").String(); id != "call_2" {
+ if id := ojson.Get(starts[1].Payload, "content_block.id").String(); id != "call_2" {
t.Fatalf("second tool id = %q, want %q", id, "call_2")
}
- if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "Read" {
+ if name := ojson.Get(starts[1].Payload, "content_block.name").String(); name != "Read" {
t.Fatalf("second tool name = %q, want %q", name, "Read")
}
var toolDeltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
toolDeltas = append(toolDeltas, e)
}
}
if len(toolDeltas) != 2 {
t.Fatalf("expected 2 tool input_json_delta, got %d", len(toolDeltas))
}
- if cmd := gjson.Get(gjson.Get(toolDeltas[0].Payload, "delta.partial_json").String(), "command").String(); cmd != "ls" {
+ if cmd := ojson.Get(ojson.Get(toolDeltas[0].Payload, "delta.partial_json").String(), "command").String(); cmd != "ls" {
t.Fatalf("first tool cmd = %q, want 'ls'", cmd)
}
- if path := gjson.Get(gjson.Get(toolDeltas[1].Payload, "delta.partial_json").String(), "path").String(); path != "/tmp" {
+ if path := ojson.Get(ojson.Get(toolDeltas[1].Payload, "delta.partial_json").String(), "path").String(); path != "/tmp" {
t.Fatalf("second tool path = %q, want '/tmp'", path)
}
}
@@ -1099,7 +1100,7 @@ func TestStreaming_InterleavedTextAndThinkingPreservesOrder(t *testing.T) {
var blockStarts []string
for _, e := range events {
if e.Type == "content_block_start" {
- blockStarts = append(blockStarts, gjson.Get(e.Payload, "content_block.type").String())
+ blockStarts = append(blockStarts, ojson.Get(e.Payload, "content_block.type").String())
}
}
expectedStarts := []string{"tool_use", "text", "thinking", "text"}
@@ -1118,19 +1119,19 @@ func TestStreaming_InterleavedTextAndThinkingPreservesOrder(t *testing.T) {
var toolDelta string
for _, e := range events {
if e.Type == "content_block_delta" {
- dt := gjson.Get(e.Payload, "delta.type").String()
+ dt := ojson.Get(e.Payload, "delta.type").String()
switch dt {
case "text_delta":
- textDeltas = append(textDeltas, gjson.Get(e.Payload, "delta.text").String())
+ textDeltas = append(textDeltas, ojson.Get(e.Payload, "delta.text").String())
case "thinking_delta":
- thinkingDeltas = append(thinkingDeltas, gjson.Get(e.Payload, "delta.thinking").String())
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
case "input_json_delta":
- toolDelta = gjson.Get(e.Payload, "delta.partial_json").String()
+ toolDelta = ojson.Get(e.Payload, "delta.partial_json").String()
}
}
}
- if gotCmd := gjson.Get(toolDelta, "command").String(); gotCmd != "pwd" {
+ if gotCmd := ojson.Get(toolDelta, "command").String(); gotCmd != "pwd" {
t.Fatalf("tool command = %q, want 'pwd'", gotCmd)
}
if len(textDeltas) != 2 || textDeltas[0] != "Note A: running check" || textDeltas[1] != "Note B: done" {
@@ -1247,8 +1248,8 @@ func TestStreaming_ReasoningFieldEmitsThinkingDelta(t *testing.T) {
var thinkingDeltas []string
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
- thinkingDeltas = append(thinkingDeltas, gjson.Get(e.Payload, "delta.thinking").String())
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
}
}
if len(thinkingDeltas) != 1 || thinkingDeltas[0] != "I am thinking" {
@@ -1264,8 +1265,8 @@ func TestStreaming_ReasoningContentStillPreferred(t *testing.T) {
var thinkingDeltas []string
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
- thinkingDeltas = append(thinkingDeltas, gjson.Get(e.Payload, "delta.thinking").String())
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
}
}
if len(thinkingDeltas) != 1 || thinkingDeltas[0] != "primary reasoning" {
@@ -1282,8 +1283,8 @@ func TestStreaming_ReasoningDetailsOnlyEmitsThinkingDelta(t *testing.T) {
var thinkingDeltas []string
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
- thinkingDeltas = append(thinkingDeltas, gjson.Get(e.Payload, "delta.thinking").String())
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
}
}
if len(thinkingDeltas) != 1 || thinkingDeltas[0] != "Only details thinking" {
@@ -1300,8 +1301,8 @@ func TestStreaming_EmptyReasoningContentFallsBackToReasoning(t *testing.T) {
var thinkingDeltas []string
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
- thinkingDeltas = append(thinkingDeltas, gjson.Get(e.Payload, "delta.thinking").String())
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "thinking_delta" {
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
}
}
if len(thinkingDeltas) != 2 || thinkingDeltas[0] != "fallback from empty" || thinkingDeltas[1] != "fallback from null" {
@@ -1314,7 +1315,7 @@ func TestNonStream_ReasoningFieldEmitsThinkingBlock(t *testing.T) {
// Test ConvertOpenAIResponseToClaudeNonStream
out := ConvertOpenAIResponseToClaudeNonStream(context.Background(), "", nil, nil, rawJSON, nil)
- content := gjson.GetBytes(out, "content").Array()
+ content := ojson.GetBytesNoCopy(out, "content").Array()
var thinkingTexts []string
for _, block := range content {
if block.Get("type").String() == "thinking" {
@@ -1332,7 +1333,7 @@ func TestNonStream_ReasoningFieldEmitsThinkingBlock(t *testing.T) {
t.Fatalf("expected non-empty emitted for non-chunk json")
}
thinkingTexts = nil
- for _, block := range gjson.GetBytes(emitted[0], "content").Array() {
+ for _, block := range ojson.GetBytesNoCopy(emitted[0], "content").Array() {
if block.Get("type").String() == "thinking" {
thinkingTexts = append(thinkingTexts, block.Get("thinking").String())
}
@@ -1451,7 +1452,7 @@ func TestExtractOpenAIUsage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- usage := gjson.Parse(tt.rawUsage)
+ usage := ojson.Parse(tt.rawUsage)
input, output, cached, cacheWrite := extractOpenAIUsage(usage)
if input != tt.wantInputTokens {
t.Fatalf("input_tokens = %d, want %d", input, tt.wantInputTokens)
@@ -1508,7 +1509,7 @@ func TestConvertOpenAIResponseToClaude_NonStreamingRequestStaysNonStreaming(t *t
var param any
out := ConvertOpenAIResponseToClaude(context.Background(), "test-model", []byte(original), nil, []byte(streamFlagTestChunk), ¶m)
joined := bytes.Join(out, nil)
- if len(out) != 1 || gjson.GetBytes(joined, "type").String() != "message" {
+ if len(out) != 1 || ojson.GetBytesNoCopy(joined, "type").String() != "message" {
t.Fatalf("request %s expected a single non-streaming message payload, got %q", original, joined)
}
if bytes.Contains(joined, []byte("content_block_delta")) {
@@ -1518,7 +1519,7 @@ func TestConvertOpenAIResponseToClaude_NonStreamingRequestStaysNonStreaming(t *t
// Subsequent call with stream:true must still use the cached non-streaming decision.
second := ConvertOpenAIResponseToClaude(context.Background(), "test-model", []byte(`{"model":"m","stream":true}`), nil, []byte(streamFlagTestChunk), ¶m)
secondJoined := bytes.Join(second, nil)
- if len(second) != 1 || gjson.GetBytes(secondJoined, "type").String() != "message" || bytes.Contains(secondJoined, []byte("content_block_delta")) {
+ if len(second) != 1 || ojson.GetBytesNoCopy(secondJoined, "type").String() != "message" || bytes.Contains(secondJoined, []byte("content_block_delta")) {
t.Fatalf("request %s expected cached non-streaming flag on second call, got %q", original, secondJoined)
}
}
@@ -1586,7 +1587,7 @@ func TestNonStreamingTool_StopReasonWithFinishReason(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := ConvertOpenAIResponseToClaudeNonStream(context.Background(), "", nil, nil, []byte(tt.payload), nil)
- if got := gjson.GetBytes(out, "stop_reason").String(); got != tt.wantStopType {
+ if got := ojson.GetBytesNoCopy(out, "stop_reason").String(); got != tt.wantStopType {
t.Fatalf("ConvertOpenAIResponseToClaudeNonStream stop_reason = %q, want %q (out=%s)", got, tt.wantStopType, string(out))
}
@@ -1595,7 +1596,7 @@ func TestNonStreamingTool_StopReasonWithFinishReason(t *testing.T) {
if len(emitted) != 1 {
t.Fatalf("expected 1 non-streaming chunk, got %d", len(emitted))
}
- if got := gjson.GetBytes(emitted[0], "stop_reason").String(); got != tt.wantStopType {
+ if got := ojson.GetBytesNoCopy(emitted[0], "stop_reason").String(); got != tt.wantStopType {
t.Fatalf("convertOpenAINonStreamingToAnthropic stop_reason = %q, want %q (out=%s)", got, tt.wantStopType, string(emitted[0]))
}
})
@@ -1615,22 +1616,22 @@ func TestStreamingTool_OmittedToolCallIndexSeparateChunksPreservesParallelCalls(
if len(starts) != 2 {
t.Fatalf("expected two tool_use starts, got %d (starts=%+v)", len(starts), starts)
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_paris" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_paris" {
t.Fatalf("first tool id = %q, want %q", id, "call_paris")
}
- if name := gjson.Get(starts[0].Payload, "content_block.name").String(); name != "get_weather" {
+ if name := ojson.Get(starts[0].Payload, "content_block.name").String(); name != "get_weather" {
t.Fatalf("first tool name = %q, want %q", name, "get_weather")
}
- if id := gjson.Get(starts[1].Payload, "content_block.id").String(); id != "call_tokyo" {
+ if id := ojson.Get(starts[1].Payload, "content_block.id").String(); id != "call_tokyo" {
t.Fatalf("second tool id = %q, want %q", id, "call_tokyo")
}
- if name := gjson.Get(starts[1].Payload, "content_block.name").String(); name != "get_weather" {
+ if name := ojson.Get(starts[1].Payload, "content_block.name").String(); name != "get_weather" {
t.Fatalf("second tool name = %q, want %q", name, "get_weather")
}
var deltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
deltas = append(deltas, e)
}
}
@@ -1638,12 +1639,12 @@ func TestStreamingTool_OmittedToolCallIndexSeparateChunksPreservesParallelCalls(
t.Fatalf("expected two input_json_delta events, got %d (deltas=%+v)", len(deltas), deltas)
}
- firstJSON := gjson.Get(deltas[0].Payload, "delta.partial_json").String()
- secondJSON := gjson.Get(deltas[1].Payload, "delta.partial_json").String()
- if gotCity := gjson.Get(firstJSON, "city").String(); gotCity != "Paris" {
+ firstJSON := ojson.Get(deltas[0].Payload, "delta.partial_json").String()
+ secondJSON := ojson.Get(deltas[1].Payload, "delta.partial_json").String()
+ if gotCity := ojson.Get(firstJSON, "city").String(); gotCity != "Paris" {
t.Fatalf("first tool args city = %q, want %q (json=%q)", gotCity, "Paris", firstJSON)
}
- if gotCity := gjson.Get(secondJSON, "city").String(); gotCity != "Tokyo" {
+ if gotCity := ojson.Get(secondJSON, "city").String(); gotCity != "Tokyo" {
t.Fatalf("second tool args city = %q, want %q (json=%q)", gotCity, "Tokyo", secondJSON)
}
@@ -1670,29 +1671,130 @@ func TestStreamingTool_OmittedToolCallIndexSeparateChunksWithFragmentedArgs(t *t
if len(starts) != 2 {
t.Fatalf("expected two tool_use starts, got %d (starts=%+v)", len(starts), starts)
}
- if id := gjson.Get(starts[0].Payload, "content_block.id").String(); id != "call_paris" {
+ if id := ojson.Get(starts[0].Payload, "content_block.id").String(); id != "call_paris" {
t.Fatalf("first tool id = %q, want %q", id, "call_paris")
}
- if id := gjson.Get(starts[1].Payload, "content_block.id").String(); id != "call_tokyo" {
+ if id := ojson.Get(starts[1].Payload, "content_block.id").String(); id != "call_tokyo" {
t.Fatalf("second tool id = %q, want %q", id, "call_tokyo")
}
var deltas []sseEvent
for _, e := range events {
- if e.Type == "content_block_delta" && gjson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
+ if e.Type == "content_block_delta" && ojson.Get(e.Payload, "delta.type").String() == "input_json_delta" {
deltas = append(deltas, e)
}
}
if len(deltas) != 2 {
t.Fatalf("expected two input_json_delta events, got %d (deltas=%+v)", len(deltas), deltas)
}
- if gotCity := gjson.Get(gjson.Get(deltas[0].Payload, "delta.partial_json").String(), "city").String(); gotCity != "Paris" {
+ if gotCity := ojson.Get(ojson.Get(deltas[0].Payload, "delta.partial_json").String(), "city").String(); gotCity != "Paris" {
t.Fatalf("first tool args city = %q, want %q", gotCity, "Paris")
}
- if gotCity := gjson.Get(gjson.Get(deltas[1].Payload, "delta.partial_json").String(), "city").String(); gotCity != "Tokyo" {
+ if gotCity := ojson.Get(ojson.Get(deltas[1].Payload, "delta.partial_json").String(), "city").String(); gotCity != "Tokyo" {
t.Fatalf("second tool args city = %q, want %q", gotCity, "Tokyo")
}
if got := lastStopReason(events); got != "tool_use" {
t.Fatalf("stop_reason = %q, want %q", got, "tool_use")
}
}
+
+func TestStreaming_DeltaContentTypedPartsArrayEmitsThinkingAndTextBlocks(t *testing.T) {
+ events := runStream(t, streamReq,
+ `{"choices":[{"index":0,"delta":{"index":0,"content":[{"type":"thinking","thinking":[{"type":"text","text":"The"}],"closed":true}]},"finish_reason":null}]}`,
+ `{"choices":[{"index":0,"delta":{"index":0,"content":[{"type":"thinking","thinking":[{"type":"text","text":" done."}],"closed":true},{"type":"text","text":"hello"}]},"finish_reason":null}]}`,
+ `{"choices":[{"index":0,"delta":{"index":0,"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":19,"total_tokens":57,"completion_tokens":38,"completion_tokens_details":{"reasoning_tokens":35}}}`,
+ )
+
+ assertSequentialContentBlocks(t, events)
+
+ var blockStarts []string
+ var thinkingDeltas []string
+ var textDeltas []string
+ for _, e := range events {
+ switch e.Type {
+ case "content_block_start":
+ blockStarts = append(blockStarts, ojson.Get(e.Payload, "content_block.type").String())
+ case "content_block_delta":
+ switch ojson.Get(e.Payload, "delta.type").String() {
+ case "thinking_delta":
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
+ case "text_delta":
+ textDeltas = append(textDeltas, ojson.Get(e.Payload, "delta.text").String())
+ }
+ }
+ }
+
+ if len(blockStarts) != 2 || blockStarts[0] != "thinking" || blockStarts[1] != "text" {
+ t.Fatalf("expected block starts [thinking, text], got %v (events=%+v)", blockStarts, events)
+ }
+ if len(thinkingDeltas) != 2 || thinkingDeltas[0] != "The" || thinkingDeltas[1] != " done." {
+ t.Fatalf("expected thinking deltas [The, done.], got %v", thinkingDeltas)
+ }
+ if len(textDeltas) != 1 || textDeltas[0] != "hello" {
+ t.Fatalf("expected text deltas [hello], got %v", textDeltas)
+ }
+}
+
+func TestStreaming_DeltaContentTypedPartsArrayPlainThinkingAndUnknownPassthrough(t *testing.T) {
+ events := runStream(t, streamReq,
+ `{"choices":[{"index":0,"delta":{"content":[]},"finish_reason":null}]}`,
+ `{"choices":[{"index":0,"delta":{"content":[{"type":"thinking","thinking":"step 1"},{"type":"reasoning","text":" step 2"},{"type":"text","text":"answer: "},{"type":"custom_part","payload":"raw"}]},"finish_reason":null}]}`,
+ `{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
+ )
+
+ assertSequentialContentBlocks(t, events)
+
+ var thinkingDeltas []string
+ var textDeltas []string
+ for _, e := range events {
+ if e.Type == "content_block_delta" {
+ switch ojson.Get(e.Payload, "delta.type").String() {
+ case "thinking_delta":
+ thinkingDeltas = append(thinkingDeltas, ojson.Get(e.Payload, "delta.thinking").String())
+ case "text_delta":
+ textDeltas = append(textDeltas, ojson.Get(e.Payload, "delta.text").String())
+ }
+ }
+ }
+
+ if got := strings.Join(thinkingDeltas, ""); got != "step 1 step 2" {
+ t.Fatalf("thinking text = %q, want %q (deltas=%v)", got, "step 1 step 2", thinkingDeltas)
+ }
+ if len(textDeltas) != 2 || textDeltas[0] != "answer: " || textDeltas[1] != `{"type":"custom_part","payload":"raw"}` {
+ t.Fatalf("unexpected text deltas: %v", textDeltas)
+ }
+}
+
+func TestNonStream_ContentPartsArrayThinkingAndBlockOrder(t *testing.T) {
+ contentPartsResp := []byte(`{"id":"chatcmpl-1","object":"chat.completion","model":"zai-glm-5-3","choices":[{"index":0,"message":{"role":"assistant","content":[{"type":"thinking","thinking":[{"type":"text","text":"First "},{"type":"text","text":"thought."}],"closed":true},{"type":"text","text":"hello"}]},"finish_reason":"stop"}]}`)
+
+ out := ConvertOpenAIResponseToClaudeNonStream(context.Background(), "", nil, nil, contentPartsResp, nil)
+ blocks := ojson.GetBytesNoCopy(out, "content").Array()
+ if len(blocks) != 2 {
+ t.Fatalf("ConvertOpenAIResponseToClaudeNonStream expected 2 blocks, got %d (out=%s)", len(blocks), string(out))
+ }
+ if blocks[0].Get("type").String() != "thinking" || blocks[0].Get("thinking").String() != "First thought." {
+ t.Fatalf("block[0] = %s, want thinking 'First thought.'", blocks[0].Raw)
+ }
+ if blocks[1].Get("type").String() != "text" || blocks[1].Get("text").String() != "hello" {
+ t.Fatalf("block[1] = %s, want text 'hello'", blocks[1].Raw)
+ }
+
+ var paramAny any
+ emitted := ConvertOpenAIResponseToClaude(context.Background(), "", []byte(`{"stream":false}`), nil, append([]byte("data: "), contentPartsResp...), ¶mAny)
+ if len(emitted) != 1 {
+ t.Fatalf("expected 1 non-stream response from ConvertOpenAIResponseToClaude, got %d", len(emitted))
+ }
+ emittedBlocks := ojson.GetBytesNoCopy(emitted[0], "content").Array()
+ if len(emittedBlocks) != 2 || emittedBlocks[0].Get("type").String() != "thinking" || emittedBlocks[0].Get("thinking").String() != "First thought." || emittedBlocks[1].Get("type").String() != "text" || emittedBlocks[1].Get("text").String() != "hello" {
+ t.Fatalf("convertOpenAINonStreamingToAnthropic blocks = %s, want [thinking, text]", string(emitted[0]))
+ }
+
+ // Verify top-level reasoning_content + string message.content emits [thinking, text] order
+ topLevelReasoningResp := []byte(`{"id":"chatcmpl-2","object":"chat.completion","model":"deepseek","choices":[{"index":0,"message":{"role":"assistant","content":"Final answer","reasoning_content":"Step by step"},"finish_reason":"stop"}]}`)
+ outTopLevel := ConvertOpenAIResponseToClaudeNonStream(context.Background(), "", nil, nil, topLevelReasoningResp, nil)
+ topBlocks := ojson.GetBytesNoCopy(outTopLevel, "content").Array()
+ if len(topBlocks) != 2 || topBlocks[0].Get("type").String() != "thinking" || topBlocks[0].Get("thinking").String() != "Step by step" || topBlocks[1].Get("type").String() != "text" || topBlocks[1].Get("text").String() != "Final answer" {
+ t.Fatalf("ConvertOpenAIResponseToClaudeNonStream block order = %s, want [thinking, text]", string(outTopLevel))
+ }
+}
diff --git a/internal/translator/openai/claude/openai_claude_user_turn_test.go b/internal/translator/openai/claude/openai_claude_user_turn_test.go
index 2efddad349a..f276c9f7726 100644
--- a/internal/translator/openai/claude/openai_claude_user_turn_test.go
+++ b/internal/translator/openai/claude/openai_claude_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const userTurnUpload = `{"type":"container_upload","file_id":"file-1"}`
@@ -49,7 +49,7 @@ func TestConvertClaudeRequestToOpenAI_RefusesAnyEmptiedUserTurn(t *testing.T) {
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
})
@@ -62,7 +62,7 @@ func TestConvertClaudeRequestToOpenAI_KeepsTurnWithTextBesideAttachment(t *testi
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "messages.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "messages.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -73,7 +73,7 @@ func TestConvertClaudeRequestToOpenAI_Base64DocumentAfterHistoryStaysAFilePart(t
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "messages.2.content.0")
if part.Get("type").String() != "file" || part.Get("file.file_data").String() != "data:application/pdf;base64,JVBERi0xLjQK" {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -123,7 +123,7 @@ func TestClaudeToOpenAIImageOnlyTurnKeepsTheImage(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- part := gjson.GetBytes(envelope.Body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content.0")
if part.Get("type").String() != "image_url" || part.Get("image_url.url").String() != tc.want {
t.Fatalf("part = %s, body = %s", part.Raw, envelope.Body)
}
@@ -140,7 +140,7 @@ func TestClaudeToOpenAIFileImageBesideTextStillSucceeds(t *testing.T) {
if envelope.Err != nil {
t.Fatalf("envelope.Err = %v, body = %s", envelope.Err, envelope.Body)
}
- parts := gjson.GetBytes(envelope.Body, "messages.2.content").Array()
+ parts := ojson.GetBytesNoCopy(envelope.Body, "messages.2.content").Array()
if len(parts) != 1 || parts[0].Get("text").String() != "keep me" {
t.Fatalf("text was lost or an image leaked: %s", envelope.Body)
}
diff --git a/internal/translator/openai/gemini/openai_gemini_request.go b/internal/translator/openai/gemini/openai_gemini_request.go
index a714adf113e..97d84943a16 100644
--- a/internal/translator/openai/gemini/openai_gemini_request.go
+++ b/internal/translator/openai/gemini/openai_gemini_request.go
@@ -11,10 +11,10 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ConvertGeminiRequestToOpenAI parses and transforms a Gemini API request into OpenAI Chat Completions API format.
@@ -23,56 +23,56 @@ import (
func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
rawJSON := inputRawJSON
// Base OpenAI Chat Completions API template
- out := []byte(`{"model":"","messages":[]}`)
+ outDoc := ojson.NewDocString(`{"model":"","messages":[]}`)
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Model mapping
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Generation config mapping
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
// Temperature
if temp := genConfig.Get("temperature"); temp.Exists() {
- out, _ = sjson.SetBytes(out, "temperature", temp.Float())
+ _ = outDoc.Set("temperature", temp.Float())
}
// Max tokens
if maxTokens := genConfig.Get("maxOutputTokens"); maxTokens.Exists() {
- out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
+ _ = outDoc.Set("max_tokens", maxTokens.Int())
}
// Top P
if topP := genConfig.Get("topP"); topP.Exists() {
- out, _ = sjson.SetBytes(out, "top_p", topP.Float())
+ _ = outDoc.Set("top_p", topP.Float())
}
// Top K (OpenAI doesn't have direct equivalent, but we can map it)
if topK := genConfig.Get("topK"); topK.Exists() {
// Store as custom parameter for potential use
- out, _ = sjson.SetBytes(out, "top_k", topK.Int())
+ _ = outDoc.Set("top_k", topK.Int())
}
// Stop sequences
if stopSequences := genConfig.Get("stopSequences"); stopSequences.Exists() && stopSequences.IsArray() {
var stops []string
- stopSequences.ForEach(func(_, value gjson.Result) bool {
+ stopSequences.ForEach(func(_, value ojson.Result) bool {
stops = append(stops, value.String())
return true
})
if len(stops) > 0 {
- out, _ = sjson.SetBytes(out, "stop", stops)
+ _ = outDoc.Set("stop", stops)
}
}
// Candidate count (OpenAI 'n' parameter)
if candidateCount := genConfig.Get("candidateCount"); candidateCount.Exists() {
- out, _ = sjson.SetBytes(out, "n", candidateCount.Int())
+ _ = outDoc.Set("n", candidateCount.Int())
}
if responseModalities := genConfig.Get("responseModalities"); responseModalities.Exists() && responseModalities.IsArray() {
var modalities []string
- responseModalities.ForEach(func(_, value gjson.Result) bool {
+ responseModalities.ForEach(func(_, value ojson.Result) bool {
switch strings.ToLower(strings.TrimSpace(value.String())) {
case "text":
modalities = append(modalities, "text")
@@ -84,7 +84,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
return true
})
if len(modalities) > 0 {
- out, _ = sjson.SetBytes(out, "modalities", modalities)
+ _ = outDoc.Set("modalities", modalities)
}
}
@@ -99,7 +99,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
if thinkingLevel.Exists() {
effort := strings.ToLower(strings.TrimSpace(thinkingLevel.String()))
if effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
} else {
thinkingBudget := thinkingConfig.Get("thinkingBudget")
@@ -108,7 +108,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
if thinkingBudget.Exists() {
if effort, ok := thinking.ConvertBudgetToLevel(int(thinkingBudget.Int())); ok {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
}
}
@@ -116,9 +116,9 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
// Stream parameter
- out, _ = sjson.SetBytes(out, "stream", stream)
- if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
+ _ = outDoc.Set("stream", stream)
+ if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == ojson.String {
+ _ = outDoc.Set("service_tier", serviceTier.String())
}
// Process contents (Gemini messages) -> OpenAI messages
@@ -140,7 +140,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
contentItems := make([][]byte, 0, 2)
if parts.Exists() && parts.IsArray() {
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if translatorcommon.IsGeminiThoughtPart(part) {
return true
}
@@ -148,7 +148,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Handle text parts
if text := part.Get("text"); text.Exists() {
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", text.String())
+ contentPart, _ = ojson.SetBytes(contentPart, "text", text.String())
contentItems = append(contentItems, contentPart)
}
@@ -165,34 +165,32 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
if len(contentItems) > 0 {
msg := []byte(`{"role":"system","content":[]}`)
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
+ msg, _ = ojson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
messageItems = append(messageItems, msg)
}
}
if contents := root.Get("contents"); contents.Exists() && contents.IsArray() {
msgIdx := 0
- contents.ForEach(func(_, content gjson.Result) bool {
- role := content.Get("role").String()
+ previousRole := ""
+ contents.ForEach(func(_, content ojson.Result) bool {
parts := content.Get("parts")
+ role := translatorcommon.NormalizeGeminiTurnRole(content.Get("role").String(), parts, previousRole)
+ previousRole = role
- // Convert role: model -> assistant
- if role == "model" {
- role = "assistant"
- }
-
- msg := []byte(`{"role":"","content":""}`)
- msg, _ = sjson.SetBytes(msg, "role", role)
+ msgDoc := ojson.NewDocString(`{"role":"","content":""}`)
+ _ = msgDoc.Set("role", role)
var textBuilder strings.Builder
contentItems := make([][]byte, 0, 4)
onlyTextContent := true
toolCallItems := make([][]byte, 0, 2)
droppedThought := false
+ hasFunctionResponse := false
if parts.Exists() && parts.IsArray() {
partIdx := 0
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
currentPartIdx := partIdx
partIdx++
@@ -206,7 +204,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
formattedText := text.String()
textBuilder.WriteString(formattedText)
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", formattedText)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", formattedText)
contentItems = append(contentItems, contentPart)
}
@@ -221,7 +219,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
// Handle function calls (Gemini) -> tool calls (OpenAI)
- if functionCall := part.Get("functionCall"); functionCall.Exists() {
+ if functionCall := translatorcommon.GeminiPartFunctionCall(part); functionCall.Exists() {
funcName := functionCall.Get("name").String()
argsRaw := ""
if args := functionCall.Get("args"); args.Exists() {
@@ -233,43 +231,43 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
}
toolCallIDsByName[funcName] = append(toolCallIDsByName[funcName], toolCallID)
- toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
- toolCall, _ = sjson.SetBytes(toolCall, "id", toolCallID)
- toolCall, _ = sjson.SetBytes(toolCall, "function.name", funcName)
+ toolCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ _ = toolCallDoc.Set("id", toolCallID)
+ _ = toolCallDoc.Set("function.name", funcName)
// Convert args to arguments JSON string
if argsRaw != "" {
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", argsRaw)
+ _ = toolCallDoc.Set("function.arguments", argsRaw)
} else {
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", "{}")
+ _ = toolCallDoc.Set("function.arguments", "{}")
}
if thoughtSig := geminiFunctionCallThoughtSignature(part); thoughtSig != "" {
- toolCall, _ = sjson.SetBytes(toolCall, "extra_content.google.thought_signature", thoughtSig)
+ _ = toolCallDoc.Set("extra_content.google.thought_signature", thoughtSig)
}
- toolCallItems = append(toolCallItems, toolCall)
+ toolCallItems = append(toolCallItems, toolCallDoc.Bytes())
}
// Handle function responses (Gemini) -> tool role messages (OpenAI)
- if functionResponse := part.Get("functionResponse"); functionResponse.Exists() {
+ if functionResponse := translatorcommon.GeminiPartFunctionResponse(part); functionResponse.Exists() {
funcName := functionResponse.Get("name").String()
// Create tool message for function response
- toolMsg := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
+ toolMsgDoc := ojson.NewDocString(`{"role":"tool","tool_call_id":"","content":""}`)
responseRaw := ""
// Convert response.content to JSON string
if response := functionResponse.Get("response"); response.Exists() {
if contentField := response.Get("content"); contentField.Exists() {
responseRaw = contentField.Raw
- toolMsg, _ = sjson.SetBytes(toolMsg, "content", responseRaw)
+ _ = toolMsgDoc.Set("content", responseRaw)
} else {
responseRaw = response.Raw
- toolMsg, _ = sjson.SetBytes(toolMsg, "content", responseRaw)
+ _ = toolMsgDoc.Set("content", responseRaw)
}
}
if toolCallID := explicitGeminiToolID(functionResponse); toolCallID != "" {
- toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID)
+ _ = toolMsgDoc.Set("tool_call_id", toolCallID)
if queue := toolCallIDsByName[funcName]; len(queue) > 0 {
for i, id := range queue {
if id == toolCallID {
@@ -281,14 +279,15 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
} else if queue := toolCallIDsByName[funcName]; len(queue) > 0 {
toolCallID := queue[0]
toolCallIDsByName[funcName] = queue[1:]
- toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", toolCallID)
+ _ = toolMsgDoc.Set("tool_call_id", toolCallID)
} else {
// Generate a deterministic tool call ID fallback if none available
fallbackID := deterministicToolCallID("response", msgIdx, currentPartIdx, funcName, responseRaw)
- toolMsg, _ = sjson.SetBytes(toolMsg, "tool_call_id", fallbackID)
+ _ = toolMsgDoc.Set("tool_call_id", fallbackID)
}
- messageItems = append(messageItems, toolMsg)
+ messageItems = append(messageItems, toolMsgDoc.Bytes())
+ hasFunctionResponse = true
}
return true
@@ -298,54 +297,56 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Set content
if len(contentItems) > 0 {
if onlyTextContent {
- msg, _ = sjson.SetBytes(msg, "content", textBuilder.String())
+ _ = msgDoc.Set("content", textBuilder.String())
} else {
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
}
}
// Set tool calls if any.
if len(toolCallItems) > 0 {
- msg, _ = sjson.SetRawBytes(msg, "tool_calls", translatorcommon.JoinRawArray(toolCallItems))
+ _ = msgDoc.SetRawBytes("tool_calls", translatorcommon.JoinRawArray(toolCallItems))
}
- if droppedThought && len(contentItems) == 0 && len(toolCallItems) == 0 {
+ if (droppedThought || hasFunctionResponse) && len(contentItems) == 0 && len(toolCallItems) == 0 {
msgIdx++
return true
}
- messageItems = append(messageItems, msg)
+ messageItems = append(messageItems, msgDoc.Bytes())
msgIdx++
return true
})
}
- out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "messages", messageItems)
// Tools mapping: Gemini tools -> OpenAI tools
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if functionDeclarations := tool.Get("functionDeclarations"); functionDeclarations.Exists() && functionDeclarations.IsArray() {
- functionDeclarations.ForEach(func(_, funcDecl gjson.Result) bool {
- openAITool := []byte(`{"type":"function","function":{"name":"","description":""}}`)
- openAITool, _ = sjson.SetBytes(openAITool, "function.name", funcDecl.Get("name").String())
- openAITool, _ = sjson.SetBytes(openAITool, "function.description", funcDecl.Get("description").String())
+ functionDeclarations.ForEach(func(_, funcDecl ojson.Result) bool {
+ openAIToolDoc := ojson.NewDocString(`{"type":"function","function":{"name":"","description":""}}`)
+ _ = openAIToolDoc.Set("function.name", funcDecl.Get("name").String())
+ _ = openAIToolDoc.Set("function.description", funcDecl.Get("description").String())
// Convert parameters schema
if parameters := funcDecl.Get("parameters"); parameters.Exists() {
- openAITool, _ = sjson.SetRawBytes(openAITool, "function.parameters", []byte(parameters.Raw))
+ normalizedParams := util.NormalizeJSONSchemaNumericConstraints(parameters.Raw)
+ _ = openAIToolDoc.SetRaw("function.parameters", normalizedParams)
} else if parameters := funcDecl.Get("parametersJsonSchema"); parameters.Exists() {
- openAITool, _ = sjson.SetRawBytes(openAITool, "function.parameters", []byte(parameters.Raw))
+ normalizedParams := util.NormalizeJSONSchemaNumericConstraints(parameters.Raw)
+ _ = openAIToolDoc.SetRaw("function.parameters", normalizedParams)
}
- toolItems = append(toolItems, openAITool)
+ toolItems = append(toolItems, openAIToolDoc.Bytes())
return true
})
}
return true
})
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
}
@@ -356,23 +357,23 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
allowedNames := functionCallingConfig.Get("allowedFunctionNames")
switch mode {
case "NONE":
- out, _ = sjson.SetBytes(out, "tool_choice", "none")
+ _ = outDoc.Set("tool_choice", "none")
case "AUTO":
- out, _ = sjson.SetBytes(out, "tool_choice", "auto")
+ _ = outDoc.Set("tool_choice", "auto")
case "ANY":
allowedNameItems := allowedNames.Array()
if allowedNames.IsArray() && len(allowedNameItems) == 1 {
choice := []byte(`{"type":"function","function":{"name":""}}`)
- choice, _ = sjson.SetBytes(choice, "function.name", allowedNameItems[0].String())
- out, _ = sjson.SetRawBytes(out, "tool_choice", choice)
+ choice, _ = ojson.SetBytes(choice, "function.name", allowedNameItems[0].String())
+ _ = outDoc.SetRawBytes("tool_choice", choice)
} else {
- out, _ = sjson.SetBytes(out, "tool_choice", "required")
+ _ = outDoc.Set("tool_choice", "required")
}
}
}
}
- return out, nil
+ return outDoc.Bytes(), nil
}
func deterministicToolCallID(kind string, msgIdx, partIdx int, name, payload string) string {
@@ -380,7 +381,7 @@ func deterministicToolCallID(kind string, msgIdx, partIdx int, name, payload str
return "call_" + hex.EncodeToString(sum[:12])
}
-func geminiFunctionCallThoughtSignature(part gjson.Result) string {
+func geminiFunctionCallThoughtSignature(part ojson.Result) string {
for _, path := range []string{
"thoughtSignature",
"thought_signature",
@@ -395,7 +396,7 @@ func geminiFunctionCallThoughtSignature(part gjson.Result) string {
return ""
}
-func explicitGeminiToolID(node gjson.Result) string {
+func explicitGeminiToolID(node ojson.Result) string {
if id := strings.TrimSpace(node.Get("id").String()); id != "" {
return id
}
@@ -405,7 +406,7 @@ func explicitGeminiToolID(node gjson.Result) string {
return strings.TrimSpace(node.Get("callId").String())
}
-func openAIContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
+func openAIContentPartFromGeminiInlineData(part ojson.Result) ([]byte, bool) {
inlineData := part.Get("inlineData")
if !inlineData.Exists() {
inlineData = part.Get("inline_data")
@@ -424,31 +425,31 @@ func openAIContentPartFromGeminiInlineData(part gjson.Result) ([]byte, bool) {
if data == "" {
return nil, false
}
- dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
+ dataURL := "data:" + mimeType + ";base64," + data
lowerMimeType := strings.ToLower(mimeType)
switch {
case strings.HasPrefix(lowerMimeType, "image/"):
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", dataURL)
+ contentPart, _ = ojson.SetBytes(contentPart, "image_url.url", dataURL)
return contentPart, true
case strings.HasPrefix(lowerMimeType, "audio/"):
- contentPart := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "input_audio.data", data)
- contentPart, _ = sjson.SetBytes(contentPart, "input_audio.format", openAIInputAudioFormatFromMIME(mimeType))
- return contentPart, true
+ contentPartDoc := ojson.NewDocString(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
+ _ = contentPartDoc.Set("input_audio.data", data)
+ _ = contentPartDoc.Set("input_audio.format", openAIInputAudioFormatFromMIME(mimeType))
+ return contentPartDoc.Bytes(), true
case strings.HasPrefix(lowerMimeType, "video/"):
contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", dataURL)
+ contentPart, _ = ojson.SetBytes(contentPart, "video_url.url", dataURL)
return contentPart, true
default:
- contentPart := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType))
- contentPart, _ = sjson.SetBytes(contentPart, "file.file_data", data)
- return contentPart, true
+ contentPartDoc := ojson.NewDocString(`{"type":"file","file":{"filename":"","file_data":""}}`)
+ _ = contentPartDoc.Set("file.filename", openAIFileNameFromMIME(mimeType))
+ _ = contentPartDoc.Set("file.file_data", data)
+ return contentPartDoc.Bytes(), true
}
}
-func openAIContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
+func openAIContentPartFromGeminiFileData(part ojson.Result) ([]byte, bool) {
fileData := part.Get("fileData")
if !fileData.Exists() {
fileData = part.Get("file_data")
@@ -470,26 +471,26 @@ func openAIContentPartFromGeminiFileData(part gjson.Result) ([]byte, bool) {
lowerMimeType := strings.ToLower(mimeType)
if strings.HasPrefix(lowerMimeType, "image/") {
contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", fileURI)
+ contentPart, _ = ojson.SetBytes(contentPart, "image_url.url", fileURI)
return contentPart, true
}
if strings.HasPrefix(lowerMimeType, "video/") {
contentPart := []byte(`{"type":"video_url","video_url":{"url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "video_url.url", fileURI)
+ contentPart, _ = ojson.SetBytes(contentPart, "video_url.url", fileURI)
return contentPart, true
}
if strings.HasPrefix(lowerMimeType, "application/") || strings.HasPrefix(lowerMimeType, "text/") {
- contentPart := []byte(`{"type":"file","file":{"filename":"","file_url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "file.filename", openAIFileNameFromMIME(mimeType))
- contentPart, _ = sjson.SetBytes(contentPart, "file.file_url", fileURI)
- return contentPart, true
+ contentPartDoc := ojson.NewDocString(`{"type":"file","file":{"filename":"","file_url":""}}`)
+ _ = contentPartDoc.Set("file.filename", openAIFileNameFromMIME(mimeType))
+ _ = contentPartDoc.Set("file.file_url", fileURI)
+ return contentPartDoc.Bytes(), true
}
fileInfo := "File: " + fileURI
if mimeType != "" {
fileInfo += " (Type: " + mimeType + ")"
}
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", fileInfo)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", fileInfo)
return contentPart, true
}
diff --git a/internal/translator/openai/gemini/openai_gemini_request_test.go b/internal/translator/openai/gemini/openai_gemini_request_test.go
index 4fbdced2138..b89b18b2833 100644
--- a/internal/translator/openai/gemini/openai_gemini_request_test.go
+++ b/internal/translator/openai/gemini/openai_gemini_request_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertGeminiRequestToOpenAI_FunctionResponsesConsumeToolCallIDsFIFO(t *testing.T) {
@@ -30,9 +30,9 @@ func TestConvertGeminiRequestToOpenAI_FunctionResponsesConsumeToolCallIDsFIFO(t
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- firstID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
- secondID := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
- thirdID := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String()
+ firstID := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String()
+ secondID := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.1.id").String()
+ thirdID := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.2.id").String()
if firstID == "" || secondID == "" || thirdID == "" {
t.Fatalf("expected all assistant tool call IDs to be set. Output: %s", string(out))
@@ -40,13 +40,13 @@ func TestConvertGeminiRequestToOpenAI_FunctionResponsesConsumeToolCallIDsFIFO(t
if firstID == secondID || secondID == thirdID || firstID == thirdID {
t.Fatalf("expected distinct assistant tool call IDs, got %q, %q, %q", firstID, secondID, thirdID)
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstID {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != firstID {
t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", got, firstID, string(out))
}
- if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != secondID {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String(); got != secondID {
t.Fatalf("messages.2.tool_call_id = %q, want %q. Output: %s", got, secondID, string(out))
}
- if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != thirdID {
+ if got := ojson.GetBytesNoCopy(out, "messages.3.tool_call_id").String(); got != thirdID {
t.Fatalf("messages.3.tool_call_id = %q, want %q. Output: %s", got, thirdID, string(out))
}
}
@@ -64,7 +64,7 @@ func TestConvertGeminiRequestToOpenAI_FunctionResponseWithoutPriorCallGetsFallba
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- toolCallID := gjson.GetBytes(out, "messages.0.tool_call_id").String()
+ toolCallID := ojson.GetBytesNoCopy(out, "messages.0.tool_call_id").String()
if !strings.HasPrefix(toolCallID, "call_") {
t.Fatalf("fallback tool_call_id = %q, want call_ prefix. Output: %s", toolCallID, string(out))
}
@@ -90,9 +90,9 @@ func TestConvertGeminiRequestToOpenAI_ExtraFunctionResponsesUseFallbackID(t *tes
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- callID := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
- firstResponseID := gjson.GetBytes(out, "messages.1.tool_call_id").String()
- extraResponseID := gjson.GetBytes(out, "messages.2.tool_call_id").String()
+ callID := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String()
+ firstResponseID := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String()
+ extraResponseID := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String()
if firstResponseID != callID {
t.Fatalf("messages.1.tool_call_id = %q, want %q. Output: %s", firstResponseID, callID, string(out))
@@ -142,10 +142,10 @@ func TestConvertGeminiRequestToOpenAI_PreservesExplicitFunctionCallIDs(t *testin
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != tt.want {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String(); got != tt.want {
t.Fatalf("tool call id = %q, want %q. Output: %s", got, tt.want, string(out))
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != tt.want {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != tt.want {
t.Fatalf("tool response id = %q, want %q. Output: %s", got, tt.want, string(out))
}
})
@@ -154,7 +154,7 @@ func TestConvertGeminiRequestToOpenAI_PreservesExplicitFunctionCallIDs(t *testin
func TestConvertGeminiRequestToOpenAI_AcceptsSnakeInlineData(t *testing.T) {
out, _ := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inline_data":{"mime_type":"image/png","data":"aGVsbG8="}}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
@@ -162,16 +162,16 @@ func TestConvertGeminiRequestToOpenAI_AcceptsSnakeInlineData(t *testing.T) {
func TestConvertGeminiRequestToOpenAI_SplitsNonImageInlineDataByMIME(t *testing.T) {
out, _ := ConvertGeminiRequestToOpenAI("gpt-test", []byte(`{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},{"inlineData":{"mimeType":"video/mp4","data":"AAAAIGZ0eXA="}},{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0="}}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.1.type").String(); got != "video_url" {
t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.2.type").String(); got != "file" {
t.Fatalf("document content type = %q, want file. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "messages.0.content.#(type==\"image_url\")").Exists() {
+ if ojson.GetBytesNoCopy(out, "messages.0.content.#(type==\"image_url\")").Exists() {
t.Fatalf("non-image inlineData must not be converted to image_url. Output: %s", string(out))
}
}
@@ -185,7 +185,7 @@ func TestConvertGeminiRequestToOpenAI_DropsHiddenThoughtParts(t *testing.T) {
]
}`), false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "user" || messages[0].Get("content").String() != "continue" {
t.Fatalf("hidden thought turn was not dropped. Output: %s", string(out))
}
@@ -199,7 +199,7 @@ func TestConvertGeminiRequestToOpenAI_DropsHiddenThoughtParts(t *testing.T) {
]}]
}`), false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 1 || messages[0].Get("role").String() != "assistant" || messages[0].Get("content").String() != "visible answer" {
t.Fatalf("hidden thought was not dropped independently of visible text. Output: %s", string(out))
}
@@ -227,10 +227,10 @@ func TestConvertGeminiRequestToOpenAI_DeterministicToolCallIDs(t *testing.T) {
}`)
firstOut, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- firstCall0 := gjson.GetBytes(firstOut, "messages.0.tool_calls.0.id").String()
- firstCall1 := gjson.GetBytes(firstOut, "messages.0.tool_calls.1.id").String()
- firstResp0 := gjson.GetBytes(firstOut, "messages.1.tool_call_id").String()
- firstResp1 := gjson.GetBytes(firstOut, "messages.2.tool_call_id").String()
+ firstCall0 := ojson.GetBytesNoCopy(firstOut, "messages.0.tool_calls.0.id").String()
+ firstCall1 := ojson.GetBytesNoCopy(firstOut, "messages.0.tool_calls.1.id").String()
+ firstResp0 := ojson.GetBytesNoCopy(firstOut, "messages.1.tool_call_id").String()
+ firstResp1 := ojson.GetBytesNoCopy(firstOut, "messages.2.tool_call_id").String()
if !strings.HasPrefix(firstCall0, "call_") || !strings.HasPrefix(firstCall1, "call_") {
t.Fatalf("expected tool call IDs to have call_ prefix, got %q, %q", firstCall0, firstCall1)
@@ -244,16 +244,16 @@ func TestConvertGeminiRequestToOpenAI_DeterministicToolCallIDs(t *testing.T) {
for i := 0; i < 100; i++ {
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != firstCall0 {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String(); got != firstCall0 {
t.Fatalf("iteration %d: tool_calls.0.id = %q, want %q", i, got, firstCall0)
}
- if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != firstCall1 {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.1.id").String(); got != firstCall1 {
t.Fatalf("iteration %d: tool_calls.1.id = %q, want %q", i, got, firstCall1)
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != firstResp0 {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != firstResp0 {
t.Fatalf("iteration %d: messages.1.tool_call_id = %q, want %q", i, got, firstResp0)
}
- if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != firstResp1 {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String(); got != firstResp1 {
t.Fatalf("iteration %d: messages.2.tool_call_id = %q, want %q", i, got, firstResp1)
}
}
@@ -280,15 +280,15 @@ func TestConvertGeminiRequestToOpenAI_SameNameCallsInSameMessageDistinct(t *test
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- id0 := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
- id1 := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
+ id0 := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String()
+ id1 := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.1.id").String()
if id0 == id1 {
t.Fatalf("expected distinct IDs for same-name calls in same message, got both %q", id0)
}
- resp0 := gjson.GetBytes(out, "messages.1.tool_call_id").String()
- resp1 := gjson.GetBytes(out, "messages.2.tool_call_id").String()
+ resp0 := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String()
+ resp1 := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String()
if resp0 != id0 {
t.Fatalf("expected first response to match first call ID %q, got %q", id0, resp0)
@@ -325,26 +325,26 @@ func TestConvertGeminiRequestToOpenAI_InterleavedPerNameFIFOMatching(t *testing.
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- callA1 := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String()
- callB1 := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String()
- callA2 := gjson.GetBytes(out, "messages.0.tool_calls.2.id").String()
- callB2 := gjson.GetBytes(out, "messages.0.tool_calls.3.id").String()
+ callA1 := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String()
+ callB1 := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.1.id").String()
+ callA2 := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.2.id").String()
+ callB2 := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.3.id").String()
// Responses:
// messages[1] = tool_b (step 1) -> should match callB1
// messages[2] = tool_a (step 1) -> should match callA1
// messages[3] = tool_b (step 2) -> should match callB2
// messages[4] = tool_a (step 2) -> should match callA2
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != callB1 {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != callB1 {
t.Fatalf("first response (tool_b) = %q, want callB1 %q", got, callB1)
}
- if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != callA1 {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String(); got != callA1 {
t.Fatalf("second response (tool_a) = %q, want callA1 %q", got, callA1)
}
- if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != callB2 {
+ if got := ojson.GetBytesNoCopy(out, "messages.3.tool_call_id").String(); got != callB2 {
t.Fatalf("third response (tool_b) = %q, want callB2 %q", got, callB2)
}
- if got := gjson.GetBytes(out, "messages.4.tool_call_id").String(); got != callA2 {
+ if got := ojson.GetBytesNoCopy(out, "messages.4.tool_call_id").String(); got != callA2 {
t.Fatalf("fourth response (tool_a) = %q, want callA2 %q", got, callA2)
}
}
@@ -362,14 +362,14 @@ func TestConvertGeminiRequestToOpenAI_DeterministicFallbackOrphanResponse(t *tes
}`)
firstOut, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- firstID := gjson.GetBytes(firstOut, "messages.0.tool_call_id").String()
+ firstID := ojson.GetBytesNoCopy(firstOut, "messages.0.tool_call_id").String()
if !strings.HasPrefix(firstID, "call_") {
t.Fatalf("expected fallback tool_call_id with call_ prefix, got %q", firstID)
}
for i := 0; i < 100; i++ {
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- if got := gjson.GetBytes(out, "messages.0.tool_call_id").String(); got != firstID {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_call_id").String(); got != firstID {
t.Fatalf("iteration %d: orphan fallback tool_call_id = %q, want %q", i, got, firstID)
}
}
@@ -394,10 +394,10 @@ func TestConvertGeminiRequestToOpenAI_ExplicitCallInheritedByImplicitResponse(t
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "explicit_call_1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String(); got != "explicit_call_1" {
t.Fatalf("tool call ID = %q, want explicit_call_1", got)
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "explicit_call_1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != "explicit_call_1" {
t.Fatalf("tool response ID = %q, want explicit_call_1", got)
}
}
@@ -428,9 +428,9 @@ func TestConvertGeminiRequestToOpenAI_OutOrderExplicitResponseDoesNotDuplicateID
}`)
out, _ := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
- resp1 := gjson.GetBytes(out, "messages.1.tool_call_id").String()
- resp2 := gjson.GetBytes(out, "messages.2.tool_call_id").String()
- resp3 := gjson.GetBytes(out, "messages.3.tool_call_id").String()
+ resp1 := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String()
+ resp2 := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String()
+ resp3 := ojson.GetBytesNoCopy(out, "messages.3.tool_call_id").String()
if resp1 != "call_2" {
t.Fatalf("first response = %q, want call_2", resp1)
@@ -473,10 +473,63 @@ func TestConvertGeminiRequestToOpenAI_PreservesFunctionCallThoughtSignature(t *t
if err != nil {
t.Fatalf("ConvertGeminiRequestToOpenAI() error = %v", err)
}
- if got := gjson.GetBytes(out, "messages.1.tool_calls.0.extra_content.google.thought_signature").String(); got != "SIG_PARIS" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_calls.0.extra_content.google.thought_signature").String(); got != "SIG_PARIS" {
t.Fatalf("messages.1.tool_calls.0.extra_content.google.thought_signature = %q, want SIG_PARIS; out=%s", got, string(out))
}
- if gjson.GetBytes(out, "messages.1.tool_calls.1.extra_content").Exists() {
+ if ojson.GetBytesNoCopy(out, "messages.1.tool_calls.1.extra_content").Exists() {
t.Fatalf("expected unsigned sibling tool call not to have extra_content; out=%s", string(out))
}
}
+
+func TestConvertGeminiRequestToOpenAI_FunctionResponseDoesNotAppendTrailingEmptyFunctionMessage(t *testing.T) {
+ inputJSON := []byte(`{
+ "contents": [
+ {"parts": [{"text": "What is the weather?"}]},
+ {"parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Tokyo"}}}]},
+ {"role": "function", "parts": [{"functionResponse": {"name": "get_weather", "response": {"result": "Sunny"}}}]}
+ ]
+ }`)
+
+ out, err := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
+ if err != nil {
+ t.Fatalf("ConvertGeminiRequestToOpenAI() error = %v", err)
+ }
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
+ if len(messages) != 3 {
+ t.Fatalf("len(messages) = %d, want 3 (user, assistant, tool); out=%s", len(messages), string(out))
+ }
+ if got := messages[0].Get("role").String(); got != "user" {
+ t.Fatalf("messages[0].role = %q, want user; out=%s", got, string(out))
+ }
+ if got := messages[1].Get("role").String(); got != "assistant" {
+ t.Fatalf("messages[1].role = %q, want assistant; out=%s", got, string(out))
+ }
+ if got := messages[2].Get("role").String(); got != "tool" {
+ t.Fatalf("messages[2].role = %q, want tool; out=%s", got, string(out))
+ }
+}
+
+func TestConvertGeminiRequestToOpenAI_DefaultsOmittedContentRoleToUser(t *testing.T) {
+ inputJSON := []byte(`{"contents":[{"parts":[{"text":"Reply with exactly: OK"}]},{"role":"invalid","parts":[{"text":"OK"}]},{"parts":[{"function_call":{"name":"ping","args":{"x":1}}}]},{"parts":[{"function_response":{"name":"ping","response":{"result":"pong"}}}]}]}`)
+
+ out, err := ConvertGeminiRequestToOpenAI("test-model", inputJSON, false)
+ if err != nil {
+ t.Fatalf("ConvertGeminiRequestToOpenAI() error = %v", err)
+ }
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
+ if len(messages) != 4 {
+ t.Fatalf("len(messages) = %d, want 4; out=%s", len(messages), string(out))
+ }
+ if got := messages[0].Get("role").String(); got != "user" {
+ t.Fatalf("messages[0].role = %q, want user; out=%s", got, string(out))
+ }
+ if got := messages[1].Get("role").String(); got != "assistant" {
+ t.Fatalf("messages[1].role = %q, want assistant (alternated after user); out=%s", got, string(out))
+ }
+ if got := messages[2].Get("role").String(); got != "assistant" || messages[2].Get("tool_calls.0.function.name").String() != "ping" {
+ t.Fatalf("messages[2] = %s, want assistant tool_call ping", messages[2].Raw)
+ }
+ if got := messages[3].Get("role").String(); got != "tool" {
+ t.Fatalf("messages[3].role = %q, want tool; out=%s", got, string(out))
+ }
+}
diff --git a/internal/translator/openai/gemini/openai_gemini_response.go b/internal/translator/openai/gemini/openai_gemini_response.go
index 557c6334cda..466b49697a8 100644
--- a/internal/translator/openai/gemini/openai_gemini_response.go
+++ b/internal/translator/openai/gemini/openai_gemini_response.go
@@ -13,9 +13,9 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ConvertOpenAIResponseToGeminiParams holds parameters for response conversion
@@ -66,7 +66,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Initialize accumulators if needed
if (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator == nil {
@@ -79,28 +79,28 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
if len(choices.Array()) == 0 {
// This is a usage-only chunk, handle usage and return
if usage := root.Get("usage"); usage.Exists() {
- template := []byte(`{"candidates":[],"usageMetadata":{}}`)
+ templateDoc := ojson.NewDocString(`{"candidates":[],"usageMetadata":{}}`)
// Set model if available
if model := root.Get("model"); model.Exists() {
- template, _ = sjson.SetBytes(template, "model", model.String())
+ _ = templateDoc.Set("model", model.String())
}
- template = setGeminiUsageMetadataFromOpenAIUsage(template, usage)
- return [][]byte{template}
+ setGeminiUsageMetadataFromOpenAIUsageDoc(templateDoc, usage)
+ return [][]byte{templateDoc.Bytes()}
}
return [][]byte{}
}
var results [][]byte
- choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
+ choices.ForEach(func(choiceIndex, choice ojson.Result) bool {
// Base Gemini response template without finishReason; set when known
template := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
// Set model if available
if model := root.Get("model"); model.Exists() {
- template, _ = sjson.SetBytes(template, "model", model.String())
+ template, _ = ojson.SetBytes(template, "model", model.String())
}
_ = int(choice.Get("index").Int()) // choiceIdx not used in streaming
@@ -111,7 +111,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
if role := delta.Get("role"); role.Exists() && (*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk {
// OpenAI assistant -> Gemini model
if role.String() == "assistant" {
- template, _ = sjson.SetBytes(template, "candidates.0.content.role", "model")
+ template, _ = ojson.SetBytes(template, "candidates.0.content.role", "model")
}
(*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk = false
results = append(results, template)
@@ -126,10 +126,10 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
if reasoningText == "" {
continue
}
- reasoningTemplate := append([]byte(nil), baseTemplate...)
- reasoningTemplate, _ = sjson.SetBytes(reasoningTemplate, "candidates.0.content.parts.0.thought", true)
- reasoningTemplate, _ = sjson.SetBytes(reasoningTemplate, "candidates.0.content.parts.0.text", reasoningText)
- chunkOutputs = append(chunkOutputs, reasoningTemplate)
+ reasoningDoc := ojson.NewDoc(baseTemplate)
+ _ = reasoningDoc.Set("candidates.0.content.parts.0.thought", true)
+ _ = reasoningDoc.Set("candidates.0.content.parts.0.text", reasoningText)
+ chunkOutputs = append(chunkOutputs, reasoningDoc.Bytes())
}
}
@@ -140,21 +140,21 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
// Create text part for this delta
contentTemplate := append([]byte(nil), baseTemplate...)
- contentTemplate, _ = sjson.SetBytes(contentTemplate, "candidates.0.content.parts.0.text", contentText)
+ contentTemplate, _ = ojson.SetBytes(contentTemplate, "candidates.0.content.parts.0.text", contentText)
chunkOutputs = append(chunkOutputs, contentTemplate)
}
if len(chunkOutputs) > 0 {
results = append(results, chunkOutputs...)
finishReason := choice.Get("finish_reason")
- if !delta.Get("tool_calls").Exists() && (finishReason.Type != gjson.String || finishReason.String() == "") {
+ if !delta.Get("tool_calls").Exists() && (finishReason.Type != ojson.String || finishReason.String() == "") {
return true
}
}
// Handle tool calls delta
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
toolIndex := int(toolCall.Get("index").Int())
toolID := toolCall.Get("id").String()
toolType := toolCall.Get("type").String()
@@ -177,8 +177,8 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
// Initialize accumulator if needed so later deltas without type can append arguments.
if _, exists := (*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex]; !exists {
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator[toolIndex] = &ToolCallAccumulator{
- ID: toolID,
- Name: functionName,
+ ID: strings.Clone(toolID),
+ Name: strings.Clone(functionName),
}
}
@@ -186,12 +186,12 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
// Update ID if provided
if toolID != "" {
- acc.ID = toolID
+ acc.ID = strings.Clone(toolID)
}
// Update name if provided
if functionName != "" {
- acc.Name = functionName
+ acc.Name = strings.Clone(functionName)
}
// Accumulate arguments
@@ -200,22 +200,23 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
}
if thoughtSig != "" {
- acc.ThoughtSignature = thoughtSig
+ acc.ThoughtSignature = strings.Clone(thoughtSig)
}
return true
})
- if finishReason := choice.Get("finish_reason"); finishReason.Type != gjson.String || finishReason.String() == "" {
+ if finishReason := choice.Get("finish_reason"); finishReason.Type != ojson.String || finishReason.String() == "" {
// Don't output anything for tool call deltas - wait for completion
return true
}
}
// Handle finish reason
- if finishReason := choice.Get("finish_reason"); finishReason.Type == gjson.String && finishReason.String() != "" {
+ if finishReason := choice.Get("finish_reason"); finishReason.Type == ojson.String && finishReason.String() != "" {
geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())
- template, _ = sjson.SetBytes(template, "candidates.0.finishReason", geminiFinishReason)
+ templateDoc := ojson.NewDoc(template)
+ _ = templateDoc.Set("candidates.0.finishReason", geminiFinishReason)
// If we have accumulated tool calls, output them now
if len((*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator) > 0 {
@@ -232,13 +233,13 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
namePath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.name", partIndex)
argsPath := fmt.Sprintf("candidates.0.content.parts.%d.functionCall.args", partIndex)
if accumulator.ID != "" {
- template, _ = sjson.SetBytes(template, idPath, accumulator.ID)
+ _ = templateDoc.Set(idPath, accumulator.ID)
}
- template, _ = sjson.SetBytes(template, namePath, accumulator.Name)
- template, _ = sjson.SetRawBytes(template, argsPath, []byte(parseArgsToObjectRaw(accumulator.Arguments.String())))
+ _ = templateDoc.Set(namePath, accumulator.Name)
+ _ = templateDoc.SetRaw(argsPath, parseArgsToObjectRaw(accumulator.Arguments.String()))
if accumulator.ThoughtSignature != "" {
sigPath := fmt.Sprintf("candidates.0.content.parts.%d.thoughtSignature", partIndex)
- template, _ = sjson.SetBytes(template, sigPath, accumulator.ThoughtSignature)
+ _ = templateDoc.Set(sigPath, accumulator.ThoughtSignature)
}
partIndex++
}
@@ -247,14 +248,15 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
(*param).(*ConvertOpenAIResponseToGeminiParams).ToolCallsAccumulator = make(map[int]*ToolCallAccumulator)
}
- results = append(results, template)
+ results = append(results, templateDoc.Bytes())
return true
}
// Handle usage information
if usage := root.Get("usage"); usage.Exists() {
- template = setGeminiUsageMetadataFromOpenAIUsage(template, usage)
- results = append(results, template)
+ templateDoc := ojson.NewDoc(template)
+ setGeminiUsageMetadataFromOpenAIUsageDoc(templateDoc, usage)
+ results = append(results, templateDoc.Bytes())
return true
}
@@ -265,7 +267,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
return [][]byte{}
}
-func openAIToolCallThoughtSignature(toolCall gjson.Result) string {
+func openAIToolCallThoughtSignature(toolCall ojson.Result) string {
for _, path := range []string{
"extra_content.google.thought_signature",
"function.extra_content.google.thought_signature",
@@ -304,8 +306,8 @@ func parseArgsToObjectRaw(argsStr string) string {
}
// First try strict JSON
- if gjson.Valid(trimmed) {
- strict := gjson.Parse(trimmed)
+ if ojson.Valid(trimmed) {
+ strict := ojson.Parse(trimmed)
if strict.IsObject() {
return strict.Raw
}
@@ -342,7 +344,7 @@ func tolerantParseJSONObjectRaw(s string) string {
runes := []rune(content)
n := len(runes)
i := 0
- result := []byte(`{}`)
+ resultDoc := ojson.NewDocString(`{}`)
for i < n {
// Skip whitespace and commas
@@ -394,10 +396,10 @@ func tolerantParseJSONObjectRaw(s string) string {
valToken, ni := parseJSONStringRunes(runes, i)
if ni == -1 {
// Malformed; treat as empty string
- result, _ = sjson.SetBytes(result, sjsonKey, "")
+ _ = resultDoc.Set(sjsonKey, "")
i = n
} else {
- result, _ = sjson.SetBytes(result, sjsonKey, jsonStringTokenToRawString(valToken))
+ _ = resultDoc.Set(sjsonKey, jsonStringTokenToRawString(valToken))
i = ni
}
case '{', '[':
@@ -406,10 +408,10 @@ func tolerantParseJSONObjectRaw(s string) string {
if ni == -1 {
i = n
} else {
- if gjson.Valid(seg) {
- result, _ = sjson.SetRawBytes(result, sjsonKey, []byte(seg))
+ if ojson.Valid(seg) {
+ _ = resultDoc.SetRaw(sjsonKey, seg)
} else {
- result, _ = sjson.SetBytes(result, sjsonKey, seg)
+ _ = resultDoc.Set(sjsonKey, seg)
}
i = ni
}
@@ -422,15 +424,15 @@ func tolerantParseJSONObjectRaw(s string) string {
token := strings.TrimSpace(string(runes[i:j]))
// Interpret common JSON atoms and numbers; otherwise treat as string
if token == "true" {
- result, _ = sjson.SetBytes(result, sjsonKey, true)
+ _ = resultDoc.Set(sjsonKey, true)
} else if token == "false" {
- result, _ = sjson.SetBytes(result, sjsonKey, false)
+ _ = resultDoc.Set(sjsonKey, false)
} else if token == "null" {
- result, _ = sjson.SetBytes(result, sjsonKey, nil)
+ _ = resultDoc.Set(sjsonKey, nil)
} else if numVal, ok := tryParseNumber(token); ok {
- result, _ = sjson.SetBytes(result, sjsonKey, numVal)
+ _ = resultDoc.Set(sjsonKey, numVal)
} else {
- result, _ = sjson.SetBytes(result, sjsonKey, token)
+ _ = resultDoc.Set(sjsonKey, token)
}
i = j
}
@@ -444,7 +446,7 @@ func tolerantParseJSONObjectRaw(s string) string {
}
}
- return string(result)
+ return resultDoc.String()
}
// parseJSONStringRunes returns the JSON string token (including quotes) and the index just after it.
@@ -472,8 +474,8 @@ func parseJSONStringRunes(runes []rune, start int) (string, int) {
// jsonStringTokenToRawString converts a JSON string token (including quotes) to a raw Go string value.
func jsonStringTokenToRawString(token string) string {
- r := gjson.Parse(token)
- if r.Type == gjson.String {
+ r := ojson.Parse(token)
+ if r.Type == ojson.String {
return r.String()
}
// Fallback: strip surrounding quotes if present
@@ -565,37 +567,37 @@ func tryParseNumber(s string) (interface{}, bool) {
// Returns:
// - []byte: A Gemini-compatible JSON response.
func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Base Gemini response template without finishReason; set when known
- out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
+ outDoc := ojson.NewDocString(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
// Set model if available
if model := root.Get("model"); model.Exists() {
- out, _ = sjson.SetBytes(out, "model", model.String())
+ _ = outDoc.Set("model", model.String())
}
var allParts [][]byte
// Process choices
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
- choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
+ choices.ForEach(func(choiceIndex, choice ojson.Result) bool {
choiceIdx := int(choice.Get("index").Int())
message := choice.Get("message")
// Set role
if role := message.Get("role"); role.Exists() {
if role.String() == "assistant" {
- out, _ = sjson.SetBytes(out, "candidates.0.content.role", "model")
+ _ = outDoc.Set("candidates.0.content.role", "model")
}
}
partIndex := 0
- ensurePart := func(idx int) []byte {
+ ensurePartDoc := func(idx int) *ojson.Doc {
for len(allParts) <= idx {
allParts = append(allParts, []byte(`{}`))
}
- return allParts[idx]
+ return ojson.NewDoc(allParts[idx])
}
// Handle reasoning content before visible text
@@ -604,41 +606,41 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina
if reasoningText == "" {
continue
}
- part := ensurePart(partIndex)
- part, _ = sjson.SetBytes(part, "thought", true)
- part, _ = sjson.SetBytes(part, "text", reasoningText)
- allParts[partIndex] = part
+ partDoc := ensurePartDoc(partIndex)
+ _ = partDoc.Set("thought", true)
+ _ = partDoc.Set("text", reasoningText)
+ allParts[partIndex] = partDoc.Bytes()
partIndex++
}
}
// Handle content first
if content := message.Get("content"); content.Exists() && content.String() != "" {
- part := ensurePart(partIndex)
- part, _ = sjson.SetBytes(part, "text", content.String())
- allParts[partIndex] = part
+ partDoc := ensurePartDoc(partIndex)
+ _ = partDoc.Set("text", content.String())
+ allParts[partIndex] = partDoc.Bytes()
partIndex++
}
// Handle tool calls
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
if toolCall.Get("type").String() == "function" {
function := toolCall.Get("function")
functionName := function.Get("name").String()
functionArgs := function.Get("arguments").String()
functionID := toolCall.Get("id").String()
- part := ensurePart(partIndex)
+ partDoc := ensurePartDoc(partIndex)
if functionID != "" {
- part, _ = sjson.SetBytes(part, "functionCall.id", functionID)
+ _ = partDoc.Set("functionCall.id", functionID)
}
- part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
- part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(parseArgsToObjectRaw(functionArgs)))
+ _ = partDoc.Set("functionCall.name", functionName)
+ _ = partDoc.SetRaw("functionCall.args", parseArgsToObjectRaw(functionArgs))
if thoughtSig := openAIToolCallThoughtSignature(toolCall); thoughtSig != "" {
- part, _ = sjson.SetBytes(part, "thoughtSignature", thoughtSig)
+ _ = partDoc.Set("thoughtSignature", thoughtSig)
}
- allParts[partIndex] = part
+ allParts[partIndex] = partDoc.Bytes()
partIndex++
}
return true
@@ -646,35 +648,35 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina
}
// Handle finish reason
- if finishReason := choice.Get("finish_reason"); finishReason.Type == gjson.String && finishReason.String() != "" {
+ if finishReason := choice.Get("finish_reason"); finishReason.Type == ojson.String && finishReason.String() != "" {
geminiFinishReason := mapOpenAIFinishReasonToGemini(finishReason.String())
- out, _ = sjson.SetBytes(out, "candidates.0.finishReason", geminiFinishReason)
+ _ = outDoc.Set("candidates.0.finishReason", geminiFinishReason)
}
// Set index
- out, _ = sjson.SetBytes(out, "candidates.0.index", choiceIdx)
+ _ = outDoc.Set("candidates.0.index", choiceIdx)
return true
})
if len(allParts) > 0 {
- out, _ = sjson.SetRawBytes(out, "candidates.0.content.parts", translatorcommon.JoinRawArray(allParts))
+ _ = outDoc.SetRawBytes("candidates.0.content.parts", translatorcommon.JoinRawArray(allParts))
}
}
// Handle usage information
if usage := root.Get("usage"); usage.Exists() {
- out = setGeminiUsageMetadataFromOpenAIUsage(out, usage)
+ setGeminiUsageMetadataFromOpenAIUsageDoc(outDoc, usage)
}
- return out
+ return outDoc.Bytes()
}
func GeminiTokenCount(ctx context.Context, count int64) []byte {
return translatorcommon.GeminiTokenCountJSON(count)
}
-func reasoningTokensFromUsage(usage gjson.Result) int64 {
+func reasoningTokensFromUsage(usage ojson.Result) int64 {
if usage.Exists() {
if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
return v.Int()
@@ -686,31 +688,30 @@ func reasoningTokensFromUsage(usage gjson.Result) int64 {
return 0
}
-func setGeminiUsageMetadataFromOpenAIUsage(out []byte, usage gjson.Result) []byte {
+func setGeminiUsageMetadataFromOpenAIUsageDoc(outDoc *ojson.Doc, usage ojson.Result) {
promptTokens, hasPromptTokens := tokenCountFromUsage(usage, "prompt_tokens", "input_tokens")
completionTokens, hasCompletionTokens := tokenCountFromUsage(usage, "completion_tokens", "output_tokens")
totalTokens, hasTotalTokens := tokenCountFromUsage(usage, "total_tokens")
if hasPromptTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", promptTokens)
+ _ = outDoc.Set("usageMetadata.promptTokenCount", promptTokens)
}
if hasCompletionTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", completionTokens)
+ _ = outDoc.Set("usageMetadata.candidatesTokenCount", completionTokens)
}
if hasTotalTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens)
+ _ = outDoc.Set("usageMetadata.totalTokenCount", totalTokens)
} else if hasPromptTokens || hasCompletionTokens {
- out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", promptTokens+completionTokens)
+ _ = outDoc.Set("usageMetadata.totalTokenCount", promptTokens+completionTokens)
}
if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {
- out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", reasoningTokens)
+ _ = outDoc.Set("usageMetadata.thoughtsTokenCount", reasoningTokens)
}
if cachedTokens := cachedTokensFromUsage(usage); cachedTokens > 0 {
- out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens)
+ _ = outDoc.Set("usageMetadata.cachedContentTokenCount", cachedTokens)
}
- return out
}
-func tokenCountFromUsage(usage gjson.Result, paths ...string) (int64, bool) {
+func tokenCountFromUsage(usage ojson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if v := usage.Get(path); v.Exists() {
return v.Int(), true
@@ -719,7 +720,7 @@ func tokenCountFromUsage(usage gjson.Result, paths ...string) (int64, bool) {
return 0, false
}
-func cachedTokensFromUsage(usage gjson.Result) int64 {
+func cachedTokensFromUsage(usage ojson.Result) int64 {
if usage.Exists() {
if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
return v.Int()
@@ -731,14 +732,14 @@ func cachedTokensFromUsage(usage gjson.Result) int64 {
return 0
}
-func extractReasoningTexts(node gjson.Result) []string {
+func extractReasoningTexts(node ojson.Result) []string {
var texts []string
if !node.Exists() {
return texts
}
if node.IsArray() {
- node.ForEach(func(_, value gjson.Result) bool {
+ node.ForEach(func(_, value ojson.Result) bool {
texts = append(texts, extractReasoningTexts(value)...)
return true
})
@@ -746,9 +747,9 @@ func extractReasoningTexts(node gjson.Result) []string {
}
switch node.Type {
- case gjson.String:
+ case ojson.String:
texts = append(texts, node.String())
- case gjson.JSON:
+ case ojson.JSON:
if text := node.Get("text"); text.Exists() {
texts = append(texts, text.String())
} else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
diff --git a/internal/translator/openai/gemini/openai_gemini_response_test.go b/internal/translator/openai/gemini/openai_gemini_response_test.go
index 2a1cb0607cd..1df203bcfcf 100644
--- a/internal/translator/openai/gemini/openai_gemini_response_test.go
+++ b/internal/translator/openai/gemini/openai_gemini_response_test.go
@@ -4,16 +4,16 @@ import (
"context"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponseToGeminiNonStreamPreservesToolCallID(t *testing.T) {
raw := []byte(`{"choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_chat_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]}}]}`)
out := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.id").String(); got != "call_chat_1" {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.id").String(); got != "call_chat_1" {
t.Fatalf("functionCall.id = %q, want call_chat_1", got)
}
- if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
+ if got := ojson.GetBytesNoCopy(out, "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
t.Fatalf("functionCall.args.q = %q, want x", got)
}
}
@@ -25,10 +25,10 @@ func TestConvertOpenAIResponseToGeminiStreamPreservesToolCallID(t *testing.T) {
if len(out) == 0 {
t.Fatalf("stream output is empty")
}
- if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_stream_1" {
+ if got := ojson.GetBytesNoCopy(out[len(out)-1], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_stream_1" {
t.Fatalf("functionCall.id = %q, want call_stream_1", got)
}
- if got := gjson.GetBytes(out[len(out)-1], "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
+ if got := ojson.GetBytesNoCopy(out[len(out)-1], "candidates.0.content.parts.0.functionCall.args.q").String(); got != "x" {
t.Fatalf("functionCall.args.q = %q, want x", got)
}
}
@@ -40,7 +40,7 @@ func TestConvertOpenAIResponseToGeminiNonStream_MultiChoicePartsOverlay(t *testi
{"index":1,"message":{"role":"assistant","content":"choice 1 text"}}
]}`)
out1 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw1, nil)
- parts1 := gjson.GetBytes(out1, "candidates.0.content.parts").Array()
+ parts1 := ojson.GetBytesNoCopy(out1, "candidates.0.content.parts").Array()
if len(parts1) != 1 {
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts1), out1)
}
@@ -57,7 +57,7 @@ func TestConvertOpenAIResponseToGeminiNonStream_MultiChoicePartsOverlay(t *testi
{"index":1,"message":{"role":"assistant","content":"final text"}}
]}`)
out2 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw2, nil)
- parts2 := gjson.GetBytes(out2, "candidates.0.content.parts").Array()
+ parts2 := ojson.GetBytesNoCopy(out2, "candidates.0.content.parts").Array()
if len(parts2) != 1 {
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts2), out2)
}
@@ -74,7 +74,7 @@ func TestConvertOpenAIResponseToGeminiNonStream_MultiChoicePartsOverlay(t *testi
{"index":1,"message":{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"search","arguments":"{}"}}]}}
]}`)
out3 := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, raw3, nil)
- parts3 := gjson.GetBytes(out3, "candidates.0.content.parts").Array()
+ parts3 := ojson.GetBytesNoCopy(out3, "candidates.0.content.parts").Array()
if len(parts3) != 1 {
t.Fatalf("expected 1 merged part, got %d. Output: %s", len(parts3), out3)
}
@@ -93,7 +93,7 @@ func TestConvertOpenAIResponseToGeminiStream_NullFinishReasonIgnored(t *testing.
chunk1 := []byte(`{"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}`)
out1 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk1, ¶m)
for i, chunk := range out1 {
- if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
+ if fr := ojson.GetBytesNoCopy(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
t.Fatalf("chunk1[%d] unexpectedly set finishReason = %q on non-final chunk; payload=%s", i, fr.String(), chunk)
}
}
@@ -104,14 +104,14 @@ func TestConvertOpenAIResponseToGeminiStream_NullFinishReasonIgnored(t *testing.
if len(out2) == 0 {
t.Fatalf("expected output for reasoning chunk, got 0 chunks")
}
- if gotThought := gjson.GetBytes(out2[0], "candidates.0.content.parts.0.thought").Bool(); !gotThought {
+ if gotThought := ojson.GetBytesNoCopy(out2[0], "candidates.0.content.parts.0.thought").Bool(); !gotThought {
t.Fatalf("expected thought: true on reasoning chunk, got false")
}
- if gotText := gjson.GetBytes(out2[0], "candidates.0.content.parts.0.text").String(); gotText != "thinking..." {
+ if gotText := ojson.GetBytesNoCopy(out2[0], "candidates.0.content.parts.0.text").String(); gotText != "thinking..." {
t.Fatalf("expected reasoning text %q, got %q", "thinking...", gotText)
}
for i, chunk := range out2 {
- if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
+ if fr := ojson.GetBytesNoCopy(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
t.Fatalf("chunk2[%d] unexpectedly set finishReason = %q on reasoning chunk; payload=%s", i, fr.String(), chunk)
}
}
@@ -120,7 +120,7 @@ func TestConvertOpenAIResponseToGeminiStream_NullFinishReasonIgnored(t *testing.
chunk3 := []byte(`{"choices":[{"index":0,"delta":{},"finish_reason":""}]}`)
out3 := ConvertOpenAIResponseToGemini(context.Background(), "gpt-test", nil, nil, chunk3, ¶m)
for i, chunk := range out3 {
- if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
+ if fr := ojson.GetBytesNoCopy(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
t.Fatalf("chunk3[%d] unexpectedly set finishReason = %q on contentless chunk with empty finish_reason; payload=%s", i, fr.String(), chunk)
}
}
@@ -131,11 +131,11 @@ func TestConvertOpenAIResponseToGeminiStream_NullFinishReasonIgnored(t *testing.
if len(out4) == 0 {
t.Fatalf("expected output for content chunk, got 0 chunks")
}
- if gotText := gjson.GetBytes(out4[0], "candidates.0.content.parts.0.text").String(); gotText != "hello world" {
+ if gotText := ojson.GetBytesNoCopy(out4[0], "candidates.0.content.parts.0.text").String(); gotText != "hello world" {
t.Fatalf("expected content text %q, got %q", "hello world", gotText)
}
for i, chunk := range out4 {
- if fr := gjson.GetBytes(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
+ if fr := ojson.GetBytesNoCopy(chunk, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
t.Fatalf("chunk4[%d] unexpectedly set finishReason = %q on non-final content chunk; payload=%s", i, fr.String(), chunk)
}
}
@@ -146,7 +146,7 @@ func TestConvertOpenAIResponseToGeminiStream_NullFinishReasonIgnored(t *testing.
if len(out5) == 0 {
t.Fatalf("expected output for final chunk, got 0 chunks")
}
- if got := gjson.GetBytes(out5[len(out5)-1], "candidates.0.finishReason").String(); got != "STOP" {
+ if got := ojson.GetBytesNoCopy(out5[len(out5)-1], "candidates.0.finishReason").String(); got != "STOP" {
t.Fatalf("expected finishReason STOP on final chunk, got %q", got)
}
}
@@ -169,7 +169,7 @@ func TestConvertOpenAIResponseToGeminiNonStream_NullFinishReasonIgnored(t *testi
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
out := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "gpt-test", nil, nil, tc.payload, nil)
- if fr := gjson.GetBytes(out, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
+ if fr := ojson.GetBytesNoCopy(out, "candidates.0.finishReason"); fr.Exists() && fr.String() != "" {
t.Fatalf("unexpected finishReason = %q on non-stream response; output=%s", fr.String(), out)
}
})
@@ -182,10 +182,10 @@ func TestConvertOpenAIResponseToGeminiNonStream_PreservesToolCallThoughtSignatur
{"id":"c2","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"London\"}"}}
]}}]}`)
res := ConvertOpenAIResponseToGeminiNonStream(context.Background(), "", nil, nil, up, nil)
- if got := gjson.GetBytes(res, "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG" {
+ if got := ojson.GetBytesNoCopy(res, "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG" {
t.Fatalf("candidates.0.content.parts.0.thoughtSignature = %q, want SIG; res=%s", got, string(res))
}
- if gjson.GetBytes(res, "candidates.0.content.parts.1.thoughtSignature").Exists() {
+ if ojson.GetBytesNoCopy(res, "candidates.0.content.parts.1.thoughtSignature").Exists() {
t.Fatalf("expected unsigned second tool call not to have thoughtSignature; res=%s", string(res))
}
}
@@ -205,16 +205,16 @@ func TestConvertOpenAIResponseToGeminiStream_PreservesToolCallThoughtSignature(t
t.Fatalf("stream output is empty")
}
last := out[len(out)-1]
- if got := gjson.GetBytes(last, "candidates.0.content.parts.0.functionCall.id").String(); got != "c1" {
+ if got := ojson.GetBytesNoCopy(last, "candidates.0.content.parts.0.functionCall.id").String(); got != "c1" {
t.Fatalf("candidates.0.content.parts.0.functionCall.id = %q, want c1; payload=%s", got, string(last))
}
- if got := gjson.GetBytes(last, "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG" {
+ if got := ojson.GetBytesNoCopy(last, "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG" {
t.Fatalf("candidates.0.content.parts.0.thoughtSignature = %q, want SIG; payload=%s", got, string(last))
}
- if got := gjson.GetBytes(last, "candidates.0.content.parts.1.functionCall.id").String(); got != "c2" {
+ if got := ojson.GetBytesNoCopy(last, "candidates.0.content.parts.1.functionCall.id").String(); got != "c2" {
t.Fatalf("candidates.0.content.parts.1.functionCall.id = %q, want c2; payload=%s", got, string(last))
}
- if gjson.GetBytes(last, "candidates.0.content.parts.1.thoughtSignature").Exists() {
+ if ojson.GetBytesNoCopy(last, "candidates.0.content.parts.1.thoughtSignature").Exists() {
t.Fatalf("expected unsigned second tool call not to have thoughtSignature; payload=%s", string(last))
}
@@ -224,14 +224,14 @@ func TestConvertOpenAIResponseToGeminiStream_PreservesToolCallThoughtSignature(t
if len(singleOut) == 0 {
t.Fatalf("expected output for single-chunk tool_calls + finish_reason")
}
- if got := gjson.GetBytes(singleOut[len(singleOut)-1], "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG_SINGLE" {
+ if got := ojson.GetBytesNoCopy(singleOut[len(singleOut)-1], "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG_SINGLE" {
t.Fatalf("single-chunk thoughtSignature = %q, want SIG_SINGLE; payload=%s", got, string(singleOut[len(singleOut)-1]))
}
// Reasoning/content in the same delta as tool_calls, plus signature arriving in a later delta without function
var mixedParam any
mixedChunk1 := ConvertOpenAIResponseToGemini(context.Background(), "google/gemini-3-pro", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"reasoning_content":"checking weather","tool_calls":[{"index":0,"id":"c_mixed","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Berlin\"}"}}]}}]}`), &mixedParam)
- if len(mixedChunk1) != 1 || !gjson.GetBytes(mixedChunk1[0], "candidates.0.content.parts.0.thought").Bool() {
+ if len(mixedChunk1) != 1 || !ojson.GetBytesNoCopy(mixedChunk1[0], "candidates.0.content.parts.0.thought").Bool() {
t.Fatalf("expected reasoning chunk output on mixed delta, got %v", mixedChunk1)
}
ConvertOpenAIResponseToGemini(context.Background(), "google/gemini-3-pro", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"extra_content":{"google":{"thought_signature":"SIG_LATE"}}}]}}]}`), &mixedParam)
@@ -239,7 +239,7 @@ func TestConvertOpenAIResponseToGeminiStream_PreservesToolCallThoughtSignature(t
if len(mixedOut) == 0 {
t.Fatalf("expected output when flushing mixed tool call")
}
- if got := gjson.GetBytes(mixedOut[len(mixedOut)-1], "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG_LATE" {
+ if got := ojson.GetBytesNoCopy(mixedOut[len(mixedOut)-1], "candidates.0.content.parts.0.thoughtSignature").String(); got != "SIG_LATE" {
t.Fatalf("mixed/late-signature thoughtSignature = %q, want SIG_LATE; payload=%s", got, string(mixedOut[len(mixedOut)-1]))
}
}
@@ -250,10 +250,10 @@ func TestConvertOpenAIResponseToGeminiStream_ContentWithFinishReason(t *testing.
out := ConvertOpenAIResponseToGemini(context.Background(), "glm-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"content":"ok"},"finish_reason":"stop"}]}`), ¶m)
var text, reason string
for _, c := range out {
- if s := gjson.GetBytes(c, "candidates.0.content.parts.0.text").String(); s != "" {
+ if s := ojson.GetBytesNoCopy(c, "candidates.0.content.parts.0.text").String(); s != "" {
text = s
}
- if s := gjson.GetBytes(c, "candidates.0.finishReason").String(); s != "" {
+ if s := ojson.GetBytesNoCopy(c, "candidates.0.finishReason").String(); s != "" {
reason = s
}
}
@@ -265,10 +265,10 @@ func TestConvertOpenAIResponseToGeminiStream_ContentWithFinishReason(t *testing.
reasoningOut := ConvertOpenAIResponseToGemini(context.Background(), "glm-test", nil, nil, []byte(`{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":"length"}]}`), &reasoningParam)
var thoughtText, reasoningFinish string
for _, c := range reasoningOut {
- if gjson.GetBytes(c, "candidates.0.content.parts.0.thought").Bool() {
- thoughtText = gjson.GetBytes(c, "candidates.0.content.parts.0.text").String()
+ if ojson.GetBytesNoCopy(c, "candidates.0.content.parts.0.thought").Bool() {
+ thoughtText = ojson.GetBytesNoCopy(c, "candidates.0.content.parts.0.text").String()
}
- if s := gjson.GetBytes(c, "candidates.0.finishReason").String(); s != "" {
+ if s := ojson.GetBytesNoCopy(c, "candidates.0.finishReason").String(); s != "" {
reasoningFinish = s
}
}
diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go
index 501b037da2d..c4a0374d488 100644
--- a/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go
+++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_request.go
@@ -4,18 +4,18 @@ import (
"fmt"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","messages":[]}`)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","messages":[]}`)
model := firstNonEmpty(modelName, root.Get("model").String())
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
if stream || root.Get("stream").Bool() {
- out, _ = sjson.SetBytes(out, "stream", true)
+ _ = outDoc.Set("stream", true)
}
messageCapacity := root.Get("input.#").Int()
if interactionsText(root.Get("system_instruction")) != "" {
@@ -25,32 +25,32 @@ func ConvertInteractionsRequestToOpenAI(modelName string, inputRawJSON []byte, s
appendInteractionsSystemToOpenAI(&messageItems, root)
forAntigravity := isAntigravityModel(model)
appendInteractionsInputToOpenAIMessages(&messageItems, root.Get("input"), forAntigravity)
- out = translatorcommon.SetRawArrayItems(out, "messages", messageItems)
- out = copyInteractionsToolsToOpenAI(out, root, forAntigravity)
- out = copyInteractionsGenerationConfigToOpenAI(out, root)
- out = copyInteractionsOpenAITopLevel(out, root)
- return out, nil
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "messages", messageItems)
+ copyInteractionsToolsToOpenAIDoc(outDoc, root, forAntigravity)
+ copyInteractionsGenerationConfigToOpenAIDoc(outDoc, root)
+ copyInteractionsOpenAITopLevelDoc(outDoc, root)
+ return outDoc.Bytes(), nil
}
-func appendInteractionsSystemToOpenAI(items *[][]byte, root gjson.Result) {
+func appendInteractionsSystemToOpenAI(items *[][]byte, root ojson.Result) {
text := interactionsText(root.Get("system_instruction"))
if text == "" {
return
}
msg := []byte(`{"role":"system","content":""}`)
- msg, _ = sjson.SetBytes(msg, "content", text)
+ msg, _ = ojson.SetBytes(msg, "content", text)
*items = append(*items, msg)
}
-func appendInteractionsInputToOpenAIMessages(items *[][]byte, input gjson.Result, forAntigravity bool) {
- if input.Type == gjson.String {
+func appendInteractionsInputToOpenAIMessages(items *[][]byte, input ojson.Result, forAntigravity bool) {
+ if input.Type == ojson.String {
msg := []byte(`{"role":"user","content":""}`)
- msg, _ = sjson.SetBytes(msg, "content", input.String())
+ msg, _ = ojson.SetBytes(msg, "content", input.String())
*items = append(*items, msg)
return
}
if input.IsArray() {
- input.ForEach(func(_, step gjson.Result) bool {
+ input.ForEach(func(_, step ojson.Result) bool {
appendInteractionsStepToOpenAI(items, step, "user", forAntigravity)
return true
})
@@ -61,7 +61,7 @@ func appendInteractionsInputToOpenAIMessages(items *[][]byte, input gjson.Result
}
}
-func appendInteractionsStepToOpenAI(items *[][]byte, step gjson.Result, defaultRole string, forAntigravity bool) {
+func appendInteractionsStepToOpenAI(items *[][]byte, step ojson.Result, defaultRole string, forAntigravity bool) {
switch step.Get("type").String() {
case "user_input":
appendInteractionsMessageToOpenAI(items, step, "user")
@@ -74,58 +74,58 @@ func appendInteractionsStepToOpenAI(items *[][]byte, step gjson.Result, defaultR
case "function_result":
appendInteractionsFunctionResultToOpenAI(items, step)
default:
- if step.Type == gjson.String {
- msg := []byte(`{"role":"","content":""}`)
- msg, _ = sjson.SetBytes(msg, "role", defaultRole)
- msg, _ = sjson.SetBytes(msg, "content", step.String())
- *items = append(*items, msg)
+ if step.Type == ojson.String {
+ msgDoc := ojson.NewDocString(`{"role":"","content":""}`)
+ _ = msgDoc.Set("role", defaultRole)
+ _ = msgDoc.Set("content", step.String())
+ *items = append(*items, msgDoc.Bytes())
}
}
}
-func appendInteractionsMessageToOpenAI(items *[][]byte, step gjson.Result, role string) {
- msg := []byte(`{"role":"","content":""}`)
- msg, _ = sjson.SetBytes(msg, "role", role)
+func appendInteractionsMessageToOpenAI(items *[][]byte, step ojson.Result, role string) {
+ msgDoc := ojson.NewDocString(`{"role":"","content":""}`)
+ _ = msgDoc.Set("role", role)
content := step.Get("content")
- if content.Type == gjson.String {
- msg, _ = sjson.SetBytes(msg, "content", content.String())
+ if content.Type == ojson.String {
+ _ = msgDoc.Set("content", content.String())
} else {
- msg = appendInteractionsContentToOpenAIMessage(msg, content, role)
+ appendInteractionsContentToOpenAIMessageDoc(msgDoc, content, role)
}
- *items = append(*items, msg)
+ *items = append(*items, msgDoc.Bytes())
}
-func appendInteractionsThoughtToOpenAI(items *[][]byte, step gjson.Result) {
+func appendInteractionsThoughtToOpenAI(items *[][]byte, step ojson.Result) {
msg := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
- msg, _ = sjson.SetBytes(msg, "reasoning_content", interactionsText(step.Get("content")))
+ msg, _ = ojson.SetBytes(msg, "reasoning_content", interactionsText(step.Get("content")))
*items = append(*items, msg)
}
-func appendInteractionsContentToOpenAIMessage(msg []byte, content gjson.Result, role string) []byte {
+func appendInteractionsContentToOpenAIMessageDoc(msgDoc *ojson.Doc, content ojson.Result, role string) {
if !content.Exists() {
- return msg
+ return
}
- if content.Type == gjson.String {
- msg, _ = sjson.SetBytes(msg, "content", content.String())
- return msg
+ if content.Type == ojson.String {
+ _ = msgDoc.Set("content", content.String())
+ return
}
contentItems := make([][]byte, 0, 4)
textOnly := true
var textBuilder strings.Builder
- appendPart := func(part gjson.Result) {
+ appendPart := func(part ojson.Result) {
converted, ok := interactionsContentPartToOpenAI(part, role)
if !ok {
return
}
- if gjson.GetBytes(converted, "type").String() == "text" {
- textBuilder.WriteString(gjson.GetBytes(converted, "text").String())
+ if ojson.GetBytesNoCopy(converted, "type").String() == "text" {
+ textBuilder.WriteString(ojson.GetBytesNoCopy(converted, "text").String())
} else {
textOnly = false
}
contentItems = append(contentItems, converted)
}
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
appendPart(part)
return true
})
@@ -134,48 +134,47 @@ func appendInteractionsContentToOpenAIMessage(msg []byte, content gjson.Result,
}
if len(contentItems) > 0 {
if textOnly {
- msg, _ = sjson.SetBytes(msg, "content", textBuilder.String())
+ _ = msgDoc.Set("content", textBuilder.String())
} else {
- msg, _ = sjson.SetRawBytes(msg, "content", translatorcommon.JoinRawArray(contentItems))
+ _ = msgDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
}
}
- return msg
}
-func appendInteractionsFunctionCallToOpenAI(items *[][]byte, step gjson.Result, forAntigravity bool) {
+func appendInteractionsFunctionCallToOpenAI(items *[][]byte, step ojson.Result, forAntigravity bool) {
msg := []byte(`{"role":"assistant","content":"","tool_calls":[]}`)
- toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
+ toolCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0")
- toolCall, _ = sjson.SetBytes(toolCall, "id", callID)
+ _ = toolCallDoc.Set("id", callID)
name := step.Get("name").String()
if forAntigravity {
name = translatorcommon.AntigravityUpstreamToolNameToClient(name)
}
- toolCall, _ = sjson.SetBytes(toolCall, "function.name", name)
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(step.Get("arguments"), "{}"))
- msg = translatorcommon.SetRawArrayItems(msg, "tool_calls", [][]byte{toolCall})
+ _ = toolCallDoc.Set("function.name", name)
+ _ = toolCallDoc.Set("function.arguments", jsonStringValue(step.Get("arguments"), "{}"))
+ msg = translatorcommon.SetRawArrayItems(msg, "tool_calls", [][]byte{toolCallDoc.Bytes()})
*items = append(*items, msg)
}
-func appendInteractionsFunctionResultToOpenAI(items *[][]byte, step gjson.Result) {
- msg := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
- msg, _ = sjson.SetBytes(msg, "tool_call_id", firstNonEmpty(step.Get("call_id").String(), step.Get("id").String()))
- msg, _ = sjson.SetBytes(msg, "content", jsonStringValue(firstExisting(step.Get("result"), step.Get("output")), ""))
- *items = append(*items, msg)
+func appendInteractionsFunctionResultToOpenAI(items *[][]byte, step ojson.Result) {
+ msgDoc := ojson.NewDocString(`{"role":"tool","tool_call_id":"","content":""}`)
+ _ = msgDoc.Set("tool_call_id", firstNonEmpty(step.Get("call_id").String(), step.Get("id").String()))
+ _ = msgDoc.Set("content", jsonStringValue(firstExisting(step.Get("result"), step.Get("output")), ""))
+ *items = append(*items, msgDoc.Bytes())
}
-func copyInteractionsToolsToOpenAI(out []byte, root gjson.Result, forAntigravity bool) []byte {
+func copyInteractionsToolsToOpenAIDoc(outDoc *ojson.Doc, root ojson.Result, forAntigravity bool) {
tools := root.Get("tools")
if !tools.Exists() || !tools.IsArray() {
- return out
+ return
}
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if converted, ok := openAIToolFromInteractionsTool(tool, forAntigravity); ok {
toolItems = append(toolItems, converted)
}
if decls := firstExisting(tool.Get("function_declarations"), tool.Get("functionDeclarations")); decls.Exists() && decls.IsArray() {
- decls.ForEach(func(_, decl gjson.Result) bool {
+ decls.ForEach(func(_, decl ojson.Result) bool {
if converted, ok := openAIToolFromInteractionsTool(decl, forAntigravity); ok {
toolItems = append(toolItems, converted)
}
@@ -185,61 +184,58 @@ func copyInteractionsToolsToOpenAI(out []byte, root gjson.Result, forAntigravity
return true
})
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func copyInteractionsGenerationConfigToOpenAI(out []byte, root gjson.Result) []byte {
+func copyInteractionsGenerationConfigToOpenAIDoc(outDoc *ojson.Doc, root ojson.Result) {
gen := root.Get("generation_config")
if !gen.Exists() {
gen = root.Get("generationConfig")
}
- copyNumber(&out, "temperature", firstExisting(gen.Get("temperature"), root.Get("temperature")))
- copyNumber(&out, "max_tokens", firstExisting(gen.Get("max_output_tokens"), gen.Get("maxOutputTokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")))
- copyNumber(&out, "top_p", firstExisting(gen.Get("top_p"), gen.Get("topP"), root.Get("top_p")))
- copyNumber(&out, "top_k", firstExisting(gen.Get("top_k"), gen.Get("topK")))
- copyNumber(&out, "n", firstExisting(gen.Get("candidate_count"), gen.Get("candidateCount"), root.Get("n")))
+ copyNumberDoc(outDoc, "temperature", firstExisting(gen.Get("temperature"), root.Get("temperature")))
+ copyNumberDoc(outDoc, "max_tokens", firstExisting(gen.Get("max_output_tokens"), gen.Get("maxOutputTokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")))
+ copyNumberDoc(outDoc, "top_p", firstExisting(gen.Get("top_p"), gen.Get("topP"), root.Get("top_p")))
+ copyNumberDoc(outDoc, "top_k", firstExisting(gen.Get("top_k"), gen.Get("topK")))
+ copyNumberDoc(outDoc, "n", firstExisting(gen.Get("candidate_count"), gen.Get("candidateCount"), root.Get("n")))
if stop := firstExisting(gen.Get("stop_sequences"), gen.Get("stopSequences"), root.Get("stop")); stop.Exists() {
- out, _ = sjson.SetRawBytes(out, "stop", []byte(stop.Raw))
+ _ = outDoc.SetRaw("stop", stop.Raw)
}
if toolChoice := firstExisting(gen.Get("tool_choice"), root.Get("tool_choice")); toolChoice.Exists() {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
+ _ = outDoc.SetRaw("tool_choice", toolChoice.Raw)
}
if effort := interactionsReasoningEffort(root, gen); effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
if responseModalities := root.Get("response_modalities"); responseModalities.Exists() {
- out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw))
+ _ = outDoc.SetRaw("modalities", responseModalities.Raw)
}
- return out
}
-func copyInteractionsOpenAITopLevel(out []byte, root gjson.Result) []byte {
+func copyInteractionsOpenAITopLevelDoc(outDoc *ojson.Doc, root ojson.Result) {
if format := root.Get("response_format"); format.Exists() {
- out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
+ _ = outDoc.SetRaw("response_format", format.Raw)
}
- if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
+ if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == ojson.String {
+ _ = outDoc.Set("service_tier", serviceTier.String())
}
if previousInteractionID := firstNonEmpty(root.Get("previous_interaction_id").String(), root.Get("previous_response_id").String()); previousInteractionID != "" {
- out, _ = sjson.SetBytes(out, "previous_response_id", previousInteractionID)
+ _ = outDoc.Set("previous_response_id", previousInteractionID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
- out, _ = sjson.SetBytes(out, "environment_id", environmentID)
+ _ = outDoc.Set("environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
+ _ = outDoc.SetRaw("agent_config", agentConfig.Raw)
}
for _, key := range []string{"parallel_tool_calls", "seed", "user"} {
if value := root.Get(key); value.Exists() {
- out, _ = sjson.SetRawBytes(out, key, []byte(value.Raw))
+ _ = outDoc.SetRaw(key, value.Raw)
}
}
- return out
}
-func interactionsContentPartToOpenAI(part gjson.Result, role string) ([]byte, bool) {
+func interactionsContentPartToOpenAI(part ojson.Result, role string) ([]byte, bool) {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
@@ -247,37 +243,37 @@ func interactionsContentPartToOpenAI(part gjson.Result, role string) ([]byte, bo
switch partType {
case "text":
out := []byte(`{"type":"text","text":""}`)
- out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
+ out, _ = ojson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image":
out := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- out, _ = sjson.SetBytes(out, "image_url.url", interactionsMediaDataURL(part, "application/octet-stream"))
+ out, _ = ojson.SetBytes(out, "image_url.url", interactionsMediaDataURL(part, "application/octet-stream"))
return out, true
case "audio":
- out := []byte(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
- out, _ = sjson.SetBytes(out, "input_audio.data", part.Get("data").String())
- out, _ = sjson.SetBytes(out, "input_audio.format", openAIInputAudioFormatFromMIME(part.Get("mime_type").String()))
- return out, true
+ outDoc := ojson.NewDocString(`{"type":"input_audio","input_audio":{"data":"","format":""}}`)
+ _ = outDoc.Set("input_audio.data", part.Get("data").String())
+ _ = outDoc.Set("input_audio.format", openAIInputAudioFormatFromMIME(part.Get("mime_type").String()))
+ return outDoc.Bytes(), true
case "video":
out := []byte(`{"type":"video_url","video_url":{"url":""}}`)
- out, _ = sjson.SetBytes(out, "video_url.url", interactionsMediaDataURL(part, "video/mp4"))
+ out, _ = ojson.SetBytes(out, "video_url.url", interactionsMediaDataURL(part, "video/mp4"))
return out, true
case "document", "file":
- out := []byte(`{"type":"file","file":{"filename":"","file_data":""}}`)
- out, _ = sjson.SetBytes(out, "file.filename", firstNonEmpty(part.Get("filename").String(), openAIFileNameFromMIME(part.Get("mime_type").String())))
- out, _ = sjson.SetBytes(out, "file.file_data", part.Get("data").String())
+ outDoc := ojson.NewDocString(`{"type":"file","file":{"filename":"","file_data":""}}`)
+ _ = outDoc.Set("file.filename", firstNonEmpty(part.Get("filename").String(), openAIFileNameFromMIME(part.Get("mime_type").String())))
+ _ = outDoc.Set("file.file_data", part.Get("data").String())
if url := firstNonEmpty(part.Get("file_url").String(), part.Get("url").String()); url != "" {
- out, _ = sjson.DeleteBytes(out, "file.file_data")
- out, _ = sjson.SetBytes(out, "file.file_url", url)
+ _ = outDoc.Delete("file.file_data")
+ _ = outDoc.Set("file.file_url", url)
}
- return out, true
+ return outDoc.Bytes(), true
default:
_ = role
}
return nil, false
}
-func openAIToolFromInteractionsTool(tool gjson.Result, forAntigravity bool) ([]byte, bool) {
+func openAIToolFromInteractionsTool(tool ojson.Result, forAntigravity bool) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
@@ -285,22 +281,22 @@ func openAIToolFromInteractionsTool(tool gjson.Result, forAntigravity bool) ([]b
if forAntigravity {
name = translatorcommon.AntigravityUpstreamToolNameToClient(name)
}
- out := []byte(`{"type":"function","function":{"name":""}}`)
- out, _ = sjson.SetBytes(out, "function.name", name)
+ outDoc := ojson.NewDocString(`{"type":"function","function":{"name":""}}`)
+ _ = outDoc.Set("function.name", name)
if desc := firstExisting(tool.Get("description"), tool.Get("function.description")); desc.Exists() {
- out, _ = sjson.SetBytes(out, "function.description", desc.String())
+ _ = outDoc.Set("function.description", desc.String())
}
if params := firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")); params.Exists() {
- out, _ = sjson.SetRawBytes(out, "function.parameters", []byte(params.Raw))
+ _ = outDoc.SetRaw("function.parameters", params.Raw)
}
- return out, true
+ return outDoc.Bytes(), true
}
-func interactionsText(value gjson.Result) string {
+func interactionsText(value ojson.Result) string {
if !value.Exists() {
return ""
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
return value.String()
}
if text := value.Get("text"); text.Exists() {
@@ -312,7 +308,7 @@ func interactionsText(value gjson.Result) string {
continue
}
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
builder.WriteString(firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()))
return true
})
@@ -321,8 +317,8 @@ func interactionsText(value gjson.Result) string {
return ""
}
-func interactionsReasoningEffort(root, gen gjson.Result) string {
- for _, value := range []gjson.Result{
+func interactionsReasoningEffort(root, gen ojson.Result) string {
+ for _, value := range []ojson.Result{
gen.Get("reasoning_effort"),
gen.Get("thinking_level"),
gen.Get("thinkingLevel"),
@@ -330,14 +326,14 @@ func interactionsReasoningEffort(root, gen gjson.Result) string {
gen.Get("thinkingConfig.thinkingLevel"),
root.Get("reasoning_effort"),
} {
- if value.Exists() && value.Type == gjson.String {
+ if value.Exists() && value.Type == ojson.String {
return strings.ToLower(strings.TrimSpace(value.String()))
}
}
return ""
}
-func interactionsMediaDataURL(part gjson.Result, fallbackMimeType string) string {
+func interactionsMediaDataURL(part ojson.Result, fallbackMimeType string) string {
if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" {
return url
}
@@ -382,29 +378,29 @@ func openAIFileNameFromMIME(mimeType string) string {
}
}
-func copyNumber(out *[]byte, path string, value gjson.Result) {
+func copyNumberDoc(outDoc *ojson.Doc, path string, value ojson.Result) {
if value.Exists() {
- *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
+ _ = outDoc.SetRaw(path, value.Raw)
}
}
-func jsonStringValue(value gjson.Result, fallback string) string {
+func jsonStringValue(value ojson.Result, fallback string) string {
if !value.Exists() {
return fallback
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
return value.String()
}
return value.Raw
}
-func firstExisting(values ...gjson.Result) gjson.Result {
+func firstExisting(values ...ojson.Result) ojson.Result {
for _, value := range values {
if value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
func firstNonEmpty(values ...string) string {
diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go
index ee6f3bd98c8..cffeb624ed8 100644
--- a/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go
+++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_request_test.go
@@ -3,24 +3,24 @@ package chat_completions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertInteractionsRequestToOpenAIPreservesExpressibleFields(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false)
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.function.name").String(); got != "lookup" {
t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "modalities.0").String(); got != "text" {
t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" {
+ if got := ojson.GetBytesNoCopy(out, "modalities.1").String(); got != "image" {
t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytesNoCopy(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
}
@@ -28,34 +28,34 @@ func TestConvertInteractionsRequestToOpenAIPreservesExpressibleFields(t *testing
func TestConvertOpenAIRequestToInteractionsMapsMessagesToolsAndStream(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","stream":true,"messages":[{"role":"system","content":"be brief"},{"role":"user","content":"今天北京的天气怎么样?"}],"tools":[{"type":"function","function":{"name":"get_weather","description":"weather","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}}],"tool_choice":"auto","max_completion_tokens":128}`)
out, _ := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(out, "model").String(); got != "gemini-3.1-flash-lite" {
+ if got := ojson.GetBytesNoCopy(out, "model").String(); got != "gemini-3.1-flash-lite" {
t.Fatalf("model = %q, want gemini-3.1-flash-lite. Output: %s", got, string(out))
}
- if !gjson.GetBytes(out, "stream").Bool() {
+ if !ojson.GetBytesNoCopy(out, "stream").Bool() {
t.Fatalf("stream should be true. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "system_instruction").String(); got != "be brief" {
+ if got := ojson.GetBytesNoCopy(out, "system_instruction").String(); got != "be brief" {
t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "今天北京的天气怎么样?" {
t.Fatalf("input text = %q. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.parameters.properties.location.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.parameters.properties.location.type").String(); got != "string" {
t.Fatalf("tool schema missing. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 128 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.max_output_tokens").Int(); got != 128 {
t.Fatalf("max_output_tokens = %d, want 128. Output: %s", got, string(out))
}
}
@@ -63,31 +63,31 @@ func TestConvertOpenAIRequestToInteractionsMapsMessagesToolsAndStream(t *testing
func TestConvertOpenAIRequestToInteractionsMapsToolCallsAndResults(t *testing.T) {
raw := []byte(`{"model":"gemini-3.1-flash-lite","messages":[{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
out, _ := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "function_call" {
t.Fatalf("input.0.type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.id").String(); got != "call_1" {
t.Fatalf("id = %q, want call_1. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "input.0.call_id").Exists() {
+ if ojson.GetBytesNoCopy(out, "input.0.call_id").Exists() {
t.Fatalf("function_call should not have call_id parameter. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "input.0.arguments.q").String(); got != "x" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.type").String(); got != "function_result" {
t.Fatalf("input.1.type = %q, want function_result. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.name").String(); got != "lookup" {
t.Fatalf("name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "input.1.id").Exists() {
+ if ojson.GetBytesNoCopy(out, "input.1.id").Exists() {
t.Fatalf("function_result should not have id parameter. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "input.1.result").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.result").String(); got != "ok" {
t.Fatalf("result = %q, want ok. Output: %s", got, string(out))
}
}
@@ -112,26 +112,26 @@ func TestConvertOpenAIRequestToInteractionsInfersToolNamesForOutOfOrderResults(t
// input.1: function_call call_2 (weather)
// input.2: function_result call_2 (weather)
// input.3: function_result call_1 (lookup)
- if got := gjson.GetBytes(out, "input.2.call_id").String(); got != "call_2" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.call_id").String(); got != "call_2" {
t.Fatalf("input.2.call_id = %q, want call_2. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.name").String(); got != "weather" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.name").String(); got != "weather" {
t.Fatalf("input.2.name = %q, want weather. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.3.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.3.call_id").String(); got != "call_1" {
t.Fatalf("input.3.call_id = %q, want call_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.3.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "input.3.name").String(); got != "lookup" {
t.Fatalf("input.3.name = %q, want lookup. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIAcceptsImageContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "image_url" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "image_url" {
t.Fatalf("content type = %q, want image_url. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.image_url.url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image url = %q, want data:image/png;base64,aGVsbG8=. Output: %s", got, string(out))
}
}
@@ -139,29 +139,29 @@ func TestConvertInteractionsRequestToOpenAIAcceptsImageContent(t *testing.T) {
func TestConvertInteractionsRequestToOpenAIPreservesNonImageMediaContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
- if got := gjson.GetBytes(out, "messages.0.content.0.type").String(); got != "input_audio" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.type").String(); got != "input_audio" {
t.Fatalf("audio content type = %q, want input_audio. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.0.input_audio.format").String(); got != "wav" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.input_audio.format").String(); got != "wav" {
t.Fatalf("audio format = %q, want wav. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.1.type").String(); got != "video_url" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.1.type").String(); got != "video_url" {
t.Fatalf("video content type = %q, want video_url. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.0.content.2.type").String(); got != "file" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.2.type").String(); got != "file" {
t.Fatalf("document content type = %q, want file. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIWithToolMessagesDirect(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAI("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]},{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}},{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false)
- if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_calls.0.function.name").String(); got != "lookup" {
t.Fatalf("tool call name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.1.tool_calls.0.function.arguments").String(); got != `{"q":"x"}` {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_calls.0.function.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("tool call arguments = %q, want JSON object string. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String(); got != "call_1" {
t.Fatalf("tool_call_id = %q, want call_1. Output: %s", got, string(out))
}
}
@@ -178,11 +178,11 @@ func TestConvertOpenAIRequestToInteractions_AntigravitySanitizesGenerationConfig
out, _ := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
// generation_config should not contain temperature, top_p, max_output_tokens
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens"} {
- if gjson.GetBytes(out, "generation_config."+knob).Exists() {
+ if ojson.GetBytesNoCopy(out, "generation_config."+knob).Exists() {
t.Fatalf("generation_config.%s should be stripped for antigravity model. Output: %s", knob, string(out))
}
}
- if got := gjson.GetBytes(out, "agent_config.max_total_tokens").Int(); got != 1024 {
+ if got := ojson.GetBytesNoCopy(out, "agent_config.max_total_tokens").Int(); got != 1024 {
t.Fatalf("agent_config.max_total_tokens = %d, want 1024. Output: %s", got, string(out))
}
}
@@ -195,10 +195,10 @@ func TestConvertOpenAIRequestToInteractions_PreservesEnvironmentIDAndPreviousInt
"environment_id":"env_456"
}`)
out, _ := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "v1_prev123" {
+ if got := ojson.GetBytesNoCopy(out, "previous_interaction_id").String(); got != "v1_prev123" {
t.Fatalf("previous_interaction_id = %q, want v1_prev123. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "environment_id").String(); got != "env_456" {
+ if got := ojson.GetBytesNoCopy(out, "environment_id").String(); got != "env_456" {
t.Fatalf("environment_id = %q, want env_456. Output: %s", got, string(out))
}
}
@@ -211,20 +211,20 @@ func TestConvertOpenAIRequestToInteractionsRenamesConflictingAntigravityTools(t
{"type":"function","function":{"name":"web_search","description":"s","parameters":{"type":"object"}}}
]}`)
out, _ := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "external_read_file" {
t.Fatalf("tools.0.name = %q, want external_read_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "external_write_file" {
+ if got := ojson.GetBytesNoCopy(out, "tools.1.name").String(); got != "external_write_file" {
t.Fatalf("tools.1.name = %q, want external_write_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.2.name").String(); got != "external_execute_code" {
+ if got := ojson.GetBytesNoCopy(out, "tools.2.name").String(); got != "external_execute_code" {
t.Fatalf("tools.2.name = %q, want external_execute_code. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.3.name").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(out, "tools.3.name").String(); got != "web_search" {
t.Fatalf("tools.3.name = %q, want web_search (unchanged). Output: %s", got, string(out))
}
outNonAnti, _ := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(outNonAnti, "tools.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(outNonAnti, "tools.0.name").String(); got != "read_file" {
t.Fatalf("gemini tools.0.name = %q, want read_file (no rename). Output: %s", got, string(outNonAnti))
}
}
@@ -238,10 +238,10 @@ func TestConvertOpenAIRequestToInteractionsRenamesConflictingAntigravityToolCall
{"type":"function","function":{"name":"read_file","parameters":{"type":"object"}}}
]}`)
out, _ := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "input.1.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.name").String(); got != "external_read_file" {
t.Fatalf("input.1.name (function_call) = %q, want external_read_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.name").String(); got != "external_read_file" {
t.Fatalf("input.2.name (function_result) = %q, want external_read_file. Output: %s", got, string(out))
}
}
@@ -254,11 +254,11 @@ func TestConvertOpenAIRequestToInteractionsRenamesConflictingToolChoice(t *testi
"tool_choice":{"type":"function","function":{"name":"read_file"}}
}`)
out, _ := ConvertOpenAIRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "generation_config.tool_choice.function.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.tool_choice.function.name").String(); got != "external_read_file" {
t.Fatalf("generation_config.tool_choice.function.name = %q, want external_read_file. Output: %s", got, string(out))
}
outNonAnti, _ := ConvertOpenAIRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(outNonAnti, "generation_config.tool_choice.function.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(outNonAnti, "generation_config.tool_choice.function.name").String(); got != "read_file" {
t.Fatalf("gemini tool_choice.function.name = %q, want read_file (no rename). Output: %s", got, string(outNonAnti))
}
}
diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go
index 353efe3cf93..ba1fc4b1c9b 100644
--- a/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go
+++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_response.go
@@ -7,9 +7,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type openAIToInteractionsStreamState struct {
@@ -25,7 +25,7 @@ type openAIToInteractionsStreamState struct {
StepIndex int
ActiveStepIndex int
ActiveStepOpen bool
- Usage gjson.Result
+ Usage ojson.Result
}
func ConvertOpenAIResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
@@ -53,13 +53,13 @@ func ConvertOpenAIResponseToInteractionsNonStream(ctx context.Context, modelName
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
- out := []byte(`{"id":"","status":"completed","object":"interaction","model":"","steps":[]}`)
- out, _ = sjson.SetBytes(out, "id", firstNonEmpty(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
- out, _ = sjson.SetBytes(out, "model", firstNonEmpty(modelName, root.Get("model").String()))
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
+ outDoc := ojson.NewDocString(`{"id":"","status":"completed","object":"interaction","model":"","steps":[]}`)
+ _ = outDoc.Set("id", firstNonEmpty(root.Get("id").String(), fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
+ _ = outDoc.Set("model", firstNonEmpty(modelName, root.Get("model").String()))
choices := root.Get("choices")
var steps [][]byte
- choices.ForEach(func(_, choice gjson.Result) bool {
+ choices.ForEach(func(_, choice ojson.Result) bool {
message := choice.Get("message")
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
@@ -71,7 +71,7 @@ func ConvertOpenAIResponseToInteractionsNonStream(ctx context.Context, modelName
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
forAntigravity := isAntigravityModel(modelName)
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
if step, ok := openAIToolCallToInteractionsStep(toolCall, forAntigravity); ok {
steps = append(steps, step)
}
@@ -79,15 +79,15 @@ func ConvertOpenAIResponseToInteractionsNonStream(ctx context.Context, modelName
})
}
if finishReason := choice.Get("finish_reason"); finishReason.Exists() {
- out, _ = sjson.SetBytes(out, "finish_reason", finishReason.String())
+ _ = outDoc.Set("finish_reason", finishReason.String())
}
return true
})
if len(steps) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "steps", steps)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "steps", steps)
}
- out = setInteractionsUsageFromOpenAIChat(out, "usage", root.Get("usage"))
- return out
+ setInteractionsUsageFromOpenAIChatDoc(outDoc, "usage", root.Get("usage"))
+ return outDoc.Bytes()
}
func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st *openAIToInteractionsStreamState) [][]byte {
@@ -99,16 +99,16 @@ func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st
out := make([][]byte, 0, 3)
out = appendInteractionsStepStop(out, st)
if !st.Completed {
- out = appendInteractionsCompleted(out, st, modelName, gjson.Result{})
+ out = appendInteractionsCompleted(out, st, modelName, ojson.Result{})
}
return appendInteractionsDone(out, st)
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if !root.Exists() {
return nil
}
if usage := root.Get("usage"); usage.Exists() {
- st.Usage = usage
+ st.Usage = ojson.Parse(strings.Clone(usage.Raw))
}
out := make([][]byte, 0)
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
@@ -119,7 +119,7 @@ func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st
}
return out
}
- choices.ForEach(func(_, choice gjson.Result) bool {
+ choices.ForEach(func(_, choice ojson.Result) bool {
delta := choice.Get("delta")
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
for _, text := range openAIReasoningTexts(reasoning) {
@@ -132,7 +132,7 @@ func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st
out = appendInteractionsTextDelta(out, st, content.String(), false)
}
if toolCalls := delta.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
out = appendOpenAIToolCallDelta(out, st, modelName, root, toolCall)
return true
})
@@ -146,27 +146,27 @@ func convertOpenAIChatStreamToInteractions(modelName string, rawJSON []byte, st
return out
}
-func appendOpenAIToolCallDelta(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root, toolCall gjson.Result) [][]byte {
+func appendOpenAIToolCallDelta(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root, toolCall ojson.Result) [][]byte {
index := int(toolCall.Get("index").Int())
if id := toolCall.Get("id").String(); id != "" {
- st.ToolCallIDs[index] = id
+ st.ToolCallIDs[index] = strings.Clone(id)
}
function := toolCall.Get("function")
if name := function.Get("name").String(); name != "" {
if isAntigravityModel(modelName) {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- st.ToolCallNames[index] = name
+ st.ToolCallNames[index] = strings.Clone(name)
}
stepID := firstNonEmpty(st.ToolCallIDs[index], fmt.Sprintf("call_%d", index))
stepName := st.ToolCallNames[index]
if st.CurrentStepType != "function_call" || st.CurrentStepID != stepID {
out = appendInteractionsStepStop(out, st)
- step := []byte(`{"type":"function_call","id":"","name":"","arguments":{}}`)
- step, _ = sjson.SetBytes(step, "id", stepID)
- step, _ = sjson.SetBytes(step, "name", stepName)
+ stepDoc := ojson.NewDocString(`{"type":"function_call","id":"","name":"","arguments":{}}`)
+ _ = stepDoc.Set("id", stepID)
+ _ = stepDoc.Set("name", stepName)
out = appendInteractionsCreated(out, st, modelName, root)
- out = appendInteractionsStepStart(out, st, "function_call", gjson.ParseBytes(step))
+ out = appendInteractionsStepStart(out, st, "function_call", util.ParseGJSONBytesNoCopy(stepDoc.Bytes()))
}
if args := function.Get("arguments"); args.Exists() && args.String() != "" {
out = appendInteractionsArgumentsDelta(out, st, args.String())
@@ -174,15 +174,15 @@ func appendOpenAIToolCallDelta(out [][]byte, st *openAIToInteractionsStreamState
return out
}
-func appendInteractionsCreated(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
+func appendInteractionsCreated(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root ojson.Result) [][]byte {
if st.Created {
return out
}
- st.ID = firstNonEmpty(root.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
- created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
- created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
- created, _ = sjson.SetBytes(created, "interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
- out = append(out, translatorcommon.SSEEventData("interaction.created", created))
+ st.ID = strings.Clone(firstNonEmpty(root.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
+ createdDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
+ _ = createdDoc.Set("interaction.id", st.ID)
+ _ = createdDoc.Set("interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
+ out = append(out, translatorcommon.SSEEventData("interaction.created", createdDoc.Bytes()))
st.Created = true
return appendInteractionsStatusUpdate(out, st)
}
@@ -192,13 +192,13 @@ func appendInteractionsStatusUpdate(out [][]byte, st *openAIToInteractionsStream
return out
}
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
- statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
+ statusUpdate, _ = ojson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, translatorcommon.SSEEventData("interaction.status_update", statusUpdate))
st.StatusUpdated = true
return out
}
-func ensureInteractionsStep(out [][]byte, st *openAIToInteractionsStreamState, modelName, stepType string, step gjson.Result) [][]byte {
+func ensureInteractionsStep(out [][]byte, st *openAIToInteractionsStreamState, modelName, stepType string, step ojson.Result) [][]byte {
out = appendInteractionsCreated(out, st, modelName, step)
if st.ActiveStepOpen && st.CurrentStepType == stepType {
return out
@@ -207,47 +207,47 @@ func ensureInteractionsStep(out [][]byte, st *openAIToInteractionsStreamState, m
return appendInteractionsStepStart(out, st, stepType, step)
}
-func appendInteractionsStepStart(out [][]byte, st *openAIToInteractionsStreamState, stepType string, step gjson.Result) [][]byte {
+func appendInteractionsStepStart(out [][]byte, st *openAIToInteractionsStreamState, stepType string, step ojson.Result) [][]byte {
index := st.StepIndex
st.StepIndex++
st.ActiveStepIndex = index
st.CurrentStepType = stepType
st.ActiveStepOpen = true
- payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
- payload, _ = sjson.SetBytes(payload, "index", index)
- payload, _ = sjson.SetBytes(payload, "step.type", stepType)
+ payloadDoc := ojson.NewDocString(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
+ _ = payloadDoc.Set("index", index)
+ _ = payloadDoc.Set("step.type", stepType)
if stepType == "function_call" {
id := firstNonEmpty(step.Get("id").String(), step.Get("call_id").String(), st.CurrentStepID)
- st.CurrentStepID = id
+ st.CurrentStepID = strings.Clone(id)
if id != "" {
- payload, _ = sjson.SetBytes(payload, "step.id", id)
+ _ = payloadDoc.Set("step.id", id)
}
- payload, _ = sjson.SetBytes(payload, "step.name", step.Get("name").String())
- payload, _ = sjson.SetRawBytes(payload, "step.arguments", []byte(`{}`))
+ _ = payloadDoc.Set("step.name", step.Get("name").String())
+ _ = payloadDoc.SetRaw("step.arguments", `{}`)
} else {
st.CurrentStepID = ""
}
- return append(out, translatorcommon.SSEEventData("step.start", payload))
+ return append(out, translatorcommon.SSEEventData("step.start", payloadDoc.Bytes()))
}
func appendInteractionsTextDelta(out [][]byte, st *openAIToInteractionsStreamState, text string, thought bool) [][]byte {
if thought {
- payload := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = sjson.SetBytes(payload, "delta.content.text", text)
- return append(out, translatorcommon.SSEEventData("step.delta", payload))
- }
- payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = sjson.SetBytes(payload, "delta.text", text)
- return append(out, translatorcommon.SSEEventData("step.delta", payload))
+ payloadDoc := ojson.NewDocString(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = payloadDoc.Set("delta.content.text", text)
+ return append(out, translatorcommon.SSEEventData("step.delta", payloadDoc.Bytes()))
+ }
+ payloadDoc := ojson.NewDocString(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = payloadDoc.Set("delta.text", text)
+ return append(out, translatorcommon.SSEEventData("step.delta", payloadDoc.Bytes()))
}
func appendInteractionsArgumentsDelta(out [][]byte, st *openAIToInteractionsStreamState, arguments string) [][]byte {
- payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = sjson.SetBytes(payload, "delta.arguments", arguments)
- return append(out, translatorcommon.SSEEventData("step.delta", payload))
+ payloadDoc := ojson.NewDocString(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = payloadDoc.Set("delta.arguments", arguments)
+ return append(out, translatorcommon.SSEEventData("step.delta", payloadDoc.Bytes()))
}
func appendInteractionsStepStop(out [][]byte, st *openAIToInteractionsStreamState) [][]byte {
@@ -255,7 +255,7 @@ func appendInteractionsStepStop(out [][]byte, st *openAIToInteractionsStreamStat
return out
}
payload := []byte(`{"index":0,"event_type":"step.stop"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
+ payload, _ = ojson.SetBytes(payload, "index", st.ActiveStepIndex)
out = append(out, translatorcommon.SSEEventData("step.stop", payload))
st.ActiveStepOpen = false
st.CurrentStepType = ""
@@ -263,7 +263,7 @@ func appendInteractionsStepStop(out [][]byte, st *openAIToInteractionsStreamStat
return out
}
-func appendInteractionsCompleted(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
+func appendInteractionsCompleted(out [][]byte, st *openAIToInteractionsStreamState, modelName string, root ojson.Result) [][]byte {
if st.Completed {
return out
}
@@ -271,17 +271,17 @@ func appendInteractionsCompleted(out [][]byte, st *openAIToInteractionsStreamSta
out = appendInteractionsCreated(out, st, modelName, root)
}
now := time.Now().UTC().Format(time.RFC3339)
- payload := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
- payload, _ = sjson.SetBytes(payload, "interaction.id", st.ID)
- payload, _ = sjson.SetBytes(payload, "interaction.created", now)
- payload, _ = sjson.SetBytes(payload, "interaction.updated", now)
- payload, _ = sjson.SetBytes(payload, "interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
+ payloadDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
+ _ = payloadDoc.Set("interaction.id", st.ID)
+ _ = payloadDoc.Set("interaction.created", now)
+ _ = payloadDoc.Set("interaction.updated", now)
+ _ = payloadDoc.Set("interaction.model", firstNonEmpty(modelName, root.Get("model").String()))
usage := root.Get("usage")
if !usage.Exists() {
usage = st.Usage
}
- payload = setInteractionsUsageFromOpenAIChat(payload, "interaction.usage", usage)
- out = append(out, translatorcommon.SSEEventData("interaction.completed", payload))
+ setInteractionsUsageFromOpenAIChatDoc(payloadDoc, "interaction.usage", usage)
+ out = append(out, translatorcommon.SSEEventData("interaction.completed", payloadDoc.Bytes()))
st.Completed = true
return out
}
@@ -321,13 +321,13 @@ func openAIChatSSEPayload(rawJSON []byte) []byte {
}
func interactionsTextStep(stepType, text string) []byte {
- step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
- step, _ = sjson.SetBytes(step, "content.0.text", text)
- return step
+ stepDoc := ojson.NewDocString(`{"type":"","content":[{"type":"text","text":""}]}`)
+ _ = stepDoc.Set("type", stepType)
+ _ = stepDoc.Set("content.0.text", text)
+ return stepDoc.Bytes()
}
-func openAIToolCallToInteractionsStep(toolCall gjson.Result, forAntigravity bool) ([]byte, bool) {
+func openAIToolCallToInteractionsStep(toolCall ojson.Result, forAntigravity bool) ([]byte, bool) {
if toolType := toolCall.Get("type").String(); toolType != "" && toolType != "function" {
return nil, false
}
@@ -335,47 +335,46 @@ func openAIToolCallToInteractionsStep(toolCall gjson.Result, forAntigravity bool
if !function.Exists() {
return nil, false
}
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
+ stepDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
if id := toolCall.Get("id").String(); id != "" {
- step, _ = sjson.SetBytes(step, "id", id)
+ _ = stepDoc.Set("id", id)
}
name := function.Get("name").String()
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- step, _ = sjson.SetBytes(step, "name", name)
- setRawJSONValue(&step, "arguments", function.Get("arguments"), []byte(`{}`))
- return step, true
+ _ = stepDoc.Set("name", name)
+ setRawJSONValueDoc(stepDoc, "arguments", function.Get("arguments"), `{}`)
+ return stepDoc.Bytes(), true
}
-func setInteractionsUsageFromOpenAIChat(out []byte, path string, usage gjson.Result) []byte {
+func setInteractionsUsageFromOpenAIChatDoc(outDoc *ojson.Doc, path string, usage ojson.Result) {
if !usage.Exists() {
- return out
+ return
}
if value := usage.Get("prompt_tokens"); value.Exists() {
- out, _ = sjson.SetBytes(out, path+".input_tokens", value.Int())
- out, _ = sjson.SetBytes(out, path+".total_input_tokens", value.Int())
+ _ = outDoc.Set(path+".input_tokens", value.Int())
+ _ = outDoc.Set(path+".total_input_tokens", value.Int())
}
if value := usage.Get("completion_tokens"); value.Exists() {
- out, _ = sjson.SetBytes(out, path+".output_tokens", value.Int())
- out, _ = sjson.SetBytes(out, path+".total_output_tokens", value.Int())
+ _ = outDoc.Set(path+".output_tokens", value.Int())
+ _ = outDoc.Set(path+".total_output_tokens", value.Int())
}
if value := usage.Get("total_tokens"); value.Exists() {
- out, _ = sjson.SetBytes(out, path+".total_tokens", value.Int())
+ _ = outDoc.Set(path+".total_tokens", value.Int())
}
if value := usage.Get("prompt_tokens_details.cached_tokens"); value.Exists() {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", value.Int())
- out, _ = sjson.SetBytes(out, path+".total_cached_tokens", value.Int())
+ _ = outDoc.Set(path+".cached_tokens", value.Int())
+ _ = outDoc.Set(path+".total_cached_tokens", value.Int())
}
if value := usage.Get("completion_tokens_details.reasoning_tokens"); value.Exists() {
- out, _ = sjson.SetBytes(out, path+".reasoning_tokens", value.Int())
- out, _ = sjson.SetBytes(out, path+".total_thought_tokens", value.Int())
+ _ = outDoc.Set(path+".reasoning_tokens", value.Int())
+ _ = outDoc.Set(path+".total_thought_tokens", value.Int())
}
- return out
}
-func openAIReasoningTexts(reasoning gjson.Result) []string {
- if reasoning.Type == gjson.String {
+func openAIReasoningTexts(reasoning ojson.Result) []string {
+ if reasoning.Type == ojson.String {
if reasoning.String() == "" {
return nil
}
@@ -383,7 +382,7 @@ func openAIReasoningTexts(reasoning gjson.Result) []string {
}
texts := make([]string, 0)
if reasoning.IsArray() {
- reasoning.ForEach(func(_, item gjson.Result) bool {
+ reasoning.ForEach(func(_, item ojson.Result) bool {
if text := firstNonEmpty(item.Get("text").String(), item.Get("content").String()); text != "" {
texts = append(texts, text)
}
@@ -393,19 +392,19 @@ func openAIReasoningTexts(reasoning gjson.Result) []string {
return texts
}
-func setRawJSONValue(out *[]byte, path string, value gjson.Result, fallback []byte) {
+func setRawJSONValueDoc(outDoc *ojson.Doc, path string, value ojson.Result, fallback string) {
if !value.Exists() {
- *out, _ = sjson.SetRawBytes(*out, path, fallback)
+ _ = outDoc.SetRaw(path, fallback)
return
}
raw := strings.TrimSpace(value.String())
- if value.Type == gjson.String && gjson.Valid(raw) {
- *out, _ = sjson.SetRawBytes(*out, path, []byte(raw))
+ if value.Type == ojson.String && ojson.Valid(raw) {
+ _ = outDoc.SetRaw(path, raw)
return
}
- if value.Type == gjson.String {
- *out, _ = sjson.SetBytes(*out, path, value.String())
+ if value.Type == ojson.String {
+ _ = outDoc.Set(path, value.String())
return
}
- *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
+ _ = outDoc.SetRaw(path, value.Raw)
}
diff --git a/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go b/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go
index f298c100cfd..2cf6d496a7c 100644
--- a/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go
+++ b/internal/translator/openai/interactions/chat-completions/interactions_openai_response_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponseToInteractionsStreamUsageOnlyTerminalChunk(t *testing.T) {
@@ -32,13 +32,13 @@ func TestConvertOpenAIResponseToInteractionsStreamUsageOnlyTerminalChunk(t *test
t.Fatalf("done event count = %d, want 1", got)
}
payload := findInteractionsEventPayload(usageOut, "interaction.completed")
- if got := gjson.GetBytes(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_input_tokens").Int(); got != 3 {
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "interaction.usage.total_output_tokens").Int(); got != 4 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_output_tokens").Int(); got != 4 {
t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 7 {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.usage.total_tokens").Int(); got != 7 {
t.Fatalf("total_tokens = %d, want 7. Payload: %s", got, string(payload))
}
}
@@ -67,10 +67,10 @@ func TestConvertOpenAIResponseToInteractionsStreamCreatedUsesChunkIdentity(t *te
raw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}`)
out := ConvertOpenAIResponseToInteractions(context.Background(), "", nil, nil, raw, ¶m)
payload := findInteractionsEventPayload(out, "interaction.created")
- if got := gjson.GetBytes(payload, "interaction.id").String(); got != "chatcmpl_1" {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.id").String(); got != "chatcmpl_1" {
t.Fatalf("interaction.id = %q, want chatcmpl_1. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "interaction.model").String(); got != "gpt-test" {
+ if got := ojson.GetBytesNoCopy(payload, "interaction.model").String(); got != "gpt-test" {
t.Fatalf("interaction.model = %q, want gpt-test. Payload: %s", got, string(payload))
}
}
@@ -78,16 +78,16 @@ func TestConvertOpenAIResponseToInteractionsStreamCreatedUsesChunkIdentity(t *te
func TestConvertOpenAIResponseToInteractionsNonStreamDirectToolCall(t *testing.T) {
raw := []byte(`{"id":"chatcmpl_1","model":"gpt-test","choices":[{"message":{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`)
out := ConvertOpenAIResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "steps.0.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.id").String(); got != "call_1" {
t.Fatalf("id = %q, want call_1. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "steps.0.call_id").Exists() {
+ if ojson.GetBytesNoCopy(out, "steps.0.call_id").Exists() {
t.Fatalf("steps.0 should not have call_id parameter. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x. Output: %s", got, string(out))
}
}
@@ -97,16 +97,16 @@ func TestConvertOpenAIResponseToInteractionsStreamToolCall(t *testing.T) {
raw := []byte(`data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"x\"}"}}]},"finish_reason":null}]}`)
out := ConvertOpenAIResponseToInteractions(context.Background(), "gpt-test", nil, nil, raw, ¶m)
payload := findInteractionsEventPayload(out, "step.start")
- if got := gjson.GetBytes(payload, "step.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(payload, "step.type").String(); got != "function_call" {
t.Fatalf("step.type = %q, want function_call. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "step.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(payload, "step.id").String(); got != "call_1" {
t.Fatalf("step.id = %q, want call_1. Payload: %s", got, string(payload))
}
- if gjson.GetBytes(payload, "step.call_id").Exists() {
+ if ojson.GetBytesNoCopy(payload, "step.call_id").Exists() {
t.Fatalf("step.call_id must be omitted in stream. Payload: %s", string(payload))
}
- if got := gjson.GetBytes(payload, "step.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(payload, "step.name").String(); got != "lookup" {
t.Fatalf("step.name = %q, want lookup. Payload: %s", got, string(payload))
}
}
@@ -125,21 +125,21 @@ func TestConvertInteractionsResponseToOpenAIStreamToolCall(t *testing.T) {
out = append(out, ConvertInteractionsResponseToOpenAI(context.Background(), "gemini-3.1-flash-lite", nil, nil, chunk, ¶m)...)
}
toolStart := findOpenAIChatChunk(out, "choices.0.delta.tool_calls.0.function.name")
- if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(toolStart, "choices.0.delta.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Payload: %s", got, string(toolStart))
}
- if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Payload: %s", got, string(toolStart))
}
toolArgs := findOpenAIChatChunkValue(out, "choices.0.delta.tool_calls.0.function.arguments", `{"location":"北京"}`)
- if got := gjson.GetBytes(toolArgs, "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
+ if got := ojson.GetBytesNoCopy(toolArgs, "choices.0.delta.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("tool args = %q, want location JSON. Payload: %s", got, string(toolArgs))
}
completed := findOpenAIChatChunkValue(out, "choices.0.finish_reason", "tool_calls")
- if got := gjson.GetBytes(completed, "choices.0.finish_reason").String(); got != "tool_calls" {
+ if got := ojson.GetBytesNoCopy(completed, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(completed, "usage.prompt_tokens").Int(); got != 2 {
t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed))
}
}
@@ -151,19 +151,19 @@ func TestConvertInteractionsResponseToOpenAIStreamFinishMetadataUsage(t *testing
if len(completed) == 0 {
t.Fatalf("completion chunk not found")
}
- if got := gjson.GetBytes(completed, "usage.prompt_tokens").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(completed, "usage.prompt_tokens").Int(); got != 2 {
t.Fatalf("prompt_tokens = %d, want 2. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "usage.completion_tokens").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(completed, "usage.completion_tokens").Int(); got != 6 {
t.Fatalf("completion_tokens = %d, want 6. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(completed, "usage.completion_tokens_details.reasoning_tokens").Int(); got != 3 {
t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "usage.prompt_tokens_details.cached_tokens").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(completed, "usage.prompt_tokens_details.cached_tokens").Int(); got != 1 {
t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(completed))
}
- if got := gjson.GetBytes(completed, "usage.total_tokens").Int(); got != 11 {
+ if got := ojson.GetBytesNoCopy(completed, "usage.total_tokens").Int(); got != 11 {
t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(completed))
}
}
@@ -171,16 +171,16 @@ func TestConvertInteractionsResponseToOpenAIStreamFinishMetadataUsage(t *testing
func TestConvertInteractionsResponseToOpenAINonStreamToolCall(t *testing.T) {
raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","id":"call_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("tool call id = %q, want call_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.name").String(); got != "get_weather" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0.function.name").String(); got != "get_weather" {
t.Fatalf("tool name = %q, want get_weather. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0.function.arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("tool args = %q, want location JSON. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "choices.0.finish_reason").String(); got != "tool_calls" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.finish_reason").String(); got != "tool_calls" {
t.Fatalf("finish_reason = %q, want tool_calls. Output: %s", got, string(out))
}
}
@@ -188,7 +188,7 @@ func TestConvertInteractionsResponseToOpenAINonStreamToolCall(t *testing.T) {
func TestConvertInteractionsResponseToOpenAINonStream_PreservesEnvironmentID(t *testing.T) {
raw := []byte(`{"id":"i1","model":"antigravity-preview-05-2026","environment_id":"env_chat123","steps":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}],"usage":{"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "antigravity-preview-05-2026", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "environment_id").String(); got != "env_chat123" {
+ if got := ojson.GetBytesNoCopy(out, "environment_id").String(); got != "env_chat123" {
t.Fatalf("environment_id = %q, want env_chat123. Output: %s", got, string(out))
}
}
@@ -200,7 +200,7 @@ func TestConvertInteractionsResponseToOpenAIStream_PreservesEnvironmentID(t *tes
if len(out) == 0 {
t.Fatalf("no output chunks generated")
}
- if got := gjson.GetBytes(out[0], "environment_id").String(); got != "env_chat_stream456" {
+ if got := ojson.GetBytesNoCopy(out[0], "environment_id").String(); got != "env_chat_stream456" {
t.Fatalf("environment_id = %q, want env_chat_stream456. Chunk: %s", got, string(out[0]))
}
}
@@ -208,7 +208,7 @@ func TestConvertInteractionsResponseToOpenAIStream_PreservesEnvironmentID(t *tes
func TestConvertInteractionsResponseToOpenAINonStreamRestoresAntigravityToolName(t *testing.T) {
raw := []byte(`{"id":"i1","model":"antigravity-preview-05-2026","steps":[{"type":"function_call","id":"call_1","name":"external_read_file","arguments":{"path":"/etc/hosts"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
out := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "antigravity-preview-05-2026", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "choices.0.message.tool_calls.0.function.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(out, "choices.0.message.tool_calls.0.function.name").String(); got != "read_file" {
t.Fatalf("tool name = %q, want read_file (restored from external_read_file). Output: %s", got, string(out))
}
}
@@ -227,7 +227,7 @@ func TestConvertInteractionsResponseToOpenAIStreamRestoresAntigravityToolName(t
out = append(out, ConvertInteractionsResponseToOpenAI(context.Background(), "antigravity-preview-05-2026", nil, nil, chunk, ¶m)...)
}
toolStart := findOpenAIChatChunk(out, "choices.0.delta.tool_calls.0.function.name")
- if got := gjson.GetBytes(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(toolStart, "choices.0.delta.tool_calls.0.function.name").String(); got != "read_file" {
t.Fatalf("stream tool name = %q, want read_file (restored from external_read_file). Payload: %s", got, string(toolStart))
}
}
@@ -235,13 +235,13 @@ func TestConvertInteractionsResponseToOpenAIStreamRestoresAntigravityToolName(t
func TestConvertInteractionsResponseToOpenAIPreservesNonCollidingAndNonAntigravityNames(t *testing.T) {
rawNonColliding := []byte(`{"id":"i1","model":"antigravity-preview-05-2026","steps":[{"type":"function_call","id":"call_1","name":"external_lookup","arguments":{"q":"test"}}]}`)
outNonColliding := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "antigravity-preview-05-2026", nil, nil, rawNonColliding, nil)
- if got := gjson.GetBytes(outNonColliding, "choices.0.message.tool_calls.0.function.name").String(); got != "external_lookup" {
+ if got := ojson.GetBytesNoCopy(outNonColliding, "choices.0.message.tool_calls.0.function.name").String(); got != "external_lookup" {
t.Fatalf("tool name = %q, want external_lookup (preserved). Output: %s", got, string(outNonColliding))
}
rawNonAnti := []byte(`{"id":"i2","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","id":"call_2","name":"external_read_file","arguments":{"path":"/etc/hosts"}}]}`)
outNonAnti := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, rawNonAnti, nil)
- if got := gjson.GetBytes(outNonAnti, "choices.0.message.tool_calls.0.function.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(outNonAnti, "choices.0.message.tool_calls.0.function.name").String(); got != "external_read_file" {
t.Fatalf("tool name = %q, want external_read_file (preserved for non-antigravity). Output: %s", got, string(outNonAnti))
}
}
@@ -268,7 +268,7 @@ func countInteractionsEvents(events [][]byte, eventType string) int {
}
func interactionsEventName(event, payload []byte) string {
- if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
+ if eventType := ojson.GetBytesNoCopy(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
@@ -290,7 +290,7 @@ func interactionsSSEPayload(event []byte) []byte {
func findOpenAIChatChunk(chunks [][]byte, path string) []byte {
for _, chunk := range chunks {
- if gjson.GetBytes(chunk, path).Exists() {
+ if ojson.GetBytesNoCopy(chunk, path).Exists() {
return chunk
}
}
@@ -299,7 +299,7 @@ func findOpenAIChatChunk(chunks [][]byte, path string) []byte {
func findOpenAIChatChunkValue(chunks [][]byte, path, want string) []byte {
for _, chunk := range chunks {
- if gjson.GetBytes(chunk, path).String() == want {
+ if ojson.GetBytesNoCopy(chunk, path).String() == want {
return chunk
}
}
@@ -339,10 +339,10 @@ func TestConvertInteractionsResponseToOpenAIStreamToolCall_ContiguousZeroBasedIn
var firstToolStart []byte
var secondToolStart []byte
for _, chunk := range out {
- if gjson.GetBytes(chunk, "choices.0.delta.tool_calls.0.id").String() == "call_first" {
+ if ojson.GetBytesNoCopy(chunk, "choices.0.delta.tool_calls.0.id").String() == "call_first" {
firstToolStart = chunk
}
- if gjson.GetBytes(chunk, "choices.0.delta.tool_calls.0.id").String() == "call_second" {
+ if ojson.GetBytesNoCopy(chunk, "choices.0.delta.tool_calls.0.id").String() == "call_second" {
secondToolStart = chunk
}
}
@@ -350,14 +350,14 @@ func TestConvertInteractionsResponseToOpenAIStreamToolCall_ContiguousZeroBasedIn
if firstToolStart == nil {
t.Fatalf("call_first chunk not found")
}
- if got := gjson.GetBytes(firstToolStart, "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(firstToolStart, "choices.0.delta.tool_calls.0.index").Int(); got != 0 {
t.Fatalf("first tool call index = %d, want 0 (0-based contiguous index). Payload: %s", got, string(firstToolStart))
}
if secondToolStart == nil {
t.Fatalf("call_second chunk not found")
}
- if got := gjson.GetBytes(secondToolStart, "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(secondToolStart, "choices.0.delta.tool_calls.0.index").Int(); got != 1 {
t.Fatalf("second tool call index = %d, want 1 (0-based contiguous index). Payload: %s", got, string(secondToolStart))
}
}
@@ -407,7 +407,7 @@ func TestConvertInteractionsResponseToOpenAI_ContentFilterFinishReason(t *testin
// 2. Non-stream
raw := []byte(`{"id":"i1","model":"devin/swe-2","status":"incomplete","finish_reason":"content_filter","steps":[{"type":"model_output","content":[{"type":"text","text":"blocked"}]}]}`)
outNonStream := ConvertInteractionsResponseToOpenAINonStream(context.Background(), "devin/swe-2", nil, nil, raw, nil)
- if got := gjson.GetBytes(outNonStream, "choices.0.finish_reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "choices.0.finish_reason").String(); got != "content_filter" {
t.Fatalf("finish_reason = %q, want content_filter. Output: %s", got, string(outNonStream))
}
}
@@ -446,12 +446,12 @@ func TestConvertInteractionsResponseToOpenAI_ResponseFailed(t *testing.T) {
if len(events) == 0 {
t.Fatalf("expected non-empty events for %s, got 0", tt.name)
}
- errorMsg := gjson.GetBytes(events[0], "error.message").String()
+ errorMsg := ojson.GetBytesNoCopy(events[0], "error.message").String()
if !strings.Contains(errorMsg, tt.wantMsg) {
t.Fatalf("expected error.message containing %q, got: %s", tt.wantMsg, string(events[0]))
}
if tt.wantCode != "" {
- if gotCode := gjson.GetBytes(events[0], "error.code").String(); gotCode != tt.wantCode {
+ if gotCode := ojson.GetBytesNoCopy(events[0], "error.code").String(); gotCode != tt.wantCode {
t.Fatalf("expected error.code %q, got: %s", tt.wantCode, string(events[0]))
}
}
diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go
index a0a64736bbc..ac42d39d257 100644
--- a/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go
+++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_file_data_test.go
@@ -3,14 +3,14 @@ package chat_completions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIRequestToInteractionsNormalizesFileDataURL(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
out, _ := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
- document := gjson.GetBytes(out, "input.0.content.0")
+ document := ojson.GetBytesNoCopy(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
@@ -23,7 +23,7 @@ func TestConvertOpenAIRequestToInteractionsPreservesRawFileDataWithMIMEType(t *t
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[{"type":"document","mime_type":"application/pdf","data":"JVBERi0xLjQK"}]}]}`)
out, _ := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false)
- document := gjson.GetBytes(out, "input.0.content.0")
+ document := ojson.GetBytesNoCopy(out, "input.0.content.0")
if got := document.Get("mime_type").String(); got != "application/pdf" {
t.Fatalf("document.mime_type = %q, want application/pdf. Output: %s", got, out)
}
diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go
index 5a938fa7530..b618f8f0ed7 100644
--- a/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go
+++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_request.go
@@ -3,9 +3,9 @@ package chat_completions
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -16,30 +16,30 @@ func ConvertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, s
// convertOpenAIRequestToInteractions also reports a file or audio part that
// Interactions cannot receive when it leaves a user turn with nothing to send.
func convertOpenAIRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","input":[]}`)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","input":[]}`)
model := firstNonEmpty(modelName, root.Get("model").String())
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
if streamValue, ok := openAIRequestStreamValue(root, stream); ok {
- out, _ = sjson.SetBytes(out, "stream", streamValue)
+ _ = outDoc.Set("stream", streamValue)
}
if previousResponseID := firstNonEmpty(root.Get("previous_response_id").String(), root.Get("previous_interaction_id").String()); previousResponseID != "" {
- out, _ = sjson.SetBytes(out, "previous_interaction_id", previousResponseID)
+ _ = outDoc.Set("previous_interaction_id", previousResponseID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
- out, _ = sjson.SetBytes(out, "environment_id", environmentID)
+ _ = outDoc.Set("environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
+ _ = outDoc.SetRaw("agent_config", agentConfig.Raw)
}
forAntigravity := isAntigravityModel(model)
- out, errMessages := appendOpenAIMessagesToInteractions(out, root.Get("messages"), forAntigravity)
- out = copyOpenAIChatGenerationConfigToInteractions(out, root, model)
- out = appendOpenAIChatToolsToInteractions(out, root.Get("tools"), forAntigravity)
- return out, errMessages
+ errMessages := appendOpenAIMessagesToInteractionsDoc(outDoc, root.Get("messages"), forAntigravity)
+ copyOpenAIChatGenerationConfigToInteractionsDoc(outDoc, root, model)
+ appendOpenAIChatToolsToInteractionsDoc(outDoc, root.Get("tools"), forAntigravity)
+ return outDoc.Bytes(), errMessages
}
-func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) {
+func openAIRequestStreamValue(root ojson.Result, stream bool) (bool, bool) {
if value := root.Get("stream"); value.Exists() {
return value.Bool(), true
}
@@ -49,15 +49,15 @@ func openAIRequestStreamValue(root gjson.Result, stream bool) (bool, bool) {
return false, false
}
-func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result, forAntigravity bool) ([]byte, error) {
+func appendOpenAIMessagesToInteractionsDoc(outDoc *ojson.Doc, messages ojson.Result, forAntigravity bool) error {
if !messages.Exists() || !messages.IsArray() {
- return out, nil
+ return nil
}
var drops translatorcommon.UserTurnDrops
inputItems := translatorcommon.NewRawArrayItems(messages.Get("#").Int())
var systemBuilder strings.Builder
toolNamesByID := make(map[string]string)
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
switch role {
case "system", "developer":
@@ -73,13 +73,13 @@ func appendOpenAIMessagesToInteractions(out []byte, messages gjson.Result, forAn
return true
})
if systemBuilder.Len() > 0 {
- out, _ = sjson.SetBytes(out, "system_instruction", systemBuilder.String())
+ _ = outDoc.Set("system_instruction", systemBuilder.String())
}
- out = translatorcommon.SetRawArrayItems(out, "input", inputItems)
- return out, drops.Err()
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "input", inputItems)
+ return drops.Err()
}
-func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, forAntigravity bool, toolNamesByID map[string]string, drops *translatorcommon.UserTurnDrops) {
+func appendOpenAIMessageToInteractions(items *[][]byte, message ojson.Result, forAntigravity bool, toolNamesByID map[string]string, drops *translatorcommon.UserTurnDrops) {
role := strings.ToLower(strings.TrimSpace(message.Get("role").String()))
switch role {
case "assistant":
@@ -92,7 +92,7 @@ func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, fo
*items = append(*items, step)
}
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
- toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
+ toolCalls.ForEach(func(_, toolCall ojson.Result) bool {
if id := toolCall.Get("id").String(); id != "" {
if name := toolCall.Get("function.name").String(); name != "" && toolNamesByID != nil {
toolNamesByID[id] = name
@@ -117,22 +117,22 @@ func appendOpenAIMessageToInteractions(items *[][]byte, message gjson.Result, fo
// A non-nil drops closes the user turn: an attachment that cannot be sent is
// recorded, and text or any other sendable part beside it keeps the turn alive.
// An empty text part is forwarded but does not count as sendable.
-func openAIChatContentStep(stepType string, content gjson.Result, drops *translatorcommon.UserTurnDrops) ([]byte, bool) {
+func openAIChatContentStep(stepType string, content ojson.Result, drops *translatorcommon.UserTurnDrops) ([]byte, bool) {
contentItems := make([][]byte, 0, 4)
sendable := 0
if drops != nil {
defer func() { drops.EndTurn(sendable) }()
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
if content.String() == "" {
return nil, false
}
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", content.String())
+ part, _ = ojson.SetBytes(part, "text", content.String())
contentItems = append(contentItems, part)
sendable++
} else {
- appendPart := func(part gjson.Result) {
+ appendPart := func(part ojson.Result) {
converted, ok := openAIChatContentPartToInteractions(part)
if !ok {
if partType := openAIChatPartType(part); drops != nil && isOpenAIChatAttachmentType(partType) {
@@ -141,12 +141,12 @@ func openAIChatContentStep(stepType string, content gjson.Result, drops *transla
return
}
contentItems = append(contentItems, converted)
- if convertedType := gjson.GetBytes(converted, "type").String(); convertedType != "text" || gjson.GetBytes(converted, "text").String() != "" {
+ if convertedType := ojson.GetBytesNoCopy(converted, "type").String(); convertedType != "text" || ojson.GetBytesNoCopy(converted, "text").String() != "" {
sendable++
}
}
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
appendPart(part)
return true
})
@@ -157,13 +157,13 @@ func openAIChatContentStep(stepType string, content gjson.Result, drops *transla
if len(contentItems) == 0 {
return nil, false
}
- step := []byte(`{"type":"","content":[]}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
- step, _ = sjson.SetRawBytes(step, "content", translatorcommon.JoinRawArray(contentItems))
- return step, true
+ stepDoc := ojson.NewDocString(`{"type":"","content":[]}`)
+ _ = stepDoc.Set("type", stepType)
+ _ = stepDoc.SetRawBytes("content", translatorcommon.JoinRawArray(contentItems))
+ return stepDoc.Bytes(), true
}
-func openAIChatPartType(part gjson.Result) string {
+func openAIChatPartType(part ojson.Result) string {
partType := strings.ToLower(strings.TrimSpace(part.Get("type").String()))
if partType == "" && part.Get("text").Exists() {
partType = "text"
@@ -181,78 +181,78 @@ func isOpenAIChatAttachmentType(partType string) bool {
return false
}
-func openAIChatContentPartToInteractions(part gjson.Result) ([]byte, bool) {
+func openAIChatContentPartToInteractions(part ojson.Result) ([]byte, bool) {
partType := openAIChatPartType(part)
switch partType {
case "text", "input_text", "output_text":
out := []byte(`{"type":"text","text":""}`)
- out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
+ out, _ = ojson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "image_url", "input_image", "image":
return openAIChatImagePartToInteractions(part), true
case "input_audio", "audio":
- out := []byte(`{"type":"audio","data":""}`)
+ outDoc := ojson.NewDocString(`{"type":"audio","data":""}`)
audio := part.Get("input_audio")
data := firstNonEmpty(audio.Get("data").String(), part.Get("data").String())
if data == "" {
return nil, false
}
- out, _ = sjson.SetBytes(out, "data", data)
+ _ = outDoc.Set("data", data)
if format := firstNonEmpty(audio.Get("format").String(), part.Get("format").String()); format != "" {
- out, _ = sjson.SetBytes(out, "mime_type", openAIInputAudioMIMEType(format))
+ _ = outDoc.Set("mime_type", openAIInputAudioMIMEType(format))
}
- return out, true
+ return outDoc.Bytes(), true
case "file", "input_file", "document":
file := part.Get("file")
filename := firstNonEmpty(file.Get("filename").String(), part.Get("filename").String())
fallbackMIMEType := firstNonEmpty(file.Get("mime_type").String(), file.Get("mimeType").String(), part.Get("mime_type").String(), part.Get("mimeType").String())
fileData := firstNonEmpty(file.Get("file_data").String(), part.Get("file_data").String(), part.Get("data").String())
fileURL := firstNonEmpty(file.Get("file_url").String(), part.Get("file_url").String(), part.Get("url").String())
- out := []byte(`{"type":"document"}`)
+ outDoc := ojson.NewDocString(`{"type":"document"}`)
if filename != "" {
- out, _ = sjson.SetBytes(out, "filename", filename)
+ _ = outDoc.Set("filename", filename)
}
hasContent := false
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIMEType, fileData); ok {
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
- out, _ = sjson.SetBytes(out, "data", data)
+ _ = outDoc.Set("mime_type", mimeType)
+ _ = outDoc.Set("data", data)
hasContent = true
}
if fileURL != "" {
- out, _ = sjson.SetBytes(out, "file_url", fileURL)
+ _ = outDoc.Set("file_url", fileURL)
hasContent = true
}
- return out, hasContent
+ return outDoc.Bytes(), hasContent
}
return nil, false
}
-func openAIChatImagePartToInteractions(part gjson.Result) []byte {
- out := []byte(`{"type":"image"}`)
+func openAIChatImagePartToInteractions(part ojson.Result) []byte {
+ outDoc := ojson.NewDocString(`{"type":"image"}`)
imageURL := firstNonEmpty(part.Get("image_url.url").String(), part.Get("image_url").String(), part.Get("url").String())
if mimeType, data, ok := openAIChatParseDataURL(imageURL); ok {
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
- out, _ = sjson.SetBytes(out, "data", data)
- return out
+ _ = outDoc.Set("mime_type", mimeType)
+ _ = outDoc.Set("data", data)
+ return outDoc.Bytes()
}
if data := part.Get("data").String(); data != "" {
- out, _ = sjson.SetBytes(out, "data", data)
+ _ = outDoc.Set("data", data)
if mimeType := part.Get("mime_type").String(); mimeType != "" {
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
+ _ = outDoc.Set("mime_type", mimeType)
}
- return out
+ return outDoc.Bytes()
}
if imageURL != "" {
- out, _ = sjson.SetBytes(out, "image_url", imageURL)
+ _ = outDoc.Set("image_url", imageURL)
}
- return out
+ return outDoc.Bytes()
}
-func openAIToolResultToInteractions(message gjson.Result, forAntigravity bool, toolNamesByID map[string]string) []byte {
- out := []byte(`{"type":"function_result","result":""}`)
+func openAIToolResultToInteractions(message ojson.Result, forAntigravity bool, toolNamesByID map[string]string) []byte {
+ outDoc := ojson.NewDocString(`{"type":"function_result","result":""}`)
callID := firstNonEmpty(message.Get("tool_call_id").String(), message.Get("id").String())
if callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
name := message.Get("name").String()
if name == "" && callID != "" && toolNamesByID != nil {
@@ -262,83 +262,81 @@ func openAIToolResultToInteractions(message gjson.Result, forAntigravity bool, t
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- out, _ = sjson.SetBytes(out, "name", name)
+ _ = outDoc.Set("name", name)
}
content := message.Get("content")
- if content.Exists() && content.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "result", content.String())
+ if content.Exists() && content.Type == ojson.String {
+ _ = outDoc.Set("result", content.String())
} else if content.Exists() {
- out, _ = sjson.SetRawBytes(out, "result", []byte(content.Raw))
+ _ = outDoc.SetRaw("result", content.Raw)
}
- return out
+ return outDoc.Bytes()
}
func isAntigravityModel(model string) bool {
return strings.Contains(strings.ToLower(model), "antigravity")
}
-func copyOpenAIChatGenerationConfigToInteractions(out []byte, root gjson.Result, model string) []byte {
+func copyOpenAIChatGenerationConfigToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result, model string) {
if isAntigravityModel(model) {
if maxOutputTokens := firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens"), root.Get("max_output_tokens")); maxOutputTokens.Exists() && !root.Get("agent_config.max_total_tokens").Exists() {
- out, _ = sjson.SetBytes(out, "agent_config.max_total_tokens", maxOutputTokens.Int())
+ _ = outDoc.Set("agent_config.max_total_tokens", maxOutputTokens.Int())
}
} else {
- copyNumber(&out, "generation_config.max_output_tokens", firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens")))
- copyNumber(&out, "generation_config.temperature", root.Get("temperature"))
- copyNumber(&out, "generation_config.top_p", root.Get("top_p"))
- copyNumber(&out, "generation_config.presence_penalty", root.Get("presence_penalty"))
- copyNumber(&out, "generation_config.frequency_penalty", root.Get("frequency_penalty"))
- copyNumber(&out, "generation_config.candidate_count", root.Get("n"))
+ copyNumberDoc(outDoc, "generation_config.max_output_tokens", firstExisting(root.Get("max_completion_tokens"), root.Get("max_tokens")))
+ copyNumberDoc(outDoc, "generation_config.temperature", root.Get("temperature"))
+ copyNumberDoc(outDoc, "generation_config.top_p", root.Get("top_p"))
+ copyNumberDoc(outDoc, "generation_config.presence_penalty", root.Get("presence_penalty"))
+ copyNumberDoc(outDoc, "generation_config.frequency_penalty", root.Get("frequency_penalty"))
+ copyNumberDoc(outDoc, "generation_config.candidate_count", root.Get("n"))
if stop := root.Get("stop"); stop.Exists() {
- out, _ = sjson.SetRawBytes(out, "generation_config.stop_sequences", []byte(stop.Raw))
+ _ = outDoc.SetRaw("generation_config.stop_sequences", stop.Raw)
}
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
if isAntigravityModel(model) && toolChoice.IsObject() {
tcRaw := []byte(toolChoice.Raw)
if fnName := toolChoice.Get("function.name").String(); fnName != "" {
- tcRaw, _ = sjson.SetBytes(tcRaw, "function.name", translatorcommon.AntigravityToolNameToUpstream(fnName))
+ tcRaw, _ = ojson.SetBytes(tcRaw, "function.name", translatorcommon.AntigravityToolNameToUpstream(fnName))
} else if name := toolChoice.Get("name").String(); name != "" {
- tcRaw, _ = sjson.SetBytes(tcRaw, "name", translatorcommon.AntigravityToolNameToUpstream(name))
+ tcRaw, _ = ojson.SetBytes(tcRaw, "name", translatorcommon.AntigravityToolNameToUpstream(name))
}
- out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", tcRaw)
+ _ = outDoc.SetRawBytes("generation_config.tool_choice", tcRaw)
} else {
- out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw))
+ _ = outDoc.SetRaw("generation_config.tool_choice", toolChoice.Raw)
}
}
- if effort := root.Get("reasoning_effort"); effort.Exists() && effort.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
+ if effort := root.Get("reasoning_effort"); effort.Exists() && effort.Type == ojson.String {
+ _ = outDoc.Set("generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
}
if responseFormat := root.Get("response_format"); responseFormat.Exists() {
- out, _ = sjson.SetRawBytes(out, "response_format", []byte(responseFormat.Raw))
+ _ = outDoc.SetRaw("response_format", responseFormat.Raw)
}
if modalities := root.Get("modalities"); modalities.Exists() {
- out, _ = sjson.SetRawBytes(out, "response_modalities", []byte(modalities.Raw))
+ _ = outDoc.SetRaw("response_modalities", modalities.Raw)
}
- if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
+ if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == ojson.String {
+ _ = outDoc.Set("service_tier", serviceTier.String())
}
- return out
}
-func appendOpenAIChatToolsToInteractions(out []byte, tools gjson.Result, forAntigravity bool) []byte {
+func appendOpenAIChatToolsToInteractionsDoc(outDoc *ojson.Doc, tools ojson.Result, forAntigravity bool) {
if !tools.Exists() || !tools.IsArray() {
- return out
+ return
}
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if converted, ok := openAIChatToolToInteractions(tool, forAntigravity); ok {
toolItems = append(toolItems, converted)
}
return true
})
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func openAIChatToolToInteractions(tool gjson.Result, forAntigravity bool) ([]byte, bool) {
+func openAIChatToolToInteractions(tool ojson.Result, forAntigravity bool) ([]byte, bool) {
toolType := strings.ToLower(strings.TrimSpace(tool.Get("type").String()))
if toolType != "" && toolType != "function" {
return nil, false
@@ -350,19 +348,19 @@ func openAIChatToolToInteractions(tool gjson.Result, forAntigravity bool) ([]byt
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- out := []byte(`{"type":"function","name":""}`)
- out, _ = sjson.SetBytes(out, "name", name)
+ outDoc := ojson.NewDocString(`{"type":"function","name":""}`)
+ _ = outDoc.Set("name", name)
if desc := firstExisting(tool.Get("function.description"), tool.Get("description")); desc.Exists() {
- out, _ = sjson.SetBytes(out, "description", desc.String())
+ _ = outDoc.Set("description", desc.String())
}
if parameters := firstExisting(tool.Get("function.parameters"), tool.Get("parameters")); parameters.Exists() {
- out, _ = sjson.SetRawBytes(out, "parameters", []byte(parameters.Raw))
+ _ = outDoc.SetRaw("parameters", parameters.Raw)
}
- return out, true
+ return outDoc.Bytes(), true
}
-func openAIChatContentText(content gjson.Result) string {
- if content.Type == gjson.String {
+func openAIChatContentText(content ojson.Result) string {
+ if content.Type == ojson.String {
return content.String()
}
if content.IsObject() {
@@ -372,7 +370,7 @@ func openAIChatContentText(content gjson.Result) string {
return ""
}
var builder strings.Builder
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go
index bc3c48a7084..dc90365010d 100644
--- a/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go
+++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_response.go
@@ -7,9 +7,9 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type interactionsToOpenAIChatStreamState struct {
@@ -50,15 +50,15 @@ func ConvertInteractionsResponseToOpenAINonStream(ctx context.Context, modelName
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
interaction := root
if nested := root.Get("interaction"); nested.Exists() {
interaction = nested
}
- out := []byte(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`)
- out, _ = sjson.SetBytes(out, "id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
- out, _ = sjson.SetBytes(out, "created", time.Now().Unix())
- out, _ = sjson.SetBytes(out, "model", firstNonEmpty(interaction.Get("model").String(), modelName))
+ outDoc := ojson.NewDocString(`{"id":"","object":"chat.completion","created":0,"model":"","choices":[{"index":0,"message":{"role":"assistant","content":""},"finish_reason":"stop"}]}`)
+ _ = outDoc.Set("id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
+ _ = outDoc.Set("created", time.Now().Unix())
+ _ = outDoc.Set("model", firstNonEmpty(interaction.Get("model").String(), modelName))
steps := interaction.Get("steps")
if !steps.Exists() {
steps = root.Get("steps")
@@ -67,7 +67,7 @@ func ConvertInteractionsResponseToOpenAINonStream(ctx context.Context, modelName
var reasoningBuilder strings.Builder
sawToolCall := false
var toolCalls [][]byte
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
switch step.Get("type").String() {
case "model_output":
for _, text := range interactionsContentTextsForOpenAIChat(step.Get("content")) {
@@ -80,35 +80,35 @@ func ConvertInteractionsResponseToOpenAINonStream(ctx context.Context, modelName
case "function_call":
sawToolCall = true
forAntigravity := isAntigravityModel(firstNonEmpty(interaction.Get("model").String(), modelName))
- toolCalls = append(toolCalls, openAIChatToolCallFromInteractions(step, gjson.Result{}, forAntigravity))
+ toolCalls = append(toolCalls, openAIChatToolCallFromInteractions(step, ojson.Result{}, forAntigravity))
}
return true
})
if textBuilder.Len() > 0 {
- out, _ = sjson.SetBytes(out, "choices.0.message.content", textBuilder.String())
+ _ = outDoc.Set("choices.0.message.content", textBuilder.String())
}
if reasoningBuilder.Len() > 0 {
- out, _ = sjson.SetBytes(out, "choices.0.message.reasoning_content", reasoningBuilder.String())
+ _ = outDoc.Set("choices.0.message.reasoning_content", reasoningBuilder.String())
}
if len(toolCalls) > 0 {
- out = translatorcommon.SetRawArrayItems(out, "choices.0.message.tool_calls", toolCalls)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "choices.0.message.tool_calls", toolCalls)
}
if sawToolCall {
- out, _ = sjson.SetBytes(out, "choices.0.message.content", nil)
- out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "tool_calls")
+ _ = outDoc.Set("choices.0.message.content", nil)
+ _ = outDoc.Set("choices.0.finish_reason", "tool_calls")
}
status := firstNonEmpty(interaction.Get("status").String(), root.Get("status").String())
interactionFinishReason := firstNonEmpty(interaction.Get("finish_reason").String(), root.Get("finish_reason").String())
if interactionFinishReason == "content_filter" {
- out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "content_filter")
+ _ = outDoc.Set("choices.0.finish_reason", "content_filter")
} else if status == "incomplete" || interactionFinishReason == "length" || interactionFinishReason == "max_tokens" {
- out, _ = sjson.SetBytes(out, "choices.0.finish_reason", "length")
+ _ = outDoc.Set("choices.0.finish_reason", "length")
}
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String(), root.Get("interaction.environment_id").String()); envID != "" {
- out, _ = sjson.SetBytes(out, "environment_id", envID)
+ _ = outDoc.Set("environment_id", envID)
}
- out = setOpenAIChatUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root))
- return out
+ setOpenAIChatUsageFromInteractionsDoc(outDoc, "usage", translatorcommon.InteractionsUsage(root))
+ return outDoc.Bytes()
}
func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *interactionsToOpenAIChatStreamState) [][]byte {
@@ -116,17 +116,17 @@ func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *
if len(payload) == 0 || bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return nil
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if !root.Exists() {
return nil
}
switch root.Get("event_type").String() {
case "interaction.created":
interaction := root.Get("interaction")
- st.ID = firstNonEmpty(interaction.Get("id").String(), st.ID)
- st.Model = firstNonEmpty(interaction.Get("model").String(), st.Model, modelName)
+ st.ID = strings.Clone(firstNonEmpty(interaction.Get("id").String(), st.ID))
+ st.Model = strings.Clone(firstNonEmpty(interaction.Get("model").String(), st.Model, modelName))
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String()); envID != "" {
- st.EnvironmentID = envID
+ st.EnvironmentID = strings.Clone(envID)
}
return ensureOpenAIChatStarted(nil, st)
case "step.start":
@@ -136,7 +136,7 @@ func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *
case "interaction.completed", "finish":
interaction := root.Get("interaction")
if envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String()); envID != "" {
- st.EnvironmentID = envID
+ st.EnvironmentID = strings.Clone(envID)
}
return appendOpenAIChatCompleted(nil, root, st)
case "response.failed", "interaction.failed":
@@ -147,13 +147,13 @@ func convertInteractionsEventToOpenAIChat(modelName string, rawJSON []byte, st *
return nil
}
-func interactionsStepStartToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
+func interactionsStepStartToOpenAIChat(modelName string, root ojson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
_ = modelName
out := ensureOpenAIChatStarted(nil, st)
index := int(root.Get("index").Int())
step := root.Get("step")
stepType := step.Get("type").String()
- st.StepTypes[index] = stepType
+ st.StepTypes[index] = strings.Clone(stepType)
switch stepType {
case "function_call":
st.SawToolCall = true
@@ -164,12 +164,12 @@ func interactionsStepStartToOpenAIChat(modelName string, root gjson.Result, st *
st.ToolCallIndexByStep[index] = toolCallIndex
st.NextToolCallIndex++
}
- st.ToolIDs[index] = firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), fmt.Sprintf("call_%d", toolCallIndex))
+ st.ToolIDs[index] = strings.Clone(firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), fmt.Sprintf("call_%d", toolCallIndex)))
name := step.Get("name").String()
if isAntigravityModel(modelName) || (st != nil && isAntigravityModel(st.Model)) {
name = translatorcommon.AntigravityUpstreamToolNameToClient(name)
}
- st.ToolNames[index] = name
+ st.ToolNames[index] = strings.Clone(name)
if st.ToolArguments[index] == nil {
st.ToolArguments[index] = &strings.Builder{}
}
@@ -182,7 +182,7 @@ func interactionsStepStartToOpenAIChat(modelName string, root gjson.Result, st *
}
}
-func interactionsStepDeltaToOpenAIChat(modelName string, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
+func interactionsStepDeltaToOpenAIChat(modelName string, root ojson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
_ = modelName
index := int(root.Get("index").Int())
delta := root.Get("delta")
@@ -218,18 +218,18 @@ func ensureOpenAIChatStarted(out [][]byte, st *interactionsToOpenAIChatStreamSta
if st.Started {
return out
}
- chunk := openAIChatBaseChunk(st)
- chunk, _ = sjson.SetBytes(chunk, "choices.0.delta.role", "assistant")
+ chunkDoc := openAIChatBaseChunkDoc(st)
+ _ = chunkDoc.Set("choices.0.delta.role", "assistant")
st.Started = true
- return append(out, chunk)
+ return append(out, chunkDoc.Bytes())
}
-func appendOpenAIChatCompleted(out [][]byte, root gjson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
+func appendOpenAIChatCompleted(out [][]byte, root ojson.Result, st *interactionsToOpenAIChatStreamState) [][]byte {
if st.Completed {
return out
}
out = ensureOpenAIChatStarted(out, st)
- chunk := openAIChatBaseChunk(st)
+ chunkDoc := openAIChatBaseChunkDoc(st)
finishReason := "stop"
if st.SawToolCall {
finishReason = "tool_calls"
@@ -242,13 +242,13 @@ func appendOpenAIChatCompleted(out [][]byte, root gjson.Result, st *interactions
} else if status == "incomplete" || interactionFinishReason == "length" || interactionFinishReason == "max_tokens" {
finishReason = "length"
}
- chunk, _ = sjson.SetBytes(chunk, "choices.0.finish_reason", finishReason)
- chunk = setOpenAIChatUsageFromInteractions(chunk, "usage", translatorcommon.InteractionsUsage(root))
+ _ = chunkDoc.Set("choices.0.finish_reason", finishReason)
+ setOpenAIChatUsageFromInteractionsDoc(chunkDoc, "usage", translatorcommon.InteractionsUsage(root))
st.Completed = true
- return append(out, chunk)
+ return append(out, chunkDoc.Bytes())
}
-func interactionsFailedToOpenAIChat(root gjson.Result) [][]byte {
+func interactionsFailedToOpenAIChat(root ojson.Result) [][]byte {
errNode := root.Get("error")
if !errNode.Exists() {
errNode = root.Get("interaction.error")
@@ -263,105 +263,104 @@ func interactionsFailedToOpenAIChat(root gjson.Result) [][]byte {
errType = "server_error"
}
- errorJSON := []byte(`{"error":{"message":"","type":"","code":""}}`)
- errorJSON, _ = sjson.SetBytes(errorJSON, "error.message", msg)
- errorJSON, _ = sjson.SetBytes(errorJSON, "error.type", errType)
+ errorDoc := ojson.NewDocString(`{"error":{"message":"","type":"","code":""}}`)
+ _ = errorDoc.Set("error.message", msg)
+ _ = errorDoc.Set("error.type", errType)
if code != "" {
- errorJSON, _ = sjson.SetBytes(errorJSON, "error.code", code)
+ _ = errorDoc.Set("error.code", code)
} else {
- errorJSON, _ = sjson.DeleteBytes(errorJSON, "error.code")
+ _ = errorDoc.Delete("error.code")
}
- return [][]byte{errorJSON}
+ return [][]byte{errorDoc.Bytes()}
}
-func openAIChatBaseChunk(st *interactionsToOpenAIChatStreamState) []byte {
- chunk := []byte(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`)
- chunk, _ = sjson.SetBytes(chunk, "id", firstNonEmpty(st.ID, fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
- chunk, _ = sjson.SetBytes(chunk, "created", openAIChatCreated(st))
- chunk, _ = sjson.SetBytes(chunk, "model", st.Model)
+func openAIChatBaseChunkDoc(st *interactionsToOpenAIChatStreamState) *ojson.Doc {
+ chunkDoc := ojson.NewDocString(`{"id":"","object":"chat.completion.chunk","created":0,"model":"","choices":[{"index":0,"delta":{},"finish_reason":null}]}`)
+ _ = chunkDoc.Set("id", firstNonEmpty(st.ID, fmt.Sprintf("chatcmpl_%d", time.Now().UnixNano())))
+ _ = chunkDoc.Set("created", openAIChatCreated(st))
+ _ = chunkDoc.Set("model", st.Model)
if st != nil && st.EnvironmentID != "" {
- chunk, _ = sjson.SetBytes(chunk, "environment_id", st.EnvironmentID)
+ _ = chunkDoc.Set("environment_id", st.EnvironmentID)
}
- return chunk
+ return chunkDoc
}
func openAIChatDeltaChunk(st *interactionsToOpenAIChatStreamState, field, value string) []byte {
- chunk := openAIChatBaseChunk(st)
- chunk, _ = sjson.SetBytes(chunk, "choices.0.delta."+field, value)
- return chunk
+ chunkDoc := openAIChatBaseChunkDoc(st)
+ _ = chunkDoc.Set("choices.0.delta."+field, value)
+ return chunkDoc.Bytes()
}
func openAIChatToolCallStartChunk(st *interactionsToOpenAIChatStreamState, index int) []byte {
- chunk := openAIChatBaseChunk(st)
+ chunkDoc := openAIChatBaseChunkDoc(st)
toolCallIndex := index
if st != nil && st.ToolCallIndexByStep != nil {
if idx, ok := st.ToolCallIndexByStep[index]; ok {
toolCallIndex = idx
}
}
- toolCall := []byte(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
- toolCall, _ = sjson.SetBytes(toolCall, "index", toolCallIndex)
- toolCall, _ = sjson.SetBytes(toolCall, "id", firstNonEmpty(st.ToolIDs[index], fmt.Sprintf("call_%d", toolCallIndex)))
- toolCall, _ = sjson.SetBytes(toolCall, "function.name", st.ToolNames[index])
- chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall)
- return chunk
+ toolCallDoc := ojson.NewDocString(`{"index":0,"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ _ = toolCallDoc.Set("index", toolCallIndex)
+ _ = toolCallDoc.Set("id", firstNonEmpty(st.ToolIDs[index], fmt.Sprintf("call_%d", toolCallIndex)))
+ _ = toolCallDoc.Set("function.name", st.ToolNames[index])
+ _ = chunkDoc.SetRawBytes("choices.0.delta.tool_calls.-1", toolCallDoc.Bytes())
+ return chunkDoc.Bytes()
}
func openAIChatToolCallArgumentsChunk(st *interactionsToOpenAIChatStreamState, index int, arguments string) []byte {
- chunk := openAIChatBaseChunk(st)
+ chunkDoc := openAIChatBaseChunkDoc(st)
toolCallIndex := index
if st != nil && st.ToolCallIndexByStep != nil {
if idx, ok := st.ToolCallIndexByStep[index]; ok {
toolCallIndex = idx
}
}
- toolCall := []byte(`{"index":0,"function":{"arguments":""}}`)
- toolCall, _ = sjson.SetBytes(toolCall, "index", toolCallIndex)
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments)
- chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.-1", toolCall)
- return chunk
+ toolCallDoc := ojson.NewDocString(`{"index":0,"function":{"arguments":""}}`)
+ _ = toolCallDoc.Set("index", toolCallIndex)
+ _ = toolCallDoc.Set("function.arguments", arguments)
+ _ = chunkDoc.SetRawBytes("choices.0.delta.tool_calls.-1", toolCallDoc.Bytes())
+ return chunkDoc.Bytes()
}
-func openAIChatToolCallFromInteractions(step, fallbackArgs gjson.Result, forAntigravity bool) []byte {
- toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
+func openAIChatToolCallFromInteractions(step, fallbackArgs ojson.Result, forAntigravity bool) []byte {
+ toolCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":"{}"}}`)
callID := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String(), "call_0")
- toolCall, _ = sjson.SetBytes(toolCall, "id", callID)
+ _ = toolCallDoc.Set("id", callID)
name := step.Get("name").String()
if forAntigravity {
name = translatorcommon.AntigravityUpstreamToolNameToClient(name)
}
- toolCall, _ = sjson.SetBytes(toolCall, "function.name", name)
+ _ = toolCallDoc.Set("function.name", name)
args := step.Get("arguments")
if !args.Exists() {
args = fallbackArgs
}
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", jsonStringValue(args, "{}"))
- return toolCall
+ _ = toolCallDoc.Set("function.arguments", jsonStringValue(args, "{}"))
+ return toolCallDoc.Bytes()
}
-func setOpenAIChatUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte {
+func setOpenAIChatUsageFromInteractionsDoc(outDoc *ojson.Doc, path string, usage ojson.Result) {
if !usage.Exists() {
- return out
+ return
}
if value, ok := interactionsUsageInt(usage, "input_tokens", "total_input_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".prompt_tokens", value)
+ _ = outDoc.Set(path+".prompt_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "output_tokens", "total_output_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".completion_tokens", value)
+ _ = outDoc.Set(path+".completion_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "total_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".total_tokens", value)
+ _ = outDoc.Set(path+".total_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".prompt_tokens_details.cached_tokens", value)
+ _ = outDoc.Set(path+".prompt_tokens_details.cached_tokens", value)
}
if value, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".completion_tokens_details.reasoning_tokens", value)
+ _ = outDoc.Set(path+".completion_tokens_details.reasoning_tokens", value)
}
- return out
}
-func interactionsUsageInt(root gjson.Result, paths ...string) (int64, bool) {
+func interactionsUsageInt(root ojson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if value := root.Get(path); value.Exists() {
return value.Int(), true
@@ -370,15 +369,15 @@ func interactionsUsageInt(root gjson.Result, paths ...string) (int64, bool) {
return 0, false
}
-func interactionsContentTextsForOpenAIChat(content gjson.Result) []string {
+func interactionsContentTextsForOpenAIChat(content ojson.Result) []string {
if !content.Exists() {
return nil
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return []string{content.String()}
}
var out []string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" {
out = append(out, text)
}
diff --git a/internal/translator/openai/interactions/chat-completions/openai_interactions_user_turn_test.go b/internal/translator/openai/interactions/chat-completions/openai_interactions_user_turn_test.go
index d6ac7b515d3..12258f0a604 100644
--- a/internal/translator/openai/interactions/chat-completions/openai_interactions_user_turn_test.go
+++ b/internal/translator/openai/interactions/chat-completions/openai_interactions_user_turn_test.go
@@ -5,9 +5,9 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -66,7 +66,7 @@ func TestConvertOpenAIRequestToInteractions_RefusesAnyEmptiedUserTurn(t *testing
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
@@ -85,7 +85,7 @@ func TestConvertOpenAIRequestToInteractions_KeepsTurnWithTextBesideAttachment(t
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
}
@@ -106,7 +106,7 @@ func TestConvertOpenAIRequestToInteractions_InlineFileAndAudioAfterHistoryStayBy
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "input.2.content.0")
if part.Get("type").String() != tc.wantType || part.Get("data").String() != tc.wantData {
t.Fatalf("part = %s, body = %s", part.Raw, body)
}
@@ -120,14 +120,14 @@ func TestConvertOpenAIRequestToInteractions_FileURLStaysAFile(t *testing.T) {
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.0.content.0.file_url").String(); got != "https://example.test/a.pdf" {
+ if got := ojson.GetBytesNoCopy(body, "input.0.content.0.file_url").String(); got != "https://example.test/a.pdf" {
t.Fatalf("file url was lost: %s", body)
}
}
func TestConvertOpenAIRequestToInteractions_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := []byte(`{"model":"gemini-3.5-flash","messages":[{"role":"user","content":[` + userTurnFileID + `]}]}`)
- if body, _ := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIRequestToInteractions("gemini-3.5-flash", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/openai/interactions/responses/apply_patch_identity_test.go b/internal/translator/openai/interactions/responses/apply_patch_identity_test.go
index 0ffc61e7b5a..322f063df90 100644
--- a/internal/translator/openai/interactions/responses/apply_patch_identity_test.go
+++ b/internal/translator/openai/interactions/responses/apply_patch_identity_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestInteractionsApplyPatchNamedLateIdentity(t *testing.T) {
@@ -40,7 +40,7 @@ func TestInteractionsApplyPatchNamedLateIdentity(t *testing.T) {
}
step["id"], step["call_id"] = "item_2", "call_2"
step["arguments"] = map[string]any{"input": patchText}
- var events []gjson.Result
+ var events []ojson.Result
if boundary == "stop" {
events = append(events, patchSend(¶m, patchStep("step.stop", step))...)
}
diff --git a/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go b/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go
index c5fa7cc8740..87945ba6ae3 100644
--- a/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go
+++ b/internal/translator/openai/interactions/responses/apply_patch_rereview_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func patchTypeReviewIdentity(key string) (int, map[string]any) {
@@ -50,7 +50,7 @@ func TestInteractionsApplyPatchPreNameConsistentTypeConflict(t *testing.T) {
resolvedIndex, resolved := patchTypeReviewIdentity(resolvedKey)
resolved["type"], resolved["name"] = "function_call", "functions__apply_patch"
resolved["arguments"] = map[string]any{"input": "p"}
- var events []gjson.Result
+ var events []ojson.Result
if entry == "step.start" || entry == "step.stop" {
for _, event := range patchSend(¶m, patchReviewSnapshot(entry, index, conflict)) {
if event.Get("item.type").String() == "custom_tool_call" || strings.HasPrefix(event.Get("type").String(), "response.custom_tool_call_input.") || event.Get("type").String() == "response.failed" {
@@ -88,7 +88,7 @@ func TestInteractionsApplyPatchPreNameOmittedType(t *testing.T) {
for _, resolvedEntry := range patchTypeReviewEntries(entry) {
t.Run(entry+"/"+resolvedEntry+"/"+resolvedKey, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"input": "p"}}))...)
_, snapshot := patchTypeReviewIdentity("index and IDs")
index, resolved := patchTypeReviewIdentity(resolvedKey)
@@ -127,7 +127,7 @@ func TestInteractionsApplyPatchPreNameTypeOrdinaryCompatibility(t *testing.T) {
for _, itemType := range []string{"model_output", "thought", "custom_tool_call", "omitted"} {
t.Run(request.name+"/"+entry+"/"+itemType, func(t *testing.T) {
var param any
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(request.request), nil, raw, ¶m))
}
send(patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"x": 1}}))
@@ -136,7 +136,7 @@ func TestInteractionsApplyPatchPreNameTypeOrdinaryCompatibility(t *testing.T) {
conflict["type"] = itemType
}
resolved := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": request.upstream, "arguments": map[string]any{"x": 1}}
- var events []gjson.Result
+ var events []ojson.Result
if entry == "step.start" || entry == "step.stop" {
events = append(events, send(patchReviewSnapshot(entry, 2, conflict))...)
events = append(events, send(patchReviewSnapshot("step.start", 2, resolved))...)
@@ -186,7 +186,7 @@ func TestInteractionsApplyPatchPreNameNoBridgeLegacy(t *testing.T) {
for _, entry := range []string{"step.start", "interaction.completed", "finish"} {
t.Run(request.name+"/"+entry, func(t *testing.T) {
var param any
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(request.request), nil, raw, ¶m))
}
send(patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "arguments": map[string]any{"x": 1}}))
diff --git a/internal/translator/openai/interactions/responses/apply_patch_review_test.go b/internal/translator/openai/interactions/responses/apply_patch_review_test.go
index 79958f30463..fb3dd550359 100644
--- a/internal/translator/openai/interactions/responses/apply_patch_review_test.go
+++ b/internal/translator/openai/interactions/responses/apply_patch_review_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestInteractionsApplyPatchOrdinaryFunctionStopSnapshotDeltas(t *testing.T) {
@@ -20,8 +20,8 @@ func TestInteractionsApplyPatchOrdinaryFunctionStopSnapshotDeltas(t *testing.T)
for _, identityAt := range []string{"start", "after delta", "stop", "delta before start"} {
t.Run(request.name+"/"+identityAt, func(t *testing.T) {
var param any
- var events []gjson.Result
- send := func(raw []byte) []gjson.Result {
+ var events []ojson.Result
+ send := func(raw []byte) []ojson.Result {
more := patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(request.request), nil, raw, ¶m))
events = append(events, more...)
return more
@@ -84,7 +84,7 @@ func TestInteractionsApplyPatchOrdinaryFunctionStopSnapshotDeltas(t *testing.T)
}
}
-func assertInteractionsPatchReviewFailure(t *testing.T, param *any, events []gjson.Result) {
+func assertInteractionsPatchReviewFailure(t *testing.T, param *any, events []ojson.Result) {
t.Helper()
if len(events) != 1 || events[0].Get("type").String() != "response.failed" || events[0].Get("response.error.code").String() != "invalid_tool_arguments" {
t.Fatalf("expected only sanitized failure: %v", events)
@@ -155,7 +155,7 @@ func TestInteractionsApplyPatchFinalOnlyIdentityAndEnvelope(t *testing.T) {
raw := patchJSON(event)
if evidence == "malformed named" {
raw = raw[:len(raw)-1]
- if gjson.ValidBytes(raw) || gjson.GetBytes(raw, path+".0.name").String() != "functions__apply_patch" {
+ if ojson.ValidBytes(raw) || ojson.GetBytesNoCopy(raw, path+".0.name").String() != "functions__apply_patch" {
t.Fatal("fixture must retain a recoverable patch step in incomplete outer JSON")
}
}
@@ -197,7 +197,7 @@ func TestInteractionsApplyPatchFinalOnlyOtherToolsKeepLegacyBehavior(t *testing.
func TestInteractionsApplyPatchFinalSnapshotsPreserveOtherOutputsAndCalls(t *testing.T) {
var param any
request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"lookup"},{"type":"custom","name":"exec"}]}`)
- send := func(index int, entry, key string, value any) []gjson.Result {
+ send := func(index int, entry, key string, value any) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, patchJSON(map[string]any{"event_type": entry, "index": index, key: value}), ¶m))
}
snapshots := []any{
@@ -229,7 +229,7 @@ func TestInteractionsApplyPatchFinalSnapshotsPreserveOtherOutputsAndCalls(t *tes
func TestInteractionsApplyPatchFinalOnlyCallDoesNotRebindUnrelatedItem(t *testing.T) {
var param any
request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"lookup"}]}`)
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m))
}
send([]byte(`{"event_type":"step.start","index":0,"step":{"type":"function_call","id":"ordinary","name":"lookup","arguments":{"x":1}}}`))
@@ -284,7 +284,7 @@ func TestInteractionsApplyPatchFinalOnlyUnnamedUnkeyedFunctionFails(t *testing.T
func TestInteractionsApplyPatchOrdinaryLateIdentityReplaysOnlyBufferedPrefix(t *testing.T) {
var param any
request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`)
- var events []gjson.Result
+ var events []ojson.Result
send := func(raw []byte) {
events = append(events, patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m))...)
}
@@ -435,7 +435,7 @@ func TestInteractionsApplyPatchPreNameIndexConflictRetained(t *testing.T) {
delete(resolved, "id")
delete(resolved, "call_id")
}
- var events []gjson.Result
+ var events []ojson.Result
if entry == "step.start" || entry == "step.stop" {
// Unknown names must not make identity contradictions disappear later.
patchSend(¶m, patchReviewSnapshot(entry, 3, conflict))
@@ -465,7 +465,7 @@ func TestInteractionsApplyPatchConsistentSnapshotIdentityMatrix(t *testing.T) {
for _, snapshotType := range []string{"function_call", "omitted"} {
t.Run(entry+"/"+key+"/"+snapshotType, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}}))...)
events = append(events, patchSend(¶m, patchStep("step.stop", nil))...)
step := map[string]any{"arguments": map[string]any{"input": "p"}}
@@ -527,7 +527,7 @@ func TestInteractionsApplyPatchUnmatchedNewIDsRemainIndependent(t *testing.T) {
func TestInteractionsApplyPatchOrdinaryExplicitIndexKeepsLegacyBehavior(t *testing.T) {
var param any
request := []byte(`{"tools":[{"type":"custom","name":"apply_patch"},{"type":"function","name":"lookup"}]}`)
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m))
}
for _, index := range []int{2, 3} {
@@ -564,7 +564,7 @@ func TestInteractionsApplyPatchIDsCannotSelectUnrelatedOrdinaryIndex(t *testing.
t.Run(entry+"/"+snapshotType, func(t *testing.T) {
var param any
request := []byte(`{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"lookup"}]}`)
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m))
}
send(patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{"input": "p"}}))
diff --git a/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go b/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go
index dfa0db98a23..12e769522b5 100644
--- a/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go
+++ b/internal/translator/openai/interactions/responses/apply_patch_source_stop_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// Source completion is a barrier even when the winning name or either ID is late.
@@ -30,7 +30,7 @@ func TestInteractionsApplyPatchSourceStopFragments(t *testing.T) {
if input == "complete" {
before, after, final = `{"input":"p"}`, " \t\n", "p"
}
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, patchSend(¶m, patchStep("step.start", step))...)
events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": before}}))...)
events = append(events, patchSend(¶m, patchStep("step.stop", nil))...)
@@ -125,7 +125,7 @@ func TestInteractionsApplyPatchSourceStopLateIdentityReplay(t *testing.T) {
}
}
resolved := map[string]any{"index": 2, "type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"}
- var events []gjson.Result
+ var events []ojson.Result
if boundary != "terminal-snapshot" {
events = patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "step": resolved, "delta": map[string]any{"type": "arguments_delta", "arguments": ""}}))
wantCount := 3
diff --git a/internal/translator/openai/interactions/responses/apply_patch_test.go b/internal/translator/openai/interactions/responses/apply_patch_test.go
index 7d338597f39..b9ca387e86b 100644
--- a/internal/translator/openai/interactions/responses/apply_patch_test.go
+++ b/internal/translator/openai/interactions/responses/apply_patch_test.go
@@ -6,7 +6,8 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
const patchRequest = `{"tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","definition":"start: patch"}}]}]}`
@@ -17,29 +18,29 @@ func patchArguments(input string) string { return string(patchJSON(map[string]st
func patchStep(event string, step any) []byte {
return patchJSON(map[string]any{"event_type": event, "index": 2, "step": step})
}
-func patchEvents(chunks [][]byte) []gjson.Result {
- var events []gjson.Result
+func patchEvents(chunks [][]byte) []ojson.Result {
+ var events []ojson.Result
for _, chunk := range chunks {
for _, line := range strings.Split(string(chunk), "\n") {
- if strings.HasPrefix(line, "data: ") && gjson.Valid(strings.TrimPrefix(line, "data: ")) {
- events = append(events, gjson.Parse(strings.TrimPrefix(line, "data: ")))
+ if strings.HasPrefix(line, "data: ") && ojson.Valid(strings.TrimPrefix(line, "data: ")) {
+ events = append(events, ojson.Parse(strings.TrimPrefix(line, "data: ")))
}
}
}
return events
}
-func patchSend(param *any, raw []byte) []gjson.Result {
+func patchSend(param *any, raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", []byte(patchRequest), nil, raw, param))
}
func TestInteractionsApplyPatchDeclarationAndHistory(t *testing.T) {
- request := gjson.Parse(patchRequest).Value().(map[string]any)
+ request := ojson.Parse(patchRequest).Value().(map[string]any)
request["input"] = []any{map[string]any{"type": "custom_tool_call", "name": "apply_patch", "namespace": "functions", "call_id": "c1", "input": patchText}, map[string]any{"type": "custom_tool_call_output", "call_id": "c1", "output": "ok"}}
body1, errConvert1 := ConvertOpenAIResponsesRequestToInteractions("devin/swe-2", patchJSON(request), false)
if errConvert1 != nil {
t.Fatal(errConvert1)
}
- out := gjson.ParseBytes(body1)
+ out := util.ParseGJSONBytesNoCopy(body1)
if !strings.Contains(out.Get("tools.0.description").String(), "*** Begin Patch") || out.Get("tools.0.parameters.additionalProperties").Bool() || !out.Get("tools.0.parameters.additionalProperties").Exists() {
t.Fatalf("missing patch contract: %s", out.Raw)
}
@@ -52,7 +53,7 @@ func TestInteractionsApplyPatchEverySplitPreviewAndCompletion(t *testing.T) {
args := strings.NewReplacer("中", `\u4e2d`, "😀", `\ud83d\ude00`).Replace(patchArguments(patchText))
for split := 0; split <= len(args); split++ {
var param any
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch", "arguments": map[string]any{}}))...)
for _, fragment := range []string{args[:split], args[split:]} {
events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": fragment}}))...)
@@ -72,7 +73,7 @@ func TestInteractionsApplyPatchEverySplitPreviewAndCompletion(t *testing.T) {
}
}
-func assertPatchLifecycle(t *testing.T, events []gjson.Result, want string) {
+func assertPatchLifecycle(t *testing.T, events []ojson.Result, want string) {
t.Helper()
var delta strings.Builder
counts := map[string]int{}
@@ -170,7 +171,7 @@ func TestInteractionsApplyPatchSnapshotMatrix(t *testing.T) {
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
for _, input := range test.inputs {
events = append(events, patchSend(¶m, input)...)
}
@@ -222,13 +223,13 @@ func TestInteractionsApplyPatchWinningDeclarationAndNegativeCompatibility(t *tes
if errConvert2 != nil {
t.Fatal(errConvert2)
}
- translated := gjson.ParseBytes(body2)
+ translated := util.ParseGJSONBytesNoCopy(body2)
declaration := translated.Get("tools.0")
if strings.Contains(declaration.Get("description").String(), "*** Begin Patch") != test.patch {
t.Fatalf("wrong contract: %s", translated.Raw)
}
var param any
- send := func(raw []byte) []gjson.Result {
+ send := func(raw []byte) []ojson.Result {
return patchEvents(ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/swe-2", request, nil, raw, ¶m))
}
send(patchStep("step.start", map[string]any{"type": "function_call", "id": "c", "name": test.upstream}))
@@ -269,7 +270,7 @@ func TestInteractionsApplyPatchDeltaBeforeStartDoesNotLeakArguments(t *testing.T
if events := patchSend(¶m, delta); len(events) != 0 {
t.Fatalf("raw arguments emitted before tool identity: %v", events)
}
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, patchSend(¶m, patchStep("step.start", map[string]any{"type": "function_call", "id": "item_2", "call_id": "call_2", "name": "functions__apply_patch"}))...)
events = append(events, patchSend(¶m, patchJSON(map[string]any{"event_type": "step.delta", "index": 2, "delta": map[string]any{"type": "arguments_delta", "arguments": `b"}`}}))...)
events = append(events, patchSend(¶m, patchStep("step.stop", nil))...)
@@ -324,7 +325,7 @@ func TestInteractionsApplyPatchDoesNotChangeOtherFunctionNames(t *testing.T) {
func TestInteractionsApplyPatchInterleavedCallsRemainIndependent(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
send := func(index int, event string, value any) {
key := "step"
if event == "step.delta" {
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go
index 63d35f8673c..de0b121791a 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_request.go
@@ -3,11 +3,10 @@ package responses
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
@@ -19,31 +18,31 @@ func ConvertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON
// part Interactions cannot receive when it leaves a user turn with nothing to send.
func convertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
var drops translatorcommon.UserTurnDrops
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","input":[]}`)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","input":[]}`)
model := requestModel(modelName, root)
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
if streamValue, ok := requestStreamValue(root, stream); ok {
- out, _ = sjson.SetBytes(out, "stream", streamValue)
+ _ = outDoc.Set("stream", streamValue)
}
if instructions := root.Get("instructions"); instructions.Exists() {
- out, _ = sjson.SetBytes(out, "system_instruction", responsesInstructionsText(instructions))
+ _ = outDoc.Set("system_instruction", responsesInstructionsText(instructions))
}
if previousResponseID := firstNonEmpty(root.Get("previous_response_id").String(), root.Get("previous_interaction_id").String()); previousResponseID != "" {
- out, _ = sjson.SetBytes(out, "previous_interaction_id", previousResponseID)
+ _ = outDoc.Set("previous_interaction_id", previousResponseID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
- out, _ = sjson.SetBytes(out, "environment_id", environmentID)
+ _ = outDoc.Set("environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
+ _ = outDoc.SetRaw("agent_config", agentConfig.Raw)
}
forAntigravity := isAntigravityModel(model)
forDevin := isDevinModel(model) || !forAntigravity
if input := root.Get("input"); input.Exists() {
- out = setResponsesInputOnInteractions(out, input, forAntigravity, &drops)
+ setResponsesInputOnInteractionsDoc(outDoc, input, forAntigravity, &drops)
}
- out = appendResponsesToolsToInteractions(out, root, forAntigravity, forDevin)
+ appendResponsesToolsToInteractionsDoc(outDoc, root, forAntigravity, forDevin)
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
if toolChoice.IsObject() {
tcRaw := []byte(toolChoice.Raw)
@@ -60,117 +59,117 @@ func convertOpenAIResponsesRequestToInteractions(modelName string, inputRawJSON
}
if fnName != "" && tcRaw != nil {
if toolChoice.Get("function.name").Exists() {
- tcRaw, _ = sjson.SetBytes(tcRaw, "function.name", fnName)
+ tcRaw, _ = ojson.SetBytes(tcRaw, "function.name", fnName)
} else if toolChoice.Get("name").Exists() {
- tcRaw, _ = sjson.SetBytes(tcRaw, "name", fnName)
+ tcRaw, _ = ojson.SetBytes(tcRaw, "name", fnName)
} else if toolChoice.Get("custom.name").Exists() {
- tcRaw, _ = sjson.SetBytes(tcRaw, "custom.name", fnName)
+ tcRaw, _ = ojson.SetBytes(tcRaw, "custom.name", fnName)
}
}
if tcRaw != nil {
- out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", tcRaw)
+ _ = outDoc.SetRawBytes("generation_config.tool_choice", tcRaw)
}
} else {
- out, _ = sjson.SetRawBytes(out, "generation_config.tool_choice", []byte(toolChoice.Raw))
+ _ = outDoc.SetRaw("generation_config.tool_choice", toolChoice.Raw)
}
}
- if effort := root.Get("reasoning.effort"); effort.Exists() && effort.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
+ if effort := root.Get("reasoning.effort"); effort.Exists() && effort.Type == ojson.String {
+ _ = outDoc.Set("generation_config.thinking_level", strings.ToLower(strings.TrimSpace(effort.String())))
}
- if summary := root.Get("reasoning.summary"); summary.Exists() && summary.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "generation_config.thinking_summaries", summary.String())
+ if summary := root.Get("reasoning.summary"); summary.Exists() && summary.Type == ojson.String {
+ _ = outDoc.Set("generation_config.thinking_summaries", summary.String())
}
if format := root.Get("response_format"); format.Exists() {
- out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
+ _ = outDoc.SetRaw("response_format", format.Raw)
} else if format := root.Get("text.format"); format.Exists() {
- out, _ = sjson.SetRawBytes(out, "response_format", []byte(format.Raw))
+ _ = outDoc.SetRaw("response_format", format.Raw)
}
if isAntigravityModel(model) {
if maxOutputTokens := firstExisting(root.Get("max_output_tokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")); maxOutputTokens.Exists() && !root.Get("agent_config.max_total_tokens").Exists() {
- out, _ = sjson.SetBytes(out, "agent_config.max_total_tokens", maxOutputTokens.Int())
+ _ = outDoc.Set("agent_config.max_total_tokens", maxOutputTokens.Int())
}
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens", "presence_penalty", "frequency_penalty", "candidate_count"} {
- out, _ = sjson.DeleteBytes(out, "generation_config."+knob)
+ _ = outDoc.Delete("generation_config." + knob)
}
} else {
if maxOutputTokens := firstExisting(root.Get("max_output_tokens"), root.Get("max_tokens"), root.Get("max_completion_tokens")); maxOutputTokens.Exists() {
- out, _ = sjson.SetBytes(out, "generation_config.max_output_tokens", maxOutputTokens.Int())
+ _ = outDoc.Set("generation_config.max_output_tokens", maxOutputTokens.Int())
}
if temp := root.Get("temperature"); temp.Exists() {
- out, _ = sjson.SetBytes(out, "generation_config.temperature", temp.Float())
+ _ = outDoc.Set("generation_config.temperature", temp.Float())
}
if topP := root.Get("top_p"); topP.Exists() {
- out, _ = sjson.SetBytes(out, "generation_config.top_p", topP.Float())
+ _ = outDoc.Set("generation_config.top_p", topP.Float())
}
if presencePenalty := root.Get("presence_penalty"); presencePenalty.Exists() {
- out, _ = sjson.SetBytes(out, "generation_config.presence_penalty", presencePenalty.Float())
+ _ = outDoc.Set("generation_config.presence_penalty", presencePenalty.Float())
}
if frequencyPenalty := root.Get("frequency_penalty"); frequencyPenalty.Exists() {
- out, _ = sjson.SetBytes(out, "generation_config.frequency_penalty", frequencyPenalty.Float())
+ _ = outDoc.Set("generation_config.frequency_penalty", frequencyPenalty.Float())
}
if stop := root.Get("stop"); stop.Exists() {
- out, _ = sjson.SetRawBytes(out, "generation_config.stop_sequences", []byte(stop.Raw))
+ _ = outDoc.SetRaw("generation_config.stop_sequences", stop.Raw)
}
}
- return out, drops.Err()
+ return outDoc.Bytes(), drops.Err()
}
func ConvertInteractionsRequestToOpenAIResponses(modelName string, inputRawJSON []byte, stream bool) ([]byte, error) {
- root := gjson.ParseBytes(inputRawJSON)
- out := []byte(`{"model":"","input":[]}`)
+ root := util.ParseGJSONBytesNoCopy(inputRawJSON)
+ outDoc := ojson.NewDocString(`{"model":"","input":[]}`)
model := requestModel(modelName, root)
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
if stream || root.Get("stream").Bool() {
- out, _ = sjson.SetBytes(out, "stream", true)
+ _ = outDoc.Set("stream", true)
}
if instructions := interactionsSystemInstructionText(root); instructions != "" {
- out, _ = sjson.SetBytes(out, "instructions", instructions)
+ _ = outDoc.Set("instructions", instructions)
}
if previousInteractionID := firstNonEmpty(root.Get("previous_interaction_id").String(), root.Get("previous_response_id").String()); previousInteractionID != "" {
- out, _ = sjson.SetBytes(out, "previous_response_id", previousInteractionID)
+ _ = outDoc.Set("previous_response_id", previousInteractionID)
}
if environmentID := firstNonEmpty(root.Get("environment_id").String(), root.Get("environment.id").String()); environmentID != "" {
- out, _ = sjson.SetBytes(out, "environment_id", environmentID)
+ _ = outDoc.Set("environment_id", environmentID)
}
if agentConfig := root.Get("agent_config"); agentConfig.Exists() {
- out, _ = sjson.SetRawBytes(out, "agent_config", []byte(agentConfig.Raw))
+ _ = outDoc.SetRaw("agent_config", agentConfig.Raw)
}
forAntigravity := isAntigravityModel(model)
if input := root.Get("input"); input.Exists() {
- out = setInteractionsInputOnResponses(out, input, forAntigravity)
+ setInteractionsInputOnResponsesDoc(outDoc, input, forAntigravity)
}
- out = appendInteractionsToolsToResponses(out, root.Get("tools"), forAntigravity)
+ appendInteractionsToolsToResponsesDoc(outDoc, root.Get("tools"), forAntigravity)
if toolChoice := root.Get("generation_config.tool_choice"); toolChoice.Exists() {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
+ _ = outDoc.SetRaw("tool_choice", toolChoice.Raw)
} else if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
- out, _ = sjson.SetRawBytes(out, "tool_choice", []byte(toolChoice.Raw))
+ _ = outDoc.SetRaw("tool_choice", toolChoice.Raw)
}
if effort := interactionsThinkingEffort(root); effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning.effort", effort)
+ _ = outDoc.Set("reasoning.effort", effort)
}
- if summary := root.Get("generation_config.thinking_summaries"); summary.Exists() && summary.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "reasoning.summary", summary.String())
+ if summary := root.Get("generation_config.thinking_summaries"); summary.Exists() && summary.Type == ojson.String {
+ _ = outDoc.Set("reasoning.summary", summary.String())
}
if responseModalities := root.Get("response_modalities"); responseModalities.Exists() {
- out, _ = sjson.SetRawBytes(out, "modalities", []byte(responseModalities.Raw))
+ _ = outDoc.SetRaw("modalities", responseModalities.Raw)
}
- if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == gjson.String {
- out, _ = sjson.SetBytes(out, "service_tier", serviceTier.String())
+ if serviceTier := root.Get("service_tier"); serviceTier.Exists() && serviceTier.Type == ojson.String {
+ _ = outDoc.Set("service_tier", serviceTier.String())
}
if format := root.Get("response_format"); format.Exists() {
- out, _ = sjson.SetRawBytes(out, "text.format", []byte(format.Raw))
+ _ = outDoc.SetRaw("text.format", format.Raw)
}
- return out, nil
+ return outDoc.Bytes(), nil
}
-func requestModel(modelName string, root gjson.Result) string {
+func requestModel(modelName string, root ojson.Result) string {
if strings.TrimSpace(modelName) != "" {
return modelName
}
return root.Get("model").String()
}
-func requestStreamValue(root gjson.Result, stream bool) (bool, bool) {
+func requestStreamValue(root ojson.Result, stream bool) (bool, bool) {
if value := root.Get("stream"); value.Exists() {
return value.Bool(), true
}
@@ -180,8 +179,8 @@ func requestStreamValue(root gjson.Result, stream bool) (bool, bool) {
return false, false
}
-func responsesInstructionsText(instructions gjson.Result) string {
- if instructions.Type == gjson.String {
+func responsesInstructionsText(instructions ojson.Result) string {
+ if instructions.Type == ojson.String {
return instructions.String()
}
if text := instructions.Get("text"); text.Exists() {
@@ -189,7 +188,7 @@ func responsesInstructionsText(instructions gjson.Result) string {
}
if parts := instructions.Get("content"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
@@ -200,12 +199,12 @@ func responsesInstructionsText(instructions gjson.Result) string {
return instructions.String()
}
-func interactionsSystemInstructionText(root gjson.Result) string {
+func interactionsSystemInstructionText(root ojson.Result) string {
sys := root.Get("system_instruction")
if !sys.Exists() {
return ""
}
- if sys.Type == gjson.String {
+ if sys.Type == ojson.String {
return sys.String()
}
if text := sys.Get("text"); text.Exists() {
@@ -213,7 +212,7 @@ func interactionsSystemInstructionText(root gjson.Result) string {
}
if parts := sys.Get("parts"); parts.Exists() && parts.IsArray() {
var builder strings.Builder
- parts.ForEach(func(_, part gjson.Result) bool {
+ parts.ForEach(func(_, part ojson.Result) bool {
if text := part.Get("text").String(); text != "" {
builder.WriteString(text)
}
@@ -224,27 +223,27 @@ func interactionsSystemInstructionText(root gjson.Result) string {
return ""
}
-func interactionsThinkingEffort(root gjson.Result) string {
+func interactionsThinkingEffort(root ojson.Result) string {
for _, path := range []string{
"generation_config.thinking_level",
"generation_config.thinkingConfig.thinkingLevel",
"generation_config.thinkingConfig.thinking_level",
"generation_config.thinking_config.thinking_level",
} {
- if level := root.Get(path); level.Exists() && level.Type == gjson.String {
+ if level := root.Get(path); level.Exists() && level.Type == ojson.String {
return strings.ToLower(strings.TrimSpace(level.String()))
}
}
return ""
}
-func setResponsesInputOnInteractions(out []byte, input gjson.Result, forAntigravity bool, drops *translatorcommon.UserTurnDrops) []byte {
+func setResponsesInputOnInteractionsDoc(outDoc *ojson.Doc, input ojson.Result, forAntigravity bool, drops *translatorcommon.UserTurnDrops) {
functionNamesByCallID := make(map[string]string)
items := make([][]byte, 0)
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
items = append(items, interactionsTextStep("user_input", input.String()))
} else if input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if converted := responsesInputItemToInteractions(item, functionNamesByCallID, forAntigravity, drops); converted != nil {
items = append(items, converted)
}
@@ -256,9 +255,8 @@ func setResponsesInputOnInteractions(out []byte, input gjson.Result, forAntigrav
}
}
if len(items) > 0 {
- out, _ = sjson.SetRawBytes(out, "input", translatorcommon.JoinRawArray(items))
+ _ = outDoc.SetRawBytes("input", translatorcommon.JoinRawArray(items))
}
- return out
}
// responsesUserTurnDrops returns drops when the item is a real user turn. A
@@ -271,7 +269,7 @@ func responsesUserTurnDrops(role string, drops *translatorcommon.UserTurnDrops)
return nil
}
-func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID map[string]string, forAntigravity bool, drops *translatorcommon.UserTurnDrops) []byte {
+func responsesInputItemToInteractions(item ojson.Result, functionNamesByCallID map[string]string, forAntigravity bool, drops *translatorcommon.UserTurnDrops) []byte {
switch item.Get("type").String() {
case "message":
stepType := "user_input"
@@ -281,9 +279,10 @@ func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID m
stepType = "model_output"
turnDrops = nil
}
- step := []byte(`{"type":"","content":[]}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
- return appendResponsesContentToInteractions(step, item.Get("content"), turnDrops)
+ stepDoc := ojson.NewDocString(`{"type":"","content":[]}`)
+ _ = stepDoc.Set("type", stepType)
+ appendResponsesContentToInteractionsDoc(stepDoc, item.Get("content"), turnDrops)
+ return stepDoc.Bytes()
case "function_call":
callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String())
name := item.Get("name").String()
@@ -306,6 +305,8 @@ func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID m
return responsesCustomToolCallToInteractions(item, forAntigravity)
case "function_call_output", "custom_tool_call_output":
return responsesFunctionOutputToInteractions(item, functionNamesByCallID, forAntigravity)
+ case "reasoning":
+ return responsesReasoningToInteractions(item)
case "input_text", "output_text", "text":
stepType := "user_input"
if item.Get("type").String() == "output_text" {
@@ -320,26 +321,28 @@ func responsesInputItemToInteractions(item gjson.Result, functionNamesByCallID m
} else {
turnDrops = drops
}
- step := []byte(`{"type":"","content":[]}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
- return appendResponsesContentToInteractions(step, item, turnDrops)
+ stepDoc := ojson.NewDocString(`{"type":"","content":[]}`)
+ _ = stepDoc.Set("type", stepType)
+ appendResponsesContentToInteractionsDoc(stepDoc, item, turnDrops)
+ return stepDoc.Bytes()
default:
if content := item.Get("content"); content.Exists() {
- step := []byte(`{"type":"user_input","content":[]}`)
- return appendResponsesContentToInteractions(step, content, responsesUserTurnDrops(item.Get("role").String(), drops))
+ stepDoc := ojson.NewDocString(`{"type":"user_input","content":[]}`)
+ appendResponsesContentToInteractionsDoc(stepDoc, content, responsesUserTurnDrops(item.Get("role").String(), drops))
+ return stepDoc.Bytes()
}
}
return nil
}
-// appendResponsesContentToInteractions converts one message content into step
+// appendResponsesContentToInteractionsDoc converts one message content into step
// content. A non-nil drops closes the user turn: an attachment that cannot be
// sent is recorded, and text or any other sendable part beside it keeps the turn
// alive. An empty text part is forwarded but does not count as sendable.
-func appendResponsesContentToInteractions(step []byte, content gjson.Result, drops *translatorcommon.UserTurnDrops) []byte {
+func appendResponsesContentToInteractionsDoc(stepDoc *ojson.Doc, content ojson.Result, drops *translatorcommon.UserTurnDrops) {
var contentItems [][]byte
sendable := 0
- appendPart := func(item gjson.Result) {
+ appendPart := func(item ojson.Result) {
part, ok := responsesContentPartToInteractions(item)
if !ok {
if partType := item.Get("type").String(); drops != nil && isResponsesUnsendableAttachmentType(partType) {
@@ -348,19 +351,19 @@ func appendResponsesContentToInteractions(step []byte, content gjson.Result, dro
return
}
contentItems = append(contentItems, part)
- if gjson.GetBytes(part, "type").String() != "text" || gjson.GetBytes(part, "text").String() != "" {
+ if ojson.GetBytesNoCopy(part, "type").String() != "text" || ojson.GetBytesNoCopy(part, "text").String() != "" {
sendable++
}
}
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", content.String())
+ part, _ = ojson.SetBytes(part, "text", content.String())
contentItems = append(contentItems, part)
if content.String() != "" {
sendable++
}
} else if content.IsArray() {
- content.ForEach(func(_, item gjson.Result) bool {
+ content.ForEach(func(_, item ojson.Result) bool {
appendPart(item)
return true
})
@@ -371,9 +374,8 @@ func appendResponsesContentToInteractions(step []byte, content gjson.Result, dro
drops.EndTurn(sendable)
}
if len(contentItems) > 0 {
- step = translatorcommon.SetRawArrayItems(step, "content", contentItems)
+ translatorcommon.SetRawArrayItemsDoc(stepDoc, "content", contentItems)
}
- return step
}
// isResponsesUnsendableAttachmentType reports the Responses attachment types
@@ -387,11 +389,11 @@ func isResponsesUnsendableAttachmentType(partType string) bool {
return false
}
-func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) {
+func responsesContentPartToInteractions(part ojson.Result) ([]byte, bool) {
switch part.Get("type").String() {
case "input_text", "output_text", "text":
out := []byte(`{"type":"text","text":""}`)
- out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
+ out, _ = ojson.SetBytes(out, "text", part.Get("text").String())
return out, true
case "input_image", "output_image":
return responsesImagePartToInteractions(part), true
@@ -402,7 +404,7 @@ func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) {
}
if text := part.Get("text"); text.Exists() {
out := []byte(`{"type":"text","text":""}`)
- out, _ = sjson.SetBytes(out, "text", text.String())
+ out, _ = ojson.SetBytes(out, "text", text.String())
return out, true
}
return nil, false
@@ -411,40 +413,40 @@ func responsesContentPartToInteractions(part gjson.Result) ([]byte, bool) {
// responsesFilePartToInteractions maps an input_file onto an Interactions
// document. Interactions takes inline bytes or a url; a bare file_id names a file
// only the OpenAI side can resolve, so it reports false.
-func responsesFilePartToInteractions(part gjson.Result) ([]byte, bool) {
+func responsesFilePartToInteractions(part ojson.Result) ([]byte, bool) {
filename := part.Get("filename").String()
fallbackMIMEType := firstNonEmpty(part.Get("mime_type").String(), part.Get("mimeType").String())
- out := []byte(`{"type":"document"}`)
+ outDoc := ojson.NewDocString(`{"type":"document"}`)
if filename != "" {
- out, _ = sjson.SetBytes(out, "filename", filename)
+ _ = outDoc.Set("filename", filename)
}
hasContent := false
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, fallbackMIMEType, part.Get("file_data").String()); ok {
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
- out, _ = sjson.SetBytes(out, "data", data)
+ _ = outDoc.Set("mime_type", mimeType)
+ _ = outDoc.Set("data", data)
hasContent = true
}
if fileURL := part.Get("file_url").String(); fileURL != "" {
- out, _ = sjson.SetBytes(out, "file_url", fileURL)
+ _ = outDoc.Set("file_url", fileURL)
hasContent = true
}
- return out, hasContent
+ return outDoc.Bytes(), hasContent
}
// responsesAudioPartToInteractions maps an input_audio with inline bytes onto an
// Interactions audio part.
-func responsesAudioPartToInteractions(part gjson.Result) ([]byte, bool) {
+func responsesAudioPartToInteractions(part ojson.Result) ([]byte, bool) {
audio := part.Get("input_audio")
data := firstNonEmpty(audio.Get("data").String(), part.Get("data").String())
if data == "" {
return nil, false
}
- out := []byte(`{"type":"audio","data":""}`)
- out, _ = sjson.SetBytes(out, "data", data)
+ outDoc := ojson.NewDocString(`{"type":"audio","data":""}`)
+ _ = outDoc.Set("data", data)
if format := firstNonEmpty(audio.Get("format").String(), part.Get("format").String()); format != "" {
- out, _ = sjson.SetBytes(out, "mime_type", responsesInputAudioMIMEType(format))
+ _ = outDoc.Set("mime_type", responsesInputAudioMIMEType(format))
}
- return out, true
+ return outDoc.Bytes(), true
}
func responsesInputAudioMIMEType(format string) string {
@@ -462,29 +464,29 @@ func responsesInputAudioMIMEType(format string) string {
}
}
-func responsesImagePartToInteractions(part gjson.Result) []byte {
- out := []byte(`{"type":"image"}`)
+func responsesImagePartToInteractions(part ojson.Result) []byte {
+ outDoc := ojson.NewDocString(`{"type":"image"}`)
imageURL := firstNonEmpty(part.Get("image_url").String(), part.Get("url").String())
if mimeType, data, ok := parseDataURL(imageURL); ok {
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
- out, _ = sjson.SetBytes(out, "data", data)
- return out
+ _ = outDoc.Set("mime_type", mimeType)
+ _ = outDoc.Set("data", data)
+ return outDoc.Bytes()
}
if data := part.Get("data").String(); data != "" {
- out, _ = sjson.SetBytes(out, "data", data)
+ _ = outDoc.Set("data", data)
if mimeType := part.Get("mime_type").String(); mimeType != "" {
- out, _ = sjson.SetBytes(out, "mime_type", mimeType)
+ _ = outDoc.Set("mime_type", mimeType)
}
- return out
+ return outDoc.Bytes()
}
if imageURL != "" {
- out, _ = sjson.SetBytes(out, "image_url", imageURL)
+ _ = outDoc.Set("image_url", imageURL)
}
- return out
+ return outDoc.Bytes()
}
-func responsesFunctionCallToInteractions(item gjson.Result, forAntigravity bool) []byte {
- out := []byte(`{"type":"function_call","name":"","arguments":{}}`)
+func responsesFunctionCallToInteractions(item ojson.Result, forAntigravity bool) []byte {
+ outDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
name := item.Get("name").String()
if ns := item.Get("namespace").String(); ns != "" && name != "" {
name = util.QualifyResponsesNamespaceToolName(ns, name)
@@ -492,16 +494,16 @@ func responsesFunctionCallToInteractions(item gjson.Result, forAntigravity bool)
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- out, _ = sjson.SetBytes(out, "name", name)
+ _ = outDoc.Set("name", name)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
- setJSONValue(&out, "arguments", item.Get("arguments"), []byte(`{}`))
- return out
+ setJSONValueDoc(outDoc, "arguments", item.Get("arguments"), `{}`)
+ return outDoc.Bytes()
}
-func responsesCustomToolCallToInteractions(item gjson.Result, forAntigravity bool) []byte {
- out := []byte(`{"type":"function_call","name":"","arguments":{}}`)
+func responsesCustomToolCallToInteractions(item ojson.Result, forAntigravity bool) []byte {
+ outDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
name := item.Get("name").String()
if ns := item.Get("namespace").String(); ns != "" && name != "" {
name = util.QualifyResponsesNamespaceToolName(ns, name)
@@ -509,20 +511,20 @@ func responsesCustomToolCallToInteractions(item gjson.Result, forAntigravity boo
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- out, _ = sjson.SetBytes(out, "name", name)
+ _ = outDoc.Set("name", name)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
if input := item.Get("input"); input.Exists() {
- out, _ = sjson.SetBytes(out, "arguments.input", input.String())
+ _ = outDoc.Set("arguments.input", input.String())
} else {
- setJSONValue(&out, "arguments", item.Get("arguments"), []byte(`{}`))
+ setJSONValueDoc(outDoc, "arguments", item.Get("arguments"), `{}`)
}
- return out
+ return outDoc.Bytes()
}
-func responsesFunctionOutputToInteractions(item gjson.Result, functionNamesByCallID map[string]string, forAntigravity bool) []byte {
- out := []byte(`{"type":"function_result","name":"","result":{}}`)
+func responsesFunctionOutputToInteractions(item ojson.Result, functionNamesByCallID map[string]string, forAntigravity bool) []byte {
+ outDoc := ojson.NewDocString(`{"type":"function_result","name":"","result":{}}`)
callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String())
name := item.Get("name").String()
if ns := item.Get("namespace").String(); ns != "" && name != "" {
@@ -535,37 +537,59 @@ func responsesFunctionOutputToInteractions(item gjson.Result, functionNamesByCal
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- out, _ = sjson.SetBytes(out, "name", name)
+ _ = outDoc.Set("name", name)
}
if callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
result := item.Get("output")
if !result.Exists() {
result = item.Get("result")
}
- setJSONValue(&out, "result", result, []byte(`{}`))
- return out
+ setJSONValueDoc(outDoc, "result", result, `{}`)
+ return outDoc.Bytes()
+}
+
+// responsesReasoningToInteractions carries a Devin-sealed reasoning item back
+// upstream as a thought step so the model keeps its own earlier thinking across
+// tool-call turns. Reasoning items without a Devin signature are left out, as
+// before, because other providers cannot read them.
+func responsesReasoningToInteractions(item ojson.Result) []byte {
+ sig := strings.TrimSpace(item.Get("encrypted_content").String())
+ if !strings.HasPrefix(sig, "sealed.v1.") {
+ return nil
+ }
+ var texts []string
+ item.Get("summary").ForEach(func(_, part ojson.Result) bool {
+ if t := part.Get("text").String(); t != "" {
+ texts = append(texts, t)
+ }
+ return true
+ })
+ stepDoc := ojson.NewDocString(`{"type":"thought","content":[{"type":"text","text":""}],"signature":""}`)
+ _ = stepDoc.Set("content.0.text", strings.Join(texts, "\n\n"))
+ _ = stepDoc.Set("signature", sig)
+ return stepDoc.Bytes()
}
func interactionsTextStep(stepType, text string) []byte {
- step := []byte(`{"type":"","content":[{"type":"text","text":""}]}`)
- step, _ = sjson.SetBytes(step, "type", stepType)
- step, _ = sjson.SetBytes(step, "content.0.text", text)
- return step
+ stepDoc := ojson.NewDocString(`{"type":"","content":[{"type":"text","text":""}]}`)
+ _ = stepDoc.Set("type", stepType)
+ _ = stepDoc.Set("content.0.text", text)
+ return stepDoc.Bytes()
}
-func appendResponsesToolsToInteractions(out []byte, root gjson.Result, forAntigravity bool, forDevin bool) []byte {
+func appendResponsesToolsToInteractionsDoc(outDoc *ojson.Doc, root ojson.Result, forAntigravity bool, forDevin bool) {
if !root.Exists() {
- return out
+ return
}
targetRoot := root
if root.IsArray() {
- targetRoot = gjson.ParseBytes([]byte(`{"tools":` + root.Raw + `}`))
+ targetRoot = ojson.Parse(`{"tools":` + root.Raw + `}`)
}
descriptors := util.CollectResponsesToolDescriptors(targetRoot)
if len(descriptors) == 0 {
- return out
+ return
}
winners := util.CollectResponsesToolWinners(targetRoot)
@@ -590,8 +614,8 @@ func appendResponsesToolsToInteractions(out []byte, root gjson.Result, forAntigr
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- item := []byte(`{"type":"function","name":""}`)
- item, _ = sjson.SetBytes(item, "name", name)
+ itemDoc := ojson.NewDocString(`{"type":"function","name":""}`)
+ _ = itemDoc.Set("name", name)
desc := util.ResponsesToolDescription(descriptor.Tool)
if applypatch.IsCustomTool(descriptor.Tool) {
desc = applypatch.Description(descriptor.Tool)
@@ -603,35 +627,34 @@ func appendResponsesToolsToInteractions(out []byte, root gjson.Result, forAntigr
desc = translatorcommon.SanitizeDevinToolDescription(descriptor.LocalName, desc)
}
}
- item, _ = sjson.SetBytes(item, "description", desc)
+ _ = itemDoc.Set("description", desc)
}
if applypatch.IsCustomTool(descriptor.Tool) {
- item, _ = sjson.SetRawBytes(item, "parameters", applypatch.Parameters())
+ _ = itemDoc.SetRawBytes("parameters", applypatch.Parameters())
} else if descriptor.ToolType == "custom" {
- item, _ = sjson.SetRawBytes(item, "parameters", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`))
+ _ = itemDoc.SetRaw("parameters", `{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`)
} else {
params := util.ResponsesToolParameters(descriptor.Tool)
if params.Exists() {
- item, _ = sjson.SetRawBytes(item, "parameters", []byte(params.Raw))
+ _ = itemDoc.SetRaw("parameters", params.Raw)
}
}
- toolItems = append(toolItems, item)
+ toolItems = append(toolItems, itemDoc.Bytes())
}
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func setInteractionsInputOnResponses(out []byte, input gjson.Result, forAntigravity bool) []byte {
+func setInteractionsInputOnResponsesDoc(outDoc *ojson.Doc, input ojson.Result, forAntigravity bool) {
items := make([][]byte, 0)
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
items = append(items, interactionsTextMessage(input.String()))
} else if input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if converted := interactionsInputItemToResponses(item, forAntigravity); converted != nil {
items = append(items, converted)
}
@@ -643,18 +666,17 @@ func setInteractionsInputOnResponses(out []byte, input gjson.Result, forAntigrav
}
}
if len(items) > 0 {
- out, _ = sjson.SetRawBytes(out, "input", translatorcommon.JoinRawArray(items))
+ _ = outDoc.SetRawBytes("input", translatorcommon.JoinRawArray(items))
}
- return out
}
func interactionsTextMessage(text string) []byte {
item := []byte(`{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}`)
- item, _ = sjson.SetBytes(item, "content.0.text", text)
+ item, _ = ojson.SetBytes(item, "content.0.text", text)
return item
}
-func interactionsInputItemToResponses(item gjson.Result, forAntigravity bool) []byte {
+func interactionsInputItemToResponses(item ojson.Result, forAntigravity bool) []byte {
switch item.Get("type").String() {
case "user_input":
return interactionsMessageToResponses(item, "user")
@@ -667,44 +689,44 @@ func interactionsInputItemToResponses(item gjson.Result, forAntigravity bool) []
case "function_result":
return interactionsFunctionResultToResponses(item, forAntigravity)
default:
- if item.Type == gjson.String {
+ if item.Type == ojson.String {
return interactionsTextMessage(item.String())
}
}
return nil
}
-func interactionsMessageToResponses(item gjson.Result, role string) []byte {
+func interactionsMessageToResponses(item ojson.Result, role string) []byte {
var contentItems [][]byte
content := item.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
- part := []byte(`{"type":"","text":""}`)
- part, _ = sjson.SetBytes(part, "type", partType)
- part, _ = sjson.SetBytes(part, "text", content.String())
- contentItems = append(contentItems, part)
+ partDoc := ojson.NewDocString(`{"type":"","text":""}`)
+ _ = partDoc.Set("type", partType)
+ _ = partDoc.Set("text", content.String())
+ contentItems = append(contentItems, partDoc.Bytes())
} else {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if converted, ok := interactionsContentPartToResponses(part, role); ok {
contentItems = append(contentItems, converted)
}
return true
})
}
- out := []byte(`{"type":"message","role":"","content":[]}`)
- out, _ = sjson.SetBytes(out, "role", role)
- out = translatorcommon.SetRawArrayItems(out, "content", contentItems)
- return out
+ outDoc := ojson.NewDocString(`{"type":"message","role":"","content":[]}`)
+ _ = outDoc.Set("role", role)
+ translatorcommon.SetRawArrayItemsDoc(outDoc, "content", contentItems)
+ return outDoc.Bytes()
}
-func interactionsThoughtToResponses(item gjson.Result) []byte {
+func interactionsThoughtToResponses(item ojson.Result) []byte {
var summaryItems [][]byte
for _, text := range interactionsContentTexts(item.Get("content")) {
part := []byte(`{"type":"summary_text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
summaryItems = append(summaryItems, part)
}
out := []byte(`{"type":"reasoning","summary":[]}`)
@@ -712,7 +734,7 @@ func interactionsThoughtToResponses(item gjson.Result) []byte {
return out
}
-func interactionsContentPartToResponses(part gjson.Result, role string) ([]byte, bool) {
+func interactionsContentPartToResponses(part ojson.Result, role string) ([]byte, bool) {
partType := part.Get("type").String()
if partType == "" && part.Get("text").Exists() {
partType = "text"
@@ -723,46 +745,46 @@ func interactionsContentPartToResponses(part gjson.Result, role string) ([]byte,
if role == "assistant" {
outType = "output_text"
}
- out := []byte(`{"type":"","text":""}`)
- out, _ = sjson.SetBytes(out, "type", outType)
- out, _ = sjson.SetBytes(out, "text", part.Get("text").String())
- return out, true
+ outDoc := ojson.NewDocString(`{"type":"","text":""}`)
+ _ = outDoc.Set("type", outType)
+ _ = outDoc.Set("text", part.Get("text").String())
+ return outDoc.Bytes(), true
case "image":
outType := "input_image"
if role == "assistant" {
outType = "output_image"
}
- out := []byte(`{"type":""}`)
- out, _ = sjson.SetBytes(out, "type", outType)
+ outDoc := ojson.NewDocString(`{"type":""}`)
+ _ = outDoc.Set("type", outType)
imageURL := interactionsMediaDataURL(part)
if imageURL != "" {
- out, _ = sjson.SetBytes(out, "image_url", imageURL)
+ _ = outDoc.Set("image_url", imageURL)
}
- return out, true
+ return outDoc.Bytes(), true
case "audio":
out := []byte(`{"type":"output_text","text":""}`)
format := mediaFormat(part.Get("mime_type").String())
- out, _ = sjson.SetBytes(out, "text", "Audio content: inline data (Format: "+format+")")
+ out, _ = ojson.SetBytes(out, "text", "Audio content: inline data (Format: "+format+")")
return out, true
case "video", "document":
outType := "input_file"
if role == "assistant" {
outType = "output_file"
}
- out := []byte(`{"type":""}`)
- out, _ = sjson.SetBytes(out, "type", outType)
+ outDoc := ojson.NewDocString(`{"type":""}`)
+ _ = outDoc.Set("type", outType)
if dataURL := interactionsMediaDataURL(part); dataURL != "" {
- out, _ = sjson.SetBytes(out, "file_data", dataURL)
+ _ = outDoc.Set("file_data", dataURL)
}
if filename := part.Get("filename").String(); filename != "" {
- out, _ = sjson.SetBytes(out, "filename", filename)
+ _ = outDoc.Set("filename", filename)
}
- return out, true
+ return outDoc.Bytes(), true
}
return nil, false
}
-func interactionsFunctionCallToResponsesWithIdentity(item gjson.Result, forAntigravity bool, toolIdentityMap map[string]util.ResponsesToolIdentity) []byte {
+func interactionsFunctionCallToResponsesWithIdentity(item ojson.Result, forAntigravity bool, toolIdentityMap map[string]util.ResponsesToolIdentity) []byte {
rawName := item.Get("name").String()
name := rawName
var namespace string
@@ -783,67 +805,66 @@ func interactionsFunctionCallToResponsesWithIdentity(item gjson.Result, forAntig
}
callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String())
- var out []byte
if isCustom {
- out = []byte(`{"type":"custom_tool_call","call_id":"","name":"","input":""}`)
+ outDoc := ojson.NewDocString(`{"type":"custom_tool_call","call_id":"","name":"","input":""}`)
if callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
if namespace != "" {
- out, _ = sjson.SetBytes(out, "namespace", namespace)
+ _ = outDoc.Set("namespace", namespace)
}
- out, _ = sjson.SetBytes(out, "name", name)
+ _ = outDoc.Set("name", name)
inputStr := util.UnwrapResponsesCustomToolInput(jsonStringValue(item.Get("arguments"), "{}"))
- out, _ = sjson.SetBytes(out, "input", inputStr)
- return out
+ _ = outDoc.Set("input", inputStr)
+ return outDoc.Bytes()
}
- out = []byte(`{"type":"function_call","call_id":"","name":"","arguments":"{}"}`)
+ outDoc := ojson.NewDocString(`{"type":"function_call","call_id":"","name":"","arguments":"{}"}`)
if callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
if namespace != "" {
- out, _ = sjson.SetBytes(out, "namespace", namespace)
+ _ = outDoc.Set("namespace", namespace)
}
- out, _ = sjson.SetBytes(out, "name", name)
- out, _ = translatorcommon.SetStringWithoutHTMLEscape(out, "arguments", jsonStringValue(item.Get("arguments"), "{}"))
- return out
+ _ = outDoc.Set("name", name)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(outDoc, "arguments", jsonStringValue(item.Get("arguments"), "{}"))
+ return outDoc.Bytes()
}
-func interactionsFunctionCallToResponses(item gjson.Result, forAntigravity bool) []byte {
+func interactionsFunctionCallToResponses(item ojson.Result, forAntigravity bool) []byte {
return interactionsFunctionCallToResponsesWithIdentity(item, forAntigravity, nil)
}
-func interactionsFunctionResultToResponses(item gjson.Result, forAntigravity bool) []byte {
- out := []byte(`{"type":"function_call_output","call_id":"","output":""}`)
+func interactionsFunctionResultToResponses(item ojson.Result, forAntigravity bool) []byte {
+ outDoc := ojson.NewDocString(`{"type":"function_call_output","call_id":"","output":""}`)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
- out, _ = sjson.SetBytes(out, "call_id", callID)
+ _ = outDoc.Set("call_id", callID)
}
if name := item.Get("name").String(); name != "" {
if forAntigravity {
name = translatorcommon.AntigravityUpstreamToolNameToClient(name)
}
- out, _ = sjson.SetBytes(out, "name", name)
+ _ = outDoc.Set("name", name)
}
result := item.Get("result")
if !result.Exists() {
result = item.Get("output")
}
- out, _ = sjson.SetBytes(out, "output", jsonStringValue(result, ""))
- return out
+ _ = outDoc.Set("output", jsonStringValue(result, ""))
+ return outDoc.Bytes()
}
-func appendInteractionsToolsToResponses(out []byte, tools gjson.Result, forAntigravity bool) []byte {
+func appendInteractionsToolsToResponsesDoc(outDoc *ojson.Doc, tools ojson.Result, forAntigravity bool) {
if !tools.Exists() || !tools.IsArray() {
- return out
+ return
}
var toolItems [][]byte
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if converted, ok := responsesToolFromInteractionsTool(tool, forAntigravity); ok {
toolItems = append(toolItems, converted)
}
if decls := tool.Get("function_declarations"); decls.Exists() && decls.IsArray() {
- decls.ForEach(func(_, decl gjson.Result) bool {
+ decls.ForEach(func(_, decl ojson.Result) bool {
if converted, ok := responsesToolFromInteractionsTool(decl, forAntigravity); ok {
toolItems = append(toolItems, converted)
}
@@ -853,12 +874,11 @@ func appendInteractionsToolsToResponses(out []byte, tools gjson.Result, forAntig
return true
})
if len(toolItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
+ _ = outDoc.SetRawBytes("tools", translatorcommon.JoinRawArray(toolItems))
}
- return out
}
-func responsesToolFromInteractionsTool(tool gjson.Result, forAntigravity bool) ([]byte, bool) {
+func responsesToolFromInteractionsTool(tool ojson.Result, forAntigravity bool) ([]byte, bool) {
name := firstNonEmpty(tool.Get("name").String(), tool.Get("function.name").String())
if name == "" {
return nil, false
@@ -866,20 +886,20 @@ func responsesToolFromInteractionsTool(tool gjson.Result, forAntigravity bool) (
if forAntigravity {
name = translatorcommon.AntigravityUpstreamToolNameToClient(name)
}
- out := []byte(`{"type":"function","name":""}`)
- out, _ = sjson.SetBytes(out, "name", name)
- copyOptionalString(&out, "description", firstExisting(tool.Get("description"), tool.Get("function.description")))
- copyOptionalRaw(&out, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")))
- return out, true
+ outDoc := ojson.NewDocString(`{"type":"function","name":""}`)
+ _ = outDoc.Set("name", name)
+ copyOptionalStringDoc(outDoc, "description", firstExisting(tool.Get("description"), tool.Get("function.description")))
+ copyOptionalRawDoc(outDoc, "parameters", firstExisting(tool.Get("parameters"), tool.Get("function.parameters"), tool.Get("parametersJsonSchema")))
+ return outDoc.Bytes(), true
}
-func interactionsContentTexts(content gjson.Result) []string {
+func interactionsContentTexts(content ojson.Result) []string {
texts := make([]string, 0)
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return append(texts, content.String())
}
if content.IsArray() {
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if text := firstNonEmpty(part.Get("text").String(), part.Get("content.text").String()); text != "" {
texts = append(texts, text)
}
@@ -889,7 +909,7 @@ func interactionsContentTexts(content gjson.Result) []string {
return texts
}
-func interactionsMediaDataURL(part gjson.Result) string {
+func interactionsMediaDataURL(part ojson.Result) string {
if url := firstNonEmpty(part.Get("image_url").String(), part.Get("file_data").String(), part.Get("url").String()); url != "" {
return url
}
@@ -929,41 +949,41 @@ func parseDataURL(value string) (string, string, bool) {
return mimeType, data, true
}
-func setJSONValue(out *[]byte, path string, value gjson.Result, defaultRaw []byte) {
+func setJSONValueDoc(outDoc *ojson.Doc, path string, value ojson.Result, defaultRaw string) {
if !value.Exists() {
- *out, _ = sjson.SetRawBytes(*out, path, defaultRaw)
+ _ = outDoc.SetRaw(path, defaultRaw)
return
}
- if value.Type == gjson.String && gjson.Valid(value.String()) {
- *out, _ = sjson.SetRawBytes(*out, path, []byte(value.String()))
+ if value.Type == ojson.String && ojson.Valid(value.String()) {
+ _ = outDoc.SetRaw(path, value.String())
return
}
- if value.Type == gjson.String {
- *out, _ = sjson.SetBytes(*out, path, value.String())
+ if value.Type == ojson.String {
+ _ = outDoc.Set(path, value.String())
return
}
- *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
+ _ = outDoc.SetRaw(path, value.Raw)
}
-func jsonStringValue(value gjson.Result, fallback string) string {
+func jsonStringValue(value ojson.Result, fallback string) string {
if !value.Exists() {
return fallback
}
- if value.Type == gjson.String {
+ if value.Type == ojson.String {
return value.String()
}
return value.Raw
}
-func copyOptionalString(out *[]byte, path string, value gjson.Result) {
+func copyOptionalStringDoc(outDoc *ojson.Doc, path string, value ojson.Result) {
if value.Exists() {
- *out, _ = sjson.SetBytes(*out, path, value.String())
+ _ = outDoc.Set(path, value.String())
}
}
-func copyOptionalRaw(out *[]byte, path string, value gjson.Result) {
+func copyOptionalRawDoc(outDoc *ojson.Doc, path string, value ojson.Result) {
if value.Exists() {
- *out, _ = sjson.SetRawBytes(*out, path, []byte(value.Raw))
+ _ = outDoc.SetRaw(path, value.Raw)
}
}
@@ -976,13 +996,13 @@ func isDevinModel(model string) bool {
return strings.HasPrefix(clean, "devin/") || clean == "devin" || strings.Contains(clean, "devin")
}
-func firstExisting(values ...gjson.Result) gjson.Result {
+func firstExisting(values ...ojson.Result) ojson.Result {
for _, value := range values {
if value.Exists() {
return value
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
func firstNonEmpty(values ...string) string {
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go
index d25bb289cef..3ee078e42e5 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_request_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponsesRequestToInteractions(t *testing.T) {
@@ -24,66 +24,66 @@ func TestConvertOpenAIResponsesRequestToInteractions(t *testing.T) {
"stream":true
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("gpt-test", raw, true)
- if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.type").String(); got != "user_input" {
t.Fatalf("input.0.type = %q, want user_input. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "text" {
t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("input text = %q, want hi. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.1.mime_type").String(); got != "image/png" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.1.mime_type").String(); got != "image/png" {
t.Fatalf("image mime_type = %q, want image/png. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.call_id").String(); got != "call_1" {
t.Fatalf("function call_id = %q, want call_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.type").String(); got != "function_result" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.type").String(); got != "function_result" {
t.Fatalf("function result type = %q, want function_result. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.name").String(); got != "lookup" {
t.Fatalf("function result name = %q, want lookup. Output: %s", got, string(out))
}
- sys := gjson.GetBytes(out, "system_instruction")
- if sys.Type != gjson.String {
+ sys := ojson.GetBytesNoCopy(out, "system_instruction")
+ if sys.Type != ojson.String {
t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out))
}
if got := sys.String(); got != "be brief" {
t.Fatalf("system_instruction = %q, want be brief. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "system_instruction.parts").Exists() {
+ if ojson.GetBytesNoCopy(out, "system_instruction.parts").Exists() {
t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "generation_config.thinking_level").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.thinking_level").String(); got != "high" {
t.Fatalf("thinking_level = %q, want high. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tool name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generation_config.tool_choice").String(); got != "auto" {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "response_format.type").String(); got != "json_object" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.type").String(); got != "json_object" {
t.Fatalf("response_format.type = %q, want json_object. Output: %s", got, string(out))
}
}
func TestConvertOpenAIResponsesRequestToInteractionsPreservesRequestStream(t *testing.T) {
out, _ := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":true}`), false)
- if got := gjson.GetBytes(out, "stream").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
out, _ = ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":false}`), true)
- if got := gjson.GetBytes(out, "stream").Bool(); got {
+ if got := ojson.GetBytesNoCopy(out, "stream").Bool(); got {
t.Fatalf("stream = %v, want false. Output: %s", got, string(out))
}
}
func TestConvertOpenAIResponsesRequestToInteractionsPreservesPreviousResponseID(t *testing.T) {
out, _ := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_response_id":"resp_123"}`), false)
- if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "resp_123" {
+ if got := ojson.GetBytesNoCopy(out, "previous_interaction_id").String(); got != "resp_123" {
t.Fatalf("previous_interaction_id = %q, want resp_123. Output: %s", got, string(out))
}
}
@@ -94,7 +94,7 @@ func TestConvertInteractionsRequestToOpenAIResponsesWithToolMessages(t *testing.
foundFunctionCall := false
foundFunctionOutput := false
- gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool {
+ ojson.GetBytesNoCopy(out, "input").ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "function_call" {
foundFunctionCall = true
if item.Get("name").String() != "lookup" {
@@ -117,30 +117,30 @@ func TestConvertInteractionsRequestToOpenAIResponsesWithToolMessages(t *testing.
func TestConvertInteractionsRequestToOpenAIResponsesPreservesStringSystemAndThinkingConfig(t *testing.T) {
raw := []byte(`{"model":"gpt-test","system_instruction":"You are a helpful assistant.","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}],"tools":[{"name":"lookup","type":"function","parameters":{"type":"object"}}],"generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true}`)
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, true)
- if got := gjson.GetBytes(out, "instructions").String(); got != "You are a helpful assistant." {
+ if got := ojson.GetBytesNoCopy(out, "instructions").String(); got != "You are a helpful assistant." {
t.Fatalf("instructions = %q, want system instruction. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice").String(); got != "auto" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice").String(); got != "auto" {
t.Fatalf("tool_choice = %q, want auto. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "reasoning.effort").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "reasoning.effort").String(); got != "high" {
t.Fatalf("reasoning.effort = %q, want high. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "reasoning.summary").String(); got != "auto" {
+ if got := ojson.GetBytesNoCopy(out, "reasoning.summary").String(); got != "auto" {
t.Fatalf("reasoning.summary = %q, want auto. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesInteractionStream(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","stream":true}`), false)
- if got := gjson.GetBytes(out, "stream").Bool(); !got {
+ if got := ojson.GetBytesNoCopy(out, "stream").Bool(); !got {
t.Fatalf("stream = %v, want true. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesPreservesPreviousInteractionID(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_interaction_id":"interaction_123"}`), false)
- if got := gjson.GetBytes(out, "previous_response_id").String(); got != "interaction_123" {
+ if got := ojson.GetBytesNoCopy(out, "previous_response_id").String(); got != "interaction_123" {
t.Fatalf("previous_response_id = %q, want interaction_123. Output: %s", got, string(out))
}
}
@@ -150,7 +150,7 @@ func TestConvertInteractionsRequestToOpenAIResponsesPreservesToolCallID(t *testi
foundFunctionCall := false
foundFunctionOutput := false
- gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool {
+ ojson.GetBytesNoCopy(out, "input").ForEach(func(_, item ojson.Result) bool {
switch item.Get("type").String() {
case "function_call":
foundFunctionCall = true
@@ -175,29 +175,29 @@ func TestConvertInteractionsRequestToOpenAIResponsesPreservesToolCallID(t *testi
func TestConvertInteractionsRequestToOpenAIResponsesConvertsSimpleTools(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tools":[{"name":"lookup","description":"Find data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}],"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "tools.0.function").Exists() {
+ if ojson.GetBytesNoCopy(out, "tools.0.function").Exists() {
t.Fatalf("tools.0.function should not be forwarded. Output: %s", string(out))
}
- if got := gjson.GetBytes(out, "tools.0.parameters.properties.q.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.parameters.properties.q.type").String(); got != "string" {
t.Fatalf("tools.0.parameters.properties.q.type = %q, want string. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponsesConvertsFunctionDeclarationsTools(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tools":[{"function_declarations":[{"name":"lookup","description":"Find data","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]}],"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "lookup" {
t.Fatalf("tools.0.name = %q, want lookup. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "tools.0.function_declarations").Exists() {
+ if ojson.GetBytesNoCopy(out, "tools.0.function_declarations").Exists() {
t.Fatalf("tools.0.function_declarations should not be forwarded. Output: %s", string(out))
}
}
@@ -205,10 +205,10 @@ func TestConvertInteractionsRequestToOpenAIResponsesConvertsFunctionDeclarations
func TestConvertInteractionsRequestToOpenAIResponsesWithImageContent(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"describe"},{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`)
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
- if got := gjson.GetBytes(out, "input.0.content.1.type").String(); got != "input_image" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.1.type").String(); got != "input_image" {
t.Fatalf("content.1.type = %q, want input_image", got)
}
- if got := gjson.GetBytes(out, "input.0.content.1.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.1.image_url").String(); got != "data:image/png;base64,aGVsbG8=" {
t.Fatalf("image_url = %q, want data URL", got)
}
}
@@ -216,16 +216,16 @@ func TestConvertInteractionsRequestToOpenAIResponsesWithImageContent(t *testing.
func TestConvertInteractionsRequestToOpenAIResponsesPreservesNonImageMediaContent(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":[{"type":"model_output","content":[{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="},{"type":"video","mime_type":"video/mp4","data":"AAAAIGZ0eXA="},{"type":"document","mime_type":"application/pdf","data":"JVBERi0="}]}]}`), false)
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "output_text" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "output_text" {
t.Fatalf("audio fallback type = %q, want output_text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.1.type").String(); got != "output_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.1.type").String(); got != "output_file" {
t.Fatalf("video type = %q, want output_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.0.content.2.type").String(); got != "output_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.2.type").String(); got != "output_file" {
t.Fatalf("document type = %q, want output_file. Output: %s", got, string(out))
}
- if gjson.GetBytes(out, "input.0.content.#(type==\"output_image\")").Exists() {
+ if ojson.GetBytesNoCopy(out, "input.0.content.#(type==\"output_image\")").Exists() {
t.Fatalf("non-image media must not be converted to output_image. Output: %s", string(out))
}
}
@@ -233,10 +233,10 @@ func TestConvertInteractionsRequestToOpenAIResponsesPreservesNonImageMediaConten
func TestConvertInteractionsRequestToOpenAIResponsesWithAssistantTextContent(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}]}`)
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "output_text" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "output_text" {
t.Fatalf("content.0.type = %q, want output_text", got)
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hello" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "hello" {
t.Fatalf("content.0.text = %q, want hello", got)
}
}
@@ -244,10 +244,10 @@ func TestConvertInteractionsRequestToOpenAIResponsesWithAssistantTextContent(t *
func TestConvertInteractionsRequestToOpenAIResponsesWithUserObjectContent(t *testing.T) {
raw := []byte(`{"model":"gpt-test","input":[{"type":"user_input","content":[{"type":"text","text":"hi"}]}]}`)
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
- if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "input_text" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.type").String(); got != "input_text" {
t.Fatalf("content.0.type = %q, want input_text", got)
}
- if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.content.0.text").String(); got != "hi" {
t.Fatalf("content.0.text = %q, want hi", got)
}
}
@@ -257,10 +257,10 @@ func TestConvertInteractionsRequestToOpenAIResponsesWithStringFunctionArguments(
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", raw, false)
found := false
- gjson.GetBytes(out, "input").ForEach(func(_, item gjson.Result) bool {
+ ojson.GetBytesNoCopy(out, "input").ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "function_call" {
found = true
- if item.Get("arguments").Type != gjson.String {
+ if item.Get("arguments").Type != ojson.String {
t.Fatalf("arguments should be string, got %v", item.Get("arguments").Type)
}
if got := item.Get("arguments").String(); got != `{"q":"x"}` {
@@ -276,23 +276,23 @@ func TestConvertInteractionsRequestToOpenAIResponsesWithStringFunctionArguments(
func TestConvertInteractionsRequestToOpenAIResponsesPreservesExpressibleFields(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","store":true,"background":true,"webhook_config":{"url":"https://example.com"},"input":"hi"}`), false)
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.function.name").String(); got != "lookup" {
t.Fatalf("tool_choice.function.name = %q, want lookup. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "modalities.0").String(); got != "text" {
+ if got := ojson.GetBytesNoCopy(out, "modalities.0").String(); got != "text" {
t.Fatalf("modalities.0 = %q, want text. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "modalities.1").String(); got != "image" {
+ if got := ojson.GetBytesNoCopy(out, "modalities.1").String(); got != "image" {
t.Fatalf("modalities.1 = %q, want image. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
+ if got := ojson.GetBytesNoCopy(out, "service_tier").String(); got != "priority" {
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
}
for _, path := range []string{"store", "background", "webhook_config"} {
- if gjson.GetBytes(out, path).Exists() {
+ if ojson.GetBytesNoCopy(out, path).Exists() {
t.Fatalf("%s should not be forwarded. Output: %s", path, string(out))
}
}
@@ -300,20 +300,20 @@ func TestConvertInteractionsRequestToOpenAIResponsesPreservesExpressibleFields(t
func TestConvertOpenAIResponsesRequestToInteractions_PreservesEnvironmentID(t *testing.T) {
out, _ := ConvertOpenAIResponsesRequestToInteractions("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_response_id":"resp_123","environment_id":"env_abc456"}`), false)
- if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "resp_123" {
+ if got := ojson.GetBytesNoCopy(out, "previous_interaction_id").String(); got != "resp_123" {
t.Fatalf("previous_interaction_id = %q, want resp_123. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "environment_id").String(); got != "env_abc456" {
+ if got := ojson.GetBytesNoCopy(out, "environment_id").String(); got != "env_abc456" {
t.Fatalf("environment_id = %q, want env_abc456. Output: %s", got, string(out))
}
}
func TestConvertInteractionsRequestToOpenAIResponses_PreservesEnvironmentID(t *testing.T) {
out, _ := ConvertInteractionsRequestToOpenAIResponses("gpt-test", []byte(`{"model":"gpt-test","input":"hi","previous_interaction_id":"interaction_123","environment_id":"env_abc456"}`), false)
- if got := gjson.GetBytes(out, "previous_response_id").String(); got != "interaction_123" {
+ if got := ojson.GetBytesNoCopy(out, "previous_response_id").String(); got != "interaction_123" {
t.Fatalf("previous_response_id = %q, want interaction_123. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "environment_id").String(); got != "env_abc456" {
+ if got := ojson.GetBytesNoCopy(out, "environment_id").String(); got != "env_abc456" {
t.Fatalf("environment_id = %q, want env_abc456. Output: %s", got, string(out))
}
}
@@ -330,20 +330,20 @@ func TestConvertOpenAIResponsesRequestToInteractions_AntigravitySanitizesGenerat
"tools":[{"type":"function","name":"web_search","parameters":{"type":"object"}}]
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "previous_interaction_id").String(); got != "v1_Chd3..." {
+ if got := ojson.GetBytesNoCopy(out, "previous_interaction_id").String(); got != "v1_Chd3..." {
t.Fatalf("previous_interaction_id = %q, want v1_Chd3.... Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "environment_id").String(); got != "env_789" {
+ if got := ojson.GetBytesNoCopy(out, "environment_id").String(); got != "env_789" {
t.Fatalf("environment_id = %q, want env_789. Output: %s", got, string(out))
}
// temperature, top_p, max_output_tokens should be stripped from generation_config for Antigravity models
for _, knob := range []string{"temperature", "top_p", "top_k", "stop_sequences", "max_output_tokens"} {
- if gjson.GetBytes(out, "generation_config."+knob).Exists() {
+ if ojson.GetBytesNoCopy(out, "generation_config."+knob).Exists() {
t.Fatalf("generation_config.%s should be stripped for antigravity model. Output: %s", knob, string(out))
}
}
// max_output_tokens should be mapped to agent_config.max_total_tokens
- if got := gjson.GetBytes(out, "agent_config.max_total_tokens").Int(); got != 2048 {
+ if got := ojson.GetBytesNoCopy(out, "agent_config.max_total_tokens").Int(); got != 2048 {
t.Fatalf("agent_config.max_total_tokens = %d, want 2048. Output: %s", got, string(out))
}
}
@@ -360,20 +360,20 @@ func TestConvertOpenAIResponsesRequestToInteractionsRenamesConflictingAntigravit
]
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "external_read_file" {
t.Fatalf("tools.0.name = %q, want external_read_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.1.name").String(); got != "external_write_file" {
+ if got := ojson.GetBytesNoCopy(out, "tools.1.name").String(); got != "external_write_file" {
t.Fatalf("tools.1.name = %q, want external_write_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.2.name").String(); got != "external_execute_code" {
+ if got := ojson.GetBytesNoCopy(out, "tools.2.name").String(); got != "external_execute_code" {
t.Fatalf("tools.2.name = %q, want external_execute_code. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.3.name").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(out, "tools.3.name").String(); got != "web_search" {
t.Fatalf("tools.3.name = %q, want web_search (unchanged). Output: %s", got, string(out))
}
outNonAnti, _ := ConvertOpenAIResponsesRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(outNonAnti, "tools.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(outNonAnti, "tools.0.name").String(); got != "read_file" {
t.Fatalf("gemini tools.0.name = %q, want read_file. Output: %s", got, string(outNonAnti))
}
}
@@ -391,10 +391,10 @@ func TestConvertOpenAIResponsesRequestToInteractionsRenamesConflictingAntigravit
]
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "input.1.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.name").String(); got != "external_read_file" {
t.Fatalf("input.1.name = %q, want external_read_file. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "input.2.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.name").String(); got != "external_read_file" {
t.Fatalf("input.2.name = %q, want external_read_file. Output: %s", got, string(out))
}
}
@@ -407,11 +407,11 @@ func TestConvertOpenAIResponsesRequestToInteractionsRenamesConflictingToolChoice
"tool_choice":{"type":"function","name":"read_file"}
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "generation_config.tool_choice.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.tool_choice.name").String(); got != "external_read_file" {
t.Fatalf("generation_config.tool_choice.name = %q, want external_read_file. Output: %s", got, string(out))
}
outNonAnti, _ := ConvertOpenAIResponsesRequestToInteractions("gemini-3.1-flash-lite", raw, false)
- if got := gjson.GetBytes(outNonAnti, "generation_config.tool_choice.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(outNonAnti, "generation_config.tool_choice.name").String(); got != "read_file" {
t.Fatalf("gemini tool_choice.name = %q, want read_file (no rename). Output: %s", got, string(outNonAnti))
}
}
@@ -425,13 +425,13 @@ func TestConvertOpenAIResponsesRequestToInteractions_NonAntigravityGenerationCon
"top_p":0.95
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("devin/swe-2", raw, false)
- if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 400 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.max_output_tokens").Int(); got != 400 {
t.Fatalf("generation_config.max_output_tokens = %d, want 400. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generation_config.temperature").Float(); got != 0.7 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.temperature").Float(); got != 0.7 {
t.Fatalf("generation_config.temperature = %f, want 0.7. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "generation_config.top_p").Float(); got != 0.95 {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.top_p").Float(); got != 0.95 {
t.Fatalf("generation_config.top_p = %f, want 0.95. Output: %s", got, string(out))
}
}
@@ -497,7 +497,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_FlattensNamespaceTools(t *t
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("devin/gemini-3-7-flash", raw, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 4 {
t.Fatalf("expected 4 flattened tools, got %d. Output: %s", len(tools), string(out))
}
@@ -535,7 +535,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_FlattensNamespaceTools(t *t
}
// Check tool_choice was qualified
- if got := gjson.GetBytes(out, "generation_config.tool_choice.name").String(); got != "multi_agent_v1__close_agent" {
+ if got := ojson.GetBytesNoCopy(out, "generation_config.tool_choice.name").String(); got != "multi_agent_v1__close_agent" {
t.Errorf("tool_choice.name = %q, want multi_agent_v1__close_agent", got)
}
}
@@ -576,31 +576,31 @@ func TestConvertOpenAIResponsesRequestToInteractions_HistoryNamespaceAndCustomTo
out, _ := ConvertOpenAIResponsesRequestToInteractions("devin/gemini-3-7-flash", raw, false)
// Step 0: function_call with qualified name
- if got := gjson.GetBytes(out, "input.0.name").String(); got != "multi_agent_v1__close_agent" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.name").String(); got != "multi_agent_v1__close_agent" {
t.Errorf("input.0.name = %q, want multi_agent_v1__close_agent", got)
}
- if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "input.0.call_id").String(); got != "call_1" {
t.Errorf("input.0.call_id = %q, want call_1", got)
}
// Step 1: function_result with qualified name
- if got := gjson.GetBytes(out, "input.1.name").String(); got != "multi_agent_v1__close_agent" {
+ if got := ojson.GetBytesNoCopy(out, "input.1.name").String(); got != "multi_agent_v1__close_agent" {
t.Errorf("input.1.name = %q, want multi_agent_v1__close_agent", got)
}
// Step 2: custom_tool_call with qualified name and input in arguments
- if got := gjson.GetBytes(out, "input.2.name").String(); got != "functions__exec" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.name").String(); got != "functions__exec" {
t.Errorf("input.2.name = %q, want functions__exec", got)
}
- if got := gjson.GetBytes(out, "input.2.arguments.input").String(); got != "ls -la" {
+ if got := ojson.GetBytesNoCopy(out, "input.2.arguments.input").String(); got != "ls -la" {
t.Errorf("input.2.arguments.input = %q, want ls -la", got)
}
// Step 3: custom_tool_call_output resolves name from functionNamesByCallID
- if got := gjson.GetBytes(out, "input.3.name").String(); got != "functions__exec" {
+ if got := ojson.GetBytesNoCopy(out, "input.3.name").String(); got != "functions__exec" {
t.Errorf("input.3.name = %q, want functions__exec", got)
}
- if got := gjson.GetBytes(out, "input.3.result").String(); got != "file1.txt" {
+ if got := ojson.GetBytesNoCopy(out, "input.3.result").String(); got != "file1.txt" {
t.Errorf("input.3.result = %q, want file1.txt", got)
}
}
@@ -613,7 +613,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_AntigravityCustomToolRename
]
}`)
out, _ := ConvertOpenAIResponsesRequestToInteractions("antigravity-preview-05-2026", raw, false)
- if got := gjson.GetBytes(out, "tools.0.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.name").String(); got != "external_read_file" {
t.Fatalf("tools.0.name = %q, want external_read_file", got)
}
}
@@ -645,7 +645,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_FixedFixtureFullNamespaceEx
}`
out, _ := ConvertOpenAIResponsesRequestToInteractions("devin/gemini-3-7-flash", []byte(fixture), false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
expectedNames := []string{
"top_fn_1",
"top_fn_2",
@@ -701,7 +701,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_LogFilePayload(t *testing.T
out, _ := ConvertOpenAIResponsesRequestToInteractions("devin/gemini-3-7-flash", []byte(reqBody), false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) == 0 {
t.Fatalf("expected tools in output, got 0")
}
@@ -780,7 +780,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_DevinToolsFilterAndObfuscat
out, _ := ConvertOpenAIResponsesRequestToInteractions("devin/swe-2", responsesPayload, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
toolNames := make([]string, 0, len(tools))
toolDescs := make(map[string]string)
for _, tool := range tools {
@@ -828,7 +828,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_DevinToolsFilterAndObfuscat
}
// 6. tool_choice targeting the filtered automation_update should not be set
- if tc := gjson.GetBytes(out, "generation_config.tool_choice"); tc.Exists() {
+ if tc := ojson.GetBytesNoCopy(out, "generation_config.tool_choice"); tc.Exists() {
t.Fatalf("expected generation_config.tool_choice to be omitted for filtered tool, got %s", tc.Raw)
}
}
@@ -862,7 +862,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_AntigravityDoesNotFilterDev
// In Antigravity, mcp__codex_app__automation_update should NOT be filtered by Devin rules
foundAuto := false
- gjson.GetBytes(out, "tools").ForEach(func(_, tool gjson.Result) bool {
+ ojson.GetBytesNoCopy(out, "tools").ForEach(func(_, tool ojson.Result) bool {
if tool.Get("name").String() == "mcp__codex_app__automation_update" {
foundAuto = true
}
@@ -877,3 +877,28 @@ func TestConvertOpenAIResponsesRequestToInteractions_AntigravityDoesNotFilterDev
t.Fatalf("expected mcp__codex_app__automation_update to be kept in antigravity output: %s", string(out))
}
}
+
+func TestConvertOpenAIResponsesRequestKeepsDevinSealedReasoning(t *testing.T) {
+ raw := []byte(`{
+ "model":"swe-2",
+ "input":[
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]},
+ {"type":"reasoning","summary":[{"type":"summary_text","text":"picked 12345"}],"encrypted_content":"sealed.v1.abc"},
+ {"type":"reasoning","summary":[],"encrypted_content":"gAAAAother-provider"},
+ {"type":"function_call","name":"ping","call_id":"c1","arguments":"{}"}
+ ]
+ }`)
+ out, _ := ConvertOpenAIResponsesRequestToInteractions("swe-2", raw, false)
+ if got := ojson.GetBytesNoCopy(out, "input.1.type").String(); got != "thought" {
+ t.Fatalf("input.1.type = %q, want thought. Output: %s", got, string(out))
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.1.signature").String(); got != "sealed.v1.abc" {
+ t.Fatalf("signature = %q. Output: %s", got, string(out))
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.1.content.0.text").String(); got != "picked 12345" {
+ t.Fatalf("thought text = %q", got)
+ }
+ if got := ojson.GetBytesNoCopy(out, "input.2.type").String(); got != "function_call" {
+ t.Fatalf("foreign reasoning item must stay dropped, input.2.type = %q. Output: %s", got, string(out))
+ }
+}
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go
index 4ce1e08f2b1..5ca46e805d0 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response.go
@@ -8,13 +8,12 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/google/uuid"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
"github.com/router-for-me/CLIProxyAPI/v8/internal/signature"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type interactionsToResponsesStreamState struct {
@@ -129,10 +128,10 @@ func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context,
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
- out := []byte(`{"id":"","object":"response","status":"completed","model":"","output":[]}`)
- out, _ = sjson.SetBytes(out, "id", firstNonEmpty(root.Get("id").String(), root.Get("interaction.id").String()))
- out, _ = sjson.SetBytes(out, "model", responseModel(modelName, root))
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
+ outDoc := ojson.NewDocString(`{"id":"","object":"response","status":"completed","model":"","output":[]}`)
+ _ = outDoc.Set("id", firstNonEmpty(root.Get("id").String(), root.Get("interaction.id").String()))
+ _ = outDoc.Set("model", responseModel(modelName, root))
steps := root.Get("steps")
if !steps.Exists() {
steps = root.Get("interaction.steps")
@@ -156,11 +155,11 @@ func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context,
var toolInputError error
status := firstNonEmpty(root.Get("status").String(), root.Get("interaction.status").String())
sourceError := firstExisting(root.Get("error"), root.Get("interaction.error"))
- if patchEnabled && (status == "failed" || (sourceError.Exists() && sourceError.Type != gjson.Null)) {
+ if patchEnabled && (status == "failed" || (sourceError.Exists() && sourceError.Type != ojson.Null)) {
toolInputError = fmt.Errorf("upstream apply_patch interaction failed")
}
var outputs [][]byte
- steps.ForEach(func(_, step gjson.Result) bool {
+ steps.ForEach(func(_, step ojson.Result) bool {
if toolInputError != nil {
return false
}
@@ -169,7 +168,7 @@ func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context,
return false
}
if step.Get("type").String() == "function_call" && toolIdentityMap[step.Get("name").String()].ApplyPatch {
- if !gjson.ValidBytes(rawJSON) {
+ if !ojson.ValidBytes(rawJSON) {
toolInputError = fmt.Errorf("invalid Interactions apply_patch response JSON")
return false
}
@@ -193,22 +192,22 @@ func ConvertInteractionsResponseToOpenAIResponsesNonStream(ctx context.Context,
return nil
}
if len(outputs) > 0 {
- out, _ = sjson.SetRawBytes(out, "output", translatorcommon.JoinRawArray(outputs))
+ _ = outDoc.SetRawBytes("output", translatorcommon.JoinRawArray(outputs))
}
interactionStatus := firstNonEmpty(root.Get("status").String(), root.Get("interaction.status").String())
interactionFinishReason := firstNonEmpty(root.Get("finish_reason").String(), root.Get("interaction.finish_reason").String())
if interactionFinishReason == "content_filter" {
- out, _ = sjson.SetBytes(out, "status", "incomplete")
- out, _ = sjson.SetBytes(out, "incomplete_details.reason", "content_filter")
+ _ = outDoc.Set("status", "incomplete")
+ _ = outDoc.Set("incomplete_details.reason", "content_filter")
} else if interactionStatus == "incomplete" || interactionFinishReason == "length" || interactionFinishReason == "max_tokens" {
- out, _ = sjson.SetBytes(out, "status", "incomplete")
- out, _ = sjson.SetBytes(out, "incomplete_details.reason", "max_output_tokens")
+ _ = outDoc.Set("status", "incomplete")
+ _ = outDoc.Set("incomplete_details.reason", "max_output_tokens")
}
if envID := firstNonEmpty(root.Get("environment_id").String(), root.Get("interaction.environment_id").String(), root.Get("environment.id").String(), root.Get("interaction.environment.id").String()); envID != "" {
- out, _ = sjson.SetBytes(out, "environment_id", envID)
+ _ = outDoc.Set("environment_id", envID)
}
- out = setResponsesUsageFromInteractions(out, "usage", translatorcommon.InteractionsUsage(root))
- return out
+ setResponsesUsageFromInteractionsDoc(outDoc, "usage", translatorcommon.InteractionsUsage(root))
+ return outDoc.Bytes()
}
func convertInteractionsEventToResponses(modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, st *interactionsToResponsesStreamState) [][]byte {
@@ -220,7 +219,7 @@ func convertInteractionsEventToResponses(modelName string, originalRequestRawJSO
return nil
}
// A source sentinel remains legal after response completion, but only once.
- isDone := bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) || (gjson.ValidBytes(payload) && gjson.GetBytes(payload, "event_type").String() == "done")
+ isDone := bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) || (ojson.ValidBytes(payload) && ojson.GetBytesNoCopy(payload, "event_type").String() == "done")
if isDone {
events := interactionsFinishPatchCalls(st)
if st.ToolInputError() != nil {
@@ -233,11 +232,11 @@ func convertInteractionsEventToResponses(modelName string, originalRequestRawJSO
if st.Terminal {
return nil
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if !root.Exists() {
return nil
}
- if !gjson.ValidBytes(payload) {
+ if !ojson.ValidBytes(payload) {
errJSON := fmt.Errorf("invalid Interactions apply_patch event JSON")
index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), root.Get("step"), 0, st)
if errResolveStepIndex != nil {
@@ -246,7 +245,7 @@ func convertInteractionsEventToResponses(modelName string, originalRequestRawJSO
if strings.HasPrefix(root.Get("event_type").String(), "step.") {
call := st.FunctionCalls[index]
if call == nil {
- call = &interactionsFunctionCallState{ID: root.Get("step.id").String(), CallID: root.Get("step.call_id").String(), ItemIDSeen: root.Get("step.id").String() != "", CallIDSeen: root.Get("step.call_id").String() != ""}
+ call = &interactionsFunctionCallState{ID: strings.Clone(root.Get("step.id").String()), CallID: strings.Clone(root.Get("step.call_id").String()), ItemIDSeen: root.Get("step.id").String() != "", CallIDSeen: root.Get("step.call_id").String() != ""}
st.FunctionCalls[index] = call
}
if call.PendingError == nil {
@@ -279,7 +278,7 @@ func convertInteractionsEventToResponses(modelName string, originalRequestRawJSO
var events [][]byte
steps := firstExisting(root.Get("interaction.steps"), root.Get("steps"))
patchEnabled := interactionsHasPatchBridge(st)
- steps.ForEach(func(key, step gjson.Result) bool {
+ steps.ForEach(func(key, step ojson.Result) bool {
index, errResolveStepIndex := interactionsResolveStepIndex(step.Get("index"), step, int(key.Int()), st)
if errResolveStepIndex != nil {
events = append(events, interactionsPatchFailure(st, errResolveStepIndex)...)
@@ -320,81 +319,81 @@ func convertInteractionsEventToResponses(modelName string, originalRequestRawJSO
return nil
}
-func interactionsStepToResponsesOutput(step gjson.Result, forAntigravity bool, toolIdentityMap map[string]util.ResponsesToolIdentity) ([]byte, bool) {
+func interactionsStepToResponsesOutput(step ojson.Result, forAntigravity bool, toolIdentityMap map[string]util.ResponsesToolIdentity) ([]byte, bool) {
switch step.Get("type").String() {
case "model_output":
- item := []byte(`{"type":"message","role":"assistant","content":[]}`)
+ itemDoc := ojson.NewDocString(`{"type":"message","role":"assistant","content":[]}`)
if id := firstNonEmpty(step.Get("id").String(), step.Get("step_id").String()); id != "" {
- item, _ = sjson.SetBytes(item, "id", id)
+ _ = itemDoc.Set("id", id)
}
content := step.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
part := []byte(`{"type":"output_text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", content.String())
- item = translatorcommon.SetRawArrayItems(item, "content", [][]byte{part})
+ part, _ = ojson.SetBytes(part, "text", content.String())
+ translatorcommon.SetRawArrayItemsDoc(itemDoc, "content", [][]byte{part})
} else {
var parts [][]byte
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if converted, ok := interactionsContentPartToResponses(part, "assistant"); ok {
parts = append(parts, converted)
}
return true
})
if len(parts) > 0 {
- item = translatorcommon.SetRawArrayItems(item, "content", parts)
+ translatorcommon.SetRawArrayItemsDoc(itemDoc, "content", parts)
}
}
- return item, true
+ return itemDoc.Bytes(), true
case "thought":
- item := []byte(`{"type":"reasoning","summary":[]}`)
+ itemDoc := ojson.NewDocString(`{"type":"reasoning","summary":[]}`)
if signature := interactionsThoughtSignature(step); signature != "" {
- item, _ = sjson.SetBytes(item, "encrypted_content", signature)
+ _ = itemDoc.Set("encrypted_content", signature)
}
texts := interactionsContentTexts(step.Get("content"))
if len(texts) > 0 {
summaries := make([][]byte, 0, len(texts))
for _, text := range texts {
part := []byte(`{"type":"summary_text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
summaries = append(summaries, part)
}
- item = translatorcommon.SetRawArrayItems(item, "summary", summaries)
+ translatorcommon.SetRawArrayItemsDoc(itemDoc, "summary", summaries)
}
- return item, true
+ return itemDoc.Bytes(), true
case "function_call":
item := interactionsFunctionCallToResponsesWithIdentity(step, forAntigravity, toolIdentityMap)
- item, _ = sjson.SetBytes(item, "status", "completed")
+ item, _ = ojson.SetBytes(item, "status", "completed")
return item, true
}
return nil, false
}
-func responsesCreatedEvent(modelName string, originalRequestRawJSON, requestRawJSON []byte, root gjson.Result, st *interactionsToResponsesStreamState) []byte {
- payload := []byte(`{"type":"response.created","response":{"id":"","object":"response","status":"in_progress","model":"","output":[]}}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
+func responsesCreatedEvent(modelName string, originalRequestRawJSON, requestRawJSON []byte, root ojson.Result, st *interactionsToResponsesStreamState) []byte {
+ payloadDoc := ojson.NewDocString(`{"type":"response.created","response":{"id":"","object":"response","status":"in_progress","model":"","output":[]}}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
id := firstNonEmpty(root.Get("interaction.id").String(), root.Get("id").String())
if st != nil && id != "" {
- st.ID = id
+ st.ID = strings.Clone(id)
}
- payload, _ = sjson.SetBytes(payload, "response.id", id)
- payload, _ = sjson.SetBytes(payload, "response.model", modelName)
+ _ = payloadDoc.Set("response.id", id)
+ _ = payloadDoc.Set("response.model", modelName)
if envID := firstNonEmpty(root.Get("interaction.environment_id").String(), root.Get("environment_id").String(), root.Get("environment.id").String(), root.Get("interaction.environment.id").String()); envID != "" {
if st != nil {
- st.EnvironmentID = envID
+ st.EnvironmentID = strings.Clone(envID)
}
- payload, _ = sjson.SetBytes(payload, "response.environment_id", envID)
+ _ = payloadDoc.Set("response.environment_id", envID)
}
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName != "" {
- payload, _ = sjson.SetBytes(payload, "response.model", requestModelName)
+ _ = payloadDoc.Set("response.model", requestModelName)
}
- return emitResponsesEvent("response.created", payload)
+ return emitResponsesEvent("response.created", payloadDoc.Bytes())
}
-func interactionsStepStartToResponses(modelName string, root gjson.Result, st *interactionsToResponsesStreamState) [][]byte {
+func interactionsStepStartToResponses(modelName string, root ojson.Result, st *interactionsToResponsesStreamState) [][]byte {
step := root.Get("step")
index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), step, 0, st)
if errResolveStepIndex != nil {
@@ -409,38 +408,38 @@ func interactionsStepStartToResponses(modelName string, root gjson.Result, st *i
if stepType == "function_call" {
return interactionsUpdateFunctionCall(index, step, st, true)
}
- st.ItemIDs[index] = itemID
- st.ItemTypes[index] = stepType
+ st.ItemIDs[index] = strings.Clone(itemID)
+ st.ItemTypes[index] = strings.Clone(stepType)
switch stepType {
case "model_output":
- added := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"message","status":"in_progress","role":"assistant","content":[]}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st))
- added, _ = sjson.SetBytes(added, "output_index", index)
- added, _ = sjson.SetBytes(added, "item.id", itemID)
- part := []byte(`{"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"","part":{"type":"output_text","text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextResponsesSeq(st))
- part, _ = sjson.SetBytes(part, "output_index", index)
- part, _ = sjson.SetBytes(part, "item_id", itemID)
- return [][]byte{emitResponsesEvent("response.output_item.added", added), emitResponsesEvent("response.content_part.added", part)}
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"message","status":"in_progress","role":"assistant","content":[]}}`)
+ _ = addedDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = addedDoc.Set("output_index", index)
+ _ = addedDoc.Set("item.id", itemID)
+ partDoc := ojson.NewDocString(`{"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"","part":{"type":"output_text","text":""}}`)
+ _ = partDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = partDoc.Set("output_index", index)
+ _ = partDoc.Set("item_id", itemID)
+ return [][]byte{emitResponsesEvent("response.output_item.added", addedDoc.Bytes()), emitResponsesEvent("response.content_part.added", partDoc.Bytes())}
case "thought":
- added := []byte(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st))
- added, _ = sjson.SetBytes(added, "output_index", index)
- added, _ = sjson.SetBytes(added, "item.id", itemID)
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`)
+ _ = addedDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = addedDoc.Set("output_index", index)
+ _ = addedDoc.Set("item.id", itemID)
if signature := interactionsReasoningEncryptedContent(st.ReasoningEncrypted[index]); signature != "" {
- added, _ = sjson.SetBytes(added, "item.encrypted_content", signature)
+ _ = addedDoc.Set("item.encrypted_content", signature)
}
- part := []byte(`{"type":"response.reasoning_summary_part.added","item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextResponsesSeq(st))
- part, _ = sjson.SetBytes(part, "item_id", itemID)
- part, _ = sjson.SetBytes(part, "output_index", index)
- return [][]byte{emitResponsesEvent("response.output_item.added", added), emitResponsesEvent("response.reasoning_summary_part.added", part)}
+ partDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.added","item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = partDoc.Set("item_id", itemID)
+ _ = partDoc.Set("output_index", index)
+ return [][]byte{emitResponsesEvent("response.output_item.added", addedDoc.Bytes()), emitResponsesEvent("response.reasoning_summary_part.added", partDoc.Bytes())}
}
return nil
}
-func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte {
+func interactionsStepDeltaToResponses(root ojson.Result, st *interactionsToResponsesStreamState) [][]byte {
index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), root.Get("step"), 0, st)
if errResolveStepIndex != nil {
return interactionsPatchFailure(st, errResolveStepIndex)
@@ -463,24 +462,25 @@ func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToRespo
}
// Process the same real delta after its late identity update, without
// replaying the update or turning its snapshot into parameter fragments.
- raw, _ := sjson.DeleteBytes([]byte(root.Raw), "step")
- raw, _ = sjson.SetBytes(raw, "index", index)
- return append(updates, interactionsStepDeltaToResponses(gjson.ParseBytes(raw), st)...)
+ rawDoc := ojson.NewDocString(root.Raw)
+ _ = rawDoc.Delete("step")
+ _ = rawDoc.Set("index", index)
+ return append(updates, interactionsStepDeltaToResponses(util.ParseGJSONBytesNoCopy(rawDoc.Bytes()), st)...)
}
delta := root.Get("delta")
switch delta.Get("type").String() {
case "thought_summary":
text := firstNonEmpty(delta.Get("content.text").String(), delta.Get("text").String())
recordResponsesReasoningSummary(st, index, text)
- payload := []byte(`{"type":"response.reasoning_summary_text.delta","item_id":"","output_index":0,"summary_index":0,"delta":""}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
- payload, _ = sjson.SetBytes(payload, "item_id", st.ItemIDs[index])
- payload, _ = sjson.SetBytes(payload, "output_index", index)
- payload, _ = sjson.SetBytes(payload, "delta", text)
- return [][]byte{emitResponsesEvent("response.reasoning_summary_text.delta", payload)}
+ payloadDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.delta","item_id":"","output_index":0,"summary_index":0,"delta":""}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = payloadDoc.Set("item_id", st.ItemIDs[index])
+ _ = payloadDoc.Set("output_index", index)
+ _ = payloadDoc.Set("delta", text)
+ return [][]byte{emitResponsesEvent("response.reasoning_summary_text.delta", payloadDoc.Bytes())}
case "thought_signature":
if signature := interactionsReasoningEncryptedContent(delta.Get("signature").String()); signature != "" {
- st.ReasoningEncrypted[index] = signature
+ st.ReasoningEncrypted[index] = strings.Clone(signature)
}
return nil
case "arguments_delta":
@@ -511,7 +511,7 @@ func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToRespo
}
call.Arguments.WriteString(arguments)
if !call.SourceStopped && (call.RawName == "" || st.ToolIdentityMap[call.RawName].ApplyPatch) {
- call.ArgumentFragments = append(call.ArgumentFragments, arguments)
+ call.ArgumentFragments = append(call.ArgumentFragments, strings.Clone(arguments))
}
if call.RawName == "" {
return nil
@@ -522,45 +522,45 @@ func interactionsStepDeltaToResponses(root gjson.Result, st *interactionsToRespo
}
return [][]byte{responsesFunctionCallArgumentsDeltaToResponses(index, st.ItemIDs[index], arguments, st)}
default:
- payload := []byte(`{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"","delta":""}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
- payload, _ = sjson.SetBytes(payload, "output_index", index)
- payload, _ = sjson.SetBytes(payload, "item_id", st.ItemIDs[index])
+ payloadDoc := ojson.NewDocString(`{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"","delta":""}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = payloadDoc.Set("output_index", index)
+ _ = payloadDoc.Set("item_id", st.ItemIDs[index])
text := delta.Get("text").String()
recordResponsesTextOutput(st, index, text)
- payload, _ = sjson.SetBytes(payload, "delta", text)
- return [][]byte{emitResponsesEvent("response.output_text.delta", payload)}
+ _ = payloadDoc.Set("delta", text)
+ return [][]byte{emitResponsesEvent("response.output_text.delta", payloadDoc.Bytes())}
}
}
func responsesFunctionCallArgumentsDeltaToResponses(index int, itemID, arguments string, st *interactionsToResponsesStreamState) []byte {
- payload := []byte(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"","delta":""}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
- payload, _ = sjson.SetBytes(payload, "output_index", index)
- payload, _ = sjson.SetBytes(payload, "item_id", itemID)
- payload, _ = translatorcommon.SetStringWithoutHTMLEscape(payload, "delta", arguments)
- return emitResponsesEvent("response.function_call_arguments.delta", payload)
+ payloadDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.delta","output_index":0,"item_id":"","delta":""}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = payloadDoc.Set("output_index", index)
+ _ = payloadDoc.Set("item_id", itemID)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(payloadDoc, "delta", arguments)
+ return emitResponsesEvent("response.function_call_arguments.delta", payloadDoc.Bytes())
}
func responsesFunctionCallArgumentsDoneToResponses(index int, itemID, arguments string, st *interactionsToResponsesStreamState) []byte {
- payload := []byte(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"","arguments":""}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
- payload, _ = sjson.SetBytes(payload, "output_index", index)
- payload, _ = sjson.SetBytes(payload, "item_id", itemID)
- payload, _ = translatorcommon.SetStringWithoutHTMLEscape(payload, "arguments", arguments)
- return emitResponsesEvent("response.function_call_arguments.done", payload)
+ payloadDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.done","output_index":0,"item_id":"","arguments":""}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = payloadDoc.Set("output_index", index)
+ _ = payloadDoc.Set("item_id", itemID)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(payloadDoc, "arguments", arguments)
+ return emitResponsesEvent("response.function_call_arguments.done", payloadDoc.Bytes())
}
func responsesCustomToolCallInputDoneToResponses(index int, itemID, input string, st *interactionsToResponsesStreamState) []byte {
- payload := []byte(`{"type":"response.custom_tool_call_input.done","output_index":0,"item_id":"","input":""}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
- payload, _ = sjson.SetBytes(payload, "output_index", index)
- payload, _ = sjson.SetBytes(payload, "item_id", itemID)
- payload, _ = sjson.SetBytes(payload, "input", input)
- return emitResponsesEvent("response.custom_tool_call_input.done", payload)
+ payloadDoc := ojson.NewDocString(`{"type":"response.custom_tool_call_input.done","output_index":0,"item_id":"","input":""}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = payloadDoc.Set("output_index", index)
+ _ = payloadDoc.Set("item_id", itemID)
+ _ = payloadDoc.Set("input", input)
+ return emitResponsesEvent("response.custom_tool_call_input.done", payloadDoc.Bytes())
}
-func interactionsStepStopToResponses(root gjson.Result, st *interactionsToResponsesStreamState) [][]byte {
+func interactionsStepStopToResponses(root ojson.Result, st *interactionsToResponsesStreamState) [][]byte {
index, errResolveStepIndex := interactionsResolveStepIndex(root.Get("index"), root.Get("step"), 0, st)
if errResolveStepIndex != nil {
return interactionsPatchFailure(st, errResolveStepIndex)
@@ -587,28 +587,28 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon
if builder := st.TextOutputs[index]; builder != nil {
text = builder.String()
}
- textDone := []byte(`{"type":"response.output_text.done","output_index":0,"content_index":0,"item_id":"","text":"","logprobs":[]}`)
- textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextResponsesSeq(st))
- textDone, _ = sjson.SetBytes(textDone, "output_index", index)
- textDone, _ = sjson.SetBytes(textDone, "item_id", itemID)
- textDone, _ = sjson.SetBytes(textDone, "text", text)
- part := []byte(`{"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"","part":{"type":"output_text","text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextResponsesSeq(st))
- part, _ = sjson.SetBytes(part, "output_index", index)
- part, _ = sjson.SetBytes(part, "item_id", itemID)
- part, _ = sjson.SetBytes(part, "part.text", text)
- done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"message","status":"completed","role":"assistant","content":[]}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st))
- done, _ = sjson.SetBytes(done, "output_index", index)
- done, _ = sjson.SetBytes(done, "item.id", itemID)
+ textDoneDoc := ojson.NewDocString(`{"type":"response.output_text.done","output_index":0,"content_index":0,"item_id":"","text":"","logprobs":[]}`)
+ _ = textDoneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = textDoneDoc.Set("output_index", index)
+ _ = textDoneDoc.Set("item_id", itemID)
+ _ = textDoneDoc.Set("text", text)
+ partDoc := ojson.NewDocString(`{"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"","part":{"type":"output_text","text":""}}`)
+ _ = partDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = partDoc.Set("output_index", index)
+ _ = partDoc.Set("item_id", itemID)
+ _ = partDoc.Set("part.text", text)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"message","status":"completed","role":"assistant","content":[]}}`)
+ _ = doneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = doneDoc.Set("output_index", index)
+ _ = doneDoc.Set("item.id", itemID)
outputText := []byte(`{"type":"output_text","text":""}`)
- outputText, _ = sjson.SetBytes(outputText, "text", text)
- done, _ = sjson.SetRawBytes(done, "item.content.-1", outputText)
- return [][]byte{emitResponsesEvent("response.output_text.done", textDone), emitResponsesEvent("response.content_part.done", part), emitResponsesEvent("response.output_item.done", done)}
+ outputText, _ = ojson.SetBytes(outputText, "text", text)
+ _ = doneDoc.SetRawBytes("item.content.-1", outputText)
+ return [][]byte{emitResponsesEvent("response.output_text.done", textDoneDoc.Bytes()), emitResponsesEvent("response.content_part.done", partDoc.Bytes()), emitResponsesEvent("response.output_item.done", doneDoc.Bytes())}
case "function_call":
call := st.FunctionCalls[index]
if call == nil {
- call = &interactionsFunctionCallState{ID: itemID, SourceStopped: true}
+ call = &interactionsFunctionCallState{ID: strings.Clone(itemID), SourceStopped: true}
st.FunctionCalls[index] = call
}
if call.RawName == "" {
@@ -628,7 +628,7 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon
if !call.HasSnapshot {
args = call.Arguments.String()
}
- if call.HasSnapshot && gjson.Valid(call.Arguments.String()) {
+ if call.HasSnapshot && ojson.Valid(call.Arguments.String()) {
var source translatorcommon.ApplyPatchCallState
_, input, errFinishArguments := source.FinishArguments(call.Arguments.String())
if errFinishArguments != nil {
@@ -645,13 +645,13 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon
events = append(events, interactionsPatchDelta(st, call, tail)...)
events = append(events, emitResponsesEvent("response.custom_tool_call_input.done", translatorcommon.ApplyPatchInputDone(call.PatchCall, input, nextResponsesSeq(st))))
item, _ := responsesCompletedOutputItem(index, "function_call", st)
- done := []byte(`{"type":"response.output_item.done"}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st))
- done, _ = sjson.SetBytes(done, "output_index", index)
- done, _ = sjson.SetRawBytes(done, "item", item)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done"}`)
+ _ = doneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = doneDoc.Set("output_index", index)
+ _ = doneDoc.SetRawBytes("item", item)
call.ItemDoneEmitted = true
call.ArgumentsDoneEmitted = true
- return append(events, emitResponsesEvent("response.output_item.done", done))
+ return append(events, emitResponsesEvent("response.output_item.done", doneDoc.Bytes()))
}
if call.IsCustom {
input := util.UnwrapResponsesCustomToolInput(call.Arguments.String())
@@ -659,18 +659,18 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon
events = append(events, responsesCustomToolCallInputDoneToResponses(index, itemID, input, st))
call.ArgumentsDoneEmitted = true
}
- done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"","status":"completed"}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st))
- done, _ = sjson.SetBytes(done, "output_index", index)
- done, _ = sjson.SetBytes(done, "item.id", itemID)
- done, _ = sjson.SetBytes(done, "item.call_id", call.CallID)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"","status":"completed"}}`)
+ _ = doneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = doneDoc.Set("output_index", index)
+ _ = doneDoc.Set("item.id", itemID)
+ _ = doneDoc.Set("item.call_id", call.CallID)
if call.Namespace != "" {
- done, _ = sjson.SetBytes(done, "item.namespace", call.Namespace)
+ _ = doneDoc.Set("item.namespace", call.Namespace)
}
- done, _ = sjson.SetBytes(done, "item.name", call.Name)
- done, _ = sjson.SetBytes(done, "item.input", input)
+ _ = doneDoc.Set("item.name", call.Name)
+ _ = doneDoc.Set("item.input", input)
call.ItemDoneEmitted = true
- return append(events, emitResponsesEvent("response.output_item.done", done))
+ return append(events, emitResponsesEvent("response.output_item.done", doneDoc.Bytes()))
}
arguments := responsesFunctionCallArguments(call)
@@ -678,49 +678,49 @@ func interactionsStepStopToResponses(root gjson.Result, st *interactionsToRespon
events = append(events, responsesFunctionCallArgumentsDoneToResponses(index, itemID, arguments, st))
call.ArgumentsDoneEmitted = true
}
- done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"function_call","call_id":"","name":"","arguments":"","status":"completed"}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st))
- done, _ = sjson.SetBytes(done, "output_index", index)
- done, _ = sjson.SetBytes(done, "item.id", itemID)
- done, _ = sjson.SetBytes(done, "item.call_id", call.CallID)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","output_index":0,"item":{"id":"","type":"function_call","call_id":"","name":"","arguments":"","status":"completed"}}`)
+ _ = doneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = doneDoc.Set("output_index", index)
+ _ = doneDoc.Set("item.id", itemID)
+ _ = doneDoc.Set("item.call_id", call.CallID)
if call.Namespace != "" {
- done, _ = sjson.SetBytes(done, "item.namespace", call.Namespace)
+ _ = doneDoc.Set("item.namespace", call.Namespace)
}
- done, _ = sjson.SetBytes(done, "item.name", call.Name)
- done, _ = translatorcommon.SetStringWithoutHTMLEscape(done, "item.arguments", arguments)
+ _ = doneDoc.Set("item.name", call.Name)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(doneDoc, "item.arguments", arguments)
call.ItemDoneEmitted = true
- return append(events, emitResponsesEvent("response.output_item.done", done))
+ return append(events, emitResponsesEvent("response.output_item.done", doneDoc.Bytes()))
case "thought":
text := strings.Join(st.ReasoningSummaries[index], "")
- textDone := []byte(`{"type":"response.reasoning_summary_text.done","item_id":"","output_index":0,"summary_index":0,"text":""}`)
- textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextResponsesSeq(st))
- textDone, _ = sjson.SetBytes(textDone, "item_id", itemID)
- textDone, _ = sjson.SetBytes(textDone, "output_index", index)
- textDone, _ = sjson.SetBytes(textDone, "text", text)
- partDone := []byte(`{"type":"response.reasoning_summary_part.done","item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextResponsesSeq(st))
- partDone, _ = sjson.SetBytes(partDone, "item_id", itemID)
- partDone, _ = sjson.SetBytes(partDone, "output_index", index)
- partDone, _ = sjson.SetBytes(partDone, "part.text", text)
- done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st))
- done, _ = sjson.SetBytes(done, "output_index", index)
- done, _ = sjson.SetRawBytes(done, "item", responsesReasoningItem(index, st))
+ textDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.done","item_id":"","output_index":0,"summary_index":0,"text":""}`)
+ _ = textDoneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = textDoneDoc.Set("item_id", itemID)
+ _ = textDoneDoc.Set("output_index", index)
+ _ = textDoneDoc.Set("text", text)
+ partDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.done","item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = partDoneDoc.Set("item_id", itemID)
+ _ = partDoneDoc.Set("output_index", index)
+ _ = partDoneDoc.Set("part.text", text)
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","output_index":0,"item":{}}`)
+ _ = doneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = doneDoc.Set("output_index", index)
+ _ = doneDoc.SetRawBytes("item", responsesReasoningItem(index, st))
return [][]byte{
- emitResponsesEvent("response.reasoning_summary_text.done", textDone),
- emitResponsesEvent("response.reasoning_summary_part.done", partDone),
- emitResponsesEvent("response.output_item.done", done),
+ emitResponsesEvent("response.reasoning_summary_text.done", textDoneDoc.Bytes()),
+ emitResponsesEvent("response.reasoning_summary_part.done", partDoneDoc.Bytes()),
+ emitResponsesEvent("response.output_item.done", doneDoc.Bytes()),
}
default:
- done := []byte(`{"type":"response.output_item.done","output_index":0,"item":{}}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextResponsesSeq(st))
- done, _ = sjson.SetBytes(done, "output_index", index)
- done, _ = sjson.SetRawBytes(done, "item", responsesReasoningItem(index, st))
- return [][]byte{emitResponsesEvent("response.output_item.done", done)}
+ doneDoc := ojson.NewDocString(`{"type":"response.output_item.done","output_index":0,"item":{}}`)
+ _ = doneDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = doneDoc.Set("output_index", index)
+ _ = doneDoc.SetRawBytes("item", responsesReasoningItem(index, st))
+ return [][]byte{emitResponsesEvent("response.output_item.done", doneDoc.Bytes())}
}
}
-func responsesCompletedEvent(modelName string, root gjson.Result, st *interactionsToResponsesStreamState) []byte {
+func responsesCompletedEvent(modelName string, root ojson.Result, st *interactionsToResponsesStreamState) []byte {
eventType := "response.completed"
status := "completed"
interaction := root.Get("interaction")
@@ -736,38 +736,38 @@ func responsesCompletedEvent(modelName string, root gjson.Result, st *interactio
status = "incomplete"
incompleteReason = "max_output_tokens"
}
- payload := []byte(`{"type":"response.completed","response":{"id":"","object":"response","status":"completed","model":"","output":[],"usage":{}}}`)
- payload, _ = sjson.SetBytes(payload, "type", eventType)
- payload, _ = sjson.SetBytes(payload, "response.status", status)
+ payloadDoc := ojson.NewDocString(`{"type":"response.completed","response":{"id":"","object":"response","status":"completed","model":"","output":[],"usage":{}}}`)
+ _ = payloadDoc.Set("type", eventType)
+ _ = payloadDoc.Set("response.status", status)
if incompleteReason != "" {
- payload, _ = sjson.SetBytes(payload, "response.incomplete_details.reason", incompleteReason)
+ _ = payloadDoc.Set("response.incomplete_details.reason", incompleteReason)
}
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
- payload, _ = sjson.SetBytes(payload, "response.id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String()))
- payload, _ = sjson.SetBytes(payload, "response.model", firstNonEmpty(interaction.Get("model").String(), modelName))
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = payloadDoc.Set("response.id", firstNonEmpty(interaction.Get("id").String(), root.Get("id").String()))
+ _ = payloadDoc.Set("response.model", firstNonEmpty(interaction.Get("model").String(), modelName))
envID := firstNonEmpty(interaction.Get("environment_id").String(), root.Get("environment_id").String(), interaction.Get("environment.id").String(), root.Get("environment.id").String())
if envID == "" && st != nil {
envID = st.EnvironmentID
}
if envID != "" {
- payload, _ = sjson.SetBytes(payload, "response.environment_id", envID)
+ _ = payloadDoc.Set("response.environment_id", envID)
}
- payload = setResponsesCompletedOutput(payload, st)
- payload = setResponsesUsageFromInteractions(payload, "response.usage", translatorcommon.InteractionsUsage(root))
- return emitResponsesEvent(eventType, payload)
+ setResponsesCompletedOutputDoc(payloadDoc, st)
+ setResponsesUsageFromInteractionsDoc(payloadDoc, "response.usage", translatorcommon.InteractionsUsage(root))
+ return emitResponsesEvent(eventType, payloadDoc.Bytes())
}
-func responsesFailedEvent(modelName string, root gjson.Result, st *interactionsToResponsesStreamState) []byte {
+func responsesFailedEvent(modelName string, root ojson.Result, st *interactionsToResponsesStreamState) []byte {
eventType := "response.failed"
- payload := []byte(`{"type":"response.failed","response":{"id":"","object":"response","status":"failed","model":"","output":[],"error":{"message":"","code":"","type":"server_error"}}}`)
- payload, _ = sjson.SetBytes(payload, "sequence_number", nextResponsesSeq(st))
+ payloadDoc := ojson.NewDocString(`{"type":"response.failed","response":{"id":"","object":"response","status":"failed","model":"","output":[],"error":{"message":"","code":"","type":"server_error"}}}`)
+ _ = payloadDoc.Set("sequence_number", nextResponsesSeq(st))
interaction := root.Get("interaction")
id := firstNonEmpty(interaction.Get("id").String(), root.Get("id").String())
if id == "" && st != nil {
id = st.ID
}
- payload, _ = sjson.SetBytes(payload, "response.id", id)
- payload, _ = sjson.SetBytes(payload, "response.model", firstNonEmpty(interaction.Get("model").String(), modelName))
+ _ = payloadDoc.Set("response.id", id)
+ _ = payloadDoc.Set("response.model", firstNonEmpty(interaction.Get("model").String(), modelName))
errNode := root.Get("error")
if !errNode.Exists() && interaction.Exists() {
errNode = interaction.Get("error")
@@ -777,21 +777,21 @@ func responsesFailedEvent(modelName string, root gjson.Result, st *interactionsT
msg = "upstream execution failed"
}
code := errNode.Get("code").String()
- payload, _ = sjson.SetBytes(payload, "response.error.message", msg)
+ _ = payloadDoc.Set("response.error.message", msg)
if code != "" {
- payload, _ = sjson.SetBytes(payload, "response.error.code", code)
+ _ = payloadDoc.Set("response.error.code", code)
} else {
- payload, _ = sjson.DeleteBytes(payload, "response.error.code")
+ _ = payloadDoc.Delete("response.error.code")
}
errType := errNode.Get("type").String()
if errType == "" {
errType = "server_error"
}
- payload, _ = sjson.SetBytes(payload, "response.error.type", errType)
- return emitResponsesEvent(eventType, payload)
+ _ = payloadDoc.Set("response.error.type", errType)
+ return emitResponsesEvent(eventType, payloadDoc.Bytes())
}
-func interactionsThoughtSignature(step gjson.Result) string {
+func interactionsThoughtSignature(step ojson.Result) string {
for _, path := range []string{
"encrypted_content",
"signature",
@@ -806,7 +806,7 @@ func interactionsThoughtSignature(step gjson.Result) string {
content := step.Get("content")
if content.IsArray() {
var signature string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
candidate := firstNonEmpty(
part.Get("signature").String(),
part.Get("thought_signature").String(),
@@ -839,7 +839,7 @@ func recordResponsesReasoningSummary(st *interactionsToResponsesStreamState, ind
if text == "" {
return
}
- st.ReasoningSummaries[index] = append(st.ReasoningSummaries[index], text)
+ st.ReasoningSummaries[index] = append(st.ReasoningSummaries[index], strings.Clone(text))
}
func recordResponsesTextOutput(st *interactionsToResponsesStreamState, index int, text string) {
@@ -852,7 +852,7 @@ func recordResponsesTextOutput(st *interactionsToResponsesStreamState, index int
st.TextOutputs[index].WriteString(text)
}
-func setResponsesCompletedOutput(payload []byte, st *interactionsToResponsesStreamState) []byte {
+func setResponsesCompletedOutputDoc(payloadDoc *ojson.Doc, st *interactionsToResponsesStreamState) {
maxIndex := -1
for index := range st.ItemTypes {
if index > maxIndex {
@@ -871,9 +871,8 @@ func setResponsesCompletedOutput(payload []byte, st *interactionsToResponsesStre
}
}
if len(outputItems) > 0 {
- payload = translatorcommon.SetRawArrayItems(payload, "response.output", outputItems)
+ translatorcommon.SetRawArrayItemsDoc(payloadDoc, "response.output", outputItems)
}
- return payload
}
func responsesFunctionCallArguments(call *interactionsFunctionCallState) string {
@@ -886,63 +885,63 @@ func responsesFunctionCallArguments(call *interactionsFunctionCallState) string
func responsesCompletedOutputItem(index int, itemType string, st *interactionsToResponsesStreamState) ([]byte, bool) {
switch itemType {
case "model_output":
- item := []byte(`{"id":"","type":"message","status":"completed","role":"assistant","content":[]}`)
- item, _ = sjson.SetBytes(item, "id", st.ItemIDs[index])
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","role":"assistant","content":[]}`)
+ _ = itemDoc.Set("id", st.ItemIDs[index])
if builder := st.TextOutputs[index]; builder != nil && builder.String() != "" {
part := []byte(`{"type":"output_text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", builder.String())
- item = translatorcommon.SetRawArrayItems(item, "content", [][]byte{part})
+ part, _ = ojson.SetBytes(part, "text", builder.String())
+ translatorcommon.SetRawArrayItemsDoc(itemDoc, "content", [][]byte{part})
}
- return item, true
+ return itemDoc.Bytes(), true
case "thought":
return responsesReasoningItem(index, st), true
case "function_call":
if call := st.FunctionCalls[index]; call != nil && call.IsCustom {
- item := []byte(`{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"","status":"completed"}`)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","call_id":"","name":"","input":"","status":"completed"}`)
itemID := st.ItemIDs[index]
- item, _ = sjson.SetBytes(item, "id", itemID)
- item, _ = sjson.SetBytes(item, "call_id", call.CallID)
+ _ = itemDoc.Set("id", itemID)
+ _ = itemDoc.Set("call_id", call.CallID)
if call.Namespace != "" {
- item, _ = sjson.SetBytes(item, "namespace", call.Namespace)
+ _ = itemDoc.Set("namespace", call.Namespace)
}
- item, _ = sjson.SetBytes(item, "name", call.Name)
+ _ = itemDoc.Set("name", call.Name)
input := util.UnwrapResponsesCustomToolInput(responsesFunctionCallArguments(call))
if call.PatchCall != nil {
input = call.PatchCall.Decoder.Input()
}
- item, _ = sjson.SetBytes(item, "input", input)
- return item, true
+ _ = itemDoc.Set("input", input)
+ return itemDoc.Bytes(), true
}
- item := []byte(`{"id":"","type":"function_call","call_id":"","name":"","arguments":"{}","status":"completed"}`)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","call_id":"","name":"","arguments":"{}","status":"completed"}`)
itemID := st.ItemIDs[index]
- item, _ = sjson.SetBytes(item, "id", itemID)
- item, _ = sjson.SetBytes(item, "call_id", itemID)
+ _ = itemDoc.Set("id", itemID)
+ _ = itemDoc.Set("call_id", itemID)
if call := st.FunctionCalls[index]; call != nil {
- item, _ = sjson.SetBytes(item, "call_id", call.CallID)
+ _ = itemDoc.Set("call_id", call.CallID)
if call.Namespace != "" {
- item, _ = sjson.SetBytes(item, "namespace", call.Namespace)
+ _ = itemDoc.Set("namespace", call.Namespace)
}
- item, _ = sjson.SetBytes(item, "name", call.Name)
- item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", responsesFunctionCallArguments(call))
+ _ = itemDoc.Set("name", call.Name)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", responsesFunctionCallArguments(call))
}
- return item, true
+ return itemDoc.Bytes(), true
}
return nil, false
}
func responsesReasoningItem(index int, st *interactionsToResponsesStreamState) []byte {
- item := []byte(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`)
- item, _ = sjson.SetBytes(item, "id", st.ItemIDs[index])
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","status":"completed","encrypted_content":"","summary":[]}`)
+ _ = itemDoc.Set("id", st.ItemIDs[index])
if signature := interactionsReasoningEncryptedContent(st.ReasoningEncrypted[index]); signature != "" {
- item, _ = sjson.SetBytes(item, "encrypted_content", signature)
+ _ = itemDoc.Set("encrypted_content", signature)
}
part := []byte(`{"type":"summary_text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", strings.Join(st.ReasoningSummaries[index], ""))
- item = translatorcommon.SetRawArrayItems(item, "summary", [][]byte{part})
- return item
+ part, _ = ojson.SetBytes(part, "text", strings.Join(st.ReasoningSummaries[index], ""))
+ translatorcommon.SetRawArrayItemsDoc(itemDoc, "summary", [][]byte{part})
+ return itemDoc.Bytes()
}
-func setResponsesUsageFromInteractions(out []byte, path string, usage gjson.Result) []byte {
+func setResponsesUsageFromInteractionsDoc(outDoc *ojson.Doc, path string, usage ojson.Result) {
var inputTokens int64
var outputTokens int64
var totalTokens int64
@@ -959,18 +958,17 @@ func setResponsesUsageFromInteractions(out []byte, path string, usage gjson.Resu
totalTokens = inputTokens + outputTokens
}
}
- out, _ = sjson.SetBytes(out, path+".input_tokens", inputTokens)
- out, _ = sjson.SetBytes(out, path+".output_tokens", outputTokens)
- out, _ = sjson.SetBytes(out, path+".total_tokens", totalTokens)
+ _ = outDoc.Set(path+".input_tokens", inputTokens)
+ _ = outDoc.Set(path+".output_tokens", outputTokens)
+ _ = outDoc.Set(path+".total_tokens", totalTokens)
if usage.Exists() {
if v, ok := firstUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".input_tokens_details.cached_tokens", v)
+ _ = outDoc.Set(path+".input_tokens_details.cached_tokens", v)
}
if v, ok := firstUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
- out, _ = sjson.SetBytes(out, path+".output_tokens_details.reasoning_tokens", v)
+ _ = outDoc.Set(path+".output_tokens_details.reasoning_tokens", v)
}
}
- return out
}
func ConvertOpenAIResponsesResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
@@ -998,26 +996,26 @@ func ConvertOpenAIResponsesResponseToInteractionsNonStream(ctx context.Context,
_ = ctx
_ = originalRequestRawJSON
_ = requestRawJSON
- root := gjson.ParseBytes(rawJSON)
- out := []byte(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
+ outDoc := ojson.NewDocString(`{"id":"","object":"interaction","status":"completed","model":"","steps":[]}`)
if status := root.Get("status").String(); status != "" {
- out, _ = sjson.SetBytes(out, "status", status)
+ _ = outDoc.Set("status", status)
}
- out, _ = sjson.SetBytes(out, "id", root.Get("id").String())
- out, _ = sjson.SetBytes(out, "model", responseModel(modelName, root))
+ _ = outDoc.Set("id", root.Get("id").String())
+ _ = outDoc.Set("model", responseModel(modelName, root))
forAntigravity := isAntigravityModel(modelName)
var stepItems [][]byte
- root.Get("output").ForEach(func(_, item gjson.Result) bool {
+ root.Get("output").ForEach(func(_, item ojson.Result) bool {
if step, ok := openAIResponsesOutputItemToInteractionsStep(item, forAntigravity); ok {
stepItems = append(stepItems, step)
}
return true
})
if len(stepItems) > 0 {
- out, _ = sjson.SetRawBytes(out, "steps", translatorcommon.JoinRawArray(stepItems))
+ _ = outDoc.SetRawBytes("steps", translatorcommon.JoinRawArray(stepItems))
}
- out = setInteractionsUsageFromResponses(out, "usage", root.Get("usage"))
- return out
+ setInteractionsUsageFromResponsesDoc(outDoc, "usage", root.Get("usage"))
+ return outDoc.Bytes()
}
func convertOpenAIResponsesEventToInteractions(modelName string, rawJSON []byte, st *responsesToInteractionsStreamState) [][]byte {
@@ -1028,7 +1026,7 @@ func convertOpenAIResponsesEventToInteractions(modelName string, rawJSON []byte,
if bytes.Equal(bytes.TrimSpace(payload), []byte("[DONE]")) {
return appendInteractionsDoneDirect(nil, st)
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
if !root.Exists() {
return nil
}
@@ -1036,12 +1034,12 @@ func convertOpenAIResponsesEventToInteractions(modelName string, rawJSON []byte,
case "response.created":
return appendInteractionsCreatedDirect(nil, st, modelName, root.Get("response"))
case "response.output_text.delta":
- out := ensureInteractionsStepDirect(nil, st, modelName, "model_output", gjson.Result{})
+ out := ensureInteractionsStepDirect(nil, st, modelName, "model_output", ojson.Result{})
out = appendInteractionsTextDeltaDirect(out, st, root.Get("delta").String(), false)
st.markTextSent(textKeysFromResponsesEvent(root))
return out
case "response.reasoning_summary_text.delta":
- out := ensureInteractionsStepDirect(nil, st, modelName, "thought", gjson.Result{})
+ out := ensureInteractionsStepDirect(nil, st, modelName, "thought", ojson.Result{})
return appendInteractionsTextDeltaDirect(out, st, root.Get("delta").String(), true)
case "response.output_item.added":
return openAIResponsesOutputItemAddedToInteractions(modelName, root, st)
@@ -1058,62 +1056,70 @@ func convertOpenAIResponsesEventToInteractions(modelName string, rawJSON []byte,
return nil
}
-func openAIResponsesOutputItemToInteractionsStep(item gjson.Result, forAntigravity bool) ([]byte, bool) {
+func openAIResponsesOutputItemToInteractionsStep(item ojson.Result, forAntigravity bool) ([]byte, bool) {
switch item.Get("type").String() {
case "message":
- step := []byte(`{"type":"model_output","content":[]}`)
- item.Get("content").ForEach(func(_, part gjson.Result) bool {
+ var parts [][]byte
+ item.Get("content").ForEach(func(_, part ojson.Result) bool {
if converted, ok := responsesContentPartToInteractions(part); ok {
- step, _ = sjson.SetRawBytes(step, "content.-1", converted)
+ parts = append(parts, converted)
}
return true
})
+ step := []byte(`{"type":"model_output","content":[]}`)
+ if len(parts) > 0 {
+ step = translatorcommon.SetRawArrayItems(step, "content", parts)
+ }
return step, true
case "function_call":
return responsesFunctionCallToInteractions(item, forAntigravity), true
case "reasoning":
- step := []byte(`{"type":"thought","content":[]}`)
- item.Get("summary").ForEach(func(_, summary gjson.Result) bool {
+ var parts [][]byte
+ item.Get("summary").ForEach(func(_, summary ojson.Result) bool {
if text := summary.Get("text").String(); text != "" {
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
- step, _ = sjson.SetRawBytes(step, "content.-1", part)
+ part, _ = ojson.SetBytes(part, "text", text)
+ parts = append(parts, part)
}
return true
})
+ step := []byte(`{"type":"thought","content":[]}`)
+ if len(parts) > 0 {
+ step = translatorcommon.SetRawArrayItems(step, "content", parts)
+ }
return step, true
}
return nil, false
}
-func openAIResponsesOutputItemAddedToInteractions(modelName string, root gjson.Result, st *responsesToInteractionsStreamState) [][]byte {
+func openAIResponsesOutputItemAddedToInteractions(modelName string, root ojson.Result, st *responsesToInteractionsStreamState) [][]byte {
item := root.Get("item")
switch item.Get("type").String() {
case "function_call":
out := ensureInteractionsCreatedDirect(nil, st, modelName)
out = appendInteractionsStepStopDirect(out, st)
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
+ stepDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
name := item.Get("name").String()
if isAntigravityModel(modelName) {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- step, _ = sjson.SetBytes(step, "name", name)
+ _ = stepDoc.Set("name", name)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String()); callID != "" {
- step, _ = sjson.SetBytes(step, "id", callID)
- step, _ = sjson.SetBytes(step, "call_id", callID)
- st.FunctionCallIndexes[callID] = st.StepIndex
+ _ = stepDoc.Set("id", callID)
+ _ = stepDoc.Set("call_id", callID)
+ st.FunctionCallIndexes[strings.Clone(callID)] = st.StepIndex
}
- out = appendInteractionsStepStartDirect(out, st, "function_call", gjson.ParseBytes(step))
+ out = appendInteractionsStepStartDirect(out, st, "function_call", util.ParseGJSONBytesNoCopy(stepDoc.Bytes()))
return out
case "message":
- return ensureInteractionsStepDirect(nil, st, modelName, "model_output", gjson.Result{})
+ return ensureInteractionsStepDirect(nil, st, modelName, "model_output", ojson.Result{})
case "reasoning":
- return ensureInteractionsStepDirect(nil, st, modelName, "thought", gjson.Result{})
+ return ensureInteractionsStepDirect(nil, st, modelName, "thought", ojson.Result{})
}
return nil
}
-func openAIResponsesOutputItemDoneToInteractions(modelName string, root gjson.Result, st *responsesToInteractionsStreamState) [][]byte {
+func openAIResponsesOutputItemDoneToInteractions(modelName string, root ojson.Result, st *responsesToInteractionsStreamState) [][]byte {
item := root.Get("item")
switch item.Get("type").String() {
case "function_call":
@@ -1123,8 +1129,8 @@ func openAIResponsesOutputItemDoneToInteractions(modelName string, root gjson.Re
}
return appendInteractionsStepStopDirect(out, st)
case "reasoning":
- out := ensureInteractionsStepDirect(nil, st, modelName, "thought", gjson.Result{})
- item.Get("summary").ForEach(func(_, summary gjson.Result) bool {
+ out := ensureInteractionsStepDirect(nil, st, modelName, "thought", ojson.Result{})
+ item.Get("summary").ForEach(func(_, summary ojson.Result) bool {
if text := summary.Get("text").String(); text != "" {
out = appendInteractionsTextDeltaDirect(out, st, text, true)
}
@@ -1137,9 +1143,9 @@ func openAIResponsesOutputItemDoneToInteractions(modelName string, root gjson.Re
return nil
}
-func openAIResponsesCompletedToInteractions(modelName string, response gjson.Result, st *responsesToInteractionsStreamState) [][]byte {
+func openAIResponsesCompletedToInteractions(modelName string, response ojson.Result, st *responsesToInteractionsStreamState) [][]byte {
var out [][]byte
- response.Get("output").ForEach(func(outputIndex, item gjson.Result) bool {
+ response.Get("output").ForEach(func(outputIndex, item ojson.Result) bool {
if item.Get("type").String() == "message" {
out = appendResponsesMessageFallbackToInteractions(out, modelName, item, responseOutputIndexRoot(item, outputIndex), st, false)
}
@@ -1150,11 +1156,11 @@ func openAIResponsesCompletedToInteractions(modelName string, response gjson.Res
return appendInteractionsDoneDirect(out, st)
}
-func appendResponsesMessageFallbackToInteractions(out [][]byte, modelName string, item, root gjson.Result, st *responsesToInteractionsStreamState, stop bool) [][]byte {
+func appendResponsesMessageFallbackToInteractions(out [][]byte, modelName string, item, root ojson.Result, st *responsesToInteractionsStreamState, stop bool) [][]byte {
itemID := item.Get("id").String()
outputIndex := int(root.Get("output_index").Int())
hasOutputIndex := root.Get("output_index").Exists()
- item.Get("content").ForEach(func(contentIndex, part gjson.Result) bool {
+ item.Get("content").ForEach(func(contentIndex, part ojson.Result) bool {
if part.Get("type").String() != "output_text" && part.Get("type").String() != "text" {
return true
}
@@ -1168,7 +1174,7 @@ func appendResponsesMessageFallbackToInteractions(out [][]byte, modelName string
if text == "" {
return true
}
- out = ensureInteractionsStepDirect(out, st, modelName, "model_output", gjson.Result{})
+ out = ensureInteractionsStepDirect(out, st, modelName, "model_output", ojson.Result{})
out = appendInteractionsTextDeltaDirect(out, st, text, false)
st.markTextSent(keys)
return true
@@ -1179,24 +1185,24 @@ func appendResponsesMessageFallbackToInteractions(out [][]byte, modelName string
return out
}
-func responseOutputIndexRoot(item, outputIndex gjson.Result) gjson.Result {
- raw := []byte(`{"output_index":0}`)
- raw, _ = sjson.SetBytes(raw, "output_index", outputIndex.Int())
+func responseOutputIndexRoot(item, outputIndex ojson.Result) ojson.Result {
+ rawDoc := ojson.NewDocString(`{"output_index":0}`)
+ _ = rawDoc.Set("output_index", outputIndex.Int())
if id := item.Get("id").String(); id != "" {
- raw, _ = sjson.SetBytes(raw, "item_id", id)
+ _ = rawDoc.Set("item_id", id)
}
- return gjson.ParseBytes(raw)
+ return util.ParseGJSONBytesNoCopy(rawDoc.Bytes())
}
-func appendInteractionsCreatedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string, response gjson.Result, markStatus ...bool) [][]byte {
+func appendInteractionsCreatedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string, response ojson.Result, markStatus ...bool) [][]byte {
if st.Created {
return out
}
- st.ID = firstNonEmpty(response.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano()))
- created := []byte(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
- created, _ = sjson.SetBytes(created, "interaction.id", st.ID)
- created, _ = sjson.SetBytes(created, "interaction.model", responseModel(modelName, response))
- out = append(out, emitInteractionsEvent("interaction.created", created))
+ st.ID = strings.Clone(firstNonEmpty(response.Get("id").String(), st.ID, fmt.Sprintf("interaction_%d", time.Now().UnixNano())))
+ createdDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"in_progress","object":"interaction","model":""},"event_type":"interaction.created"}`)
+ _ = createdDoc.Set("interaction.id", st.ID)
+ _ = createdDoc.Set("interaction.model", responseModel(modelName, response))
+ out = append(out, emitInteractionsEvent("interaction.created", createdDoc.Bytes()))
st.Created = true
if len(markStatus) == 0 || markStatus[0] {
out = appendInteractionsStatusUpdateDirect(out, st)
@@ -1209,13 +1215,13 @@ func appendInteractionsStatusUpdateDirect(out [][]byte, st *responsesToInteracti
return out
}
statusUpdate := []byte(`{"interaction_id":"","status":"in_progress","event_type":"interaction.status_update"}`)
- statusUpdate, _ = sjson.SetBytes(statusUpdate, "interaction_id", st.ID)
+ statusUpdate, _ = ojson.SetBytes(statusUpdate, "interaction_id", st.ID)
out = append(out, emitInteractionsEvent("interaction.status_update", statusUpdate))
st.StatusUpdated = true
return out
}
-func ensureInteractionsStepDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName, stepType string, step gjson.Result) [][]byte {
+func ensureInteractionsStepDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName, stepType string, step ojson.Result) [][]byte {
out = ensureInteractionsCreatedDirect(out, st, modelName)
if st.ActiveStepOpen && st.ActiveStepType == stepType {
return out
@@ -1225,47 +1231,47 @@ func ensureInteractionsStepDirect(out [][]byte, st *responsesToInteractionsStrea
}
func ensureInteractionsCreatedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string) [][]byte {
- return appendInteractionsCreatedDirect(out, st, modelName, gjson.Result{})
+ return appendInteractionsCreatedDirect(out, st, modelName, ojson.Result{})
}
-func appendInteractionsStepStartDirect(out [][]byte, st *responsesToInteractionsStreamState, stepType string, step gjson.Result) [][]byte {
+func appendInteractionsStepStartDirect(out [][]byte, st *responsesToInteractionsStreamState, stepType string, step ojson.Result) [][]byte {
index := st.StepIndex
st.StepIndex++
st.ActiveStepIndex = index
- st.ActiveStepType = stepType
+ st.ActiveStepType = strings.Clone(stepType)
st.ActiveStepOpen = true
- payload := []byte(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
- payload, _ = sjson.SetBytes(payload, "index", index)
- payload, _ = sjson.SetBytes(payload, "step.type", stepType)
+ payloadDoc := ojson.NewDocString(`{"index":0,"step":{"type":""},"event_type":"step.start"}`)
+ _ = payloadDoc.Set("index", index)
+ _ = payloadDoc.Set("step.type", stepType)
if stepType == "function_call" {
if id := firstNonEmpty(step.Get("call_id").String(), step.Get("id").String()); id != "" {
- payload, _ = sjson.SetBytes(payload, "step.id", id)
- payload, _ = sjson.SetBytes(payload, "step.call_id", id)
+ _ = payloadDoc.Set("step.id", id)
+ _ = payloadDoc.Set("step.call_id", id)
}
- payload, _ = sjson.SetBytes(payload, "step.name", step.Get("name").String())
- payload, _ = sjson.SetRawBytes(payload, "step.arguments", []byte(`{}`))
+ _ = payloadDoc.Set("step.name", step.Get("name").String())
+ _ = payloadDoc.SetRaw("step.arguments", `{}`)
}
- return append(out, emitInteractionsEvent("step.start", payload))
+ return append(out, emitInteractionsEvent("step.start", payloadDoc.Bytes()))
}
func appendInteractionsTextDeltaDirect(out [][]byte, st *responsesToInteractionsStreamState, text string, thought bool) [][]byte {
if thought {
- payload := []byte(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = sjson.SetBytes(payload, "delta.content.text", text)
- return append(out, emitInteractionsEvent("step.delta", payload))
+ payloadDoc := ojson.NewDocString(`{"index":0,"delta":{"content":{"text":"","type":"text"},"type":"thought_summary"},"event_type":"step.delta"}`)
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = payloadDoc.Set("delta.content.text", text)
+ return append(out, emitInteractionsEvent("step.delta", payloadDoc.Bytes()))
}
- payload := []byte(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = sjson.SetBytes(payload, "delta.text", text)
- return append(out, emitInteractionsEvent("step.delta", payload))
+ payloadDoc := ojson.NewDocString(`{"index":0,"delta":{"text":"","type":"text"},"event_type":"step.delta"}`)
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = payloadDoc.Set("delta.text", text)
+ return append(out, emitInteractionsEvent("step.delta", payloadDoc.Bytes()))
}
func appendInteractionsArgumentsDeltaDirect(out [][]byte, st *responsesToInteractionsStreamState, arguments string) [][]byte {
- payload := []byte(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
- payload, _ = translatorcommon.SetStringWithoutHTMLEscape(payload, "delta.arguments", arguments)
- return append(out, emitInteractionsEvent("step.delta", payload))
+ payloadDoc := ojson.NewDocString(`{"index":0,"delta":{"arguments":"","type":"arguments_delta"},"event_type":"step.delta"}`)
+ _ = payloadDoc.Set("index", st.ActiveStepIndex)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(payloadDoc, "delta.arguments", arguments)
+ return append(out, emitInteractionsEvent("step.delta", payloadDoc.Bytes()))
}
func appendInteractionsStepStopDirect(out [][]byte, st *responsesToInteractionsStreamState) [][]byte {
@@ -1273,28 +1279,28 @@ func appendInteractionsStepStopDirect(out [][]byte, st *responsesToInteractionsS
return out
}
payload := []byte(`{"index":0,"event_type":"step.stop"}`)
- payload, _ = sjson.SetBytes(payload, "index", st.ActiveStepIndex)
+ payload, _ = ojson.SetBytes(payload, "index", st.ActiveStepIndex)
out = append(out, emitInteractionsEvent("step.stop", payload))
st.ActiveStepOpen = false
st.ActiveStepType = ""
return out
}
-func appendInteractionsCompletedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string, response gjson.Result) [][]byte {
+func appendInteractionsCompletedDirect(out [][]byte, st *responsesToInteractionsStreamState, modelName string, response ojson.Result) [][]byte {
if st.Completed {
return out
}
now := time.Now().UTC().Format(time.RFC3339)
- payload := []byte(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
- payload, _ = sjson.SetBytes(payload, "interaction.id", st.ID)
- payload, _ = sjson.SetBytes(payload, "interaction.created", now)
- payload, _ = sjson.SetBytes(payload, "interaction.updated", now)
- payload, _ = sjson.SetBytes(payload, "interaction.model", responseModel(modelName, response))
+ payloadDoc := ojson.NewDocString(`{"interaction":{"id":"","status":"completed","usage":{},"created":"","updated":"","service_tier":"standard","object":"interaction","model":""},"event_type":"interaction.completed"}`)
+ _ = payloadDoc.Set("interaction.id", st.ID)
+ _ = payloadDoc.Set("interaction.created", now)
+ _ = payloadDoc.Set("interaction.updated", now)
+ _ = payloadDoc.Set("interaction.model", responseModel(modelName, response))
if status := response.Get("status").String(); status != "" {
- payload, _ = sjson.SetBytes(payload, "interaction.status", status)
+ _ = payloadDoc.Set("interaction.status", status)
}
- payload = setInteractionsUsageFromResponses(payload, "interaction.usage", response.Get("usage"))
- out = append(out, emitInteractionsEvent("interaction.completed", payload))
+ setInteractionsUsageFromResponsesDoc(payloadDoc, "interaction.usage", response.Get("usage"))
+ out = append(out, emitInteractionsEvent("interaction.completed", payloadDoc.Bytes()))
st.Completed = true
return out
}
@@ -1308,7 +1314,7 @@ func appendInteractionsDoneDirect(out [][]byte, st *responsesToInteractionsStrea
return out
}
-func ensureInteractionsFunctionCallStep(out [][]byte, st *responsesToInteractionsStreamState, modelName string, root gjson.Result) [][]byte {
+func ensureInteractionsFunctionCallStep(out [][]byte, st *responsesToInteractionsStreamState, modelName string, root ojson.Result) [][]byte {
if st.ActiveStepOpen && st.ActiveStepType == "function_call" {
return out
}
@@ -1316,45 +1322,44 @@ func ensureInteractionsFunctionCallStep(out [][]byte, st *responsesToInteraction
if !item.Exists() {
item = root
}
- step := []byte(`{"type":"function_call","name":"","arguments":{}}`)
+ stepDoc := ojson.NewDocString(`{"type":"function_call","name":"","arguments":{}}`)
name := item.Get("name").String()
if isAntigravityModel(modelName) {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- step, _ = sjson.SetBytes(step, "name", name)
+ _ = stepDoc.Set("name", name)
if callID := firstNonEmpty(item.Get("call_id").String(), item.Get("id").String(), root.Get("call_id").String(), root.Get("item_id").String()); callID != "" {
- step, _ = sjson.SetBytes(step, "id", callID)
- step, _ = sjson.SetBytes(step, "call_id", callID)
+ _ = stepDoc.Set("id", callID)
+ _ = stepDoc.Set("call_id", callID)
}
out = ensureInteractionsCreatedDirect(out, st, modelName)
out = appendInteractionsStepStopDirect(out, st)
- return appendInteractionsStepStartDirect(out, st, "function_call", gjson.ParseBytes(step))
+ return appendInteractionsStepStartDirect(out, st, "function_call", util.ParseGJSONBytesNoCopy(stepDoc.Bytes()))
}
-func setInteractionsUsageFromResponses(out []byte, path string, usage gjson.Result) []byte {
+func setInteractionsUsageFromResponsesDoc(outDoc *ojson.Doc, path string, usage ojson.Result) {
if !usage.Exists() {
- return out
+ return
}
if v := usage.Get("input_tokens"); v.Exists() {
- out, _ = sjson.SetBytes(out, path+".input_tokens", v.Int())
- out, _ = sjson.SetBytes(out, path+".total_input_tokens", v.Int())
+ _ = outDoc.Set(path+".input_tokens", v.Int())
+ _ = outDoc.Set(path+".total_input_tokens", v.Int())
}
if v := usage.Get("output_tokens"); v.Exists() {
- out, _ = sjson.SetBytes(out, path+".output_tokens", v.Int())
- out, _ = sjson.SetBytes(out, path+".total_output_tokens", v.Int())
+ _ = outDoc.Set(path+".output_tokens", v.Int())
+ _ = outDoc.Set(path+".total_output_tokens", v.Int())
}
if v := usage.Get("total_tokens"); v.Exists() {
- out, _ = sjson.SetBytes(out, path+".total_tokens", v.Int())
+ _ = outDoc.Set(path+".total_tokens", v.Int())
}
if v := usage.Get("input_tokens_details.cached_tokens"); v.Exists() {
- out, _ = sjson.SetBytes(out, path+".cached_tokens", v.Int())
- out, _ = sjson.SetBytes(out, path+".total_cached_tokens", v.Int())
+ _ = outDoc.Set(path+".cached_tokens", v.Int())
+ _ = outDoc.Set(path+".total_cached_tokens", v.Int())
}
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
- out, _ = sjson.SetBytes(out, path+".reasoning_tokens", v.Int())
- out, _ = sjson.SetBytes(out, path+".total_thought_tokens", v.Int())
+ _ = outDoc.Set(path+".reasoning_tokens", v.Int())
+ _ = outDoc.Set(path+".total_thought_tokens", v.Int())
}
- return out
}
func interactionsSSEPayload(rawJSON []byte) []byte {
@@ -1378,11 +1383,11 @@ func interactionsSSEPayload(rawJSON []byte) []byte {
return trimmed
}
-func responseModel(modelName string, root gjson.Result) string {
+func responseModel(modelName string, root ojson.Result) string {
return firstNonEmpty(modelName, root.Get("model").String(), root.Get("response.model").String(), root.Get("interaction.model").String())
}
-func firstUsageInt(root gjson.Result, paths ...string) (int64, bool) {
+func firstUsageInt(root ojson.Result, paths ...string) (int64, bool) {
for _, path := range paths {
if v := root.Get(path); v.Exists() {
return v.Int(), true
@@ -1411,7 +1416,7 @@ func emitInteractionsEvent(event string, payload []byte) []byte {
return translatorcommon.SSEEventData(event, payload)
}
-func textKeysFromResponsesEvent(root gjson.Result) []string {
+func textKeysFromResponsesEvent(root ojson.Result) []string {
itemID := root.Get("item_id").String()
outputIndex := int(root.Get("output_index").Int())
hasOutputIndex := root.Get("output_index").Exists()
@@ -1423,7 +1428,7 @@ func textKeysFromResponsesEvent(root gjson.Result) []string {
return openAIResponsesTextKeys(itemID, outputIndex, hasOutputIndex, contentIndex, hasContentIndex)
}
-func functionArgsKeysFromResponsesEvent(root gjson.Result) []string {
+func functionArgsKeysFromResponsesEvent(root ojson.Result) []string {
item := root.Get("item")
outputIndex := int(root.Get("output_index").Int())
hasOutputIndex := root.Get("output_index").Exists()
@@ -1537,17 +1542,17 @@ func (st *responsesToInteractionsStreamState) hasSentFunctionArgs(keys []string)
// Interactions uses qualified names directly, rather than Gemini's sanitized names.
func interactionsToolIdentityMap(rawJSON []byte, forAntigravity bool) map[string]util.ResponsesToolIdentity {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
if request := root.Get("request"); request.Exists() {
root = request
}
identities := make(map[string]util.ResponsesToolIdentity)
for name, descriptor := range util.CollectResponsesToolWinners(root) {
- identity := util.ResponsesToolIdentity{Name: descriptor.LocalName, Namespace: descriptor.Namespace, Custom: descriptor.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(descriptor.Tool)}
+ identity := util.ResponsesToolIdentity{Name: strings.Clone(descriptor.LocalName), Namespace: strings.Clone(descriptor.Namespace), Custom: descriptor.ToolType == "custom", ApplyPatch: applypatch.IsCustomTool(descriptor.Tool)}
if forAntigravity {
name = translatorcommon.AntigravityToolNameToUpstream(name)
}
- identities[name] = identity
+ identities[strings.Clone(name)] = identity
}
return identities
}
@@ -1569,7 +1574,7 @@ func interactionsPatchDelta(st *interactionsToResponsesStreamState, call *intera
}
// Reconcile every supplied index and ID before snapshot types are inspected.
-func interactionsResolveStepIndex(explicitIndex, step gjson.Result, fallback int, st *interactionsToResponsesStreamState) (int, error) {
+func interactionsResolveStepIndex(explicitIndex, step ojson.Result, fallback int, st *interactionsToResponsesStreamState) (int, error) {
stepIndex := step.Get("index")
index, indexed := fallback, explicitIndex.Exists() || stepIndex.Exists()
if explicitIndex.Exists() {
@@ -1669,12 +1674,12 @@ func interactionsResolveStepIndex(explicitIndex, step gjson.Result, fallback int
}
if itemID != "" {
if _, exists := st.ItemIdentityIndexes[itemID]; !exists {
- st.ItemIdentityIndexes[itemID] = index
+ st.ItemIdentityIndexes[strings.Clone(itemID)] = index
}
}
if callID != "" {
if _, exists := st.CallIdentityIndexes[callID]; !exists {
- st.CallIdentityIndexes[callID] = index
+ st.CallIdentityIndexes[strings.Clone(callID)] = index
}
}
if patchRelated {
@@ -1698,7 +1703,7 @@ func interactionsHasPatchBridge(st *interactionsToResponsesStreamState) bool {
}
// Keep evidence even before the upstream name identifies the winning declaration.
-func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactionsToResponsesStreamState, initial bool) [][]byte {
+func interactionsUpdateFunctionCall(index int, step ojson.Result, st *interactionsToResponsesStreamState, initial bool) [][]byte {
call := st.FunctionCalls[index]
if call == nil {
call = &interactionsFunctionCallState{}
@@ -1720,7 +1725,7 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
recordError(fmt.Errorf("conflicting apply_patch call identity"))
return
}
- *target = value
+ *target = strings.Clone(value)
*seen = true
}
mergeID(&call.ID, &call.ItemIDSeen, step.Get("id").String())
@@ -1729,7 +1734,7 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
if call.RawName != "" && call.RawName != name {
recordError(fmt.Errorf("conflicting apply_patch call name"))
} else {
- call.RawName = name
+ call.RawName = strings.Clone(name)
}
}
if call.PendingError != nil && st.ToolIdentityMap[step.Get("name").String()].ApplyPatch {
@@ -1739,7 +1744,7 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
arguments := jsonStringValue(args, "{}")
// A later complete snapshot is not a new prefix for already buffered fragments.
if !call.Added && call.InitialArguments == "" && call.Arguments.Len() == 0 {
- call.InitialArguments = arguments
+ call.InitialArguments = strings.Clone(arguments)
}
var snapshot translatorcommon.ApplyPatchCallState
_, input, errFinishArguments := snapshot.FinishArguments(arguments)
@@ -1753,8 +1758,8 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
recordError(fmt.Errorf("apply_patch snapshot conflicts with completed input"))
}
call.HasSnapshot = true
- call.SnapshotInput = input
- call.SnapshotArguments = arguments
+ call.SnapshotInput = strings.Clone(input)
+ call.SnapshotArguments = strings.Clone(arguments)
}
}
st.ItemIDs[index] = call.ID
@@ -1809,15 +1814,15 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
if call.IsCustom {
itemType, inputKey = "custom_tool_call", "input"
}
- added := []byte(`{"type":"response.output_item.added","item":{"status":"in_progress"}}`)
- added, _ = sjson.SetBytes(added, "sequence_number", nextResponsesSeq(st))
- added, _ = sjson.SetBytes(added, "output_index", index)
- added, _ = sjson.SetBytes(added, "item.type", itemType)
- added, _ = sjson.SetBytes(added, "item."+inputKey, "")
- added, _ = sjson.SetBytes(added, "item.id", call.ID)
- added, _ = sjson.SetBytes(added, "item.call_id", call.CallID)
- added = translatorcommon.SetResponsesToolCallIdentity(added, call.Name, call.Namespace, "item")
- events = append(events, emitResponsesEvent("response.output_item.added", added))
+ addedDoc := ojson.NewDocString(`{"type":"response.output_item.added","item":{"status":"in_progress"}}`)
+ _ = addedDoc.Set("sequence_number", nextResponsesSeq(st))
+ _ = addedDoc.Set("output_index", index)
+ _ = addedDoc.Set("item.type", itemType)
+ _ = addedDoc.Set("item."+inputKey, "")
+ _ = addedDoc.Set("item.id", call.ID)
+ _ = addedDoc.Set("item.call_id", call.CallID)
+ translatorcommon.SetResponsesToolCallIdentityDoc(addedDoc, call.Name, call.Namespace, "item")
+ events = append(events, emitResponsesEvent("response.output_item.added", addedDoc.Bytes()))
call.Added = true
}
if identity.ApplyPatch && call.PatchCall == nil {
@@ -1835,7 +1840,7 @@ func interactionsUpdateFunctionCall(index int, step gjson.Result, st *interactio
}
if call.StopPending && call.PatchCall != nil {
call.StopPending = false
- events = append(events, interactionsStepStopToResponses(gjson.Parse(fmt.Sprintf(`{"index":%d}`, index)), st)...)
+ events = append(events, interactionsStepStopToResponses(ojson.Parse(fmt.Sprintf(`{"index":%d}`, index)), st)...)
}
return events
}
@@ -1869,12 +1874,12 @@ func interactionsFinishPatchCalls(st *interactionsToResponsesStreamState) [][]by
call.CallID = call.ID
}
call.IdentityFinalized = true
- events = append(events, interactionsUpdateFunctionCall(index, gjson.Result{}, st, false)...)
+ events = append(events, interactionsUpdateFunctionCall(index, ojson.Result{}, st, false)...)
if st.Terminal {
break
}
}
- root := gjson.Parse(fmt.Sprintf(`{"index":%d}`, index))
+ root := ojson.Parse(fmt.Sprintf(`{"index":%d}`, index))
events = append(events, interactionsStepStopToResponses(root, st)...)
if st.Terminal {
break
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go
index 4476bac153d..1a2297c7ed9 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_response_test.go
@@ -3,21 +3,21 @@ package responses
import (
"bytes"
"context"
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertInteractionsResponseToOpenAIResponsesNonStream(t *testing.T) {
raw := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, nil)
- if got := gjson.GetBytes(out, "output.0.content.0.text").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "output.0.content.0.text").String(); got != "ok" {
t.Fatalf("response text = %q, want ok. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); got != 3 {
t.Fatalf("usage.total_tokens = %d, want 3. Output: %s", got, string(out))
}
}
@@ -48,7 +48,7 @@ func TestInteractionsResponsesGeneratedItemIDsDoNotCollideAcrossResponses(t *tes
} {
events = append(events, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "devin/gpt-6-1-sol", tc.req, nil, []byte(raw), ¶m)...)
}
- id := gjson.GetBytes(findResponsesEventPayload(events, "response.output_item.added"), "item.id").String()
+ id := ojson.GetBytesNoCopy(findResponsesEventPayload(events, "response.output_item.added"), "item.id").String()
if id == "" || seen[id] {
t.Fatalf("%s response %d generated duplicate or empty item ID %q", tc.name, response, id)
}
@@ -58,7 +58,7 @@ func TestInteractionsResponsesGeneratedItemIDsDoNotCollideAcrossResponses(t *tes
{"response.output_item.done", "item.id"},
{"response.completed", "response.output.0.id"},
} {
- if got := gjson.GetBytes(findResponsesEventPayload(events, check.event), check.path).String(); got != id {
+ if got := ojson.GetBytesNoCopy(findResponsesEventPayload(events, check.event), check.path).String(); got != id {
t.Fatalf("%s response %d %s item ID = %q, want %q", tc.name, response, check.event, got, id)
}
}
@@ -106,28 +106,28 @@ data: [DONE]
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, ¶m)...)
}
- if payload := findResponsesEventPayload(out, "response.output_text.delta"); gjson.GetBytes(payload, "delta").String() != "I will call a tool." {
+ if payload := findResponsesEventPayload(out, "response.output_text.delta"); ojson.GetBytesNoCopy(payload, "delta").String() != "I will call a tool." {
t.Fatalf("output_text delta payload = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"location":"北京"}` {
+ if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); ojson.GetBytesNoCopy(payload, "delta").String() != `{"location":"北京"}` {
t.Fatalf("function args delta payload = %s", string(payload))
}
argumentsDonePayload := findResponsesEventPayload(out, "response.function_call_arguments.done")
- if got := gjson.GetBytes(argumentsDonePayload, "item_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(argumentsDonePayload, "item_id").String(); got != "call_1" {
t.Fatalf("function args done item_id = %q, want call_1. Payload: %s", got, string(argumentsDonePayload))
}
- if got := gjson.GetBytes(argumentsDonePayload, "arguments").String(); got != `{"location":"北京"}` {
+ if got := ojson.GetBytesNoCopy(argumentsDonePayload, "arguments").String(); got != `{"location":"北京"}` {
t.Fatalf("function args done arguments = %q, want full arguments. Payload: %s", got, string(argumentsDonePayload))
}
createdPayload := findResponsesEventPayload(out, "response.created")
- if got := gjson.GetBytes(createdPayload, "response.model").String(); got != "gpt-test" {
+ if got := ojson.GetBytesNoCopy(createdPayload, "response.model").String(); got != "gpt-test" {
t.Fatalf("response.created models = %q, want gpt-test", got)
}
completedPayload := findResponsesEventPayload(out, "response.completed")
- if got := gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int(); got != 399 {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int(); got != 399 {
t.Fatalf("total_tokens = %d, want 399. Payload: %s", got, string(completedPayload))
}
- if got := gjson.GetBytes(completedPayload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 240 {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 240 {
t.Fatalf("reasoning_tokens = %d, want 240. Payload: %s", got, string(completedPayload))
}
if got := strings.Join(responsesEventNames(out), ","); !strings.Contains(got, "response.completed") {
@@ -156,13 +156,13 @@ data: {"index":0,"event_type":"step.stop"}
if gotEvents != wantEvents {
t.Fatalf("events = %s, want %s", gotEvents, wantEvents)
}
- if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); gjson.GetBytes(payload, "delta").String() != `{"q":"x"}` {
+ if payload := findResponsesEventPayload(out, "response.function_call_arguments.delta"); ojson.GetBytesNoCopy(payload, "delta").String() != `{"q":"x"}` {
t.Fatalf("function args delta = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); gjson.GetBytes(payload, "arguments").String() != `{"q":"x"}` {
+ if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); ojson.GetBytesNoCopy(payload, "arguments").String() != `{"q":"x"}` {
t.Fatalf("function args done = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.arguments").String() != `{"q":"x"}` {
+ if payload := findResponsesEventPayload(out, "response.output_item.done"); ojson.GetBytesNoCopy(payload, "item.arguments").String() != `{"q":"x"}` {
t.Fatalf("output item done = %s", string(payload))
}
}
@@ -192,13 +192,13 @@ data: {"interaction":{"id":"interaction_1","status":"completed","model":"gpt-tes
if gotEvents != wantEvents {
t.Fatalf("events = %s, want %s", gotEvents, wantEvents)
}
- if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); gjson.GetBytes(payload, "arguments").String() != "{}" {
+ if payload := findResponsesEventPayload(out, "response.function_call_arguments.done"); ojson.GetBytesNoCopy(payload, "arguments").String() != "{}" {
t.Fatalf("function args done = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.arguments").String() != "{}" {
+ if payload := findResponsesEventPayload(out, "response.output_item.done"); ojson.GetBytesNoCopy(payload, "item.arguments").String() != "{}" {
t.Fatalf("output item done = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.completed"); gjson.GetBytes(payload, "response.output.0.arguments").String() != "{}" {
+ if payload := findResponsesEventPayload(out, "response.completed"); ojson.GetBytesNoCopy(payload, "response.output.0.arguments").String() != "{}" {
t.Fatalf("completed output = %s", string(payload))
}
}
@@ -258,13 +258,13 @@ data: {"index":0,"event_type":"step.stop"}
out = append(out, ConvertInteractionsResponseToOpenAIResponses(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, raw, ¶m)...)
}
- if payload := findResponsesEventPayload(out, "response.output_text.done"); gjson.GetBytes(payload, "text").String() != "hello world" {
+ if payload := findResponsesEventPayload(out, "response.output_text.done"); ojson.GetBytesNoCopy(payload, "text").String() != "hello world" {
t.Fatalf("output_text done payload = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.content_part.done"); gjson.GetBytes(payload, "part.text").String() != "hello world" {
+ if payload := findResponsesEventPayload(out, "response.content_part.done"); ojson.GetBytesNoCopy(payload, "part.text").String() != "hello world" {
t.Fatalf("content_part done payload = %s", string(payload))
}
- if payload := findResponsesEventPayload(out, "response.output_item.done"); gjson.GetBytes(payload, "item.content.0.text").String() != "hello world" {
+ if payload := findResponsesEventPayload(out, "response.output_item.done"); ojson.GetBytesNoCopy(payload, "item.content.0.text").String() != "hello world" {
t.Fatalf("output_item done payload = %s", string(payload))
}
}
@@ -309,7 +309,7 @@ func TestConvertInteractionsResponseToOpenAIResponsesStreamReasoningSummaryLifec
t.Fatalf("frame %d events = %s, want exactly one immediate summary delta", i, got)
}
payload := findResponsesEventPayload(frames, "response.reasoning_summary_text.delta")
- if got := gjson.GetBytes(payload, "delta"); !got.Exists() || got.String() != chunk {
+ if got := ojson.GetBytesNoCopy(payload, "delta"); !got.Exists() || got.String() != chunk {
t.Fatalf("frame %d delta = %s, want %q", i, payload, chunk)
}
}
@@ -335,7 +335,7 @@ func TestConvertInteractionsResponseToOpenAIResponsesStreamReasoningSummaryLifec
var previousSequence int64
for i, frame := range out {
payload := findResponsesEventPayload([][]byte{frame}, wantNames[i])
- sequence := gjson.GetBytes(payload, "sequence_number")
+ sequence := ojson.GetBytesNoCopy(payload, "sequence_number")
if !sequence.Exists() || (i > 0 && sequence.Int() != previousSequence+1) {
t.Fatalf("invalid sequence: %s", payload)
}
@@ -343,17 +343,17 @@ func TestConvertInteractionsResponseToOpenAIResponsesStreamReasoningSummaryLifec
if wantNames[i] == "response.completed" {
continue
}
- if index := gjson.GetBytes(payload, "output_index"); !index.Exists() || index.Int() != 3 {
+ if index := ojson.GetBytesNoCopy(payload, "output_index"); !index.Exists() || index.Int() != 3 {
t.Fatalf("invalid output_index: %s", payload)
}
idPath := "item.id"
if strings.HasPrefix(wantNames[i], "response.reasoning_summary_") {
idPath = "item_id"
- if index := gjson.GetBytes(payload, "summary_index"); !index.Exists() || index.Int() != 0 {
+ if index := ojson.GetBytesNoCopy(payload, "summary_index"); !index.Exists() || index.Int() != 0 {
t.Fatalf("invalid summary_index: %s", payload)
}
}
- if id := gjson.GetBytes(payload, idPath); !id.Exists() || id.String() != "reasoning_3" {
+ if id := ojson.GetBytesNoCopy(payload, idPath); !id.Exists() || id.String() != "reasoning_3" {
t.Fatalf("invalid item identity: %s", payload)
}
}
@@ -372,12 +372,12 @@ func TestConvertInteractionsResponseToOpenAIResponsesStreamReasoningSummaryLifec
{"response.output_item.done", "item.encrypted_content", signature},
} {
payload := findResponsesEventPayload(out, check.event)
- if got := gjson.GetBytes(payload, check.path); !got.Exists() || got.String() != check.want {
+ if got := ojson.GetBytesNoCopy(payload, check.path); !got.Exists() || got.String() != check.want {
t.Errorf("%s %s = %s, want %q", check.event, check.path, payload, check.want)
}
}
- done := gjson.GetBytes(findResponsesEventPayload(out, "response.output_item.done"), "item")
- completed := gjson.GetBytes(findResponsesEventPayload(out, "response.completed"), "response.output")
+ done := ojson.GetBytesNoCopy(findResponsesEventPayload(out, "response.output_item.done"), "item")
+ completed := ojson.GetBytesNoCopy(findResponsesEventPayload(out, "response.completed"), "response.output")
if len(done.Get("summary").Array()) != 1 || len(completed.Array()) != 1 || done.Raw != completed.Get("0").Raw {
t.Fatalf("terminal reasoning items differ or summary count is wrong: done=%s completed=%s", done.Raw, completed.Raw)
}
@@ -422,14 +422,14 @@ data: {"interaction":{"id":"interaction_1","status":"completed","object":"intera
t.Fatalf("events = %s, did not expect output_text delta for thought signature", got)
}
donePayload := findResponsesEventPayload(out, "response.output_item.done")
- if got := gjson.GetBytes(donePayload, "item.encrypted_content").String(); got != signature {
+ if got := ojson.GetBytesNoCopy(donePayload, "item.encrypted_content").String(); got != signature {
t.Fatalf("done encrypted_content = %q, want %q. Payload: %s", got, signature, string(donePayload))
}
- if got := gjson.GetBytes(donePayload, "item.summary.0.text").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(donePayload, "item.summary.0.text").String(); got != "thinking" {
t.Fatalf("done summary = %q, want thinking. Payload: %s", got, string(donePayload))
}
completedPayload := findResponsesEventPayload(out, "response.completed")
- if got := gjson.GetBytes(completedPayload, "response.output.0.encrypted_content").String(); got != signature {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.output.0.encrypted_content").String(); got != signature {
t.Fatalf("completed encrypted_content = %q, want %q. Payload: %s", got, signature, string(completedPayload))
}
}
@@ -464,14 +464,14 @@ data: {"interaction":{"id":"interaction_1","status":"completed","object":"intera
}
donePayload := findResponsesEventPayload(out, "response.output_item.done")
- if got := gjson.GetBytes(donePayload, "item.encrypted_content").String(); got != "" {
+ if got := ojson.GetBytesNoCopy(donePayload, "item.encrypted_content").String(); got != "" {
t.Fatalf("done encrypted_content = %q, want empty for foreign signature. Payload: %s", got, string(donePayload))
}
- if got := gjson.GetBytes(donePayload, "item.summary.0.text").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(donePayload, "item.summary.0.text").String(); got != "thinking" {
t.Fatalf("done summary = %q, want thinking. Payload: %s", got, string(donePayload))
}
completedPayload := findResponsesEventPayload(out, "response.completed")
- if got := gjson.GetBytes(completedPayload, "response.output.0.encrypted_content").String(); got != "" {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.output.0.encrypted_content").String(); got != "" {
t.Fatalf("completed encrypted_content = %q, want empty for foreign signature. Payload: %s", got, string(completedPayload))
}
}
@@ -480,19 +480,19 @@ func TestConvertInteractionsResponseToOpenAIResponsesNonStreamThoughtSignature(t
validSig := testGPTResponsesReasoningSignature()
rawValid := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"thought","signature":"` + validSig + `","content":[{"type":"text","text":"thinking"}]}],"usage":{"total_tokens":1}}`)
outValid := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, rawValid, nil)
- if got := gjson.GetBytes(outValid, "output.0.encrypted_content").String(); got != validSig {
+ if got := ojson.GetBytesNoCopy(outValid, "output.0.encrypted_content").String(); got != validSig {
t.Fatalf("valid encrypted_content = %q, want %q. Output: %s", got, validSig, string(outValid))
}
- if got := gjson.GetBytes(outValid, "output.0.summary.0.text").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(outValid, "output.0.summary.0.text").String(); got != "thinking" {
t.Fatalf("summary = %q, want thinking. Output: %s", got, string(outValid))
}
rawForeign := []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"thought","thought_signature":"foreign-gemini-signature","content":[{"type":"text","text":"thinking"}]}],"usage":{"total_tokens":1}}`)
outForeign := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-test", []byte(`{"model":"gpt-test"}`), nil, rawForeign, nil)
- if got := gjson.GetBytes(outForeign, "output.0.encrypted_content").String(); got != "" {
+ if got := ojson.GetBytesNoCopy(outForeign, "output.0.encrypted_content").String(); got != "" {
t.Fatalf("foreign encrypted_content = %q, want empty. Output: %s", got, string(outForeign))
}
- if got := gjson.GetBytes(outForeign, "output.0.summary.0.text").String(); got != "thinking" {
+ if got := ojson.GetBytesNoCopy(outForeign, "output.0.summary.0.text").String(); got != "thinking" {
t.Fatalf("summary = %q, want thinking. Output: %s", got, string(outForeign))
}
}
@@ -500,13 +500,13 @@ func TestConvertInteractionsResponseToOpenAIResponsesNonStreamThoughtSignature(t
func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCall(t *testing.T) {
raw := []byte(`{"id":"resp_1","output":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call", got)
}
- if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.name").String(); got != "lookup" {
t.Fatalf("name = %q, want lookup", got)
}
- if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.call_id").String(); got != "call_1" {
t.Fatalf("call_id = %q, want call_1", got)
}
}
@@ -514,10 +514,10 @@ func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCall(t *te
func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCallStringArgs(t *testing.T) {
raw := []byte(`{"id":"resp_1","output":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":"{\"q\":\"x\"}"}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.type").String(); got != "function_call" {
t.Fatalf("step type = %q, want function_call", got)
}
- if got := gjson.GetBytes(out, "steps.0.arguments.q").String(); got != "x" {
+ if got := ojson.GetBytesNoCopy(out, "steps.0.arguments.q").String(); got != "x" {
t.Fatalf("arguments.q = %q, want x", got)
}
}
@@ -525,19 +525,19 @@ func TestConvertOpenAIResponsesResponseToInteractionsNonStreamFunctionCallString
func TestConvertOpenAIResponsesResponseToInteractionsNonStreamUsageDetails(t *testing.T) {
raw := []byte(`{"id":"resp_1","output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":11,"output_tokens":13,"total_tokens":24,"input_tokens_details":{"cached_tokens":5},"output_tokens_details":{"reasoning_tokens":7}}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "id").String(); got != "resp_1" {
+ if got := ojson.GetBytesNoCopy(out, "id").String(); got != "resp_1" {
t.Fatalf("id = %q, want resp_1. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.input_tokens").Int(); got != 11 {
+ if got := ojson.GetBytesNoCopy(out, "usage.input_tokens").Int(); got != 11 {
t.Fatalf("usage.input_tokens = %d, want 11. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.output_tokens").Int(); got != 13 {
+ if got := ojson.GetBytesNoCopy(out, "usage.output_tokens").Int(); got != 13 {
t.Fatalf("usage.output_tokens = %d, want 13. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.reasoning_tokens").Int(); got != 7 {
+ if got := ojson.GetBytesNoCopy(out, "usage.reasoning_tokens").Int(); got != 7 {
t.Fatalf("usage.reasoning_tokens = %d, want 7. Output: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 5 {
+ if got := ojson.GetBytesNoCopy(out, "usage.cached_tokens").Int(); got != 5 {
t.Fatalf("usage.cached_tokens = %d, want 5. Output: %s", got, string(out))
}
}
@@ -551,10 +551,10 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamFunctionCallCallID(t
t.Fatalf("step.delta payload not found")
}
startPayload := findInteractionsEventPayload(out, "step.start")
- if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" {
+ if got := ojson.GetBytesNoCopy(startPayload, "step.id").String(); got != "call_stream_1" {
t.Fatalf("step.id = %q, want call_stream_1", got)
}
- if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
+ if got := ojson.GetBytesNoCopy(payload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string", got)
}
}
@@ -567,7 +567,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneArgumentsAft
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
- if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
+ if got := ojson.GetBytesNoCopy(payload, "delta.arguments").String(); got != `{"q":"x"}` {
t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(payload))
}
@@ -589,7 +589,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneTextAfterDel
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "hi" {
t.Fatalf("delta.text = %q, want hi. Payload: %s", got, string(payload))
}
@@ -608,7 +608,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsDoneTextAfterUnk
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "hi" {
t.Fatalf("delta.text = %q, want hi. Payload: %s", got, string(payload))
}
@@ -627,7 +627,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamCompletedOutputFallba
if len(payload) == 0 {
t.Fatalf("fallback step.delta payload not found")
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "final" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "final" {
t.Fatalf("delta.text = %q, want final. Payload: %s", got, string(payload))
}
if got := countInteractionsEventType(out, "interaction.completed"); got != 1 {
@@ -665,19 +665,19 @@ func TestConvertInteractionsResponseToOpenAIResponsesStreamFinishMetadataUsage(t
if len(payload) == 0 {
t.Fatalf("response.completed payload not found")
}
- if got := gjson.GetBytes(payload, "response.usage.input_tokens").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(payload, "response.usage.input_tokens").Int(); got != 2 {
t.Fatalf("input_tokens = %d, want 2. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "response.usage.output_tokens").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(payload, "response.usage.output_tokens").Int(); got != 6 {
t.Fatalf("output_tokens = %d, want 6. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 3 {
+ if got := ojson.GetBytesNoCopy(payload, "response.usage.output_tokens_details.reasoning_tokens").Int(); got != 3 {
t.Fatalf("reasoning_tokens = %d, want 3. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "response.usage.input_tokens_details.cached_tokens").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(payload, "response.usage.input_tokens_details.cached_tokens").Int(); got != 1 {
t.Fatalf("cached_tokens = %d, want 1. Payload: %s", got, string(payload))
}
- if got := gjson.GetBytes(payload, "response.usage.total_tokens").Int(); got != 11 {
+ if got := ojson.GetBytesNoCopy(payload, "response.usage.total_tokens").Int(); got != 11 {
t.Fatalf("total_tokens = %d, want 11. Payload: %s", got, string(payload))
}
}
@@ -698,7 +698,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamCreatedThenDelta(t *t
t.Fatalf("events = %s, want %s", got, want)
}
payload := findInteractionsEventPayload(out, "interaction.status_update")
- if gotID := gjson.GetBytes(payload, "interaction_id").String(); gotID != "resp_1" {
+ if gotID := ojson.GetBytesNoCopy(payload, "interaction_id").String(); gotID != "resp_1" {
t.Fatalf("interaction_id = %q, want resp_1. Payload: %s", gotID, string(payload))
}
}
@@ -720,7 +720,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamCompletesAfterSteps(t
t.Fatalf("events = %s, want %s", got, want)
}
completedPayload := findInteractionsEventPayload(out, "interaction.completed")
- if gotTokens := gjson.GetBytes(completedPayload, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
+ if gotTokens := ojson.GetBytesNoCopy(completedPayload, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completedPayload))
}
}
@@ -733,7 +733,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsStreamSkipsCompletedTextAft
if len(payload) == 0 {
t.Fatalf("delta step.delta payload not found")
}
- if got := gjson.GetBytes(payload, "delta.text").String(); got != "final" {
+ if got := ojson.GetBytesNoCopy(payload, "delta.text").String(); got != "final" {
t.Fatalf("delta.text = %q, want final. Payload: %s", got, string(payload))
}
@@ -751,13 +751,13 @@ func TestConvertOpenAIResponsesResponseToInteractionsIncompleteTerminal(t *testi
t.Run("NonStream", func(t *testing.T) {
raw := []byte(`{"id":"resp_1","status":"incomplete","output":[{"type":"message","content":[{"type":"output_text","text":"partial"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete. Output: %s", got, string(out))
}
- if gotText := gjson.GetBytes(out, "steps.0.content.0.text").String(); gotText != "partial" {
+ if gotText := ojson.GetBytesNoCopy(out, "steps.0.content.0.text").String(); gotText != "partial" {
t.Fatalf("step text = %q, want partial. Output: %s", gotText, string(out))
}
- if gotTokens := gjson.GetBytes(out, "usage.total_tokens").Int(); gotTokens != 3 {
+ if gotTokens := ojson.GetBytesNoCopy(out, "usage.total_tokens").Int(); gotTokens != 3 {
t.Fatalf("total_tokens = %d, want 3. Output: %s", gotTokens, string(out))
}
})
@@ -773,14 +773,14 @@ func TestConvertOpenAIResponsesResponseToInteractionsIncompleteTerminal(t *testi
t.Fatalf("done count = %d, want 1", got)
}
deltaPayload := findInteractionsStepDeltaPayload(out)
- if gotText := gjson.GetBytes(deltaPayload, "delta.text").String(); gotText != "partial" {
+ if gotText := ojson.GetBytesNoCopy(deltaPayload, "delta.text").String(); gotText != "partial" {
t.Fatalf("delta.text = %q, want partial. Payload: %s", gotText, string(deltaPayload))
}
completedPayload := findInteractionsEventPayload(out, "interaction.completed")
- if got := gjson.GetBytes(completedPayload, "interaction.status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(completedPayload, "interaction.status").String(); got != "incomplete" {
t.Fatalf("interaction.status = %q, want incomplete. Payload: %s", got, string(completedPayload))
}
- if gotTokens := gjson.GetBytes(completedPayload, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
+ if gotTokens := ojson.GetBytesNoCopy(completedPayload, "interaction.usage.total_tokens").Int(); gotTokens != 3 {
t.Fatalf("total_tokens = %d, want 3. Payload: %s", gotTokens, string(completedPayload))
}
@@ -796,7 +796,7 @@ func TestConvertOpenAIResponsesResponseToInteractionsIncompleteTerminal(t *testi
t.Run("CompletedControl", func(t *testing.T) {
raw := []byte(`{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertOpenAIResponsesResponseToInteractionsNonStream(context.Background(), "gpt-test", nil, nil, raw, nil)
- if got := gjson.GetBytes(out, "status").String(); got != "completed" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "completed" {
t.Fatalf("status = %q, want completed. Output: %s", got, string(out))
}
})
@@ -848,7 +848,7 @@ func interactionsEventNames(events [][]byte) []string {
}
func interactionsEventName(event, payload []byte) string {
- if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
+ if eventType := ojson.GetBytesNoCopy(payload, "event_type").String(); eventType != "" {
return eventType
}
const prefix = "event: "
@@ -862,7 +862,7 @@ func interactionsEventName(event, payload []byte) string {
func findResponsesEventPayload(events [][]byte, eventType string) []byte {
for _, event := range events {
payload := ssePayload(event)
- if gjson.GetBytes(payload, "type").String() == eventType {
+ if ojson.GetBytesNoCopy(payload, "type").String() == eventType {
return payload
}
}
@@ -873,7 +873,7 @@ func responsesEventNames(events [][]byte) []string {
names := make([]string, 0, len(events))
for _, event := range events {
payload := ssePayload(event)
- if name := gjson.GetBytes(payload, "type").String(); name != "" {
+ if name := ojson.GetBytesNoCopy(payload, "type").String(); name != "" {
names = append(names, name)
}
}
@@ -883,7 +883,7 @@ func responsesEventNames(events [][]byte) []string {
func TestConvertInteractionsResponseToOpenAIResponsesNonStream_PreservesEnvironmentID(t *testing.T) {
raw := []byte(`{"id":"interaction_1","object":"interaction","environment_id":"env_abc123","status":"completed","steps":[{"type":"model_output","content":[{"text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}`)
out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "antigravity-preview-05-2026", []byte(`{"model":"antigravity-preview-05-2026"}`), nil, raw, nil)
- if got := gjson.GetBytes(out, "environment_id").String(); got != "env_abc123" {
+ if got := ojson.GetBytesNoCopy(out, "environment_id").String(); got != "env_abc123" {
t.Fatalf("environment_id = %q, want env_abc123. Output: %s", got, string(out))
}
}
@@ -901,11 +901,11 @@ func TestConvertInteractionsResponseToOpenAIResponsesStream_PreservesEnvironment
}
createdPayload := findResponsesEventPayload(out, "response.created")
- if got := gjson.GetBytes(createdPayload, "response.environment_id").String(); got != "env_stream123" {
+ if got := ojson.GetBytesNoCopy(createdPayload, "response.environment_id").String(); got != "env_stream123" {
t.Fatalf("response.created environment_id = %q, want env_stream123. Payload: %s", got, string(createdPayload))
}
completedPayload := findResponsesEventPayload(out, "response.completed")
- if got := gjson.GetBytes(completedPayload, "response.environment_id").String(); got != "env_stream123" {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.environment_id").String(); got != "env_stream123" {
t.Fatalf("response.completed environment_id = %q, want env_stream123. Payload: %s", got, string(completedPayload))
}
}
@@ -919,7 +919,7 @@ func TestConvertInteractionsResponseToOpenAIResponsesNonStreamRestoresAntigravit
]
}`)
out := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "antigravity-preview-05-2026", []byte(`{"model":"antigravity-preview-05-2026"}`), nil, raw, nil)
- if got := gjson.GetBytes(out, "output.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(out, "output.0.name").String(); got != "read_file" {
t.Fatalf("output.0.name = %q, want read_file. Output: %s", got, string(out))
}
}
@@ -940,7 +940,7 @@ func TestConvertInteractionsResponseToOpenAIResponsesStreamRestoresAntigravityTo
}
addedPayload := findResponsesEventPayload(out, "response.output_item.added")
- if got := gjson.GetBytes(addedPayload, "item.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(addedPayload, "item.name").String(); got != "read_file" {
t.Fatalf("stream item.name = %q, want read_file. Payload: %s", got, string(addedPayload))
}
}
@@ -955,7 +955,7 @@ func TestConvertInteractionsResponseToOpenAIResponsesPreservesNonCollidingAndNon
]
}`)
outNonColliding := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "antigravity-preview-05-2026", []byte(`{"model":"antigravity-preview-05-2026"}`), nil, rawNonColliding, nil)
- if got := gjson.GetBytes(outNonColliding, "output.0.name").String(); got != "external_lookup" {
+ if got := ojson.GetBytesNoCopy(outNonColliding, "output.0.name").String(); got != "external_lookup" {
t.Fatalf("output.0.name = %q, want external_lookup (preserved). Output: %s", got, string(outNonColliding))
}
@@ -968,7 +968,7 @@ func TestConvertInteractionsResponseToOpenAIResponsesPreservesNonCollidingAndNon
]
}`)
outNonAnti := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.1-flash-lite", []byte(`{"model":"gemini-3.1-flash-lite"}`), nil, rawNonAnti, nil)
- if got := gjson.GetBytes(outNonAnti, "output.0.name").String(); got != "external_read_file" {
+ if got := ojson.GetBytesNoCopy(outNonAnti, "output.0.name").String(); got != "external_read_file" {
t.Fatalf("output.0.name = %q, want external_read_file (preserved for non-antigravity). Output: %s", got, string(outNonAnti))
}
}
@@ -1066,7 +1066,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_LogReplayTwoToolCalls(t *t
t.Fatalf("missing response.completed event")
}
- outputItems := gjson.GetBytes(completedPayload, "response.output").Array()
+ outputItems := ojson.GetBytesNoCopy(completedPayload, "response.output").Array()
if len(outputItems) != 3 {
t.Fatalf("expected 3 output items (thought, title, bash), got %d: %s", len(outputItems), string(completedPayload))
}
@@ -1109,7 +1109,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_FunctionCallHasStatus(t *t
if addedPayload == nil {
t.Fatalf("missing response.output_item.added")
}
- if got := gjson.GetBytes(addedPayload, "item.status").String(); got != "in_progress" {
+ if got := ojson.GetBytesNoCopy(addedPayload, "item.status").String(); got != "in_progress" {
t.Fatalf("added item.status = %q, want in_progress. Payload: %s", got, string(addedPayload))
}
@@ -1117,7 +1117,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_FunctionCallHasStatus(t *t
if donePayload == nil {
t.Fatalf("missing response.output_item.done")
}
- if got := gjson.GetBytes(donePayload, "item.status").String(); got != "completed" {
+ if got := ojson.GetBytesNoCopy(donePayload, "item.status").String(); got != "completed" {
t.Fatalf("done item.status = %q, want completed. Payload: %s", got, string(donePayload))
}
@@ -1125,7 +1125,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_FunctionCallHasStatus(t *t
if completedPayload == nil {
t.Fatalf("missing response.completed")
}
- if got := gjson.GetBytes(completedPayload, "response.output.0.status").String(); got != "completed" {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.output.0.status").String(); got != "completed" {
t.Fatalf("completed response.output.0.status = %q, want completed. Payload: %s", got, string(completedPayload))
}
@@ -1138,7 +1138,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_FunctionCallHasStatus(t *t
]
}`)
outNonStream := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin/swe-2", nil, nil, rawNonStream, nil)
- if got := gjson.GetBytes(outNonStream, "output.0.status").String(); got != "completed" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.status").String(); got != "completed" {
t.Fatalf("non-stream output.0.status = %q, want completed. Output: %s", got, string(outNonStream))
}
@@ -1161,10 +1161,10 @@ func TestConvertInteractionsResponseToOpenAIResponses_FunctionCallHasStatus(t *t
if incompletePayload == nil {
t.Fatalf("expected response.incomplete event for truncated stream")
}
- if got := gjson.GetBytes(incompletePayload, "response.status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(incompletePayload, "response.status").String(); got != "incomplete" {
t.Fatalf("response.status = %q, want incomplete", got)
}
- if got := gjson.GetBytes(incompletePayload, "response.incomplete_details.reason").String(); got != "max_output_tokens" {
+ if got := ojson.GetBytesNoCopy(incompletePayload, "response.incomplete_details.reason").String(); got != "max_output_tokens" {
t.Fatalf("response.incomplete_details.reason = %q, want max_output_tokens", got)
}
}
@@ -1188,7 +1188,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_ContentFilterIncomplete(t
if incompletePayload == nil {
t.Fatalf("expected response.incomplete for content_filter")
}
- if got := gjson.GetBytes(incompletePayload, "response.incomplete_details.reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(incompletePayload, "response.incomplete_details.reason").String(); got != "content_filter" {
t.Fatalf("incomplete_details.reason = %q, want content_filter. Payload: %s", got, string(incompletePayload))
}
@@ -1201,10 +1201,10 @@ func TestConvertInteractionsResponseToOpenAIResponses_ContentFilterIncomplete(t
"steps":[{"type":"model_output","content":[{"type":"text","text":"blocked"}]}]
}`)
outNonStream := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin/swe-2", nil, nil, rawNonStream, nil)
- if got := gjson.GetBytes(outNonStream, "status").String(); got != "incomplete" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "status").String(); got != "incomplete" {
t.Fatalf("non-stream status = %q, want incomplete", got)
}
- if got := gjson.GetBytes(outNonStream, "incomplete_details.reason").String(); got != "content_filter" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "incomplete_details.reason").String(); got != "content_filter" {
t.Fatalf("non-stream incomplete_details.reason = %q, want content_filter", got)
}
}
@@ -1228,16 +1228,16 @@ func TestConvertInteractionsResponseToOpenAIResponses_MissingUsageDefaultsToZero
if completedPayload == nil {
t.Fatalf("missing response.completed")
}
- if !gjson.GetBytes(completedPayload, "response.usage.input_tokens").Exists() {
+ if !ojson.GetBytesNoCopy(completedPayload, "response.usage.input_tokens").Exists() {
t.Fatalf("expected usage.input_tokens to exist. Payload: %s", string(completedPayload))
}
- if got := gjson.GetBytes(completedPayload, "response.usage.input_tokens").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(completedPayload, "response.usage.input_tokens").Int(); got != 0 {
t.Fatalf("usage.input_tokens = %d, want 0", got)
}
- if !gjson.GetBytes(completedPayload, "response.usage.output_tokens").Exists() {
+ if !ojson.GetBytesNoCopy(completedPayload, "response.usage.output_tokens").Exists() {
t.Fatalf("expected usage.output_tokens to exist")
}
- if !gjson.GetBytes(completedPayload, "response.usage.total_tokens").Exists() {
+ if !ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Exists() {
t.Fatalf("expected usage.total_tokens to exist")
}
@@ -1249,13 +1249,13 @@ func TestConvertInteractionsResponseToOpenAIResponses_MissingUsageDefaultsToZero
"steps":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}]
}`)
outNonStream := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin/swe-2", nil, nil, rawNonStream, nil)
- if !gjson.GetBytes(outNonStream, "usage.input_tokens").Exists() {
+ if !ojson.GetBytesNoCopy(outNonStream, "usage.input_tokens").Exists() {
t.Fatalf("expected non-stream usage.input_tokens to exist. Output: %s", string(outNonStream))
}
- if !gjson.GetBytes(outNonStream, "usage.output_tokens").Exists() {
+ if !ojson.GetBytesNoCopy(outNonStream, "usage.output_tokens").Exists() {
t.Fatalf("expected non-stream usage.output_tokens to exist")
}
- if !gjson.GetBytes(outNonStream, "usage.total_tokens").Exists() {
+ if !ojson.GetBytesNoCopy(outNonStream, "usage.total_tokens").Exists() {
t.Fatalf("expected non-stream usage.total_tokens to exist")
}
}
@@ -1303,30 +1303,30 @@ func TestConvertInteractionsResponseToOpenAIResponses_RestoresNamespaceAndCustom
outNonStream := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "devin/gemini-3-7-flash", origRequest, nil, rawNonStream, nil)
// Item 0: multi_agent_v1__close_agent -> name: "close_agent", namespace: "multi_agent_v1", type: "function_call"
- if got := gjson.GetBytes(outNonStream, "output.0.name").String(); got != "close_agent" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.name").String(); got != "close_agent" {
t.Errorf("output.0.name = %q, want close_agent", got)
}
- if got := gjson.GetBytes(outNonStream, "output.0.namespace").String(); got != "multi_agent_v1" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.namespace").String(); got != "multi_agent_v1" {
t.Errorf("output.0.namespace = %q, want multi_agent_v1", got)
}
- if got := gjson.GetBytes(outNonStream, "output.0.type").String(); got != "function_call" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.type").String(); got != "function_call" {
t.Errorf("output.0.type = %q, want function_call", got)
}
// Item 1: functions__exec -> name: "exec", namespace: "functions", type: "custom_tool_call"
- if got := gjson.GetBytes(outNonStream, "output.1.name").String(); got != "exec" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.1.name").String(); got != "exec" {
t.Errorf("output.1.name = %q, want exec", got)
}
- if got := gjson.GetBytes(outNonStream, "output.1.namespace").String(); got != "functions" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.1.namespace").String(); got != "functions" {
t.Errorf("output.1.namespace = %q, want functions", got)
}
- if got := gjson.GetBytes(outNonStream, "output.1.type").String(); got != "custom_tool_call" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.1.type").String(); got != "custom_tool_call" {
t.Errorf("output.1.type = %q, want custom_tool_call", got)
}
- if got := gjson.GetBytes(outNonStream, "output.1.input").String(); got != "echo hi" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.1.input").String(); got != "echo hi" {
t.Errorf("output.1.input = %q, want echo hi", got)
}
- if gjson.GetBytes(outNonStream, "output.1.arguments").Exists() {
+ if ojson.GetBytesNoCopy(outNonStream, "output.1.arguments").Exists() {
t.Errorf("output.1.arguments should not exist for custom_tool_call")
}
@@ -1340,13 +1340,13 @@ func TestConvertInteractionsResponseToOpenAIResponses_RestoresNamespaceAndCustom
if strings.Contains(evStr, "response.output_item.added") {
foundAdded = true
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "item.name").String(); got != "close_agent" {
+ if got := ojson.Get(data, "item.name").String(); got != "close_agent" {
t.Errorf("stream item.name = %q, want close_agent", got)
}
- if got := gjson.Get(data, "item.namespace").String(); got != "multi_agent_v1" {
+ if got := ojson.Get(data, "item.namespace").String(); got != "multi_agent_v1" {
t.Errorf("stream item.namespace = %q, want multi_agent_v1", got)
}
- if got := gjson.Get(data, "item.type").String(); got != "function_call" {
+ if got := ojson.Get(data, "item.type").String(); got != "function_call" {
t.Errorf("stream item.type = %q, want function_call", got)
}
}
@@ -1364,10 +1364,10 @@ func TestConvertInteractionsResponseToOpenAIResponses_RestoresNamespaceAndCustom
if strings.Contains(evStr, "response.output_item.done") {
foundDone = true
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "item.name").String(); got != "close_agent" {
+ if got := ojson.Get(data, "item.name").String(); got != "close_agent" {
t.Errorf("stream done item.name = %q, want close_agent", got)
}
- if got := gjson.Get(data, "item.namespace").String(); got != "multi_agent_v1" {
+ if got := ojson.Get(data, "item.namespace").String(); got != "multi_agent_v1" {
t.Errorf("stream done item.namespace = %q, want multi_agent_v1", got)
}
}
@@ -1386,13 +1386,13 @@ func TestConvertInteractionsResponseToOpenAIResponses_RestoresNamespaceAndCustom
if strings.Contains(evStr, "response.output_item.added") {
foundCustomAdded = true
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "item.type").String(); got != "custom_tool_call" {
+ if got := ojson.Get(data, "item.type").String(); got != "custom_tool_call" {
t.Errorf("custom stream item.type = %q, want custom_tool_call", got)
}
- if got := gjson.Get(data, "item.name").String(); got != "exec" {
+ if got := ojson.Get(data, "item.name").String(); got != "exec" {
t.Errorf("custom stream item.name = %q, want exec", got)
}
- if got := gjson.Get(data, "item.namespace").String(); got != "functions" {
+ if got := ojson.Get(data, "item.namespace").String(); got != "functions" {
t.Errorf("custom stream item.namespace = %q, want functions", got)
}
}
@@ -1421,17 +1421,17 @@ func TestConvertInteractionsResponseToOpenAIResponses_RestoresNamespaceAndCustom
if strings.Contains(evStr, "response.custom_tool_call_input.done") {
customInputDoneCount++
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "input").String(); got != "pwd" {
+ if got := ojson.Get(data, "input").String(); got != "pwd" {
t.Errorf("custom stream input = %q, want pwd", got)
}
}
if strings.Contains(evStr, "response.output_item.done") {
customItemDoneCount++
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "item.input").String(); got != "pwd" {
+ if got := ojson.Get(data, "item.input").String(); got != "pwd" {
t.Errorf("custom stream done item.input = %q, want pwd", got)
}
- if got := gjson.Get(data, "item.type").String(); got != "custom_tool_call" {
+ if got := ojson.Get(data, "item.type").String(); got != "custom_tool_call" {
t.Errorf("custom stream done item.type = %q, want custom_tool_call", got)
}
}
@@ -1464,16 +1464,16 @@ func TestConvertInteractionsResponseToOpenAIResponses_AntigravityCustomToolResto
}`)
outNonStream := ConvertInteractionsResponseToOpenAIResponsesNonStream(context.Background(), "antigravity-preview-05-2026", origRequest, nil, rawNonStream, nil)
- if got := gjson.GetBytes(outNonStream, "output.0.type").String(); got != "custom_tool_call" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("output.0.type = %q, want custom_tool_call", got)
}
- if got := gjson.GetBytes(outNonStream, "output.0.name").String(); got != "read_file" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.name").String(); got != "read_file" {
t.Fatalf("output.0.name = %q, want read_file", got)
}
- if got := gjson.GetBytes(outNonStream, "output.0.input").String(); got != "/path/to/file" {
+ if got := ojson.GetBytesNoCopy(outNonStream, "output.0.input").String(); got != "/path/to/file" {
t.Fatalf("output.0.input = %q, want /path/to/file", got)
}
- if gjson.GetBytes(outNonStream, "output.0.arguments").Exists() {
+ if ojson.GetBytesNoCopy(outNonStream, "output.0.arguments").Exists() {
t.Fatalf("output.0.arguments unexpectedly exists for custom_tool_call")
}
@@ -1487,10 +1487,10 @@ func TestConvertInteractionsResponseToOpenAIResponses_AntigravityCustomToolResto
if strings.Contains(evStr, "response.output_item.added") {
foundAdded = true
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "item.type").String(); got != "custom_tool_call" {
+ if got := ojson.Get(data, "item.type").String(); got != "custom_tool_call" {
t.Fatalf("stream item.type = %q, want custom_tool_call", got)
}
- if got := gjson.Get(data, "item.name").String(); got != "read_file" {
+ if got := ojson.Get(data, "item.name").String(); got != "read_file" {
t.Fatalf("stream item.name = %q, want read_file", got)
}
}
@@ -1513,7 +1513,7 @@ func TestConvertInteractionsResponseToOpenAIResponses_AntigravityCustomToolResto
if strings.Contains(evStr, "response.custom_tool_call_input.done") {
customDoneCount++
data := strings.TrimPrefix(evStr, "data: ")
- if got := gjson.Get(data, "input").String(); got != "/path/to/file" {
+ if got := ojson.Get(data, "input").String(); got != "/path/to/file" {
t.Fatalf("stream custom_tool_call_input.done = %q, want /path/to/file", got)
}
}
@@ -1561,14 +1561,14 @@ func TestConvertInteractionsResponseToOpenAIResponses_ResponseFailed(t *testing.
if len(payload) == 0 {
t.Fatalf("expected response.failed event payload, got: %s", string(bytes.Join(events, []byte("\n"))))
}
- if got := gjson.GetBytes(payload, "response.status").String(); got != "failed" {
+ if got := ojson.GetBytesNoCopy(payload, "response.status").String(); got != "failed" {
t.Fatalf("response.status = %q, want failed", got)
}
- if got := gjson.GetBytes(payload, "response.error.message").String(); !strings.Contains(got, tt.wantMsg) {
+ if got := ojson.GetBytesNoCopy(payload, "response.error.message").String(); !strings.Contains(got, tt.wantMsg) {
t.Fatalf("response.error.message = %q, want containing %q", got, tt.wantMsg)
}
if tt.wantCode != "" {
- if got := gjson.GetBytes(payload, "response.error.code").String(); got != tt.wantCode {
+ if got := ojson.GetBytesNoCopy(payload, "response.error.code").String(); got != tt.wantCode {
t.Fatalf("response.error.code = %q, want %q", got, tt.wantCode)
}
}
@@ -1586,10 +1586,10 @@ func TestConvertInteractionsResponseToOpenAIResponses_PreservesIDAndSeqOnFailure
t.Fatalf("expected response.created event")
}
createdPayload := findResponsesEventPayload(eventsCreated, "response.created")
- if gotID := gjson.GetBytes(createdPayload, "response.id").String(); gotID != "interaction_test_id" {
+ if gotID := ojson.GetBytesNoCopy(createdPayload, "response.id").String(); gotID != "interaction_test_id" {
t.Fatalf("created response.id = %q, want interaction_test_id", gotID)
}
- if seq := gjson.GetBytes(createdPayload, "sequence_number").Int(); seq != 1 {
+ if seq := ojson.GetBytesNoCopy(createdPayload, "sequence_number").Int(); seq != 1 {
t.Fatalf("created sequence_number = %d, want 1", seq)
}
@@ -1598,10 +1598,10 @@ func TestConvertInteractionsResponseToOpenAIResponses_PreservesIDAndSeqOnFailure
t.Fatalf("expected response.failed event")
}
failedPayload := findResponsesEventPayload(eventsFailed, "response.failed")
- if gotID := gjson.GetBytes(failedPayload, "response.id").String(); gotID != "interaction_test_id" {
+ if gotID := ojson.GetBytesNoCopy(failedPayload, "response.id").String(); gotID != "interaction_test_id" {
t.Fatalf("failed response.id = %q, want interaction_test_id", gotID)
}
- if seq := gjson.GetBytes(failedPayload, "sequence_number").Int(); seq != 2 {
+ if seq := ojson.GetBytesNoCopy(failedPayload, "sequence_number").Int(); seq != 2 {
t.Fatalf("failed sequence_number = %d, want 2", seq)
}
}
diff --git a/internal/translator/openai/interactions/responses/interactions_openai_responses_user_turn_test.go b/internal/translator/openai/interactions/responses/interactions_openai_responses_user_turn_test.go
index 65c49266701..14efd25b384 100644
--- a/internal/translator/openai/interactions/responses/interactions_openai_responses_user_turn_test.go
+++ b/internal/translator/openai/interactions/responses/interactions_openai_responses_user_turn_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -94,7 +94,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_RefusesAnyEmptiedUserTurn(t
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
@@ -115,7 +115,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_KeepsTurnWithTextBesideAtta
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "input.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "input.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
})
@@ -147,7 +147,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_MapsBytesAndUrls(t *testing
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "input.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "input.2.content.0")
for path, want := range tc.want {
if got := part.Get(path).String(); got != want {
t.Fatalf("%s = %q, want %q; part = %s, body = %s", path, got, want, part.Raw, body)
@@ -163,7 +163,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_BareFileItemStaysADocument(
if err != nil {
t.Fatalf("err = %v", err)
}
- step := gjson.GetBytes(body, "input.2")
+ step := ojson.GetBytesNoCopy(body, "input.2")
if step.Get("type").String() != "user_input" || step.Get("content.0.type").String() != "document" || step.Get("content.0.data").String() != "JVBERi0xLjQK" {
t.Fatalf("step = %s, body = %s", step.Raw, body)
}
@@ -178,7 +178,7 @@ func TestConvertOpenAIResponsesRequestToInteractions_DeveloperAttachmentIsNotAUs
func TestConvertOpenAIResponsesRequestToInteractions_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := interactionsTurnPayload("", interactionsUserTurn("user", interactionsTurnFileID))
- if body, _ := ConvertOpenAIResponsesRequestToInteractions("gemini-3.5-flash", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIResponsesRequestToInteractions("gemini-3.5-flash", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/openai/openai/chat-completions/openai_openai_request.go b/internal/translator/openai/openai/chat-completions/openai_openai_request.go
index bb97e243363..ab80a439f12 100644
--- a/internal/translator/openai/openai/chat-completions/openai_openai_request.go
+++ b/internal/translator/openai/openai/chat-completions/openai_openai_request.go
@@ -3,12 +3,11 @@
package chat_completions
import (
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// ConvertOpenAIRequestToOpenAI converts an OpenAI Chat Completions request (raw JSON)
-// into a complete OpenAI request JSON. All JSON construction uses sjson and lookups use gjson.
+// into a complete OpenAI request JSON. All JSON construction uses sjson and lookups use ojson.
//
// Parameters:
// - modelName: The name of the model to use for the request
@@ -18,14 +17,14 @@ import (
// Returns:
// - []byte: The transformed request data in OpenAI API format
func ConvertOpenAIRequestToOpenAI(modelName string, inputRawJSON []byte, _ bool) ([]byte, error) {
- currentModel := gjson.GetBytes(inputRawJSON, "model")
- if currentModel.Type == gjson.String && currentModel.String() == modelName {
+ currentModel := ojson.GetBytesNoCopy(inputRawJSON, "model")
+ if currentModel.Type == ojson.String && currentModel.String() == modelName {
return inputRawJSON, nil
}
// Update the "model" field in the JSON payload with the provided modelName
- // The sjson.SetBytes function returns a new byte slice with the updated JSON.
- updatedJSON, err := sjson.SetBytes(inputRawJSON, "model", modelName)
+ // The ojson.SetBytes function returns a new byte slice with the updated JSON.
+ updatedJSON, err := ojson.SetBytes(inputRawJSON, "model", modelName)
if err != nil {
// If there's an error, return the original JSON or handle the error appropriately.
// For now, we'll return the original, but in a real scenario, logging or a more robust error
diff --git a/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go b/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go
index b721a256982..6b4d96cc116 100644
--- a/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go
+++ b/internal/translator/openai/openai/chat-completions/openai_openai_request_test.go
@@ -3,7 +3,7 @@ package chat_completions
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIRequestToOpenAIReusesMatchingModelPayload(t *testing.T) {
@@ -21,7 +21,7 @@ func TestConvertOpenAIRequestToOpenAIUpdatesDifferentModel(t *testing.T) {
output, _ := ConvertOpenAIRequestToOpenAI("new-model", input, false)
- if model := gjson.GetBytes(output, "model").String(); model != "new-model" {
+ if model := ojson.GetBytesNoCopy(output, "model").String(); model != "new-model" {
t.Fatalf("model = %q, want new-model", model)
}
}
diff --git a/internal/translator/openai/openai/responses/custom_tool_namespace_recovery_test.go b/internal/translator/openai/openai/responses/custom_tool_namespace_recovery_test.go
index 43a47543fdd..7ce63da3125 100644
--- a/internal/translator/openai/openai/responses/custom_tool_namespace_recovery_test.go
+++ b/internal/translator/openai/openai/responses/custom_tool_namespace_recovery_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
const namespaceRecoveryRequest = `{"input":[{"type":"additional_tools","tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"},{"type":"function","name":"wait"}]}]}]}`
@@ -19,7 +19,7 @@ func TestCustomToolNamespaceRecoveryPreservesStreamAndNonStream(t *testing.T) {
call := fmt.Sprintf(`{"index":0,"id":"call_fixture","type":"function","function":{"name":%q,"arguments":%q}}`, name, args)
raw := fmt.Sprintf(`{"id":"fixture","choices":[{"index":0,"message":{"tool_calls":[%s]},"finish_reason":"tool_calls"}]}`, call)
response := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "fixture", request, nil, []byte(raw), nil)
- assertRecoveredCustomCall(t, gjson.GetBytes(response, "output.0"), true)
+ assertRecoveredCustomCall(t, ojson.GetBytesNoCopy(response, "output.0"), true)
var state any
counts := map[string]int{}
for _, line := range []string{
@@ -50,7 +50,7 @@ func TestCustomToolNamespaceRecoveryPreservesStreamAndNonStream(t *testing.T) {
}
}
-func assertRecoveredCustomCall(t *testing.T, item gjson.Result, withInput bool) {
+func assertRecoveredCustomCall(t *testing.T, item ojson.Result, withInput bool) {
t.Helper()
for key, want := range map[string]string{"type": "custom_tool_call", "name": "exec", "namespace": "functions", "call_id": "call_fixture"} {
if got := item.Get(key).String(); got != want {
@@ -69,10 +69,10 @@ func TestCustomToolReplayPreservesNamespaceAndResultPair(t *testing.T) {
for _, namespace := range []string{`,"namespace":"functions"`, ""} {
request := strings.TrimSuffix(namespaceRecoveryRequest, "]}") + fmt.Sprintf(`,{"type":"custom_tool_call","name":"exec"%s,"call_id":"call_fixture","input":"text(1);"},{"type":"custom_tool_call_output","call_id":"call_fixture","output":[{"type":"input_text","text":"1"}]}]}`, namespace)
body, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("fixture", []byte(request), false)
- if got := gjson.GetBytes(body, "messages.0.tool_calls.0.function.name").String(); got != "functions__exec" {
+ if got := ojson.GetBytesNoCopy(body, "messages.0.tool_calls.0.function.name").String(); got != "functions__exec" {
t.Fatalf("replay name %q: %s", got, body)
}
- if got := gjson.GetBytes(body, "messages.1.tool_call_id").String(); got != "call_fixture" {
+ if got := ojson.GetBytesNoCopy(body, "messages.1.tool_call_id").String(); got != "call_fixture" {
t.Fatalf("result pair lost: %s", body)
}
}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go
index c27cd6eb5d0..6708fe6100d 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go
@@ -3,9 +3,9 @@ package responses
import (
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// ConvertOpenAIResponsesRequestToOpenAIChatCompletions converts OpenAI responses format to OpenAI chat completions format.
@@ -39,9 +39,9 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
rawJSON := inputRawJSON
var drops translatorcommon.UserTurnDrops
// Base OpenAI chat completions template with default values
- out := []byte(`{"model":"","messages":[],"stream":false}`)
+ outDoc := ojson.NewDocString(`{"model":"","messages":[],"stream":false}`)
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
toolIndex := newResponsesToolIndex(root)
messages := make([][]byte, 0)
@@ -50,31 +50,31 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
// Set model name
- out, _ = sjson.SetBytes(out, "model", modelName)
+ _ = outDoc.Set("model", modelName)
// Set stream configuration
- out, _ = sjson.SetBytes(out, "stream", stream)
+ _ = outDoc.Set("stream", stream)
// Map Responses text format to Chat Completions response format.
if textFormat := root.Get("text.format"); textFormat.Exists() {
if responseFormat := convertResponsesTextFormatToChatResponseFormat(textFormat); len(responseFormat) > 0 {
- out, _ = sjson.SetRawBytes(out, "response_format", responseFormat)
+ _ = outDoc.SetRawBytes("response_format", responseFormat)
}
}
// Map generation parameters from responses format to chat completions format
if maxTokens := root.Get("max_output_tokens"); maxTokens.Exists() {
if maxTokens.Raw != "" {
- out, _ = sjson.SetRawBytes(out, "max_tokens", []byte(maxTokens.Raw))
+ _ = outDoc.SetRaw("max_tokens", maxTokens.Raw)
} else {
- out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Value())
+ _ = outDoc.Set("max_tokens", maxTokens.Value())
}
}
// Convert instructions to system message
if instructions := root.Get("instructions"); instructions.Exists() {
systemMessage := []byte(`{"role":"system","content":""}`)
- systemMessage, _ = sjson.SetBytes(systemMessage, "content", instructions.String())
+ systemMessage, _ = ojson.SetBytes(systemMessage, "content", instructions.String())
appendMessage(systemMessage)
}
@@ -82,7 +82,7 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Convert input array to messages
if input := root.Get("input"); input.Exists() && input.IsArray() {
- rawInputArray := toolIndex.shellHistory(input.Array())
+ rawInputArray := toolIndex.toolSearchHistory(toolIndex.shellHistory(input.Array()))
explicitOutputCounts := make(map[string]int)
missingIDOutputsCount := 0
for _, item := range rawInputArray {
@@ -114,11 +114,11 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
itemType := item.Get("type").String()
if itemType == "function_call_output" || itemType == "custom_tool_call_output" {
if idx < len(rawInputArray) && translatorcommon.ExtractResponsesCallID(rawInputArray[idx]) == "" {
- raw := []byte(item.Raw)
- raw, _ = sjson.DeleteBytes(raw, "call_id")
- raw, _ = sjson.DeleteBytes(raw, "tool_call_id")
- raw, _ = sjson.DeleteBytes(raw, "callId")
- inputItems[idx] = gjson.ParseBytes(raw)
+ rawDoc := ojson.NewDocString(item.Raw)
+ _ = rawDoc.Delete("call_id")
+ _ = rawDoc.Delete("tool_call_id")
+ _ = rawDoc.Delete("callId")
+ inputItems[idx] = util.ParseGJSONBytesNoCopy(rawDoc.Bytes())
}
}
}
@@ -182,34 +182,35 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
reasoningContent := takePendingReasoningContent()
mergedIntoAssistant := false
if mergeableAssistantIndex >= 0 && mergeableAssistantIndex == len(messages)-1 {
- assistantMessage := gjson.ParseBytes(messages[mergeableAssistantIndex])
+ assistantMessage := util.ParseGJSONBytesNoCopy(messages[mergeableAssistantIndex])
if assistantMessage.Get("role").String() == "assistant" && !assistantMessage.Get("tool_calls").Exists() {
- updatedMessage, _ := sjson.SetBytes(messages[mergeableAssistantIndex], "tool_calls", pendingToolCalls)
+ updatedMessageDoc := ojson.NewDoc(messages[mergeableAssistantIndex])
+ _ = updatedMessageDoc.Set("tool_calls", pendingToolCalls)
combinedReasoning := combineOpenAIResponsesReasoning(assistantMessage.Get("reasoning_content").String(), reasoningContent)
if combinedReasoning != "" {
- updatedMessage, _ = sjson.SetBytes(updatedMessage, "reasoning_content", combinedReasoning)
+ _ = updatedMessageDoc.Set("reasoning_content", combinedReasoning)
if isUsableResponsesReasoning(combinedReasoning) {
latestReasoningContent = combinedReasoning
}
} else if fallback := fallbackToolReasoning(); fallback != "" {
- updatedMessage, _ = sjson.SetBytes(updatedMessage, "reasoning_content", fallback)
+ _ = updatedMessageDoc.Set("reasoning_content", fallback)
}
- messages[mergeableAssistantIndex] = updatedMessage
+ messages[mergeableAssistantIndex] = updatedMessageDoc.Bytes()
mergedIntoAssistant = true
}
}
if !mergedIntoAssistant {
- assistantMessage := []byte(`{"role":"assistant","tool_calls":[]}`)
- assistantMessage, _ = sjson.SetBytes(assistantMessage, "tool_calls", pendingToolCalls)
+ assistantMessageDoc := ojson.NewDocString(`{"role":"assistant","tool_calls":[]}`)
+ _ = assistantMessageDoc.Set("tool_calls", pendingToolCalls)
if reasoningContent != "" {
- assistantMessage, _ = sjson.SetBytes(assistantMessage, "reasoning_content", reasoningContent)
+ _ = assistantMessageDoc.Set("reasoning_content", reasoningContent)
if isUsableResponsesReasoning(reasoningContent) {
latestReasoningContent = reasoningContent
}
} else if fallback := fallbackToolReasoning(); fallback != "" {
- assistantMessage, _ = sjson.SetBytes(assistantMessage, "reasoning_content", fallback)
+ _ = assistantMessageDoc.Set("reasoning_content", fallback)
}
- appendMessage(assistantMessage)
+ appendMessage(assistantMessageDoc.Bytes())
}
for _, id := range pendingToolCallIDs {
trimmed := strings.TrimSpace(id)
@@ -235,7 +236,7 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
latestReasoningContent = reasoningContent
}
message := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
- message, _ = sjson.SetBytes(message, "reasoning_content", reasoningContent)
+ message, _ = ojson.SetBytes(message, "reasoning_content", reasoningContent)
appendRegularMessage(message)
}
@@ -252,25 +253,23 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
- // Only a real user turn can be refused; developer text is sent as user text
- // but never counts as the user's own turn.
+ // Chat Completions supports developer messages. Preserve their role and
+ // position so mid-session instructions retain their original authority.
+ // Only real user turns participate in unsupported-content refusal.
isUserTurn := role == "user"
- if role == "developer" {
- role = "user"
- }
mergeableAssistantIndex = -1
if role != "assistant" {
appendPendingReasoningMessage()
latestReasoningContent = ""
}
- message := []byte(`{"role":"","content":[]}`)
- message, _ = sjson.SetBytes(message, "role", role)
+ messageDoc := ojson.NewDocString(`{"role":"","content":[]}`)
+ _ = messageDoc.Set("role", role)
if content := item.Get("content"); content.Exists() && content.IsArray() {
var contentItems [][]byte
// Counts the parts this turn really sends; an empty text part does not.
turnSendable := 0
- content.ForEach(func(_, contentItem gjson.Result) bool {
+ content.ForEach(func(_, contentItem ojson.Result) bool {
contentType := contentItem.Get("type").String()
if contentType == "" {
contentType = "input_text"
@@ -280,34 +279,34 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
case "input_text", "output_text":
text := contentItem.Get("text").String()
contentPart := []byte(`{"type":"text","text":""}`)
- contentPart, _ = sjson.SetBytes(contentPart, "text", text)
+ contentPart, _ = ojson.SetBytes(contentPart, "text", text)
contentItems = append(contentItems, contentPart)
if text != "" {
turnSendable++
}
case "input_video", "video_url":
- contentPart := []byte(`{"type":"video_url","video_url":{}}`)
+ contentPartDoc := ojson.NewDocString(`{"type":"video_url","video_url":{}}`)
videoURL := contentItem.Get("video_url")
if videoURL.IsObject() {
- contentPart, _ = sjson.SetRawBytes(contentPart, "video_url", []byte(videoURL.Raw))
+ _ = contentPartDoc.SetRaw("video_url", videoURL.Raw)
} else if videoURL.Exists() {
- contentPart, _ = sjson.SetRawBytes(contentPart, "video_url.url", []byte(videoURL.Raw))
+ _ = contentPartDoc.SetRaw("video_url.url", videoURL.Raw)
}
if processing := contentItem.Get("processing"); processing.Exists() {
- contentPart, _ = sjson.SetRawBytes(contentPart, "video_url.processing", []byte(processing.Raw))
+ _ = contentPartDoc.SetRaw("video_url.processing", processing.Raw)
}
// Preserve malformed video parts for upstream validation instead of
// silently turning a video request into a text-only request.
- contentItems = append(contentItems, contentPart)
+ contentItems = append(contentItems, contentPartDoc.Bytes())
turnSendable++
case "input_image":
imageURL := contentItem.Get("image_url").String()
- contentPart := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- contentPart, _ = sjson.SetBytes(contentPart, "image_url.url", imageURL)
+ contentPartDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = contentPartDoc.Set("image_url.url", imageURL)
if detail, ok := normalizeChatImageDetail(contentItem.Get("detail")); ok && detail != "" {
- contentPart, _ = sjson.SetBytes(contentPart, "image_url.detail", detail)
+ _ = contentPartDoc.Set("image_url.detail", detail)
}
- contentItems = append(contentItems, contentPart)
+ contentItems = append(contentItems, contentPartDoc.Bytes())
turnSendable++
case "input_file":
if contentPart, ok := responsesInputFileToChatPart(contentItem); ok {
@@ -330,22 +329,22 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
if isUserTurn {
drops.EndTurn(turnSendable)
}
- message = translatorcommon.SetRawArrayItems(message, "content", contentItems)
- } else if content.Type == gjson.String {
- message, _ = sjson.SetBytes(message, "content", content.String())
+ translatorcommon.SetRawArrayItemsDoc(messageDoc, "content", contentItems)
+ } else if content.Type == ojson.String {
+ _ = messageDoc.Set("content", content.String())
}
if role == "assistant" {
reasoningContent := combineOpenAIResponsesReasoning(takePendingReasoningContent(), item.Get("reasoning_content").String())
if reasoningContent != "" {
- message, _ = sjson.SetBytes(message, "reasoning_content", reasoningContent)
+ _ = messageDoc.Set("reasoning_content", reasoningContent)
if isUsableResponsesReasoning(reasoningContent) {
latestReasoningContent = reasoningContent
}
}
}
- messageIndex := appendRegularMessage(message)
+ messageIndex := appendRegularMessage(messageDoc.Bytes())
if role == "assistant" {
mergeableAssistantIndex = messageIndex
}
@@ -364,10 +363,10 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
latestReasoningContent = rc
}
// Buffer consecutive function calls and emit them as one assistant message.
- toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ toolCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
if callId := translatorcommon.ExtractResponsesCallID(item); callId != "" {
- toolCall, _ = sjson.SetBytes(toolCall, "id", callId)
+ _ = toolCallDoc.Set("id", callId)
}
if name := item.Get("name"); name.Exists() {
@@ -377,13 +376,13 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
} else {
functionName = toolIndex.canonicalName(functionName)
}
- toolCall, _ = sjson.SetBytes(toolCall, "function.name", functionName)
+ _ = toolCallDoc.Set("function.name", functionName)
}
if arguments := item.Get("arguments"); arguments.Exists() {
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", arguments.String())
+ _ = toolCallDoc.Set("function.arguments", arguments.String())
}
- pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
+ pendingToolCalls = append(pendingToolCalls, util.ParseGJSONBytesNoCopy(toolCallDoc.Bytes()).Value())
if callID := translatorcommon.ExtractResponsesCallID(item); callID != "" {
pendingToolCallIDs = append(pendingToolCallIDs, callID)
}
@@ -401,15 +400,15 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Orphan outputs (empty call_id or no matching assistant
// tool_calls, e.g. Codex send_message_to_thread cards) must
// not become tool messages. Emit as user text instead.
- appendStandaloneResponsesToolOutputAsUser(item.Get("output"), setFunctionCallOutputContent, appendRegularMessage)
+ appendStandaloneResponsesToolOutputAsUser(item.Get("output"), setFunctionCallOutputContentDoc, appendRegularMessage)
} else {
- toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
- toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
+ toolMessageDoc := ojson.NewDocString(`{"role":"tool","tool_call_id":"","content":""}`)
+ _ = toolMessageDoc.Set("tool_call_id", callID)
delete(awaitingToolOutputs, callID)
if output := item.Get("output"); output.Exists() {
- toolMessage = setFunctionCallOutputContent(toolMessage, output)
+ setFunctionCallOutputContentDoc(toolMessageDoc, output)
}
- appendMessage(toolMessage)
+ appendMessage(toolMessageDoc.Bytes())
}
case "custom_tool_call":
@@ -421,18 +420,18 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Codex freeform tool call replay: wrap the raw input so it
// matches the {"input": string} function shape used when
// converting custom tool definitions.
- toolCall := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
- toolCall, _ = sjson.SetBytes(toolCall, "id", translatorcommon.ExtractResponsesCallID(item))
+ toolCallDoc := ojson.NewDocString(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
+ _ = toolCallDoc.Set("id", translatorcommon.ExtractResponsesCallID(item))
functionName := item.Get("name").String()
if namespace := item.Get("namespace").String(); namespace != "" {
functionName = toolIndex.namespaceName(namespace, functionName)
} else {
functionName = toolIndex.canonicalName(functionName)
}
- toolCall, _ = sjson.SetBytes(toolCall, "function.name", functionName)
- wrappedArgs, _ := sjson.SetBytes([]byte(`{"input":""}`), "input", item.Get("input").String())
- toolCall, _ = sjson.SetBytes(toolCall, "function.arguments", string(wrappedArgs))
- pendingToolCalls = append(pendingToolCalls, gjson.ParseBytes(toolCall).Value())
+ _ = toolCallDoc.Set("function.name", functionName)
+ wrappedArgs, _ := ojson.SetBytes([]byte(`{"input":""}`), "input", item.Get("input").String())
+ _ = toolCallDoc.Set("function.arguments", string(wrappedArgs))
+ pendingToolCalls = append(pendingToolCalls, util.ParseGJSONBytesNoCopy(toolCallDoc.Bytes()).Value())
if callID := translatorcommon.ExtractResponsesCallID(item); callID != "" {
pendingToolCallIDs = append(pendingToolCallIDs, callID)
}
@@ -447,15 +446,15 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
}
if _, awaiting := awaitingToolOutputs[callID]; !awaiting {
- appendStandaloneResponsesToolOutputAsUser(item.Get("output"), setCustomToolCallOutputContent, appendRegularMessage)
+ appendStandaloneResponsesToolOutputAsUser(item.Get("output"), setCustomToolCallOutputContentDoc, appendRegularMessage)
} else {
- toolMessage := []byte(`{"role":"tool","tool_call_id":"","content":""}`)
- toolMessage, _ = sjson.SetBytes(toolMessage, "tool_call_id", callID)
+ toolMessageDoc := ojson.NewDocString(`{"role":"tool","tool_call_id":"","content":""}`)
+ _ = toolMessageDoc.Set("tool_call_id", callID)
delete(awaitingToolOutputs, callID)
if output := item.Get("output"); output.Exists() {
- toolMessage = setCustomToolCallOutputContent(toolMessage, output)
+ setCustomToolCallOutputContentDoc(toolMessageDoc, output)
}
- appendMessage(toolMessage)
+ appendMessage(toolMessageDoc.Bytes())
}
default:
@@ -465,11 +464,11 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
flushPendingToolCalls()
appendPendingReasoningMessage()
- } else if input.Type == gjson.String {
- msg := []byte(`{}`)
- msg, _ = sjson.SetBytes(msg, "role", "user")
- msg, _ = sjson.SetBytes(msg, "content", input.String())
- appendMessage(msg)
+ } else if input.Type == ojson.String {
+ msgDoc := ojson.NewDocString(`{}`)
+ _ = msgDoc.Set("role", "user")
+ _ = msgDoc.Set("content", input.String())
+ appendMessage(msgDoc.Bytes())
}
if len(messages) > 0 {
@@ -478,7 +477,7 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
extraAmbiguous = append(extraAmbiguous, id)
}
messages = translatorcommon.AlignOpenAIToolCallMessages(messages, extraAmbiguous...)
- out, _ = sjson.SetRawBytes(out, "messages", translatorcommon.JoinRawArray(messages))
+ _ = outDoc.SetRawBytes("messages", translatorcommon.JoinRawArray(messages))
}
// Convert tools from responses format to chat completions format.
@@ -487,53 +486,53 @@ func convertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// so merge both sources.
var chatCompletionsTools []interface{}
for _, chatTool := range toolIndex.chatTools() {
- chatCompletionsTools = append(chatCompletionsTools, gjson.ParseBytes(chatTool).Value())
+ chatCompletionsTools = append(chatCompletionsTools, util.ParseGJSONBytesNoCopy(chatTool).Value())
}
if len(chatCompletionsTools) > 0 {
- out, _ = sjson.SetBytes(out, "tools", chatCompletionsTools)
+ _ = outDoc.Set("tools", chatCompletionsTools)
if parallelToolCalls := root.Get("parallel_tool_calls"); parallelToolCalls.Exists() {
- out, _ = sjson.SetBytes(out, "parallel_tool_calls", parallelToolCalls.Bool())
+ _ = outDoc.Set("parallel_tool_calls", parallelToolCalls.Bool())
}
if toolChoice := root.Get("tool_choice"); toolChoice.Exists() {
- out, _ = sjson.SetRawBytes(out, "tool_choice", convertResponsesToolChoiceWithIndex(toolChoice, toolIndex))
+ _ = outDoc.SetRawBytes("tool_choice", convertResponsesToolChoiceWithIndex(toolChoice, toolIndex))
}
}
if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() {
effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))
if effort != "" {
- out, _ = sjson.SetBytes(out, "reasoning_effort", effort)
+ _ = outDoc.Set("reasoning_effort", effort)
}
}
- return out, drops.Err()
+ return outDoc.Bytes(), drops.Err()
}
// responsesInputFileToChatPart maps a Responses input_file part onto a Chat
// Completions file part. It reports false when the part carries neither a file
// id nor inline bytes, because Chat Completions has no field for a bare url.
-func responsesInputFileToChatPart(contentItem gjson.Result) ([]byte, bool) {
+func responsesInputFileToChatPart(contentItem ojson.Result) ([]byte, bool) {
fileID := contentItem.Get("file_id").String()
fileData := contentItem.Get("file_data").String()
if fileID == "" && fileData == "" {
return nil, false
}
- part := []byte(`{"type":"file","file":{}}`)
+ partDoc := ojson.NewDocString(`{"type":"file","file":{}}`)
if fileID != "" {
- part, _ = sjson.SetBytes(part, "file.file_id", fileID)
+ _ = partDoc.Set("file.file_id", fileID)
}
if fileData != "" {
- part, _ = sjson.SetBytes(part, "file.file_data", fileData)
+ _ = partDoc.Set("file.file_data", fileData)
}
if filename := contentItem.Get("filename").String(); filename != "" {
- part, _ = sjson.SetBytes(part, "file.filename", filename)
+ _ = partDoc.Set("file.filename", filename)
}
- return part, true
+ return partDoc.Bytes(), true
}
// responsesInputAudioToChatPart maps a Responses input_audio part onto a Chat
// Completions input_audio part. It reports false when the part has no bytes.
-func responsesInputAudioToChatPart(contentItem gjson.Result) ([]byte, bool) {
+func responsesInputAudioToChatPart(contentItem ojson.Result) ([]byte, bool) {
audio := contentItem.Get("input_audio")
data := audio.Get("data").String()
if data == "" {
@@ -546,19 +545,19 @@ func responsesInputAudioToChatPart(contentItem gjson.Result) ([]byte, bool) {
if format == "" {
format = contentItem.Get("format").String()
}
- part := []byte(`{"type":"input_audio","input_audio":{"data":""}}`)
- part, _ = sjson.SetBytes(part, "input_audio.data", data)
+ partDoc := ojson.NewDocString(`{"type":"input_audio","input_audio":{"data":""}}`)
+ _ = partDoc.Set("input_audio.data", data)
if format != "" {
- part, _ = sjson.SetBytes(part, "input_audio.format", format)
+ _ = partDoc.Set("input_audio.format", format)
}
- return part, true
+ return partDoc.Bytes(), true
}
-func convertResponsesToolChoiceToChatCompletions(toolChoice gjson.Result, inputRawJSON []byte) []byte {
- return convertResponsesToolChoiceWithIndex(toolChoice, newResponsesToolIndex(gjson.ParseBytes(inputRawJSON)))
+func convertResponsesToolChoiceToChatCompletions(toolChoice ojson.Result, inputRawJSON []byte) []byte {
+ return convertResponsesToolChoiceWithIndex(toolChoice, newResponsesToolIndex(util.ParseGJSONBytesNoCopy(inputRawJSON)))
}
-func convertResponsesToolChoiceWithIndex(toolChoice gjson.Result, toolIndex *responsesToolIndex) []byte {
+func convertResponsesToolChoiceWithIndex(toolChoice ojson.Result, toolIndex *responsesToolIndex) []byte {
if !toolChoice.IsObject() {
return []byte(toolChoice.Raw)
}
@@ -566,8 +565,16 @@ func convertResponsesToolChoiceWithIndex(toolChoice gjson.Result, toolIndex *res
choiceType := toolChoice.Get("type").String()
if choiceType == "shell" {
if name := toolIndex.shellName(); name != "" {
- converted, _ := sjson.SetBytes([]byte(`{"type":"function","function":{}}`), "function.name", name)
- return converted
+ choiceDoc := ojson.NewDocString(`{"type":"function","function":{}}`)
+ _ = choiceDoc.Set("function.name", name)
+ return choiceDoc.Bytes()
+ }
+ }
+ if choiceType == util.ResponsesToolSearchType {
+ if name := toolIndex.toolSearchName(); name != "" {
+ choiceDoc := ojson.NewDocString(`{"type":"function","function":{}}`)
+ _ = choiceDoc.Set("function.name", name)
+ return choiceDoc.Bytes()
}
}
if choiceType != "function" && choiceType != "custom" {
@@ -599,43 +606,44 @@ func convertResponsesToolChoiceWithIndex(toolChoice gjson.Result, toolIndex *res
}
converted := []byte(`{"type":"function","function":{"name":""}}`)
- converted, _ = sjson.SetBytes(converted, "function.name", name)
+ converted, _ = ojson.SetBytes(converted, "function.name", name)
return converted
}
-func convertResponsesTextFormatToChatResponseFormat(textFormat gjson.Result) []byte {
+func convertResponsesTextFormatToChatResponseFormat(textFormat ojson.Result) []byte {
formatType := textFormat.Get("type").String()
switch formatType {
case "text", "json_object":
responseFormat := []byte(`{"type":""}`)
- responseFormat, _ = sjson.SetBytes(responseFormat, "type", formatType)
+ responseFormat, _ = ojson.SetBytes(responseFormat, "type", formatType)
return responseFormat
case "json_schema":
- responseFormat := []byte(`{"type":"json_schema","json_schema":{}}`)
+ responseFormatDoc := ojson.NewDocString(`{"type":"json_schema","json_schema":{}}`)
for _, field := range []string{"name", "description", "strict"} {
if value := textFormat.Get(field); value.Exists() {
- responseFormat, _ = sjson.SetBytes(responseFormat, "json_schema."+field, value.Value())
+ _ = responseFormatDoc.Set("json_schema."+field, value.Value())
}
}
if schema := textFormat.Get("schema"); schema.Exists() {
- responseFormat, _ = sjson.SetRawBytes(responseFormat, "json_schema.schema", []byte(schema.Raw))
+ _ = responseFormatDoc.SetRaw("json_schema.schema", schema.Raw)
}
- return responseFormat
+ return responseFormatDoc.Bytes()
default:
return nil
}
}
-func appendStandaloneResponsesToolOutputAsUser(output gjson.Result, setContent func([]byte, gjson.Result) []byte, appendMessage func([]byte) int) {
- userMessage := []byte(`{"role":"user","content":""}`)
+func appendStandaloneResponsesToolOutputAsUser(output ojson.Result, setContentDoc func(*ojson.Doc, ojson.Result), appendMessage func([]byte) int) {
+ userMessageDoc := ojson.NewDocString(`{"role":"user","content":""}`)
if output.Exists() {
- userMessage = setContent(userMessage, output)
+ setContentDoc(userMessageDoc, output)
}
- content := gjson.GetBytes(userMessage, "content")
+ userMessage := userMessageDoc.Bytes()
+ content := ojson.GetBytesNoCopy(userMessage, "content")
if !content.Exists() {
return
}
- if content.Type == gjson.String && strings.TrimSpace(content.String()) == "" {
+ if content.Type == ojson.String && strings.TrimSpace(content.String()) == "" {
return
}
if content.IsArray() && !content.Get("0").Exists() {
@@ -644,14 +652,14 @@ func appendStandaloneResponsesToolOutputAsUser(output gjson.Result, setContent f
appendMessage(userMessage)
}
-func setFunctionCallOutputContent(toolMessage []byte, output gjson.Result) []byte {
+func setFunctionCallOutputContentDoc(toolMessageDoc *ojson.Doc, output ojson.Result) {
structuredContent := output
- if output.Type == gjson.String {
- if !gjson.Valid(output.String()) {
- toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
- return toolMessage
+ if output.Type == ojson.String {
+ if !ojson.Valid(output.String()) {
+ _ = toolMessageDoc.Set("content", output.String())
+ return
}
- structuredContent = gjson.Parse(output.String())
+ structuredContent = ojson.Parse(output.String())
}
if hasChatToolOutputImagePart(structuredContent) {
@@ -659,50 +667,50 @@ func setFunctionCallOutputContent(toolMessage []byte, output gjson.Result) []byt
for _, item := range structuredContent.Array() {
contentItems = append(contentItems, chatToolOutputContentPart(item))
}
- return translatorcommon.SetRawArrayItems(toolMessage, "content", contentItems)
+ translatorcommon.SetRawArrayItemsDoc(toolMessageDoc, "content", contentItems)
+ return
}
- toolMessage, _ = sjson.SetBytes(toolMessage, "content", output.String())
- return toolMessage
+ _ = toolMessageDoc.Set("content", output.String())
}
-func setCustomToolCallOutputContent(toolMessage []byte, output gjson.Result) []byte {
+func setCustomToolCallOutputContentDoc(toolMessageDoc *ojson.Doc, output ojson.Result) {
structuredContent := output
- if output.Type == gjson.String && gjson.Valid(output.String()) {
- structuredContent = gjson.Parse(output.String())
+ if output.Type == ojson.String && ojson.Valid(output.String()) {
+ structuredContent = ojson.Parse(output.String())
}
if hasChatToolOutputImagePart(structuredContent) {
- return setFunctionCallOutputContent(toolMessage, output)
+ setFunctionCallOutputContentDoc(toolMessageDoc, output)
+ return
}
- toolMessage, _ = sjson.SetBytes(toolMessage, "content", responsesToolOutputText(output))
- return toolMessage
+ _ = toolMessageDoc.Set("content", responsesToolOutputText(output))
}
-func chatToolOutputContentPart(item gjson.Result) []byte {
+func chatToolOutputContentPart(item ojson.Result) []byte {
itemType := item.Get("type").String()
switch itemType {
case "text", "input_text", "output_text":
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", item.Get("text").String())
+ part, _ = ojson.SetBytes(part, "text", item.Get("text").String())
return part
case "image_url", "input_image":
imageURL, detail, ok := chatToolOutputImageFields(item)
if !ok {
return chatToolOutputFallbackPart(item)
}
- part := []byte(`{"type":"image_url","image_url":{"url":""}}`)
- part, _ = sjson.SetBytes(part, "image_url.url", imageURL)
+ partDoc := ojson.NewDocString(`{"type":"image_url","image_url":{"url":""}}`)
+ _ = partDoc.Set("image_url.url", imageURL)
if detail != "" {
- part, _ = sjson.SetBytes(part, "image_url.detail", detail)
+ _ = partDoc.Set("image_url.detail", detail)
}
- return part
+ return partDoc.Bytes()
default:
return chatToolOutputFallbackPart(item)
}
}
-func hasChatToolOutputImagePart(content gjson.Result) bool {
+func hasChatToolOutputImagePart(content ojson.Result) bool {
if !content.IsArray() {
return false
}
@@ -710,12 +718,12 @@ func hasChatToolOutputImagePart(content gjson.Result) bool {
hasImage := false
for _, item := range content.Array() {
itemType := item.Get("type")
- if itemType.Type != gjson.String {
+ if itemType.Type != ojson.String {
continue
}
switch itemType.String() {
case "text", "input_text", "output_text":
- if item.Get("text").Type != gjson.String {
+ if item.Get("text").Type != ojson.String {
return false
}
case "image_url", "input_image":
@@ -728,9 +736,9 @@ func hasChatToolOutputImagePart(content gjson.Result) bool {
return hasImage
}
-func chatToolOutputImageFields(item gjson.Result) (imageURL, detail string, ok bool) {
- var imageURLValue gjson.Result
- var detailValue gjson.Result
+func chatToolOutputImageFields(item ojson.Result) (imageURL, detail string, ok bool) {
+ var imageURLValue ojson.Result
+ var detailValue ojson.Result
switch item.Get("type").String() {
case "image_url":
imageURLValue = item.Get("image_url.url")
@@ -742,7 +750,7 @@ func chatToolOutputImageFields(item gjson.Result) (imageURL, detail string, ok b
return "", "", false
}
- if imageURLValue.Type != gjson.String {
+ if imageURLValue.Type != ojson.String {
return "", "", false
}
imageURL = strings.TrimSpace(imageURLValue.String())
@@ -757,11 +765,11 @@ func chatToolOutputImageFields(item gjson.Result) (imageURL, detail string, ok b
return imageURL, detail, true
}
-func normalizeChatImageDetail(detailValue gjson.Result) (string, bool) {
+func normalizeChatImageDetail(detailValue ojson.Result) (string, bool) {
if !detailValue.Exists() {
return "", true
}
- if detailValue.Type != gjson.String {
+ if detailValue.Type != ojson.String {
return "", false
}
@@ -777,20 +785,20 @@ func normalizeChatImageDetail(detailValue gjson.Result) (string, bool) {
}
}
-func chatToolOutputFallbackPart(item gjson.Result) []byte {
+func chatToolOutputFallbackPart(item ojson.Result) []byte {
text := item.Raw
- if item.Type == gjson.String || text == "" {
+ if item.Type == ojson.String || text == "" {
text = item.String()
}
part := []byte(`{"type":"text","text":""}`)
- part, _ = sjson.SetBytes(part, "text", text)
+ part, _ = ojson.SetBytes(part, "text", text)
return part
}
-func collectOpenAIResponsesReasoningContent(item gjson.Result) string {
+func collectOpenAIResponsesReasoningContent(item ojson.Result) string {
var reasoningText strings.Builder
if summary := item.Get("summary"); summary.Exists() && summary.IsArray() {
- summary.ForEach(func(_, summaryItem gjson.Result) bool {
+ summary.ForEach(func(_, summaryItem ojson.Result) bool {
if summaryItem.Get("type").String() != "summary_text" {
return true
}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go
index 878567cd5a9..35903980766 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_request_test.go
@@ -7,11 +7,12 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
func prettyJSONForTest(raw []byte) string {
- if !gjson.ValidBytes(raw) {
+ if !ojson.ValidBytes(raw) {
return string(raw)
}
var out bytes.Buffer
@@ -35,7 +36,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFu
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- msgs := gjson.GetBytes(out, "messages")
+ msgs := ojson.GetBytesNoCopy(out, "messages")
if !msgs.Exists() || !msgs.IsArray() {
t.Fatalf("messages should be an array")
}
@@ -43,23 +44,23 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergeConsecutiveFu
t.Fatalf("messages count = %d, want %d", got, 3)
}
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want %q", got, "assistant")
}
- if got := len(gjson.GetBytes(out, "messages.0.tool_calls").Array()); got != 2 {
+ if got := len(ojson.GetBytesNoCopy(out, "messages.0.tool_calls").Array()); got != 2 {
t.Fatalf("messages.0.tool_calls length = %d, want %d", got, 2)
}
- if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String(); got != "exec_command:0" {
t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "exec_command:0")
}
- if got := gjson.GetBytes(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.1.id").String(); got != "exec_command:1" {
t.Fatalf("messages.0.tool_calls.1.id = %q, want %q", got, "exec_command:1")
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "exec_command:0" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != "exec_command:0" {
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "exec_command:0")
}
- if got := gjson.GetBytes(out, "messages.2.tool_call_id").String(); got != "exec_command:1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.tool_call_id").String(); got != "exec_command:1" {
t.Fatalf("messages.2.tool_call_id = %q, want %q", got, "exec_command:1")
}
}
@@ -77,13 +78,13 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SplitFunctionCalls
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := len(gjson.GetBytes(out, "messages").Array()); got != 3 {
+ if got := len(ojson.GetBytesNoCopy(out, "messages").Array()); got != 3 {
t.Fatalf("messages count = %d, want %d", got, 3)
}
- if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_a" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String(); got != "call_a" {
t.Fatalf("messages.0.tool_calls.0.id = %q, want %q", got, "call_a")
}
- if got := gjson.GetBytes(out, "messages.2.tool_calls.0.id").String(); got != "call_b" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.tool_calls.0.id").String(); got != "call_b" {
t.Fatalf("messages.2.tool_calls.0.id = %q, want %q", got, "call_b")
}
}
@@ -102,25 +103,25 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntil
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k2.6", raw, true)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := len(gjson.GetBytes(out, "messages").Array()); got != 4 {
+ if got := len(ojson.GetBytesNoCopy(out, "messages").Array()); got != 4 {
t.Fatalf("messages count = %d, want %d", got, 4)
}
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want %q", got, "assistant")
}
- if got := gjson.GetBytes(out, "messages.1.role").String(); got != "tool" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.role").String(); got != "tool" {
t.Fatalf("messages.1.role = %q, want %q", got, "tool")
}
- if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_x" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.tool_call_id").String(); got != "call_x" {
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_x")
}
- if got := gjson.GetBytes(out, "messages.2.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.role").String(); got != "user" {
t.Fatalf("messages.2.role = %q, want %q", got, "user")
}
- if got := gjson.GetBytes(out, "messages.2.content").String(); got != "Approved command prefix saved" {
+ if got := ojson.GetBytesNoCopy(out, "messages.2.content").String(); got != "Approved command prefix saved" {
t.Fatalf("messages.2.content = %q, want %q", got, "Approved command prefix saved")
}
- if got := gjson.GetBytes(out, "messages.3.content").String(); got != "next" {
+ if got := ojson.GetBytesNoCopy(out, "messages.3.content").String(); got != "next" {
t.Fatalf("messages.3.content = %q, want %q", got, "next")
}
}
@@ -161,7 +162,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_UnwrapsStringified
}`, tt.output))
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("k3", raw, false)
- content := gjson.GetBytes(out, "messages.1.content")
+ content := ojson.GetBytesNoCopy(out, "messages.1.content")
if !content.IsArray() {
t.Fatalf("expected tool content array, got %s; output=%s", content.Raw, out)
}
@@ -200,7 +201,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_UnwrapsStringified
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k3", raw, false)
- content := gjson.GetBytes(out, "messages.1.content")
+ content := ojson.GetBytesNoCopy(out, "messages.1.content")
if !content.IsArray() {
t.Fatalf("expected custom tool content array, got %s; output=%s", content.Raw, out)
}
@@ -236,8 +237,8 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesCustomToo
}`, tt.output))
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k3", raw, false)
- content := gjson.GetBytes(out, "messages.1.content")
- if content.Type != gjson.String {
+ content := ojson.GetBytesNoCopy(out, "messages.1.content")
+ if content.Type != ojson.String {
t.Fatalf("expected custom tool content string, got %s; output=%s", content.Raw, out)
}
if got := content.String(); got != tt.expected {
@@ -263,7 +264,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ConvertsStructured
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("k3", raw, false)
- content := gjson.GetBytes(out, "messages.1.content")
+ content := ojson.GetBytesNoCopy(out, "messages.1.content")
if !content.IsArray() {
t.Fatalf("expected tool content array, got %s; output=%s", content.Raw, out)
}
@@ -304,8 +305,8 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_KeepsNonImageToolO
}`, tt.output))
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("k3", raw, false)
- content := gjson.GetBytes(out, "messages.1.content")
- if content.Type != gjson.String {
+ content := ojson.GetBytesNoCopy(out, "messages.1.content")
+ if content.Type != ojson.String {
t.Fatalf("expected tool content string, got %s; output=%s", content.Raw, out)
}
if got := content.String(); got != tt.output {
@@ -339,19 +340,19 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_AttachesReasoningT
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(out, "messages.#").Int(); got != 2 {
t.Fatalf("messages count = %d, want 2; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want assistant; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.reasoning_content").String(); got != "first line\nsecond line" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.reasoning_content").String(); got != "first line\nsecond line" {
t.Fatalf("messages.0.reasoning_content = %q, want %q; output=%s", got, "first line\nsecond line", out)
}
- if got := gjson.GetBytes(out, "messages.0.content.0.text").String(); got != "answer" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.text").String(); got != "answer" {
t.Fatalf("messages.0.content.0.text = %q, want answer; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.1.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.role").String(); got != "user" {
t.Fatalf("messages.1.role = %q, want user; output=%s", got, out)
}
}
@@ -376,7 +377,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesAssistant
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k3", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if got := len(messages); got != 2 {
t.Fatalf("messages count = %d, want 2; output=%s", got, out)
}
@@ -410,7 +411,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DoesNotMergeToolCa
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k3", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if got := len(messages); got != 4 {
t.Fatalf("messages count = %d, want 4; output=%s", got, out)
}
@@ -437,7 +438,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MergesDistinctReas
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k3", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if got := len(messages); got != 1 {
t.Fatalf("messages count = %d, want 1; output=%s", got, out)
}
@@ -460,7 +461,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ReplacesUnavailabl
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("kimi-k3", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if got := len(messages); got != 1 {
t.Fatalf("messages count = %d, want 1; output=%s", got, out)
}
@@ -486,19 +487,19 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_AttachesReasoningT
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, true)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(out, "messages.#").Int(); got != 2 {
t.Fatalf("messages count = %d, want 2; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want assistant; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.reasoning_content").String(); got != "tool reasoning" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.reasoning_content").String(); got != "tool reasoning" {
t.Fatalf("messages.0.reasoning_content = %q, want tool reasoning; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.tool_calls.0.id").String(); got != "call_1" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.id").String(); got != "call_1" {
t.Fatalf("messages.0.tool_calls.0.id = %q, want call_1; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.1.role").String(); got != "tool" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.role").String(); got != "tool" {
t.Fatalf("messages.1.role = %q, want tool; output=%s", got, out)
}
}
@@ -515,16 +516,16 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_KeepsReasoningBefo
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "messages.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(out, "messages.#").Int(); got != 2 {
t.Fatalf("messages count = %d, want 2; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.role").String(); got != "assistant" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.role").String(); got != "assistant" {
t.Fatalf("messages.0.role = %q, want assistant; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.0.reasoning_content").String(); got != "[reasoning unavailable]" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.reasoning_content").String(); got != "[reasoning unavailable]" {
t.Fatalf("messages.0.reasoning_content = %q, want placeholder; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "messages.1.role").String(); got != "user" {
+ if got := ojson.GetBytesNoCopy(out, "messages.1.role").String(); got != "user" {
t.Fatalf("messages.1.role = %q, want user; output=%s", got, out)
}
}
@@ -551,7 +552,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesReasoning
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", raw, true)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 8 {
t.Fatalf("messages count = %d, want 8; output=%s", len(messages), out)
}
@@ -591,7 +592,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FallsBackToPlaceho
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("messages count = %d, want 2; output=%s", len(messages), out)
}
@@ -612,7 +613,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_EffortNoneDoesNotI
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-4o", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("messages count = %d, want 2; output=%s", len(messages), out)
}
@@ -636,7 +637,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesReasoning
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 4 {
t.Fatalf("messages count = %d, want 4; output=%s", len(messages), out)
}
@@ -664,7 +665,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ResetsReasoningAcr
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", raw, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 5 {
t.Fatalf("messages count = %d, want 5; output=%s", len(messages), out)
}
@@ -710,19 +711,19 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceT
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != 1 {
t.Fatalf("tools count = %d, want 1; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "function" {
t.Fatalf("tools.0.type = %q, want function; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "mcp__test_mcp__add_numbers" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.name").String(); got != "mcp__test_mcp__add_numbers" {
t.Fatalf("tools.0.function.name = %q, want mcp__test_mcp__add_numbers; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "Add two numbers" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.description").String(); got != "Add two numbers" {
t.Fatalf("tools.0.function.description = %q, want Add two numbers; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.parameters.required.0").String(); got != "a" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.parameters.required.0").String(); got != "a" {
t.Fatalf("tools.0.function.parameters.required.0 = %q, want a; output=%s", got, out)
}
}
@@ -744,8 +745,8 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_QualifiesNamespace
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
- gotHistoryName := gjson.GetBytes(out, "messages.0.tool_calls.0.function.name").String()
- gotDeclaredName := gjson.GetBytes(out, "tools.0.function.name").String()
+ gotHistoryName := ojson.GetBytesNoCopy(out, "messages.0.tool_calls.0.function.name").String()
+ gotDeclaredName := ojson.GetBytesNoCopy(out, "tools.0.function.name").String()
if gotHistoryName != "mcp__github__get_me" {
t.Fatalf("history function name = %q, want mcp__github__get_me; output=%s", gotHistoryName, out)
}
@@ -788,22 +789,22 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FlattensNamespaceC
t.Run(tt.name, func(t *testing.T) {
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", tt.raw, false)
- if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != 1 {
t.Fatalf("tools count = %d, want 1; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "terminal__exec" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.name").String(); got != "terminal__exec" {
t.Fatalf("tool name = %q, want terminal__exec; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "Run a command" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.description").String(); got != "Run a command" {
t.Fatalf("tool description = %q, want Run a command; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.parameters.type").String(); got != "object" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.parameters.type").String(); got != "object" {
t.Fatalf("parameters type = %q, want object; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.parameters.properties.input.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.parameters.properties.input.type").String(); got != "string" {
t.Fatalf("input type = %q, want string; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.parameters.required.0").String(); got != "input" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.parameters.required.0").String(); got != "input" {
t.Fatalf("required parameter = %q, want input; output=%s", got, out)
}
})
@@ -834,10 +835,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesStructure
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "run_command" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.function.name").String(); got != "run_command" {
t.Fatalf("tool_choice.function.name = %q, want run_command; output=%s", got, out)
}
}
@@ -863,13 +864,13 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ConvertsCanonicalR
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", raw, false)
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function; output=%s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.function.name").String(); got != "gateway_echo" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.function.name").String(); got != "gateway_echo" {
t.Fatalf("tool_choice.function.name = %q, want gateway_echo; output=%s", got, string(out))
}
- if gjson.GetBytes(out, "tool_choice.name").Exists() {
+ if ojson.GetBytesNoCopy(out, "tool_choice.name").Exists() {
t.Fatalf("tool_choice.name should be absent at top-level; output=%s", string(out))
}
}
@@ -898,16 +899,16 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ConvertsNamespaceA
}`)
outNamespace, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", rawNamespace, false)
- if got := gjson.GetBytes(outNamespace, "tool_choice.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(outNamespace, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function; output=%s", got, string(outNamespace))
}
- if got := gjson.GetBytes(outNamespace, "tool_choice.function.name").String(); got != "service_tools__lookup" {
+ if got := ojson.GetBytesNoCopy(outNamespace, "tool_choice.function.name").String(); got != "service_tools__lookup" {
t.Fatalf("tool_choice.function.name = %q, want service_tools__lookup; output=%s", got, string(outNamespace))
}
- if declaredToolName := gjson.GetBytes(outNamespace, "tools.0.function.name").String(); declaredToolName != gjson.GetBytes(outNamespace, "tool_choice.function.name").String() {
- t.Fatalf("tool_choice.function.name (%q) must match declared tools.0.function.name (%q); output=%s", gjson.GetBytes(outNamespace, "tool_choice.function.name").String(), declaredToolName, string(outNamespace))
+ if declaredToolName := ojson.GetBytesNoCopy(outNamespace, "tools.0.function.name").String(); declaredToolName != ojson.GetBytesNoCopy(outNamespace, "tool_choice.function.name").String() {
+ t.Fatalf("tool_choice.function.name (%q) must match declared tools.0.function.name (%q); output=%s", ojson.GetBytesNoCopy(outNamespace, "tool_choice.function.name").String(), declaredToolName, string(outNamespace))
}
- if gjson.GetBytes(outNamespace, "tool_choice.name").Exists() {
+ if ojson.GetBytesNoCopy(outNamespace, "tool_choice.name").Exists() {
t.Fatalf("tool_choice.name should be absent at top-level; output=%s", string(outNamespace))
}
@@ -935,13 +936,13 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ConvertsNamespaceA
}`)
outExplicitNamespace, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", rawExplicitNamespace, false)
- if got := gjson.GetBytes(outExplicitNamespace, "tool_choice.function.name").String(); got != "service_tools__lookup" {
+ if got := ojson.GetBytesNoCopy(outExplicitNamespace, "tool_choice.function.name").String(); got != "service_tools__lookup" {
t.Fatalf("explicit namespace tool_choice.function.name = %q, want service_tools__lookup; output=%s", got, string(outExplicitNamespace))
}
- if gjson.GetBytes(outExplicitNamespace, "tool_choice.namespace").Exists() {
+ if ojson.GetBytesNoCopy(outExplicitNamespace, "tool_choice.namespace").Exists() {
t.Fatalf("tool_choice.namespace should be absent at top-level; output=%s", string(outExplicitNamespace))
}
- if gjson.GetBytes(outExplicitNamespace, "tool_choice.name").Exists() {
+ if ojson.GetBytesNoCopy(outExplicitNamespace, "tool_choice.name").Exists() {
t.Fatalf("tool_choice.name should be absent at top-level; output=%s", string(outExplicitNamespace))
}
@@ -962,10 +963,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ConvertsNamespaceA
}`)
outCustom, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", rawCustom, false)
- if got := gjson.GetBytes(outCustom, "tool_choice.type").String(); got != "function" {
+ if got := ojson.GetBytesNoCopy(outCustom, "tool_choice.type").String(); got != "function" {
t.Fatalf("tool_choice.type = %q, want function; output=%s", got, string(outCustom))
}
- if got := gjson.GetBytes(outCustom, "tool_choice.function.name").String(); got != "patch_runner" {
+ if got := ojson.GetBytesNoCopy(outCustom, "tool_choice.function.name").String(); got != "patch_runner" {
t.Fatalf("tool_choice.function.name = %q, want patch_runner; output=%s", got, string(outCustom))
}
@@ -983,7 +984,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ConvertsNamespaceA
"tool_choice": ` + scalar + `
}`)
outScalar, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", rawScalar, false)
- if got := gjson.GetBytes(outScalar, "tool_choice").Raw; got != scalar {
+ if got := ojson.GetBytesNoCopy(outScalar, "tool_choice").Raw; got != scalar {
t.Fatalf("tool_choice = %q, want %s; output=%s", got, scalar, string(outScalar))
}
}
@@ -1018,7 +1019,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_OmitsToolSettingsW
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("grok-4.5", tt.raw, false)
for _, field := range []string{"tools", "tool_choice", "parallel_tool_calls"} {
- if got := gjson.GetBytes(out, field); got.Exists() {
+ if got := ojson.GetBytesNoCopy(out, field); got.Exists() {
t.Fatalf("%s should be omitted without tools; output=%s", field, out)
}
}
@@ -1040,7 +1041,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesParallelT
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("grok-4.5", raw, false)
- if got := gjson.GetBytes(out, "parallel_tool_calls"); !got.Exists() || got.Bool() {
+ if got := ojson.GetBytesNoCopy(out, "parallel_tool_calls"); !got.Exists() || got.Bool() {
t.Fatalf("parallel_tool_calls = %v, want false; output=%s", got.Value(), out)
}
}
@@ -1067,25 +1068,25 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesJSONSchem
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
- if got := gjson.GetBytes(out, "response_format.type").String(); got != "json_schema" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.type").String(); got != "json_schema" {
t.Fatalf("response_format.type = %q, want json_schema; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema.name").String(); got != "answer" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema.name").String(); got != "answer" {
t.Fatalf("response_format.json_schema.name = %q, want answer; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema.description").String(); got != "Structured answer" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema.description").String(); got != "Structured answer" {
t.Fatalf("response_format.json_schema.description = %q, want Structured answer; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema.strict"); !got.Exists() || !got.Bool() {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema.strict"); !got.Exists() || !got.Bool() {
t.Fatalf("response_format.json_schema.strict = %v, want true; output=%s", got.Value(), out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema.schema.properties.ok.type").String(); got != "boolean" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema.schema.properties.ok.type").String(); got != "boolean" {
t.Fatalf("response_format.json_schema.schema.properties.ok.type = %q, want boolean; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema.schema.required.0").String(); got != "ok" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema.schema.required.0").String(); got != "ok" {
t.Fatalf("response_format.json_schema.schema.required.0 = %q, want ok; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema.schema.additionalProperties"); !got.Exists() || got.Bool() {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema.schema.additionalProperties"); !got.Exists() || got.Bool() {
t.Fatalf("response_format.json_schema.schema.additionalProperties = %v, want false; output=%s", got.Value(), out)
}
}
@@ -1095,10 +1096,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesJSONObjec
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
- if got := gjson.GetBytes(out, "response_format.type").String(); got != "json_object" {
+ if got := ojson.GetBytesNoCopy(out, "response_format.type").String(); got != "json_object" {
t.Fatalf("response_format.type = %q, want json_object; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "response_format.json_schema"); got.Exists() {
+ if got := ojson.GetBytesNoCopy(out, "response_format.json_schema"); got.Exists() {
t.Fatalf("response_format.json_schema should be omitted; output=%s", out)
}
}
@@ -1108,7 +1109,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_OmitsResponseForma
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
- if got := gjson.GetBytes(out, "response_format"); got.Exists() {
+ if got := ojson.GetBytesNoCopy(out, "response_format"); got.Exists() {
t.Fatalf("response_format should be omitted, got %s; output=%s", got.Raw, out)
}
}
@@ -1143,10 +1144,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_NormalizesInputIma
}`, tt.detailJSON))
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", raw, false)
- if got := gjson.GetBytes(out, "messages.0.content.0.image_url.url").String(); got != "https://example.com/image.png" {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content.0.image_url.url").String(); got != "https://example.com/image.png" {
t.Fatalf("image URL = %q, want https://example.com/image.png; output=%s", got, out)
}
- detail := gjson.GetBytes(out, "messages.0.content.0.image_url.detail")
+ detail := ojson.GetBytesNoCopy(out, "messages.0.content.0.image_url.detail")
if tt.expectedDetail == "" {
if detail.Exists() {
t.Fatalf("image detail should be omitted, got %q; output=%s", detail.String(), out)
@@ -1180,16 +1181,16 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DeduplicatesToolsA
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != 1 {
t.Fatalf("tools count = %d, want 1; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "get_time" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.name").String(); got != "get_time" {
t.Fatalf("tools.0.function.name = %q, want get_time; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.description").String(); got != "authoritative top-level definition" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.description").String(); got != "authoritative top-level definition" {
t.Fatalf("tools.0.function.description = %q, want the top-level definition to win; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.parameters.properties.timezone.type").String(); got != "string" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.parameters.properties.timezone.type").String(); got != "string" {
t.Fatalf("tools.0.function.parameters should come from the top-level definition; output=%s", out)
}
}
@@ -1213,10 +1214,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DeduplicatesNamesp
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false)
t.Logf("output json:\n%s", prettyJSONForTest(out))
- if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != 1 {
t.Fatalf("tools count = %d, want 1; output=%s", got, out)
}
- if got := gjson.GetBytes(out, "tools.0.function.name").String(); got != "editor__apply_patch" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.function.name").String(); got != "editor__apply_patch" {
t.Fatalf("tools.0.function.name = %q, want editor__apply_patch; output=%s", got, out)
}
}
@@ -1244,11 +1245,11 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_KeepsDistinctTools
t.Logf("output json:\n%s", prettyJSONForTest(out))
want := []string{"get_time", "get_weather", "get_date"}
- if got := gjson.GetBytes(out, "tools.#").Int(); got != int64(len(want)) {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != int64(len(want)) {
t.Fatalf("tools count = %d, want %d; output=%s", got, len(want), out)
}
for i, wantName := range want {
- got := gjson.GetBytes(out, fmt.Sprintf("tools.%d.function.name", i)).String()
+ got := ojson.GetBytesNoCopy(out, fmt.Sprintf("tools.%d.function.name", i)).String()
if got != wantName {
t.Fatalf("tools.%d.function.name = %q, want %q; output=%s", i, got, wantName, out)
}
@@ -1334,11 +1335,11 @@ func TestSplitResponsesQualifiedFunctionCallFromRequest_MatchesMergedToolIdentit
]
}`)
- merged := mergeResponsesRequestChatTools(gjson.ParseBytes(raw))
+ merged := mergeResponsesRequestChatTools(util.ParseGJSONBytesNoCopy(raw))
if len(merged) != 1 {
t.Fatalf("merged tool count = %d, want 1", len(merged))
}
- emitted := gjson.GetBytes(merged[0], "function.name").String()
+ emitted := ojson.GetBytesNoCopy(merged[0], "function.name").String()
name, namespace := splitResponsesQualifiedFunctionCallFromRequest(raw, emitted)
if namespace != "" {
@@ -1383,12 +1384,12 @@ func TestResponsesCustomToolNames_FollowsMergedDeclaration(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- merged := mergeResponsesRequestChatTools(gjson.ParseBytes(tt.raw))
+ merged := mergeResponsesRequestChatTools(util.ParseGJSONBytesNoCopy(tt.raw))
if len(merged) != 1 {
t.Fatalf("merged tool count = %d, want 1", len(merged))
}
// Freeform tools are the ones converted to the single-string shape.
- mergedIsCustom := gjson.GetBytes(merged[0], "function.parameters.properties.input").Exists()
+ mergedIsCustom := ojson.GetBytesNoCopy(merged[0], "function.parameters.properties.input").Exists()
if mergedIsCustom != tt.wantCustom {
t.Fatalf("merged tool custom = %v, want %v", mergedIsCustom, tt.wantCustom)
}
@@ -1421,8 +1422,8 @@ func TestResponsesCustomToolNames_OnlyReportsMergedTools(t *testing.T) {
}`)
mergedNames := make(map[string]struct{})
- for _, chatTool := range mergeResponsesRequestChatTools(gjson.ParseBytes(raw)) {
- mergedNames[gjson.GetBytes(chatTool, "function.name").String()] = struct{}{}
+ for _, chatTool := range mergeResponsesRequestChatTools(util.ParseGJSONBytesNoCopy(raw)) {
+ mergedNames[ojson.GetBytesNoCopy(chatTool, "function.name").String()] = struct{}{}
}
if _, ok := mergedNames["outer__reachable"]; !ok {
t.Fatalf("merged tool names = %v, want outer__reachable", mergedNames)
@@ -1485,7 +1486,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_FunctionCallOutput
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 2 {
t.Fatalf("expected 2 messages (assistant, tool), got %d; output=%s", len(messages), string(out))
}
@@ -1527,7 +1528,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MixedMissingAndExp
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages (assistant, tool_b, tool_a), got %d; output=%s", len(messages), string(out))
}
@@ -1558,7 +1559,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DefersMessageUntil
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
if len(messages) != 3 {
t.Fatalf("expected 3 messages (assistant, tool, user), got %d; output=%s", len(messages), string(out))
}
@@ -1601,7 +1602,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MixedMissingAndExp
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
resultMap := make(map[string]string)
for _, m := range messages {
@@ -1630,7 +1631,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_OrphanFunctionCall
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
delegationFound := false
bashToolFound := false
@@ -1669,7 +1670,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_UnpairedExplicitCa
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
delegationFound := false
bashToolFound := false
@@ -1719,7 +1720,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_CapsLongNamespaceT
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", raw, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 4 {
t.Fatalf("tools count = %d, want 4; output=%s", len(tools), out)
}
@@ -1760,7 +1761,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DisambiguatesTrunc
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", raw, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", len(tools), out)
}
@@ -1797,7 +1798,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DisambiguatesTrunc
}`)
replayOut, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", merged, false)
replayedName := ""
- for _, m := range gjson.GetBytes(replayOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(replayOut, "messages").Array() {
if m.Get("role").String() == "assistant" {
replayedName = m.Get("tool_calls.0.function.name").String()
}
@@ -1834,7 +1835,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongDeclarationDoe
"input": [{"role":"user","content":"hi"}],
"tools": `+toolsJSON+`
}`), false)
- emitted := gjson.GetBytes(out, "tools").Array()
+ emitted := ojson.GetBytesNoCopy(out, "tools").Array()
if len(emitted) != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", len(emitted), out)
}
@@ -1861,7 +1862,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongDeclarationDoe
"tools": ` + toolsJSON + `
}`)
replayOut, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", replay, false)
- for _, m := range gjson.GetBytes(replayOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(replayOut, "messages").Array() {
if m.Get("role").String() == "assistant" {
if got := m.Get("tool_calls.0.function.name").String(); got != flatName {
t.Fatalf("replayed flat call resolved to %q, want %q; output=%s", got, flatName, replayOut)
@@ -1874,7 +1875,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongDeclarationDoe
"tools": `+toolsJSON+`,
"tool_choice": {"type":"function","function":{"name":"`+flatName+`"}}
}`), false)
- if got := gjson.GetBytes(forcedOut, "tool_choice.function.name").String(); got != flatName {
+ if got := ojson.GetBytesNoCopy(forcedOut, "tool_choice.function.name").String(); got != flatName {
t.Fatalf("tool_choice for the flat tool resolved to %q, want %q; output=%s", got, flatName, forcedOut)
}
@@ -1890,7 +1891,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongDeclarationDoe
"tools": ` + toolsJSON + `
}`)
longReplayOut, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", longReplay, false)
- for _, m := range gjson.GetBytes(longReplayOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(longReplayOut, "messages").Array() {
if m.Get("role").String() == "assistant" {
if got := m.Get("tool_calls.0.function.name").String(); got != suffixed {
t.Fatalf("replayed long-qualified call resolved to %q, want %q; output=%s", got, suffixed, longReplayOut)
@@ -1928,14 +1929,14 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_AmbiguousLongLocal
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", replay, false)
declaredAliases := map[string]bool{}
- for _, tool := range gjson.GetBytes(out, "tools").Array() {
+ for _, tool := range ojson.GetBytesNoCopy(out, "tools").Array() {
declaredAliases[tool.Get("function.name").String()] = true
}
if len(declaredAliases) != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", len(declaredAliases), out)
}
replayedName := ""
- for _, m := range gjson.GetBytes(out, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(out, "messages").Array() {
if m.Get("role").String() == "assistant" {
replayedName = m.Get("tool_calls.0.function.name").String()
}
@@ -1975,7 +1976,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongAliasDoesNotDi
"input": [{"role":"user","content":"hi"}],
"tools": `+toolsJSON+`
}`), false)
- emitted := gjson.GetBytes(out, "tools").Array()
+ emitted := ojson.GetBytesNoCopy(out, "tools").Array()
if len(emitted) != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", len(emitted), out)
}
@@ -1999,7 +2000,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongAliasDoesNotDi
"tools": ` + toolsJSON + `
}`)
replayOut, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", replay, false)
- for _, m := range gjson.GetBytes(replayOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(replayOut, "messages").Array() {
if m.Get("role").String() == "assistant" {
if got := m.Get("tool_calls.0.function.name").String(); got != namespacedAlias {
t.Fatalf("replayed bare local name resolved to %q, want the namespaced alias %q; output=%s", got, namespacedAlias, replayOut)
@@ -2013,7 +2014,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_LongAliasDoesNotDi
"tools": `+toolsJSON+`,
"tool_choice": {"type":"function","function":{"name":"`+localName+`"}}
}`), false)
- if got := gjson.GetBytes(forcedOut, "tool_choice.function.name").String(); got != namespacedAlias {
+ if got := ojson.GetBytesNoCopy(forcedOut, "tool_choice.function.name").String(); got != namespacedAlias {
t.Fatalf("tool_choice bare local name resolved to %q, want the namespaced alias %q; output=%s", got, namespacedAlias, forcedOut)
}
}
@@ -2050,7 +2051,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SharedLocalNameIsN
"input": [{"role":"user","content":"hi"}],
"tools": `+toolsJSON+`
}`), false)
- emitted := gjson.GetBytes(out, "tools").Array()
+ emitted := ojson.GetBytesNoCopy(out, "tools").Array()
if len(emitted) != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", len(emitted), out)
}
@@ -2087,7 +2088,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SharedLocalNameIsN
"tools": `+toolsJSON+`
}`), false)
got := ""
- for _, m := range gjson.GetBytes(namespacedOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(namespacedOut, "messages").Array() {
if m.Get("role").String() == "assistant" {
got = m.Get("tool_calls.0.function.name").String()
}
@@ -2106,7 +2107,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SharedLocalNameIsN
],
"tools": `+toolsJSON+`
}`), false)
- for _, m := range gjson.GetBytes(bareOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(bareOut, "messages").Array() {
if m.Get("role").String() != "assistant" {
continue
}
@@ -2123,7 +2124,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SharedLocalNameIsN
"tools": `+toolsJSON+`,
"tool_choice": {"type":"function","function":{"name":"`+sharedLocal+`"}}
}`), false)
- if got := gjson.GetBytes(bareForced, "tool_choice.function.name").String(); aliases[got] {
+ if got := ojson.GetBytesNoCopy(bareForced, "tool_choice.function.name").String(); aliases[got] {
t.Fatalf("ambiguous tool_choice resolved to declared alias %q, silently invoking one namespace's tool; output=%s", got, bareForced)
}
forcedAlpha, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("z-ai/glm-5.3-free", []byte(`{
@@ -2131,7 +2132,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_SharedLocalNameIsN
"tools": `+toolsJSON+`,
"tool_choice": {"type":"function","namespace":"mcp__beta","function":{"name":"`+sharedLocal+`"}}
}`), false)
- if got := gjson.GetBytes(forcedAlpha, "tool_choice.function.name").String(); got != betaAlias {
+ if got := ojson.GetBytesNoCopy(forcedAlpha, "tool_choice.function.name").String(); got != betaAlias {
t.Fatalf("namespaced tool_choice resolved to %q, want %q; output=%s", got, betaAlias, forcedAlpha)
}
}
@@ -2170,7 +2171,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_QualifiedIdentityO
"input": [{"role":"user","content":"hi"}],
"tools": `+toolsJSON+`
}`), false)
- emitted := gjson.GetBytes(out, "tools").Array()
+ emitted := ojson.GetBytesNoCopy(out, "tools").Array()
if len(emitted) != 2 {
t.Fatalf("tools count = %d, want 2; output=%s", len(emitted), out)
}
@@ -2193,7 +2194,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_QualifiedIdentityO
],
"tools": `+toolsJSON+`
}`), false)
- for _, m := range gjson.GetBytes(bareOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(bareOut, "messages").Array() {
if m.Get("role").String() != "assistant" {
continue
}
@@ -2206,7 +2207,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_QualifiedIdentityO
"tools": `+toolsJSON+`,
"tool_choice": {"type":"function","function":{"name":"`+qualified+`"}}
}`), false)
- if got := gjson.GetBytes(bareForced, "tool_choice.function.name").String(); got != alphaAlias {
+ if got := ojson.GetBytesNoCopy(bareForced, "tool_choice.function.name").String(); got != alphaAlias {
t.Fatalf("tool_choice bare qualified name resolved to %q, want %q; output=%s", got, alphaAlias, bareForced)
}
@@ -2227,7 +2228,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_QualifiedIdentityO
"tools": `+toolsJSON+`
}`), false)
got := ""
- for _, m := range gjson.GetBytes(namespacedOut, "messages").Array() {
+ for _, m := range ojson.GetBytesNoCopy(namespacedOut, "messages").Array() {
if m.Get("role").String() == "assistant" {
got = m.Get("tool_calls.0.function.name").String()
}
@@ -2253,7 +2254,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_IncompleteToolCall
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2288,7 +2289,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_CompleteToolCallsD
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2328,7 +2329,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MixedEmptyIDDoesNo
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2357,7 +2358,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DuplicateCallIDDoe
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2386,7 +2387,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DuplicateOutputCal
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2418,7 +2419,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_DuplicateCustomOut
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2452,7 +2453,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MultipleOutputsWit
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2501,7 +2502,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MultipleOutputsWit
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4.1-flash", inputJSON, false)
- messages := gjson.GetBytes(out, "messages").Array()
+ messages := ojson.GetBytesNoCopy(out, "messages").Array()
roles := make([]string, 0, len(messages))
for _, m := range messages {
@@ -2536,10 +2537,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsMaxOutputToken
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", raw, false)
- if got := gjson.GetBytes(out, "max_tokens").Int(); got != 1024 {
+ if got := ojson.GetBytesNoCopy(out, "max_tokens").Int(); got != 1024 {
t.Fatalf("max_tokens = %d, want 1024; output=%s", got, string(out))
}
- if gjson.GetBytes(out, "max_completion_tokens").Exists() {
+ if ojson.GetBytesNoCopy(out, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; output=%s", string(out))
}
@@ -2549,10 +2550,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsMaxOutputToken
}`)
outWithoutLimit, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", rawWithoutLimit, false)
- if gjson.GetBytes(outWithoutLimit, "max_completion_tokens").Exists() {
+ if ojson.GetBytesNoCopy(outWithoutLimit, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent when omitted; output=%s", string(outWithoutLimit))
}
- if gjson.GetBytes(outWithoutLimit, "max_tokens").Exists() {
+ if ojson.GetBytesNoCopy(outWithoutLimit, "max_tokens").Exists() {
t.Fatalf("max_tokens should be absent when omitted; output=%s", string(outWithoutLimit))
}
@@ -2563,10 +2564,10 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsMaxOutputToken
}`)
outNull, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", rawNull, false)
- if got := gjson.GetBytes(outNull, "max_tokens"); !got.Exists() || got.Type != gjson.Null {
+ if got := ojson.GetBytesNoCopy(outNull, "max_tokens"); !got.Exists() || got.Type != ojson.Null {
t.Fatalf("max_tokens = %v, want null; output=%s", got, string(outNull))
}
- if gjson.GetBytes(outNull, "max_completion_tokens").Exists() {
+ if ojson.GetBytesNoCopy(outNull, "max_completion_tokens").Exists() {
t.Fatalf("max_completion_tokens should be absent; output=%s", string(outNull))
}
}
@@ -2600,7 +2601,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_NamespaceToolPrefi
}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5.4", raw, false)
- tools := gjson.GetBytes(out, "tools").Array()
+ tools := ojson.GetBytesNoCopy(out, "tools").Array()
if len(tools) != 2 {
t.Fatalf("len(tools) = %d, want 2; output=%s", len(tools), string(out))
}
@@ -2625,7 +2626,7 @@ func TestApplyPatchChatRequestContractAndHistory(t *testing.T) {
if errConvert1 != nil {
t.Fatal(errConvert1)
}
- result := gjson.ParseBytes(body1)
+ result := util.ParseGJSONBytesNoCopy(body1)
tool := result.Get("tools.0.function")
description := tool.Get("description").String()
schema := tool.Get("parameters")
@@ -2641,7 +2642,7 @@ func TestApplyPatchChatRequestContractAndHistory(t *testing.T) {
t.Fatalf("not strict schema: %s", schema.Raw)
}
call := result.Get("messages.0.tool_calls.0")
- if call.Get("function.name").String() != "editor__apply_patch" || gjson.Get(call.Get("function.arguments").String(), "input").String() != "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch" || call.Get("id").String() != "c1" || result.Get("messages.1.tool_call_id").String() != "c1" || result.Get("messages.1.content").String() != "done" {
+ if call.Get("function.name").String() != "editor__apply_patch" || ojson.Get(call.Get("function.arguments").String(), "input").String() != "*** Begin Patch\n*** Add File: a.txt\n+hello\n*** End Patch" || call.Get("id").String() != "c1" || result.Get("messages.1.tool_call_id").String() != "c1" || result.Get("messages.1.content").String() != "done" {
t.Fatalf("history mismatch: %s", result.Raw)
}
}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response.go b/internal/translator/openai/openai/responses/openai_openai-responses_response.go
index 18fe95370f3..e3d2b42fa7a 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_response.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_response.go
@@ -9,9 +9,9 @@ import (
"sync/atomic"
"time"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
type oaiToResponsesStateReasoning struct {
@@ -139,13 +139,13 @@ type responsesRequestEcho struct {
hasMetadata bool
}
-func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
+func extractResponsesRequestEcho(root ojson.Result) responsesRequestEcho {
var e responsesRequestEcho
- root.ForEach(func(key, value gjson.Result) bool {
+ root.ForEach(func(key, value ojson.Result) bool {
switch key.String() {
case "instructions":
if !e.hasInstructions {
- e.instructions, e.hasInstructions = value.String(), true
+ e.instructions, e.hasInstructions = strings.Clone(value.String()), true
}
case "max_output_tokens":
if !e.hasMaxOutputTokens {
@@ -161,7 +161,7 @@ func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
}
case "model":
if !e.hasModel {
- e.model, e.hasModel = value.String(), true
+ e.model, e.hasModel = strings.Clone(value.String()), true
}
case "parallel_tool_calls":
if !e.hasParallelToolCalls {
@@ -169,23 +169,23 @@ func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
}
case "previous_response_id":
if !e.hasPreviousResponseID {
- e.previousResponseID, e.hasPreviousResponseID = value.String(), true
+ e.previousResponseID, e.hasPreviousResponseID = strings.Clone(value.String()), true
}
case "prompt_cache_key":
if !e.hasPromptCacheKey {
- e.promptCacheKey, e.hasPromptCacheKey = value.String(), true
+ e.promptCacheKey, e.hasPromptCacheKey = strings.Clone(value.String()), true
}
case "reasoning":
if !e.hasReasoning {
- e.reasoning, e.hasReasoning = value.Value(), true
+ e.reasoning, e.hasReasoning = ojson.Parse(strings.Clone(value.Raw)).Value(), true
}
case "safety_identifier":
if !e.hasSafetyIdentifier {
- e.safetyIdentifier, e.hasSafetyIdentifier = value.String(), true
+ e.safetyIdentifier, e.hasSafetyIdentifier = strings.Clone(value.String()), true
}
case "service_tier":
if !e.hasServiceTier {
- e.serviceTier, e.hasServiceTier = value.String(), true
+ e.serviceTier, e.hasServiceTier = strings.Clone(value.String()), true
}
case "store":
if !e.hasStore {
@@ -197,15 +197,15 @@ func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
}
case "text":
if !e.hasText {
- e.text, e.hasText = value.Value(), true
+ e.text, e.hasText = ojson.Parse(strings.Clone(value.Raw)).Value(), true
}
case "tool_choice":
if !e.hasToolChoice {
- e.toolChoice, e.hasToolChoice = value.Value(), true
+ e.toolChoice, e.hasToolChoice = ojson.Parse(strings.Clone(value.Raw)).Value(), true
}
case "tools":
if !e.hasTools {
- e.tools, e.hasTools = value.Value(), true
+ e.tools, e.hasTools = ojson.Parse(strings.Clone(value.Raw)).Value(), true
}
case "top_logprobs":
if !e.hasTopLogprobs {
@@ -217,15 +217,15 @@ func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
}
case "truncation":
if !e.hasTruncation {
- e.truncation, e.hasTruncation = value.String(), true
+ e.truncation, e.hasTruncation = strings.Clone(value.String()), true
}
case "user":
if !e.hasUser {
- e.user, e.hasUser = value.Value(), true
+ e.user, e.hasUser = ojson.Parse(strings.Clone(value.Raw)).Value(), true
}
case "metadata":
if !e.hasMetadata {
- e.metadata, e.hasMetadata = value.Value(), true
+ e.metadata, e.hasMetadata = ojson.Parse(strings.Clone(value.Raw)).Value(), true
}
}
return true
@@ -233,68 +233,67 @@ func extractResponsesRequestEcho(root gjson.Result) responsesRequestEcho {
return e
}
-func (e responsesRequestEcho) apply(dst []byte, prefix string) []byte {
+func (e responsesRequestEcho) applyDoc(dstDoc *ojson.Doc, prefix string) {
if e.hasInstructions {
- dst, _ = sjson.SetBytes(dst, prefix+"instructions", e.instructions)
+ _ = dstDoc.Set(prefix+"instructions", e.instructions)
}
if e.hasMaxOutputTokens {
- dst, _ = sjson.SetBytes(dst, prefix+"max_output_tokens", e.maxOutputTokens)
+ _ = dstDoc.Set(prefix+"max_output_tokens", e.maxOutputTokens)
}
if e.hasMaxToolCalls {
- dst, _ = sjson.SetBytes(dst, prefix+"max_tool_calls", e.maxToolCalls)
+ _ = dstDoc.Set(prefix+"max_tool_calls", e.maxToolCalls)
}
if e.hasModel {
- dst, _ = sjson.SetBytes(dst, prefix+"model", e.model)
+ _ = dstDoc.Set(prefix+"model", e.model)
}
if e.hasParallelToolCalls {
- dst, _ = sjson.SetBytes(dst, prefix+"parallel_tool_calls", e.parallelToolCalls)
+ _ = dstDoc.Set(prefix+"parallel_tool_calls", e.parallelToolCalls)
}
if e.hasPreviousResponseID {
- dst, _ = sjson.SetBytes(dst, prefix+"previous_response_id", e.previousResponseID)
+ _ = dstDoc.Set(prefix+"previous_response_id", e.previousResponseID)
}
if e.hasPromptCacheKey {
- dst, _ = sjson.SetBytes(dst, prefix+"prompt_cache_key", e.promptCacheKey)
+ _ = dstDoc.Set(prefix+"prompt_cache_key", e.promptCacheKey)
}
if e.hasReasoning {
- dst, _ = sjson.SetBytes(dst, prefix+"reasoning", e.reasoning)
+ _ = dstDoc.Set(prefix+"reasoning", e.reasoning)
}
if e.hasSafetyIdentifier {
- dst, _ = sjson.SetBytes(dst, prefix+"safety_identifier", e.safetyIdentifier)
+ _ = dstDoc.Set(prefix+"safety_identifier", e.safetyIdentifier)
}
if e.hasServiceTier {
- dst, _ = sjson.SetBytes(dst, prefix+"service_tier", e.serviceTier)
+ _ = dstDoc.Set(prefix+"service_tier", e.serviceTier)
}
if e.hasStore {
- dst, _ = sjson.SetBytes(dst, prefix+"store", e.store)
+ _ = dstDoc.Set(prefix+"store", e.store)
}
if e.hasTemperature {
- dst, _ = sjson.SetBytes(dst, prefix+"temperature", e.temperature)
+ _ = dstDoc.Set(prefix+"temperature", e.temperature)
}
if e.hasText {
- dst, _ = sjson.SetBytes(dst, prefix+"text", e.text)
+ _ = dstDoc.Set(prefix+"text", e.text)
}
if e.hasToolChoice {
- dst, _ = sjson.SetBytes(dst, prefix+"tool_choice", e.toolChoice)
+ _ = dstDoc.Set(prefix+"tool_choice", e.toolChoice)
}
if e.hasTools {
- dst, _ = sjson.SetBytes(dst, prefix+"tools", e.tools)
+ _ = dstDoc.Set(prefix+"tools", e.tools)
}
if e.hasTopLogprobs {
- dst, _ = sjson.SetBytes(dst, prefix+"top_logprobs", e.topLogprobs)
+ _ = dstDoc.Set(prefix+"top_logprobs", e.topLogprobs)
}
if e.hasTopP {
- dst, _ = sjson.SetBytes(dst, prefix+"top_p", e.topP)
+ _ = dstDoc.Set(prefix+"top_p", e.topP)
}
if e.hasTruncation {
- dst, _ = sjson.SetBytes(dst, prefix+"truncation", e.truncation)
+ _ = dstDoc.Set(prefix+"truncation", e.truncation)
}
if e.hasUser {
- dst, _ = sjson.SetBytes(dst, prefix+"user", e.user)
+ _ = dstDoc.Set(prefix+"user", e.user)
}
if e.hasMetadata {
- dst, _ = sjson.SetBytes(dst, prefix+"metadata", e.metadata)
+ _ = dstDoc.Set(prefix+"metadata", e.metadata)
}
- return dst
}
func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) []byte {
@@ -306,17 +305,17 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) [
status = "incomplete"
}
- completed := []byte(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`)
- completed, _ = sjson.SetBytes(completed, "type", eventType)
- completed, _ = sjson.SetBytes(completed, "sequence_number", nextSeq())
- completed, _ = sjson.SetBytes(completed, "response.id", st.ResponseID)
- completed, _ = sjson.SetBytes(completed, "response.created_at", st.Created)
- completed, _ = sjson.SetBytes(completed, "response.status", status)
+ completedDoc := ojson.NewDocString(`{"type":"","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"","background":false,"error":null}}`)
+ _ = completedDoc.Set("type", eventType)
+ _ = completedDoc.Set("sequence_number", nextSeq())
+ _ = completedDoc.Set("response.id", st.ResponseID)
+ _ = completedDoc.Set("response.created_at", st.Created)
+ _ = completedDoc.Set("response.status", status)
if len(incompleteDetails) > 0 {
- completed, _ = sjson.SetRawBytes(completed, "response.incomplete_details", incompleteDetails)
+ _ = completedDoc.SetRawBytes("response.incomplete_details", incompleteDetails)
}
// Inject original request fields into response as per docs/response.completed.json
- completed = st.RequestEcho.apply(completed, "response.")
+ st.RequestEcho.applyDoc(completedDoc, "response.")
type completedOutputItem struct {
index int
@@ -325,10 +324,10 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) [
outputItems := make([]completedOutputItem, 0, len(st.Reasonings)+len(st.MsgItemAdded)+len(st.FuncArgsBuf))
if len(st.Reasonings) > 0 {
for _, r := range st.Reasonings {
- item := []byte(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
- item, _ = sjson.SetBytes(item, "id", r.ReasoningID)
- item, _ = sjson.SetBytes(item, "summary.0.text", r.ReasoningData)
- outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: item})
+ itemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","summary":[{"type":"summary_text","text":""}]}`)
+ _ = itemDoc.Set("id", r.ReasoningID)
+ _ = itemDoc.Set("summary.0.text", r.ReasoningData)
+ outputItems = append(outputItems, completedOutputItem{index: r.OutputIndex, raw: itemDoc.Bytes()})
}
}
if len(st.MsgItemAdded) > 0 {
@@ -341,11 +340,11 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) [
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
msgStatus = "incomplete"
}
- item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
- item, _ = sjson.SetBytes(item, "status", msgStatus)
- item, _ = sjson.SetBytes(item, "content.0.text", txt)
- outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: item})
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("msg_%s_%d", st.ResponseID, i))
+ _ = itemDoc.Set("status", msgStatus)
+ _ = itemDoc.Set("content.0.text", txt)
+ outputItems = append(outputItems, completedOutputItem{index: st.MsgOutputIx[i], raw: itemDoc.Bytes()})
}
}
if len(st.FuncArgsBuf) > 0 {
@@ -370,29 +369,33 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) [
}
continue
}
+ if st.ToolIndex.isToolSearch(name) {
+ outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: toolSearchCallItem(callID, args, toolStatus)})
+ continue
+ }
if st.FuncItemCustom[key] {
- item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
- item, _ = sjson.SetBytes(item, "status", toolStatus)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoc.Set("status", toolStatus)
input := ""
if patchCall := st.ApplyPatchCalls[key]; patchCall != nil {
input = patchCall.Decoder.Input()
} else {
input = unwrapCustomToolInput(args)
}
- item, _ = sjson.SetBytes(item, "input", input)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = st.ToolIndex.applyIdentity(item, name, "")
- outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
+ _ = itemDoc.Set("input", input)
+ _ = itemDoc.Set("call_id", callID)
+ st.ToolIndex.applyIdentityDoc(itemDoc, name, "")
+ outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: itemDoc.Bytes()})
continue
}
- item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
- item, _ = sjson.SetBytes(item, "status", toolStatus)
- item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = st.ToolIndex.applyIdentity(item, name, "")
- outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: item})
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoc.Set("status", toolStatus)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", args)
+ _ = itemDoc.Set("call_id", callID)
+ st.ToolIndex.applyIdentityDoc(itemDoc, name, "")
+ outputItems = append(outputItems, completedOutputItem{index: st.FuncOutputIx[key], raw: itemDoc.Bytes()})
}
}
sort.Slice(outputItems, func(i, j int) bool { return outputItems[i].index < outputItems[j].index })
@@ -401,22 +404,22 @@ func buildResponsesCompletedEvent(st *oaiToResponsesState, nextSeq func() int) [
outputs = append(outputs, item.raw)
}
if len(outputs) > 0 {
- completed, _ = sjson.SetRawBytes(completed, "response.output", translatorcommon.JoinRawArray(outputs))
+ _ = completedDoc.SetRawBytes("response.output", translatorcommon.JoinRawArray(outputs))
}
if st.UsageSeen {
- completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens", st.PromptTokens)
- completed, _ = sjson.SetBytes(completed, "response.usage.input_tokens_details.cached_tokens", st.CachedTokens)
- completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens", st.CompletionTokens)
+ _ = completedDoc.Set("response.usage.input_tokens", st.PromptTokens)
+ _ = completedDoc.Set("response.usage.input_tokens_details.cached_tokens", st.CachedTokens)
+ _ = completedDoc.Set("response.usage.output_tokens", st.CompletionTokens)
if st.ReasoningTokens > 0 {
- completed, _ = sjson.SetBytes(completed, "response.usage.output_tokens_details.reasoning_tokens", st.ReasoningTokens)
+ _ = completedDoc.Set("response.usage.output_tokens_details.reasoning_tokens", st.ReasoningTokens)
}
total := st.TotalTokens
if total == 0 {
total = st.PromptTokens + st.CompletionTokens
}
- completed, _ = sjson.SetBytes(completed, "response.usage.total_tokens", total)
+ _ = completedDoc.Set("response.usage.total_tokens", total)
}
- return emitRespEvent(eventType, completed)
+ return emitRespEvent(eventType, completedDoc.Bytes())
}
// ConvertOpenAIChatCompletionsResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
@@ -452,7 +455,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
rawJSON = bytes.TrimSpace(rawJSON)
if !st.RequestInitialized {
- requestRoot := gjson.ParseBytes(pickRequestJSON(originalRequestRawJSON, requestRawJSON))
+ requestRoot := util.ParseGJSONBytesNoCopy(bytes.Clone(pickRequestJSON(originalRequestRawJSON, requestRawJSON)))
st.ToolIndex = newResponsesToolIndex(requestRoot)
st.RequestEcho = extractResponsesRequestEcho(requestRoot)
st.RequestInitialized = true
@@ -465,7 +468,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
return [][]byte{}
}
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
if !isDone {
obj := root.Get("object")
if obj.Exists() && obj.String() != "" && obj.String() != "chat.completion.chunk" {
@@ -548,11 +551,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
_, isCustomTool := st.CustomToolNames[name]
st.FuncItemCustom[key] = isCustomTool
if st.ToolIndex.isShell(name) {
- o := []byte(`{"type":"response.output_item.added"}`)
- o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
- o, _ = sjson.SetBytes(o, "output_index", outputIndex)
- o, _ = sjson.SetRawBytes(o, "item", shellCallPlaceholder(callID))
- out = append(out, emitRespEvent("response.output_item.added", o))
+ oDoc := ojson.NewDocString(`{"type":"response.output_item.added"}`)
+ _ = oDoc.Set("sequence_number", nextSeq())
+ _ = oDoc.Set("output_index", outputIndex)
+ _ = oDoc.SetRawBytes("item", shellCallPlaceholder(callID))
+ out = append(out, emitRespEvent("response.output_item.added", oDoc.Bytes()))
+ } else if st.ToolIndex.isToolSearch(name) {
+ oDoc := ojson.NewDocString(`{"type":"response.output_item.added"}`)
+ _ = oDoc.Set("sequence_number", nextSeq())
+ _ = oDoc.Set("output_index", outputIndex)
+ _ = oDoc.SetRawBytes("item", toolSearchCallPlaceholder(callID))
+ out = append(out, emitRespEvent("response.output_item.added", oDoc.Bytes()))
} else if isCustomTool {
if st.ToolIndex.isApplyPatch(name) {
d := st.ToolIndex.byChat[name]
@@ -561,26 +570,26 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
Name: d.localName, Namespace: d.namespace, OutputIndex: outputIndex,
}
}
- o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
- o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
- o, _ = sjson.SetBytes(o, "output_index", outputIndex)
- o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("ctc_%s", callID))
- o, _ = sjson.SetBytes(o, "item.call_id", callID)
- o = st.ToolIndex.applyIdentity(o, name, "item")
- out = append(out, emitRespEvent("response.output_item.added", o))
+ oDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"in_progress","input":"","call_id":"","name":""}}`)
+ _ = oDoc.Set("sequence_number", nextSeq())
+ _ = oDoc.Set("output_index", outputIndex)
+ _ = oDoc.Set("item.id", fmt.Sprintf("ctc_%s", callID))
+ _ = oDoc.Set("item.call_id", callID)
+ st.ToolIndex.applyIdentityDoc(oDoc, name, "item")
+ out = append(out, emitRespEvent("response.output_item.added", oDoc.Bytes()))
} else {
- o := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
- o, _ = sjson.SetBytes(o, "sequence_number", nextSeq())
- o, _ = sjson.SetBytes(o, "output_index", outputIndex)
- o, _ = sjson.SetBytes(o, "item.id", fmt.Sprintf("fc_%s", callID))
- o, _ = sjson.SetBytes(o, "item.call_id", callID)
- o = st.ToolIndex.applyIdentity(o, name, "item")
- out = append(out, emitRespEvent("response.output_item.added", o))
+ oDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"in_progress","arguments":"","call_id":"","name":""}}`)
+ _ = oDoc.Set("sequence_number", nextSeq())
+ _ = oDoc.Set("output_index", outputIndex)
+ _ = oDoc.Set("item.id", fmt.Sprintf("fc_%s", callID))
+ _ = oDoc.Set("item.call_id", callID)
+ st.ToolIndex.applyIdentityDoc(oDoc, name, "item")
+ out = append(out, emitRespEvent("response.output_item.added", oDoc.Bytes()))
}
st.FuncItemAdded[key] = true
}
emitPendingFunctionArgs := func(key string) {
- if !st.FuncItemAdded[key] || st.ToolInputError() != nil || st.ToolIndex.isShell(st.FuncNames[key]) {
+ if !st.FuncItemAdded[key] || st.ToolInputError() != nil || st.ToolIndex.isShell(st.FuncNames[key]) || st.ToolIndex.isToolSearch(st.FuncNames[key]) {
return
}
argsBuf := st.FuncArgsBuf[key]
@@ -602,17 +611,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
return
}
callID := st.FuncCallIDs[key]
- ad := []byte(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
- ad, _ = sjson.SetBytes(ad, "sequence_number", nextSeq())
- ad, _ = sjson.SetBytes(ad, "item_id", fmt.Sprintf("fc_%s", callID))
- ad, _ = sjson.SetBytes(ad, "output_index", st.FuncOutputIx[key])
- ad, _ = translatorcommon.SetStringWithoutHTMLEscape(ad, "delta", delta)
- out = append(out, emitRespEvent("response.function_call_arguments.delta", ad))
+ adDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`)
+ _ = adDoc.Set("sequence_number", nextSeq())
+ _ = adDoc.Set("item_id", fmt.Sprintf("fc_%s", callID))
+ _ = adDoc.Set("output_index", st.FuncOutputIx[key])
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(adDoc, "delta", delta)
+ out = append(out, emitRespEvent("response.function_call_arguments.delta", adDoc.Bytes()))
st.FuncArgsSent[key] = len(args)
}
if !st.Started {
- st.ResponseID = root.Get("id").String()
+ st.ResponseID = strings.Clone(root.Get("id").String())
st.Created = root.Get("created").Int()
// reset aggregation state for a new streaming response
st.MsgTextBuf = make(map[int]*strings.Builder)
@@ -646,50 +655,50 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
st.CompletedEmitted = false
st.CompletionPending = false
// response.created
- created := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
- created, _ = sjson.SetBytes(created, "sequence_number", nextSeq())
- created, _ = sjson.SetBytes(created, "response.id", st.ResponseID)
- created, _ = sjson.SetBytes(created, "response.created_at", st.Created)
+ createdDoc := ojson.NewDocString(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
+ _ = createdDoc.Set("sequence_number", nextSeq())
+ _ = createdDoc.Set("response.id", st.ResponseID)
+ _ = createdDoc.Set("response.created_at", st.Created)
requestModelName := translatorcommon.RequestModelName(originalRequestRawJSON, requestRawJSON)
if requestModelName == "" {
requestModelName = modelName
}
if requestModelName != "" {
- created, _ = sjson.SetBytes(created, "response.model", requestModelName)
+ _ = createdDoc.Set("response.model", requestModelName)
}
- out = append(out, emitRespEvent("response.created", created))
+ out = append(out, emitRespEvent("response.created", createdDoc.Bytes()))
- inprog := []byte(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
- inprog, _ = sjson.SetBytes(inprog, "sequence_number", nextSeq())
- inprog, _ = sjson.SetBytes(inprog, "response.id", st.ResponseID)
- inprog, _ = sjson.SetBytes(inprog, "response.created_at", st.Created)
+ inprogDoc := ojson.NewDocString(`{"type":"response.in_progress","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","output":[]}}`)
+ _ = inprogDoc.Set("sequence_number", nextSeq())
+ _ = inprogDoc.Set("response.id", st.ResponseID)
+ _ = inprogDoc.Set("response.created_at", st.Created)
if requestModelName != "" {
- inprog, _ = sjson.SetBytes(inprog, "response.model", requestModelName)
+ _ = inprogDoc.Set("response.model", requestModelName)
}
- out = append(out, emitRespEvent("response.in_progress", inprog))
+ out = append(out, emitRespEvent("response.in_progress", inprogDoc.Bytes()))
st.Started = true
}
stopReasoning := func(text string) {
// Emit reasoning done events
- textDone := []byte(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
- textDone, _ = sjson.SetBytes(textDone, "sequence_number", nextSeq())
- textDone, _ = sjson.SetBytes(textDone, "item_id", st.ReasoningID)
- textDone, _ = sjson.SetBytes(textDone, "output_index", st.ReasoningIndex)
- textDone, _ = sjson.SetBytes(textDone, "text", text)
- out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDone))
- partDone := []byte(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
- partDone, _ = sjson.SetBytes(partDone, "item_id", st.ReasoningID)
- partDone, _ = sjson.SetBytes(partDone, "output_index", st.ReasoningIndex)
- partDone, _ = sjson.SetBytes(partDone, "part.text", text)
- out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDone))
- outputItemDone := []byte(`{"type":"response.output_item.done","item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]},"output_index":0,"sequence_number":0}`)
- outputItemDone, _ = sjson.SetBytes(outputItemDone, "sequence_number", nextSeq())
- outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.id", st.ReasoningID)
- outputItemDone, _ = sjson.SetBytes(outputItemDone, "output_index", st.ReasoningIndex)
- outputItemDone, _ = sjson.SetBytes(outputItemDone, "item.summary.0.text", text)
- out = append(out, emitRespEvent("response.output_item.done", outputItemDone))
+ textDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`)
+ _ = textDoneDoc.Set("sequence_number", nextSeq())
+ _ = textDoneDoc.Set("item_id", st.ReasoningID)
+ _ = textDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = textDoneDoc.Set("text", text)
+ out = append(out, emitRespEvent("response.reasoning_summary_text.done", textDoneDoc.Bytes()))
+ partDoneDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextSeq())
+ _ = partDoneDoc.Set("item_id", st.ReasoningID)
+ _ = partDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = partDoneDoc.Set("part.text", text)
+ out = append(out, emitRespEvent("response.reasoning_summary_part.done", partDoneDoc.Bytes()))
+ outputItemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]},"output_index":0,"sequence_number":0}`)
+ _ = outputItemDoneDoc.Set("sequence_number", nextSeq())
+ _ = outputItemDoneDoc.Set("item.id", st.ReasoningID)
+ _ = outputItemDoneDoc.Set("output_index", st.ReasoningIndex)
+ _ = outputItemDoneDoc.Set("item.summary.0.text", text)
+ out = append(out, emitRespEvent("response.output_item.done", outputItemDoneDoc.Bytes()))
st.Reasonings = append(st.Reasonings, oaiToResponsesStateReasoning{ReasoningID: st.ReasoningID, ReasoningData: text, OutputIndex: st.ReasoningIndex})
st.ReasoningID = ""
@@ -704,33 +713,33 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
if b := st.MsgTextBuf[idx]; b != nil {
fullText = b.String()
}
- done := []byte(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
- done, _ = sjson.SetBytes(done, "sequence_number", nextSeq())
- done, _ = sjson.SetBytes(done, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
- done, _ = sjson.SetBytes(done, "output_index", msgOutputIndex)
- done, _ = sjson.SetBytes(done, "content_index", 0)
- done, _ = sjson.SetBytes(done, "text", fullText)
- out = append(out, emitRespEvent("response.output_text.done", done))
+ doneDoc := ojson.NewDocString(`{"type":"response.output_text.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"text":"","logprobs":[]}`)
+ _ = doneDoc.Set("sequence_number", nextSeq())
+ _ = doneDoc.Set("item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
+ _ = doneDoc.Set("output_index", msgOutputIndex)
+ _ = doneDoc.Set("content_index", 0)
+ _ = doneDoc.Set("text", fullText)
+ out = append(out, emitRespEvent("response.output_text.done", doneDoc.Bytes()))
- partDone := []byte(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- partDone, _ = sjson.SetBytes(partDone, "sequence_number", nextSeq())
- partDone, _ = sjson.SetBytes(partDone, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
- partDone, _ = sjson.SetBytes(partDone, "output_index", msgOutputIndex)
- partDone, _ = sjson.SetBytes(partDone, "content_index", 0)
- partDone, _ = sjson.SetBytes(partDone, "part.text", fullText)
- out = append(out, emitRespEvent("response.content_part.done", partDone))
+ partDoneDoc := ojson.NewDocString(`{"type":"response.content_part.done","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partDoneDoc.Set("sequence_number", nextSeq())
+ _ = partDoneDoc.Set("item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
+ _ = partDoneDoc.Set("output_index", msgOutputIndex)
+ _ = partDoneDoc.Set("content_index", 0)
+ _ = partDoneDoc.Set("part.text", fullText)
+ out = append(out, emitRespEvent("response.content_part.done", partDoneDoc.Bytes()))
msgStatus := "completed"
if _, isInc := incompleteByFinishReason(st.FinishReason); isInc {
msgStatus = "incomplete"
}
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", msgOutputIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
- itemDone, _ = sjson.SetBytes(itemDone, "item.status", msgStatus)
- itemDone, _ = sjson.SetBytes(itemDone, "item.content.0.text", fullText)
- out = append(out, emitRespEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", msgOutputIndex)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
+ _ = itemDoneDoc.Set("item.status", msgStatus)
+ _ = itemDoneDoc.Set("item.content.0.text", fullText)
+ out = append(out, emitRespEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.MsgItemDone[idx] = true
}
@@ -782,7 +791,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
if name == "" {
name, _ = st.ToolIndex.singleCustomName()
}
- if !st.ToolIndex.isApplyPatch(name) && st.FinishReason == "" && (!hasArgs || !gjson.Valid(b.String())) {
+ if !st.ToolIndex.isApplyPatch(name) && st.FinishReason == "" && (!hasArgs || !ojson.Valid(b.String())) {
continue
}
@@ -798,11 +807,13 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
outputIndex := st.FuncOutputIx[key]
toolStatus := "completed"
- args := "{}"
+ rawArgs := ""
if hasArgs {
- args = b.String()
- } else if isIncomplete || !isExplicitToolFinish {
- args = ""
+ rawArgs = b.String()
+ }
+ args := rawArgs
+ if !hasArgs && !isIncomplete && isExplicitToolFinish {
+ args = "{}"
}
if isIncomplete {
toolStatus = "incomplete"
@@ -814,11 +825,21 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
failToolInput(errShellCallItem)
return
}
- event := []byte(`{"type":"response.output_item.done"}`)
- event, _ = sjson.SetBytes(event, "sequence_number", nextSeq())
- event, _ = sjson.SetBytes(event, "output_index", outputIndex)
- event, _ = sjson.SetRawBytes(event, "item", item)
- out = append(out, emitRespEvent("response.output_item.done", event))
+ eventDoc := ojson.NewDocString(`{"type":"response.output_item.done"}`)
+ _ = eventDoc.Set("sequence_number", nextSeq())
+ _ = eventDoc.Set("output_index", outputIndex)
+ _ = eventDoc.SetRawBytes("item", item)
+ out = append(out, emitRespEvent("response.output_item.done", eventDoc.Bytes()))
+ st.FuncItemDone[key] = true
+ st.FuncArgsDone[key] = true
+ continue
+ }
+ if st.ToolIndex.isToolSearch(name) {
+ eventDoc := ojson.NewDocString(`{"type":"response.output_item.done"}`)
+ _ = eventDoc.Set("sequence_number", nextSeq())
+ _ = eventDoc.Set("output_index", outputIndex)
+ _ = eventDoc.SetRawBytes("item", toolSearchCallItem(callID, rawArgs, toolStatus))
+ out = append(out, emitRespEvent("response.output_item.done", eventDoc.Bytes()))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
continue
@@ -839,43 +860,43 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
out = append(out, emitRespEvent("response.custom_tool_call_input.done", translatorcommon.ApplyPatchInputDone(patchCall, input, nextSeq())))
} else {
input = unwrapCustomToolInput(args)
- inputDone := []byte(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
- inputDone, _ = sjson.SetBytes(inputDone, "sequence_number", nextSeq())
- inputDone, _ = sjson.SetBytes(inputDone, "item_id", fmt.Sprintf("ctc_%s", callID))
- inputDone, _ = sjson.SetBytes(inputDone, "output_index", outputIndex)
- inputDone, _ = sjson.SetBytes(inputDone, "input", input)
- out = append(out, emitRespEvent("response.custom_tool_call_input.done", inputDone))
+ inputDoneDoc := ojson.NewDocString(`{"type":"response.custom_tool_call_input.done","sequence_number":0,"item_id":"","output_index":0,"input":""}`)
+ _ = inputDoneDoc.Set("sequence_number", nextSeq())
+ _ = inputDoneDoc.Set("item_id", fmt.Sprintf("ctc_%s", callID))
+ _ = inputDoneDoc.Set("output_index", outputIndex)
+ _ = inputDoneDoc.Set("input", input)
+ out = append(out, emitRespEvent("response.custom_tool_call_input.done", inputDoneDoc.Bytes()))
}
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("ctc_%s", callID))
- itemDone, _ = sjson.SetBytes(itemDone, "item.status", toolStatus)
- itemDone, _ = sjson.SetBytes(itemDone, "item.input", input)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
- itemDone = st.ToolIndex.applyIdentity(itemDone, st.FuncNames[key], "item")
- out = append(out, emitRespEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", outputIndex)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoneDoc.Set("item.status", toolStatus)
+ _ = itemDoneDoc.Set("item.input", input)
+ _ = itemDoneDoc.Set("item.call_id", callID)
+ st.ToolIndex.applyIdentityDoc(itemDoneDoc, st.FuncNames[key], "item")
+ out = append(out, emitRespEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
continue
}
- fcDone := []byte(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
- fcDone, _ = sjson.SetBytes(fcDone, "sequence_number", nextSeq())
- fcDone, _ = sjson.SetBytes(fcDone, "item_id", fmt.Sprintf("fc_%s", callID))
- fcDone, _ = sjson.SetBytes(fcDone, "output_index", outputIndex)
- fcDone, _ = translatorcommon.SetStringWithoutHTMLEscape(fcDone, "arguments", args)
- out = append(out, emitRespEvent("response.function_call_arguments.done", fcDone))
+ fcDoneDoc := ojson.NewDocString(`{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`)
+ _ = fcDoneDoc.Set("sequence_number", nextSeq())
+ _ = fcDoneDoc.Set("item_id", fmt.Sprintf("fc_%s", callID))
+ _ = fcDoneDoc.Set("output_index", outputIndex)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(fcDoneDoc, "arguments", args)
+ out = append(out, emitRespEvent("response.function_call_arguments.done", fcDoneDoc.Bytes()))
- itemDone := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
- itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq())
- itemDone, _ = sjson.SetBytes(itemDone, "output_index", outputIndex)
- itemDone, _ = sjson.SetBytes(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
- itemDone, _ = sjson.SetBytes(itemDone, "item.status", toolStatus)
- itemDone, _ = translatorcommon.SetStringWithoutHTMLEscape(itemDone, "item.arguments", args)
- itemDone, _ = sjson.SetBytes(itemDone, "item.call_id", callID)
- itemDone = st.ToolIndex.applyIdentity(itemDone, st.FuncNames[key], "item")
- out = append(out, emitRespEvent("response.output_item.done", itemDone))
+ itemDoneDoc := ojson.NewDocString(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}}`)
+ _ = itemDoneDoc.Set("sequence_number", nextSeq())
+ _ = itemDoneDoc.Set("output_index", outputIndex)
+ _ = itemDoneDoc.Set("item.id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoneDoc.Set("item.status", toolStatus)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoneDoc, "item.arguments", args)
+ _ = itemDoneDoc.Set("item.call_id", callID)
+ st.ToolIndex.applyIdentityDoc(itemDoneDoc, st.FuncNames[key], "item")
+ out = append(out, emitRespEvent("response.output_item.done", itemDoneDoc.Bytes()))
st.FuncItemDone[key] = true
st.FuncArgsDone[key] = true
}
@@ -906,7 +927,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
// choices[].delta content / tool_calls / reasoning_content
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
- choices.ForEach(func(_, choice gjson.Result) bool {
+ choices.ForEach(func(_, choice ojson.Result) bool {
idx := int(choice.Get("index").Int())
delta := choice.Get("delta")
if delta.Exists() {
@@ -920,25 +941,25 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
if st.ReasoningID == "" {
st.ReasoningID = fmt.Sprintf("rs_%s_%d", st.ResponseID, idx)
st.ReasoningIndex = allocOutputIndex()
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex)
- item, _ = sjson.SetBytes(item, "item.id", st.ReasoningID)
- out = append(out, emitRespEvent("response.output_item.added", item))
- part := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
- part, _ = sjson.SetBytes(part, "item_id", st.ReasoningID)
- part, _ = sjson.SetBytes(part, "output_index", st.ReasoningIndex)
- out = append(out, emitRespEvent("response.reasoning_summary_part.added", part))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","summary":[]}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", st.ReasoningIndex)
+ _ = itemDoc.Set("item.id", st.ReasoningID)
+ out = append(out, emitRespEvent("response.output_item.added", itemDoc.Bytes()))
+ partDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`)
+ _ = partDoc.Set("sequence_number", nextSeq())
+ _ = partDoc.Set("item_id", st.ReasoningID)
+ _ = partDoc.Set("output_index", st.ReasoningIndex)
+ out = append(out, emitRespEvent("response.reasoning_summary_part.added", partDoc.Bytes()))
}
// Append incremental text to reasoning buffer
st.ReasoningBuf.WriteString(rc.String())
- msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningID)
- msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex)
- msg, _ = sjson.SetBytes(msg, "delta", rc.String())
- out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", st.ReasoningID)
+ _ = msgDoc.Set("output_index", st.ReasoningIndex)
+ _ = msgDoc.Set("delta", rc.String())
+ out = append(out, emitRespEvent("response.reasoning_summary_text.delta", msgDoc.Bytes()))
}
if c := delta.Get("content"); c.Exists() && c.String() != "" {
@@ -952,30 +973,30 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
}
msgOutputIndex := st.MsgOutputIx[idx]
if !st.MsgItemAdded[idx] {
- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq())
- item, _ = sjson.SetBytes(item, "output_index", msgOutputIndex)
- item, _ = sjson.SetBytes(item, "item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
- out = append(out, emitRespEvent("response.output_item.added", item))
+ itemDoc := ojson.NewDocString(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`)
+ _ = itemDoc.Set("sequence_number", nextSeq())
+ _ = itemDoc.Set("output_index", msgOutputIndex)
+ _ = itemDoc.Set("item.id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
+ out = append(out, emitRespEvent("response.output_item.added", itemDoc.Bytes()))
st.MsgItemAdded[idx] = true
}
if !st.MsgContentAdded[idx] {
- part := []byte(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
- part, _ = sjson.SetBytes(part, "sequence_number", nextSeq())
- part, _ = sjson.SetBytes(part, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
- part, _ = sjson.SetBytes(part, "output_index", msgOutputIndex)
- part, _ = sjson.SetBytes(part, "content_index", 0)
- out = append(out, emitRespEvent("response.content_part.added", part))
+ partDoc := ojson.NewDocString(`{"type":"response.content_part.added","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""}}`)
+ _ = partDoc.Set("sequence_number", nextSeq())
+ _ = partDoc.Set("item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
+ _ = partDoc.Set("output_index", msgOutputIndex)
+ _ = partDoc.Set("content_index", 0)
+ out = append(out, emitRespEvent("response.content_part.added", partDoc.Bytes()))
st.MsgContentAdded[idx] = true
}
- msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq())
- msg, _ = sjson.SetBytes(msg, "item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
- msg, _ = sjson.SetBytes(msg, "output_index", msgOutputIndex)
- msg, _ = sjson.SetBytes(msg, "content_index", 0)
- msg, _ = sjson.SetBytes(msg, "delta", c.String())
- out = append(out, emitRespEvent("response.output_text.delta", msg))
+ msgDoc := ojson.NewDocString(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`)
+ _ = msgDoc.Set("sequence_number", nextSeq())
+ _ = msgDoc.Set("item_id", fmt.Sprintf("msg_%s_%d", st.ResponseID, idx))
+ _ = msgDoc.Set("output_index", msgOutputIndex)
+ _ = msgDoc.Set("content_index", 0)
+ _ = msgDoc.Set("delta", c.String())
+ out = append(out, emitRespEvent("response.output_text.delta", msgDoc.Bytes()))
// aggregate for response.output
if st.MsgTextBuf[idx] == nil {
st.MsgTextBuf[idx] = &strings.Builder{}
@@ -993,7 +1014,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
// close its text/content to match Codex expected ordering.
emitMessageItemDone(idx)
- tcs.ForEach(func(_, tc gjson.Result) bool {
+ tcs.ForEach(func(_, tc ojson.Result) bool {
toolIndex := int(tc.Get("index").Int())
key := toolStateKey(idx, toolIndex)
if st.FuncArgsBuf[key] == nil {
@@ -1013,11 +1034,11 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
}
}
if newCallID := tc.Get("id").String(); newCallID != "" && st.FuncCallIDs[key] == "" {
- st.FuncCallIDs[key] = newCallID
+ st.FuncCallIDs[key] = strings.Clone(newCallID)
}
nameChunk := tc.Get("function.name").String()
if nameChunk != "" && !st.FuncItemAdded[key] {
- st.FuncNames[key] = nameChunk
+ st.FuncNames[key] = strings.Clone(nameChunk)
}
if args := tc.Get("function.arguments"); args.Exists() && args.String() != "" {
@@ -1038,7 +1059,7 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
// deferred until [DONE] or transport finalization so late usage-only chunks
// can still populate response.usage.
if fr := choice.Get("finish_reason"); fr.Exists() && fr.String() != "" {
- st.FinishReason = fr.String()
+ st.FinishReason = strings.Clone(fr.String())
finalizeOpenItems()
st.CompletionPending = st.canFinalizeResponse()
}
@@ -1053,9 +1074,9 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
// ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream builds a single Responses JSON
// from a non-streaming OpenAI Chat Completions response.
func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
requestForNamespace := pickRequestJSON(originalRequestRawJSON, requestRawJSON)
- toolIndex := newResponsesToolIndex(gjson.ParseBytes(requestForNamespace))
+ toolIndex := newResponsesToolIndex(util.ParseGJSONBytesNoCopy(requestForNamespace))
st := &oaiToResponsesState{}
if param != nil {
*param = st
@@ -1070,10 +1091,10 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
}
// Basic response scaffold
- resp := []byte(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
- resp, _ = sjson.SetBytes(resp, "status", respStatus)
+ respDoc := ojson.NewDocString(`{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"incomplete_details":null}`)
+ _ = respDoc.Set("status", respStatus)
if isIncomplete {
- resp, _ = sjson.SetRawBytes(resp, "incomplete_details", incompleteDetails)
+ _ = respDoc.SetRawBytes("incomplete_details", incompleteDetails)
}
// id: use provider id if present, otherwise synthesize
@@ -1081,19 +1102,19 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
if id == "" {
id = fmt.Sprintf("resp_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&responseIDCounter, 1))
}
- resp, _ = sjson.SetBytes(resp, "id", id)
+ _ = respDoc.Set("id", id)
// created_at: map from chat.completion created
created := root.Get("created").Int()
if created == 0 {
created = time.Now().Unix()
}
- resp, _ = sjson.SetBytes(resp, "created_at", created)
+ _ = respDoc.Set("created_at", created)
// Echo request fields when available (aligns with streaming path behavior)
echo := responsesRequestEcho{}
if len(requestRawJSON) > 0 {
- echo = extractResponsesRequestEcho(gjson.ParseBytes(requestRawJSON))
+ echo = extractResponsesRequestEcho(util.ParseGJSONBytesNoCopy(requestRawJSON))
// Also support max_tokens from chat completion style
if !echo.hasMaxOutputTokens && echo.hasMaxTokens {
echo.maxOutputTokens, echo.hasMaxOutputTokens = echo.maxTokens, true
@@ -1105,14 +1126,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
echo.model, echo.hasModel = v.String(), true
}
}
- resp = echo.apply(resp, "")
+ echo.applyDoc(respDoc, "")
// Build output list from choices[...]
var outputItems [][]byte
// Detect and capture reasoning content if present (with fallback to reasoning)
- rc := gjson.GetBytes(rawJSON, "choices.0.message.reasoning_content")
+ rc := ojson.GetBytesNoCopy(rawJSON, "choices.0.message.reasoning_content")
if !rc.Exists() || rc.String() == "" {
- rc = gjson.GetBytes(rawJSON, "choices.0.message.reasoning")
+ rc = ojson.GetBytesNoCopy(rawJSON, "choices.0.message.reasoning")
}
rcText := rc.String()
includeReasoning := rcText != ""
@@ -1125,17 +1146,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
rid = strings.TrimPrefix(rid, "resp_")
}
// Prefer summary_text from reasoning_content; encrypted_content is optional
- reasoningItem := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
- reasoningItem, _ = sjson.SetBytes(reasoningItem, "id", fmt.Sprintf("rs_%s", rid))
+ reasoningItemDoc := ojson.NewDocString(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`)
+ _ = reasoningItemDoc.Set("id", fmt.Sprintf("rs_%s", rid))
if rcText != "" {
- reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.type", "summary_text")
- reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rcText)
+ _ = reasoningItemDoc.Set("summary.0.type", "summary_text")
+ _ = reasoningItemDoc.Set("summary.0.text", rcText)
}
- outputItems = append(outputItems, reasoningItem)
+ outputItems = append(outputItems, reasoningItemDoc.Bytes())
}
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
- choices.ForEach(func(_, choice gjson.Result) bool {
+ choices.ForEach(func(_, choice ojson.Result) bool {
msg := choice.Get("message")
if msg.Exists() {
// Text message part
@@ -1144,17 +1165,17 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
if isIncomplete {
itemStatus = "incomplete"
}
- item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("msg_%s_%d", id, int(choice.Get("index").Int())))
- item, _ = sjson.SetBytes(item, "status", itemStatus)
- item, _ = sjson.SetBytes(item, "content.0.text", c.String())
- outputItems = append(outputItems, item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("msg_%s_%d", id, int(choice.Get("index").Int())))
+ _ = itemDoc.Set("status", itemStatus)
+ _ = itemDoc.Set("content.0.text", c.String())
+ outputItems = append(outputItems, itemDoc.Bytes())
}
// Function/tool calls
if tcs := msg.Get("tool_calls"); tcs.Exists() && tcs.IsArray() {
customToolNames := toolIndex.custom
- tcs.ForEach(func(tcIndex, tc gjson.Result) bool {
+ tcs.ForEach(func(tcIndex, tc ojson.Result) bool {
callID := tc.Get("id").String()
if callID == "" {
// Providers may omit tool_call ids; synthesize one so the
@@ -1176,10 +1197,14 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
outputItems = append(outputItems, item)
return true
}
+ if toolIndex.isToolSearch(name) {
+ outputItems = append(outputItems, toolSearchCallItem(callID, args, toolStatus))
+ return true
+ }
if _, isCustomTool := customToolNames[name]; isCustomTool {
- item := []byte(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("ctc_%s", callID))
- item, _ = sjson.SetBytes(item, "status", toolStatus)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"custom_tool_call","status":"completed","input":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("ctc_%s", callID))
+ _ = itemDoc.Set("status", toolStatus)
input := ""
if toolIndex.isApplyPatch(name) {
patchCall := &translatorcommon.ApplyPatchCallState{}
@@ -1192,19 +1217,19 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
} else {
input = unwrapCustomToolInput(args)
}
- item, _ = sjson.SetBytes(item, "input", input)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = toolIndex.applyIdentity(item, name, "")
- outputItems = append(outputItems, item)
+ _ = itemDoc.Set("input", input)
+ _ = itemDoc.Set("call_id", callID)
+ toolIndex.applyIdentityDoc(itemDoc, name, "")
+ outputItems = append(outputItems, itemDoc.Bytes())
return true
}
- item := []byte(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
- item, _ = sjson.SetBytes(item, "id", fmt.Sprintf("fc_%s", callID))
- item, _ = sjson.SetBytes(item, "status", toolStatus)
- item, _ = translatorcommon.SetStringWithoutHTMLEscape(item, "arguments", args)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- item = toolIndex.applyIdentity(item, name, "")
- outputItems = append(outputItems, item)
+ itemDoc := ojson.NewDocString(`{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":""}`)
+ _ = itemDoc.Set("id", fmt.Sprintf("fc_%s", callID))
+ _ = itemDoc.Set("status", toolStatus)
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(itemDoc, "arguments", args)
+ _ = itemDoc.Set("call_id", callID)
+ toolIndex.applyIdentityDoc(itemDoc, name, "")
+ outputItems = append(outputItems, itemDoc.Bytes())
return true
})
}
@@ -1213,36 +1238,36 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(_ context.Co
})
}
if st.ToolInputError() != nil {
- return []byte(gjson.GetBytes(responsesToolInputFailure(id, 0, st.ToolInputError()), "response").Raw)
+ return []byte(ojson.GetBytesNoCopy(responsesToolInputFailure(id, 0, st.ToolInputError()), "response").Raw)
}
if len(outputItems) > 0 {
- resp, _ = sjson.SetRawBytes(resp, "output", translatorcommon.JoinRawArray(outputItems))
+ _ = respDoc.SetRawBytes("output", translatorcommon.JoinRawArray(outputItems))
}
// usage mapping
if usage := root.Get("usage"); usage.Exists() {
// Map common tokens
if usage.Get("prompt_tokens").Exists() || usage.Get("completion_tokens").Exists() || usage.Get("total_tokens").Exists() {
- resp, _ = sjson.SetBytes(resp, "usage.input_tokens", usage.Get("prompt_tokens").Int())
+ _ = respDoc.Set("usage.input_tokens", usage.Get("prompt_tokens").Int())
if d := usage.Get("prompt_tokens_details.cached_tokens"); d.Exists() {
- resp, _ = sjson.SetBytes(resp, "usage.input_tokens_details.cached_tokens", d.Int())
+ _ = respDoc.Set("usage.input_tokens_details.cached_tokens", d.Int())
}
- resp, _ = sjson.SetBytes(resp, "usage.output_tokens", usage.Get("completion_tokens").Int())
+ _ = respDoc.Set("usage.output_tokens", usage.Get("completion_tokens").Int())
// Chat Completions upstreams report reasoning under completion_tokens_details;
// Responses-native upstreams report it under output_tokens_details. Accept both.
if d := usage.Get("output_tokens_details.reasoning_tokens"); d.Exists() {
- resp, _ = sjson.SetBytes(resp, "usage.output_tokens_details.reasoning_tokens", d.Int())
+ _ = respDoc.Set("usage.output_tokens_details.reasoning_tokens", d.Int())
} else if d := usage.Get("completion_tokens_details.reasoning_tokens"); d.Exists() {
- resp, _ = sjson.SetBytes(resp, "usage.output_tokens_details.reasoning_tokens", d.Int())
+ _ = respDoc.Set("usage.output_tokens_details.reasoning_tokens", d.Int())
}
- resp, _ = sjson.SetBytes(resp, "usage.total_tokens", usage.Get("total_tokens").Int())
+ _ = respDoc.Set("usage.total_tokens", usage.Get("total_tokens").Int())
} else {
// Fallback to raw usage object if structure differs
- resp, _ = sjson.SetBytes(resp, "usage", usage.Value())
+ _ = respDoc.Set("usage", usage.Value())
}
}
- return resp
+ return respDoc.Bytes()
}
func (st *oaiToResponsesState) canFinalizeResponse() bool {
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go
index 7c1b53873f0..af27634c221 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_response_test.go
@@ -6,10 +6,11 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
-func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) {
+func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, ojson.Result) {
t.Helper()
lines := strings.Split(string(chunk), "\n")
@@ -19,10 +20,10 @@ func parseOpenAIResponsesSSEEvent(t *testing.T, chunk []byte) (string, gjson.Res
event := strings.TrimSpace(strings.TrimPrefix(lines[0], "event:"))
dataLine := strings.TrimSpace(strings.TrimPrefix(lines[1], "data:"))
- if !gjson.Valid(dataLine) {
+ if !ojson.Valid(dataLine) {
t.Fatalf("invalid SSE data JSON: %q", dataLine)
}
- return event, gjson.Parse(dataLine)
+ return event, ojson.Parse(dataLine)
}
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ResponseCompletedWaitsForDone(t *testing.T) {
@@ -97,9 +98,9 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ResponseCompleted
t.Run(tt.name, func(t *testing.T) {
completedCount := 0
completedInputIndex := -1
- var createdData gjson.Result
- var inProgressData gjson.Result
- var completedData gjson.Result
+ var createdData ojson.Result
+ var inProgressData ojson.Result
+ var completedData ojson.Result
// Reuse converter state across input lines to simulate one streaming response.
var param any
@@ -186,10 +187,10 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_FinalizesOpenMess
t.Run(tt.name, func(t *testing.T) {
var param any
var events []string
- var textDone gjson.Result
- var partDone gjson.Result
- var itemDone gjson.Result
- var completed gjson.Result
+ var textDone ojson.Result
+ var partDone ojson.Result
+ var itemDone ojson.Result
+ var completed ojson.Result
for _, line := range []string{tt.chunk, `data: [DONE]`} {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), ¶m) {
@@ -264,7 +265,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultipleToolCalls
addedNames := map[string]string{}
doneArgs := map[string]string{}
doneNames := map[string]string{}
- outputItems := map[string]gjson.Result{}
+ outputItems := map[string]ojson.Result{}
for _, chunk := range out {
ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -305,10 +306,10 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultipleToolCalls
t.Fatalf("unexpected added name for call_glob: %q", addedNames["call_glob"])
}
- if !gjson.Valid(doneArgs["call_read"]) {
+ if !ojson.Valid(doneArgs["call_read"]) {
t.Fatalf("invalid JSON args for call_read: %q", doneArgs["call_read"])
}
- if !gjson.Valid(doneArgs["call_glob"]) {
+ if !ojson.Valid(doneArgs["call_glob"]) {
t.Fatalf("invalid JSON args for call_glob: %q", doneArgs["call_glob"])
}
if strings.Contains(doneArgs["call_read"], "}{") {
@@ -325,13 +326,13 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultipleToolCalls
t.Fatalf("unexpected done name for call_glob: %q", doneNames["call_glob"])
}
- if got := gjson.Get(doneArgs["call_read"], "filePath").String(); got != `C:\repo` {
+ if got := ojson.Get(doneArgs["call_read"], "filePath").String(); got != `C:\repo` {
t.Fatalf("unexpected filePath for call_read: %q", got)
}
- if got := gjson.Get(doneArgs["call_glob"], "path").String(); got != `C:\repo` {
+ if got := ojson.Get(doneArgs["call_glob"], "path").String(); got != `C:\repo` {
t.Fatalf("unexpected path for call_glob: %q", got)
}
- if got := gjson.Get(doneArgs["call_glob"], "pattern").String(); got != "*.{yml,yaml}" {
+ if got := ojson.Get(doneArgs["call_glob"], "pattern").String(); got != "*.{yml,yaml}" {
t.Fatalf("unexpected pattern for call_glob: %q", got)
}
@@ -413,10 +414,10 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_MultiChoiceToolCa
t.Fatalf("expected distinct output indexes for different choices, both got %d", added["call_choice0"].outputIndex)
}
- if !gjson.Valid(done["call_choice0"].arguments) {
+ if !ojson.Valid(done["call_choice0"].arguments) {
t.Fatalf("invalid JSON args for call_choice0: %q", done["call_choice0"].arguments)
}
- if !gjson.Valid(done["call_choice1"].arguments) {
+ if !ojson.Valid(done["call_choice1"].arguments) {
t.Fatalf("invalid JSON args for call_choice1: %q", done["call_choice1"].arguments)
}
if done["call_choice0"].outputIndex == done["call_choice1"].outputIndex {
@@ -486,7 +487,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CompletedOmitsTop
request := []byte(`{"model":"gpt-5.4"}`)
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), ¶m) {
ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -518,7 +519,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ToolCallCompleted
request := []byte(`{"model":"gpt-5.4","tool_choice":"auto","parallel_tool_calls":true}`)
var param any
- var completed gjson.Result
+ var completed ojson.Result
for _, line := range in {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", request, request, []byte(line), ¶m) {
ev, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -598,7 +599,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_OmitsTop
raw := []byte(`{"id":"chatcmpl_output_text","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":"ping"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`)
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", request, request, raw, nil)
- data := gjson.ParseBytes(resp)
+ data := util.ParseGJSONBytesNoCopy(resp)
if data.Get("output_text").Exists() {
t.Fatalf("output_text should be omitted to match native Responses output: %s", resp)
@@ -626,9 +627,9 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresNamespace
}
var param any
- var added gjson.Result
- var done gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var done ojson.Result
+ var completed ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -649,7 +650,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresNamespace
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
}{
{"added", added},
{"done", done},
@@ -689,7 +690,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores
raw := []byte(`{"id":"chatcmpl_namespace_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_ns","type":"function","function":{"name":"mcp__test_mcp__add_numbers","arguments":"{\"a\":3,\"b\":5}"}}]},"finish_reason":"tool_calls"}]}`)
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
- data := gjson.ParseBytes(resp)
+ data := util.ParseGJSONBytesNoCopy(resp)
if got := data.Get("output.0.name").String(); got != "add_numbers" {
t.Fatalf("non-stream output name = %q, want add_numbers; response=%s", got, resp)
@@ -719,9 +720,9 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresCappedNam
}
var param any
- var added gjson.Result
- var done gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var done ojson.Result
+ var completed ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -742,7 +743,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresCappedNam
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
}{
{"added", added},
{"done", done},
@@ -783,7 +784,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores
raw := []byte(`{"id":"chatcmpl_capped_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_capped","type":"function","function":{"name":"` + chatName + `","arguments":"{\"cmd\":\"run\"}"}}]},"finish_reason":"tool_calls"}]}`)
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
- data := gjson.ParseBytes(resp)
+ data := util.ParseGJSONBytesNoCopy(resp)
if got := data.Get("output.0.name").String(); got != "_execute_document_command" {
t.Fatalf("non-stream output name = %q, want _execute_document_command; response=%s", got, resp)
@@ -806,10 +807,10 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameArr
}
var param any
- var added gjson.Result
- var inputDone gjson.Result
- var itemDone gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var inputDone ojson.Result
+ var itemDone ojson.Result
+ var completed ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -834,7 +835,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameArr
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
path string
}{
{"added", added, "item"},
@@ -870,9 +871,9 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameAnd
}
var param any
- var added gjson.Result
- var done gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var done ojson.Result
+ var completed ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -890,7 +891,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_CustomToolNameAnd
wantCallID := "call_chatcmpl_custom_missing_fields_0_0"
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
path string
}{
{"added", added, "item"},
@@ -939,10 +940,10 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ToolCallIDMayArri
t.Run(tt.name, func(t *testing.T) {
var param any
var events []string
- var added gjson.Result
- var argsDelta gjson.Result
- var argsDone gjson.Result
- var itemDone gjson.Result
+ var added ojson.Result
+ var argsDelta ojson.Result
+ var argsDone ojson.Result
+ var itemDone ojson.Result
for _, line := range append(tt.chunks, `data: [DONE]`) {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", nil, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -1002,9 +1003,9 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditiona
}
var param any
- var added gjson.Result
- var done gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var done ojson.Result
+ var completed ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -1021,7 +1022,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditiona
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
path string
}{
{"added", added, "item"},
@@ -1052,7 +1053,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores
raw := []byte(`{"id":"chatcmpl_additional_namespace_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_send","type":"function","function":{"name":"collaboration__send_message","arguments":"{\"target\":\"worker\",\"message\":\"ping\"}"}}]},"finish_reason":"tool_calls"}]}`)
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
- data := gjson.ParseBytes(resp)
+ data := util.ParseGJSONBytesNoCopy(resp)
if got := data.Get("output.0.name").String(); got != "send_message" {
t.Fatalf("non-stream output name = %q, want send_message; response=%s", got, resp)
}
@@ -1080,10 +1081,10 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditiona
}
var param any
- var added gjson.Result
- var inputDone gjson.Result
- var done gjson.Result
- var completed gjson.Result
+ var added ojson.Result
+ var inputDone ojson.Result
+ var done ojson.Result
+ var completed ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "model", originalRequest, nil, []byte(line), ¶m) {
event, data := parseOpenAIResponsesSSEEvent(t, chunk)
@@ -1104,7 +1105,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_RestoresAdditiona
for _, tc := range []struct {
label string
- got gjson.Result
+ got ojson.Result
path string
}{
{"added", added, "item"},
@@ -1150,7 +1151,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Restores
raw := []byte(`{"id":"chatcmpl_additional_namespace_custom_nonstream","object":"chat.completion","created":1773896263,"model":"model","choices":[{"index":0,"message":{"role":"assistant","tool_calls":[{"id":"call_exec","type":"function","function":{"name":"functions__exec","arguments":"{\"input\":\"pwd\"}"}}]},"finish_reason":"tool_calls"}]}`)
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "model", originalRequest, nil, raw, nil)
- data := gjson.ParseBytes(resp)
+ data := util.ParseGJSONBytesNoCopy(resp)
if got := data.Get("output.0.type").String(); got != "custom_tool_call" {
t.Fatalf("output type = %q, want custom_tool_call; response=%s", got, resp)
}
@@ -1328,7 +1329,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_FinishReasonConte
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_FinishReasonLength(t *testing.T) {
raw := []byte(`{"id":"chatcmpl_len","object":"chat.completion","created":1773896263,"model":"gpt-5.6","choices":[{"index":0,"message":{"role":"assistant","content":"truncated text"},"finish_reason":"length"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`)
out := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-5.6", nil, nil, raw, nil)
- data := gjson.ParseBytes(out)
+ data := util.ParseGJSONBytesNoCopy(out)
if got := data.Get("status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; out=%s", got, out)
}
@@ -1343,7 +1344,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_FinishRe
func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_FinishReasonContentFilter(t *testing.T) {
raw := []byte(`{"id":"chatcmpl_filter","object":"chat.completion","created":1773896263,"model":"gpt-5.6","choices":[{"index":0,"message":{"role":"assistant","content":"blocked text"},"finish_reason":"content_filter"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`)
out := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "gpt-5.6", nil, nil, raw, nil)
- data := gjson.ParseBytes(out)
+ data := util.ParseGJSONBytesNoCopy(out)
if got := data.Get("status").String(); got != "incomplete" {
t.Fatalf("status = %q, want incomplete; out=%s", got, out)
}
@@ -1408,11 +1409,11 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_Reasonin
reqBytes = []byte(tt.requestJSON)
}
out := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "o3-mini", reqBytes, reqBytes, []byte(tt.rawJSON), nil)
- data := gjson.ParseBytes(out)
+ data := util.ParseGJSONBytesNoCopy(out)
- var reasoningItem gjson.Result
+ var reasoningItem ojson.Result
found := false
- data.Get("output").ForEach(func(_, item gjson.Result) bool {
+ data.Get("output").ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "reasoning" {
found = true
reasoningItem = item
@@ -1463,7 +1464,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_EmptyToolCallsArr
var completedCount int
var lastReasoningSummaryText string
var lastMessageContentText string
- var completedData gjson.Result
+ var completedData ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "codebuddy-hy4", request, request, []byte(line), ¶m) {
@@ -1537,7 +1538,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_ChunkWithContentA
var lastReasoningSummaryText string
var lastMessageContentText string
var eventOrder []string
- var completedData gjson.Result
+ var completedData ojson.Result
var addedItemIDs []string
for _, line := range chunks {
@@ -1632,7 +1633,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponses_SingleChunkWithBo
var param any
var eventOrder []string
- var completedData gjson.Result
+ var completedData ojson.Result
for _, line := range chunks {
for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "deepseek-v4-flash", request, request, []byte(line), ¶m) {
@@ -1679,9 +1680,9 @@ func applyPatchChatFragment(index int, fragment string) []byte {
func applyPatchChatEnd() [][]byte {
return [][]byte{[]byte(`data: {"id":"r1","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`), []byte(`data: [DONE]`)}
}
-func applyPatchChatEvents(t *testing.T, chunks [][]byte) []gjson.Result {
+func applyPatchChatEvents(t *testing.T, chunks [][]byte) []ojson.Result {
t.Helper()
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range chunks {
_, data := parseOpenAIResponsesSSEEvent(t, chunk)
events = append(events, data)
@@ -1694,7 +1695,7 @@ const applyPatchChatRequest = `{"tools":[{"type":"custom","name":"apply_patch","
func TestApplyPatchChatPreviewBeforeDone(t *testing.T) {
request := []byte(applyPatchChatRequest)
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", "apply_patch"))
@@ -1705,7 +1706,7 @@ func TestApplyPatchChatPreviewBeforeDone(t *testing.T) {
if preview[0].Get("call_id").String() != "c1" || preview[0].Get("item_id").String() != "ctc_c1" {
t.Fatalf("preview identity: %s", preview[0].Raw)
}
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, preview...)
events = append(events, feed(applyPatchChatFragment(0, `*** End Patch"}`))...)
for _, chunk := range applyPatchChatEnd() {
@@ -1748,7 +1749,7 @@ func TestApplyPatchChatPreviewBeforeDone(t *testing.T) {
func TestApplyPatchChatLateIdentityAndInterleavedCalls(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "", ""))
@@ -1757,7 +1758,7 @@ func TestApplyPatchChatLateIdentityAndInterleavedCalls(t *testing.T) {
t.Fatalf("emitted before identity: %s", event.Raw)
}
}
- var events []gjson.Result
+ var events []ojson.Result
events = append(events, feed(applyPatchChatStart(0, "c1", "apply_patch"))...)
events = append(events, feed(applyPatchChatStart(1, "c2", "apply_patch"))...)
events = append(events, feed(applyPatchChatFragment(1, `{"input":"second`))...)
@@ -1790,7 +1791,7 @@ func TestApplyPatchChatInvalidArgumentsFailOnce(t *testing.T) {
for _, arguments := range []string{`plain patch`, `{}`, `{"input":42}`, `{"input":"x","extra":1}`, `{"input":"x","input":"y"}`, `{"input":"x"} {}`, `{"input":"unfinished`, `{"input":"bad\q"}`, `{"input":"\ud800"}`} {
t.Run(arguments, func(t *testing.T) {
var param any
- var events []gjson.Result
+ var events []ojson.Result
feed := func(chunk []byte) {
events = append(events, applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))...)
}
@@ -1837,7 +1838,7 @@ func TestApplyPatchChatWinnerAndNamespace(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
var param any
request := []byte(tc.request)
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", tc.upstream))
@@ -1849,7 +1850,7 @@ func TestApplyPatchChatWinnerAndNamespace(t *testing.T) {
for _, chunk := range applyPatchChatEnd() {
events = append(events, feed(chunk)...)
}
- var item gjson.Result
+ var item ojson.Result
for _, event := range events {
if event.Get("type").String() == "response.completed" {
item = event.Get("response.output.0")
@@ -1879,7 +1880,7 @@ func TestApplyPatchChatNonStreamStrictInput(t *testing.T) {
t.Run(tc.arguments, func(t *testing.T) {
var param any
raw := []byte(fmt.Sprintf(`{"id":"r1","choices":[{"index":0,"message":{"tool_calls":[{"id":"c1","function":{"name":"apply_patch","arguments":%q}}]},"finish_reason":"tool_calls"}]}`, tc.arguments))
- result := gjson.ParseBytes(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchChatRequest), nil, raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", []byte(applyPatchChatRequest), nil, raw, ¶m))
if tc.invalid {
state, ok := param.(interface{ ToolInputError() error })
if result.Get("status").String() != "failed" || result.Get("error.code").String() != "invalid_tool_arguments" || !ok || state.ToolInputError() == nil {
@@ -1894,7 +1895,7 @@ func TestApplyPatchChatNonStreamStrictInput(t *testing.T) {
func TestApplyPatchChatTerminalValidatesTruncatedCall(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", "apply_patch"))
@@ -1910,7 +1911,7 @@ func TestApplyPatchChatUnicodeFragments(t *testing.T) {
arguments := `{"input":"line\n\u4f60\u597d \ud83d\ude00 \" \\ \u96ea"}`
for split := 1; split < len(arguments); split++ {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", "apply_patch"))
@@ -1934,7 +1935,7 @@ func TestApplyPatchChatUnicodeFragments(t *testing.T) {
func TestApplyPatchChatIdentityFieldsArriveSeparately(t *testing.T) {
for _, tc := range []struct{ id, name string }{{"c1", ""}, {"", "apply_patch"}} {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, tc.id, tc.name))
@@ -1958,7 +1959,7 @@ func TestApplyPatchChatIdentityFieldsArriveSeparately(t *testing.T) {
func TestApplyPatchChatConflictingIdentityFails(t *testing.T) {
for _, tc := range []struct{ id, name string }{{"other", "apply_patch"}, {"c1", "other"}} {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", "apply_patch"))
@@ -1977,7 +1978,7 @@ func TestApplyPatchChatNonStreamOriginalDeclarationWins(t *testing.T) {
original := []byte(`{"tools":[{"type":"function","name":"apply_patch"}]}`)
var param any
raw := []byte(`{"id":"r1","choices":[{"index":0,"message":{"tool_calls":[{"id":"c1","function":{"name":"apply_patch","arguments":"{\"not_input\":42}"}}]},"finish_reason":"tool_calls"}]}`)
- result := gjson.ParseBytes(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", original, []byte(applyPatchChatRequest), raw, ¶m))
+ result := util.ParseGJSONBytesNoCopy(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", original, []byte(applyPatchChatRequest), raw, ¶m))
if result.Get("output.0.type").String() != "function_call" || result.Get("output.0.arguments").String() != `{"not_input":42}` {
t.Fatalf("converted declaration overrode original: %s", result.Raw)
}
@@ -1985,7 +1986,7 @@ func TestApplyPatchChatNonStreamOriginalDeclarationWins(t *testing.T) {
func TestApplyPatchChatPendingIdentityConflictFails(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", ""))
@@ -1998,16 +1999,16 @@ func TestApplyPatchChatPendingIdentityConflictFails(t *testing.T) {
func TestApplyPatchChatMissingIDSynthesizedAtTerminal(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "", "apply_patch"))
feed(applyPatchChatFragment(0, `{"input":"x"}`))
- var events []gjson.Result
+ var events []ojson.Result
for _, chunk := range applyPatchChatEnd() {
events = append(events, feed(chunk)...)
}
- var completed gjson.Result
+ var completed ojson.Result
for _, event := range events {
if event.Get("type").String() == "response.completed" {
completed = event
@@ -2034,7 +2035,7 @@ func TestApplyPatchChatDeferredIdentityConflict(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(tc.request), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", ""))
@@ -2047,7 +2048,7 @@ func TestApplyPatchChatDeferredIdentityConflict(t *testing.T) {
events = append(events, feed(chunk)...)
}
failures, completions := 0, 0
- var item gjson.Result
+ var item ojson.Result
for _, event := range events {
switch event.Get("type").String() {
case "response.failed":
@@ -2090,7 +2091,7 @@ func TestApplyPatchChatSuccessfulTerminalSealsState(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
var param any
- feed := func(chunk []byte) []gjson.Result {
+ feed := func(chunk []byte) []ojson.Result {
return applyPatchChatEvents(t, ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", []byte(applyPatchChatRequest), nil, chunk, ¶m))
}
feed(applyPatchChatStart(0, "c1", "apply_patch"))
@@ -2139,7 +2140,7 @@ func TestConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream_MapsComp
t.Run(tc.name, func(t *testing.T) {
raw := []byte(`{"id":"chatcmpl_reasoning","object":"chat.completion","created":1773896263,"model":"mercury-3","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":` + tc.usageJSON + `}`)
resp := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "mercury-3", request, request, raw, nil)
- data := gjson.ParseBytes(resp)
+ data := util.ParseGJSONBytesNoCopy(resp)
reasoning := data.Get("usage.output_tokens_details.reasoning_tokens")
if !reasoning.Exists() {
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_roles_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_roles_test.go
new file mode 100644
index 00000000000..6243566922f
--- /dev/null
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_roles_test.go
@@ -0,0 +1,59 @@
+package responses
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+)
+
+func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_PreservesInstructionRoles(t *testing.T) {
+ for _, stream := range []bool{false, true} {
+ for _, arrayContent := range []bool{false, true} {
+ t.Run(fmt.Sprintf("stream=%t/array=%t", stream, arrayContent), func(t *testing.T) {
+ wantRoles := []string{"system", "system", "developer", "user", "assistant", "developer", "user", "developer"}
+ wantTexts := []string{"base instructions", "session instructions", "mode: plan", "mode: default is only user text", "acknowledged", "mode: default", "continue", "mode: plan"}
+ input := make([]any, 0, len(wantRoles)-1)
+ for index := 1; index < len(wantRoles); index++ {
+ var content any = wantTexts[index]
+ if arrayContent {
+ contentType := "input_text"
+ if wantRoles[index] == "assistant" {
+ contentType = "output_text"
+ }
+ content = []any{map[string]any{"type": contentType, "text": wantTexts[index]}}
+ }
+ input = append(input, map[string]any{"type": "message", "role": wantRoles[index], "content": content})
+ }
+ payload, errMarshal := json.Marshal(map[string]any{"instructions": wantTexts[0], "input": input})
+ if errMarshal != nil {
+ t.Fatal(errMarshal)
+ }
+ output, errConvert := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test-model", payload, stream)
+ if errConvert != nil {
+ t.Fatal(errConvert)
+ }
+ if gotStream := ojson.GetBytesNoCopy(output, "stream").Bool(); gotStream != stream {
+ t.Fatalf("stream = %t, want %t", gotStream, stream)
+ }
+ messages := ojson.GetBytesNoCopy(output, "messages").Array()
+ if len(messages) != len(wantRoles) {
+ t.Fatalf("message count = %d, want %d; output=%s", len(messages), len(wantRoles), output)
+ }
+ for index, message := range messages {
+ if got := message.Get("role").String(); got != wantRoles[index] {
+ t.Errorf("message %d role = %q, want %q", index, got, wantRoles[index])
+ }
+ text := message.Get("content")
+ if text.IsArray() {
+ text = text.Get("0.text")
+ }
+ if text.String() != wantTexts[index] {
+ t.Errorf("message %d text = %q, want %q", index, text.String(), wantTexts[index])
+ }
+ }
+ })
+ }
+ }
+}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tool_search.go b/internal/translator/openai/openai/responses/openai_openai-responses_tool_search.go
new file mode 100644
index 00000000000..509dbc02b83
--- /dev/null
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_tool_search.go
@@ -0,0 +1,182 @@
+package responses
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/MoeForge/moego/ojson"
+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
+)
+
+// Chat Completions has no tool search, so the client-executed Responses
+// tool_search declaration is bridged as an ordinary function. Calls to it are
+// restored as tool_search_call items, the client runs the search, and the
+// tools it returns in tool_search_output stay declared as functions on every
+// later request (see walkResponsesToolDeclarations).
+
+const (
+ responsesToolSearchIDPrefix = "tsc_"
+ responsesToolSearchDefaultParameters = `{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}`
+)
+
+func convertResponsesToolSearchToolToOpenAIChat(tool ojson.Result, name string) ([]byte, bool) {
+ chatToolDoc := ojson.NewDocString(`{"type":"function","function":{"name":"","description":"Search for deferred tools and load the matching tools.","parameters":{}}}`)
+ _ = chatToolDoc.Set("function.name", name)
+ if description := util.ResponsesToolDescription(tool); description != "" {
+ _ = chatToolDoc.Set("function.description", description)
+ }
+ parameters := responsesToolSearchDefaultParameters
+ if declared := util.ResponsesToolParameters(tool); declared.Exists() && declared.IsObject() {
+ parameters = declared.Raw
+ }
+ _ = chatToolDoc.SetRaw("function.parameters", parameters)
+ return chatToolDoc.Bytes(), true
+}
+
+func (idx *responsesToolIndex) isToolSearch(name string) bool {
+ return idx.byChat[name].search
+}
+
+func (idx *responsesToolIndex) toolSearchName() string {
+ for _, d := range idx.declarations {
+ if d.search {
+ return d.chatName
+ }
+ }
+ return ""
+}
+
+// toolSearchHistory rewrites replayed client tool_search_call and
+// tool_search_output items into the function_call pair that the Chat
+// Completions conversion already groups and pairs. Hosted (server) search
+// items are left as they are.
+func (idx *responsesToolIndex) toolSearchHistory(items []ojson.Result) []ojson.Result {
+ var out []ojson.Result
+ name := ""
+ resolveName := func() string {
+ if name != "" {
+ return name
+ }
+ name = idx.toolSearchName()
+ if name != "" {
+ return name
+ }
+ reserved := make(map[string]bool)
+ for key := range idx.byChat {
+ reserved[key] = true
+ }
+ for key := range idx.byRaw {
+ reserved[key] = true
+ }
+ for key := range idx.byLocal {
+ reserved[key] = true
+ }
+ for _, item := range items {
+ if kind := item.Get("type").String(); kind == "function_call" || kind == "custom_tool_call" {
+ reserved[idx.canonicalName(item.Get("name").String())] = true
+ }
+ }
+ name = util.ResponsesToolSearchFunctionName
+ for suffix := 1; reserved[name]; suffix++ {
+ name = util.ResponsesToolSearchFunctionName + "_" + strconv.Itoa(suffix)
+ }
+ return name
+ }
+ for i, item := range items {
+ itemType := item.Get("type").String()
+ if (itemType != util.ResponsesToolSearchCallType && itemType != util.ResponsesToolSearchOutputType) ||
+ !util.IsResponsesClientToolSearchItem(item) {
+ if out != nil {
+ out = append(out, item)
+ }
+ continue
+ }
+ if out == nil {
+ out = append(make([]ojson.Result, 0, len(items)), items[:i]...)
+ }
+ if itemType == util.ResponsesToolSearchCallType {
+ out = append(out, idx.toolSearchCallAsFunctionCall(item, resolveName()))
+ } else {
+ out = append(out, idx.toolSearchOutputAsFunctionCallOutput(item))
+ }
+ }
+ if out == nil {
+ return items
+ }
+ return out
+}
+
+func (idx *responsesToolIndex) toolSearchCallAsFunctionCall(item ojson.Result, searchName string) ojson.Result {
+ callDoc := ojson.NewDocString(`{"type":"function_call","call_id":"","name":"","arguments":"{}"}`)
+ _ = callDoc.Set("call_id", translatorcommon.ExtractResponsesCallID(item))
+ _ = callDoc.Set("name", searchName)
+ if arguments := item.Get("arguments"); arguments.Type == ojson.String && strings.TrimSpace(arguments.String()) != "" {
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(callDoc, "arguments", arguments.String())
+ } else if arguments.IsObject() {
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(callDoc, "arguments", arguments.Raw)
+ }
+ return util.ParseGJSONBytesNoCopy(callDoc.Bytes())
+}
+
+func (idx *responsesToolIndex) toolSearchOutputAsFunctionCallOutput(item ojson.Result) ojson.Result {
+ names := make([]string, 0)
+ seen := make(map[string]struct{})
+ appendName := func(name string) {
+ if _, exists := seen[name]; name == "" || exists {
+ return
+ }
+ seen[name] = struct{}{}
+ names = append(names, name)
+ }
+ item.Get("tools").ForEach(func(_, tool ojson.Result) bool {
+ if strings.TrimSpace(tool.Get("type").String()) == "namespace" {
+ namespaceName := strings.TrimSpace(tool.Get("name").String())
+ tool.Get("tools").ForEach(func(_, child ojson.Result) bool {
+ if childName := responsesToolName(child); childName != "" {
+ appendName(idx.namespaceName(namespaceName, childName))
+ }
+ return true
+ })
+ } else if name := responsesToolName(tool); name != "" {
+ appendName(idx.canonicalName(name))
+ }
+ return true
+ })
+
+ summaryDoc := ojson.NewDocString(`{"status":"completed","tools":[]}`)
+ if status := strings.TrimSpace(item.Get("status").String()); status != "" {
+ _ = summaryDoc.Set("status", status)
+ }
+ _ = summaryDoc.Set("tools", names)
+ outputDoc := ojson.NewDocString(`{"type":"function_call_output","call_id":"","output":""}`)
+ _ = outputDoc.Set("call_id", translatorcommon.ExtractResponsesCallID(item))
+ _ = translatorcommon.SetStringWithoutHTMLEscapeDoc(outputDoc, "output", summaryDoc.String())
+ return util.ParseGJSONBytesNoCopy(outputDoc.Bytes())
+}
+
+// toolSearchCallItem renders a bridged search function call as a
+// client-executed Responses tool_search_call with object arguments.
+func toolSearchCallItem(callID, args, status string) []byte {
+ trimmed := strings.TrimSpace(args)
+ parsedArgs := ojson.Parse(trimmed)
+ validArgs := ojson.Valid(trimmed) && parsedArgs.IsObject()
+ if status == "completed" && !validArgs {
+ status = "incomplete"
+ }
+ itemDoc := ojson.NewDocString(`{"id":"","type":"tool_search_call","status":"","execution":"client","call_id":"","arguments":{}}`)
+ _ = itemDoc.Set("id", responsesToolSearchIDPrefix+callID)
+ _ = itemDoc.Set("status", status)
+ _ = itemDoc.Set("call_id", callID)
+ if validArgs {
+ _ = itemDoc.SetRaw("arguments", parsedArgs.Raw)
+ }
+ return itemDoc.Bytes()
+}
+
+func toolSearchCallPlaceholder(callID string) []byte {
+ itemDoc := ojson.NewDocString(`{"id":"","type":"tool_search_call","status":"in_progress","execution":"client","call_id":"","arguments":{}}`)
+ _ = itemDoc.Set("id", responsesToolSearchIDPrefix+callID)
+ _ = itemDoc.Set("call_id", callID)
+ return itemDoc.Bytes()
+}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tool_search_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_tool_search_test.go
new file mode 100644
index 00000000000..c421c6ba2bf
--- /dev/null
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_tool_search_test.go
@@ -0,0 +1,202 @@
+package responses
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/MoeForge/moego/ojson"
+)
+
+// Synthetic Codex request: one eager tool, the client tool_search declaration,
+// and one completed search round that returned a namespaced tool.
+const chatToolSearchTestRequest = `{
+ "model": "chat-test",
+ "stream": true,
+ "tools": [
+ {"type":"function","name":"exec_command","parameters":{"type":"object","properties":{"cmd":{"type":"string"}}}},
+ {"type":"tool_search","execution":"client","description":"Search deferred tools","parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"}},"required":["query"]}}
+ ],
+ "input": [
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"list my calendars"}]},
+ {"type":"tool_search_call","call_id":"call_ts1","status":"completed","execution":"client","arguments":{"query":"calendar"}},
+ {"type":"tool_search_output","call_id":"call_ts1","status":"completed","execution":"client","tools":[
+ {"type":"namespace","name":"mcp__demo__cal","description":"Calendar","tools":[
+ {"type":"function","name":"_list","description":"List calendars","defer_loading":true,"parameters":{"type":"object","properties":{}}}
+ ]}
+ ]}
+ ]
+}`
+
+func TestChatToolSearch_RequestBridgesSearchAndDeclaresLoadedTools(t *testing.T) {
+ out, errConvert := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("chat-test", []byte(chatToolSearchTestRequest), true)
+ if errConvert != nil {
+ t.Fatalf("convert request: %v", errConvert)
+ }
+
+ names := ojson.GetBytes(out, "tools.#.function.name").Raw
+ for _, want := range []string{`"exec_command"`, `"tool_search"`, `"mcp__demo__cal___list"`} {
+ if !strings.Contains(names, want) {
+ t.Fatalf("tools %s missing %s", names, want)
+ }
+ }
+ search := ojson.GetBytes(out, `tools.#(function.name=="tool_search").function`)
+ if search.Get("parameters.properties.limit").Type == ojson.Null {
+ t.Fatalf("tool_search schema lost: %s", search.Raw)
+ }
+
+ assistant := ojson.GetBytes(out, `messages.#(role=="assistant")`)
+ call := assistant.Get("tool_calls.0")
+ if call.Get("id").String() != "call_ts1" || call.Get("function.name").String() != "tool_search" {
+ t.Fatalf("search call not bridged: %s", assistant.Raw)
+ }
+ if ojson.Get(call.Get("function.arguments").String(), "query").String() != "calendar" {
+ t.Fatalf("search arguments lost: %s", call.Raw)
+ }
+ tool := ojson.GetBytes(out, `messages.#(role=="tool")`)
+ if tool.Get("tool_call_id").String() != "call_ts1" || !strings.Contains(tool.Get("content").String(), "mcp__demo__cal___list") {
+ t.Fatalf("search output not bridged: %s", tool.Raw)
+ }
+}
+
+func TestChatToolSearch_StreamResponseRestoresSearchCall(t *testing.T) {
+ lines := []string{
+ `data: {"id":"r1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_s","type":"function","function":{"name":"tool_search","arguments":""}}]},"finish_reason":null}]}`,
+ `data: {"id":"r1","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\":\"cal\"}"}}]},"finish_reason":"tool_calls"}]}`,
+ `data: [DONE]`,
+ }
+ var param any
+ var added, done, completed ojson.Result
+ for _, line := range lines {
+ for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "m", []byte(chatToolSearchTestRequest), nil, []byte(line), ¶m) {
+ event, data := parseOpenAIResponsesSSEEvent(t, chunk)
+ if strings.HasPrefix(event, "response.function_call_arguments") {
+ t.Fatalf("search call must not stream function argument events: %s", event)
+ }
+ switch {
+ case event == "response.output_item.added" && data.Get("item.type").String() == "tool_search_call":
+ added = data
+ case event == "response.output_item.done" && data.Get("item.type").String() == "tool_search_call":
+ done = data
+ case event == "response.completed":
+ completed = data
+ }
+ }
+ }
+ if !added.Exists() || added.Get("item.call_id").String() != "call_s" {
+ t.Fatalf("missing tool_search_call added event")
+ }
+ if done.Get("item.arguments.query").String() != "cal" || done.Get("item.execution").String() != "client" {
+ t.Fatalf("bad tool_search_call done event: %s", done.Raw)
+ }
+ if completed.Get("response.output.0.type").String() != "tool_search_call" {
+ t.Fatalf("completed output = %s", completed.Get("response.output").Raw)
+ }
+}
+
+func TestChatToolSearch_NonStreamResponseRestoresSearchAndLoadedToolCalls(t *testing.T) {
+ resp := `{"id":"r2","object":"chat.completion","created":1,"model":"m","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[
+ {"id":"call_a","type":"function","function":{"name":"tool_search","arguments":"{\"query\":\"cal\"}"}},
+ {"id":"call_b","type":"function","function":{"name":"mcp__demo__cal___list","arguments":"{}"}}
+ ]}}]}`
+ out := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "m", []byte(chatToolSearchTestRequest), nil, []byte(resp), nil)
+ search := ojson.GetBytes(out, `output.#(call_id=="call_a")`)
+ if search.Get("type").String() != "tool_search_call" || search.Get("arguments.query").String() != "cal" {
+ t.Fatalf("want tool_search_call, got %s", search.Raw)
+ }
+ loaded := ojson.GetBytes(out, `output.#(call_id=="call_b")`)
+ if loaded.Get("type").String() != "function_call" || loaded.Get("name").String() != "_list" || loaded.Get("namespace").String() != "mcp__demo__cal" {
+ t.Fatalf("loaded tool call must restore namespace identity: %s", loaded.Raw)
+ }
+}
+
+func TestChatToolSearch_HostedSearchIsNotBridged(t *testing.T) {
+ raw := `{"model":"chat-test","tools":[{"type":"tool_search","execution":"server"}],"input":[
+ {"type":"message","role":"user","content":"hi"},
+ {"type":"tool_search_call","call_id":"call_h","execution":"server","arguments":{"query":"x"}},
+ {"type":"tool_search_output","call_id":"call_h","execution":"server","tools":[{"type":"function","name":"hosted_tool","parameters":{"type":"object"}}]}
+ ]}`
+ out, errConvert := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("chat-test", []byte(raw), false)
+ if errConvert != nil {
+ t.Fatalf("convert request: %v", errConvert)
+ }
+ if tools := ojson.GetBytes(out, "tools"); tools.Exists() && len(tools.Array()) > 0 {
+ t.Fatalf("hosted search must not declare tools: %s", tools.Raw)
+ }
+ if strings.Contains(string(out), "call_h") {
+ t.Fatalf("hosted search history must not be replayed: %s", out)
+ }
+}
+
+func TestChatToolSearch_NameCollisionKeepsUserFunction(t *testing.T) {
+ raw := `{"model":"chat-test","tools":[
+ {"type":"function","name":"tool_search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}},
+ {"type":"tool_search","execution":"client"}
+ ],"input":[{"type":"message","role":"user","content":"hi"}]}`
+ out, errConvert := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("chat-test", []byte(raw), false)
+ if errConvert != nil {
+ t.Fatalf("convert request: %v", errConvert)
+ }
+ names := ojson.GetBytes(out, "tools.#.function.name").Array()
+ if len(names) != 2 || names[0].String() == names[1].String() {
+ t.Fatalf("both tools must be declared with distinct names: %s", ojson.GetBytes(out, "tools").Raw)
+ }
+
+ resp := `{"id":"r3","object":"chat.completion","created":1,"model":"m","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[
+ {"id":"call_u","type":"function","function":{"name":"tool_search","arguments":"{\"q\":\"x\"}"}},
+ {"id":"call_s","type":"function","function":{"name":"tool_search_1","arguments":"{\"query\":\"y\"}"}}
+ ]}}]}`
+ restored := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "m", []byte(raw), nil, []byte(resp), nil)
+ if user := ojson.GetBytes(restored, `output.#(call_id=="call_u")`); user.Get("type").String() != "function_call" || user.Get("name").String() != "tool_search" {
+ t.Fatalf("user function must stay a function_call: %s", user.Raw)
+ }
+ if search := ojson.GetBytes(restored, `output.#(call_id=="call_s")`); search.Get("type").String() != "tool_search_call" || search.Get("arguments.query").String() != "y" {
+ t.Fatalf("renamed search call must restore as tool_search_call: %s", search.Raw)
+ }
+}
+
+func TestChatToolSearch_ToolChoiceAndIncompleteArguments(t *testing.T) {
+ raw := `{"model":"chat-test","tools":[
+ {"type":"function","name":"tool_search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}},
+ {"type":"tool_search","execution":"client"}
+ ],"tool_choice":{"type":"tool_search"},"input":[{"type":"message","role":"user","content":"hi"}]}`
+ out, errConvert := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("chat-test", []byte(raw), false)
+ if errConvert != nil {
+ t.Fatalf("convert request: %v", errConvert)
+ }
+ if got := ojson.GetBytes(out, "tool_choice.function.name").String(); got != "tool_search_1" {
+ t.Fatalf("tool_choice.function.name = %q, want tool_search_1; raw: %s", got, out)
+ }
+
+ resp := `{"id":"r4","object":"chat.completion","created":1,"model":"m","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[
+ {"id":"call_trunc","type":"function","function":{"name":"tool_search_1","arguments":"{\"query\":"}}
+ ]}}]}`
+ restored := ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "m", []byte(raw), nil, []byte(resp), nil)
+ search := ojson.GetBytes(restored, `output.#(call_id=="call_trunc")`)
+ if search.Get("type").String() != "tool_search_call" || search.Get("status").String() != "incomplete" {
+ t.Fatalf("truncated search call must be incomplete tool_search_call: %s", search.Raw)
+ }
+
+ emptyLines := []string{
+ `data: {"id":"r5","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_empty","type":"function","function":{"name":"tool_search_1","arguments":""}}]},"finish_reason":"tool_calls"}]}`,
+ `data: [DONE]`,
+ }
+ var param any
+ var doneItem, completedItem ojson.Result
+ for _, line := range emptyLines {
+ for _, chunk := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "m", []byte(raw), nil, []byte(line), ¶m) {
+ event, data := parseOpenAIResponsesSSEEvent(t, chunk)
+ switch event {
+ case "response.output_item.done":
+ doneItem = data.Get("item")
+ case "response.completed":
+ completedItem = data.Get("response.output.0")
+ }
+ }
+ }
+ emptyNonStream := `{"id":"r5","object":"chat.completion","created":1,"model":"m","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_empty","type":"function","function":{"name":"tool_search_1","arguments":""}}]}}]}`
+ nonStreamItem := ojson.GetBytes(ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "m", []byte(raw), nil, []byte(emptyNonStream), nil), "output.0")
+ if doneItem.Get("status").String() != "incomplete" || doneItem.Raw != completedItem.Raw || doneItem.Raw != nonStreamItem.Raw {
+ t.Fatalf("empty-args tool_search_call mismatch: done=%s completed=%s nonstream=%s", doneItem.Raw, completedItem.Raw, nonStreamItem.Raw)
+ }
+}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go
index dd0a1405b20..7712186baa3 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_tools.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_tools.go
@@ -4,10 +4,10 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
// responsesToolDeclaration is one Responses tool declaration paired with the
@@ -15,12 +15,13 @@ import (
// their declared name and the owning namespace, so reverse translation can
// restore the split identity.
type responsesToolDeclaration struct {
- tool gjson.Result
+ tool ojson.Result
chatName string
localName string
namespace string
custom bool
shell bool
+ search bool // client tool_search, bridged as a function
}
// walkResponsesToolDeclarations visits the tool declarations of a Responses
@@ -34,10 +35,10 @@ type responsesToolDeclaration struct {
// onto the same name. Request conversion, reverse name resolution and freeform
// tool classification all traverse through here, so they cannot disagree about
// which declaration backs a given Chat Completions tool name.
-func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDeclaration) bool) {
+func walkResponsesToolDeclarations(root ojson.Result, visit func(responsesToolDeclaration) bool) {
var declarations []responsesToolDeclaration
- emit := func(tool gjson.Result, namespaceName string) {
- var custom, shell bool
+ emit := func(tool ojson.Result, namespaceName string) {
+ var custom, shell, search bool
switch strings.TrimSpace(tool.Get("type").String()) {
case "", "function":
case "custom":
@@ -47,12 +48,19 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe
return
}
shell = true
+ case util.ResponsesToolSearchType:
+ if namespaceName != "" || !util.IsResponsesClientToolSearchDeclaration(tool) {
+ return
+ }
+ search = true
default:
return
}
localName := responsesToolName(tool)
if shell {
localName = "__cpa_local_shell"
+ } else if search {
+ localName = util.ResponsesToolSearchFunctionName
}
if localName == "" {
return
@@ -64,17 +72,18 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe
namespace: namespaceName,
custom: custom,
shell: shell,
+ search: search,
})
}
- scan := func(tools gjson.Result) {
+ scan := func(tools ojson.Result) {
if !tools.Exists() || !tools.IsArray() {
return
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
if strings.TrimSpace(tool.Get("type").String()) == "namespace" {
if children := tool.Get("tools"); children.Exists() && children.IsArray() {
namespaceName := strings.TrimSpace(tool.Get("name").String())
- children.ForEach(func(_, child gjson.Result) bool {
+ children.ForEach(func(_, child ojson.Result) bool {
emit(child, namespaceName)
return true
})
@@ -88,18 +97,26 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe
scan(root.Get("tools"))
if input := root.Get("input"); input.Exists() && input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
- if item.Get("type").String() == "additional_tools" {
+ input.ForEach(func(_, item ojson.Result) bool {
+ switch item.Get("type").String() {
+ case "additional_tools":
scan(item.Get("tools"))
+ case util.ResponsesToolSearchOutputType:
+ // Tools loaded by a client tool search stay declared on every
+ // later request, so the model can call them.
+ if util.IsResponsesClientToolSearchItem(item) {
+ scan(item.Get("tools"))
+ }
}
return true
})
}
- // Reserve user identities before assigning the synthetic shell name.
+ // Reserve user identities before assigning the synthetic shell and
+ // tool_search names.
reserved := make(map[string]bool)
for _, d := range declarations {
- if !d.shell {
+ if !d.shell && !d.search {
reserved[d.localName] = true
reserved[d.chatName] = true
reserved[rawResponsesNamespaceQualifiedName(d.namespace, d.localName)] = true
@@ -109,10 +126,17 @@ func walkResponsesToolDeclarations(root gjson.Result, visit func(responsesToolDe
for suffix := 1; reserved[shellName]; suffix++ {
shellName = "__cpa_local_shell_" + strconv.Itoa(suffix)
}
+ searchName := util.ResponsesToolSearchFunctionName
+ for suffix := 1; reserved[searchName]; suffix++ {
+ searchName = util.ResponsesToolSearchFunctionName + "_" + strconv.Itoa(suffix)
+ }
for i := range declarations {
if declarations[i].shell {
declarations[i].localName = shellName
declarations[i].chatName = shellName
+ } else if declarations[i].search {
+ declarations[i].localName = searchName
+ declarations[i].chatName = searchName
}
}
disambiguateResponsesChatToolNames(declarations)
@@ -228,14 +252,14 @@ func disambiguateResponsesChatToolNames(declarations []responsesToolDeclaration)
// wins, which keeps the top-level "tools" definition authoritative over the
// "additional_tools" copy. Chat Completions requires tool names to be unique;
// strict upstreams reject the whole request otherwise.
-func mergeResponsesRequestChatTools(root gjson.Result) [][]byte {
+func mergeResponsesRequestChatTools(root ojson.Result) [][]byte {
return newResponsesToolIndex(root).chatTools()
}
// convertResponsesCustomToolToOpenAIChat maps a Responses freeform ("custom")
// tool onto a Chat Completions function tool with a single freeform "input"
// string, mirroring the function-based shape Codex uses for apply_patch.
-func convertResponsesCustomToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
+func convertResponsesCustomToolToOpenAIChat(tool ojson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
@@ -243,16 +267,16 @@ func convertResponsesCustomToolToOpenAIChat(tool gjson.Result, overrideName stri
if name == "" {
return nil, false
}
- chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}`)
- chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
+ chatToolDoc := ojson.NewDocString(`{"type":"function","function":{"name":"","description":"","parameters":{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}}}`)
+ _ = chatToolDoc.Set("function.name", name)
if description := responsesToolDescription(tool); description != "" {
- chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
+ _ = chatToolDoc.Set("function.description", description)
}
if applypatch.IsCustomTool(tool) {
- chatTool, _ = sjson.SetBytes(chatTool, "function.description", applypatch.Description(tool))
- chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", applypatch.Parameters())
+ _ = chatToolDoc.Set("function.description", applypatch.Description(tool))
+ _ = chatToolDoc.SetRawBytes("function.parameters", applypatch.Parameters())
}
- return chatTool, true
+ return chatToolDoc.Bytes(), true
}
// isApplyPatch resolves only the original winning custom declaration.
@@ -261,7 +285,7 @@ func (idx *responsesToolIndex) isApplyPatch(name string) bool {
return ok && d.custom && applypatch.IsCustomTool(d.tool)
}
-func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName string) ([]byte, bool) {
+func convertResponsesFunctionToolToOpenAIChat(tool ojson.Result, overrideName string) ([]byte, bool) {
name := strings.TrimSpace(overrideName)
if name == "" {
name = responsesToolName(tool)
@@ -270,32 +294,32 @@ func convertResponsesFunctionToolToOpenAIChat(tool gjson.Result, overrideName st
return nil, false
}
- chatTool := []byte(`{"type":"function","function":{"name":"","description":"","parameters":{}}}`)
- chatTool, _ = sjson.SetBytes(chatTool, "function.name", name)
+ chatToolDoc := ojson.NewDocString(`{"type":"function","function":{"name":"","description":"","parameters":{}}}`)
+ _ = chatToolDoc.Set("function.name", name)
if description := responsesToolDescription(tool); description != "" {
- chatTool, _ = sjson.SetBytes(chatTool, "function.description", description)
+ _ = chatToolDoc.Set("function.description", description)
}
if parameters := responsesToolParameters(tool); parameters.Exists() {
- chatTool, _ = sjson.SetRawBytes(chatTool, "function.parameters", []byte(parameters.Raw))
+ _ = chatToolDoc.SetRaw("function.parameters", parameters.Raw)
}
- return chatTool, true
+ return chatToolDoc.Bytes(), true
}
-func responsesToolName(tool gjson.Result) string {
+func responsesToolName(tool ojson.Result) string {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return name
}
return strings.TrimSpace(tool.Get("function.name").String())
}
-func responsesToolDescription(tool gjson.Result) string {
+func responsesToolDescription(tool ojson.Result) string {
if description := tool.Get("description").String(); description != "" {
return description
}
return tool.Get("function.description").String()
}
-func responsesToolParameters(tool gjson.Result) gjson.Result {
+func responsesToolParameters(tool ojson.Result) ojson.Result {
for _, path := range []string{
"parameters",
"parametersJsonSchema",
@@ -307,20 +331,20 @@ func responsesToolParameters(tool gjson.Result) gjson.Result {
return parameters
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
// responsesToolOutputText flattens a tool output value that may be a plain
// string or an array of content parts ({"type":"input_text","text":...}) into
// a single text payload for a Chat Completions tool message.
-func responsesToolOutputText(output gjson.Result) string {
- if output.Type == gjson.String {
+func responsesToolOutputText(output ojson.Result) string {
+ if output.Type == ojson.String {
return output.String()
}
if output.IsArray() {
var b strings.Builder
- output.ForEach(func(_, part gjson.Result) bool {
- if part.Type == gjson.String {
+ output.ForEach(func(_, part ojson.Result) bool {
+ if part.Type == ojson.String {
b.WriteString(part.String())
return true
}
@@ -347,19 +371,19 @@ func responsesToolOutputText(output gjson.Result) string {
// a discarded custom declaration must not turn a surviving ordinary function
// into a custom_tool_call.
func responsesCustomToolNames(requestRawJSON []byte) map[string]struct{} {
- return newResponsesToolIndex(gjson.ParseBytes(requestRawJSON)).custom
+ return newResponsesToolIndex(util.ParseGJSONBytesNoCopy(requestRawJSON)).custom
}
func responsesSingleCustomToolName(requestRawJSON []byte) (string, bool) {
- return newResponsesToolIndex(gjson.ParseBytes(requestRawJSON)).singleCustomName()
+ return newResponsesToolIndex(util.ParseGJSONBytesNoCopy(requestRawJSON)).singleCustomName()
}
// unwrapCustomToolInput extracts the freeform input from the {"input": "..."}
// function-call arguments produced for a converted custom tool; it falls back
// to the raw arguments when the wrapper is absent.
func unwrapCustomToolInput(arguments string) string {
- if v := gjson.Get(arguments, "input"); v.Exists() {
- if v.Type == gjson.String {
+ if v := ojson.Get(arguments, "input"); v.Exists() {
+ if v.Type == ojson.String {
return v.String()
}
return v.Raw
@@ -422,7 +446,7 @@ func capResponsesChatToolName(name string) string {
// reports the identity of the declaration that actually survived the merge. A
// flat top-level tool named "editor__apply_patch" therefore stays flat even
// when a later namespace declares a child qualifying to the same name.
-func resolveResponsesQualifiedToolIdentity(root gjson.Result, qualifiedName string) (name, namespace string, found bool) {
+func resolveResponsesQualifiedToolIdentity(root ojson.Result, qualifiedName string) (name, namespace string, found bool) {
d, found := newResponsesToolIndex(root).byChat[qualifiedName]
return d.localName, d.namespace, found
}
@@ -434,7 +458,7 @@ func resolveResponsesQualifiedToolIdentity(root gjson.Result, qualifiedName stri
// calls stay consistent with their declarations even when a collision renamed
// the tool. Unknown identities fall back to plain namespace qualification.
func chatNameForResponsesNamespaceToolCall(requestRawJSON []byte, namespace, localName string) string {
- return newResponsesToolIndex(gjson.ParseBytes(requestRawJSON)).namespaceName(namespace, localName)
+ return newResponsesToolIndex(util.ParseGJSONBytesNoCopy(requestRawJSON)).namespaceName(namespace, localName)
}
// canonicalResponsesToolName resolves a name carried by a replayed call or
@@ -447,7 +471,7 @@ func chatNameForResponsesNamespaceToolCall(requestRawJSON []byte, namespace, loc
// also another namespace's declared child. Ambiguous names remain unresolved
// rather than being dispatched to another tool.
func canonicalResponsesToolName(requestRawJSON []byte, name string) string {
- return newResponsesToolIndex(gjson.ParseBytes(requestRawJSON)).canonicalName(name)
+ return newResponsesToolIndex(util.ParseGJSONBytesNoCopy(requestRawJSON)).canonicalName(name)
}
// avoidResponsesDeclaredChatAliases keeps a fallback name (the blind cap of an
@@ -455,7 +479,7 @@ func canonicalResponsesToolName(requestRawJSON []byte, name string) string {
// declarations actually emit. Dispatching such a call to a real declaration
// would silently invoke the wrong tool; a distinct name keeps the identity
// unresolved instead, which upstreams and clients can surface properly.
-func avoidResponsesDeclaredChatAliases(root gjson.Result, candidate string) string {
+func avoidResponsesDeclaredChatAliases(root ojson.Result, candidate string) string {
return newResponsesToolIndex(root).avoidAlias(candidate)
}
@@ -465,17 +489,17 @@ func splitResponsesQualifiedFunctionCallFromRequest(requestRawJSON []byte, quali
return "", ""
}
- if resolvedName, resolvedNamespace, ok := resolveResponsesQualifiedToolIdentity(gjson.ParseBytes(requestRawJSON), qualifiedName); ok {
+ if resolvedName, resolvedNamespace, ok := resolveResponsesQualifiedToolIdentity(util.ParseGJSONBytesNoCopy(requestRawJSON), qualifiedName); ok {
return resolvedName, resolvedNamespace
}
return qualifiedName, ""
}
func pickRequestJSON(originalRequestRawJSON, requestRawJSON []byte) []byte {
- if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
+ if len(originalRequestRawJSON) > 0 && ojson.ValidBytes(originalRequestRawJSON) {
return originalRequestRawJSON
}
- if len(requestRawJSON) > 0 && gjson.ValidBytes(requestRawJSON) {
+ if len(requestRawJSON) > 0 && ojson.ValidBytes(requestRawJSON) {
return requestRawJSON
}
return nil
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_user_turn_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_user_turn_test.go
index f587603e62e..2e12b5afcc7 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_user_turn_test.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_user_turn_test.go
@@ -6,9 +6,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -79,7 +79,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_RefusesAnyEmptiedU
if got, want := err.Error(), "unsupported content part: "+tc.wantType; got != want {
t.Fatalf("error text = %q, want %q", got, want)
}
- if !gjson.ValidBytes(body) {
+ if !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
@@ -117,7 +117,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MapsFilesAndAudio(
if err != nil {
t.Fatalf("err = %v", err)
}
- part := gjson.GetBytes(body, "messages.2.content.0")
+ part := ojson.GetBytesNoCopy(body, "messages.2.content.0")
for path, want := range tc.want {
if got := part.Get(path).String(); got != want {
t.Fatalf("%s = %q, want %q; part = %s, body = %s", path, got, want, part.Raw, body)
@@ -135,7 +135,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_KeepsTurnWithTextB
if err != nil {
t.Fatalf("err = %v", err)
}
- if got := gjson.GetBytes(body, "messages.2.content.0.text").String(); got != "keep me" {
+ if got := ojson.GetBytesNoCopy(body, "messages.2.content.0.text").String(); got != "keep me" {
t.Fatalf("text was lost: %s", body)
}
})
@@ -148,7 +148,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MappedAttachmentBe
if err != nil {
t.Fatalf("err = %v", err)
}
- content := gjson.GetBytes(body, "messages.0.content")
+ content := ojson.GetBytesNoCopy(body, "messages.0.content")
if got := content.Get("#").Int(); got != 3 {
t.Fatalf("content = %s, want text, file and audio", content.Raw)
}
@@ -156,7 +156,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MappedAttachmentBe
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_ExportedWrapperKeepsAJSONBody(t *testing.T) {
input := chatPayload("", chatUserTurn(chatTurnFileURL))
- if body, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5", input, false); !gjson.ValidBytes(body) {
+ if body, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("gpt-5", input, false); !ojson.ValidBytes(body) {
t.Fatalf("body is not JSON: %q", body)
}
}
diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_video_test.go b/internal/translator/openai/openai/responses/openai_openai-responses_video_test.go
index 1df4689f2c2..5e65700fc91 100644
--- a/internal/translator/openai/openai/responses/openai_openai-responses_video_test.go
+++ b/internal/translator/openai/openai/responses/openai_openai-responses_video_test.go
@@ -6,7 +6,7 @@ import (
"reflect"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_VideoInput(t *testing.T) {
@@ -62,7 +62,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_VideoInput(t *test
t.Run(fmt.Sprintf("%s/stream=%t", tt.name, stream), func(t *testing.T) {
raw := []byte(`{"input":[{"role":"user","content":[` + tt.part + `]}]}`)
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("video-model", raw, stream)
- content := gjson.GetBytes(out, "messages.0.content").Array()
+ content := ojson.GetBytesNoCopy(out, "messages.0.content").Array()
if len(content) != 1 {
t.Fatalf("video content was lost: got %d parts, want 1; output=%s", len(content), out)
}
@@ -98,7 +98,7 @@ func TestConvertOpenAIResponsesRequestToOpenAIChatCompletions_MixedVideoInputOrd
t.Fatal(err)
}
out, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("video-model", raw, false)
- if got := gjson.GetBytes(out, "messages.0.content").Value(); !reflect.DeepEqual(got, want) {
+ if got := ojson.GetBytesNoCopy(out, "messages.0.content").Value(); !reflect.DeepEqual(got, want) {
t.Fatalf("mixed content order or media changed: output=%s", out)
}
}
diff --git a/internal/translator/openai/openai/responses/responses_tool_index.go b/internal/translator/openai/openai/responses/responses_tool_index.go
index 353e897178e..65d517c5557 100644
--- a/internal/translator/openai/openai/responses/responses_tool_index.go
+++ b/internal/translator/openai/openai/responses/responses_tool_index.go
@@ -4,8 +4,8 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
)
type responsesToolIdentity struct {
@@ -24,7 +24,7 @@ type responsesToolIndex struct {
custom map[string]struct{}
}
-func newResponsesToolIndex(root gjson.Result) *responsesToolIndex {
+func newResponsesToolIndex(root ojson.Result) *responsesToolIndex {
idx := &responsesToolIndex{
byChat: make(map[string]responsesToolDeclaration),
byIdentity: make(map[responsesToolIdentity]string),
@@ -99,6 +99,14 @@ func (idx *responsesToolIndex) applyIdentity(item []byte, qualifiedName, itemPat
return translatorcommon.SetResponsesToolCallIdentity(item, name, namespace, itemPath)
}
+func (idx *responsesToolIndex) applyIdentityDoc(doc *ojson.Doc, qualifiedName, itemPath string) {
+ name, namespace := strings.TrimSpace(qualifiedName), ""
+ if d, ok := idx.byChat[name]; ok {
+ name, namespace = d.localName, d.namespace
+ }
+ translatorcommon.SetResponsesToolCallIdentityDoc(doc, name, namespace, itemPath)
+}
+
func (idx *responsesToolIndex) singleCustomName() (string, bool) {
if len(idx.custom) == 1 {
for name := range idx.custom {
@@ -120,6 +128,8 @@ func (idx *responsesToolIndex) chatTools() [][]byte {
convert = convertResponsesCustomToolToOpenAIChat
} else if d.shell {
convert = convertResponsesShellToolToOpenAIChat
+ } else if d.search {
+ convert = convertResponsesToolSearchToolToOpenAIChat
}
if tool, ok := convert(d.tool, d.chatName); ok {
merged = append(merged, tool)
diff --git a/internal/translator/openai/openai/responses/shell_tool.go b/internal/translator/openai/openai/responses/shell_tool.go
index 9b8f9cd8ffc..3114d90ba20 100644
--- a/internal/translator/openai/openai/responses/shell_tool.go
+++ b/internal/translator/openai/openai/responses/shell_tool.go
@@ -6,16 +6,16 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
translatorcommon "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/common"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
)
var errInvalidShellAction = errors.New("invalid shell action: expected nonempty commands strings and optional positive integer limits")
-func convertResponsesShellToolToOpenAIChat(_ gjson.Result, name string) ([]byte, bool) {
+func convertResponsesShellToolToOpenAIChat(_ ojson.Result, name string) ([]byte, bool) {
tool := []byte(`{"type":"function","function":{"name":"","description":"Request commands to execute in the client-provided local shell environment. Each commands entry is a complete shell command, not an argv element.","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"},"minItems":1},"timeout_ms":{"type":"integer","minimum":1},"max_output_length":{"type":"integer","minimum":1}},"required":["commands"],"additionalProperties":false}}}`)
- tool, _ = sjson.SetBytes(tool, "function.name", name)
+ tool, _ = ojson.SetBytes(tool, "function.name", name)
return tool, true
}
@@ -34,7 +34,7 @@ func (idx *responsesToolIndex) shellName() string {
// Normalize shell history before the existing call grouping and output pairing.
// Keep the entire output envelope as JSON text, including truncation metadata.
-func (idx *responsesToolIndex) shellHistory(items []gjson.Result) []gjson.Result {
+func (idx *responsesToolIndex) shellHistory(items []ojson.Result) []ojson.Result {
name := idx.shellName()
if name == "" {
// Historical calls remain meaningful after the client withdraws the tool.
@@ -60,29 +60,31 @@ func (idx *responsesToolIndex) shellHistory(items []gjson.Result) []gjson.Result
}
}
for i, item := range items {
- raw := []byte(item.Raw)
+ var rawDoc *ojson.Doc
switch item.Get("type").String() {
case "shell_call":
if environment := item.Get("environment.type"); environment.Exists() && environment.String() != "local" {
continue
}
- raw, _ = sjson.SetBytes(raw, "type", "function_call")
- raw, _ = sjson.SetBytes(raw, "name", name)
- raw, _ = sjson.SetBytes(raw, "arguments", item.Get("action").Raw)
+ rawDoc = ojson.NewDocString(item.Raw)
+ _ = rawDoc.Set("type", "function_call")
+ _ = rawDoc.Set("name", name)
+ _ = rawDoc.Set("arguments", item.Get("action").Raw)
case "shell_call_output":
- raw, _ = sjson.SetBytes(raw, "type", "function_call_output")
- raw, _ = sjson.SetBytes(raw, "output", item.Raw)
+ rawDoc = ojson.NewDocString(item.Raw)
+ _ = rawDoc.Set("type", "function_call_output")
+ _ = rawDoc.Set("output", item.Raw)
default:
continue
}
- items[i] = gjson.ParseBytes(raw)
+ items[i] = util.ParseGJSONBytesNoCopy(rawDoc.Bytes())
}
return items
}
func shellCallItem(callID, arguments, status string) ([]byte, error) {
- action := gjson.Parse(arguments)
- if !gjson.Valid(arguments) || !action.IsObject() {
+ action := ojson.Parse(arguments)
+ if !ojson.Valid(arguments) || !action.IsObject() {
return nil, errInvalidShellAction
}
commands := action.Get("commands")
@@ -90,16 +92,16 @@ func shellCallItem(callID, arguments, status string) ([]byte, error) {
return nil, errInvalidShellAction
}
for _, command := range commands.Array() {
- if command.Type != gjson.String || strings.TrimSpace(command.String()) == "" {
+ if command.Type != ojson.String || strings.TrimSpace(command.String()) == "" {
return nil, errInvalidShellAction
}
}
valid := true
- action.ForEach(func(key, value gjson.Result) bool {
+ action.ForEach(func(key, value ojson.Result) bool {
switch key.String() {
case "commands":
case "timeout_ms", "max_output_length":
- if value.Type != gjson.Null && (value.Type != gjson.Number || value.Float() <= 0 || math.Trunc(value.Float()) != value.Float()) {
+ if value.Type != ojson.Null && (value.Type != ojson.Number || value.Float() <= 0 || math.Trunc(value.Float()) != value.Float()) {
valid = false
}
default:
@@ -110,23 +112,27 @@ func shellCallItem(callID, arguments, status string) ([]byte, error) {
if !valid {
return nil, errInvalidShellAction
}
- item := shellCallPlaceholder(callID)
- item, _ = sjson.SetBytes(item, "status", status)
- item, _ = sjson.SetRawBytes(item, "action", []byte(arguments))
- return item, nil
+ itemDoc := shellCallPlaceholderDoc(callID)
+ _ = itemDoc.Set("status", status)
+ _ = itemDoc.SetRaw("action", arguments)
+ return itemDoc.Bytes(), nil
+}
+
+func shellCallPlaceholderDoc(callID string) *ojson.Doc {
+ itemDoc := ojson.NewDocString(`{"id":"","type":"shell_call","status":"in_progress","call_id":"","action":{"commands":[]}}`)
+ _ = itemDoc.Set("id", "sh_"+callID)
+ _ = itemDoc.Set("call_id", callID)
+ return itemDoc
}
func shellCallPlaceholder(callID string) []byte {
- item := []byte(`{"id":"","type":"shell_call","status":"in_progress","call_id":"","action":{"commands":[]}}`)
- item, _ = sjson.SetBytes(item, "id", "sh_"+callID)
- item, _ = sjson.SetBytes(item, "call_id", callID)
- return item
+ return shellCallPlaceholderDoc(callID).Bytes()
}
func responsesToolInputFailure(responseID string, sequence int, err error) []byte {
failure := translatorcommon.ApplyPatchFailure(responseID, sequence)
if errors.Is(err, errInvalidShellAction) {
- failure, _ = sjson.SetBytes(failure, "response.error.message", errInvalidShellAction.Error())
+ failure, _ = ojson.SetBytes(failure, "response.error.message", errInvalidShellAction.Error())
}
return failure
}
diff --git a/internal/translator/openai/openai/responses/shell_tool_test.go b/internal/translator/openai/openai/responses/shell_tool_test.go
index b081abed32f..dc572005e7e 100644
--- a/internal/translator/openai/openai/responses/shell_tool_test.go
+++ b/internal/translator/openai/openai/responses/shell_tool_test.go
@@ -7,17 +7,16 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestLocalShellDeclarationSurvivesChatTranslation(t *testing.T) {
request := []byte(`{"tools":[{"type":"shell","environment":{"type":"local"}}],"tool_choice":"auto","input":"Run echo SHELL_OK"}`)
got, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
- if gjson.GetBytes(got, "tools.#").Int() != 1 {
+ if ojson.GetBytesNoCopy(got, "tools.#").Int() != 1 {
t.Fatalf("local shell declaration was dropped from chat request: %s", got)
}
- if gjson.GetBytes(got, "tools.0.function.parameters.properties.commands.items.type").String() != "string" {
+ if ojson.GetBytesNoCopy(got, "tools.0.function.parameters.properties.commands.items.type").String() != "string" {
t.Fatalf("shell action must expose commands as strings: %s", got)
}
}
@@ -25,26 +24,26 @@ func TestLocalShellDeclarationSurvivesChatTranslation(t *testing.T) {
const shellRequest = `{"tools":[{"type":"shell","environment":{"type":"local"}},{"type":"function","name":"__cpa_local_shell","parameters":{}},{"type":"custom","name":"__cpa_local_shell_1"},{"type":"namespace","name":"ns","tools":[{"type":"function","name":"lookup","parameters":{}}]}],"tool_choice":{"type":"shell"}}`
func shellUpstreamCall(name, args string) []byte {
- call := []byte(`{"index":0,"id":"call_shell","type":"function","function":{}}`)
- call, _ = sjson.SetBytes(call, "function.name", name)
- call, _ = sjson.SetBytes(call, "function.arguments", args)
- return call
+ callDoc := ojson.NewDocString(`{"index":0,"id":"call_shell","type":"function","function":{}}`)
+ _ = callDoc.Set("function.name", name)
+ _ = callDoc.Set("function.arguments", args)
+ return callDoc.Bytes()
}
func shellNonStream(request []byte, name, args string) []byte {
upstream := []byte(`{"id":"resp_test","choices":[{"index":0,"finish_reason":"tool_calls","message":{"role":"assistant","tool_calls":[]}}]}`)
- upstream, _ = sjson.SetRawBytes(upstream, "choices.0.message.tool_calls.0", shellUpstreamCall(name, args))
+ upstream, _ = ojson.SetRawBytes(upstream, "choices.0.message.tool_calls.0", shellUpstreamCall(name, args))
return ConvertOpenAIChatCompletionsResponseToOpenAIResponsesNonStream(context.Background(), "test", request, nil, upstream, nil)
}
-func shellStream(request []byte, name, args string) []gjson.Result {
+func shellStream(request []byte, name, args string) []ojson.Result {
var state any
- var events []gjson.Result
+ var events []ojson.Result
push := func(chunk []byte) {
for _, output := range ConvertOpenAIChatCompletionsResponseToOpenAIResponses(context.Background(), "test", request, nil, chunk, &state) {
for _, line := range strings.Split(string(output), "\n") {
if strings.HasPrefix(line, "data: ") {
- events = append(events, gjson.Parse(strings.TrimPrefix(line, "data: ")))
+ events = append(events, ojson.Parse(strings.TrimPrefix(line, "data: ")))
}
}
}
@@ -53,10 +52,12 @@ func shellStream(request []byte, name, args string) []gjson.Result {
chunk := []byte(`{"id":"resp_test","choices":[{"index":0,"delta":{"tool_calls":[]}}]}`)
call := shellUpstreamCall(name, fragment)
if i == 1 {
- call, _ = sjson.DeleteBytes(call, "id")
- call, _ = sjson.DeleteBytes(call, "function.name")
+ callDoc := ojson.NewDoc(call)
+ _ = callDoc.Delete("id")
+ _ = callDoc.Delete("function.name")
+ call = callDoc.Bytes()
}
- chunk, _ = sjson.SetRawBytes(chunk, "choices.0.delta.tool_calls.0", call)
+ chunk, _ = ojson.SetRawBytes(chunk, "choices.0.delta.tool_calls.0", call)
push(append([]byte("data: "), chunk...))
}
push([]byte(`data: {"id":"resp_test","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`))
@@ -67,20 +68,20 @@ func shellStream(request []byte, name, args string) []gjson.Result {
func TestLocalShellRoundTrip(t *testing.T) {
request := []byte(shellRequest)
chat, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
- name := gjson.GetBytes(chat, "tools.0.function.name").String()
- if name == "" || name == "__cpa_local_shell" || name == "__cpa_local_shell_1" || gjson.GetBytes(chat, "tools.#").Int() != 4 {
+ name := ojson.GetBytesNoCopy(chat, "tools.0.function.name").String()
+ if name == "" || name == "__cpa_local_shell" || name == "__cpa_local_shell_1" || ojson.GetBytesNoCopy(chat, "tools.#").Int() != 4 {
t.Fatalf("synthetic tool collided with user tools: %s", chat)
}
- if gjson.GetBytes(chat, "tool_choice.function.name").String() != name {
+ if ojson.GetBytesNoCopy(chat, "tool_choice.function.name").String() != name {
t.Fatalf("shell tool_choice not mapped: %s", chat)
}
args := `{"commands":["echo SHELL_OK","pwd"],"timeout_ms":120000,"max_output_length":4096}`
nonstream := shellNonStream(request, name, args)
- item := gjson.GetBytes(nonstream, "output.0")
+ item := ojson.GetBytesNoCopy(nonstream, "output.0")
if item.Get("type").String() != "shell_call" || item.Get("action.commands.0").String() != "echo SHELL_OK" || item.Get("status").String() != "completed" || item.Get("name").Exists() || item.Get("arguments").Exists() {
t.Fatalf("invalid shell item: %s", nonstream)
}
- var done, completed gjson.Result
+ var done, completed ojson.Result
for _, event := range shellStream(request, name, args) {
switch event.Get("type").String() {
case "response.output_item.added":
@@ -99,23 +100,23 @@ func TestLocalShellRoundTrip(t *testing.T) {
t.Fatalf("inconsistent final items: nonstream=%s done=%s completed=%s", item.Raw, done.Raw, completed.Raw)
}
output := `{"type":"shell_call_output","call_id":"call_shell","max_output_length":4096,"output":[{"stdout":"SHELL_OK\n","stderr":"warning","outcome":{"type":"exit","exit_code":0}},{"stdout":"partial","stderr":"","outcome":{"type":"timeout"}}]}`
- replay, _ := sjson.SetRawBytes(request, "input", []byte("["+item.Raw+`,`+`{"type":"function_call","call_id":"call_user","name":"__cpa_local_shell","arguments":"{}"},`+output+`,`+`{"type":"function_call_output","call_id":"call_user","output":"ok"}]`))
+ replay, _ := ojson.SetRawBytes(request, "input", []byte("["+item.Raw+`,`+`{"type":"function_call","call_id":"call_user","name":"__cpa_local_shell","arguments":"{}"},`+output+`,`+`{"type":"function_call_output","call_id":"call_user","output":"ok"}]`))
replayed, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", replay, false)
- if gjson.GetBytes(replayed, "messages.0.tool_calls.#").Int() != 2 || gjson.GetBytes(replayed, "messages.0.tool_calls.0.function.name").String() != name || gjson.GetBytes(replayed, "messages.0.tool_calls.0.function.arguments").String() != args {
+ if ojson.GetBytesNoCopy(replayed, "messages.0.tool_calls.#").Int() != 2 || ojson.GetBytesNoCopy(replayed, "messages.0.tool_calls.0.function.name").String() != name || ojson.GetBytesNoCopy(replayed, "messages.0.tool_calls.0.function.arguments").String() != args {
t.Fatalf("shell history not grouped/replayed: %s", replayed)
}
- content := gjson.GetBytes(replayed, "messages.1.content").String()
- if gjson.GetBytes(replayed, "messages.1.role").String() != "tool" || !reflect.DeepEqual(gjson.Parse(content).Value(), gjson.Parse(output).Value()) {
+ content := ojson.GetBytesNoCopy(replayed, "messages.1.content").String()
+ if ojson.GetBytesNoCopy(replayed, "messages.1.role").String() != "tool" || !reflect.DeepEqual(ojson.Parse(content).Value(), ojson.Parse(output).Value()) {
t.Fatalf("shell output envelope lost: %s", replayed)
}
for _, tc := range []struct{ name, kind string }{{"__cpa_local_shell", "function_call"}, {"__cpa_local_shell_1", "custom_tool_call"}, {"ns__lookup", "function_call"}} {
got := shellNonStream(request, tc.name, `{"input":"hello"}`)
- if gjson.GetBytes(got, "output.0.type").String() != tc.kind {
+ if ojson.GetBytesNoCopy(got, "output.0.type").String() != tc.kind {
t.Fatalf("user tool misclassified: %s", got)
}
}
unknown := shellNonStream([]byte(`{}`), name, args)
- if gjson.GetBytes(unknown, "output.0.type").String() != "function_call" {
+ if ojson.GetBytesNoCopy(unknown, "output.0.type").String() != "function_call" {
t.Fatalf("synthetic name recognized without provenance: %s", unknown)
}
}
@@ -125,7 +126,7 @@ func TestLocalShellInvalidActions(t *testing.T) {
for _, args := range []string{`{`, `{}`, `{"commands":[]}`, `{"commands":[42]}`, `{"commands":[" "]}`, `{"command":["echo","ok"]}`, `{"commands":["pwd"],"timeout_ms":-1}`, `{"commands":["pwd"],"timeout_ms":1.5}`, `{"commands":["pwd"],"env":{}}`} {
t.Run(args, func(t *testing.T) {
got := shellNonStream(request, "__cpa_local_shell", args)
- if gjson.GetBytes(got, "status").String() != "failed" || !strings.Contains(gjson.GetBytes(got, "error.message").String(), "shell") {
+ if ojson.GetBytesNoCopy(got, "status").String() != "failed" || !strings.Contains(ojson.GetBytesNoCopy(got, "error.message").String(), "shell") {
t.Fatalf("invalid action accepted: %s", got)
}
failed := false
@@ -148,7 +149,7 @@ func TestLocalShellOnlyExplicitLocalEnvironment(t *testing.T) {
for _, environment := range []string{`{"type":"container_auto"}`, `{"type":"container_reference","container_id":"cntr_x"}`, `{}`} {
request := []byte(`{"tools":[{"type":"shell","environment":` + environment + `}]}`)
got, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
- if gjson.GetBytes(got, "tools.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(got, "tools.#").Int() != 0 {
t.Fatalf("hosted shell represented as local: %s", got)
}
}
@@ -159,7 +160,7 @@ func TestLocalShellOnlyExplicitLocalEnvironment(t *testing.T) {
if errUnmarshal := json.Unmarshal([]byte(choice), &expected); errUnmarshal != nil {
t.Fatal(errUnmarshal)
}
- if gjson.GetBytes(got, "tool_choice").String() != expected {
+ if ojson.GetBytesNoCopy(got, "tool_choice").String() != expected {
t.Fatalf("choice changed: %s", got)
}
}
@@ -169,18 +170,18 @@ func TestLocalShellHistoryWithoutDeclaration(t *testing.T) {
for _, tools := range []string{``, `,"tools":[]`, `,"tools":[{"type":"function","name":"__cpa_local_shell_1","parameters":{}}]`} {
request := []byte(`{"input":[{"type":"shell_call","call_id":"shell_1","action":{"commands":["pwd"]}},{"type":"shell_call_output","call_id":"shell_1","output":[{"stdout":"/workspace","stderr":"","outcome":{"type":"exit","exit_code":0}}]},{"type":"function_call","call_id":"user_1","name":"__cpa_local_shell","arguments":"{}"},{"type":"function_call_output","call_id":"user_1","output":"ok"}]` + tools + `}`)
got, _ := ConvertOpenAIResponsesRequestToOpenAIChatCompletions("test", request, false)
- calls := gjson.GetBytes(got, "messages.0.tool_calls").Array()
- if len(calls) != 1 || calls[0].Get("id").String() != "shell_1" || gjson.GetBytes(got, "messages.2.tool_calls.0.id").String() != "user_1" {
+ calls := ojson.GetBytesNoCopy(got, "messages.0.tool_calls").Array()
+ if len(calls) != 1 || calls[0].Get("id").String() != "shell_1" || ojson.GetBytesNoCopy(got, "messages.2.tool_calls.0.id").String() != "user_1" {
t.Fatalf("shell history dropped without declaration: %s", got)
}
name := calls[0].Get("function.name").String()
if name == "__cpa_local_shell" || name == "__cpa_local_shell_1" && tools != "" && tools != `,"tools":[]` {
t.Fatalf("history name collided: %s", got)
}
- if gjson.Parse(gjson.GetBytes(got, "messages.1.content").String()).Get("output.0.stdout").String() != "/workspace" {
+ if ojson.Parse(ojson.GetBytesNoCopy(got, "messages.1.content").String()).Get("output.0.stdout").String() != "/workspace" {
t.Fatalf("shell result lost: %s", got)
}
- if gjson.GetBytes(got, "tools.#").Int() != gjson.GetBytes(request, "tools.#").Int() {
+ if ojson.GetBytesNoCopy(got, "tools.#").Int() != ojson.GetBytesNoCopy(request, "tools.#").Int() {
t.Fatalf("history re-enabled a tool: %s", got)
}
}
diff --git a/internal/translator/request_benchmark_test.go b/internal/translator/request_benchmark_test.go
index 1360583f3ba..4a86581a33f 100644
--- a/internal/translator/request_benchmark_test.go
+++ b/internal/translator/request_benchmark_test.go
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorapi "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/translator"
- "github.com/tidwall/gjson"
)
const benchmarkHistorySentinel = "benchmark-final-history-turn"
@@ -51,7 +51,7 @@ func benchmarkRequestTranslation(b *testing.B, turns int) {
for _, target := range route.targets {
b.Run(route.source+"_to_"+target, func(b *testing.B) {
output := translatorapi.Request(route.source, target, "gemini-2.5-pro", request, true)
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
b.Fatalf("translator generated invalid JSON: %s", output)
}
if turns > 0 && !bytes.Contains(output, []byte(benchmarkHistorySentinel)) {
diff --git a/internal/translator/response_benchmark_test.go b/internal/translator/response_benchmark_test.go
index fb1eadaaf31..a6f1edc495f 100644
--- a/internal/translator/response_benchmark_test.go
+++ b/internal/translator/response_benchmark_test.go
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
translatorapi "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/translator"
- "github.com/tidwall/gjson"
)
var benchmarkResponseTranslationOutput []byte
@@ -49,7 +49,7 @@ func BenchmarkResponseTranslationLargePayload(b *testing.B) {
for _, testCase := range cases {
b.Run(testCase.name, func(b *testing.B) {
output := translatorapi.ResponseNonStream(testCase.from, testCase.to, context.Background(), "benchmark-model", nil, nil, testCase.rawJSON, nil)
- if !gjson.ValidBytes(output) {
+ if !ojson.ValidBytes(output) {
b.Fatalf("translator generated invalid JSON: %s", output)
}
if !bytes.Contains(output, []byte(payload)) {
diff --git a/internal/util/claude_attribution.go b/internal/util/claude_attribution.go
index 9cd43e8f20c..3d78099fa7b 100644
--- a/internal/util/claude_attribution.go
+++ b/internal/util/claude_attribution.go
@@ -4,8 +4,7 @@ import (
"strings"
"unicode"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
const claudeCodeAttributionSystemPrefix = "x-anthropic-billing-header:"
@@ -22,15 +21,15 @@ func IsClaudeCodeAttributionSystemText(text string) bool {
// Kimi and Antigravity may treat this block as prompt text, so callers use this
// helper when the active policy has not explicitly opted into a full CLI profile.
func StripClaudeCodeAttributionSystem(payload []byte) []byte {
- system := gjson.GetBytes(payload, "system")
+ system := ojson.GetBytesNoCopy(payload, "system")
if !system.Exists() {
return payload
}
- if system.Type == gjson.String {
+ if system.Type == ojson.String {
if !IsClaudeCodeAttributionSystemText(system.String()) {
return payload
}
- updated, errDelete := sjson.DeleteBytes(payload, "system")
+ updated, errDelete := ojson.DeleteBytes(payload, "system")
if errDelete != nil {
return payload
}
@@ -41,7 +40,7 @@ func StripClaudeCodeAttributionSystem(payload []byte) []byte {
}
kept := make([]string, 0, len(system.Array()))
removed := false
- system.ForEach(func(_, block gjson.Result) bool {
+ system.ForEach(func(_, block ojson.Result) bool {
if block.Get("type").String() == "text" && IsClaudeCodeAttributionSystemText(block.Get("text").String()) {
removed = true
return true
@@ -55,13 +54,13 @@ func StripClaudeCodeAttributionSystem(payload []byte) []byte {
return payload
}
if len(kept) == 0 {
- updated, errDelete := sjson.DeleteBytes(payload, "system")
+ updated, errDelete := ojson.DeleteBytes(payload, "system")
if errDelete != nil {
return payload
}
return updated
}
- updated, errSet := sjson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]"))
+ updated, errSet := ojson.SetRawBytes(payload, "system", []byte("["+strings.Join(kept, ",")+"]"))
if errSet != nil {
return payload
}
diff --git a/internal/util/claude_attribution_test.go b/internal/util/claude_attribution_test.go
index 7cc6357213c..6d631a9976b 100644
--- a/internal/util/claude_attribution_test.go
+++ b/internal/util/claude_attribution_test.go
@@ -4,7 +4,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestIsClaudeCodeAttributionSystemText(t *testing.T) {
@@ -79,7 +79,7 @@ func TestStripClaudeCodeAttributionSystem(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := StripClaudeCodeAttributionSystem([]byte(tt.body))
- system := gjson.GetBytes(got, "system")
+ system := ojson.GetBytes(got, "system")
if system.Exists() != tt.wantPresent {
t.Fatalf("system exists = %v, want %v: %s", system.Exists(), tt.wantPresent, got)
}
diff --git a/internal/util/claude_tool_result.go b/internal/util/claude_tool_result.go
index dc2c3cd885c..d7f0016ab9f 100644
--- a/internal/util/claude_tool_result.go
+++ b/internal/util/claude_tool_result.go
@@ -1,8 +1,7 @@
package util
import (
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
// ClaudeToolResultImage represents a base64-encoded image extracted from a Claude
@@ -18,9 +17,9 @@ type ClaudeToolResultImage struct {
type ClaudeToolResult struct {
// Result is the value for functionResponse.response.result or .error.
Result string
- // ResultIsRaw reports whether Result holds raw JSON (write with sjson.SetRaw*)
- // or a plain string (write with sjson.Set*). Writing raw JSON text through
- // sjson.Set as a string value would double-encode it, so callers must honor
+ // ResultIsRaw reports whether Result holds raw JSON (write with ojson.SetRaw*)
+ // or a plain string (write with ojson.Set*). Writing raw JSON text through
+ // ojson.Set as a string value would double-encode it, so callers must honor
// this flag.
ResultIsRaw bool
// IsError preserves an explicit tool_result.is_error failure flag.
@@ -31,9 +30,9 @@ type ClaudeToolResult struct {
// ConvertClaudeToolResult normalizes a complete Claude tool_result block while
// preserving its success/failure semantics.
-func ConvertClaudeToolResult(block gjson.Result) ClaudeToolResult {
+func ConvertClaudeToolResult(block ojson.Result) ClaudeToolResult {
result := ConvertClaudeToolResultContent(block.Get("content"))
- result.IsError = block.Get("is_error").Type == gjson.True
+ result.IsError = block.Get("is_error").Type == ojson.True
return result
}
@@ -42,7 +41,7 @@ func ConvertClaudeToolResult(block gjson.Result) ClaudeToolResult {
//
// Claude tool_result content may be a plain string, an array of mixed text/image
// blocks, a single object, or absent. Some Claude->Gemini translators previously
-// wrote content.Raw straight through sjson.SetBytes, which double-encoded string
+// wrote content.Raw straight through ojson.SetBytes, which double-encoded string
// content and flattened structured arrays (including base64 image data) into one
// opaque escaped string. This helper mirrors the Antigravity Claude translator,
// which already handles structured content correctly:
@@ -56,16 +55,16 @@ func ConvertClaudeToolResult(block gjson.Result) ClaudeToolResult {
//
// Unlike Antigravity, image blocks without base64 data are dropped rather than
// emitted as empty inline data parts, matching the Gemini image part guards.
-func ConvertClaudeToolResultContent(content gjson.Result) ClaudeToolResult {
+func ConvertClaudeToolResultContent(content ojson.Result) ClaudeToolResult {
switch {
- case content.Type == gjson.String:
+ case content.Type == ojson.String:
return ClaudeToolResult{Result: content.String()}
case content.IsArray():
var images []ClaudeToolResultImage
nonImageCount := 0
lastNonImageRaw := ""
- filtered := []byte(`[]`)
- content.ForEach(func(_, block gjson.Result) bool {
+ filteredDoc := ojson.NewDoc([]byte(`[]`))
+ content.ForEach(func(_, block ojson.Result) bool {
if isClaudeBase64Image(block) {
if img, ok := claudeImageFromBlock(block); ok {
images = append(images, img)
@@ -74,14 +73,14 @@ func ConvertClaudeToolResultContent(content gjson.Result) ClaudeToolResult {
}
nonImageCount++
lastNonImageRaw = block.Raw
- filtered, _ = sjson.SetRawBytes(filtered, "-1", []byte(block.Raw))
+ _ = filteredDoc.SetRaw("-1", block.Raw)
return true
})
switch {
case nonImageCount == 1:
return ClaudeToolResult{Result: lastNonImageRaw, ResultIsRaw: true, Images: images}
case nonImageCount > 1:
- return ClaudeToolResult{Result: string(filtered), ResultIsRaw: true, Images: images}
+ return ClaudeToolResult{Result: filteredDoc.String(), ResultIsRaw: true, Images: images}
default:
return ClaudeToolResult{Images: images}
}
@@ -101,13 +100,13 @@ func ConvertClaudeToolResultContent(content gjson.Result) ClaudeToolResult {
}
// isClaudeBase64Image reports whether a content block is a base64-encoded image block.
-func isClaudeBase64Image(block gjson.Result) bool {
+func isClaudeBase64Image(block ojson.Result) bool {
return block.Get("type").String() == "image" && block.Get("source.type").String() == "base64"
}
// claudeImageFromBlock extracts image data from a base64 image block. It returns false
// when the block carries no base64 data, so empty inline data parts are not emitted.
-func claudeImageFromBlock(block gjson.Result) (ClaudeToolResultImage, bool) {
+func claudeImageFromBlock(block ojson.Result) (ClaudeToolResultImage, bool) {
data := block.Get("source.data").String()
if data == "" {
return ClaudeToolResultImage{}, false
diff --git a/internal/util/claude_tool_result_test.go b/internal/util/claude_tool_result_test.go
index fc1f309d7fc..86ad0ae823e 100644
--- a/internal/util/claude_tool_result_test.go
+++ b/internal/util/claude_tool_result_test.go
@@ -3,7 +3,7 @@ package util
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestConvertClaudeToolResult_PreservesErrorFlag(t *testing.T) {
@@ -20,7 +20,7 @@ func TestConvertClaudeToolResult_PreservesErrorFlag(t *testing.T) {
{"number", `{"is_error":1,"content":"permission denied"}`, false},
} {
t.Run(tt.name, func(t *testing.T) {
- got := ConvertClaudeToolResult(gjson.Parse(tt.block))
+ got := ConvertClaudeToolResult(ojson.Parse(tt.block))
if got.IsError != tt.isError {
t.Errorf("IsError = %v, want %v", got.IsError, tt.isError)
}
@@ -106,7 +106,7 @@ func TestConvertClaudeToolResultContent(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got := ConvertClaudeToolResultContent(gjson.Get(tt.wrapper, "content"))
+ got := ConvertClaudeToolResultContent(ojson.Get(tt.wrapper, "content"))
if got.Result != tt.wantResult {
t.Errorf("Result = %q, want %q", got.Result, tt.wantResult)
}
@@ -121,7 +121,7 @@ func TestConvertClaudeToolResultContent(t *testing.T) {
}
func TestConvertClaudeToolResultContent_ImageFields(t *testing.T) {
- content := gjson.Get(`{"content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}`, "content")
+ content := ojson.Get(`{"content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}]}`, "content")
got := ConvertClaudeToolResultContent(content)
if len(got.Images) != 1 {
t.Fatalf("expected 1 image, got %d", len(got.Images))
diff --git a/internal/util/codex.go b/internal/util/codex.go
index 8d3bdcac1fa..2bf7e6f302d 100644
--- a/internal/util/codex.go
+++ b/internal/util/codex.go
@@ -4,7 +4,7 @@ import (
"net/http"
"strings"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
// IsCodexResponsesLiteRequest recognizes the native header and its websocket metadata mirror.
@@ -12,6 +12,6 @@ func IsCodexResponsesLiteRequest(body []byte, headers http.Header) bool {
if strings.EqualFold(strings.TrimSpace(headers.Get("X-OpenAI-Internal-Codex-Responses-Lite")), "true") {
return true
}
- value := gjson.GetBytes(body, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite")
- return value.Type == gjson.True || value.Type == gjson.String && strings.EqualFold(strings.TrimSpace(value.String()), "true")
+ value := ojson.GetBytesNoCopy(body, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite")
+ return value.Type == ojson.True || value.Type == ojson.String && strings.EqualFold(strings.TrimSpace(value.String()), "true")
}
diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go
index 8bf0dea6daf..e5f7efa8ed7 100644
--- a/internal/util/gemini_schema.go
+++ b/internal/util/gemini_schema.go
@@ -10,8 +10,7 @@ import (
"strconv"
"strings"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
var gjsonPathKeyReplacer = strings.NewReplacer(".", "\\.", "*", "\\*", "?", "\\?")
@@ -137,6 +136,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
// local references are inlined and allOf/union structures are resolved, so synthetic placeholders
// do not clobber items supplied by $ref targets or allOf branches.
jsonStr = normalizeMalformedSchemaObjects(jsonStr)
+ jsonStr = NormalizeJSONSchemaNumericConstraints(jsonStr)
// Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local
// references must be expanded here for every route; otherwise nested fields are lost.
@@ -198,22 +198,28 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
func sanitizeObjectProperties(jsonStr string) string {
paths := findPaths(jsonStr, "properties")
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
parentPath := trimSuffix(p, ".properties")
if isPropertyDefinition(parentPath) {
continue
}
- props := gjson.Get(jsonStr, p)
+ props := doc.Get(p)
if !props.IsObject() {
continue
}
typePath := joinPath(parentPath, "type")
- t := gjson.Get(jsonStr, typePath).String()
+ t := doc.Get(typePath).String()
if !strings.EqualFold(t, "object") {
- updated, _ := sjson.SetBytes([]byte(jsonStr), typePath, "object")
- jsonStr = string(updated)
+ if doc.Set(typePath, "object") == nil {
+ modified = true
+ }
}
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
@@ -223,36 +229,50 @@ func sanitizeObjectProperties(jsonStr string) string {
func sanitizeArrayItems(jsonStr string, addMissing bool) string {
paths := findPaths(jsonStr, "items")
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
parentPath := trimSuffix(p, ".items")
if isPropertyDefinition(parentPath) {
continue
}
typePath := joinPath(parentPath, "type")
- t := gjson.Get(jsonStr, typePath).String()
+ t := doc.Get(typePath).String()
if t == "" {
- updated, _ := sjson.SetBytes([]byte(jsonStr), typePath, "array")
- jsonStr = string(updated)
+ if doc.Set(typePath, "array") == nil {
+ modified = true
+ }
} else if !strings.EqualFold(t, "array") {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ if doc.Delete(p) == nil {
+ modified = true
+ }
}
}
+ if modified {
+ jsonStr = doc.String()
+ }
if !addMissing {
return jsonStr
}
typePaths := findPaths(jsonStr, "type")
sortByDepth(typePaths)
+ doc = ojson.NewDocString(jsonStr)
+ modified = false
for _, p := range typePaths {
parentPath := trimSuffix(p, ".type")
- if isPropertyDefinition(parentPath) || !isArrayDeclaredType(gjson.Get(jsonStr, p).Value()) {
+ if isPropertyDefinition(parentPath) || !isArrayDeclaredType(doc.Get(p).Value()) {
continue
}
- if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() || gjson.Get(jsonStr, joinPath(parentPath, "$ref")).Exists() {
+ if doc.Get(joinPath(parentPath, "items")).Exists() || doc.Get(joinPath(parentPath, "$ref")).Exists() {
continue
}
- updated, _ := sjson.SetRawBytes([]byte(jsonStr), joinPath(parentPath, "items"), []byte(`{"type":"string"}`))
- jsonStr = string(updated)
+ if doc.SetRaw(joinPath(parentPath, "items"), `{"type":"string"}`) == nil {
+ modified = true
+ }
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
@@ -269,11 +289,15 @@ func removeKeywords(jsonStr string, keywords []string) string {
deletePaths = append(deletePaths, p)
}
}
+ if len(deletePaths) == 0 {
+ return jsonStr
+ }
sortByDepth(deletePaths)
+ doc := ojson.NewDocString(jsonStr)
for _, p := range deletePaths {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
}
- return jsonStr
+ return doc.String()
}
// removePlaceholderFields removes placeholder-only properties ("_" and "reason") and their required entries.
@@ -281,63 +305,87 @@ func removePlaceholderFields(jsonStr string) string {
// Remove "_" placeholder properties.
paths := findPaths(jsonStr, "_")
sortByDepth(paths)
- for _, p := range paths {
- if !strings.HasSuffix(p, ".properties._") {
- continue
- }
- jsonStr, _ = sjson.Delete(jsonStr, p)
- parentPath := trimSuffix(p, ".properties._")
- reqPath := joinPath(parentPath, "required")
- req := gjson.Get(jsonStr, reqPath)
- if req.IsArray() {
- var filtered []string
- for _, r := range req.Array() {
- if r.String() != "_" {
- filtered = append(filtered, r.String())
- }
+ if len(paths) > 0 {
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range paths {
+ if !strings.HasSuffix(p, ".properties._") {
+ continue
}
- if len(filtered) == 0 {
- jsonStr, _ = sjson.Delete(jsonStr, reqPath)
- } else {
- updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered)
- jsonStr = string(updated)
+ if doc.Delete(p) == nil {
+ modified = true
}
+ parentPath := trimSuffix(p, ".properties._")
+ reqPath := joinPath(parentPath, "required")
+ req := doc.Get(reqPath)
+ if req.IsArray() {
+ var filtered []string
+ for _, r := range req.Array() {
+ if r.String() != "_" {
+ filtered = append(filtered, r.String())
+ }
+ }
+ if len(filtered) == 0 {
+ if doc.Delete(reqPath) == nil {
+ modified = true
+ }
+ } else {
+ if doc.Set(reqPath, filtered) == nil {
+ modified = true
+ }
+ }
+ }
+ }
+ if modified {
+ jsonStr = doc.String()
}
}
// Remove placeholder-only "reason" objects.
reasonPaths := findPaths(jsonStr, "reason")
sortByDepth(reasonPaths)
- for _, p := range reasonPaths {
- if !strings.HasSuffix(p, ".properties.reason") {
- continue
- }
- parentPath := trimSuffix(p, ".properties.reason")
- props := gjson.Get(jsonStr, joinPath(parentPath, "properties"))
- if !props.IsObject() || len(props.Map()) != 1 {
- continue
- }
- desc := gjson.Get(jsonStr, p+".description").String()
- if desc != placeholderReasonDescription {
- continue
- }
- jsonStr, _ = sjson.Delete(jsonStr, p)
- reqPath := joinPath(parentPath, "required")
- req := gjson.Get(jsonStr, reqPath)
- if req.IsArray() {
- var filtered []string
- for _, r := range req.Array() {
- if r.String() != "reason" {
- filtered = append(filtered, r.String())
- }
+ if len(reasonPaths) > 0 {
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range reasonPaths {
+ if !strings.HasSuffix(p, ".properties.reason") {
+ continue
}
- if len(filtered) == 0 {
- jsonStr, _ = sjson.Delete(jsonStr, reqPath)
- } else {
- updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered)
- jsonStr = string(updated)
+ parentPath := trimSuffix(p, ".properties.reason")
+ props := doc.Get(joinPath(parentPath, "properties"))
+ if !props.IsObject() || len(props.Map()) != 1 {
+ continue
+ }
+ desc := doc.Get(p + ".description").String()
+ if desc != placeholderReasonDescription {
+ continue
+ }
+ if doc.Delete(p) == nil {
+ modified = true
+ }
+ reqPath := joinPath(parentPath, "required")
+ req := doc.Get(reqPath)
+ if req.IsArray() {
+ var filtered []string
+ for _, r := range req.Array() {
+ if r.String() != "reason" {
+ filtered = append(filtered, r.String())
+ }
+ }
+ if len(filtered) == 0 {
+ if doc.Delete(reqPath) == nil {
+ modified = true
+ }
+ } else {
+ if doc.Set(reqPath, filtered) == nil {
+ modified = true
+ }
+ }
}
}
+ if modified {
+ jsonStr = doc.String()
+ }
}
return jsonStr
@@ -1305,43 +1353,63 @@ func refName(ref string) string {
// to descriptions. Local references have already been expanded by inlineLocalRefs.
func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
paths := findPaths(jsonStr, "$ref")
+ if len(paths) == 0 {
+ return jsonStr
+ }
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
parentPath := trimSuffix(p, ".$ref")
if isPropertyDefinition(parentPath) {
continue // a property literally named "$ref", not a reference keyword
}
- refVal := gjson.Get(jsonStr, p).String()
+ refVal := doc.Get(p).String()
defName := refName(refVal)
hint := fmt.Sprintf("See: %s", defName)
if !preserveSiblings {
- if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
+ if existing := doc.Get(descriptionPath(parentPath)).String(); existing != "" {
hint = fmt.Sprintf("%s (%s)", existing, hint)
}
- replacement := `{"type":"object","description":""}`
- replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint)
- jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes))
+ replacementDoc := ojson.NewDocString(`{"type":"object","description":""}`)
+ _ = replacementDoc.Set("description", hint)
+ setRawAtDoc(doc, parentPath, replacementDoc.String())
+ modified = true
continue
}
- jsonStr, _ = sjson.Delete(jsonStr, p)
- jsonStr = appendHint(jsonStr, parentPath, hint)
+ _ = doc.Delete(p)
+ appendHintDoc(doc, parentPath, hint)
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
func convertConstToEnum(jsonStr string) string {
- for _, p := range findPaths(jsonStr, "const") {
- val := gjson.Get(jsonStr, p)
+ paths := findPaths(jsonStr, "const")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range paths {
+ val := doc.Get(p)
if !val.Exists() {
continue
}
enumPath := trimSuffix(p, ".const") + ".enum"
- if !gjson.Get(jsonStr, enumPath).Exists() {
- updated, _ := sjson.SetBytes([]byte(jsonStr), enumPath, []interface{}{val.Value()})
- jsonStr = string(updated)
+ if !doc.Get(enumPath).Exists() {
+ if doc.Set(enumPath, []interface{}{val.Value()}) == nil {
+ modified = true
+ }
}
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
@@ -1349,8 +1417,14 @@ func convertConstToEnum(jsonStr string) string {
// Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the
// emitted JSON type on both response and function-argument paths.
func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
- for _, p := range findPaths(jsonStr, "enum") {
- arr := gjson.Get(jsonStr, p)
+ paths := findPaths(jsonStr, "enum")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range paths {
+ arr := doc.Get(p)
if !arr.IsArray() {
continue
}
@@ -1360,20 +1434,31 @@ func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string {
stringVals = append(stringVals, item.String())
}
- updated, _ := sjson.SetBytes([]byte(jsonStr), p, stringVals)
- jsonStr = string(updated)
+ if doc.Set(p, stringVals) == nil {
+ modified = true
+ }
if forceStringType {
parentPath := trimSuffix(p, ".enum")
- updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "string")
- jsonStr = string(updated)
+ if doc.Set(joinPath(parentPath, "type"), "string") == nil {
+ modified = true
+ }
}
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
func addEnumHints(jsonStr string) string {
- for _, p := range findPaths(jsonStr, "enum") {
- arr := gjson.Get(jsonStr, p)
+ paths := findPaths(jsonStr, "enum")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range paths {
+ arr := doc.Get(p)
if !arr.IsArray() {
continue
}
@@ -1386,7 +1471,11 @@ func addEnumHints(jsonStr string) string {
for _, item := range items {
vals = append(vals, item.String())
}
- jsonStr = appendHint(jsonStr, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", "))
+ appendHintDoc(doc, trimSuffix(p, ".enum"), "Allowed: "+strings.Join(vals, ", "))
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
@@ -1395,30 +1484,176 @@ func addEnumHints(jsonStr string) string {
// Preserve the advisory values in description, but do not leave an unenforced constraint in the
// schema contract. Response enums for string, number, and integer remain native constraints.
func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string {
- for _, path := range findPaths(jsonStr, "enum") {
+ paths := findPaths(jsonStr, "enum")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, path := range paths {
parentPath := trimSuffix(path, ".enum")
- shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean")
+ shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && doc.Get(joinPath(parentPath, "type")).String() == "boolean")
if !shouldDrop {
continue
}
- enum := gjson.Get(jsonStr, path)
+ enum := doc.Get(path)
if enum.IsArray() && len(enum.Array()) == 1 {
- jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String())
+ appendHintDoc(doc, parentPath, "Allowed: "+enum.Array()[0].String())
}
- jsonStr, _ = sjson.Delete(jsonStr, path)
+ _ = doc.Delete(path)
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
func addAdditionalPropertiesHints(jsonStr string) string {
- for _, p := range findPaths(jsonStr, "additionalProperties") {
- if gjson.Get(jsonStr, p).Type == gjson.False {
- jsonStr = appendHint(jsonStr, trimSuffix(p, ".additionalProperties"), "No extra properties allowed")
+ paths := findPaths(jsonStr, "additionalProperties")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range paths {
+ if doc.Get(p).Type == ojson.False {
+ appendHintDoc(doc, trimSuffix(p, ".additionalProperties"), "No extra properties allowed")
+ modified = true
}
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
+var numericSchemaConstraintKeywords = []string{
+ "minLength", "maxLength",
+ "minItems", "maxItems",
+ "minProperties", "maxProperties",
+}
+
+type numericConstraintEdit struct {
+ offset int
+ end int
+ replacement string
+}
+
+// NormalizeJSONSchemaNumericConstraints converts string-encoded integer values on JSON Schema
+// numeric constraint keywords (minLength, maxLength, minItems, maxItems, minProperties,
+// maxProperties) to JSON numbers. Google's Protobuf Schema format represents these int64
+// fields as strings, whereas standard JSON Schema carriers (such as parametersJsonSchema)
+// require numeric values.
+func NormalizeJSONSchemaNumericConstraints(jsonStr string) string {
+ if !hasNumericSchemaConstraintKeyword(jsonStr) && !strings.Contains(jsonStr, `\u`) && !strings.Contains(jsonStr, `\U`) {
+ return jsonStr
+ }
+ root := ojson.Parse(jsonStr)
+ if !root.IsObject() {
+ return jsonStr
+ }
+ doc := root.Raw
+ root.Index = 0
+ var edits []numericConstraintEdit
+ collectNumericConstraintEdits(doc, root, &edits)
+ if len(edits) == 0 {
+ return jsonStr
+ }
+ var sb strings.Builder
+ sb.Grow(len(doc))
+ cursor := 0
+ for _, edit := range edits {
+ if edit.offset < cursor || edit.end > len(doc) {
+ return jsonStr
+ }
+ sb.WriteString(doc[cursor:edit.offset])
+ sb.WriteString(edit.replacement)
+ cursor = edit.end
+ }
+ sb.WriteString(doc[cursor:])
+ return sb.String()
+}
+
+func collectNumericConstraintEdits(doc string, node ojson.Result, edits *[]numericConstraintEdit) {
+ if !node.IsObject() {
+ return
+ }
+ node.ForEach(func(key, val ojson.Result) bool {
+ keyStr := key.String()
+ switch keyStr {
+ case "minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties":
+ if val.Type == ojson.String {
+ if n, ok := parseSchemaNonNegativeInt(val.String()); ok {
+ end := val.Index + len(val.Raw)
+ if val.Index >= 0 && end <= len(doc) && doc[val.Index:end] == val.Raw {
+ *edits = append(*edits, numericConstraintEdit{
+ offset: val.Index,
+ end: end,
+ replacement: strconv.FormatInt(n, 10),
+ })
+ }
+ }
+ }
+ case "properties", "patternProperties", "dependentSchemas", "dependencies", "$defs", "definitions":
+ if val.IsObject() {
+ val.ForEach(func(_, propVal ojson.Result) bool {
+ if propVal.IsObject() {
+ collectNumericConstraintEdits(doc, propVal, edits)
+ }
+ return true
+ })
+ }
+ case "items":
+ if val.IsObject() {
+ collectNumericConstraintEdits(doc, val, edits)
+ } else if val.IsArray() {
+ val.ForEach(func(_, item ojson.Result) bool {
+ if item.IsObject() {
+ collectNumericConstraintEdits(doc, item, edits)
+ }
+ return true
+ })
+ }
+ case "additionalProperties", "additionalItems", "unevaluatedProperties", "unevaluatedItems",
+ "if", "then", "else", "not", "contains", "propertyNames", "contentSchema":
+ if val.IsObject() {
+ collectNumericConstraintEdits(doc, val, edits)
+ }
+ case "prefixItems", "anyOf", "oneOf", "allOf":
+ if val.IsArray() {
+ val.ForEach(func(_, item ojson.Result) bool {
+ if item.IsObject() {
+ collectNumericConstraintEdits(doc, item, edits)
+ }
+ return true
+ })
+ }
+ }
+ return true
+ })
+}
+
+func hasNumericSchemaConstraintKeyword(jsonStr string) bool {
+ for _, key := range numericSchemaConstraintKeywords {
+ if strings.Contains(jsonStr, key) {
+ return true
+ }
+ }
+ return false
+}
+
+func parseSchemaNonNegativeInt(raw string) (int64, bool) {
+ if raw == "" {
+ return 0, false
+ }
+ n, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || n < 0 || strconv.FormatInt(n, 10) != raw {
+ return 0, false
+ }
+ return n, true
+}
+
var unsupportedConstraints = []string{
"minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum",
"pattern", "minItems", "maxItems", "uniqueItems", "contains", "format",
@@ -1442,9 +1677,11 @@ func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions
return jsonStr
}
pathsByField := findPathsByFields(jsonStr, constraints)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, key := range constraints {
for _, p := range pathsByField[key] {
- val := gjson.Get(jsonStr, p)
+ val := doc.Get(p)
if !val.Exists() {
continue
}
@@ -1453,22 +1690,37 @@ func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions
continue
}
if val.IsObject() || val.IsArray() {
- jsonStr = appendHint(jsonStr, parentPath, fmt.Sprintf("%s: %s", key, val.Raw))
+ appendHintDoc(doc, parentPath, fmt.Sprintf("%s: %s", key, val.Raw))
+ modified = true
continue
}
- jsonStr = appendHint(jsonStr, parentPath, fmt.Sprintf("%s: %s", key, val.String()))
+ appendHintDoc(doc, parentPath, fmt.Sprintf("%s: %s", key, val.String()))
+ modified = true
}
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
func moveNotToDescription(jsonStr string) string {
- for _, path := range findPaths(jsonStr, "not") {
- value := gjson.Get(jsonStr, path)
+ paths := findPaths(jsonStr, "not")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, path := range paths {
+ value := doc.Get(path)
if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) {
continue
}
- jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw)
+ appendHintDoc(doc, trimSuffix(path, ".not"), "not: "+value.Raw)
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
@@ -1485,10 +1737,15 @@ func mergeConditionals(jsonStr string) string {
paths = append(paths, p)
}
}
+ if len(paths) == 0 {
+ return jsonStr
+ }
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
- props := gjson.Get(jsonStr, joinPath(p, "properties"))
+ props := doc.Get(joinPath(p, "properties"))
if !props.IsObject() {
continue
}
@@ -1503,24 +1760,33 @@ func mergeConditionals(jsonStr string) string {
continue
}
- props.ForEach(func(key, value gjson.Result) bool {
+ props.ForEach(func(key, value ojson.Result) bool {
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String()))
- if !gjson.Get(jsonStr, destPath).Exists() {
- updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw))
- jsonStr = string(updated)
+ if !doc.Get(destPath).Exists() {
+ if doc.SetRaw(destPath, value.Raw) == nil {
+ modified = true
+ }
}
return true
})
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
func mergeAllOf(jsonStr string) string {
paths := findPaths(jsonStr, "allOf")
+ if len(paths) == 0 {
+ return jsonStr
+ }
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
- allOf := gjson.Get(jsonStr, p)
+ allOf := doc.Get(p)
if !allOf.IsArray() {
continue
}
@@ -1530,7 +1796,7 @@ func mergeAllOf(jsonStr string) string {
if !item.IsObject() {
continue
}
- item.ForEach(func(key, value gjson.Result) bool {
+ item.ForEach(func(key, value ojson.Result) bool {
field := key.String()
switch field {
case "required":
@@ -1538,49 +1804,51 @@ func mergeAllOf(jsonStr string) string {
return true
}
reqPath := joinPath(parentPath, "required")
- current := getStrings(jsonStr, reqPath)
+ current := getStringsDoc(doc, reqPath)
for _, required := range value.Array() {
if name := required.String(); !contains(current, name) {
current = append(current, name)
}
}
- updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current)
- jsonStr = string(updated)
+ _ = doc.Set(reqPath, current)
case "if", "then", "else", "allOf":
// Conditional applicability cannot be represented by the upstream schema.
default:
destination := joinPath(parentPath, escapeGJSONPathKey(field))
- jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value)
+ mergeMissingSchemaAtPathDoc(doc, destination, value)
}
return true
})
}
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
-// mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing
+// mergeMissingSchemaAtPathDoc recursively fills absent fields without replacing any existing
// definition. A parent schema is the canonical definition; allOf and conditional branches may
// enrich gaps in it, but can never replace it with a narrower branch shell.
-func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string {
- existing := gjson.Get(jsonStr, destination)
+func mergeMissingSchemaAtPathDoc(doc *ojson.Doc, destination string, incoming ojson.Result) {
+ existing := doc.Get(destination)
if !existing.Exists() {
- updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw))
- return string(updated)
+ _ = doc.SetRaw(destination, incoming.Raw)
+ return
}
if !existing.IsObject() || !incoming.IsObject() {
- return jsonStr
+ return
}
- incoming.ForEach(func(key, value gjson.Result) bool {
+ incoming.ForEach(func(key, value ojson.Result) bool {
child := joinPath(destination, escapeGJSONPathKey(key.String()))
- jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value)
+ mergeMissingSchemaAtPathDoc(doc, child, value)
return true
})
- return jsonStr
}
-func isRequiredOnlyUnionBranch(item gjson.Result) bool {
+func isRequiredOnlyUnionBranch(item ojson.Result) bool {
if !item.IsObject() || !item.Get("required").Exists() {
return false
}
@@ -1592,38 +1860,33 @@ func isRequiredOnlyUnionBranch(item gjson.Result) bool {
return true
}
-func materializeRequiredOnlyBranch(item, parentProps gjson.Result) (gjson.Result, bool) {
+func materializeRequiredOnlyBranch(item, parentProps ojson.Result) (ojson.Result, bool) {
if !parentProps.IsObject() {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
reqs := getStrings(item.Raw, "required")
if len(reqs) == 0 {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
- raw := item.Raw
+ doc := ojson.NewDocString(item.Raw)
for _, reqName := range reqs {
escaped := escapeGJSONPathKey(reqName)
propVal := parentProps.Get(escaped)
if !propVal.Exists() {
- return gjson.Result{}, false
- }
- updated, errSet := sjson.SetRawBytes([]byte(raw), "properties."+escaped, []byte(propVal.Raw))
- if errSet == nil {
- raw = string(updated)
+ return ojson.Result{}, false
}
+ _ = doc.SetRaw("properties."+escaped, propVal.Raw)
}
- if updated, errSet := sjson.SetBytes([]byte(raw), "type", "object"); errSet == nil {
- raw = string(updated)
- }
- return gjson.Parse(raw), true
+ _ = doc.Set("type", "object")
+ return ojson.Parse(doc.String()), true
}
-func isNullOnlyBranch(item gjson.Result) bool {
+func isNullOnlyBranch(item ojson.Result) bool {
if item.Get("properties").Exists() || item.Get("items").Exists() {
return false
}
t := item.Get("type")
- if t.Type == gjson.String {
+ if t.Type == ojson.String {
return t.String() == "null"
}
if t.IsArray() && len(t.Array()) > 0 {
@@ -1637,10 +1900,10 @@ func isNullOnlyBranch(item gjson.Result) bool {
return false
}
-func partitionUnionBranches(items []gjson.Result, parentProps gjson.Result) (nonNullItems []gjson.Result, hasNull, hasFilteredBranch bool) {
+func partitionUnionBranches(items []ojson.Result, parentProps ojson.Result) (nonNullItems []ojson.Result, hasNull, hasFilteredBranch bool) {
for _, item := range items {
- if item.Type == gjson.True {
- nonNullItems = append(nonNullItems, gjson.Parse("{}"))
+ if item.Type == ojson.True {
+ nonNullItems = append(nonNullItems, ojson.Parse("{}"))
hasFilteredBranch = true
continue
}
@@ -1672,15 +1935,20 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
for _, key := range []string{"oneOf", "anyOf"} {
paths = append(paths, pathsByField[key]...)
}
+ if len(paths) == 0 {
+ return jsonStr
+ }
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
key := "anyOf"
if p == "oneOf" || strings.HasSuffix(p, ".oneOf") {
key = "oneOf"
}
- arr := gjson.Get(jsonStr, p)
+ arr := doc.Get(p)
if !arr.IsArray() || len(arr.Array()) == 0 {
continue
}
@@ -1689,9 +1957,13 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
if isPropertyDefinition(parentPath) {
continue
}
- parent := gjson.Get(jsonStr, parentPath)
+ parent := doc.Get(parentPath)
if parentPath == "" {
- parent = gjson.Parse(jsonStr)
+ if modified {
+ jsonStr = doc.String()
+ modified = false
+ }
+ parent = ojson.Parse(jsonStr)
}
// When a node declares both anyOf and oneOf on a native Schema carrier (!preserveOneOf),
@@ -1702,8 +1974,9 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
// about to be flattened away too, and hinting the raw oneOf JSON into the
// description would only add a giant, misleading hint.
if key == "oneOf" && !flattenMultiBranch && !preserveOneOf && parent.Get("anyOf").IsArray() {
- jsonStr = appendHint(jsonStr, parentPath, "oneOf: "+arr.Raw)
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ appendHintDoc(doc, parentPath, "oneOf: "+arr.Raw)
+ _ = doc.Delete(p)
+ modified = true
continue
}
@@ -1732,20 +2005,18 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
for _, item := range nonNullItems {
rawBranches = append(rawBranches, json.RawMessage(item.Raw))
}
- updated, errSet := sjson.SetBytes([]byte(jsonStr), targetPath, rawBranches)
- if errSet == nil {
- jsonStr = string(updated)
+ if doc.Set(targetPath, rawBranches) == nil {
+ modified = true
}
if targetKey != key {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
+ modified = true
}
}
if hasNull {
- updated, errSet := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
- if errSet == nil {
- jsonStr = string(updated)
- }
- jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
+ _ = doc.Set(joinPath(parentPath, "nullable"), true)
+ appendHintDoc(doc, parentPath, "(nullable)")
+ modified = true
}
continue
}
@@ -1756,35 +2027,36 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
if parentProps.IsObject() {
for _, item := range nonNullItems {
if branchProps := item.Get("properties"); branchProps.IsObject() {
- branchProps.ForEach(func(propKey, propVal gjson.Result) bool {
+ branchProps.ForEach(func(propKey, propVal ojson.Result) bool {
destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(propKey.String()))
- jsonStr = mergeMissingSchemaAtPath(jsonStr, destPath, propVal)
+ mergeMissingSchemaAtPathDoc(doc, destPath, propVal)
return true
})
}
}
if nullable {
- updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
- jsonStr = string(updated)
+ _ = doc.Set(joinPath(parentPath, "nullable"), true)
}
if parent.Get("type").String() == "" {
- updated, _ := sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "type"), "object")
- jsonStr = string(updated)
+ _ = doc.Set(joinPath(parentPath, "type"), "object")
}
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
+ modified = true
continue
}
if len(nonNullItems) == 0 {
if !hasNull {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
+ modified = true
continue
}
- selected := `{"type":"null"}`
- if parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); parentDesc != "" {
- selected = mergeDescriptionRaw(selected, parentDesc)
+ selectedDoc := ojson.NewDocString(`{"type":"null"}`)
+ if parentDesc := doc.Get(descriptionPath(parentPath)).String(); parentDesc != "" {
+ mergeDescriptionDoc(selectedDoc, parentDesc)
}
- jsonStr = setRawAt(jsonStr, parentPath, selected)
+ setRawAtDoc(doc, parentPath, selectedDoc.String())
+ modified = true
continue
}
@@ -1792,11 +2064,11 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
if hasFilteredBranch || len(nonNullItems) >= 2 {
candidateItems = nonNullItems
if hasNull {
- candidateItems = append(append([]gjson.Result(nil), nonNullItems...), gjson.Parse(`{"type":"null"}`))
+ candidateItems = append(append([]ojson.Result(nil), nonNullItems...), ojson.Parse(`{"type":"null"}`))
}
}
- parentDesc := gjson.Get(jsonStr, descriptionPath(parentPath)).String()
+ parentDesc := doc.Get(descriptionPath(parentPath)).String()
_, allTypes := selectBest(candidateItems)
// Select the strongest non-null branch (ties keep the first one), matching the
// pre-preservation flattening behavior. selectBest already treats union-bearing
@@ -1805,28 +2077,41 @@ func flattenAnyOfOneOf(jsonStr string, preserveOneOf, flattenMultiBranch bool) s
if bestIdx, _ := selectBest(nonNullItems); bestIdx >= 0 && bestIdx < len(nonNullItems) {
selected = nonNullItems[bestIdx].Raw
}
+ selectedDoc := ojson.NewDocString(selected)
+ selectedModified := false
if nullable {
- updated, _ := sjson.SetBytes([]byte(selected), "nullable", true)
- selected = string(updated)
+ _ = selectedDoc.Set("nullable", true)
+ selectedModified = true
}
if parentDesc != "" {
- selected = mergeDescriptionRaw(selected, parentDesc)
+ if mergeDescriptionDoc(selectedDoc, parentDesc) {
+ selectedModified = true
+ }
}
if len(allTypes) > 1 {
hint := "Accepts: " + strings.Join(allTypes, " | ")
- selected = appendHintRaw(selected, hint)
+ appendHintDoc(selectedDoc, "", hint)
+ selectedModified = true
} else if nullable {
- selected = appendHintRaw(selected, "(nullable)")
+ appendHintDoc(selectedDoc, "", "(nullable)")
+ selectedModified = true
+ }
+ if selectedModified {
+ selected = selectedDoc.String()
}
- jsonStr = setRawAt(jsonStr, parentPath, selected)
+ setRawAtDoc(doc, parentPath, selected)
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
-func selectBest(items []gjson.Result) (bestIdx int, types []string) {
+func selectBest(items []ojson.Result) (bestIdx int, types []string) {
bestScore := -1
for i, item := range items {
t := item.Get("type").String()
@@ -1857,12 +2142,17 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) {
func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
paths := findPaths(jsonStr, "type")
+ if len(paths) == 0 {
+ return jsonStr
+ }
sortByDepth(paths)
nullableFields := make(map[string][]string)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
- res := gjson.Get(jsonStr, p)
+ res := doc.Get(p)
if !res.IsArray() || len(res.Array()) == 0 {
continue
}
@@ -1882,31 +2172,30 @@ func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
firstType := "string"
if len(nonNullTypes) > 0 {
- if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") {
+ if doc.Get(joinPath(parentPath, "items")).Exists() && contains(nonNullTypes, "array") {
firstType = "array"
- } else if gjson.Get(jsonStr, joinPath(parentPath, "properties")).Exists() && contains(nonNullTypes, "object") {
+ } else if doc.Get(joinPath(parentPath, "properties")).Exists() && contains(nonNullTypes, "object") {
firstType = "object"
} else {
firstType = nonNullTypes[0]
}
}
- updated, _ := sjson.SetBytes([]byte(jsonStr), p, firstType)
- jsonStr = string(updated)
+ _ = doc.Set(p, firstType)
+ modified = true
- if firstType != "array" && gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() {
- jsonStr, _ = sjson.Delete(jsonStr, joinPath(parentPath, "items"))
+ if firstType != "array" && doc.Get(joinPath(parentPath, "items")).Exists() {
+ _ = doc.Delete(joinPath(parentPath, "items"))
}
if len(nonNullTypes) > 1 {
hint := "Accepts: " + strings.Join(nonNullTypes, " | ")
- jsonStr = appendHint(jsonStr, parentPath, hint)
+ appendHintDoc(doc, parentPath, hint)
}
if hasNull {
if preserveNativeNullable {
- updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true)
- jsonStr = string(updated)
- jsonStr = appendHint(jsonStr, parentPath, "(nullable)")
+ _ = doc.Set(joinPath(parentPath, "nullable"), true)
+ appendHintDoc(doc, parentPath, "(nullable)")
continue
}
@@ -1916,14 +2205,14 @@ func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
fieldName := unescapeGJSONPathKey(fieldNameEscaped)
objectPath := strings.Join(parts[:len(parts)-3], ".")
nullableFields[objectPath] = append(nullableFields[objectPath], fieldName)
- jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
+ appendHintDoc(doc, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)")
}
}
}
for objectPath, fields := range nullableFields {
reqPath := joinPath(objectPath, "required")
- req := gjson.Get(jsonStr, reqPath)
+ req := doc.Get(reqPath)
if !req.IsArray() {
continue
}
@@ -1935,11 +2224,14 @@ func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string {
}
}
if len(filtered) == 0 {
- jsonStr, _ = sjson.Delete(jsonStr, reqPath)
+ _ = doc.Delete(reqPath)
} else {
- updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, filtered)
- jsonStr = string(updated)
+ _ = doc.Set(reqPath, filtered)
}
+ modified = true
+ }
+ if modified {
+ return doc.String()
}
return jsonStr
}
@@ -1969,16 +2261,20 @@ func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) s
if options.preserveAllAdditionalProperties {
continue
}
- if options.preserveAdditionalPropertiesFalse && gjson.Get(jsonStr, p).Type == gjson.False {
+ if options.preserveAdditionalPropertiesFalse && ojson.Get(jsonStr, p).Type == ojson.False {
continue
}
}
deletePaths = append(deletePaths, p)
}
}
- sortByDepth(deletePaths)
- for _, p := range deletePaths {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ if len(deletePaths) > 0 {
+ sortByDepth(deletePaths)
+ doc := ojson.NewDocString(jsonStr)
+ for _, p := range deletePaths {
+ _ = doc.Delete(p)
+ }
+ jsonStr = doc.String()
}
// Remove x-* extension fields (e.g., x-google-enum-descriptions) that are not supported by Gemini API
jsonStr = removeExtensionFields(jsonStr)
@@ -1989,19 +2285,21 @@ func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) s
// These are OpenAPI/JSON Schema extension fields that Google APIs don't recognize.
func removeExtensionFields(jsonStr string) string {
var paths []string
- walkForExtensions(gjson.Parse(jsonStr), "", &paths)
+ walkForExtensions(ojson.Parse(jsonStr), "", &paths)
// walkForExtensions returns paths in a way that deeper paths are added before their ancestors
// when they are not deleted wholesale, but since we skip children of deleted x-* nodes,
- // any collected path is safe to delete. We still use DeleteBytes for efficiency.
-
- b := []byte(jsonStr)
+ // any collected path is safe to delete.
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
for _, p := range paths {
- b, _ = sjson.DeleteBytes(b, p)
+ _ = doc.Delete(p)
}
- return string(b)
+ return doc.String()
}
-func walkForExtensions(value gjson.Result, path string, paths *[]string) {
+func walkForExtensions(value ojson.Result, path string, paths *[]string) {
if value.IsArray() {
arr := value.Array()
for i := len(arr) - 1; i >= 0; i-- {
@@ -2011,7 +2309,7 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) {
}
if value.IsObject() {
- value.ForEach(func(key, val gjson.Result) bool {
+ value.ForEach(func(key, val ojson.Result) bool {
keyStr := key.String()
safeKey := escapeGJSONPathKey(keyStr)
childPath := joinPath(path, safeKey)
@@ -2035,7 +2333,7 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) {
func hasSchemaConjunction(jsonStr string) bool {
for _, path := range findPaths(jsonStr, "allOf") {
- if gjson.Get(jsonStr, path).IsArray() {
+ if ojson.Get(jsonStr, path).IsArray() {
return true
}
}
@@ -2043,17 +2341,24 @@ func hasSchemaConjunction(jsonStr string) bool {
}
func cleanupRequiredFields(jsonStr string) string {
- for _, p := range findPaths(jsonStr, "required") {
+ paths := findPaths(jsonStr, "required")
+ if len(paths) == 0 {
+ return jsonStr
+ }
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
+ for _, p := range paths {
parentPath := trimSuffix(p, ".required")
propsPath := joinPath(parentPath, "properties")
- req := gjson.Get(jsonStr, p)
- props := gjson.Get(jsonStr, propsPath)
+ req := doc.Get(p)
+ props := doc.Get(propsPath)
if !req.IsArray() {
continue
}
if !props.IsObject() {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
+ modified = true
continue
}
@@ -2067,13 +2372,16 @@ func cleanupRequiredFields(jsonStr string) string {
if len(valid) != len(req.Array()) {
if len(valid) == 0 {
- jsonStr, _ = sjson.Delete(jsonStr, p)
+ _ = doc.Delete(p)
} else {
- updated, _ := sjson.SetBytes([]byte(jsonStr), p, valid)
- jsonStr = string(updated)
+ _ = doc.Set(p, valid)
}
+ modified = true
}
}
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
@@ -2082,12 +2390,17 @@ func cleanupRequiredFields(jsonStr string) string {
func addEmptySchemaPlaceholder(jsonStr string) string {
// Find all "type" fields
paths := findPaths(jsonStr, "type")
+ if len(paths) == 0 {
+ return jsonStr
+ }
// Process from deepest to shallowest (to handle nested objects properly)
sortByDepth(paths)
+ doc := ojson.NewDocString(jsonStr)
+ modified := false
for _, p := range paths {
- typeVal := gjson.Get(jsonStr, p)
+ typeVal := doc.Get(p)
if typeVal.String() != "object" {
continue
}
@@ -2097,11 +2410,11 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
// Check if properties exists and is empty or missing
propsPath := joinPath(parentPath, "properties")
- propsVal := gjson.Get(jsonStr, propsPath)
+ propsVal := doc.Get(propsPath)
reqPath := joinPath(parentPath, "required")
- reqVal := gjson.Get(jsonStr, reqPath)
+ reqVal := doc.Get(reqPath)
hasRequiredProperties := reqVal.IsArray() && len(reqVal.Array()) > 0
- if gjson.Get(jsonStr, joinPath(parentPath, "anyOf")).IsArray() || gjson.Get(jsonStr, joinPath(parentPath, "oneOf")).IsArray() {
+ if doc.Get(joinPath(parentPath, "anyOf")).IsArray() || doc.Get(joinPath(parentPath, "oneOf")).IsArray() {
continue
}
@@ -2117,14 +2430,10 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
if needsPlaceholder {
// Add placeholder "reason" property
reasonPath := joinPath(propsPath, "reason")
- updated, _ := sjson.SetBytes([]byte(jsonStr), reasonPath+".type", "string")
- jsonStr = string(updated)
- updated, _ = sjson.SetBytes([]byte(jsonStr), reasonPath+".description", placeholderReasonDescription)
- jsonStr = string(updated)
-
- // Add to required array
- updated, _ = sjson.SetBytes([]byte(jsonStr), reqPath, []string{"reason"})
- jsonStr = string(updated)
+ _ = doc.Set(reasonPath+".type", "string")
+ _ = doc.Set(reasonPath+".description", placeholderReasonDescription)
+ _ = doc.Set(reqPath, []string{"reason"})
+ modified = true
continue
}
@@ -2136,15 +2445,16 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
continue
}
placeholderPath := joinPath(propsPath, "_")
- if !gjson.Get(jsonStr, placeholderPath).Exists() {
- updated, _ := sjson.SetBytes([]byte(jsonStr), placeholderPath+".type", "boolean")
- jsonStr = string(updated)
+ if !doc.Get(placeholderPath).Exists() {
+ _ = doc.Set(placeholderPath+".type", "boolean")
}
- updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, []string{"_"})
- jsonStr = string(updated)
+ _ = doc.Set(reqPath, []string{"_"})
+ modified = true
}
}
-
+ if modified {
+ return doc.String()
+ }
return jsonStr
}
@@ -2152,7 +2462,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
func findPaths(jsonStr, field string) []string {
var paths []string
- WalkSchema(gjson.Parse(jsonStr), "", field, &paths)
+ WalkSchema(ojson.Parse(jsonStr), "", field, &paths)
return paths
}
@@ -2162,14 +2472,14 @@ func findPathsByFields(jsonStr string, fields []string) map[string][]string {
set[field] = struct{}{}
}
paths := make(map[string][]string, len(set))
- walkForFields(gjson.Parse(jsonStr), "", set, paths)
+ walkForFields(ojson.Parse(jsonStr), "", set, paths)
return paths
}
-func walkForFields(value gjson.Result, path string, fields map[string]struct{}, paths map[string][]string) {
+func walkForFields(value ojson.Result, path string, fields map[string]struct{}, paths map[string][]string) {
switch value.Type {
- case gjson.JSON:
- value.ForEach(func(key, val gjson.Result) bool {
+ case ojson.JSON:
+ value.ForEach(func(key, val ojson.Result) bool {
keyStr := key.String()
safeKey := escapeGJSONPathKey(keyStr)
@@ -2193,7 +2503,7 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{},
walkForFields(val, childPath, fields, paths)
return true
})
- case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
+ case ojson.String, ojson.Number, ojson.True, ojson.False, ojson.Null:
// Terminal types - no further traversal needed
}
}
@@ -2218,12 +2528,12 @@ func joinPath(base, suffix string) string {
return base + "." + suffix
}
-func setRawAt(jsonStr, path, value string) string {
+func setRawAtDoc(doc *ojson.Doc, path, value string) {
if path == "" {
- return value
+ *doc = *ojson.NewDocString(value)
+ return
}
- result, _ := sjson.SetRawBytes([]byte(jsonStr), path, []byte(value))
- return string(result)
+ _ = doc.SetRaw(path, value)
}
// schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to
@@ -2289,27 +2599,28 @@ func mergeHint(existing, hint string) string {
return fmt.Sprintf("%s (%s)", existing, hint)
}
-func appendHint(jsonStr, parentPath, hint string) string {
+func appendHintDoc(doc *ojson.Doc, parentPath, hint string) {
descPath := parentPath + ".description"
if parentPath == "" || parentPath == "@this" {
descPath = "description"
}
- merged := mergeHint(gjson.Get(jsonStr, descPath).String(), hint)
- updated, _ := sjson.SetBytes([]byte(jsonStr), descPath, merged)
- jsonStr = string(updated)
- return jsonStr
+ merged := mergeHint(doc.Get(descPath).String(), hint)
+ _ = doc.Set(descPath, merged)
}
-func appendHintRaw(jsonRaw, hint string) string {
- merged := mergeHint(gjson.Get(jsonRaw, "description").String(), hint)
- updated, _ := sjson.SetBytes([]byte(jsonRaw), "description", merged)
- jsonRaw = string(updated)
- return jsonRaw
+func getStrings(jsonStr, path string) []string {
+ var result []string
+ if arr := ojson.Get(jsonStr, path); arr.IsArray() {
+ for _, r := range arr.Array() {
+ result = append(result, r.String())
+ }
+ }
+ return result
}
-func getStrings(jsonStr, path string) []string {
+func getStringsDoc(doc *ojson.Doc, path string) []string {
var result []string
- if arr := gjson.Get(jsonStr, path); arr.IsArray() {
+ if arr := doc.Get(path); arr.IsArray() {
for _, r := range arr.Array() {
result = append(result, r.String())
}
@@ -2385,17 +2696,17 @@ func splitGJSONPath(path string) []string {
return parts
}
-func mergeDescriptionRaw(schemaRaw, parentDesc string) string {
- childDesc := gjson.Get(schemaRaw, "description").String()
+func mergeDescriptionDoc(doc *ojson.Doc, parentDesc string) bool {
+ childDesc := doc.Get("description").String()
switch {
case childDesc == "":
- updated, _ := sjson.SetBytes([]byte(schemaRaw), "description", parentDesc)
- return string(updated)
+ _ = doc.Set("description", parentDesc)
+ return true
case childDesc == parentDesc:
- return schemaRaw
+ return false
default:
combined := fmt.Sprintf("%s (%s)", parentDesc, childDesc)
- updated, _ := sjson.SetBytes([]byte(schemaRaw), "description", combined)
- return string(updated)
+ _ = doc.Set("description", combined)
+ return true
}
}
diff --git a/internal/util/gemini_schema_ref_test.go b/internal/util/gemini_schema_ref_test.go
index de94a67a985..fc57f1af113 100644
--- a/internal/util/gemini_schema_ref_test.go
+++ b/internal/util/gemini_schema_ref_test.go
@@ -4,7 +4,7 @@ import (
"encoding/json"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestIssue6897_RefSiblingConjunctions(t *testing.T) {
@@ -108,7 +108,7 @@ func TestIssue6897_RefSiblingConjunctions(t *testing.T) {
} {
t.Run(cleaner, func(t *testing.T) {
got := clean(input)
- node := gjson.Parse(got)
+ node := ojson.Parse(got)
if path != "" {
node = node.Get(path)
}
@@ -136,7 +136,7 @@ func TestIssue6897_RefSiblingAnnotationsAndIdenticalTypes(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
got := clean(input)
- compareJSON(t, `{"type":"string","minLength":5,"description":"local","default":"local","examples":[{"$ref":"literal"}]}`, gjson.Get(got, "properties.x").Raw)
+ compareJSON(t, `{"type":"string","minLength":5,"description":"local","default":"local","examples":[{"$ref":"literal"}]}`, ojson.Get(got, "properties.x").Raw)
})
}
}
@@ -156,7 +156,7 @@ func TestIssue6897_RootRefKeepsDocumentDeclarations(t *testing.T) {
func TestIssue6897_RequiredOutsideAllOf(t *testing.T) {
input := `{"type":"object","allOf":[{"properties":{"x":{"type":"string"}}}],"required":["x"]}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- if gjson.Get(got, "required.0").String() != "x" {
+ if ojson.Get(got, "required.0").String() != "x" {
t.Fatalf("required property declared in allOf was dropped: %s", got)
}
}
@@ -173,7 +173,7 @@ func TestIssue6897_ArrayConjunctionDoesNotInventItems(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
got := CleanJSONSchemaForGeminiJSONSchema(input)
- node := gjson.Parse(got)
+ node := ojson.Parse(got)
if path != "" {
node = node.Get(path)
}
@@ -196,7 +196,7 @@ func TestIssue6897_ArrayTargetDoesNotInventItemsBeforeInlining(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
got := CleanJSONSchemaForGeminiJSONSchema(input)
- node := gjson.Parse(got)
+ node := ojson.Parse(got)
if path != "" {
node = node.Get(path)
}
@@ -210,10 +210,10 @@ func TestIssue6897_ArrayTargetDoesNotInventItemsBeforeInlining(t *testing.T) {
func TestIssue6897_ArrayItemsInsideAllOf(t *testing.T) {
input := `{"type":"array","allOf":[{"items":{"type":"integer"}}]}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- if gjson.Get(got, "items").Exists() {
+ if ojson.Get(got, "items").Exists() {
t.Fatalf("array conjunction gained an unrelated element constraint: %s", got)
}
- if gjson.Get(got, "allOf.0.items.type").String() != "integer" {
+ if ojson.Get(got, "allOf.0.items.type").String() != "integer" {
t.Fatalf("integer element constraint was lost: %s", got)
}
}
@@ -222,7 +222,7 @@ func TestIssue6897_UnionCannotReplaceConjunction(t *testing.T) {
input := `{"type":"object","allOf":[{"properties":{"x":{"type":"string"}},"required":["x"]}],"anyOf":[{"type":"object"}]}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
// {} must remain invalid, even though it satisfies the anyOf branch.
- if gjson.Get(got, "allOf.0.properties.x.type").String() != "string" || gjson.Get(got, "allOf.0.required.0").String() != "x" {
+ if ojson.Get(got, "allOf.0.properties.x.type").String() != "string" || ojson.Get(got, "allOf.0.required.0").String() != "x" {
t.Fatalf("union cleanup removed the required-property conjunction: %s", got)
}
compareJSON(t, got, CleanJSONSchemaForGeminiJSONSchema(got))
@@ -232,7 +232,7 @@ func TestIssue6897_ConjunctionPreservesRequiredOnlyUnion(t *testing.T) {
input := `{"type":"object","allOf":[{"properties":{"a":{"type":"string"},"b":{"type":"string"}}},{"anyOf":[{"required":["a"]},{"required":["b"]}]}]}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
// {} must fail both alternatives; either declared key can satisfy the union.
- if gjson.Get(got, "allOf.1.anyOf.0.required.0").String() != "a" || gjson.Get(got, "allOf.1.anyOf.1.required.0").String() != "b" {
+ if ojson.Get(got, "allOf.1.anyOf.0.required.0").String() != "a" || ojson.Get(got, "allOf.1.anyOf.1.required.0").String() != "b" {
t.Fatalf("required-only union lost constraints declared in another conjunction branch: %s", got)
}
compareJSON(t, got, CleanJSONSchemaForGeminiJSONSchema(got))
@@ -242,7 +242,7 @@ func TestIssue6897_RefSiblingRequiredOnlyUnion(t *testing.T) {
input := `{"$defs":{"A":{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"string"}},"minProperties":1}},"type":"object","properties":{"x":{"$ref":"#/$defs/A","minProperties":0,"anyOf":[{"required":["a"]},{"required":["b"]}]}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
// {"x":{"z":1}} must remain invalid: at least a or b is required.
- if gjson.Get(got, "properties.x.anyOf.0.required.0").String() != "a" || gjson.Get(got, "properties.x.anyOf.1.required.0").String() != "b" {
+ if ojson.Get(got, "properties.x.anyOf.0.required.0").String() != "a" || ojson.Get(got, "properties.x.anyOf.1.required.0").String() != "b" {
t.Fatalf("sibling union required constraints were removed: %s", got)
}
compareJSON(t, got, CleanJSONSchemaForGeminiJSONSchema(got))
@@ -256,7 +256,7 @@ func TestIssue6897_ConjunctionDoesNotInferContainerType(t *testing.T) {
t.Run(name, func(t *testing.T) {
input := `{"$defs":{"A":{"type":"number","minimum":5}},"type":"object","properties":{"x":{"$ref":"#/$defs/A","minimum":1,` + sibling + `}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- node := gjson.Get(got, "properties.x")
+ node := ojson.Get(got, "properties.x")
// 5 is valid; items/properties do not constrain non-container instances.
if node.Get("type").Exists() || node.Get("allOf.0.type").String() != "number" {
t.Fatalf("container inference made a valid numeric conjunction impossible: %s", got)
@@ -292,7 +292,7 @@ func TestIssue6897_OuterUnionPreservesConjunction(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := CleanJSONSchemaForGeminiJSONSchema(tc.input)
- if gjson.Get(got, tc.path).String() != tc.want {
+ if ojson.Get(got, tc.path).String() != tc.want {
t.Fatalf("outer union lost %s = %s: %s", tc.path, tc.want, got)
}
compareJSON(t, got, CleanJSONSchemaForGeminiJSONSchema(got))
@@ -344,7 +344,7 @@ func TestIssue6897_RefSiblingUnevaluatedScope(t *testing.T) {
}
t.Run(name, func(t *testing.T) {
got := InlineLocalRefs(input)
- node := gjson.Parse(got)
+ node := ojson.Parse(got)
if path != "" {
node = node.Get(path)
} else {
@@ -355,7 +355,7 @@ func TestIssue6897_RefSiblingUnevaluatedScope(t *testing.T) {
if errMarshal != nil {
t.Fatal(errMarshal)
}
- node = gjson.ParseBytes(encoded)
+ node = ojson.ParseBytes(encoded)
}
// Unevaluated keywords must stay adjacent to the applicator so they see its
// annotations: {"x":"ok"} and [1] remain valid, unlike extra keys or items.
@@ -371,15 +371,15 @@ func TestIssue6897_RefSiblingMinLength(t *testing.T) {
t.Logf("cleaned=%s", got)
// Both constraints apply: "a" must remain invalid and "abcde" must remain valid.
- var minLength func(gjson.Result) int64
- minLength = func(schema gjson.Result) int64 {
+ var minLength func(ojson.Result) int64
+ minLength = func(schema ojson.Result) int64 {
minimum := schema.Get("minLength").Int()
for _, branch := range schema.Get("allOf").Array() {
minimum = max(minimum, minLength(branch))
}
return minimum
}
- if minimum := minLength(gjson.Get(got, "properties.x")); minimum != 5 {
+ if minimum := minLength(ojson.Get(got, "properties.x")); minimum != 5 {
t.Fatalf("effective minLength = %d, want 5; reference target constraint was lost", minimum)
}
}
diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go
index 088df3b4086..f67566808ef 100644
--- a/internal/util/gemini_schema_test.go
+++ b/internal/util/gemini_schema_test.go
@@ -7,7 +7,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestCleanJSONSchemaForAntigravity_ConstToEnum(t *testing.T) {
@@ -299,7 +299,7 @@ func TestCleanJSONSchemaForAntigravity_CyclicRefDefaults(t *testing.T) {
t.Errorf("Expected type: object, got: %v", resMap["type"])
}
- child := gjson.Get(result, "properties.child")
+ child := ojson.Get(result, "properties.child")
if child.Get("type").String() != "object" || !strings.Contains(child.Get("description").String(), "Node") {
t.Errorf("Expected typed cycle hint containing Node, got: %s", result)
}
@@ -617,7 +617,7 @@ func TestCleanJSONSchemaForAntigravity_SingleEnumBecomesHint(t *testing.T) {
result := CleanJSONSchemaForAntigravity(input)
- if !strings.Contains(result, "Allowed: fixed") || gjson.Get(result, "properties.kind.enum").Exists() {
+ if !strings.Contains(result, "Allowed: fixed") || ojson.Get(result, "properties.kind.enum").Exists() {
t.Errorf("Ignored tool enum should become a hint, got: %s", result)
}
}
@@ -734,7 +734,7 @@ func TestCleanJSONSchemaForAntigravity_NestedEmptySchema(t *testing.T) {
// Nested empty object should also get placeholder
// Check that the nested object has a reason property
- parsed := gjson.Parse(result)
+ parsed := ojson.Parse(result)
nestedProps := parsed.Get("properties.items.items.properties")
if !nestedProps.Exists() || !nestedProps.Get("reason").Exists() {
t.Errorf("Nested empty object should have 'reason' placeholder, got: %s", result)
@@ -760,7 +760,7 @@ func TestCleanJSONSchemaForAntigravity_EmptySchemaWithDescription(t *testing.T)
}
func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *testing.T) {
- bare := gjson.Parse(CleanJSONSchemaForAntigravityResponse(`{"type":"object"}`))
+ bare := ojson.Parse(CleanJSONSchemaForAntigravityResponse(`{"type":"object"}`))
if bare.Get("properties.reason").Exists() || bare.Get("required").Exists() {
t.Fatalf("bare response schema gained tool placeholders: %s", bare.Raw)
}
@@ -774,7 +774,7 @@ func TestCleanJSONSchemaForAntigravityResponseDoesNotAddToolPlaceholders(t *test
"optional":{"type":"object","properties":{"value":{"type":"string"}}}
}
}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
for _, path := range []string{
"properties.empty.properties.reason",
"properties.empty.required",
@@ -802,7 +802,7 @@ func TestCleanJSONSchemaForAntigravityResponseProjectsIgnoredUnions(t *testing.T
}
}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
for _, path := range []string{"properties.action.anyOf", "properties.label.oneOf"} {
if result.Get(path).Exists() {
t.Errorf("ignored response union %s survived: %s", path, result.Raw)
@@ -835,17 +835,17 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesAdditionalPropertiesFalse
"additionalProperties":false
}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
// Root additionalProperties should be preserved as false
rootAP := result.Get("additionalProperties")
- if !rootAP.Exists() || rootAP.Type != gjson.False {
+ if !rootAP.Exists() || rootAP.Type != ojson.False {
t.Errorf("root additionalProperties = %v, want false; cleaned: %s", rootAP, result.Raw)
}
// Nested additionalProperties should be preserved as false
nestedAP := result.Get("properties.nested.additionalProperties")
- if !nestedAP.Exists() || nestedAP.Type != gjson.False {
+ if !nestedAP.Exists() || nestedAP.Type != ojson.False {
t.Errorf("nested additionalProperties = %v, want false; cleaned: %s", nestedAP, result.Raw)
}
@@ -886,7 +886,7 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesEnumType(t *testing.T) {
}
}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
for _, testCase := range []struct {
path string
wantType string
@@ -901,7 +901,7 @@ func TestCleanJSONSchemaForAntigravityResponsePreservesEnumType(t *testing.T) {
}
var gotValues []string
for _, enumValue := range schema.Get("enum").Array() {
- if enumValue.Type != gjson.String {
+ if enumValue.Type != ojson.String {
t.Errorf("%s enum value is not a string: %s", testCase.path, enumValue.Raw)
}
gotValues = append(gotValues, enumValue.String())
@@ -1009,7 +1009,7 @@ func TestCleanJSONSchemaForAntigravity_ToolEnumsBecomeHints(t *testing.T) {
}`
result := CleanJSONSchemaForAntigravity(input)
- parsed := gjson.Parse(result)
+ parsed := ojson.Parse(result)
// Antigravity ignores function-argument enum but still uses the declared type to choose the
// emitted JSON type. Preserve types and convert enum values to advisory hints.
@@ -1037,7 +1037,7 @@ func TestCleanJSONSchemaForAntigravity_BooleanToolEnumBecomesHint(t *testing.T)
result := CleanJSONSchemaForAntigravity(input)
- value := gjson.Get(result, "properties.enabled")
+ value := ojson.Get(result, "properties.enabled")
if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true, false") {
t.Errorf("Boolean tool enum should become a typed hint, got: %s", result)
}
@@ -1341,7 +1341,7 @@ func TestCleanJSONSchemaKeepsPropertiesNamedLikeKeywords(t *testing.T) {
"antigravity": CleanJSONSchemaForAntigravity,
"gemini": CleanJSONSchemaForGemini,
} {
- got := gjson.Parse(clean(input))
+ got := ojson.Parse(clean(input))
for _, path := range []string{
"properties.propertyNames",
"properties.patternProperties",
@@ -1370,7 +1370,7 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) {
"Antigravity": CleanJSONSchemaForAntigravity,
"Gemini": CleanJSONSchemaForGemini,
} {
- res := gjson.Parse(clean(rootInput))
+ res := ojson.Parse(clean(rootInput))
if res.Get("if").Exists() {
t.Errorf("[%s] root 'if' was not removed: %s", name, res.Raw)
}
@@ -1412,7 +1412,7 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) {
"Antigravity": CleanJSONSchemaForAntigravity,
"Gemini": CleanJSONSchemaForGemini,
} {
- res := gjson.Parse(clean(allOfInput))
+ res := ojson.Parse(clean(allOfInput))
if res.Get("allOf").Exists() {
t.Errorf("[%s] 'allOf' was not removed: %s", name, res.Raw)
}
@@ -1445,7 +1445,7 @@ func TestCleanJSONSchema_ConditionalKeywords(t *testing.T) {
"Antigravity": CleanJSONSchemaForAntigravity,
"Gemini": CleanJSONSchemaForGemini,
} {
- res := gjson.Parse(clean(nestedInput))
+ res := ojson.Parse(clean(nestedInput))
if res.Get("properties.trade.if").Exists() {
t.Errorf("[%s] nested 'if' was not removed: %s", name, res.Raw)
}
@@ -1475,7 +1475,7 @@ func TestCleanJSONSchemaForAntigravityResponseConditionalCannotOverwriteParent(t
}]
}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
action := result.Get("properties.action")
if action.Get("type").String() != "object" || !action.Get("properties.full").Exists() {
t.Fatalf("conditional branch replaced canonical action: %s", result.Raw)
@@ -1496,7 +1496,7 @@ func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) {
"required":["payload"]
}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
if result.Get(`\$defs`).Exists() || strings.Contains(result.Raw, `"$ref"`) {
t.Fatalf("local reference metadata survived: %s", result.Raw)
}
@@ -1508,7 +1508,7 @@ func TestCleanJSONSchemaForAntigravityResponseInlinesLocalRef(t *testing.T) {
func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *testing.T) {
input := `{"type":"object","properties":{"value":{"type":["number","null"]}},"required":["value"]}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
value := result.Get("properties.value")
if value.Get("type").String() != "number" || !value.Get("nullable").Bool() {
t.Fatalf("type array was not projected to native nullable: %s", result.Raw)
@@ -1520,7 +1520,7 @@ func TestCleanJSONSchemaForAntigravityResponseTypeArrayUsesNativeNullable(t *tes
func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) {
input := `{"type":"object","properties":{"value":{"type":"number","enum":[1,2]}},"required":["value"]}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityTool(input, false))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityTool(input, false))
value := result.Get("properties.value")
if value.Get("type").String() != "number" {
t.Fatalf("numeric tool enum changed argument JSON type: %s", result.Raw)
@@ -1532,7 +1532,7 @@ func TestCleanJSONSchemaForAntigravityToolKeepsNumericEnumType(t *testing.T) {
func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing.T) {
input := `{"type":"object","properties":{"value":{"type":"boolean","enum":["true"]}},"required":["value"]}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
value := result.Get("properties.value")
if value.Get("enum").Exists() || value.Get("type").String() != "boolean" || !strings.Contains(value.Get("description").String(), "Allowed: true") {
t.Fatalf("ignored boolean response enum was not projected to a hint: %s", result.Raw)
@@ -1541,7 +1541,7 @@ func TestCleanJSONSchemaForAntigravityResponseDropsIgnoredBooleanEnum(t *testing
func TestCleanJSONSchemaForAntigravityResponseHintsIgnoredConstraints(t *testing.T) {
input := `{"type":"object","properties":{"value":{"type":"number","minimum":1,"maximum":2,"not":{"enum":[1.5]}}}}`
- result := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ result := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
value := result.Get("properties.value")
for _, keyword := range []string{"minimum", "maximum", "not"} {
if value.Get(keyword).Exists() {
@@ -1601,7 +1601,7 @@ func TestCleanJSONSchemaStripsEncryptedMetadata(t *testing.T) {
if strings.Contains(got, `"encrypted"`) {
t.Errorf("%s: 'encrypted' marker survived cleaning: %s", cleaner, got)
}
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if !parsed.Get("properties.api_key.type").Exists() || parsed.Get("properties.api_key.description").String() != "API credential" {
t.Errorf("%s: api_key schema was corrupted: %s", cleaner, got)
}
@@ -1634,7 +1634,7 @@ func TestCleanJSONSchemaKeepsPropertyNamedEncrypted(t *testing.T) {
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if !parsed.Get("properties.encrypted").Exists() {
t.Errorf("%s: property named 'encrypted' was removed: %s", cleaner, got)
}
@@ -1673,7 +1673,7 @@ func TestCleanJSONSchema_BarePropertyMapNormalized(t *testing.T) {
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
// data must be normalized into an object schema with properties
if parsed.Get("properties.data.type").String() != "object" {
@@ -1732,7 +1732,7 @@ func TestCleanJSONSchema_NestedBarePropertyMap(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
@@ -1789,7 +1789,7 @@ func TestCleanJSONSchema_BarePropertyMapWithKeywordNames(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
@@ -1835,7 +1835,7 @@ func TestCleanJSONSchema_ArrayItemsBarePropertyMap(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.tasks.items.type").String() != "object" {
t.Errorf("%s: properties.tasks.items.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.tasks.items.type").String(), got)
@@ -1871,7 +1871,7 @@ func TestCleanJSONSchema_ToolArraysMissingItems(t *testing.T) {
"gemini": CleanJSONSchemaForGemini,
} {
t.Run(cleaner, func(t *testing.T) {
- got := gjson.Parse(clean(input))
+ got := ojson.Parse(clean(input))
for _, path := range []string{"properties.params.items.type", "properties.values.items.type"} {
if itemType := got.Get(path).String(); itemType != "string" {
@@ -1882,7 +1882,7 @@ func TestCleanJSONSchema_ToolArraysMissingItems(t *testing.T) {
t.Errorf("existing items type = %q, want number; got schema: %s", itemType, got.Raw)
}
- rootArray := gjson.Parse(clean(`{"type":"array"}`))
+ rootArray := ojson.Parse(clean(`{"type":"array"}`))
if itemType := rootArray.Get("items.type").String(); itemType != "string" {
t.Errorf("root items type = %q, want string; got schema: %s", itemType, rootArray.Raw)
}
@@ -1892,7 +1892,7 @@ func TestCleanJSONSchema_ToolArraysMissingItems(t *testing.T) {
func TestCleanJSONSchema_ResponseArrayMissingItemsUnchanged(t *testing.T) {
input := `{"type":"object","properties":{"values":{"type":"array"}}}`
- got := gjson.Parse(CleanJSONSchemaForAntigravityResponse(input))
+ got := ojson.Parse(CleanJSONSchemaForAntigravityResponse(input))
if got.Get("properties.values.items").Exists() {
t.Fatalf("response schema gained tool-only items placeholder: %s", got.Raw)
}
@@ -1918,7 +1918,7 @@ func TestCleanJSONSchema_BooleanRequiredPromoted(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
var req []string
for _, r := range parsed.Get("required").Array() {
@@ -1990,7 +1990,7 @@ func TestCleanJSONSchema_BarePropertyMapWithRequestAndToolsNames(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
@@ -2037,7 +2037,7 @@ func TestCleanJSONSchema_BarePropertyMapWithSiblingDescription(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
@@ -2075,7 +2075,7 @@ func TestCleanJSONSchema_SingleKeySchemaWrapper(t *testing.T) {
wrapped := `{"schema": ` + inner + `}`
result := CleanJSONSchemaForAntigravityTool(wrapped, true)
- parsed := gjson.Parse(result)
+ parsed := ojson.Parse(result)
if !parsed.Get("schema").Exists() {
t.Fatalf("wrapper key 'schema' was lost: %s", result)
@@ -2115,7 +2115,7 @@ func TestCleanJSONSchema_BarePropertyMapWithExplicitTypeObject(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
@@ -2154,7 +2154,7 @@ func TestCleanJSONSchema_BarePropertyMapWithNullable(t *testing.T) {
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.data.type").String() != "object" {
t.Errorf("%s: properties.data.type = %q, want object; got schema: %s", cleaner, parsed.Get("properties.data.type").String(), got)
@@ -2205,7 +2205,7 @@ func TestCleanJSONSchema_VendorExtensionOnEnumNotWrappedIntoProperties(t *testin
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties").Exists() {
t.Errorf("%s: string enum gained unexpected properties: %s", cleaner, got)
@@ -2235,7 +2235,7 @@ func TestCleanJSONSchema_ObjectDefaultNotWrappedIntoProperties(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
// settings must not gain properties.default.properties.theme
if parsed.Get("properties.settings.properties.default").Exists() {
@@ -2264,7 +2264,7 @@ func TestCleanJSONSchema_MixedPropertiesAndOrphanBareProperty(t *testing.T) {
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.foo.type").String() != "string" {
t.Errorf("%s: foo corrupted: %s", cleaner, got)
@@ -2299,7 +2299,7 @@ func TestCleanJSONSchema_PreservesAdditionalPropertiesObjectSchema(t *testing.T)
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
// Should not be wrapped as properties.additionalProperties
if parsed.Get("properties.additionalProperties").Exists() {
t.Errorf("%s: additionalProperties was wrapped into properties: %s", cleaner, got)
@@ -2325,7 +2325,7 @@ func TestCleanJSONSchemaForAntigravityResponse_AnyOfRequiredOnlyBranches(t *test
}`
got := CleanJSONSchemaForAntigravityResponse(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("type").String() != "object" {
t.Fatalf("type = %q, want object; cleaned: %s", parsed.Get("type").String(), got)
@@ -2367,7 +2367,7 @@ func TestCleanJSONSchemaForAntigravityResponse_ContainsKeywordStripped(t *testin
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("properties.tags.contains").Exists() {
t.Errorf("%s: contains keyword was not removed: %s", cleaner, got)
}
@@ -2413,7 +2413,7 @@ func TestCleanJSONSchema_RemovesDraft04IdAndSchemaIdentifierKeywords(t *testing.
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
// Root keywords should be stripped
if parsed.Get("id").Exists() {
@@ -2470,7 +2470,7 @@ func TestCleanJSONSchema_RemovesDraft04IdAndSchemaIdentifierKeywords(t *testing.
"antigravityResponse": CleanJSONSchemaForAntigravityResponse,
} {
got := clean(refInput)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if !parsed.Get("properties.kind").Exists() || parsed.Get("properties.kind.type").String() != "string" {
t.Errorf("%s: inlined $ref property 'properties.kind' corrupted or missing: %s", cleaner, got)
}
@@ -2504,7 +2504,7 @@ func TestCleanJSONSchema_TrueBooleanSubschemas(t *testing.T) {
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
// true subschemas under properties must be converted to empty object schemas {}
for _, prop := range []string{"screenshot_id", "filename", "file_size", "source_file_checksum"} {
@@ -2512,7 +2512,7 @@ func TestCleanJSONSchema_TrueBooleanSubschemas(t *testing.T) {
if !val.Exists() {
t.Fatalf("%s: expected property %q to exist in %s", cleaner, prop, got)
}
- if val.Type != gjson.JSON || val.Raw != "{}" {
+ if val.Type != ojson.JSON || val.Raw != "{}" {
t.Errorf("%s: property %q should be normalized to {}, got %s (type %v)", cleaner, prop, val.Raw, val.Type)
}
}
@@ -2522,7 +2522,7 @@ func TestCleanJSONSchema_TrueBooleanSubschemas(t *testing.T) {
if !disabledVal.Exists() {
t.Fatalf("%s: expected property 'disabled_field' to exist in %s", cleaner, got)
}
- if disabledVal.Type != gjson.False {
+ if disabledVal.Type != ojson.False {
t.Errorf("%s: property 'disabled_field' should remain false, got %s", cleaner, disabledVal.Raw)
}
}
@@ -2569,29 +2569,29 @@ func TestCleanJSONSchema_NestedTrueBooleanSubschemas(t *testing.T) {
"gemini": CleanJSONSchemaForGemini,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
// tags.items: true -> {}
- if val := parsed.Get("properties.tags.items"); val.Exists() && val.Type == gjson.True {
+ if val := parsed.Get("properties.tags.items"); val.Exists() && val.Type == ojson.True {
t.Errorf("%s: tags.items should not be boolean true: %s", cleaner, got)
}
// nested properties: true -> {}, false preserved
- if val := parsed.Get("properties.metadata.properties.nested_true"); !val.Exists() || val.Type != gjson.JSON || val.Raw != "{}" {
+ if val := parsed.Get("properties.metadata.properties.nested_true"); !val.Exists() || val.Type != ojson.JSON || val.Raw != "{}" {
t.Errorf("%s: nested_true should be {}, got %s", cleaner, val.Raw)
}
- if val := parsed.Get("properties.metadata.properties.nested_false"); !val.Exists() || val.Type != gjson.False {
+ if val := parsed.Get("properties.metadata.properties.nested_false"); !val.Exists() || val.Type != ojson.False {
t.Errorf("%s: nested_false should remain false, got %s", cleaner, val.Raw)
}
// tuple items: true in list should be normalized to {}
- if val := parsed.Get("properties.tuple.items.0"); val.Exists() && val.Type == gjson.True {
+ if val := parsed.Get("properties.tuple.items.0"); val.Exists() && val.Type == ojson.True {
t.Errorf("%s: tuple items[0] should not be boolean true: %s", cleaner, got)
}
// union / combination inner property
if val := parsed.Get("properties.combination.properties.opt"); val.Exists() {
- if val.Type == gjson.True {
+ if val.Type == ojson.True {
t.Errorf("%s: combination.properties.opt should not be boolean true: %s", cleaner, got)
}
}
@@ -2619,8 +2619,8 @@ func TestCleanJSONSchema_RootAndWrappedTrue(t *testing.T) {
// Verify wrapped {"schema": true} normalization
wrapped := `{"schema": true}`
gotWrapped := clean(wrapped)
- parsed := gjson.Parse(gotWrapped)
- if parsed.Get("schema").Exists() && parsed.Get("schema").Type == gjson.True {
+ parsed := ojson.Parse(gotWrapped)
+ if parsed.Get("schema").Exists() && parsed.Get("schema").Type == ojson.True {
t.Errorf("%s: wrapped schema true should normalize to {}, got %s", cleaner, gotWrapped)
}
}
@@ -2665,9 +2665,9 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesAdditionalPropertiesAndPatt
}`
cleaned := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
- if got := parsed.Get("additionalProperties"); !got.Exists() || got.Type != gjson.False {
+ if got := parsed.Get("additionalProperties"); !got.Exists() || got.Type != ojson.False {
t.Fatalf("additionalProperties should be preserved as false, got: %v. Cleaned: %s", got, cleaned)
}
if got := parsed.Get("properties.recipient.pattern"); !got.Exists() || got.String() != "^(alice|bob)$" {
@@ -2682,7 +2682,7 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesAdditionalPropertiesAndPatt
if got := parsed.Get("properties.amount.minimum").Int(); got != 1 {
t.Fatalf("minimum should be preserved as 1, got: %v. Cleaned: %s", got, cleaned)
}
- if got := parsed.Get("properties.nested.additionalProperties"); !got.Exists() || got.Type != gjson.False {
+ if got := parsed.Get("properties.nested.additionalProperties"); !got.Exists() || got.Type != ojson.False {
t.Fatalf("nested additionalProperties should be preserved as false, got: %v. Cleaned: %s", got, cleaned)
}
if got := parsed.Get("properties.nested.properties.tag.pattern"); !got.Exists() || got.String() != "^[a-z]+$" {
@@ -2717,7 +2717,7 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesAdditionalPropertiesAndPatt
// Compare with legacy CleanJSONSchemaForGemini to verify backward compatibility
legacyCleaned := CleanJSONSchemaForGemini(input)
- legacyParsed := gjson.Parse(legacyCleaned)
+ legacyParsed := ojson.Parse(legacyCleaned)
if legacyParsed.Get("additionalProperties").Exists() {
t.Fatalf("legacy cleaner should strip additionalProperties. Cleaned: %s", legacyCleaned)
}
@@ -2740,7 +2740,7 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesSchemaValuedAdditionalPrope
}`
cleaned := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
ap := parsed.Get("additionalProperties")
if !ap.Exists() || !ap.IsObject() {
@@ -2761,7 +2761,7 @@ func TestCleanJSONSchemaForGeminiJSONSchema_PreservesSchemaValuedAdditionalPrope
// Legacy cleaner should strip schema-valued additionalProperties
legacyCleaned := CleanJSONSchemaForGemini(input)
- if gjson.Get(legacyCleaned, "additionalProperties").Exists() {
+ if ojson.Get(legacyCleaned, "additionalProperties").Exists() {
t.Fatalf("legacy CleanJSONSchemaForGemini should strip additionalProperties schema: %s", legacyCleaned)
}
}
@@ -2803,7 +2803,7 @@ func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
for name, clean := range cleaners {
t.Run(name+"_missing_type", func(t *testing.T) {
cleaned := clean(inputMissingType)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference")
if !target.Exists() {
t.Fatalf("evidence_reference property missing in %s: %s", name, cleaned)
@@ -2841,7 +2841,7 @@ func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
for name, clean := range cleaners {
t.Run(name+"_union_type_with_items", func(t *testing.T) {
cleaned := clean(inputUnionType)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
target := parsed.Get("properties.revision_reasons.items.properties.evidence_reference")
if gotType := target.Get("type").String(); gotType != "array" {
t.Fatalf("%s: evidence_reference.type = %q, want 'array' when items is defined; cleaned schema: %s", name, gotType, cleaned)
@@ -2868,7 +2868,7 @@ func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
for name, clean := range cleaners {
t.Run(name+"_non_array_strips_items", func(t *testing.T) {
cleaned := clean(inputNonArrayWithItems)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
if parsed.Get("properties.label.items").Exists() {
t.Fatalf("%s: properties.label should not retain items on string type: %s", name, cleaned)
}
@@ -2895,7 +2895,7 @@ func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
for name, clean := range cleaners {
t.Run(name+"_property_named_items_preserved", func(t *testing.T) {
cleaned := clean(inputPropertyNamedItems)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
itemProp := parsed.Get("properties.items")
if !itemProp.Exists() {
t.Fatalf("%s: property named 'items' was removed: %s", name, cleaned)
@@ -2916,7 +2916,7 @@ func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
for name, clean := range cleaners {
t.Run(name+"_root_array_missing_type", func(t *testing.T) {
cleaned := clean(inputRootArrayMissingType)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
if parsed.Get("type").String() != "array" {
t.Fatalf("%s: root schema type should be inferred as 'array': %s", name, cleaned)
}
@@ -2942,14 +2942,14 @@ func TestCleanJSONSchema_ArrayItemsRequireArrayType_Issue6011(t *testing.T) {
// Response schema declaring items without type infers array type
respInput := `{"type":"object","properties":{"names":{"items":{"type":"string"}}}}`
cleaned := CleanJSONSchemaForAntigravityResponse(respInput)
- parsed := gjson.Parse(cleaned)
+ parsed := ojson.Parse(cleaned)
if parsed.Get("properties.names.type").String() != "array" {
t.Fatalf("response schema should infer type 'array' when items is defined: %s", cleaned)
}
// Response array missing items still remains untouched
respNoItems := `{"type":"object","properties":{"values":{"type":"array"}}}`
cleanedNoItems := CleanJSONSchemaForAntigravityResponse(respNoItems)
- parsedNoItems := gjson.Parse(cleanedNoItems)
+ parsedNoItems := ojson.Parse(cleanedNoItems)
if parsedNoItems.Get("properties.values.items").Exists() {
t.Fatalf("response array missing items should not gain placeholder items: %s", cleanedNoItems)
}
@@ -2990,7 +2990,7 @@ func TestSanitizeArrayItems_PreservesItemsForUppercaseArrayType(t *testing.T) {
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
brandsItems := parsed.Get("properties.brands.items")
if !brandsItems.Exists() {
@@ -3027,7 +3027,7 @@ func TestCleanJSONSchema_EnforcesObjectTypeForProperties_Issue6394(t *testing.T)
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
endpointType := parsed.Get("properties.requestBody.properties.delivery.properties.endpoint.type").String()
if !strings.EqualFold(endpointType, "object") {
@@ -3046,7 +3046,7 @@ func TestCleanJSONSchema_EnforcesObjectTypeForProperties_Issue6394(t *testing.T)
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
endpointType := parsed.Get("properties.endpoint.type").String()
if !strings.EqualFold(endpointType, "object") {
@@ -3065,7 +3065,7 @@ func TestCleanJSONSchema_EnforcesObjectTypeForProperties_Issue6394(t *testing.T)
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
endpointType := parsed.Get("properties.endpoint.type").String()
if !strings.EqualFold(endpointType, "object") {
@@ -3148,7 +3148,7 @@ func TestCleanJSONSchema_RemovesDependentKeywords_Issue6539(t *testing.T) {
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
for _, kw := range []string{"dependentRequired", "dependentSchemas", "dependencies"} {
if parsed.Get(kw).Exists() {
@@ -3249,7 +3249,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
"required": ["item"]
}`
- agCleaned := gjson.Parse(CleanJSONSchemaForAntigravity(nullableUnion))
+ agCleaned := ojson.Parse(CleanJSONSchemaForAntigravity(nullableUnion))
branches := agCleaned.Get("properties.item.anyOf").Array()
if len(branches) != 3 {
t.Fatalf("Antigravity anyOf branch count = %d, want 3: %s", len(branches), agCleaned.Raw)
@@ -3261,7 +3261,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
t.Fatalf("Antigravity lost audio branch: %s", agCleaned.Raw)
}
- geminiCleaned := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(nullableUnion))
+ geminiCleaned := ojson.Parse(CleanJSONSchemaForGeminiJSONSchema(nullableUnion))
if len(geminiCleaned.Get("properties.item.anyOf").Array()) != 3 {
t.Fatalf("GeminiJSONSchema anyOf branch count = %d, want 3: %s", len(geminiCleaned.Get("properties.item.anyOf").Array()), geminiCleaned.Raw)
}
@@ -3327,7 +3327,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
"Gemini": CleanJSONSchemaForGemini,
} {
- parsed := gjson.Parse(clean(oneOfInput))
+ parsed := ojson.Parse(clean(oneOfInput))
if parsed.Get("properties.item.oneOf").Exists() {
t.Fatalf("[%s] oneOf survived on native Schema carrier: %s", name, parsed.Raw)
}
@@ -3337,7 +3337,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
}
// parametersJsonSchema carrier preserves standard JSON Schema oneOf
- jsonSchemaParsed := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(oneOfInput))
+ jsonSchemaParsed := ojson.Parse(CleanJSONSchemaForGeminiJSONSchema(oneOfInput))
if got := len(jsonSchemaParsed.Get("properties.item.oneOf").Array()); got != 3 {
t.Fatalf("[GeminiJSONSchema] oneOf branch count = %d, want 3: %s", got, jsonSchemaParsed.Raw)
}
@@ -3493,7 +3493,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
"AntigravityResponse": CleanJSONSchemaForAntigravityResponse,
"Gemini": CleanJSONSchemaForGemini,
} {
- parsed := gjson.Parse(clean(siblingUnions))
+ parsed := ojson.Parse(clean(siblingUnions))
if parsed.Get("properties.value.oneOf").Exists() {
t.Fatalf("[%s] sibling oneOf survived on native Schema carrier: %s", name, parsed.Raw)
}
@@ -3505,7 +3505,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
}
}
- jsonSchemaSibling := gjson.Parse(CleanJSONSchemaForGeminiJSONSchema(siblingUnions))
+ jsonSchemaSibling := ojson.Parse(CleanJSONSchemaForGeminiJSONSchema(siblingUnions))
if len(jsonSchemaSibling.Get("properties.value.anyOf").Array()) != 2 || len(jsonSchemaSibling.Get("properties.value.oneOf").Array()) != 2 {
t.Fatalf("[GeminiJSONSchema] expected both anyOf and oneOf to be preserved: %s", jsonSchemaSibling.Raw)
}
@@ -3515,7 +3515,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
// Antigravity Claude backend is known to reject nested multi-branch unions when
// it converts function declarations to Claude custom.input_schema.
t.Run("AntigravityClaudeToolFlattensNestedUnions", func(t *testing.T) {
- flattened := gjson.Parse(CleanJSONSchemaForAntigravityClaudeTool(input, false))
+ flattened := ojson.Parse(CleanJSONSchemaForAntigravityClaudeTool(input, false))
item := flattened.Get("properties.item")
if item.Get("anyOf").Exists() || item.Get("oneOf").Exists() {
t.Fatalf("Antigravity Claude tool union survived cleaning: %s", flattened.Raw)
@@ -3547,12 +3547,12 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
// pass must inherit that marker instead of silently dropping it.
nullableUnionInput := `{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"null"}]}}}`
directFlattened := CleanJSONSchemaForAntigravityClaudeTool(nullableUnionInput, false)
- directValue := gjson.Parse(directFlattened).Get("properties.value")
+ directValue := ojson.Parse(directFlattened).Get("properties.value")
if !directValue.Get("nullable").Bool() || directValue.Get("type").String() != "string" {
t.Fatalf("direct Claude flattening lost nullability: %s", directFlattened)
}
chainedFlattened := CleanJSONSchemaForAntigravityClaudeTool(CleanJSONSchemaForAntigravity(nullableUnionInput), false)
- chainedValue := gjson.Parse(chainedFlattened).Get("properties.value")
+ chainedValue := ojson.Parse(chainedFlattened).Get("properties.value")
if !chainedValue.Get("nullable").Bool() || chainedValue.Get("type").String() != "string" || chainedValue.Get("anyOf").Exists() {
t.Fatalf("translator -> executor chain lost nullability while flattening: %s", chainedFlattened)
}
@@ -3568,7 +3568,7 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
// Sibling anyOf + oneOf on one node: both are flattened and neither raw union
// JSON leaks into the description (paths are processed oneOf-first, so the
// oneOf branch set wins the single remaining node).
- siblingClaude := gjson.Parse(CleanJSONSchemaForAntigravityClaudeTool(siblingUnions, false))
+ siblingClaude := ojson.Parse(CleanJSONSchemaForAntigravityClaudeTool(siblingUnions, false))
siblingValue := siblingClaude.Get("properties.value")
if siblingValue.Get("anyOf").Exists() || siblingValue.Get("oneOf").Exists() {
t.Fatalf("Antigravity Claude sibling union survived cleaning: %s", siblingClaude.Raw)
@@ -3593,10 +3593,10 @@ func TestCleanJSONSchema_PreservesMultiBranchAnyOfOneOf_Issue3662(t *testing.T)
} {
for name, clean := range nullOnlyCleaners {
got := clean(nullOnlyInput)
- if !gjson.Valid(got) {
+ if !ojson.Valid(got) {
t.Fatalf("[%s] invalid JSON for null-only union %s: %s", name, nullOnlyInput, got)
}
- if gjson.Get(got, "properties.value.type").String() != "null" {
+ if ojson.Get(got, "properties.value.type").String() != "null" {
t.Fatalf("[%s] expected properties.value.type = null, got: %s", name, got)
}
}
@@ -3677,7 +3677,7 @@ func TestCleanJSONSchema_RemovesDiscriminatorKeyword_Issue6639(t *testing.T) {
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
for _, forbiddenPath := range []string{
"discriminator",
@@ -3759,7 +3759,7 @@ func TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699(t *testing.T)
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
for _, forbiddenPath := range []string{
"contentMediaType",
@@ -3805,7 +3805,7 @@ func TestIssue6732_GeminiCleanersInlineLocalDefs(t *testing.T) {
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
t.Errorf("%s: inlined properties.person.properties.name.type = %q, want %q: %s", name, gotType, "string", got)
}
@@ -3828,7 +3828,7 @@ func TestIssue6732_GeminiCleanersInlinePropertyReuse(t *testing.T) {
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.shipping.properties.zip.type").String(); gotType != "string" {
t.Errorf("%s: properties.shipping.properties.zip.type = %q, want %q: %s", name, gotType, "string", got)
}
@@ -3846,7 +3846,7 @@ func TestIssue6732_GeminiCleanersReuseSameDefinitionTwice(t *testing.T) {
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
} {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
for _, field := range []string{"billing", "shipping"} {
if gotType := parsed.Get("properties." + field + ".properties.zip.type").String(); gotType != "string" {
t.Errorf("%s: properties.%s.properties.zip.type = %q, want %q: %s", name, field, gotType, "string", got)
@@ -3863,7 +3863,7 @@ func TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot(t *testing.T) {
wrapped := `{"schema":` + inner + `}`
got := CleanJSONSchemaForAntigravityTool(wrapped, false)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("schema.properties.person.properties.name.type").String(); gotType != "string" {
t.Errorf("wrapped schema.properties.person.properties.name.type = %q, want %q: %s", gotType, "string", got)
}
@@ -3881,7 +3881,7 @@ func TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute(t *testing.T) {
input := `{"type":"object","properties":{"report":{"type":"object","default":{"title":"Report","format":"pdf","id":7,"x-owner":"ops"}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- def := gjson.Parse(got).Get("properties.report.default")
+ def := ojson.Parse(got).Get("properties.report.default")
if !def.Exists() {
t.Fatalf("default literal missing: %s", got)
}
@@ -3896,7 +3896,7 @@ func TestIssue6732_ExampleLiteralsPreserved(t *testing.T) {
input := `{"type":"object","properties":{"a":{"type":"string","example":{"title":"Hi","x-owner":"ops","id":3},"examples":[{"title":"Hi2","x-owner":"ops","id":4}]}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
example := parsed.Get("properties.a.example")
if example.Get("title").String() != "Hi" || example.Get("x-owner").String() != "ops" || example.Get("id").Int() != 3 {
t.Errorf("example literal was rewritten, got %s: %s", example.Raw, got)
@@ -3913,7 +3913,7 @@ func TestIssue6732_InlineLocalRefsLeavesLiteralRefs(t *testing.T) {
input := `{"type":"object","properties":{"a":{"type":"object","default":{"$ref":"#/$defs/Person"}}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
got := InlineLocalRefs(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if ref := parsed.Get("properties.a.default.$ref").String(); ref != "#/$defs/Person" {
t.Errorf("literal $ref inside default = %q, want it preserved verbatim: %s", ref, got)
}
@@ -3932,7 +3932,7 @@ func TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas(t *testing.T) {
input := `{"type":"object","properties":{"default":{"$ref":"#/$defs/Person"},"type":{"type":"string","title":"Kind"}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.default.properties.name.type").String(); gotType != "string" {
t.Errorf("property named \"default\" ref was not inlined, name type = %q: %s", gotType, got)
}
@@ -3950,7 +3950,7 @@ func TestIssue6732_ExpansionBudgetBoundsSharedDefs(t *testing.T) {
if len(got) >= 512*1024 {
t.Errorf("diamond schema output = %d bytes, want < %d (input %d bytes)", len(got), 512*1024, len(input))
}
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if !parsed.Get("properties.leaf").Exists() {
t.Errorf("diamond schema lost the top-level property: %s", got)
}
@@ -3984,7 +3984,7 @@ func TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget(t *testing.T) {
if got != first {
t.Fatalf("run %d: output is not deterministic:\nfirst: %s\ngot: %s", run, first, got)
}
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
t.Fatalf("run %d: used definition was not inlined, name type = %q: %s", run, gotType, got)
}
@@ -4011,7 +4011,7 @@ func TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions(t *testing.T) {
if len(got) >= 512*1024 {
t.Errorf("shared large-description output = %d bytes, want < %d", len(got), 512*1024)
}
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if !parsed.Get("properties.p0").Exists() || !parsed.Get("properties.p199").Exists() {
t.Errorf("shared large-description schema lost properties: %s", got)
}
@@ -4035,7 +4035,7 @@ func TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget(t *testing.T
sb.WriteString(`}}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`)
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
t.Errorf("nested unused definitions starved the kept field, name type = %q: %s", gotType, got)
}
@@ -4050,7 +4050,7 @@ func TestIssue6732_RefSiblingNameMapReferencesStillExpand(t *testing.T) {
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Base","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Base":{"type":"object"},"S":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
t.Errorf("ref sibling name-map reference lost its type, got %q: %s", gotType, got)
}
@@ -4068,7 +4068,7 @@ func TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate(t *testing.T)
if len(got) >= 64*1024 {
t.Errorf("clean inlining intermediate = %d bytes, want < %d", len(got), 64*1024)
}
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if parsed.Get("$defs").Exists() {
t.Errorf("intermediate should not carry root $defs: %s", got)
}
@@ -4087,7 +4087,7 @@ func TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems(t *testing.T) {
input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Rows"}},"$defs":{"Rows":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.rows.items.properties.name.type").String(); gotType != "string" {
t.Errorf("array ref target items were clobbered, name type = %q: %s", gotType, got)
}
@@ -4100,7 +4100,7 @@ func TestIssue6732_BudgetFallbackStillResolvesSiblings(t *testing.T) {
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Big","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 280*1024) + `"},"S":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
t.Errorf("sibling reference after budget fallback lost its type, got %q: %s", gotType, got)
}
@@ -4116,7 +4116,7 @@ func TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics(t *testing.
input := `{"$defs":{"S":{"type":"string"},"properties":{"type":"object","default":{"$ref":"#/$defs/S"}},"default":{"type":"object","properties":{"x":{"$ref":"#/$defs/S"}}}}}`
got := InlineLocalRefs(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if ref := parsed.Get("$defs.properties.default.$ref").String(); ref != "#/$defs/S" {
t.Errorf("literal default inside definition named \"properties\" was expanded, $ref = %q: %s", ref, got)
}
@@ -4135,7 +4135,7 @@ func TestIssue6732_TargetNestedDefinitionsExemptFromBudget(t *testing.T) {
}
for caseName, input := range cases {
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
namePath := "properties.person.properties.name.type"
if caseName == "rootRef" {
namePath = "properties.name.type"
@@ -4156,7 +4156,7 @@ func TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder(t *testing.T) {
input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Common"}},"$defs":{"Common":{"description":"Rows"}}}`
first := CleanJSONSchemaForAntigravityTool(input, false)
- parsed := gjson.Parse(first)
+ parsed := ojson.Parse(first)
if gotType := parsed.Get("properties.rows.items.type").String(); gotType != "string" {
t.Errorf("array ref without target items missing placeholder items, got %q: %s", gotType, first)
}
@@ -4203,7 +4203,7 @@ func TestIssue6732_DependenciesNameMapKeepsSchemaSemantics(t *testing.T) {
input := `{"type":"object","dependencies":{"default":{"properties":{"x":{"$ref":"#/definitions/S"}}}},"definitions":{"S":{"type":"string"}}}`
got := InlineLocalRefs(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("dependencies.default.properties.x.type").String(); gotType != "string" {
t.Errorf("dependency schema named \"default\" was not resolved, x type = %q: %s", gotType, got)
}
@@ -4219,7 +4219,7 @@ func TestIssue6732_OmittedFallbackDescriptionNotCharged(t *testing.T) {
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/A"},"b":{"$ref":"#/$defs/B"}},"$defs":{"A":{"type":"string","description":"` + strings.Repeat("x", 262142) + `"},"B":{"type":"object","properties":{"name":{"type":"string"}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get("properties.b.properties.name.type").String(); gotType != "string" {
t.Errorf("small reference after omitted fallback description lost its fields, name type = %q: %s", gotType, got)
}
@@ -4232,7 +4232,7 @@ func TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget(t *testing.T) {
input := `{"$ref":"#/$defs/O","items":{"type":"integer"},"$defs":{"O":{"type":"object","properties":{"x":{"type":"string"}}}}}`
first := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(first)
+ parsed := ojson.Parse(first)
if gotType := parsed.Get("type").String(); gotType != "object" {
t.Errorf("root ref target type = %q, want %q: %s", gotType, "object", first)
}
@@ -4253,7 +4253,7 @@ func TestIssue6732_PropertyNamedRefRemainsProperty(t *testing.T) {
input := `{"type":"object","properties":{"$ref":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get(`properties.\$ref.type`).String(); gotType != "string" {
t.Errorf("property named $ref lost its schema, type = %q: %s", gotType, got)
}
@@ -4268,7 +4268,7 @@ func TestIssue6732_PropertyNamedConstRemainsProperty(t *testing.T) {
input := `{"type":"object","properties":{"const":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
if gotType := parsed.Get(`properties.const.type`).String(); gotType != "string" {
t.Errorf("property named const lost its schema, type = %q: %s", gotType, got)
}
@@ -4357,7 +4357,7 @@ func TestCleanJSONSchema_RemovesReadOnlyWriteOnlyKeywords_Issue6778(t *testing.T
for name, clean := range cleaners {
t.Run(name, func(t *testing.T) {
got := clean(input)
- parsed := gjson.Parse(got)
+ parsed := ojson.Parse(got)
for _, forbiddenPath := range []string{
"readOnly",
@@ -4498,7 +4498,7 @@ func TestIssue6950_ArrayItemsFromAllOfPreserved(t *testing.T) {
for _, tc := range cases {
t.Run(cleanerName+"/"+tc.name, func(t *testing.T) {
got := cleaner.clean(tc.input)
- node := gjson.Parse(got)
+ node := ojson.Parse(got)
if tc.path != "" {
node = node.Get(tc.path)
}
@@ -4523,3 +4523,126 @@ func TestIssue6950_ArrayItemsFromAllOfPreserved(t *testing.T) {
}
}
}
+
+func TestIssue7149_CleanJSONSchemaForGeminiJSONSchemaNormalizesNumericConstraintStrings(t *testing.T) {
+ input := `{
+ "type": "OBJECT",
+ "minProperties": "1",
+ "maxProperties": "8",
+ "$defs": {
+ "Tag": {
+ "type": "STRING",
+ "minLength": "2",
+ "maxLength": "16"
+ }
+ },
+ "properties": {
+ "Message": {
+ "type": "STRING",
+ "minLength": "1",
+ "maxLength": "100",
+ "enum": ["1", "2"],
+ "default": "1",
+ "example": "1",
+ "examples": ["1", {"minLength": "1"}]
+ },
+ "Items": {
+ "type": "ARRAY",
+ "minItems": "1",
+ "maxItems": "4",
+ "items": {
+ "$ref": "#/$defs/Tag"
+ }
+ },
+ "EqualRef": {
+ "$ref": "#/$defs/Tag",
+ "minLength": 2
+ }
+ }
+ }`
+
+ got := CleanJSONSchemaForGeminiJSONSchema(input)
+ parsed := ojson.Parse(got)
+
+ for _, tc := range []struct {
+ path string
+ want int64
+ }{
+ {"minProperties", 1},
+ {"maxProperties", 8},
+ {"properties.Message.minLength", 1},
+ {"properties.Message.maxLength", 100},
+ {"properties.Items.minItems", 1},
+ {"properties.Items.maxItems", 4},
+ {"properties.Items.items.minLength", 2},
+ {"properties.Items.items.maxLength", 16},
+ {"properties.EqualRef.minLength", 2},
+ {"properties.EqualRef.maxLength", 16},
+ } {
+ val := parsed.Get(tc.path)
+ if val.Type != ojson.Number || val.Int() != tc.want {
+ t.Errorf("%s = %s (type %v), want number %d; output=%s", tc.path, val.Raw, val.Type, tc.want, got)
+ }
+ }
+
+ for _, tc := range []struct {
+ path string
+ want string
+ }{
+ {"properties.Message.enum.0", "1"},
+ {"properties.Message.enum.1", "2"},
+ {"properties.Message.default", "1"},
+ {"properties.Message.example", "1"},
+ {"properties.Message.examples.0", "1"},
+ {"properties.Message.examples.1.minLength", "1"},
+ } {
+ val := parsed.Get(tc.path)
+ if val.Type != ojson.String || val.String() != tc.want {
+ t.Errorf("%s = %s (type %v), want string %q; output=%s", tc.path, val.Raw, val.Type, tc.want, got)
+ }
+ }
+
+ extInput := `{"type":"string","minLength":"1","x-metadata":{"minLength":"2","maxItems":"3"},"customAnnotation":{"minProperties":"4"},"schema":{"minLength":"5"},"defs":{"metadata":{"minLength":"9"}},"properties":{"a\\b":{"type":"string","minLength":"6"},"a.b":{"type":"string","maxLength":"7"}},"patternProperties":{"^\\d+$":{"type":"string","minLength":"8"}}}`
+ extGot := NormalizeJSONSchemaNumericConstraints(extInput)
+ extParsed := ojson.Parse(extGot)
+ if got := extParsed.Get("minLength"); got.Type != ojson.Number || got.Int() != 1 {
+ t.Errorf("minLength = %s (type %v), want number 1; output=%s", got.Raw, got.Type, extGot)
+ }
+ if got := extParsed.Get("properties").Map()[`a\b`].Get("minLength"); got.Type != ojson.Number || got.Int() != 6 {
+ t.Errorf(`properties["a\\b"].minLength = %s (type %v), want number 6; output=%s`, got.Raw, got.Type, extGot)
+ }
+ if _, BogusExists := extParsed.Get("properties").Map()["ab"]; BogusExists {
+ t.Errorf(`unexpected fabricated property "ab" in output: %s`, extGot)
+ }
+ if got := extParsed.Get("properties").Map()["a.b"].Get("maxLength"); got.Type != ojson.Number || got.Int() != 7 {
+ t.Errorf(`properties["a.b"].maxLength = %s (type %v), want number 7; output=%s`, got.Raw, got.Type, extGot)
+ }
+ if got := extParsed.Get("patternProperties").Map()[`^\d+$`].Get("minLength"); got.Type != ojson.Number || got.Int() != 8 {
+ t.Errorf(`patternProperties["^\\d+$"].minLength = %s (type %v), want number 8; output=%s`, got.Raw, got.Type, extGot)
+ }
+ for _, path := range []struct {
+ path string
+ want string
+ }{
+ {"x-metadata.minLength", "2"},
+ {"x-metadata.maxItems", "3"},
+ {"customAnnotation.minProperties", "4"},
+ {"schema.minLength", "5"},
+ {"defs.metadata.minLength", "9"},
+ } {
+ got := extParsed.Get(path.path)
+ if got.Type != ojson.String || got.String() != path.want {
+ t.Errorf("%s = %s (type %v), want string %q; output=%s", path.path, got.Raw, got.Type, path.want, extGot)
+ }
+ }
+
+ unicodeEscapedInput := `{"type":"string","\u006dinLength":"1","\u0070roperties":{"Msg":{"type":"string","\u006daxLength":"2"}}}`
+ unicodeEscapedGot := NormalizeJSONSchemaNumericConstraints(unicodeEscapedInput)
+ unicodeParsed := ojson.Parse(unicodeEscapedGot)
+ if got := unicodeParsed.Get("minLength"); got.Type != ojson.Number || got.Int() != 1 {
+ t.Errorf("unicode-escaped minLength = %s (type %v), want number 1; output=%s", got.Raw, got.Type, unicodeEscapedGot)
+ }
+ if got := unicodeParsed.Get("properties.Msg.maxLength"); got.Type != ojson.Number || got.Int() != 2 {
+ t.Errorf("unicode-escaped properties.Msg.maxLength = %s (type %v), want number 2; output=%s", got.Raw, got.Type, unicodeEscapedGot)
+ }
+}
diff --git a/internal/util/gjson.go b/internal/util/gjson.go
index 840cf76823a..15757bf8c13 100644
--- a/internal/util/gjson.go
+++ b/internal/util/gjson.go
@@ -3,7 +3,7 @@ package util
import (
"unsafe"
- "github.com/tidwall/gjson"
+ gjson "github.com/MoeForge/moego/ojson"
)
// GetGJSONBytesNoCopy returns a GJSON result that may reference data directly.
@@ -12,7 +12,7 @@ func GetGJSONBytesNoCopy(data []byte, path string) gjson.Result {
if len(data) == 0 {
return gjson.Result{}
}
- return gjson.Get(unsafe.String(unsafe.SliceData(data), len(data)), path)
+ return gjson.GetBytesNoCopy(data, path)
}
// ParseGJSONBytesNoCopy parses data into a GJSON result that references data
diff --git a/internal/util/image.go b/internal/util/image.go
index 70d5cdc413c..2b929da31c3 100644
--- a/internal/util/image.go
+++ b/internal/util/image.go
@@ -2,7 +2,7 @@ package util
import (
"bytes"
- "encoding/base64"
+ "github.com/MoeForge/moego/base64"
"image"
"image/draw"
"image/png"
diff --git a/internal/util/nocopy_invariant_test.go b/internal/util/nocopy_invariant_test.go
index 2f0ae46d450..e4c64f09ce9 100644
--- a/internal/util/nocopy_invariant_test.go
+++ b/internal/util/nocopy_invariant_test.go
@@ -57,7 +57,7 @@ func forEachSourceFile(t *testing.T, root string, visit func(rel string, data []
// TestNoInPlaceSJSONWrites protects the invariant that request payload buffers
// stay immutable for their whole lifetime.
//
-// GetGJSONBytesNoCopy and ParseGJSONBytesNoCopy hand out gjson.Result values
+// GetGJSONBytesNoCopy and ParseGJSONBytesNoCopy hand out ojson.Result values
// whose Raw and Str alias the caller's []byte. Go strings must never change,
// so any in-place mutation of that buffer turns already-derived results into
// silently wrong data: re-parsing sees the new bytes, and strings that were
@@ -104,15 +104,18 @@ type reviewedInPlaceByteWrite struct {
}
var reviewedInPlaceByteWrites = map[string]reviewedInPlaceByteWrite{
- "internal/translator/common/apply_patch_responses.go": {1, "shifts [][]byte item references in a private output slice; no byte of any JSON payload is rewritten"},
- "internal/translator/common/vertex_partial_args.go": {1, "shifts []string object keys to insert a key; no byte of any payload is rewritten"},
- "internal/runtime/executor/claude_signing.go": {2, "writes CCH digits into bytes.Clone(body); the caller's body is never touched"},
- "internal/runtime/executor/claude_executor_cloaking.go": {1, "shifts []string headers to prepend a block; no byte of any payload is rewritten"},
- "internal/runtime/executor/claude_executor_request.go": {3, "shifts []string headers to insert a part; no byte of any payload is rewritten"},
- "internal/runtime/executor/helps/claude_mcp_alias.go": {1, "copies an HMAC sum into a local fixed-size digest array"},
- "internal/client/codex/live/tcp_proxy.go": {1, "copies header and payload into a freshly allocated frame"},
- "internal/home/client.go": {1, "zeroes a secret buffer after json.Unmarshal has copied every value out"},
- "internal/pluginstore/auth.go": {1, "zeroes a locally built credential buffer after base64 encoding copied it out"},
+ "internal/translator/common/apply_patch_responses.go": {1, "shifts [][]byte item references in a private output slice; no byte of any JSON payload is rewritten"},
+ "internal/translator/common/vertex_partial_args.go": {1, "shifts []string object keys to insert a key; no byte of any payload is rewritten"},
+ "internal/runtime/executor/claude_signing.go": {2, "writes CCH digits into bytes.Clone(body); the caller's body is never touched"},
+ "internal/runtime/executor/claude_executor_cloaking.go": {4, "shifts []string headers to prepend a block, or copies streamed tokens into private scanner/quote scratch buffers; no byte of any payload is rewritten"},
+ "internal/runtime/executor/claude_executor_request.go": {3, "shifts []string headers to insert a part; no byte of any payload is rewritten"},
+ "internal/runtime/executor/xai_executor_request.go": {5, "copies streamed tokens into private scanner/quote scratch buffers; no byte of any payload is rewritten"},
+ "internal/runtime/executor/helps/claude_credential_identity.go": {1, "copies key bytes into a local stack-allocated quote array for json.Unmarshal"},
+ "internal/runtime/executor/helps/claude_mcp_alias.go": {1, "copies an HMAC sum into a local fixed-size digest array"},
+ "internal/client/codex/live/tcp_proxy.go": {1, "copies header and payload into a freshly allocated frame"},
+ "internal/kvstore/bolt_store.go": {1, "copies a stored value into a freshly allocated envelope buffer; the caller's value is never touched"},
+ "internal/home/client.go": {1, "zeroes a secret buffer after json.Unmarshal has copied every value out"},
+ "internal/pluginstore/auth.go": {1, "zeroes a locally built credential buffer after base64 encoding copied it out"},
}
// TestInPlaceByteWritesAreReviewed keeps the set of in-place byte writes small
diff --git a/internal/util/responses_tools.go b/internal/util/responses_tools.go
index b2c7dafca70..2675c7dc294 100644
--- a/internal/util/responses_tools.go
+++ b/internal/util/responses_tools.go
@@ -7,9 +7,8 @@ import (
"sort"
"strings"
+ "github.com/MoeForge/moego/ojson"
applypatch "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/apply-patch"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ResponsesToolIdentity represents the resolved identity of a tool in OpenAI Responses format.
@@ -49,7 +48,7 @@ type ResponsesToolDescriptor struct {
LocalName string // Local name without namespace (e.g. "exec")
Namespace string // Namespace if any (e.g. "functions")
ToolType string // "function", "custom", etc.
- Tool gjson.Result
+ Tool ojson.Result
SourcePriority int // 0 for top-level tools, 1 for additional_tools, 2 for tool_search_output
Direct bool // true if declared directly, false if declared as namespace child
Order int // original discovery order
@@ -72,32 +71,42 @@ func QualifyResponsesNamespaceToolName(namespaceName, childName string) string {
return namespaceName + "__" + childName
}
-func responsesToolSources(root gjson.Result, includeClientToolSearch bool) []struct {
- tools gjson.Result
+func responsesToolSources(root ojson.Result, includeClientToolSearch bool) []struct {
+ tools ojson.Result
priority int
} {
+ return responsesToolSourcesFromGetter(root.Get, includeClientToolSearch)
+}
+
+func responsesToolSourcesFromGetter(get func(string) ojson.Result, includeClientToolSearch bool) []struct {
+ tools ojson.Result
+ priority int
+} {
+ if get == nil {
+ return nil
+ }
var sources []struct {
- tools gjson.Result
+ tools ojson.Result
priority int
}
- appendSource := func(tools gjson.Result, priority int) {
+ appendSource := func(tools ojson.Result, priority int) {
if tools.Exists() && tools.IsArray() {
sources = append(sources, struct {
- tools gjson.Result
+ tools ojson.Result
priority int
}{tools: tools, priority: priority})
}
}
- appendSource(root.Get("tools"), 0)
- if input := root.Get("input"); input.Exists() && input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ appendSource(get("tools"), 0)
+ if input := get("input"); input.Exists() && input.IsArray() {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "additional_tools" {
appendSource(item.Get("tools"), 1)
}
return true
})
if includeClientToolSearch {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == ResponsesToolSearchOutputType && IsResponsesClientToolSearchItem(item) {
appendSource(item.Get("tools"), ResponsesDiscoveredToolPriority)
}
@@ -110,7 +119,7 @@ func responsesToolSources(root gjson.Result, includeClientToolSearch bool) []str
// IsResponsesClientToolSearchDeclaration reports whether a tool declaration is
// a client-executed Responses tool_search.
-func IsResponsesClientToolSearchDeclaration(tool gjson.Result) bool {
+func IsResponsesClientToolSearchDeclaration(tool ojson.Result) bool {
return strings.TrimSpace(tool.Get("type").String()) == ResponsesToolSearchType &&
strings.TrimSpace(tool.Get("execution").String()) == ResponsesToolSearchExecutionClient
}
@@ -118,12 +127,12 @@ func IsResponsesClientToolSearchDeclaration(tool gjson.Result) bool {
// IsResponsesClientToolSearchItem reports whether a tool_search_call or
// tool_search_output input item belongs to client-executed tool search.
// Hosted server search explicitly sets execution to "server".
-func IsResponsesClientToolSearchItem(item gjson.Result) bool {
+func IsResponsesClientToolSearchItem(item ojson.Result) bool {
execution := strings.TrimSpace(item.Get("execution").String())
return execution == "" || execution == ResponsesToolSearchExecutionClient
}
-func responsesToolName(tool gjson.Result) string {
+func responsesToolName(tool ojson.Result) string {
if name := strings.TrimSpace(tool.Get("name").String()); name != "" {
return name
}
@@ -131,19 +140,19 @@ func responsesToolName(tool gjson.Result) string {
}
// ResponsesToolDescription extracts the description from a tool or function object.
-func ResponsesToolDescription(tool gjson.Result) string {
+func ResponsesToolDescription(tool ojson.Result) string {
if description := tool.Get("description").String(); description != "" {
return description
}
return tool.Get("function.description").String()
}
-func responsesToolDescription(tool gjson.Result) string {
+func responsesToolDescription(tool ojson.Result) string {
return ResponsesToolDescription(tool)
}
// ResponsesToolParameters extracts the schema/parameters from a tool or function object.
-func ResponsesToolParameters(tool gjson.Result) gjson.Result {
+func ResponsesToolParameters(tool ojson.Result) ojson.Result {
for _, path := range []string{
"parameters",
"parametersJsonSchema",
@@ -155,21 +164,33 @@ func ResponsesToolParameters(tool gjson.Result) gjson.Result {
return parameters
}
}
- return gjson.Result{}
+ return ojson.Result{}
}
-func responsesToolParameters(tool gjson.Result) gjson.Result {
+func responsesToolParameters(tool ojson.Result) ojson.Result {
return ResponsesToolParameters(tool)
}
// CollectResponsesToolDescriptors extracts all tool descriptors from a Responses request root.
-func CollectResponsesToolDescriptors(root gjson.Result) []ResponsesToolDescriptor {
+func CollectResponsesToolDescriptors(root ojson.Result) []ResponsesToolDescriptor {
return collectResponsesToolDescriptors(root, false)
}
-func collectResponsesToolDescriptors(root gjson.Result, includeClientToolSearch bool) []ResponsesToolDescriptor {
+// CollectResponsesToolDescriptorsDoc extracts all tool descriptors from a Responses request doc.
+func CollectResponsesToolDescriptorsDoc(doc *ojson.Doc) []ResponsesToolDescriptor {
+ if doc == nil || doc.Len() == 0 {
+ return nil
+ }
+ return collectResponsesToolDescriptorsFromGetter(doc.Get, false)
+}
+
+func collectResponsesToolDescriptors(root ojson.Result, includeClientToolSearch bool) []ResponsesToolDescriptor {
+ return collectResponsesToolDescriptorsFromGetter(root.Get, includeClientToolSearch)
+}
+
+func collectResponsesToolDescriptorsFromGetter(get func(string) ojson.Result, includeClientToolSearch bool) []ResponsesToolDescriptor {
var descriptors []ResponsesToolDescriptor
- appendDescriptor := func(tool gjson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool, clientSearch bool) {
+ appendDescriptor := func(tool ojson.Result, name, localName, namespace string, toolType string, sourcePriority int, direct bool, clientSearch bool) {
if name == "" {
return
}
@@ -185,7 +206,7 @@ func collectResponsesToolDescriptors(root gjson.Result, includeClientToolSearch
ClientSearch: clientSearch,
})
}
- appendNamespaceChildren := func(namespaceTool gjson.Result, sourcePriority int) {
+ appendNamespaceChildren := func(namespaceTool ojson.Result, sourcePriority int) {
namespaceName := strings.TrimSpace(namespaceTool.Get("name").String())
children := namespaceTool.Get("tools")
if !children.Exists() || !children.IsArray() {
@@ -194,7 +215,7 @@ func collectResponsesToolDescriptors(root gjson.Result, includeClientToolSearch
if !children.Exists() || !children.IsArray() {
return
}
- children.ForEach(func(_, child gjson.Result) bool {
+ children.ForEach(func(_, child ojson.Result) bool {
childName := responsesToolName(child)
if childName == "" {
return true
@@ -209,8 +230,8 @@ func collectResponsesToolDescriptors(root gjson.Result, includeClientToolSearch
return true
})
}
- for _, source := range responsesToolSources(root, includeClientToolSearch) {
- source.tools.ForEach(func(_, tool gjson.Result) bool {
+ for _, source := range responsesToolSourcesFromGetter(get, includeClientToolSearch) {
+ source.tools.ForEach(func(_, tool ojson.Result) bool {
toolType := strings.TrimSpace(tool.Get("type").String())
switch toolType {
case "", "function":
@@ -264,10 +285,15 @@ func responsesToolDescriptorPrecedes(left, right ResponsesToolDescriptor) bool {
}
// CollectResponsesToolWinners collects deduplicated winning descriptors for each qualified tool name.
-func CollectResponsesToolWinners(root gjson.Result) map[string]ResponsesToolDescriptor {
+func CollectResponsesToolWinners(root ojson.Result) map[string]ResponsesToolDescriptor {
return collectResponsesToolWinnersFromDescriptors(CollectResponsesToolDescriptors(root))
}
+// CollectResponsesToolWinnersDoc collects deduplicated winning descriptors from doc.
+func CollectResponsesToolWinnersDoc(doc *ojson.Doc) map[string]ResponsesToolDescriptor {
+ return collectResponsesToolWinnersFromDescriptors(CollectResponsesToolDescriptorsDoc(doc))
+}
+
func collectResponsesToolWinnersFromDescriptors(descriptors []ResponsesToolDescriptor) map[string]ResponsesToolDescriptor {
winners := map[string]ResponsesToolDescriptor{}
for _, descriptor := range descriptors {
@@ -338,13 +364,13 @@ type responsesToolWinnerKey struct {
}
// BuildGeminiFunctionDeclarations builds Gemini function declarations, forward name mapping, and reverse identity mapping.
-func BuildGeminiFunctionDeclarations(root gjson.Result) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) {
+func BuildGeminiFunctionDeclarations(root ojson.Result) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) {
return buildGeminiFunctionDeclarations(root, true)
}
// buildGeminiFunctionDeclarations skips per-tool declaration construction and schema
// cleanup when the caller only needs the name and identity maps.
-func buildGeminiFunctionDeclarations(root gjson.Result, withDeclarations bool) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) {
+func buildGeminiFunctionDeclarations(root ojson.Result, withDeclarations bool) ([][]byte, map[string]string, map[string]ResponsesToolIdentity) {
descriptors := collectResponsesToolDescriptors(root, true)
winners := make(map[responsesToolWinnerKey]ResponsesToolDescriptor, len(descriptors))
for _, descriptor := range descriptors {
@@ -372,7 +398,7 @@ func buildGeminiFunctionDeclarations(root gjson.Result, withDeclarations bool) (
hasClientSearchHistory := false
if include := root.Get("input"); include.Exists() && include.IsArray() {
- include.ForEach(func(_, item gjson.Result) bool {
+ include.ForEach(func(_, item ojson.Result) bool {
typ := item.Get("type").String()
if (typ == ResponsesToolSearchCallType || typ == ResponsesToolSearchOutputType) && IsResponsesClientToolSearchItem(item) {
hasClientSearchHistory = true
@@ -486,26 +512,26 @@ func buildGeminiFunctionDeclarations(root gjson.Result, withDeclarations bool) (
continue
}
- funcDecl := []byte(`{"name":"","description":"","parametersJsonSchema":{}}`)
- funcDecl, _ = sjson.SetBytes(funcDecl, "name", geminiName)
+ funcDeclDoc := ojson.NewDoc([]byte(`{"name":"","description":"","parametersJsonSchema":{}}`))
+ _ = funcDeclDoc.Set("name", geminiName)
if descStr := responsesToolDescription(desc.Tool); descStr != "" {
- funcDecl, _ = sjson.SetBytes(funcDecl, "description", descStr)
+ _ = funcDeclDoc.Set("description", descStr)
}
if applypatch.IsCustomTool(desc.Tool) {
- funcDecl, _ = sjson.SetBytes(funcDecl, "description", applypatch.Description(desc.Tool))
- funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", applypatch.Parameters())
+ _ = funcDeclDoc.Set("description", applypatch.Description(desc.Tool))
+ _ = funcDeclDoc.SetRawBytes("parametersJsonSchema", applypatch.Parameters())
} else if desc.ToolType == "custom" {
- funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(`{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`))
+ _ = funcDeclDoc.SetRaw("parametersJsonSchema", `{"type":"object","properties":{"input":{"type":"string"}},"required":["input"]}`)
} else if desc.ClientSearch {
- funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", buildGeminiToolSearchParametersSchema(desc.Tool))
+ _ = funcDeclDoc.SetRawBytes("parametersJsonSchema", buildGeminiToolSearchParametersSchema(desc.Tool))
} else {
params := responsesToolParameters(desc.Tool)
if params.Exists() {
- funcDecl, _ = sjson.SetRawBytes(funcDecl, "parametersJsonSchema", []byte(CleanJSONSchemaForGeminiJSONSchema(params.Raw)))
+ _ = funcDeclDoc.SetRaw("parametersJsonSchema", CleanJSONSchemaForGeminiJSONSchema(params.Raw))
}
}
- declarations = append(declarations, funcDecl)
+ declarations = append(declarations, funcDeclDoc.Bytes())
}
// Populate local-name aliases after all direct/qualified names are registered
@@ -521,10 +547,10 @@ func buildGeminiFunctionDeclarations(root gjson.Result, withDeclarations bool) (
return declarations, forwardMap, reverseMap
}
-func buildGeminiToolSearchParametersSchema(tool gjson.Result) []byte {
+func buildGeminiToolSearchParametersSchema(tool ojson.Result) []byte {
if params := responsesToolParameters(tool); params.Exists() && params.IsObject() {
cleaned := CleanJSONSchemaForGeminiJSONSchema(params.Raw)
- if gjson.Valid(cleaned) && gjson.Parse(cleaned).IsObject() {
+ if ojson.Valid(cleaned) && ojson.Parse(cleaned).IsObject() {
return []byte(cleaned)
}
}
@@ -533,10 +559,10 @@ func buildGeminiToolSearchParametersSchema(tool gjson.Result) []byte {
// ResponsesToolReverseIdentityMap builds a Gemini function name -> ResponsesToolIdentity map from a Responses request raw JSON.
func ResponsesToolReverseIdentityMap(rawJSON []byte) map[string]ResponsesToolIdentity {
- if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
+ if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) {
return nil
}
- root := gjson.ParseBytes(rawJSON)
+ root := ojson.ParseBytes(rawJSON)
if req := root.Get("request"); req.Exists() && (req.Get("model").Exists() || req.Get("input").Exists() || req.Get("tools").Exists()) {
root = req
}
@@ -562,13 +588,13 @@ func MapResponsesClientToolSearchName(forwardMap map[string]string) string {
}
// ConvertResponsesToolChoiceToGemini translates Responses tool_choice into Gemini functionCallingConfig JSON.
-func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[string]string) ([]byte, bool) {
+func ConvertResponsesToolChoiceToGemini(toolChoice ojson.Result, forwardMap map[string]string) ([]byte, bool) {
if !toolChoice.Exists() {
return nil, false
}
mode := ""
var allowedNames []string
- if toolChoice.Type == gjson.String {
+ if toolChoice.Type == ojson.String {
switch strings.ToLower(strings.TrimSpace(toolChoice.String())) {
case "none":
mode = "NONE"
@@ -617,12 +643,12 @@ func ConvertResponsesToolChoiceToGemini(toolChoice gjson.Result, forwardMap map[
if mode == "" {
return nil, false
}
- cfg := []byte(`{"mode":""}`)
- cfg, _ = sjson.SetBytes(cfg, "mode", mode)
+ cfgDoc := ojson.NewDoc([]byte(`{"mode":""}`))
+ _ = cfgDoc.Set("mode", mode)
if len(allowedNames) > 0 {
- cfg, _ = sjson.SetBytes(cfg, "allowedFunctionNames", allowedNames)
+ _ = cfgDoc.Set("allowedFunctionNames", allowedNames)
}
- return cfg, true
+ return cfgDoc.Bytes(), true
}
// UnwrapResponsesCustomToolInput extracts the raw input string from custom tool arguments JSON or plain string.
@@ -631,15 +657,15 @@ func UnwrapResponsesCustomToolInput(arguments string) string {
if arguments == "" || arguments == "{}" {
return ""
}
- if gjson.Valid(arguments) {
- parsed := gjson.Parse(arguments)
+ if ojson.Valid(arguments) {
+ parsed := ojson.Parse(arguments)
if v := parsed.Get("input"); v.Exists() {
- if v.Type == gjson.String {
+ if v.Type == ojson.String {
return v.String()
}
return v.Raw
}
- if parsed.Type == gjson.String {
+ if parsed.Type == ojson.String {
return parsed.String()
}
}
diff --git a/internal/util/responses_tools_test.go b/internal/util/responses_tools_test.go
index c52afac322a..028d215b586 100644
--- a/internal/util/responses_tools_test.go
+++ b/internal/util/responses_tools_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestCollectResponsesToolDescriptors_PriorityAndNamespace(t *testing.T) {
@@ -31,7 +31,7 @@ func TestCollectResponsesToolDescriptors_PriorityAndNamespace(t *testing.T) {
]
}`
- root := gjson.Parse(raw)
+ root := ojson.Parse(raw)
descriptors := CollectResponsesToolDescriptors(root)
if len(descriptors) != 4 {
t.Fatalf("expected 4 descriptors, got %d", len(descriptors))
@@ -72,7 +72,7 @@ func TestResponsesToolWinners_TopLevelBeatsAdditionalTools(t *testing.T) {
]
}`
- root := gjson.Parse(raw)
+ root := ojson.Parse(raw)
winners := CollectResponsesToolWinners(root)
winner := winners["shared_fn"]
if winner.SourcePriority != 0 {
@@ -91,7 +91,7 @@ func TestResponsesToolWinners_DirectBeatsNamespaceChild(t *testing.T) {
]
}`
- root := gjson.Parse(raw)
+ root := ojson.Parse(raw)
winners := CollectResponsesToolWinners(root)
winner := winners["n__x"]
if !winner.Direct {
@@ -143,17 +143,17 @@ func TestConvertResponsesToolChoiceToGemini(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- choice := gjson.Parse(tt.choiceJSON)
+ choice := ojson.Parse(tt.choiceJSON)
out, ok := ConvertResponsesToolChoiceToGemini(choice, tt.forwardMap)
if !ok {
t.Fatalf("ConvertResponsesToolChoiceToGemini returned false")
}
- mode := gjson.GetBytes(out, "mode").String()
+ mode := ojson.GetBytes(out, "mode").String()
if mode != tt.wantMode {
t.Fatalf("mode = %q, want %q", mode, tt.wantMode)
}
if len(tt.wantNames) > 0 {
- names := gjson.GetBytes(out, "allowedFunctionNames").Array()
+ names := ojson.GetBytes(out, "allowedFunctionNames").Array()
if len(names) != len(tt.wantNames) {
t.Fatalf("allowedFunctionNames count = %d, want %d", len(names), len(tt.wantNames))
}
@@ -197,7 +197,7 @@ func TestBuildGeminiFunctionDeclarations_DisambiguationAndLongNames(t *testing.T
]
}`
- root := gjson.Parse(raw)
+ root := ojson.Parse(raw)
decls, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(root)
if len(decls) != 3 {
t.Fatalf("expected 3 decls, got %d", len(decls))
@@ -230,12 +230,12 @@ func TestBuildGeminiFunctionDeclarations_DisambiguationAndLongNames(t *testing.T
}
func TestBuildGeminiApplyPatchDeclaration(t *testing.T) {
- root := gjson.Parse(`{"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}`)
+ root := ojson.Parse(`{"tools":[{"type":"custom","name":"apply_patch","format":{"type":"grammar","syntax":"lark","definition":"start: patch"}}]}`)
declarations, _, reverse := BuildGeminiFunctionDeclarations(root)
if len(declarations) != 1 {
t.Fatalf("declarations: %d", len(declarations))
}
- declaration := gjson.ParseBytes(declarations[0])
+ declaration := ojson.ParseBytes(declarations[0])
if !strings.Contains(declaration.Get("description").String(), "*** Begin Patch") || !strings.Contains(declaration.Get("description").String(), "start: patch") {
t.Fatal("patch format is missing")
}
@@ -248,7 +248,7 @@ func TestBuildGeminiApplyPatchDeclaration(t *testing.T) {
}
func TestBuildGeminiApplyPatchIdentityUsesWinningOriginalDeclaration(t *testing.T) {
- root := gjson.Parse(`{"tools":[{"type":"namespace","name":"a.b","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"a_b__apply_patch","parameters":{"type":"object","properties":{"n":{"type":"number"}}}},{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`)
+ root := ojson.Parse(`{"tools":[{"type":"namespace","name":"a.b","tools":[{"type":"custom","name":"apply_patch"}]},{"type":"function","name":"a_b__apply_patch","parameters":{"type":"object","properties":{"n":{"type":"number"}}}},{"type":"function","name":"apply_patch"}],"input":[{"type":"additional_tools","tools":[{"type":"custom","name":"apply_patch"}]}]}`)
declarations, forward, reverse := BuildGeminiFunctionDeclarations(root)
if len(declarations) != 3 {
t.Fatalf("declarations=%d", len(declarations))
@@ -257,7 +257,7 @@ func TestBuildGeminiApplyPatchIdentityUsesWinningOriginalDeclaration(t *testing.
t.Fatal("sanitized collision was not separated")
}
for _, raw := range declarations {
- decl := gjson.ParseBytes(raw)
+ decl := ojson.ParseBytes(raw)
identity := reverse[decl.Get("name").String()]
patch := identity.Custom && identity.Namespace == "a.b"
if identity.ApplyPatch != patch || strings.Contains(decl.Get("description").String(), "*** Begin Patch") != patch {
@@ -278,12 +278,12 @@ func TestBuildGeminiFunctionDeclarations_ClientToolSearchShimAndDeferredFilterin
{"type": "web_search"}
]
}`
- declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if len(declarations) != 2 {
t.Fatalf("expected exec_command and tool_search declarations (with deferred_fn hidden), got %d: %s", len(declarations), declarations)
}
- shim := gjson.ParseBytes(declarations[1])
+ shim := ojson.ParseBytes(declarations[1])
if got := shim.Get("name").String(); got != "tool_search" {
t.Fatalf("shim name = %q, want tool_search", got)
}
@@ -309,35 +309,35 @@ func TestBuildGeminiFunctionDeclarations_ClientToolSearchShimAndDeferredFilterin
func TestBuildGeminiFunctionDeclarations_ToolSearchShimDefaultsAndPrecedence(t *testing.T) {
t.Run("default parameters when omitted and custom schema preserved when provided", func(t *testing.T) {
- defaultDecls, _, _ := BuildGeminiFunctionDeclarations(gjson.Parse(`{"tools": [{"type": "tool_search", "execution": "client"}]}`))
+ defaultDecls, _, _ := BuildGeminiFunctionDeclarations(ojson.Parse(`{"tools": [{"type": "tool_search", "execution": "client"}]}`))
if len(defaultDecls) != 1 {
t.Fatalf("expected one shim declaration, got %d", len(defaultDecls))
}
- if got := gjson.GetBytes(defaultDecls[0], "parametersJsonSchema.properties.query.type").String(); got != "string" {
+ if got := ojson.GetBytes(defaultDecls[0], "parametersJsonSchema.properties.query.type").String(); got != "string" {
t.Fatalf("default parameters missing query: %s", defaultDecls[0])
}
- if !gjson.GetBytes(defaultDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
+ if !ojson.GetBytes(defaultDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
t.Fatalf("default parameters missing required query: %s", defaultDecls[0])
}
customGoalRaw := `{"tools": [{"type": "tool_search", "execution": "client", "description": "Custom goal search", "parameters": {"type": "object", "properties": {"goal": {"type": "string"}}, "required": ["goal"], "additionalProperties": false}}]}`
- customDecls, _, _ := BuildGeminiFunctionDeclarations(gjson.Parse(customGoalRaw))
+ customDecls, _, _ := BuildGeminiFunctionDeclarations(ojson.Parse(customGoalRaw))
if len(customDecls) != 1 {
t.Fatalf("expected one shim declaration, got %d", len(customDecls))
}
- if got := gjson.GetBytes(customDecls[0], "parametersJsonSchema.properties.goal.type").String(); got != "string" {
+ if got := ojson.GetBytes(customDecls[0], "parametersJsonSchema.properties.goal.type").String(); got != "string" {
t.Fatalf("custom goal property not preserved: %s", customDecls[0])
}
- if gjson.GetBytes(customDecls[0], "parametersJsonSchema.properties.query").Exists() {
+ if ojson.GetBytes(customDecls[0], "parametersJsonSchema.properties.query").Exists() {
t.Fatalf("custom schema must not be mutated to inject query: %s", customDecls[0])
}
- if gjson.GetBytes(customDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
+ if ojson.GetBytes(customDecls[0], `parametersJsonSchema.required.#(=="query")`).Exists() {
t.Fatalf("custom required array must not inject query: %s", customDecls[0])
}
})
t.Run("server executed search is not emulated", func(t *testing.T) {
raw := `{"tools": [{"type": "tool_search"}, {"type": "tool_search", "execution": "server"}]}`
- declarations, _, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ declarations, _, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if len(declarations) != 0 || len(reverseMap) != 0 {
t.Fatalf("server-executed tool_search must be dropped, got %s", declarations)
}
@@ -358,7 +358,7 @@ func TestBuildGeminiFunctionDeclarations_ToolSearchShimDefaultsAndPrecedence(t *
},
} {
t.Run(tc.name, func(t *testing.T) {
- declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(tc.raw))
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(tc.raw))
if len(declarations) != 2 {
t.Fatalf("expected both user %s and client tool_search shim (with deferred_fn hidden), got %d declarations: %s", userFnName, len(declarations), declarations)
}
@@ -382,7 +382,7 @@ func TestBuildGeminiFunctionDeclarations_ToolSearchShimDefaultsAndPrecedence(t *
})
t.Run("sanitized name collision with tool.search keeps both", func(t *testing.T) {
raw := `{"tools": [{"type": "function", "name": "tool.search", "description": "dotted user tool"}, {"type": "tool_search", "execution": "client", "description": "client search"}]}`
- declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if len(declarations) != 2 {
t.Fatalf("expected 2 declarations, got %d: %s", len(declarations), declarations)
}
@@ -403,14 +403,14 @@ func TestBuildGeminiFunctionDeclarations_ToolSearchShimDefaultsAndPrecedence(t *
})
t.Run("shared CollectResponsesToolDescriptors keeps deferred tools for non-search callers", func(t *testing.T) {
raw := `{"tools": [{"type": "tool_search", "execution": "client"}, {"type": "function", "name": "deferred_fn", "defer_loading": true}, {"type": "function", "name": "eager_fn"}]}`
- descriptors := CollectResponsesToolDescriptors(gjson.Parse(raw))
+ descriptors := CollectResponsesToolDescriptors(ojson.Parse(raw))
if len(descriptors) != 2 || descriptors[0].Name != "deferred_fn" || descriptors[1].Name != "eager_fn" {
t.Fatalf("CollectResponsesToolDescriptors = %+v, want [deferred_fn eager_fn]", descriptors)
}
})
t.Run("additional_tools declaration", func(t *testing.T) {
raw := `{"input": [{"type": "additional_tools", "tools": [{"type": "tool_search", "execution": "client"}]}]}`
- _, _, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ _, _, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
if !reverseMap[ResponsesToolSearchFunctionName].ToolSearch {
t.Fatalf("tool_search inside additional_tools must be exposed: %+v", reverseMap)
}
@@ -437,16 +437,16 @@ func TestBuildGeminiFunctionDeclarations_CollectsToolSearchOutputTools(t *testin
]}
]
}`
- declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(gjson.Parse(raw))
+ declarations, forwardMap, reverseMap := BuildGeminiFunctionDeclarations(ojson.Parse(raw))
names := make([]string, 0, len(declarations))
for _, declaration := range declarations {
- names = append(names, gjson.GetBytes(declaration, "name").String())
+ names = append(names, ojson.GetBytes(declaration, "name").String())
}
if len(names) != 3 || names[0] != "mcp__cua_repl__js" || names[1] != "tool_search" || names[2] != "fixture_tools__ping" {
t.Fatalf("declarations = %v, want [mcp__cua_repl__js tool_search fixture_tools__ping]", names)
}
- if got := gjson.GetBytes(declarations[0], "description").String(); got != "current" {
+ if got := ojson.GetBytes(declarations[0], "description").String(); got != "current" {
t.Fatalf("top-level declaration must win over a discovered duplicate, got %q", got)
}
if got := forwardMap["fixture_tools__ping"]; got != "fixture_tools__ping" {
@@ -477,7 +477,7 @@ func TestResponsesToolReverseIdentityMap_MatchesFullDeclarations(t *testing.T) {
{"type": "namespace", "name": "mcp", "tools": [{"type": "function", "name": "search", "parameters": {"$defs": {"filter": {"type": "object", "properties": {"tag": {"type": ["string", "null"]}}}}, "type": "object", "properties": {"q": {"type": ["string", "null"]}, "filter": {"$ref": "#/$defs/filter"}}, "additionalProperties": false}}]}
]
}`)
- _, _, full := BuildGeminiFunctionDeclarations(gjson.ParseBytes(heavyRaw))
+ _, _, full := BuildGeminiFunctionDeclarations(ojson.ParseBytes(heavyRaw))
fast := ResponsesToolReverseIdentityMap(heavyRaw)
if len(full) == 0 || !reflect.DeepEqual(full, fast) {
t.Fatalf("reverse identity map differs from full declarations:\nfull=%#v\nfast=%#v", full, fast)
diff --git a/internal/util/sanitize_test.go b/internal/util/sanitize_test.go
index da4c4778e81..112a9f6002e 100644
--- a/internal/util/sanitize_test.go
+++ b/internal/util/sanitize_test.go
@@ -3,7 +3,7 @@ package util
import (
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestSanitizeClaudeFunctionName(t *testing.T) {
@@ -219,7 +219,7 @@ func TestDeduplicateFunctionDeclarations(t *testing.T) {
{"name":"lookup","description":"second"}
]`)
deduped := DeduplicateFunctionDeclarations(raw)
- declarations := gjson.ParseBytes(deduped).Array()
+ declarations := ojson.ParseBytes(deduped).Array()
if len(declarations) != 2 {
t.Fatalf("declaration count = %d, want 2: %s", len(declarations), deduped)
}
@@ -765,3 +765,39 @@ func TestCleanClaudeToolArguments_ImplicitOpenObjectPreservesArguments(t *testin
})
}
}
+
+func TestToolNameMapsDoNotAliasInputBytes(t *testing.T) {
+ raw := []byte(`{"tools":[{"name":"mcp__alpha__run/tool"},{"type":"namespace","name":"ns","tools":[{"type":"custom","name":"patch/tool"}]}],"messages":[{"role":"system","content":[{"type":"tool_addition","tool":{"type":"tool_definition","definition":{"name":"Inline/Tool"}}}]}]}`)
+ claudeMap := ToolNameMapFromClaudeRequest(raw)
+ unambiguousMap := UnambiguousToolNameMapFromClaudeRequest(raw)
+ sanitizedToolMap := SanitizedToolNameMap(raw)
+ sanitizedFuncMap := SanitizedFunctionNameMap(raw)
+ disambiguatedMap := DisambiguatedToolNameMap(raw)
+ reverseIdentityMap := ResponsesToolReverseIdentityMap(raw)
+
+ for i := range raw {
+ raw[i] = 'X'
+ }
+
+ if got := claudeMap[CanonicalToolName("mcp__alpha__run/tool")]; got != "mcp__alpha__run/tool" {
+ t.Fatalf("ToolNameMapFromClaudeRequest aliased input buffer: got %q", got)
+ }
+ if got := claudeMap[CanonicalToolName("Inline/Tool")]; got != "Inline/Tool" {
+ t.Fatalf("ToolNameMapFromClaudeRequest inline tool_addition aliased input buffer: got %q", got)
+ }
+ if got := unambiguousMap[CanonicalToolName("mcp__alpha__run/tool")]; got != "mcp__alpha__run/tool" {
+ t.Fatalf("UnambiguousToolNameMapFromClaudeRequest aliased input buffer: got %q", got)
+ }
+ if got := sanitizedToolMap["mcp__alpha__run_tool"]; got != "mcp__alpha__run/tool" {
+ t.Fatalf("SanitizedToolNameMap aliased input buffer: got %q", got)
+ }
+ if got := sanitizedFuncMap["mcp__alpha__run/tool"]; got != "mcp__alpha__run_tool" {
+ t.Fatalf("SanitizedFunctionNameMap aliased input buffer: got %q", got)
+ }
+ if got := disambiguatedMap["mcp__alpha__run_tool"]; got != "mcp__alpha__run/tool" {
+ t.Fatalf("DisambiguatedToolNameMap aliased input buffer: got %q", got)
+ }
+ if got := reverseIdentityMap["ns__patch_tool"]; got.Name != "patch/tool" || got.Namespace != "ns" {
+ t.Fatalf("ResponsesToolReverseIdentityMap aliased input buffer: got %+v", got)
+ }
+}
diff --git a/internal/util/sjson_delete.go b/internal/util/sjson_delete.go
index c8cb11f5ad9..67cfad17472 100644
--- a/internal/util/sjson_delete.go
+++ b/internal/util/sjson_delete.go
@@ -1,21 +1,32 @@
package util
import (
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
-// DeleteJSONBytesIfExists behaves like sjson.DeleteBytes but returns data
-// unchanged when path is absent. sjson.DeleteBytes copies the document at
+// DeleteJSONBytesIfExists behaves like ojson.DeleteBytes but returns data
+// unchanged when path is absent. ojson.DeleteBytes copies the document at
// least once even when there is nothing to delete (a partial prefix copy is
// built and discarded, then the whole input is copied again before sjson
// returns the original slice), which dominates allocation when callers
// defensively delete alias spellings that are usually missing. path must be a
// nonempty literal dotted path (gjson-compatible escapes like \. are
// supported); sjson's "-1" last-element delete and ":" forced-object-key syntax
-// are not supported here. Behavior otherwise matches sjson.DeleteBytes.
+// are not supported here. Behavior otherwise matches ojson.DeleteBytes.
func DeleteJSONBytesIfExists(data []byte, path string) ([]byte, error) {
if !GetGJSONBytesNoCopy(data, path).Exists() {
return data, nil
}
- return sjson.DeleteBytes(data, path)
+ return ojson.DeleteBytes(data, path)
+}
+
+// DeleteJSONDocIfExists deletes path from doc only when the path exists.
+func DeleteJSONDocIfExists(doc *ojson.Doc, path string) error {
+ if doc == nil {
+ return nil
+ }
+ if !doc.Get(path).Exists() {
+ return nil
+ }
+ return doc.Delete(path)
}
diff --git a/internal/util/sjson_delete_test.go b/internal/util/sjson_delete_test.go
index 3a6a5e26ea2..6585d6918a9 100644
--- a/internal/util/sjson_delete_test.go
+++ b/internal/util/sjson_delete_test.go
@@ -5,8 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestDeleteJSONBytesIfExistsMatchesSJSON(t *testing.T) {
@@ -20,7 +19,7 @@ func TestDeleteJSONBytesIfExistsMatchesSJSON(t *testing.T) {
"missing",
`a\.b`,
} {
- want, errWant := sjson.DeleteBytes(bytes.Clone(doc), path)
+ want, errWant := ojson.DeleteBytes(bytes.Clone(doc), path)
got, errGot := DeleteJSONBytesIfExists(bytes.Clone(doc), path)
if (errWant != nil) != (errGot != nil) || !bytes.Equal(got, want) {
t.Fatalf("path %q: got %s (%v), want %s (%v)", path, got, errGot, want, errWant)
@@ -50,10 +49,10 @@ func TestDeleteJSONBytesIfExistsMissingPathReturnsInput(t *testing.T) {
func TestDeleteJSONBytesIfExistsPresentLargeValueAllocation(t *testing.T) {
doc := []byte(`{"request":{"contents":"hi","generationConfig":{"thinkingConfig":{"includeThoughts":"` + strings.Repeat("x", 1<<20) + `"}}}}`)
const path = "request.generationConfig.thinkingConfig.includeThoughts"
- if !gjson.ValidBytes(doc) {
+ if !ojson.ValidBytes(doc) {
t.Fatal("fixture is not valid JSON")
}
- want, errWant := sjson.DeleteBytes(bytes.Clone(doc), path)
+ want, errWant := ojson.DeleteBytes(bytes.Clone(doc), path)
got, errGot := DeleteJSONBytesIfExists(bytes.Clone(doc), path)
if errWant != nil || errGot != nil || !bytes.Equal(got, want) {
t.Fatalf("delete result differs from sjson: got %q (%v), want %q (%v)", got, errGot, want, errWant)
@@ -62,7 +61,7 @@ func TestDeleteJSONBytesIfExistsPresentLargeValueAllocation(t *testing.T) {
sjsonAllocs := testing.Benchmark(func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
- _, _ = sjson.DeleteBytes(doc, path)
+ _, _ = ojson.DeleteBytes(doc, path)
}
}).AllocedBytesPerOp()
@@ -74,8 +73,32 @@ func TestDeleteJSONBytesIfExistsPresentLargeValueAllocation(t *testing.T) {
}).AllocedBytesPerOp()
// GetGJSONBytesNoCopy ensures that checking existence of a 1 MiB value
- // does not copy its Raw string (which gjson.GetBytes would).
+ // does not copy its Raw string (which ojson.GetBytes would).
if helperAllocs > sjsonAllocs+1024 {
t.Fatalf("present large value: helper allocated %d B/op, sjson allocated %d B/op; want difference <= 1024", helperAllocs, sjsonAllocs)
}
}
+
+func TestDeleteJSONDocIfExists(t *testing.T) {
+ if errNil := DeleteJSONDocIfExists(nil, "extra"); errNil != nil {
+ t.Fatalf("DeleteJSONDocIfExists(nil) error: %v", errNil)
+ }
+ emptyDoc := ojson.NewDoc(nil)
+ if errSet := emptyDoc.Set("a", 1); errSet != nil {
+ t.Fatalf("emptyDoc.Set error: %v", errSet)
+ }
+ if errMissing := DeleteJSONDocIfExists(emptyDoc, "missing"); errMissing != nil {
+ t.Fatalf("DeleteJSONDocIfExists(missing) error: %v", errMissing)
+ }
+ doc := ojson.NewDoc([]byte(`{"keep":"yes","extra":"remove_me"}`))
+ if errDel := DeleteJSONDocIfExists(doc, "extra"); errDel != nil {
+ t.Fatalf("DeleteJSONDocIfExists(extra) error: %v", errDel)
+ }
+ out := doc.Bytes()
+ if ojson.GetBytesNoCopy(out, "extra").Exists() {
+ t.Fatalf("expected extra to be deleted in %s", string(out))
+ }
+ if got := ojson.GetBytesNoCopy(out, "keep").String(); got != "yes" {
+ t.Fatalf("keep = %q, want yes", got)
+ }
+}
diff --git a/internal/util/translator.go b/internal/util/translator.go
index 75b4a5995a5..d449e50f059 100644
--- a/internal/util/translator.go
+++ b/internal/util/translator.go
@@ -12,23 +12,22 @@ import (
"strconv"
"strings"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Walk recursively traverses a JSON structure to find all occurrences of a specific field.
// It builds paths to each occurrence and adds them to the provided paths slice.
//
// Parameters:
-// - value: The gjson.Result object to traverse
+// - value: The ojson.Result object to traverse
// - path: The current path in the JSON structure (empty string for root)
// - field: The field name to search for
// - paths: Pointer to a slice where found paths will be stored
//
// The function works recursively, building dot-notation paths to each occurrence
// of the specified field throughout the JSON structure.
-func Walk(value gjson.Result, path, field string, paths *[]string) {
+func Walk(value ojson.Result, path, field string, paths *[]string) {
walk(value, path, field, paths, false)
}
@@ -37,15 +36,15 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
// whose contents are client data rather than subschemas. The literal keyword itself is still
// recorded when it is the searched field, and under a property-name map a key spelled like one is
// a property name whose subschema is still walked.
-func WalkSchema(value gjson.Result, path, field string, paths *[]string) {
+func WalkSchema(value ojson.Result, path, field string, paths *[]string) {
walk(value, path, field, paths, true)
}
-func walk(value gjson.Result, path, field string, paths *[]string, schemaAware bool) {
+func walk(value ojson.Result, path, field string, paths *[]string, schemaAware bool) {
switch value.Type {
- case gjson.JSON:
+ case ojson.JSON:
// For JSON objects and arrays, iterate through each child
- value.ForEach(func(key, val gjson.Result) bool {
+ value.ForEach(func(key, val ojson.Result) bool {
var childPath string
// Escape special characters for gjson/sjson path syntax
// . -> \.
@@ -68,7 +67,7 @@ func walk(value gjson.Result, path, field string, paths *[]string, schemaAware b
walk(val, childPath, field, paths, schemaAware)
return true
})
- case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
+ case ojson.String, ojson.Number, ojson.True, ojson.False, ojson.Null:
// Terminal types - no further traversal needed
}
}
@@ -89,23 +88,22 @@ func walk(value gjson.Result, path, field string, paths *[]string, schemaAware b
// 1. Sets the value at the new key path
// 2. Deletes the old key path
func RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) {
- value := gjson.Get(jsonStr, oldKeyPath)
+ value := ojson.Get(jsonStr, oldKeyPath)
if !value.Exists() {
return "", fmt.Errorf("old key '%s' does not exist", oldKeyPath)
}
- interimJSON, errSet := sjson.SetRawBytes([]byte(jsonStr), newKeyPath, []byte(value.Raw))
- if errSet != nil {
+ doc := ojson.NewDocString(jsonStr)
+ if errSet := doc.SetRaw(newKeyPath, value.Raw); errSet != nil {
return "", fmt.Errorf("failed to set new key '%s': %w", newKeyPath, errSet)
}
- finalJSON, errDelete := sjson.DeleteBytes(interimJSON, oldKeyPath)
- if errDelete != nil {
+ if errDelete := doc.Delete(oldKeyPath); errDelete != nil {
return "", fmt.Errorf("failed to delete old key '%s': %w", oldKeyPath, errDelete)
}
- return string(finalJSON), nil
+ return doc.String(), nil
}
// FixJSON converts non-standard JSON that uses single quotes for strings into
@@ -269,15 +267,15 @@ func claudePayloadMayContainToolAddition(rawJSON []byte) bool {
bytes.Contains(rawJSON, []byte(`\U`))
}
-func forEachClaudeInlineToolAdditionDefinition(rawJSON []byte, fn func(def gjson.Result)) {
+func forEachClaudeInlineToolAdditionDefinition(rawJSON []byte, fn func(def ojson.Result)) {
if !claudePayloadMayContainToolAddition(rawJSON) {
return
}
- messages := gjson.GetBytes(rawJSON, "messages")
+ messages := ojson.GetBytes(rawJSON, "messages")
if !messages.IsArray() {
return
}
- messages.ForEach(func(_, message gjson.Result) bool {
+ messages.ForEach(func(_, message ojson.Result) bool {
if message.Get("role").String() != "system" {
return true
}
@@ -285,7 +283,7 @@ func forEachClaudeInlineToolAdditionDefinition(rawJSON []byte, fn func(def gjson
if !content.IsArray() {
return true
}
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
if part.Get("type").String() != "tool_addition" {
return true
}
@@ -302,11 +300,11 @@ func forEachClaudeInlineToolAdditionDefinition(rawJSON []byte, fn func(def gjson
}
func toolNameMapFromClaudeRequest(rawJSON []byte, rejectAmbiguous bool) map[string]string {
- if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
+ if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) {
return nil
}
- tools := gjson.GetBytes(rawJSON, "tools")
+ tools := ojson.GetBytes(rawJSON, "tools")
hasTools := tools.Exists() && tools.IsArray()
hasToolAddition := claudePayloadMayContainToolAddition(rawJSON)
if !hasTools && !hasToolAddition {
@@ -314,15 +312,15 @@ func toolNameMapFromClaudeRequest(rawJSON []byte, rejectAmbiguous bool) map[stri
}
var out map[string]string
- recordTool := func(tool gjson.Result) {
- name := strings.TrimSpace(tool.Get("name").String())
+ recordTool := func(tool ojson.Result) {
+ name := strings.Clone(strings.TrimSpace(tool.Get("name").String()))
if name == "" {
- name = strings.TrimSpace(tool.Get("function.name").String())
+ name = strings.Clone(strings.TrimSpace(tool.Get("function.name").String()))
}
if name == "" {
return
}
- key := CanonicalToolName(name)
+ key := strings.Clone(CanonicalToolName(name))
if key == "" {
return
}
@@ -339,7 +337,7 @@ func toolNameMapFromClaudeRequest(rawJSON []byte, rejectAmbiguous bool) map[stri
}
if hasTools {
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
recordTool(tool)
return true
})
@@ -374,15 +372,15 @@ func MapClaudeToolName(toolNameMap map[string]string, originalRequestRawJSON []b
if name == "" {
return name
}
- if len(originalRequestRawJSON) > 0 && gjson.ValidBytes(originalRequestRawJSON) {
- tools := gjson.GetBytes(originalRequestRawJSON, "tools")
+ if len(originalRequestRawJSON) > 0 && ojson.ValidBytes(originalRequestRawJSON) {
+ tools := ojson.GetBytes(originalRequestRawJSON, "tools")
hasTools := tools.IsArray()
hasToolAddition := claudePayloadMayContainToolAddition(originalRequestRawJSON)
if hasTools || hasToolAddition {
canonical := CanonicalToolName(name)
canonicalNames := map[string]bool{}
exactMatch := false
- inspectTool := func(tool gjson.Result) {
+ inspectTool := func(tool ojson.Result) {
declared := strings.TrimSpace(tool.Get("name").String())
if declared == "" {
declared = strings.TrimSpace(tool.Get("function.name").String())
@@ -495,21 +493,21 @@ func DisambiguatedToolNameMap(rawJSON []byte) map[string]string {
// top-level Claude-style tools. Collision-aware translators should use
// DisambiguatedToolNameMap instead.
func SanitizedToolNameMap(rawJSON []byte) map[string]string {
- if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
+ if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) {
return nil
}
- tools := gjson.GetBytes(rawJSON, "tools")
+ tools := ojson.GetBytes(rawJSON, "tools")
if !tools.IsArray() {
return nil
}
out := make(map[string]string)
- tools.ForEach(func(_, tool gjson.Result) bool {
- name := strings.TrimSpace(tool.Get("name").String())
+ tools.ForEach(func(_, tool ojson.Result) bool {
+ name := strings.Clone(strings.TrimSpace(tool.Get("name").String()))
if name == "" {
return true
}
- sanitized := SanitizeFunctionName(name)
+ sanitized := strings.Clone(SanitizeFunctionName(name))
if sanitized == name {
return true
}
@@ -527,30 +525,30 @@ func SanitizedToolNameMap(rawJSON []byte) map[string]string {
}
func functionNamesFromRequest(rawJSON []byte) []string {
- if len(rawJSON) == 0 || !gjson.ValidBytes(rawJSON) {
+ if len(rawJSON) == 0 || !ojson.ValidBytes(rawJSON) {
return nil
}
- tools := gjson.GetBytes(rawJSON, "tools")
+ tools := ojson.GetBytes(rawJSON, "tools")
if !tools.IsArray() {
return nil
}
names := make([]string, 0, len(tools.Array()))
- var collectTool func(gjson.Result)
- collectDeclarations := func(declarations gjson.Result) {
+ var collectTool func(ojson.Result)
+ collectDeclarations := func(declarations ojson.Result) {
if !declarations.IsArray() {
return
}
- declarations.ForEach(func(_, declaration gjson.Result) bool {
+ declarations.ForEach(func(_, declaration ojson.Result) bool {
if name := declaration.Get("name").String(); name != "" {
- names = append(names, name)
+ names = append(names, strings.Clone(name))
}
return true
})
}
- collectTool = func(tool gjson.Result) {
+ collectTool = func(tool ojson.Result) {
if nestedTools := tool.Get("tools"); nestedTools.IsArray() {
- nestedTools.ForEach(func(_, nestedTool gjson.Result) bool {
+ nestedTools.ForEach(func(_, nestedTool ojson.Result) bool {
collectTool(nestedTool)
return true
})
@@ -569,14 +567,14 @@ func functionNamesFromRequest(rawJSON []byte) []string {
return
}
if name := tool.Get("function.name").String(); name != "" {
- names = append(names, name)
+ names = append(names, strings.Clone(name))
return
}
if name := tool.Get("name").String(); name != "" {
- names = append(names, name)
+ names = append(names, strings.Clone(name))
}
}
- tools.ForEach(func(_, tool gjson.Result) bool {
+ tools.ForEach(func(_, tool ojson.Result) bool {
collectTool(tool)
return true
})
@@ -600,7 +598,7 @@ func disambiguateSanitizedFunctionName(base, original string, used map[string]st
// DeduplicateFunctionDeclarations removes duplicate named declarations while preserving order.
func DeduplicateFunctionDeclarations(raw []byte) []byte {
- result := gjson.ParseBytes(raw)
+ result := ParseGJSONBytesNoCopy(raw)
if !result.IsArray() {
return raw
}
@@ -634,7 +632,7 @@ func RestoreSanitizedToolName(toolNameMap map[string]string, sanitizedName strin
}
type claudeObjectSchemaInfo struct {
- properties map[string][]gjson.Result
+ properties map[string][]ojson.Result
required map[string]bool
hasProperties bool
allowsDynamic bool
@@ -646,7 +644,7 @@ type claudeObjectSchemaInfo struct {
branchInfos []claudeObjectSchemaInfo
}
-func claudeSchemaAllowsDynamic(schema gjson.Result) bool {
+func claudeSchemaAllowsDynamic(schema ojson.Result) bool {
// patternProperties permit matching keys even when additionalProperties is false
// (the latter only restricts keys matching no pattern), so pattern permission is
// never cancelled by an additionalProperties: false sibling.
@@ -654,24 +652,24 @@ func claudeSchemaAllowsDynamic(schema gjson.Result) bool {
return true
}
additionalProperties := schema.Get("additionalProperties")
- if additionalProperties.Exists() && additionalProperties.Type != gjson.False {
+ if additionalProperties.Exists() && additionalProperties.Type != ojson.False {
return true
}
return false
}
-func claudeSchemaHasPatternProperties(schema gjson.Result) bool {
+func claudeSchemaHasPatternProperties(schema ojson.Result) bool {
return schema.Get("patternProperties").IsObject() && len(schema.Get("patternProperties").Map()) > 0
}
-func claudeSchemaForbidsDynamic(schema gjson.Result) bool {
+func claudeSchemaForbidsDynamic(schema ojson.Result) bool {
additionalProperties := schema.Get("additionalProperties")
- return additionalProperties.Exists() && additionalProperties.Type == gjson.False
+ return additionalProperties.Exists() && additionalProperties.Type == ojson.False
}
-func claudeResolveLocalRef(root gjson.Result, ref string) (gjson.Result, bool) {
+func claudeResolveLocalRef(root ojson.Result, ref string) (ojson.Result, bool) {
if !strings.HasPrefix(ref, "#/") {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
current := root
for _, segment := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
@@ -681,11 +679,11 @@ func claudeResolveLocalRef(root gjson.Result, ref string) (gjson.Result, bool) {
if current.IsArray() {
index, errAtoi := strconv.Atoi(segment)
if errAtoi != nil || index < 0 || index >= len(current.Array()) {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
next = current.Array()[index]
} else {
- return gjson.Result{}, false
+ return ojson.Result{}, false
}
}
current = next
@@ -693,11 +691,11 @@ func claudeResolveLocalRef(root gjson.Result, ref string) (gjson.Result, bool) {
return current, true
}
-func analyzeClaudeObjectSchema(schema gjson.Result, root gjson.Result) claudeObjectSchemaInfo {
+func analyzeClaudeObjectSchema(schema ojson.Result, root ojson.Result) claudeObjectSchemaInfo {
return analyzeClaudeObjectSchemaDepth(schema, root, 0, map[string]bool{})
}
-func claudeSchemaDeclaresObject(schema gjson.Result) bool {
+func claudeSchemaDeclaresObject(schema ojson.Result) bool {
typeValue := schema.Get("type")
if typeValue.String() == "object" {
return true
@@ -712,8 +710,8 @@ func claudeSchemaDeclaresObject(schema gjson.Result) bool {
return false
}
-func claudeUnionBranches(schema gjson.Result) ([]gjson.Result, bool) {
- var branches []gjson.Result
+func claudeUnionBranches(schema ojson.Result) ([]ojson.Result, bool) {
+ var branches []ojson.Result
seen := map[string]bool{}
for _, keyword := range []string{"anyOf", "oneOf"} {
union := schema.Get(keyword)
@@ -755,7 +753,7 @@ var claudeAnalyzedSchemaKeywords = map[string]bool{
"example": true, "contentEncoding": true, "contentMediaType": true,
}
-func claudeSchemaHasUnanalyzedKeyword(schema gjson.Result) bool {
+func claudeSchemaHasUnanalyzedKeyword(schema ojson.Result) bool {
for key := range schema.Map() {
if strings.HasPrefix(key, "x-") {
continue
@@ -767,8 +765,8 @@ func claudeSchemaHasUnanalyzedKeyword(schema gjson.Result) bool {
return false
}
-func analyzeClaudeObjectSchemaDepth(schema gjson.Result, root gjson.Result, depth int, visitedRefs map[string]bool) claudeObjectSchemaInfo {
- info := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}, required: map[string]bool{}}
+func analyzeClaudeObjectSchemaDepth(schema ojson.Result, root ojson.Result, depth int, visitedRefs map[string]bool) claudeObjectSchemaInfo {
+ info := claudeObjectSchemaInfo{properties: map[string][]ojson.Result{}, required: map[string]bool{}}
if depth > 16 || !schema.Exists() || !schema.IsObject() {
info.complex = true
return info
@@ -902,7 +900,7 @@ func analyzeClaudeObjectSchemaDepth(schema gjson.Result, root gjson.Result, dept
return info
}
-func appendUniqueSchema(schemas []gjson.Result, value gjson.Result) []gjson.Result {
+func appendUniqueSchema(schemas []ojson.Result, value ojson.Result) []ojson.Result {
for _, existing := range schemas {
if existing.Raw == value.Raw {
return schemas
@@ -913,7 +911,7 @@ func appendUniqueSchema(schemas []gjson.Result, value gjson.Result) []gjson.Resu
func mergeClaudeObjectSchemaInfo(dst *claudeObjectSchemaInfo, src claudeObjectSchemaInfo) {
if dst.properties == nil {
- dst.properties = map[string][]gjson.Result{}
+ dst.properties = map[string][]ojson.Result{}
}
if dst.required == nil {
dst.required = map[string]bool{}
@@ -938,18 +936,18 @@ func mergeClaudeObjectSchemaInfo(dst *claudeObjectSchemaInfo, src claudeObjectSc
dst.declaresObject = dst.declaresObject || src.declaresObject
}
-func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (gjson.Result, gjson.Result, bool) {
- if toolName == "" || len(originalRequestRawJSON) == 0 || !gjson.ValidBytes(originalRequestRawJSON) {
- return gjson.Result{}, gjson.Result{}, false
+func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (ojson.Result, ojson.Result, bool) {
+ if toolName == "" || len(originalRequestRawJSON) == 0 || !ojson.ValidBytes(originalRequestRawJSON) {
+ return ojson.Result{}, ojson.Result{}, false
}
- root := gjson.ParseBytes(originalRequestRawJSON)
+ root := ojson.ParseBytes(originalRequestRawJSON)
tools := root.Get("tools")
if !tools.IsArray() {
- return gjson.Result{}, root, false
+ return ojson.Result{}, root, false
}
type candidate struct {
name string
- schema gjson.Result
+ schema ojson.Result
found bool
}
var exact []candidate
@@ -993,7 +991,7 @@ func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (gjson
if identical {
return exact[0].schema, root, true
}
- return gjson.Result{}, root, false
+ return ojson.Result{}, root, false
}
if len(canonicalMatches) == 1 {
for _, matches := range canonicalMatches {
@@ -1013,7 +1011,7 @@ func findClaudeToolSchema(originalRequestRawJSON []byte, toolName string) (gjson
}
}
}
- return gjson.Result{}, root, false
+ return ojson.Result{}, root, false
}
// ClaudeToolIsParameterless reports whether the tool was declared in the original
@@ -1036,13 +1034,13 @@ func ClaudeToolIsParameterless(toolName string, originalRequestRawJSON []byte) b
return len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0)
}
-func resolveClaudeArrayItemsSchema(schema gjson.Result, root gjson.Result, depth int, visitedRefs map[string]bool) ([]gjson.Result, bool) {
+func resolveClaudeArrayItemsSchema(schema ojson.Result, root ojson.Result, depth int, visitedRefs map[string]bool) ([]ojson.Result, bool) {
if depth > 16 || !schema.Exists() || !schema.IsObject() {
return nil, false
}
- var items []gjson.Result
+ var items []ojson.Result
deterministic := true
- collect := func(candidate gjson.Result) {
+ collect := func(candidate ojson.Result) {
items = appendUniqueSchema(items, candidate)
}
if ref := schema.Get("$ref"); ref.Exists() {
@@ -1106,11 +1104,11 @@ func claudeDeclaredPropertyPlaceholders(info claudeObjectSchemaInfo) (bool, bool
return reasonDeclared, underscoreDeclared
}
-func claudeArraySchemaUnsafe(schema gjson.Result, root gjson.Result) bool {
+func claudeArraySchemaUnsafe(schema ojson.Result, root ojson.Result) bool {
return analyzeClaudeObjectSchema(schema, root).unsafeComplex
}
-func cleanClaudeToolArray(argsJSON string, schemas []gjson.Result, root gjson.Result, path string) string {
+func cleanClaudeToolArray(argsJSON string, schemas []ojson.Result, root ojson.Result, path string) string {
for _, schema := range schemas {
if claudeArraySchemaUnsafe(schema, root) {
// Value-constraining keywords on the array itself (e.g. enum of whole
@@ -1118,7 +1116,7 @@ func cleanClaudeToolArray(argsJSON string, schemas []gjson.Result, root gjson.Re
return argsJSON
}
}
- var itemSchemas []gjson.Result
+ var itemSchemas []ojson.Result
deterministic := true
for _, schema := range schemas {
resolvedItems, okItems := resolveClaudeArrayItemsSchema(schema, root, 0, map[string]bool{})
@@ -1141,10 +1139,10 @@ func cleanClaudeToolArray(argsJSON string, schemas []gjson.Result, root gjson.Re
return argsJSON
}
cleaned := argsJSON
- elementCount := len(gjson.Get(cleaned, path).Array())
+ elementCount := len(ojson.Get(cleaned, path).Array())
for index := 0; index < elementCount; index++ {
elementPath := joinPath(path, strconv.Itoa(index))
- element := gjson.Get(cleaned, elementPath)
+ element := ojson.Get(cleaned, elementPath)
if element.IsObject() {
cleaned = cleanClaudeToolObject(cleaned, itemSchemas, root, elementPath, false)
} else if element.IsArray() {
@@ -1154,15 +1152,15 @@ func cleanClaudeToolArray(argsJSON string, schemas []gjson.Result, root gjson.Re
return cleaned
}
-func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.Result, path string, topLevel bool) string {
- argsResult := gjson.Get(argsJSON, path)
+func cleanClaudeToolObject(argsJSON string, schemas []ojson.Result, root ojson.Result, path string, topLevel bool) string {
+ argsResult := ojson.Get(argsJSON, path)
if path == "" {
- argsResult = gjson.Parse(argsJSON)
+ argsResult = ojson.Parse(argsJSON)
}
if !argsResult.IsObject() || len(schemas) == 0 {
return argsJSON
}
- merged := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}}
+ merged := claudeObjectSchemaInfo{properties: map[string][]ojson.Result{}}
for _, schema := range schemas {
mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(schema, root))
}
@@ -1184,7 +1182,7 @@ func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.R
if topLevel {
return "{}"
}
- updated, _ := sjson.SetRaw(argsJSON, path, "{}")
+ updated, _ := ojson.SetRaw(argsJSON, path, "{}")
return updated
}
keys := make([]string, 0, len(merged.properties))
@@ -1196,13 +1194,13 @@ func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.R
for _, key := range keys {
propertySchemas := merged.properties[key]
childPath := joinPath(path, escapeGJSONPathKey(key))
- child := gjson.Get(cleaned, childPath)
+ child := ojson.Get(cleaned, childPath)
if !child.Exists() {
continue
}
- currentForBranch := gjson.Parse(cleaned)
+ currentForBranch := ojson.Parse(cleaned)
if path != "" {
- currentForBranch = gjson.Get(cleaned, path)
+ currentForBranch = ojson.Get(cleaned, path)
}
if claudeParentBranchAcceptsChild(merged, key, currentForBranch) || claudeOriginalsMayPermitChild(schemas, root, key, currentForBranch) {
continue
@@ -1216,14 +1214,16 @@ func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.R
if claudeInfoAnyBranchAllowsDynamic(merged) {
return cleaned
}
- current := gjson.Parse(cleaned)
+ current := ojson.Parse(cleaned)
if path != "" {
- current = gjson.Get(cleaned, path)
+ current = ojson.Get(cleaned, path)
}
if !current.IsObject() {
return cleaned
}
reasonDeclared, underscoreDeclared := claudeDeclaredPropertyPlaceholders(merged)
+ doc := ojson.NewDocString(cleaned)
+ docModified := false
for _, placeholder := range []string{"reason", "_"} {
if placeholder == "reason" && reasonDeclared {
continue
@@ -1245,12 +1245,17 @@ func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.R
}
if _, ok := current.Map()[placeholder]; ok {
placeholderPath := joinPath(path, placeholder)
- cleaned, _ = sjson.Delete(cleaned, placeholderPath)
+ if doc.Delete(placeholderPath) == nil {
+ docModified = true
+ }
}
}
- parsedCleaned := gjson.Parse(cleaned)
+ if docModified {
+ cleaned = doc.String()
+ }
+ parsedCleaned := ojson.Parse(cleaned)
if path != "" {
- parsedCleaned = gjson.Get(cleaned, path)
+ parsedCleaned = ojson.Get(cleaned, path)
}
if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" {
return "{}"
@@ -1258,17 +1263,17 @@ func cleanClaudeToolObject(argsJSON string, schemas []gjson.Result, root gjson.R
return cleaned
}
-func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName string, originalRequestRawJSON []byte) []gjson.Result {
- if toolName == "" || len(translatedRequestRawJSON) == 0 || !gjson.ValidBytes(translatedRequestRawJSON) {
+func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName string, originalRequestRawJSON []byte) []ojson.Result {
+ if toolName == "" || len(translatedRequestRawJSON) == 0 || !ojson.ValidBytes(translatedRequestRawJSON) {
return nil
}
- root := gjson.ParseBytes(translatedRequestRawJSON)
+ root := ojson.ParseBytes(translatedRequestRawJSON)
type namedSchema struct {
name string
- schema gjson.Result
+ schema ojson.Result
}
var named []namedSchema
- collectNamed := func(tools gjson.Result) {
+ collectNamed := func(tools ojson.Result) {
if !tools.IsArray() {
return
}
@@ -1306,8 +1311,8 @@ func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName s
if forward := SanitizedFunctionNameMap(originalRequestRawJSON); forward != nil {
sentName = forward[toolName]
}
- var exact []gjson.Result
- canonicalByName := map[string][]gjson.Result{}
+ var exact []ojson.Result
+ canonicalByName := map[string][]ojson.Result{}
for _, entry := range named {
if entry.name == toolName || (sentName != "" && entry.name == sentName) {
exact = append(exact, entry.schema)
@@ -1327,7 +1332,7 @@ func findTranslatedClaudeToolSchemas(translatedRequestRawJSON []byte, toolName s
return nil
}
-func claudeOriginalDeclaresPropertyDeep(schema gjson.Result, root gjson.Result, property string, depth int, visitedRefs map[string]bool) bool {
+func claudeOriginalDeclaresPropertyDeep(schema ojson.Result, root ojson.Result, property string, depth int, visitedRefs map[string]bool) bool {
if depth > 16 || !schema.Exists() || !schema.IsObject() {
return false
}
@@ -1377,7 +1382,7 @@ func claudeInfoAnyBranchAllowsDynamic(info claudeObjectSchemaInfo) bool {
return info.allowsDynamic && !info.forbidsDynamic
}
-func claudeBranchMayAccept(info claudeObjectSchemaInfo, key string, current gjson.Result) bool {
+func claudeBranchMayAccept(info claudeObjectSchemaInfo, key string, current ojson.Result) bool {
// patternProperties permit matching keys regardless of additionalProperties.
if info.hasPatternProperties {
return true
@@ -1416,7 +1421,7 @@ func claudeBranchMayAccept(info claudeObjectSchemaInfo, key string, current gjso
return false
}
-func claudeParentBranchAcceptsChild(originalInfo claudeObjectSchemaInfo, key string, current gjson.Result) bool {
+func claudeParentBranchAcceptsChild(originalInfo claudeObjectSchemaInfo, key string, current ojson.Result) bool {
if len(originalInfo.branchInfos) == 0 {
return false
}
@@ -1432,7 +1437,7 @@ func claudeParentBranchAcceptsChild(originalInfo claudeObjectSchemaInfo, key str
return false
}
-func claudeCandidateMayPermitChild(info claudeObjectSchemaInfo, key string, current gjson.Result) bool {
+func claudeCandidateMayPermitChild(info claudeObjectSchemaInfo, key string, current ojson.Result) bool {
if _, declaresKey := info.properties[key]; declaresKey {
return false
}
@@ -1450,7 +1455,7 @@ func claudeCandidateMayPermitChild(info claudeObjectSchemaInfo, key string, curr
return true
}
-func claudeOriginalsMayPermitChild(originals []gjson.Result, root gjson.Result, key string, current gjson.Result) bool {
+func claudeOriginalsMayPermitChild(originals []ojson.Result, root ojson.Result, key string, current ojson.Result) bool {
if len(originals) <= 1 {
return false
}
@@ -1466,7 +1471,7 @@ func claudeOriginalsMayPermitChild(originals []gjson.Result, root gjson.Result,
return false
}
-func claudeOriginalsAnyCandidateAcceptsImplicitly(originals []gjson.Result, root gjson.Result, current gjson.Result) bool {
+func claudeOriginalsAnyCandidateAcceptsImplicitly(originals []ojson.Result, root ojson.Result, current ojson.Result) bool {
if len(originals) <= 1 {
return false
}
@@ -1489,7 +1494,7 @@ func claudeOriginalsAnyCandidateAcceptsImplicitly(originals []gjson.Result, root
return false
}
-func claudeOriginalsAnyBranchAllowsDynamic(originals []gjson.Result, root gjson.Result) bool {
+func claudeOriginalsAnyBranchAllowsDynamic(originals []ojson.Result, root ojson.Result) bool {
for _, original := range originals {
if info := analyzeClaudeObjectSchema(original, root); claudeInfoAnyBranchAllowsDynamic(info) {
return true
@@ -1498,18 +1503,18 @@ func claudeOriginalsAnyBranchAllowsDynamic(originals []gjson.Result, root gjson.
return false
}
-func mergedClaudeOriginalInfo(originals []gjson.Result, root gjson.Result) claudeObjectSchemaInfo {
- merged := claudeObjectSchemaInfo{properties: map[string][]gjson.Result{}}
+func mergedClaudeOriginalInfo(originals []ojson.Result, root ojson.Result) claudeObjectSchemaInfo {
+ merged := claudeObjectSchemaInfo{properties: map[string][]ojson.Result{}}
for _, original := range originals {
mergeClaudeObjectSchemaInfo(&merged, analyzeClaudeObjectSchema(original, root))
}
return merged
}
-func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchemas []gjson.Result, originals []gjson.Result, root gjson.Result, path string, topLevel bool) string {
- current := gjson.Parse(argsJSON)
+func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchemas []ojson.Result, originals []ojson.Result, root ojson.Result, path string, topLevel bool) string {
+ current := ojson.Parse(argsJSON)
if path != "" {
- current = gjson.Get(argsJSON, path)
+ current = ojson.Get(argsJSON, path)
}
if !current.IsObject() || len(translatedSchemas) == 0 || len(originals) == 0 {
return argsJSON
@@ -1546,11 +1551,11 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
sort.Strings(keys)
for _, key := range keys {
childPath := joinPath(path, escapeGJSONPathKey(key))
- child := gjson.Get(cleaned, childPath)
+ child := ojson.Get(cleaned, childPath)
if !child.Exists() {
continue
}
- var translatedChildren []gjson.Result
+ var translatedChildren []ojson.Result
for _, translated := range translatedSchemas {
translatedInfo := analyzeClaudeObjectSchema(translated, translated)
for _, childSchema := range translatedInfo.properties[key] {
@@ -1561,9 +1566,9 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
continue
}
originalChildren := originalInfo.properties[key]
- currentForBranch := gjson.Parse(cleaned)
+ currentForBranch := ojson.Parse(cleaned)
if path != "" {
- currentForBranch = gjson.Get(cleaned, path)
+ currentForBranch = ojson.Get(cleaned, path)
}
if claudeParentBranchAcceptsChild(originalInfo, key, currentForBranch) || claudeOriginalsMayPermitChild(originals, root, key, currentForBranch) {
// Another alternative branch/candidate may accept this key without an
@@ -1579,13 +1584,14 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
}
}
}
- currentAfterChildren := gjson.Parse(cleaned)
+ currentAfterChildren := ojson.Parse(cleaned)
if path != "" {
- currentAfterChildren = gjson.Get(cleaned, path)
+ currentAfterChildren = ojson.Get(cleaned, path)
}
if !currentAfterChildren.IsObject() {
return cleaned
}
+ var placeholdersToDelete []string
for _, placeholder := range []string{"reason", "_"} {
if _, declared := originalInfo.properties[placeholder]; declared {
continue
@@ -1707,11 +1713,11 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
// Mirror the executor's cleanNestedSchema wrapping: the schema is nested
// under a wrapper key before cleaning, so its top-level object counts as
// nested and can receive the "_" placeholder.
- wrapped, errWrap := sjson.SetRaw("{}", "schema", translated.Raw)
+ wrapped, errWrap := ojson.SetRaw("{}", "schema", translated.Raw)
if errWrap != nil {
continue
}
- recleaned := gjson.Get(CleanJSONSchemaForAntigravity(wrapped), "schema")
+ recleaned := ojson.Get(CleanJSONSchemaForAntigravity(wrapped), "schema")
if !recleaned.Exists() {
continue
}
@@ -1727,7 +1733,14 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
}
}
if injected {
- cleaned, _ = sjson.Delete(cleaned, joinPath(path, placeholder))
+ placeholdersToDelete = append(placeholdersToDelete, joinPath(path, placeholder))
+ }
+ }
+ doc := ojson.NewDocString(cleaned)
+ docModified := false
+ for _, placeholderPath := range placeholdersToDelete {
+ if doc.Delete(placeholderPath) == nil {
+ docModified = true
}
}
// Closed parameterless objects: if the original declared no properties and
@@ -1742,20 +1755,29 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
break
}
}
- currentForNormalize := gjson.Parse(cleaned)
- if path != "" {
- currentForNormalize = gjson.Get(cleaned, path)
+ currentForNormalize := doc.Get(path)
+ if path == "" {
+ if docModified {
+ cleaned = doc.String()
+ docModified = false
+ }
+ currentForNormalize = ojson.Parse(cleaned)
}
if !originalInfo.complex && allCandidatesDeclareObject && originalInfo.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(originalInfo) && !claudeOriginalsAnyBranchAllowsDynamic(originals, root) && !claudeOriginalsAnyCandidateAcceptsImplicitly(originals, root, currentForNormalize) && originalInfo.declaresObject && len(originalInfo.required) == 0 && (!originalInfo.hasProperties || len(originalInfo.properties) == 0) {
if topLevel {
return "{}"
}
- updated, _ := sjson.SetRaw(cleaned, path, "{}")
- return updated
+ if doc.SetRaw(path, "{}") == nil {
+ return doc.String()
+ }
+ return cleaned
+ }
+ if docModified {
+ cleaned = doc.String()
}
- parsedCleaned := gjson.Parse(cleaned)
+ parsedCleaned := ojson.Parse(cleaned)
if path != "" {
- parsedCleaned = gjson.Get(cleaned, path)
+ parsedCleaned = ojson.Get(cleaned, path)
}
if parsedCleaned.IsObject() && len(parsedCleaned.Map()) == 0 && path == "" {
return "{}"
@@ -1764,14 +1786,14 @@ func cleanClaudeToolArgumentsByTranslatedSchema(argsJSON string, translatedSchem
return cleaned
}
-func cleanClaudeTranslatedArray(argsJSON string, translatedArraySchemas []gjson.Result, originalArraySchemas []gjson.Result, root gjson.Result, path string) string {
+func cleanClaudeTranslatedArray(argsJSON string, translatedArraySchemas []ojson.Result, originalArraySchemas []ojson.Result, root ojson.Result, path string) string {
for _, schema := range originalArraySchemas {
if claudeArraySchemaUnsafe(schema, root) {
return argsJSON
}
}
- var translatedItems []gjson.Result
- var originalItems []gjson.Result
+ var translatedItems []ojson.Result
+ var originalItems []ojson.Result
deterministic := true
for _, schema := range translatedArraySchemas {
items, okItems := resolveClaudeArrayItemsSchema(schema, schema, 0, map[string]bool{})
@@ -1799,10 +1821,10 @@ func cleanClaudeTranslatedArray(argsJSON string, translatedArraySchemas []gjson.
return argsJSON
}
cleaned := argsJSON
- elementCount := len(gjson.Get(cleaned, path).Array())
+ elementCount := len(ojson.Get(cleaned, path).Array())
for index := 0; index < elementCount; index++ {
elementPath := joinPath(path, strconv.Itoa(index))
- element := gjson.Get(cleaned, elementPath)
+ element := ojson.Get(cleaned, elementPath)
if element.IsObject() {
cleaned = cleanClaudeToolArgumentsByTranslatedSchema(cleaned, translatedItems, originalItems, root, elementPath, false)
} else if element.IsArray() {
@@ -1825,7 +1847,7 @@ func CleanClaudeToolArgumentsWithTranslated(rawArgs string, toolName string, ori
if rawArgs == "" || rawArgs == "{}" {
return "{}"
}
- if !gjson.Valid(rawArgs) || !gjson.Parse(rawArgs).IsObject() {
+ if !ojson.Valid(rawArgs) || !ojson.Parse(rawArgs).IsObject() {
return rawArgs
}
schema, _, found := findClaudeToolSchema(originalRequestRawJSON, toolName)
@@ -1835,7 +1857,7 @@ func CleanClaudeToolArgumentsWithTranslated(rawArgs string, toolName string, ori
if !schema.Exists() {
return "{}"
}
- return cleanClaudeToolArgumentsByTranslatedSchema(rawArgs, translatedSchemas, []gjson.Result{schema}, schema, "", true)
+ return cleanClaudeToolArgumentsByTranslatedSchema(rawArgs, translatedSchemas, []ojson.Result{schema}, schema, "", true)
}
return CleanClaudeToolArguments(rawArgs, toolName, originalRequestRawJSON)
}
@@ -1854,7 +1876,7 @@ func CleanClaudeToolArguments(rawArgs string, toolName string, originalRequestRa
if rawArgs == "" || rawArgs == "{}" {
return "{}"
}
- if !gjson.Valid(rawArgs) {
+ if !ojson.Valid(rawArgs) {
return rawArgs
}
schema, root, found := findClaudeToolSchema(originalRequestRawJSON, toolName)
@@ -1864,7 +1886,7 @@ func CleanClaudeToolArguments(rawArgs string, toolName string, originalRequestRa
if !schema.Exists() {
return "{}"
}
- argsResult := gjson.Parse(rawArgs)
+ argsResult := ojson.Parse(rawArgs)
if !argsResult.IsObject() {
return rawArgs
}
@@ -1876,5 +1898,5 @@ func CleanClaudeToolArguments(rawArgs string, toolName string, originalRequestRa
if len(info.required) == 0 && (!info.hasProperties || len(info.properties) == 0) && info.forbidsDynamic && !claudeInfoAnyBranchAllowsDynamic(info) {
return "{}"
}
- return cleanClaudeToolObject(rawArgs, []gjson.Result{schema}, schema, "", true)
+ return cleanClaudeToolObject(rawArgs, []ojson.Result{schema}, schema, "", true)
}
diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go
index 62b24c5be4b..5ae60d3aeb8 100644
--- a/internal/watcher/diff/config_diff.go
+++ b/internal/watcher/diff/config_diff.go
@@ -37,6 +37,12 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(oldCfg.Pprof.Addr) != strings.TrimSpace(newCfg.Pprof.Addr) {
changes = append(changes, fmt.Sprintf("pprof.addr: %s -> %s", strings.TrimSpace(oldCfg.Pprof.Addr), strings.TrimSpace(newCfg.Pprof.Addr)))
}
+ if oldCfg.KVStore.Disabled != newCfg.KVStore.Disabled {
+ changes = append(changes, fmt.Sprintf("kv-store.disabled: %t -> %t", oldCfg.KVStore.Disabled, newCfg.KVStore.Disabled))
+ }
+ if oldCfg.EffectiveKVStorePath() != newCfg.EffectiveKVStorePath() {
+ changes = append(changes, fmt.Sprintf("kv-store.path: %s -> %s", oldCfg.EffectiveKVStorePath(), newCfg.EffectiveKVStorePath()))
+ }
if oldCfg.LoggingToFile != newCfg.LoggingToFile {
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
}
@@ -219,6 +225,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("gemini[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("gemini[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("gemini[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
}
@@ -254,6 +261,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("interactions[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("interactions[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("interactions[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
}
@@ -291,6 +299,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("claude[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("claude[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("claude[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
}
@@ -348,6 +357,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("codex[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("codex[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("codex[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
}
@@ -392,6 +402,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("xai[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("xai[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("xai[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
}
@@ -435,6 +446,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("meta[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("meta[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("meta[%d].prefix: %s -> %s", i, o.Prefix, n.Prefix))
}
@@ -527,6 +539,7 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
changes = append(changes, fmt.Sprintf("vertex[%d].proxy-url: %s -> %s", i, formatProxyURL(o.ProxyURL), formatProxyURL(n.ProxyURL)))
}
+ changes = appendConnectionPoolChanges(changes, fmt.Sprintf("vertex[%d].connection-pool", i), o.ConnectionPool, n.ConnectionPool)
if strings.TrimSpace(o.Prefix) != strings.TrimSpace(n.Prefix) {
changes = append(changes, fmt.Sprintf("vertex[%d].prefix: %s -> %s", i, strings.TrimSpace(o.Prefix), strings.TrimSpace(n.Prefix)))
}
@@ -589,6 +602,39 @@ func appendPayloadFilterRuleChanges(changes []string, section string, oldRules,
return append(changes, fmt.Sprintf("payload.%s: updated (%d -> %d rules)", section, len(oldRules), len(newRules)))
}
+func appendConnectionPoolChanges(changes []string, prefix string, oldPool, newPool *config.ProxyConnectionPoolConfig) []string {
+ if oldPool == nil && newPool == nil {
+ return changes
+ }
+ var oldCfg, newCfg config.ProxyConnectionPoolConfig
+ if oldPool != nil {
+ oldCfg = *oldPool
+ }
+ if newPool != nil {
+ newCfg = *newPool
+ }
+ oldEnabled := formatConnectionPoolEnabled(oldPool)
+ newEnabled := formatConnectionPoolEnabled(newPool)
+ if oldEnabled != newEnabled {
+ changes = append(changes, fmt.Sprintf("%s.enabled: %s -> %s", prefix, oldEnabled, newEnabled))
+ }
+ if strings.TrimSpace(oldCfg.IdleConnTimeout) != strings.TrimSpace(newCfg.IdleConnTimeout) {
+ changes = append(changes, fmt.Sprintf("%s.idle-conn-timeout: %q -> %q", prefix, strings.TrimSpace(oldCfg.IdleConnTimeout), strings.TrimSpace(newCfg.IdleConnTimeout)))
+ }
+ changes = appendOptionalIntChange(changes, prefix+".max-idle-conns-per-host", oldCfg.MaxIdleConnsPerHost, newCfg.MaxIdleConnsPerHost)
+ return changes
+}
+
+func formatConnectionPoolEnabled(pool *config.ProxyConnectionPoolConfig) string {
+ if pool == nil {
+ return ""
+ }
+ if pool.Enabled == nil {
+ return "true"
+ }
+ return fmt.Sprintf("%t", *pool.Enabled)
+}
+
func appendOptionalIntChange(changes []string, field string, oldVal, newVal *int) []string {
if optionalIntEqual(oldVal, newVal) {
return changes
diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go
index 4ad39f2f306..963a704cc1c 100644
--- a/internal/watcher/diff/config_diff_test.go
+++ b/internal/watcher/diff/config_diff_test.go
@@ -774,3 +774,68 @@ func TestBuildConfigChangeDetails_ClaudeNativeToolSearch(t *testing.T) {
newCfg := &config.Config{ClaudeKey: []config.ClaudeKey{{APIKey: "key", BaseURL: "https://gateway.example.com", NativeToolSearch: true}}}
expectContains(t, BuildConfigChangeDetails(oldCfg, newCfg), "claude[0].native-tool-search: false -> true")
}
+
+func TestBuildConfigChangeDetails_APIKeyConnectionPool(t *testing.T) {
+ enabled := true
+ maxIdle := 4
+ oldCfg := &config.Config{
+ CodexKey: []config.CodexKey{{APIKey: "k", BaseURL: "https://ollama.com/v1"}},
+ OpenAICompatibility: []config.OpenAICompatibility{{
+ Name: "compat",
+ BaseURL: "https://compat.example.com",
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "k"}},
+ }},
+ }
+ newCfg := &config.Config{
+ CodexKey: []config.CodexKey{{
+ APIKey: "k",
+ BaseURL: "https://ollama.com/v1",
+ ConnectionPool: &config.ProxyConnectionPoolConfig{
+ Enabled: &enabled,
+ IdleConnTimeout: "300s",
+ MaxIdleConnsPerHost: &maxIdle,
+ },
+ }},
+ OpenAICompatibility: []config.OpenAICompatibility{{
+ Name: "compat",
+ BaseURL: "https://compat.example.com",
+ ConnectionPool: &config.ProxyConnectionPoolConfig{Enabled: &enabled},
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{{APIKey: "k"}},
+ }},
+ }
+ changes := BuildConfigChangeDetails(oldCfg, newCfg)
+ expectContains(t, changes, "codex[0].connection-pool.enabled: -> true")
+ expectContains(t, changes, `codex[0].connection-pool.idle-conn-timeout: "" -> "300s"`)
+ expectContains(t, changes, "codex[0].connection-pool.max-idle-conns-per-host: -> 4")
+ expectContains(t, changes, " provider updated: compat (connection-pool updated)")
+}
+
+func TestBuildConfigChangeDetails_KVStore(t *testing.T) {
+ oldCfg := &config.Config{
+ AuthDir: "/tmp/auth-a",
+ KVStore: config.KVStoreConfig{
+ Disabled: false,
+ Path: "",
+ },
+ }
+ newCfg := &config.Config{
+ AuthDir: "/tmp/auth-a",
+ KVStore: config.KVStoreConfig{
+ Disabled: true,
+ Path: " ./data/kv.db ",
+ },
+ }
+ changes := BuildConfigChangeDetails(oldCfg, newCfg)
+ expectContains(t, changes, "kv-store.disabled: false -> true")
+ expectContains(t, changes, "kv-store.path: "+oldCfg.EffectiveKVStorePath()+" -> ./data/kv.db")
+ samePathTrimmed := &config.Config{
+ AuthDir: "/tmp/auth-a",
+ KVStore: config.KVStoreConfig{
+ Disabled: true,
+ Path: "./data/kv.db",
+ },
+ }
+ if unchanged := BuildConfigChangeDetails(newCfg, samePathTrimmed); len(unchanged) != 0 {
+ t.Fatalf("whitespace-only path difference produced changes: %v", unchanged)
+ }
+}
diff --git a/internal/watcher/diff/openai_compat.go b/internal/watcher/diff/openai_compat.go
index 09b307b3cab..c9d839a850f 100644
--- a/internal/watcher/diff/openai_compat.go
+++ b/internal/watcher/diff/openai_compat.go
@@ -98,12 +98,52 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi
if !equalStringMap(oldEntry.Headers, newEntry.Headers) {
details = append(details, "headers updated")
}
+ if openAICompatConnectionPoolChanged(oldEntry, newEntry) {
+ details = append(details, "connection-pool updated")
+ }
if len(details) == 0 {
return ""
}
return "(" + strings.Join(details, ", ") + ")"
}
+func openAICompatConnectionPoolChanged(oldEntry, newEntry config.OpenAICompatibility) bool {
+ if !connectionPoolConfigEqual(oldEntry.ConnectionPool, newEntry.ConnectionPool) {
+ return true
+ }
+ if len(oldEntry.APIKeyEntries) != len(newEntry.APIKeyEntries) {
+ for _, item := range oldEntry.APIKeyEntries {
+ if item.ConnectionPool != nil {
+ return true
+ }
+ }
+ for _, item := range newEntry.APIKeyEntries {
+ if item.ConnectionPool != nil {
+ return true
+ }
+ }
+ return false
+ }
+ for i := range oldEntry.APIKeyEntries {
+ if !connectionPoolConfigEqual(oldEntry.APIKeyEntries[i].ConnectionPool, newEntry.APIKeyEntries[i].ConnectionPool) {
+ return true
+ }
+ }
+ return false
+}
+
+func connectionPoolConfigEqual(a, b *config.ProxyConnectionPoolConfig) bool {
+ if a == nil && b == nil {
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+ return optionalBoolEqual(a.Enabled, b.Enabled) &&
+ strings.TrimSpace(a.IdleConnTimeout) == strings.TrimSpace(b.IdleConnTimeout) &&
+ optionalIntEqual(a.MaxIdleConnsPerHost, b.MaxIdleConnsPerHost)
+}
+
func countAPIKeys(entry config.OpenAICompatibility) int {
count := 0
for _, keyEntry := range entry.APIKeyEntries {
diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go
index ab0f2a77c83..6f829b65910 100644
--- a/internal/watcher/synthesizer/config.go
+++ b/internal/watcher/synthesizer/config.go
@@ -21,6 +21,59 @@ func NewConfigSynthesizer() *ConfigSynthesizer {
return &ConfigSynthesizer{}
}
+// authProxyConnectionPool converts a per-credential pool override from config into
+// the SDK representation carried on the auth record. Values are copied so the auth
+// snapshot never aliases a live config field.
+func authProxyConnectionPool(pool *config.ProxyConnectionPoolConfig) *coreauth.ProxyConnectionPoolOverride {
+ if pool == nil {
+ return nil
+ }
+ out := &coreauth.ProxyConnectionPoolOverride{IdleConnTimeout: pool.IdleConnTimeout}
+ if pool.Enabled != nil {
+ enabled := *pool.Enabled
+ out.Enabled = &enabled
+ }
+ if pool.MaxIdleConnsPerHost != nil {
+ maxIdle := *pool.MaxIdleConnsPerHost
+ out.MaxIdleConnsPerHost = &maxIdle
+ }
+ // A block without fields is still the opt-in: it asks for the pool defaults. It
+ // must therefore stay non-nil, or an operator writing `connection-pool: {}` (or
+ // inheriting an empty group block) would silently get no pooling at all.
+ return out
+}
+
+// openAICompatConnectionPool resolves the pool override for one OpenAI-compatible
+// key: the per-key block wins field by field, so a key that overrides only one value
+// keeps inheriting the provider-group default for the rest.
+// OpenAICompatConnectionPool resolves the effective connection-pool config for an OpenAI-compatible entry.
+func OpenAICompatConnectionPool(compat *config.OpenAICompatibility, entry *config.OpenAICompatibilityAPIKey) *config.ProxyConnectionPoolConfig {
+ return openAICompatConnectionPool(compat, entry)
+}
+
+func openAICompatConnectionPool(compat *config.OpenAICompatibility, entry *config.OpenAICompatibilityAPIKey) *config.ProxyConnectionPoolConfig {
+ var group *config.ProxyConnectionPoolConfig
+ if compat != nil {
+ group = compat.ConnectionPool
+ }
+ if entry == nil || entry.ConnectionPool == nil {
+ return group
+ }
+ merged := *entry.ConnectionPool
+ if group != nil {
+ if merged.Enabled == nil {
+ merged.Enabled = group.Enabled
+ }
+ if strings.TrimSpace(merged.IdleConnTimeout) == "" {
+ merged.IdleConnTimeout = group.IdleConnTimeout
+ }
+ if merged.MaxIdleConnsPerHost == nil {
+ merged.MaxIdleConnsPerHost = group.MaxIdleConnsPerHost
+ }
+ }
+ return &merged
+}
+
func addWeightToAttrs(weight *int, attrs map[string]string) {
if weight == nil {
return
@@ -86,7 +139,11 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, en
}
prefix := strings.TrimSpace(entry.Prefix)
proxyURL := strings.TrimSpace(entry.ProxyURL)
- id, token := idGen.Next(idKind, key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, token := idGen.Next(idKind, idParts...)
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", sourceName, token),
"config_index": strconv.Itoa(i),
@@ -122,6 +179,8 @@ func (s *ConfigSynthesizer) synthesizeGeminiKeyEntries(ctx *SynthesisContext, en
Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
+
+ ProxyConnectionPool: authProxyConnectionPool(entry.ConnectionPool),
}
ApplyAuthExcludedModelsMeta(a, entry.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
@@ -148,7 +207,11 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
}
prefix := strings.TrimSpace(ck.Prefix)
proxyURL := strings.TrimSpace(ck.ProxyURL)
- id, token := idGen.Next("claude:apikey", key, base, proxyURL, prefix, config.FormatSortedHeaders(ck.Headers))
+ idParts := []string{key, base, proxyURL, prefix, config.FormatSortedHeaders(ck.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(ck.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, token := idGen.Next("claude:apikey", idParts...)
attrs := map[string]string{
"source": fmt.Sprintf("config:claude[%s]", token),
"config_index": strconv.Itoa(i),
@@ -193,6 +256,8 @@ func (s *ConfigSynthesizer) synthesizeClaudeKeys(ctx *SynthesisContext) []*corea
Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
+
+ ProxyConnectionPool: authProxyConnectionPool(ck.ConnectionPool),
}
ApplyAuthExcludedModelsMeta(a, ck.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
@@ -232,7 +297,11 @@ func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entr
}
prefix := strings.TrimSpace(entry.Prefix)
proxyURL := strings.TrimSpace(entry.ProxyURL)
- id, token := idGen.Next(provider+":apikey", key, baseURL, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers))
+ idParts := []string{key, baseURL, proxyURL, prefix, config.FormatSortedHeaders(entry.Headers)}
+ if poolID := config.FormatConnectionPoolDedupID(entry.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, token := idGen.Next(provider+":apikey", idParts...)
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", provider, token),
"config_index": strconv.Itoa(i),
@@ -267,16 +336,17 @@ func (s *ConfigSynthesizer) synthesizeCodexStyleKeys(ctx *SynthesisContext, entr
}
addConfigHeadersToAttrs(entry.Headers, attrs)
a := &coreauth.Auth{
- ID: id,
- Provider: provider,
- Label: provider + "-apikey",
- Prefix: prefix,
- Status: coreauth.StatusActive,
- ProxyURL: strings.TrimSpace(entry.ProxyURL),
- Attributes: attrs,
- Metadata: metadata,
- CreatedAt: now,
- UpdatedAt: now,
+ ID: id,
+ Provider: provider,
+ Label: provider + "-apikey",
+ Prefix: prefix,
+ Status: coreauth.StatusActive,
+ ProxyURL: strings.TrimSpace(entry.ProxyURL),
+ Attributes: attrs,
+ Metadata: metadata,
+ CreatedAt: now,
+ UpdatedAt: now,
+ ProxyConnectionPool: authProxyConnectionPool(entry.ConnectionPool),
}
ApplyAuthExcludedModelsMeta(a, entry.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
@@ -314,8 +384,13 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
entry := &compat.APIKeyEntries[j]
key := strings.TrimSpace(entry.APIKey)
proxyURL := strings.TrimSpace(entry.ProxyURL)
+ effectivePool := openAICompatConnectionPool(compat, entry)
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
- id, token := idGen.Next(idKind, key, base, proxyURL)
+ idParts := []string{key, base, proxyURL}
+ if poolID := config.FormatConnectionPoolDedupID(effectivePool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, token := idGen.Next(idKind, idParts...)
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
"base_url": base,
@@ -341,16 +416,17 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
}
addConfigHeadersToAttrs(compat.Headers, attrs)
a := &coreauth.Auth{
- ID: id,
- Provider: internalProviderKey,
- Label: compat.Name,
- Prefix: prefix,
- Status: coreauth.StatusActive,
- ProxyURL: proxyURL,
- Attributes: attrs,
- Metadata: metadata,
- CreatedAt: now,
- UpdatedAt: now,
+ ID: id,
+ Provider: internalProviderKey,
+ Label: compat.Name,
+ Prefix: prefix,
+ Status: coreauth.StatusActive,
+ ProxyURL: proxyURL,
+ Attributes: attrs,
+ Metadata: metadata,
+ CreatedAt: now,
+ UpdatedAt: now,
+ ProxyConnectionPool: authProxyConnectionPool(openAICompatConnectionPool(compat, entry)),
}
if len(a.Metadata) == 0 {
a.Metadata = nil
@@ -360,8 +436,13 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
}
// Fallback: create entry without API key if no APIKeyEntries
if createdEntries == 0 {
+ effectivePool := openAICompatConnectionPool(compat, nil)
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
- id, token := idGen.Next(idKind, base)
+ idParts := []string{base}
+ if poolID := config.FormatConnectionPoolDedupID(effectivePool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, token := idGen.Next(idKind, idParts...)
attrs := map[string]string{
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
"base_url": base,
@@ -383,15 +464,16 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor
}
addConfigHeadersToAttrs(compat.Headers, attrs)
a := &coreauth.Auth{
- ID: id,
- Provider: internalProviderKey,
- Label: compat.Name,
- Prefix: prefix,
- Status: coreauth.StatusActive,
- Attributes: attrs,
- Metadata: metadata,
- CreatedAt: now,
- UpdatedAt: now,
+ ID: id,
+ Provider: internalProviderKey,
+ Label: compat.Name,
+ Prefix: prefix,
+ Status: coreauth.StatusActive,
+ Attributes: attrs,
+ Metadata: metadata,
+ CreatedAt: now,
+ UpdatedAt: now,
+ ProxyConnectionPool: authProxyConnectionPool(openAICompatConnectionPool(compat, nil)),
}
if len(a.Metadata) == 0 {
a.Metadata = nil
@@ -418,7 +500,11 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
prefix := strings.TrimSpace(compat.Prefix)
proxyURL := strings.TrimSpace(compat.ProxyURL)
idKind := "vertex:apikey"
- id, token := idGen.Next(idKind, key, base, proxyURL)
+ idParts := []string{key, base, proxyURL}
+ if poolID := config.FormatConnectionPoolDedupID(compat.ConnectionPool); poolID != "" {
+ idParts = append(idParts, poolID)
+ }
+ id, token := idGen.Next(idKind, idParts...)
attrs := map[string]string{
"source": fmt.Sprintf("config:vertex-apikey[%s]", token),
"base_url": base,
@@ -461,6 +547,8 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
+
+ ProxyConnectionPool: authProxyConnectionPool(compat.ConnectionPool),
}
ApplyAuthExcludedModelsMeta(a, compat.ExcludedModels, "apikey")
if len(a.Metadata) == 0 {
diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go
index 8e40719128a..96207ba6892 100644
--- a/internal/watcher/synthesizer/config_test.go
+++ b/internal/watcher/synthesizer/config_test.go
@@ -1340,3 +1340,158 @@ func TestConfigSynthesizer_RequestScopedErrors(t *testing.T) {
}
}
}
+
+// TestConfigSynthesizer_ConnectionPoolOverride pins the per-credential pool opt-in:
+// the override declared on an API-key entry must reach the auth record so the
+// generic proxy client can pool just that credential.
+func TestConfigSynthesizer_ConnectionPoolOverride(t *testing.T) {
+ enabled := true
+ maxIdle := 4
+ cfg := &config.Config{
+ CodexKey: []config.CodexKey{{
+ APIKey: "ollama-key",
+ BaseURL: "https://ollama.com/v1",
+ ProxyURL: "direct",
+ ConnectionPool: &config.ProxyConnectionPoolConfig{
+ Enabled: &enabled,
+ IdleConnTimeout: "300s",
+ MaxIdleConnsPerHost: &maxIdle,
+ },
+ }},
+ ClaudeKey: []config.ClaudeKey{{APIKey: "claude-key", BaseURL: "https://api.anthropic.com"}},
+ }
+ auths, err := NewConfigSynthesizer().Synthesize(&SynthesisContext{
+ Config: cfg,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ var codexAuth, claudeAuth *coreauth.Auth
+ for _, a := range auths {
+ switch {
+ case a.Provider == "codex":
+ codexAuth = a
+ case a.Provider == "claude":
+ claudeAuth = a
+ }
+ }
+ if codexAuth == nil || claudeAuth == nil {
+ t.Fatalf("missing synthesised auths: codex=%v claude=%v", codexAuth, claudeAuth)
+ }
+ pool := codexAuth.ProxyConnectionPool
+ if pool == nil {
+ t.Fatal("codex override did not reach the auth record")
+ }
+ if pool.Enabled == nil || !*pool.Enabled || pool.IdleConnTimeout != "300s" || pool.MaxIdleConnsPerHost == nil || *pool.MaxIdleConnsPerHost != 4 {
+ t.Fatalf("unexpected override: %+v", pool)
+ }
+ if claudeAuth.ProxyConnectionPool != nil {
+ t.Fatal("entry without a connection-pool block must stay unpooled")
+ }
+ // The override must be copied, never aliased to live config values.
+ *pool.Enabled = false
+ if !enabled {
+ t.Fatal("auth override aliases the config pointer")
+ }
+}
+
+// TestConfigSynthesizer_OpenAICompatConnectionPoolMerge pins field-level inheritance
+// for OpenAI-compatible keys: a per-key block only replaces the fields it sets.
+func TestConfigSynthesizer_OpenAICompatConnectionPoolMerge(t *testing.T) {
+ groupEnabled := true
+ groupMax := 2
+ cfg := &config.Config{
+ OpenAICompatibility: []config.OpenAICompatibility{{
+ Name: "compat",
+ BaseURL: "https://compat.example.com",
+ ConnectionPool: &config.ProxyConnectionPoolConfig{
+ Enabled: &groupEnabled,
+ IdleConnTimeout: "300s",
+ MaxIdleConnsPerHost: &groupMax,
+ },
+ APIKeyEntries: []config.OpenAICompatibilityAPIKey{
+ {APIKey: "inherits"},
+ {APIKey: "partial", ConnectionPool: &config.ProxyConnectionPoolConfig{IdleConnTimeout: "60s"}},
+ },
+ }},
+ }
+ auths, err := NewConfigSynthesizer().Synthesize(&SynthesisContext{
+ Config: cfg,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ byKey := map[string]*coreauth.ProxyConnectionPoolOverride{}
+ for _, a := range auths {
+ byKey[a.Attributes["api_key"]] = a.ProxyConnectionPool
+ }
+ inherits := byKey["inherits"]
+ if inherits == nil || inherits.Enabled == nil || !*inherits.Enabled || inherits.IdleConnTimeout != "300s" {
+ t.Fatalf("key without an override must inherit the group block: %+v", inherits)
+ }
+ if inherits.MaxIdleConnsPerHost == nil || *inherits.MaxIdleConnsPerHost != 2 {
+ t.Fatalf("inheriting key lost max-idle: %+v", inherits)
+ }
+ partial := byKey["partial"]
+ if partial == nil || partial.Enabled == nil || !*partial.Enabled {
+ t.Fatalf("partial override must keep the group's enabled value: %+v", partial)
+ }
+ if partial.IdleConnTimeout != "60s" {
+ t.Fatalf("partial override idle = %q, want 60s", partial.IdleConnTimeout)
+ }
+ if partial.MaxIdleConnsPerHost == nil || *partial.MaxIdleConnsPerHost != 2 {
+ t.Fatalf("partial override must inherit max-idle-conns-per-host: %+v", partial)
+ }
+}
+
+// TestConfigSynthesizer_EmptyConnectionPoolBlockOptsIn pins that writing the block
+// without fields requests the pool defaults. Dropping it during synthesis disabled
+// pooling for `connection-pool: {}` and for keys inheriting an empty group block.
+func TestConfigSynthesizer_EmptyConnectionPoolBlockOptsIn(t *testing.T) {
+ cfg := &config.Config{CodexKey: []config.CodexKey{{
+ APIKey: "empty-block",
+ BaseURL: "https://ollama.com/v1",
+ ProxyURL: "direct",
+ ConnectionPool: &config.ProxyConnectionPoolConfig{},
+ }}}
+ auths, err := NewConfigSynthesizer().Synthesize(&SynthesisContext{
+ Config: cfg,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(auths) != 1 {
+ t.Fatalf("auths = %d, want 1", len(auths))
+ }
+ if auths[0].ProxyConnectionPool == nil {
+ t.Fatal("an empty connection-pool block is still an opt-in and must reach the auth record")
+ }
+ if enabled := auths[0].ProxyConnectionPool.Enabled; enabled != nil {
+ t.Fatalf("empty block must leave enabled unset (defaults to on), got %v", *enabled)
+ }
+
+ // An OpenAI-compatible provider with no API keys still synthesizes one auth
+ // record (e.g. a local Ollama/vLLM daemon) and must inherit the group pool block.
+ keyless := &config.Config{OpenAICompatibility: []config.OpenAICompatibility{{
+ Name: "local-ollama",
+ BaseURL: "http://127.0.0.1:11434/v1",
+ ConnectionPool: &config.ProxyConnectionPoolConfig{},
+ }}}
+ keylessAuths, err := NewConfigSynthesizer().Synthesize(&SynthesisContext{
+ Config: keyless,
+ Now: time.Now(),
+ IDGenerator: NewStableIDGenerator(),
+ })
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if len(keylessAuths) != 1 || keylessAuths[0].ProxyConnectionPool == nil {
+ t.Fatalf("keyless OpenAI-compatible entry lost its group connection-pool block: %+v", keylessAuths)
+ }
+}
diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go
index b26aeb0df39..8ec2a6e2009 100644
--- a/internal/watcher/synthesizer/file_test.go
+++ b/internal/watcher/synthesizer/file_test.go
@@ -2,7 +2,6 @@ package synthesizer
import (
"context"
- "encoding/base64"
"encoding/json"
"errors"
"io/fs"
@@ -12,6 +11,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/base64"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go
index 04b32ae1ac9..90c6a3117eb 100644
--- a/sdk/api/handlers/claude/code_handlers.go
+++ b/sdk/api/handlers/claude/code_handlers.go
@@ -15,20 +15,21 @@ import (
"fmt"
"io"
"net/http"
+ "strconv"
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
claudemodels "github.com/router-for-me/CLIProxyAPI/v8/internal/client/claude/models"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// ClaudeCodeAPIHandler contains the handlers for Claude API endpoints.
@@ -87,8 +88,8 @@ func (h *ClaudeCodeAPIHandler) ClaudeMessages(c *gin.Context) {
rawJSON = rewriteClaudeDDModelInBody(rawJSON)
// Check if the client requested a streaming response.
- streamResult := gjson.GetBytes(rawJSON, "stream")
- if !streamResult.Exists() || streamResult.Type == gjson.False {
+ streamResult := ojson.GetBytesNoCopy(rawJSON, "stream")
+ if !streamResult.Exists() || streamResult.Type == ojson.False {
h.handleNonStreamingResponse(c, rawJSON)
} else {
h.handleStreamingResponse(c, rawJSON)
@@ -123,7 +124,7 @@ func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) {
alt := h.GetAlt(c)
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
resp, upstreamHeaders, errMsg := h.ExecuteCountWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
if errMsg != nil {
@@ -139,12 +140,12 @@ func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) {
// rewriteClaudeDDModelInBody decodes model IDs of the form claude-fable-5-dd-
// back into the original model name used for routing and upstream requests.
func rewriteClaudeDDModelInBody(rawJSON []byte) []byte {
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := ojson.GetBytesNoCopy(rawJSON, "model").String()
resolved := claudemodels.ResolveClaudeModelIDPrefix(modelName)
if resolved == modelName {
return rawJSON
}
- updated, errSet := sjson.SetBytes(rawJSON, "model", resolved)
+ updated, errSet := ojson.SetBytes(rawJSON, "model", resolved)
if errSet != nil {
return rawJSON
}
@@ -177,7 +178,7 @@ func (h *ClaudeCodeAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSO
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, alt)
stopKeepAlive()
@@ -234,7 +235,7 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
return
}
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
// Create a cancellable context for the backend client request
// This allows proper cleanup and cancellation of ongoing requests
@@ -334,6 +335,10 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
var streamState claudeSSEStreamState
streamState.trailingNewlines = 2
streamState.ObserveChunk(chunk)
+ if streamState.policyRefusalErr != nil && !streamState.loggedTerminalError {
+ h.LoggingAPIResponseError(c, streamState.policyRefusalErr)
+ streamState.loggedTerminalError = true
+ }
if len(chunk) > 0 {
_, _ = c.Writer.Write(chunk)
flusher.Flush()
@@ -349,13 +354,15 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
}
type claudeSSEStreamState struct {
- sawErrorEvent bool
- sawMessageStop bool
- sawBareMessageJSON bool
- trailingNewlines int
- currentEvent string
- eventData [][]byte
- lineBuf bytes.Buffer
+ sawErrorEvent bool
+ sawMessageStop bool
+ sawBareMessageJSON bool
+ loggedTerminalError bool
+ policyRefusalErr *interfaces.ErrorMessage
+ trailingNewlines int
+ currentEvent string
+ eventData [][]byte
+ lineBuf bytes.Buffer
}
func (s *claudeSSEStreamState) ObserveChunk(chunk []byte) {
@@ -385,7 +392,7 @@ func (s *claudeSSEStreamState) flushEventData() {
if len(s.eventData) > 0 {
joined := bytes.TrimSpace(bytes.Join(s.eventData, []byte("\n")))
s.eventData = s.eventData[:0]
- if len(joined) > 0 && gjson.ValidBytes(joined) {
+ if len(joined) > 0 && ojson.ValidBytes(joined) {
s.observeJSONPayload(joined, true)
}
}
@@ -422,14 +429,22 @@ func (s *claudeSSEStreamState) observeLine(line []byte) {
}
return
}
- if !bytes.HasPrefix(trimmed, []byte("{")) || !gjson.ValidBytes(trimmed) {
+ if !bytes.HasPrefix(trimmed, []byte("{")) || !ojson.ValidBytes(trimmed) {
return
}
s.observeJSONPayload(trimmed, false)
}
func (s *claudeSSEStreamState) observeJSONPayload(payload []byte, hasDataPrefix bool) {
- root := gjson.ParseBytes(payload)
+ if s.policyRefusalErr == nil {
+ if refusalMsg, blocked := clienterror.ClaudePolicyRefusalMessage(payload); blocked {
+ s.policyRefusalErr = &interfaces.ErrorMessage{
+ StatusCode: http.StatusBadRequest,
+ Error: errors.New(refusalMsg),
+ }
+ }
+ }
+ root := util.ParseGJSONBytesNoCopy(payload)
switch root.Get("type").String() {
case "message_stop":
s.sawMessageStop = true
@@ -482,6 +497,10 @@ func (h *ClaudeCodeAPIHandler) forwardClaudeStreamWithState(c *gin.Context, flus
return
}
state.ObserveChunk(chunk)
+ if state.policyRefusalErr != nil && !state.loggedTerminalError {
+ h.LoggingAPIResponseError(c, state.policyRefusalErr)
+ state.loggedTerminalError = true
+ }
_, _ = c.Writer.Write(chunk)
},
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
@@ -489,7 +508,11 @@ func (h *ClaudeCodeAPIHandler) forwardClaudeStreamWithState(c *gin.Context, flus
if errMsg == nil {
return
}
- if state.sawErrorEvent {
+ if !state.loggedTerminalError {
+ h.LoggingAPIResponseError(c, errMsg)
+ state.loggedTerminalError = true
+ }
+ if state.sawErrorEvent || (state.sawMessageStop && clienterror.IsClaudePolicyRefusalError(errMsg.Error)) {
state.ensureSSEEventBoundary(c.Writer)
return
}
@@ -533,6 +556,14 @@ type claudeErrorResponse struct {
}
func (h *ClaudeCodeAPIHandler) toClaudeError(msg *interfaces.ErrorMessage) claudeErrorResponse {
+ var retryAfter string
+ if msg != nil && msg.Error != nil {
+ retryAfter = coreauth.SafeResponseHeaders(msg.Error).Get("Retry-After")
+ }
+ return h.toClaudeErrorWithRetryAfter(msg, retryAfter)
+}
+
+func (h *ClaudeCodeAPIHandler) toClaudeErrorWithRetryAfter(msg *interfaces.ErrorMessage, retryAfter string) claudeErrorResponse {
status := http.StatusInternalServerError
errText := http.StatusText(status)
if msg != nil {
@@ -547,6 +578,18 @@ func (h *ClaudeCodeAPIHandler) toClaudeError(msg *interfaces.ErrorMessage) claud
}
}
errType, message := claudeErrorDetailFromText(status, errText)
+ if status == http.StatusTooManyRequests && msg != nil && msg.Error != nil {
+ var cooldown interface{ IsModelCooldown() bool }
+ if errors.As(msg.Error, &cooldown) && cooldown != nil && cooldown.IsModelCooldown() {
+ if seconds, errParse := strconv.ParseInt(retryAfter, 10, 64); errParse == nil && seconds > 0 {
+ delay := retryAfter + "s"
+ if seconds <= (1<<63-1)/int64(time.Second) {
+ delay = (time.Duration(seconds) * time.Second).String()
+ }
+ message += " (reset after " + delay + ")"
+ }
+ }
+ }
response := claudeErrorResponse{
Type: "error",
Error: claudeErrorDetail{
@@ -592,8 +635,11 @@ func (h *ClaudeCodeAPIHandler) WriteErrorResponse(c *gin.Context, msg *interface
_, _ = c.Writer.Write(body)
return
}
+ var retryAfter string
if msg != nil && msg.Error != nil {
- for _, value := range coreauth.SafeResponseHeaders(msg.Error).Values("Retry-After") {
+ safeHeaders := coreauth.SafeResponseHeaders(msg.Error)
+ retryAfter = safeHeaders.Get("Retry-After")
+ for _, value := range safeHeaders.Values("Retry-After") {
c.Writer.Header().Add("Retry-After", value)
}
}
@@ -614,7 +660,7 @@ func (h *ClaudeCodeAPIHandler) WriteErrorResponse(c *gin.Context, msg *interface
}
}
- body, err := json.Marshal(h.toClaudeError(msg))
+ body, err := json.Marshal(h.toClaudeErrorWithRetryAfter(msg, retryAfter))
if err != nil {
body = []byte(`{"type":"error","error":{"type":"api_error","message":"Internal Server Error"}}`)
}
diff --git a/sdk/api/handlers/claude/code_handlers_cooldown_test.go b/sdk/api/handlers/claude/code_handlers_cooldown_test.go
new file mode 100644
index 00000000000..33c8e6e37f1
--- /dev/null
+++ b/sdk/api/handlers/claude/code_handlers_cooldown_test.go
@@ -0,0 +1,226 @@
+package claude
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/gin-gonic/gin"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
+)
+
+func TestClaudeCooldownMessageMatchesRetryAfter(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, tt := range []struct {
+ name string
+ duration time.Duration
+ header string
+ resetAfter string
+ }{
+ {name: "reported delay", duration: 8250 * time.Second, header: "8250", resetAfter: "2h17m30s"},
+ {name: "round up fractional second", duration: 60*time.Second + 100*time.Millisecond, header: "61", resetAfter: "1m1s"},
+ {name: "largest duration", duration: time.Duration(1<<63 - 1), header: "9223372037", resetAfter: "9223372037s"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ handler.WriteErrorResponse(c, &interfaces.ErrorMessage{
+ StatusCode: http.StatusTooManyRequests,
+ Error: coreauth.NewModelCooldownError("claude-sonnet-4-5-20250929", "claude", tt.duration),
+ })
+
+ if recorder.Code != http.StatusTooManyRequests {
+ t.Fatalf("status = %d, want 429", recorder.Code)
+ }
+ if got := recorder.Header().Get("Retry-After"); got != tt.header {
+ t.Fatalf("Retry-After = %q, want %q", got, tt.header)
+ }
+ if got := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.type").String(); got != "rate_limit_error" {
+ t.Fatalf("error.type = %q, want rate_limit_error", got)
+ }
+ message := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.message").String()
+ if !strings.Contains(message, "cooling down") || !strings.Contains(message, "reset after "+tt.resetAfter) {
+ t.Fatalf("error.message = %q, want cooldown message with reset after %s", message, tt.resetAfter)
+ }
+ })
+ }
+}
+
+func TestClaudeCooldownMessageInCommittedStream(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ c.Header("Content-Type", "text/event-stream")
+ if _, errWrite := c.Writer.Write([]byte(": keep-alive\n\n")); errWrite != nil {
+ t.Fatalf("write keep-alive: %v", errWrite)
+ }
+ c.Writer.Flush()
+
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ errs := make(chan *interfaces.ErrorMessage, 1)
+ errs <- &interfaces.ErrorMessage{
+ StatusCode: http.StatusTooManyRequests,
+ Error: coreauth.NewModelCooldownError("claude-sonnet-4-5-20250929", "claude", 8250*time.Second),
+ }
+ close(errs)
+ handler.forwardClaudeStream(c, c.Writer, func(error) {}, nil, errs)
+
+ body := recorder.Body.String()
+ if recorder.Code != http.StatusOK || !strings.Contains(body, "event: error\ndata: ") {
+ t.Fatalf("status = %d, body = %q; want a terminal SSE error", recorder.Code, body)
+ }
+ _, event, _ := strings.Cut(body, "event: error\ndata: ")
+ message := ojson.Get(strings.TrimSpace(event), "error.message").String()
+ if !strings.Contains(message, "cooling down") || !strings.Contains(message, "reset after 2h17m30s") {
+ t.Fatalf("SSE error.message = %q, want cooldown message with reset after 2h17m30s", message)
+ }
+}
+
+func TestClaudeCooldownLeavesOrdinary429MessageUnchanged(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ handler := NewClaudeCodeAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{PassthroughHeaders: true}, nil))
+ handler.WriteErrorResponse(c, &interfaces.ErrorMessage{
+ StatusCode: http.StatusTooManyRequests,
+ Error: errors.New(`{"error":{"type":"rate_limit_error","message":"upstream quota exceeded"}}`),
+ Addon: http.Header{"Retry-After": {"300"}},
+ })
+ if got := recorder.Header().Get("Retry-After"); got != "300" {
+ t.Fatalf("Retry-After = %q, want passed-through 300", got)
+ }
+ if got := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.message").String(); got != "upstream quota exceeded" {
+ t.Fatalf("error.message = %q, want original upstream message", got)
+ }
+}
+
+func TestClaudeCooldownTrustedRetryAfterWinsOverPassthrough(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ handler := NewClaudeCodeAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{PassthroughHeaders: true}, nil))
+ handler.WriteErrorResponse(c, &interfaces.ErrorMessage{
+ StatusCode: http.StatusTooManyRequests,
+ Error: coreauth.NewModelCooldownError("claude-model", "claude", 60*time.Second+100*time.Millisecond),
+ Addon: http.Header{"Retry-After": {"300"}},
+ })
+ if got := recorder.Header().Get("Retry-After"); got != "61" {
+ t.Fatalf("Retry-After = %q, want trusted 61", got)
+ }
+ if got := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.message").String(); !strings.Contains(got, "reset after 1m1s") {
+ t.Fatalf("error.message = %q, want trusted retry delay", got)
+ }
+}
+
+func TestClaudeCooldownSkipsZeroDelayAndOtherStatuses(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, tt := range []struct {
+ name string
+ status int
+ duration time.Duration
+ }{
+ {name: "zero delay", status: http.StatusTooManyRequests},
+ {name: "other status", status: http.StatusServiceUnavailable, duration: time.Minute},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ handler.WriteErrorResponse(c, &interfaces.ErrorMessage{
+ StatusCode: tt.status,
+ Error: coreauth.NewModelCooldownError("claude-model", "claude", tt.duration),
+ })
+ if got := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.message").String(); strings.Contains(got, "reset after") {
+ t.Fatalf("error.message = %q, want no reset hint", got)
+ }
+ })
+ }
+}
+
+func TestClaudeCooldownDirectResponseIsUnchanged(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+ body := `{"type":"error","error":{"type":"rate_limit_error","message":"original response"}}`
+ handler := NewClaudeCodeAPIHandler(&handlers.BaseAPIHandler{})
+ handler.WriteErrorResponse(c, &interfaces.ErrorMessage{
+ StatusCode: http.StatusTooManyRequests,
+ Error: coreauth.NewModelCooldownError("claude-model", "claude", time.Minute),
+ DirectResponse: true,
+ Body: []byte(body),
+ })
+ if got := recorder.Body.String(); got != body {
+ t.Fatalf("direct response = %q, want %q", got, body)
+ }
+}
+
+func TestClaudeCooldownMessagesRoute(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ chunks := make(chan coreexecutor.StreamChunk)
+ close(chunks)
+ executor := &preFTKeepAliveClaudeExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ const model = "issue-7285-cooldown-model"
+ const authID = "issue-7285-cooldown-auth"
+ next := time.Now().Add(8250 * time.Second)
+ auth := &coreauth.Auth{
+ ID: authID,
+ Provider: executor.Identifier(),
+ Status: coreauth.StatusActive,
+ Unavailable: true,
+ NextRetryAfter: next,
+ Quota: coreauth.QuotaState{Exceeded: true, Reason: "credential_quota", NextRecoverAt: next},
+ LastError: &coreauth.Error{Code: "rate_limit_error", Message: "upstream quota exceeded", HTTPStatus: http.StatusTooManyRequests},
+ }
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(authID, executor.Identifier(), []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(authID)
+ })
+ handler := NewClaudeCodeAPIHandler(handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager))
+ router := gin.New()
+ router.POST("/v1/messages", handler.ClaudeMessages)
+
+ for _, streaming := range []bool{false, true} {
+ t.Run(strconv.FormatBool(streaming), func(t *testing.T) {
+ requestBody := `{"model":"` + model + `","messages":[{"role":"user","content":"hi"}],"stream":` + strconv.FormatBool(streaming) + `}`
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(requestBody))
+ request.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, request)
+
+ if recorder.Code != http.StatusTooManyRequests {
+ t.Fatalf("status = %d, want 429; body=%s", recorder.Code, recorder.Body.String())
+ }
+ seconds, errSeconds := strconv.ParseInt(recorder.Header().Get("Retry-After"), 10, 64)
+ if errSeconds != nil || seconds <= 0 {
+ t.Fatalf("Retry-After = %q, want positive seconds", recorder.Header().Get("Retry-After"))
+ }
+ message := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.message").String()
+ if !strings.Contains(message, "upstream quota exceeded") || !strings.Contains(message, "reset after "+(time.Duration(seconds)*time.Second).String()) {
+ t.Fatalf("error.message = %q, want upstream cause and reset matching Retry-After %d", message, seconds)
+ }
+ })
+ }
+}
diff --git a/sdk/api/handlers/claude/code_handlers_error_test.go b/sdk/api/handlers/claude/code_handlers_error_test.go
index 3ff08c3c105..b9a5abba411 100644
--- a/sdk/api/handlers/claude/code_handlers_error_test.go
+++ b/sdk/api/handlers/claude/code_handlers_error_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -17,7 +18,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func TestClaudeErrorTypeFromStatus(t *testing.T) {
@@ -86,13 +86,13 @@ func TestClaudeIncompleteStreamError(t *testing.T) {
_, body, _ = strings.Cut(body, prefix)
body = strings.TrimSpace(body)
}
- if got := gjson.Get(body, "type").String(); got != "error" {
+ if got := ojson.Get(body, "type").String(); got != "error" {
t.Errorf("type = %q, want error", got)
}
- if got := gjson.Get(body, "error.type").String(); got != "timeout_error" {
+ if got := ojson.Get(body, "error.type").String(); got != "timeout_error" {
t.Errorf("error.type = %q, want timeout_error", got)
}
- if got := gjson.Get(body, "error.message").String(); got != message {
+ if got := ojson.Get(body, "error.message").String(); got != message {
t.Errorf("error.message = %q, want %q", got, message)
}
})
@@ -154,7 +154,7 @@ func TestClaudeErrorMarksWrappedMissingThreadForClientReplay(t *testing.T) {
if errMarshal != nil {
t.Fatalf("marshal Claude error: %v", errMarshal)
}
- if got := gjson.GetBytes(body, "error.details.error_code").String(); got != "thread_not_found" {
+ if got := ojson.GetBytesNoCopy(body, "error.details.error_code").String(); got != "thread_not_found" {
t.Fatalf("error.details.error_code = %q, want thread_not_found; body=%s", got, body)
}
}
@@ -170,7 +170,7 @@ func TestClaudeErrorMarksMissingThreadForClientReplay(t *testing.T) {
if errMarshal != nil {
t.Fatalf("marshal Claude error: %v", errMarshal)
}
- if got := gjson.GetBytes(body, "error.details.error_code").String(); got != "thread_not_found" {
+ if got := ojson.GetBytesNoCopy(body, "error.details.error_code").String(); got != "thread_not_found" {
t.Fatalf("error.details.error_code = %q, want thread_not_found; body=%s", got, body)
}
}
@@ -186,7 +186,7 @@ func TestClaudeErrorMarksPlainTextMissingThreadForClientReplay(t *testing.T) {
if errMarshal != nil {
t.Fatalf("marshal Claude error: %v", errMarshal)
}
- if got := gjson.GetBytes(body, "error.details.error_code").String(); got != "thread_not_found" {
+ if got := ojson.GetBytesNoCopy(body, "error.details.error_code").String(); got != "thread_not_found" {
t.Fatalf("error.details.error_code = %q, want thread_not_found; body=%s", got, body)
}
}
@@ -213,7 +213,7 @@ func TestClaudeErrorMarksStructuredExecutorMissingThreadForClientReplay(t *testi
if errMarshal != nil {
t.Fatalf("marshal Claude error: %v", errMarshal)
}
- if got := gjson.GetBytes(body, "error.details.error_code").String(); got != "thread_not_found" {
+ if got := ojson.GetBytesNoCopy(body, "error.details.error_code").String(); got != "thread_not_found" {
t.Fatalf("error.details.error_code = %q, want thread_not_found; body=%s", got, body)
}
}
@@ -231,7 +231,7 @@ func TestWriteClaudeDirectErrorMarksMissingThreadForClientReplay(t *testing.T) {
handler.WriteErrorResponse(c, msg)
- if got := gjson.GetBytes(recorder.Body.Bytes(), "error.details.error_code").String(); got != "thread_not_found" {
+ if got := ojson.GetBytesNoCopy(recorder.Body.Bytes(), "error.details.error_code").String(); got != "thread_not_found" {
t.Fatalf("error.details.error_code = %q, want thread_not_found; body=%s", got, recorder.Body.Bytes())
}
}
@@ -252,13 +252,13 @@ func TestWriteClaudeErrorResponseUsesClaudeEnvelope(t *testing.T) {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
body := recorder.Body.Bytes()
- if got := gjson.GetBytes(body, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(body, "type").String(); got != "error" {
t.Fatalf("type = %q, want error; body=%s", got, body)
}
- if got := gjson.GetBytes(body, "error.type").String(); got != "invalid_request_error" {
+ if got := ojson.GetBytesNoCopy(body, "error.type").String(); got != "invalid_request_error" {
t.Fatalf("error.type = %q, want invalid_request_error; body=%s", got, body)
}
- if got := gjson.GetBytes(body, "error.message").String(); got != "Your input exceeds the context window of this model. Please adjust your input and try again." {
+ if got := ojson.GetBytesNoCopy(body, "error.message").String(); got != "Your input exceeds the context window of this model. Please adjust your input and try again." {
t.Fatalf("error.message = %q; body=%s", got, body)
}
}
diff --git a/sdk/api/handlers/claude/code_handlers_model_test.go b/sdk/api/handlers/claude/code_handlers_model_test.go
index dbd563b513e..910632f032d 100644
--- a/sdk/api/handlers/claude/code_handlers_model_test.go
+++ b/sdk/api/handlers/claude/code_handlers_model_test.go
@@ -5,11 +5,11 @@ import (
"net/http/httptest"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func TestClaudeModelsResponseUsesConfiguredDisplayName(t *testing.T) {
@@ -112,7 +112,7 @@ func TestRewriteClaudeDDModelInBody(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rewriteClaudeDDModelInBody([]byte(tt.body))
- if model := gjson.GetBytes(got, "model").String(); model != tt.wantModel {
+ if model := ojson.GetBytesNoCopy(got, "model").String(); model != tt.wantModel {
t.Fatalf("model = %q, want %q; body=%s", model, tt.wantModel, string(got))
}
})
diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go
index 4226cc9d063..ba6f4420d67 100644
--- a/sdk/api/handlers/gemini/gemini_handlers.go
+++ b/sdk/api/handlers/gemini/gemini_handlers.go
@@ -384,6 +384,7 @@ func (h *GeminiAPIHandler) forwardGeminiStream(c *gin.Context, flusher http.Flus
if errMsg == nil {
return
}
+ h.LoggingAPIResponseError(c, errMsg)
status := http.StatusInternalServerError
if errMsg.StatusCode > 0 {
status = errMsg.StatusCode
diff --git a/sdk/api/handlers/gemini/interactions_handlers.go b/sdk/api/handlers/gemini/interactions_handlers.go
index 080b3a58f4c..36c34ea96a5 100644
--- a/sdk/api/handlers/gemini/interactions_handlers.go
+++ b/sdk/api/handlers/gemini/interactions_handlers.go
@@ -7,13 +7,13 @@ import (
"net/http"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const interactionsAgentAuthSelectionModel = "gemini-2.5-flash"
@@ -25,12 +25,12 @@ type interactionsRequestTarget struct {
}
func parseInteractionsRequestTarget(rawJSON []byte) (interactionsRequestTarget, error) {
- if !gjson.ValidBytes(rawJSON) {
+ if !ojson.ValidBytes(rawJSON) {
return interactionsRequestTarget{}, fmt.Errorf("invalid JSON body")
}
- root := gjson.ParseBytes(rawJSON)
- model := strings.TrimSpace(root.Get("model").String())
- agent := strings.TrimSpace(root.Get("agent").String())
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
+ model := strings.Clone(strings.TrimSpace(root.Get("model").String()))
+ agent := strings.Clone(strings.TrimSpace(root.Get("agent").String()))
if model == "" && agent == "" {
return interactionsRequestTarget{}, fmt.Errorf("request requires exactly one of model or agent")
}
@@ -56,7 +56,7 @@ func prepareInteractionsExecutionTarget(rawJSON []byte, target interactionsReque
if model == target.Model {
return model, rawJSON
}
- updatedRawJSON, errSet := sjson.SetBytes(rawJSON, "model", model)
+ updatedRawJSON, errSet := ojson.SetBytes(rawJSON, "model", model)
if errSet != nil {
return model, rawJSON
}
@@ -238,6 +238,7 @@ func (h *GeminiAPIHandler) forwardInteractionsStream(c *gin.Context, flusher htt
if errMsg == nil {
return
}
+ h.LoggingAPIResponseError(c, errMsg)
status := http.StatusInternalServerError
if errMsg.StatusCode > 0 {
status = errMsg.StatusCode
diff --git a/sdk/api/handlers/gemini/interactions_handlers_test.go b/sdk/api/handlers/gemini/interactions_handlers_test.go
index eeeb86daccc..561b2769932 100644
--- a/sdk/api/handlers/gemini/interactions_handlers_test.go
+++ b/sdk/api/handlers/gemini/interactions_handlers_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
@@ -20,7 +21,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func TestParseInteractionsRequestTarget(t *testing.T) {
@@ -67,7 +67,7 @@ func TestPrepareInteractionsExecutionTargetNormalizesModelResourceName(t *testin
if model != "gemini-3.5-flash" {
t.Fatalf("model = %q, want gemini-3.5-flash", model)
}
- if got := gjson.GetBytes(body, "model").String(); got != "gemini-3.5-flash" {
+ if got := ojson.GetBytesNoCopy(body, "model").String(); got != "gemini-3.5-flash" {
t.Fatalf("body model = %q, want gemini-3.5-flash. Body: %s", got, string(body))
}
}
@@ -81,7 +81,7 @@ func TestPrepareInteractionsExecutionTargetPreservesBareModel(t *testing.T) {
if model != "gemini-3.5-flash" {
t.Fatalf("model = %q, want gemini-3.5-flash", model)
}
- if got := gjson.GetBytes(body, "model").String(); got != "gemini-3.5-flash" {
+ if got := ojson.GetBytesNoCopy(body, "model").String(); got != "gemini-3.5-flash" {
t.Fatalf("body model = %q, want gemini-3.5-flash. Body: %s", got, string(body))
}
}
@@ -101,7 +101,7 @@ func TestBuildInteractionsExecutionRequestUsesAgentAuthSelectionModel(t *testing
if req.Model != "agents/test-agent" {
t.Fatalf("Model = %q, want agents/test-agent", req.Model)
}
- if got := gjson.GetBytes(req.Body, "agent").String(); got != "agents/test-agent" {
+ if got := ojson.GetBytesNoCopy(req.Body, "agent").String(); got != "agents/test-agent" {
t.Fatalf("body agent = %q, want agents/test-agent", got)
}
}
@@ -224,10 +224,10 @@ func TestInteractionsAgentUsesNativeInteractionsEndpoint(t *testing.T) {
if gotPath != "/v1beta/interactions" {
t.Fatalf("path = %q, want /v1beta/interactions", gotPath)
}
- if got := gjson.GetBytes(upstreamBody, "agent").String(); got != "agents/test-agent" {
+ if got := ojson.GetBytesNoCopy(upstreamBody, "agent").String(); got != "agents/test-agent" {
t.Fatalf("upstream agent = %q, want agents/test-agent. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(rec.Body.Bytes(), "id").String(); got != "interaction_1" {
+ if got := ojson.GetBytesNoCopy(rec.Body.Bytes(), "id").String(); got != "interaction_1" {
t.Fatalf("response id = %q, want interaction_1. Body: %s", got, rec.Body.String())
}
}
@@ -285,19 +285,19 @@ func TestInteractionsAntigravityModelUsesTranslatorBridge(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
- if gjson.GetBytes(upstreamBody, "input").Exists() {
+ if ojson.GetBytesNoCopy(upstreamBody, "input").Exists() {
t.Fatalf("upstream body still contains raw interactions input: %s", string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.text").String(); got != "hi" {
+ if got := ojson.GetBytesNoCopy(upstreamBody, "request.contents.0.parts.0.text").String(); got != "hi" {
t.Fatalf("upstream request text = %q, want hi. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(upstreamBody, "request.generationConfig.topP").Float(); got != 0.8 {
+ if got := ojson.GetBytesNoCopy(upstreamBody, "request.generationConfig.topP").Float(); got != 0.8 {
t.Fatalf("upstream topP = %v, want 0.8. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(rec.Body.Bytes(), "steps.0.content.0.text").String(); got != "translated-ok" {
+ if got := ojson.GetBytesNoCopy(rec.Body.Bytes(), "steps.0.content.0.text").String(); got != "translated-ok" {
t.Fatalf("response text = %q, want translated-ok. Body: %s", got, rec.Body.String())
}
- if gjson.GetBytes(rec.Body.Bytes(), "response").Exists() {
+ if ojson.GetBytesNoCopy(rec.Body.Bytes(), "response").Exists() {
t.Fatalf("response still contains raw antigravity response wrapper: %s", rec.Body.String())
}
}
@@ -353,10 +353,10 @@ func TestInteractionsVertexModelUsesNativeInteractionsEndpoint(t *testing.T) {
if !strings.HasSuffix(gotPath, "interactions") {
t.Fatalf("upstream path = %q, want path ending with interactions", gotPath)
}
- if got := gjson.GetBytes(upstreamBody, "input").String(); got != "hi vertex" {
+ if got := ojson.GetBytesNoCopy(upstreamBody, "input").String(); got != "hi vertex" {
t.Fatalf("upstream input = %q, want hi vertex. Body: %s", got, string(upstreamBody))
}
- if got := gjson.GetBytes(rec.Body.Bytes(), "steps.0.content.0.text").String(); got != "vertex-ok" {
+ if got := ojson.GetBytesNoCopy(rec.Body.Bytes(), "steps.0.content.0.text").String(); got != "vertex-ok" {
t.Fatalf("response text = %q, want vertex-ok. Body: %s", got, rec.Body.String())
}
}
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index 5e7d4383edf..d365430d7db 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -14,6 +14,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
@@ -26,7 +27,6 @@ import (
coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/httptransport"
- "github.com/tidwall/gjson"
"golang.org/x/net/context"
)
@@ -406,11 +406,11 @@ func setServiceTierMetadata(meta map[string]any, rawJSON []byte) {
return
}
serviceTier := coreusage.AutoServiceTier
- node := gjson.GetBytes(rawJSON, "service_tier")
+ node := ojson.GetBytesNoCopy(rawJSON, "service_tier")
if node.Exists() {
value := strings.TrimSpace(node.String())
if value != "" {
- serviceTier = value
+ serviceTier = strings.Clone(value)
}
}
meta[coreexecutor.ServiceTierMetadataKey] = serviceTier
@@ -422,7 +422,7 @@ func setGenerateMetadata(meta map[string]any, rawJSON []byte) {
}
// Missing or true means generation is enabled; only an explicit false disables generation.
generate := true
- node := gjson.GetBytes(rawJSON, "generate")
+ node := ojson.GetBytesNoCopy(rawJSON, "generate")
if node.Exists() && node.IsBool() && !node.Bool() {
generate = false
}
diff --git a/sdk/api/handlers/handlers_errors.go b/sdk/api/handlers/handlers_errors.go
index 418f83f2c26..c727d9e2829 100644
--- a/sdk/api/handlers/handlers_errors.go
+++ b/sdk/api/handlers/handlers_errors.go
@@ -201,17 +201,22 @@ func writeDirectErrorResponse(c *gin.Context, status int, msg *interfaces.ErrorM
}
func (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) {
- if cfg := h.CurrentConfig(); cfg != nil && cfg.RequestLog {
- if ginContext, ok := ctx.Value("gin").(*gin.Context); ok {
- if apiResponseErrors, isExist := ginContext.Get("API_RESPONSE_ERROR"); isExist {
- if slicesAPIResponseError, isOk := apiResponseErrors.([]*interfaces.ErrorMessage); isOk {
- slicesAPIResponseError = append(slicesAPIResponseError, err)
- ginContext.Set("API_RESPONSE_ERROR", slicesAPIResponseError)
- }
- } else {
- // Create new response data entry
- ginContext.Set("API_RESPONSE_ERROR", []*interfaces.ErrorMessage{err})
+ if h == nil || err == nil || ctx == nil {
+ return
+ }
+ ginContext, ok := ctx.(*gin.Context)
+ if !ok || ginContext == nil {
+ ginContext, ok = ctx.Value("gin").(*gin.Context)
+ }
+ if ok && ginContext != nil {
+ if apiResponseErrors, isExist := ginContext.Get("API_RESPONSE_ERROR"); isExist {
+ if slicesAPIResponseError, isOk := apiResponseErrors.([]*interfaces.ErrorMessage); isOk {
+ slicesAPIResponseError = append(slicesAPIResponseError, err)
+ ginContext.Set("API_RESPONSE_ERROR", slicesAPIResponseError)
}
+ } else {
+ // Create new response data entry
+ ginContext.Set("API_RESPONSE_ERROR", []*interfaces.ErrorMessage{err})
}
}
}
diff --git a/sdk/api/handlers/handlers_execution.go b/sdk/api/handlers/handlers_execution.go
index 742c1573b95..dff03be5f68 100644
--- a/sdk/api/handlers/handlers_execution.go
+++ b/sdk/api/handlers/handlers_execution.go
@@ -222,6 +222,7 @@ func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryPro
if !execOptions.InternalSource {
reporter = helps.NewUsageReporter(execCtx, executorPluginID, modelName, nil)
reporter.SetTranslatedReasoningEffort(req.Payload, entryProtocol)
+ reporter.StartResponseTTFT()
}
resp, errExecute := host.ExecutePluginExecutor(execCtx, executorPluginID, req, opts)
if errExecute != nil {
@@ -233,6 +234,7 @@ func (h *BaseAPIHandler) executeWithPluginExecutor(ctx context.Context, entryPro
return nil, nil, errMsg
}
if reporter != nil && !nestedTracker.hasNestedExecution() {
+ reporter.RecordFirstPacket()
detail := parsePluginExecutorResponseUsage(responseProtocol, resp.Payload)
reporter.Publish(execCtx, detail)
reporter.EnsurePublished(execCtx)
diff --git a/sdk/api/handlers/handlers_model_router_test.go b/sdk/api/handlers/handlers_model_router_test.go
index d0b57a4454e..9c8d01a748f 100644
--- a/sdk/api/handlers/handlers_model_router_test.go
+++ b/sdk/api/handlers/handlers_model_router_test.go
@@ -654,6 +654,21 @@ func TestHandlerProvidersForExecutionRejectsImageOnlyModelOnProviderRoute(t *tes
originalModel: "original-model",
decision: modelRouteDecision{Provider: "claude", Model: "gpt-image-2.5-sunburst"},
},
+ {
+ name: "target-model-image-2.6",
+ originalModel: "original-model",
+ decision: modelRouteDecision{Provider: "claude", Model: "gpt-image-2.6"},
+ },
+ {
+ name: "target-model-image-2.6-flare",
+ originalModel: "original-model",
+ decision: modelRouteDecision{Provider: "claude", Model: "gpt-image-2.6-flare"},
+ },
+ {
+ name: "target-model-image-2.6-sunburst",
+ originalModel: "original-model",
+ decision: modelRouteDecision{Provider: "claude", Model: "gpt-image-2.6-sunburst"},
+ },
{
name: "target-model-thinking-suffix",
originalModel: "original-model",
diff --git a/sdk/api/handlers/handlers_plugin_executor_usage.go b/sdk/api/handlers/handlers_plugin_executor_usage.go
index 6e3f9be304c..56b82a890d5 100644
--- a/sdk/api/handlers/handlers_plugin_executor_usage.go
+++ b/sdk/api/handlers/handlers_plugin_executor_usage.go
@@ -12,3 +12,7 @@ func parsePluginExecutorResponseUsage(protocol string, payload []byte) usage.Det
func observePluginExecutorStreamUsage(protocol string, payload []byte, buffer *helps.StreamUsageBuffer) {
helps.ObservePluginExecutorStreamUsage(protocol, payload, buffer)
}
+
+func observePluginExecutorStreamTTFT(protocol string, reporter *helps.UsageReporter, payload []byte) {
+ helps.ObservePluginExecutorStreamTTFT(protocol, reporter, payload)
+}
diff --git a/sdk/api/handlers/handlers_plugin_executor_usage_test.go b/sdk/api/handlers/handlers_plugin_executor_usage_test.go
index 10ae37162a6..d66fefcef6b 100644
--- a/sdk/api/handlers/handlers_plugin_executor_usage_test.go
+++ b/sdk/api/handlers/handlers_plugin_executor_usage_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "runtime"
"testing"
"time"
@@ -755,6 +756,7 @@ type mockPluginUsageHost struct {
execErr error
execFunc func(context.Context, string, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error)
streamResult *coreexecutor.StreamResult
+ streamFunc func(context.Context, string, coreexecutor.Request, coreexecutor.Options) (*coreexecutor.StreamResult, error)
}
func (h *mockPluginUsageHost) ExecutePluginExecutor(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
@@ -774,5 +776,159 @@ func (h *mockPluginUsageHost) ExecutePluginExecutorStream(ctx context.Context, p
h.lastPluginID = pluginID
h.lastRequest = req
h.lastOptions = opts
+ if h.streamFunc != nil {
+ return h.streamFunc(ctx, pluginID, req, opts)
+ }
return h.streamResult, nil
}
+
+func advanceMonotonicClock() time.Duration {
+ start := time.Now()
+ for {
+ elapsed := time.Since(start)
+ if elapsed > 0 {
+ return elapsed
+ }
+ runtime.Gosched()
+ }
+}
+
+func TestHandlerPluginExecutorPublishesNonZeroTTFTNonStream(t *testing.T) {
+ targetPluginID := "custom-ttft-nonstream-plugin"
+ plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
+ registerUsagePluginForTest(t, "test-plugin-executor-ttft-nonstream", plugin)
+
+ originalModel := "gpt-4o"
+ openAIResponseBody := []byte(`{"id":"chatcmpl-1","choices":[{"message":{"role":"assistant","content":"hello"}}],"usage":{"prompt_tokens":12,"completion_tokens":34,"total_tokens":46}}`)
+
+ mockHost := &mockPluginUsageHost{
+ execFunc: func(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (coreexecutor.Response, error) {
+ advanceMonotonicClock()
+ return coreexecutor.Response{Payload: openAIResponseBody}, nil
+ },
+ }
+ mockHost.hasRouters = true
+ mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
+ return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
+ }
+
+ handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
+ handler.SetModelRouterHost(mockHost)
+
+ body, _, errMsg := handler.ExecuteWithAuthManager(context.Background(), "openai", originalModel, []byte(fmt.Sprintf(`{"model":%q}`, originalModel)), "")
+ if errMsg != nil {
+ t.Fatalf("ExecuteWithAuthManager() error = %+v", errMsg)
+ }
+ if len(body) == 0 {
+ t.Fatal("empty response body")
+ }
+
+ record := plugin.waitRecord(t)
+ if record.TTFT <= 0 {
+ t.Fatalf("record.TTFT = %v, want > 0", record.TTFT)
+ }
+ if record.Latency <= 0 {
+ t.Fatalf("record.Latency = %v, want > 0", record.Latency)
+ }
+}
+
+func TestHandlerPluginExecutorPublishesNonZeroTTFTStream(t *testing.T) {
+ targetPluginID := "custom-ttft-stream-plugin"
+ plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
+ registerUsagePluginForTest(t, "test-plugin-executor-ttft-stream", plugin)
+
+ originalModel := "codex-5.2"
+ firstChunkDelivered := make(chan struct{})
+ var minTokenOffset time.Duration
+
+ mockHost := &mockPluginUsageHost{
+ streamFunc: func(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ streamStart := time.Now()
+ chunks := make(chan coreexecutor.StreamChunk)
+ go func() {
+ defer close(chunks)
+ advanceMonotonicClock()
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n")}
+ <-firstChunkDelivered
+ advanceMonotonicClock()
+ minTokenOffset = time.Since(streamStart)
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hi\"}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}
+ }()
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ },
+ }
+ mockHost.hasRouters = true
+ mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
+ return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
+ }
+
+ handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
+ handler.SetModelRouterHost(mockHost)
+
+ dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai-response", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
+ firstSeen := false
+ for range dataChan {
+ if !firstSeen {
+ firstSeen = true
+ close(firstChunkDelivered)
+ }
+ }
+ if !firstSeen {
+ close(firstChunkDelivered)
+ }
+ for err := range errChan {
+ if err != nil {
+ t.Fatalf("stream error = %+v", err)
+ }
+ }
+
+ record := plugin.waitRecord(t)
+ if record.TTFT <= 0 {
+ t.Fatalf("record.TTFT = %v, want > 0", record.TTFT)
+ }
+ if record.TTFT < minTokenOffset {
+ t.Fatalf("record.TTFT = %v, want >= token delta offset %v (must reflect token event, not metadata first packet)", record.TTFT, minTokenOffset)
+ }
+}
+
+func TestHandlerPluginExecutorPublishesFirstPacketFallbackTTFTStream(t *testing.T) {
+ targetPluginID := "custom-ttft-fallback-stream-plugin"
+ plugin := newCapturePluginExecutorUsagePlugin(targetPluginID)
+ registerUsagePluginForTest(t, "test-plugin-executor-ttft-fallback-stream", plugin)
+
+ originalModel := "codex-5.2"
+
+ mockHost := &mockPluginUsageHost{
+ streamFunc: func(ctx context.Context, pluginID string, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ chunks := make(chan coreexecutor.StreamChunk, 2)
+ advanceMonotonicClock()
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n")}
+ chunks <- coreexecutor.StreamChunk{Payload: []byte("data: [DONE]\n\n")}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ },
+ }
+ mockHost.hasRouters = true
+ mockHost.route = func(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
+ return pluginapi.ModelRouteResponse{Handled: true, TargetKind: pluginapi.ModelRouteTargetExecutor, Target: targetPluginID}, true
+ }
+
+ handler := NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, nil)
+ handler.SetModelRouterHost(mockHost)
+
+ dataChan, _, errChan := handler.ExecuteStreamWithAuthManager(context.Background(), "openai-response", originalModel, []byte(fmt.Sprintf(`{"model":%q,"stream":true}`, originalModel)), "")
+ for range dataChan {
+ }
+ for err := range errChan {
+ if err != nil {
+ t.Fatalf("stream error = %+v", err)
+ }
+ }
+
+ record := plugin.waitRecord(t)
+ if record.TTFT <= 0 {
+ t.Fatalf("record.TTFT = %v, want > 0 for first-packet fallback", record.TTFT)
+ }
+}
diff --git a/sdk/api/handlers/handlers_request_details_test.go b/sdk/api/handlers/handlers_request_details_test.go
index 99cbb306db4..df96d672ebb 100644
--- a/sdk/api/handlers/handlers_request_details_test.go
+++ b/sdk/api/handlers/handlers_request_details_test.go
@@ -9,7 +9,7 @@ import (
"testing"
"time"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -150,10 +150,10 @@ func TestGetRequestDetails_UnknownModelErrorResistsJSONInjection(t *testing.T) {
if !json.Valid([]byte(body)) {
t.Fatalf("error body is not valid JSON: %s", body)
}
- if got := gjson.Get(body, "error.code").String(); got != "model_not_found" {
+ if got := ojson.Get(body, "error.code").String(); got != "model_not_found" {
t.Fatalf("error code = %q, want model_not_found; the caller controlled the body: %s", got, body)
}
- if got, want := gjson.Get(body, "error.message").String(), "unknown provider for model "+model; got != want {
+ if got, want := ojson.Get(body, "error.message").String(), "unknown provider for model "+model; got != want {
t.Fatalf("error message = %q, want %q", got, want)
}
})
@@ -173,6 +173,12 @@ func TestGetRequestDetails_ImageModelReturns503(t *testing.T) {
"codex/gpt-image-2.5-sunburst",
"gpt-image-2.5",
"codex/gpt-image-2.5",
+ "gpt-image-2.6-flare",
+ "codex/gpt-image-2.6-flare",
+ "gpt-image-2.6-sunburst",
+ "codex/gpt-image-2.6-sunburst",
+ "gpt-image-2.6",
+ "codex/gpt-image-2.6",
"grok-imagine-image",
"xai/grok-imagine-image",
"grok-imagine-image-quality",
@@ -213,6 +219,12 @@ func TestValidateImageOnlyModel_AllowsImageEndpoints(t *testing.T) {
"codex/gpt-image-2.5-sunburst",
"gpt-image-2.5",
"codex/gpt-image-2.5",
+ "gpt-image-2.6-flare",
+ "codex/gpt-image-2.6-flare",
+ "gpt-image-2.6-sunburst",
+ "codex/gpt-image-2.6-sunburst",
+ "gpt-image-2.6",
+ "codex/gpt-image-2.6",
"grok-imagine-image",
"xai/grok-imagine-image",
"grok-imagine-image-quality",
@@ -248,6 +260,12 @@ func TestIsOpenAIImageOnlyModel(t *testing.T) {
{model: "codex/gpt-image-2.5-sunburst", want: true},
{model: "gpt-image-2.5", want: true},
{model: "codex/gpt-image-2.5", want: true},
+ {model: "gpt-image-2.6-flare", want: true},
+ {model: "codex/gpt-image-2.6-flare", want: true},
+ {model: "gpt-image-2.6-sunburst", want: true},
+ {model: "codex/gpt-image-2.6-sunburst", want: true},
+ {model: "gpt-image-2.6", want: true},
+ {model: "codex/gpt-image-2.6", want: true},
{model: "grok-imagine-image", want: true},
{model: "xai/grok-imagine-image", want: true},
{model: "XAI/Grok-Imagine-Image-Quality", want: true},
@@ -276,6 +294,9 @@ func TestExecuteImageWithAuthManager_AllowsImageOnlyModels(t *testing.T) {
"gpt-image-2.5-flare",
"gpt-image-2.5-sunburst",
"gpt-image-2.5",
+ "gpt-image-2.6-flare",
+ "gpt-image-2.6-sunburst",
+ "gpt-image-2.6",
"grok-imagine-image",
"grok-imagine-image-quality",
"xai/grok-imagine-image-quality",
diff --git a/sdk/api/handlers/handlers_routing.go b/sdk/api/handlers/handlers_routing.go
index e4e03f017fd..671aa5118da 100644
--- a/sdk/api/handlers/handlers_routing.go
+++ b/sdk/api/handlers/handlers_routing.go
@@ -6,7 +6,7 @@ import (
"net/http"
"strings"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
. "github.com/router-for-me/CLIProxyAPI/v8/internal/constant"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
@@ -217,7 +217,7 @@ func (h *BaseAPIHandler) getRequestDetailsWithOptions(modelName string, allowIma
// than formatted into the JSON literal: an unescaped quote would otherwise
// corrupt the body or let the caller overwrite the error code.
body := `{"error":{"message":"","type":"invalid_request_error","code":"model_not_found","param":"model"}}`
- body, errSet := sjson.Set(body, "error.message", "unknown provider for model "+modelName)
+ body, errSet := ojson.Set(body, "error.message", "unknown provider for model "+modelName)
if errSet != nil {
body = `{"error":{"message":"unknown provider for model","type":"invalid_request_error","code":"model_not_found","param":"model"}}`
}
@@ -248,7 +248,7 @@ func (h *BaseAPIHandler) validateImageOnlyModel(modelName string, allowImageMode
func isOpenAIImageOnlyModel(model string) bool {
switch strings.ToLower(strings.TrimSpace(routeModelBaseName(model))) {
- case "gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "grok-imagine-image", "grok-imagine-image-quality", "grok-imagine-image-2.0":
+ case "gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "gpt-image-2.6-flare", "gpt-image-2.6-sunburst", "gpt-image-2.6", "grok-imagine-image", "grok-imagine-image-quality", "grok-imagine-image-2.0":
return true
default:
return false
diff --git a/sdk/api/handlers/handlers_stream.go b/sdk/api/handlers/handlers_stream.go
index 86a30602308..e04fe3bb043 100644
--- a/sdk/api/handlers/handlers_stream.go
+++ b/sdk/api/handlers/handlers_stream.go
@@ -23,6 +23,56 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
return h.executeStreamWithAuthManager(ctx, handlerType, modelName, rawJSON, alt, false)
}
+// HasAlternativeExecutionTarget reports whether an alternative credential other
+// than excludedAuthID can serve a replayed request for modelName.
+func (h *BaseAPIHandler) HasAlternativeExecutionTarget(ctx context.Context, handlerType, modelName string, rawJSON []byte, excludedAuthID string) bool {
+ if h == nil || h.AuthManager == nil {
+ return false
+ }
+ originalRequestedModel := modelName
+ execOptions := modelExecutionOptions{
+ AllowSpeechModel: isModelExecutionSpeechProtocol(handlerType),
+ }
+ routeDecision, preparedRoute := preparedModelRouteFromContext(ctx, "")
+ if !preparedRoute {
+ routeDecision = h.applyModelRouter(ctx, handlerType, modelName, rawJSON, true, execOptions)
+ }
+ if routeDecision.ExecutorPluginID != "" {
+ return false
+ }
+ providers, normalizedModel, errMsg := h.providersForExecution(modelName, originalRequestedModel, false, routeDecision, execOptions)
+ if errMsg != nil {
+ return false
+ }
+ providers = adjustExecutionProvidersForEntryProtocol(handlerType, providers)
+ reqMeta := requestExecutionMetadata(ctx)
+ delete(reqMeta, coreexecutor.PinnedAuthMetadataKey)
+ delete(reqMeta, coreexecutor.ExecutionSessionMetadataKey)
+ delete(reqMeta, coreexecutor.SelectedAuthCallbackMetadataKey)
+ delete(reqMeta, coreexecutor.FinalizedResponseFormatCallbackMetadataKey)
+ reqMeta[coreexecutor.RequestedModelMetadataKey] = originalRequestedModel
+ addModelExecutionSourceMetadata(reqMeta, false)
+ setReasoningEffortMetadata(reqMeta, handlerType, normalizedModel, rawJSON)
+ setServiceTierMetadata(reqMeta, rawJSON)
+ setGenerateMetadata(reqMeta, rawJSON)
+ originalRequest := rawJSON
+ if ctxOriginal := originalRequestFromContext(ctx); len(ctxOriginal) > 0 {
+ originalRequest = ctxOriginal
+ }
+ responseProtocol := modelExecutionResponseProtocol(handlerType, handlerType)
+ opts := coreexecutor.Options{
+ Stream: true,
+ OriginalRequest: originalRequest,
+ SourceFormat: sdktranslator.FromString(handlerType),
+ ResponseFormat: sdktranslator.FromString(responseProtocol),
+ Headers: modelExecutionHeaders(ctx, nil),
+ Query: modelExecutionQuery(ctx, nil),
+ Metadata: reqMeta,
+ }
+ ctx = enrichContextWithSessionHierarchy(ctx, opts.Headers, rawJSON, opts.Metadata)
+ return h.AuthManager.HasAlternativeTargetForModel(ctx, providers, normalizedModel, opts, excludedAuthID)
+}
+
// ExecuteImageStreamWithAuthManager executes a streaming OpenAI-compatible image endpoint request.
func (h *BaseAPIHandler) ExecuteImageStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, http.Header, <-chan *interfaces.ErrorMessage) {
return h.executeStreamWithAuthManager(ctx, handlerType, modelName, rawJSON, alt, true)
@@ -67,6 +117,7 @@ func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProt
if !execOptions.InternalSource {
reporter = helps.NewUsageReporter(execCtx, executorPluginID, modelName, nil)
reporter.SetTranslatedReasoningEffort(req.Payload, entryProtocol)
+ reporter.StartResponseTTFT()
}
streamResult, errStream := host.ExecutePluginExecutorStream(execCtx, executorPluginID, req, opts)
if errStream != nil {
@@ -206,6 +257,7 @@ func (h *BaseAPIHandler) streamWithPluginExecutor(ctx context.Context, entryProt
if len(chunk.Payload) == 0 {
continue
}
+ observePluginExecutorStreamTTFT(responseProtocol, reporter, chunk.Payload)
observePluginExecutorStreamUsage(responseProtocol, chunk.Payload, &streamUsage)
payload := cloneBytes(chunk.Payload)
if streamInterceptorsActive {
diff --git a/sdk/api/handlers/openai/issue6332_review7_test.go b/sdk/api/handlers/openai/issue6332_review7_test.go
index 5aefd6711de..2897439e201 100644
--- a/sdk/api/handlers/openai/issue6332_review7_test.go
+++ b/sdk/api/handlers/openai/issue6332_review7_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -22,7 +23,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/sjson"
)
const issue6332ReviewModel = "gemini-3.7-flash"
@@ -214,8 +214,10 @@ func testIssue6332LifecycleNextTurn(t *testing.T, split bool) {
}
manager.SetRoundTripperProvider(rt)
body := issue6332ReviewBody(t)
- second, _ := sjson.SetBytes(body, "input.-1", map[string]any{"type": "function_call", "call_id": "native-call", "name": "lookup", "arguments": `{"key":"value"}`})
- second, _ = sjson.SetBytes(second, "input.-1", map[string]any{"type": "function_call_output", "call_id": "native-call", "output": "found"})
+ secondDoc := ojson.NewDoc(body)
+ _ = secondDoc.Set("input.-1", map[string]any{"type": "function_call", "call_id": "native-call", "name": "lookup", "arguments": `{"key":"value"}`})
+ _ = secondDoc.Set("input.-1", map[string]any{"type": "function_call_output", "call_id": "native-call", "output": "found"})
+ second := secondDoc.Bytes()
host := &issue6332CallbackHost{base: base, body: second, result: make(chan error, 1)}
base.SetPluginHost(host)
capture := &issue6332Capture{records: make(chan usage.Record, 4), authID: t.Name()}
diff --git a/sdk/api/handlers/openai/issue6332_test.go b/sdk/api/handlers/openai/issue6332_test.go
index da0bc43c3a8..d565a976281 100644
--- a/sdk/api/handlers/openai/issue6332_test.go
+++ b/sdk/api/handlers/openai/issue6332_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/api/middleware"
"github.com/router-for-me/CLIProxyAPI/v8/internal/cache"
@@ -25,8 +26,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type issue6332Capture struct {
@@ -352,12 +351,12 @@ func TestIssue6332ReasoningReplayTwoTurns(t *testing.T) {
if !ok || len(items) == 0 {
t.Error("complete upstream output did not commit reasoning replay")
}
- var functionCall gjson.Result
+ var functionCall ojson.Result
for _, line := range strings.Split(downstream, "\n") {
if !strings.HasPrefix(line, "data:") {
continue
}
- event := gjson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
+ event := ojson.Parse(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
if event.Get("type").String() == "response.completed" {
for _, item := range event.Get("response.output").Array() {
if item.Get("type").String() == "function_call" {
@@ -370,12 +369,14 @@ func TestIssue6332ReasoningReplayTwoTurns(t *testing.T) {
t.Fatalf("no completed tool call: %s", downstream)
}
// Deliberately omit encrypted reasoning: restoration must come from the session ledger.
- second, _ := sjson.Set(first, "input.-1", map[string]any{"type": "function_call", "call_id": functionCall.Get("call_id").String(), "name": "lookup", "arguments": `{"key":"value"}`})
- second, _ = sjson.Set(second, "input.-1", map[string]any{"type": "function_call_output", "call_id": functionCall.Get("call_id").String(), "output": "found"})
+ secondDoc := ojson.NewDocString(first)
+ _ = secondDoc.Set("input.-1", map[string]any{"type": "function_call", "call_id": functionCall.Get("call_id").String(), "name": "lookup", "arguments": `{"key":"value"}`})
+ _ = secondDoc.Set("input.-1", map[string]any{"type": "function_call_output", "call_id": functionCall.Get("call_id").String(), "output": "found"})
+ second := secondDoc.String()
call(second)
upstream := <-requests
restored := false
- for _, content := range gjson.GetBytes(upstream, "request.contents").Array() {
+ for _, content := range ojson.GetBytesNoCopy(upstream, "request.contents").Array() {
for _, part := range content.Get("parts").Array() {
if part.Get("functionCall.name").String() == "lookup" && part.Get("thoughtSignature").String() == signature {
restored = true
diff --git a/sdk/api/handlers/openai/openai_handlers.go b/sdk/api/handlers/openai/openai_handlers.go
index 33ca653f9d2..257251d8a1c 100644
--- a/sdk/api/handlers/openai/openai_handlers.go
+++ b/sdk/api/handlers/openai/openai_handlers.go
@@ -12,8 +12,10 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
codexlive "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/live"
codexmodels "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/models"
@@ -22,10 +24,9 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
responsesconverter "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/openai/openai/responses"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// OpenAIAPIHandler contains the handlers for OpenAI API endpoints.
@@ -146,15 +147,15 @@ func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
}
// Check if the client requested a streaming response.
- streamResult := gjson.GetBytes(rawJSON, "stream")
- stream := streamResult.Type == gjson.True
+ streamResult := ojson.GetBytesNoCopy(rawJSON, "stream")
+ stream := streamResult.Type == ojson.True
// Some clients send OpenAI Responses-format payloads to /v1/chat/completions.
// Convert them to Chat Completions so downstream translators preserve tool metadata.
if shouldTreatAsResponsesFormat(rawJSON) {
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
rawJSON, _ = responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName, rawJSON, stream)
- stream = gjson.GetBytes(rawJSON, "stream").Bool()
+ stream = ojson.GetBytesNoCopy(rawJSON, "stream").Bool()
}
if stream {
@@ -168,16 +169,12 @@ func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
// shouldTreatAsResponsesFormat detects OpenAI Responses-style payloads that are
// accidentally sent to the Chat Completions endpoint.
func shouldTreatAsResponsesFormat(rawJSON []byte) bool {
- if gjson.GetBytes(rawJSON, "messages").Exists() {
+ var fieldsBuf [3]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "messages", "input", "instructions")
+ if fields[0].Exists() {
return false
}
- if gjson.GetBytes(rawJSON, "input").Exists() {
- return true
- }
- if gjson.GetBytes(rawJSON, "instructions").Exists() {
- return true
- }
- return false
+ return fields[1].Exists() || fields[2].Exists()
}
// Completions handles the /v1/completions endpoint.
@@ -201,8 +198,8 @@ func (h *OpenAIAPIHandler) Completions(c *gin.Context) {
}
// Check if the client requested a streaming response.
- streamResult := gjson.GetBytes(rawJSON, "stream")
- if streamResult.Type == gjson.True {
+ streamResult := ojson.GetBytesNoCopy(rawJSON, "stream")
+ if streamResult.Type == ojson.True {
h.handleCompletionsStreamingResponse(c, rawJSON)
} else {
h.handleCompletionsNonStreamingResponse(c, rawJSON)
@@ -219,7 +216,7 @@ func (h *OpenAIAPIHandler) Completions(c *gin.Context) {
// Returns:
// - []byte: The converted chat completions request
func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Extract prompt from completions request
prompt := root.Get("prompt").String()
@@ -228,58 +225,58 @@ func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
}
// Create chat completions structure
- out := []byte(`{"model":"","messages":[{"role":"user","content":""}]}`)
+ outDoc := ojson.NewDoc([]byte(`{"model":"","messages":[{"role":"user","content":""}]}`))
// Set model
if model := root.Get("model"); model.Exists() {
- out, _ = sjson.SetBytes(out, "model", model.String())
+ _ = outDoc.Set("model", model.String())
}
// Set the prompt as user message content
- out, _ = sjson.SetBytes(out, "messages.0.content", prompt)
+ _ = outDoc.Set("messages.0.content", prompt)
// Copy other parameters from completions to chat completions
if maxTokens := root.Get("max_tokens"); maxTokens.Exists() {
- out, _ = sjson.SetBytes(out, "max_tokens", maxTokens.Int())
+ _ = outDoc.Set("max_tokens", maxTokens.Int())
}
if temperature := root.Get("temperature"); temperature.Exists() {
- out, _ = sjson.SetBytes(out, "temperature", temperature.Float())
+ _ = outDoc.Set("temperature", temperature.Float())
}
if topP := root.Get("top_p"); topP.Exists() {
- out, _ = sjson.SetBytes(out, "top_p", topP.Float())
+ _ = outDoc.Set("top_p", topP.Float())
}
if frequencyPenalty := root.Get("frequency_penalty"); frequencyPenalty.Exists() {
- out, _ = sjson.SetBytes(out, "frequency_penalty", frequencyPenalty.Float())
+ _ = outDoc.Set("frequency_penalty", frequencyPenalty.Float())
}
if presencePenalty := root.Get("presence_penalty"); presencePenalty.Exists() {
- out, _ = sjson.SetBytes(out, "presence_penalty", presencePenalty.Float())
+ _ = outDoc.Set("presence_penalty", presencePenalty.Float())
}
if stop := root.Get("stop"); stop.Exists() {
- out, _ = sjson.SetRawBytes(out, "stop", []byte(stop.Raw))
+ _ = outDoc.SetRawBytes("stop", []byte(stop.Raw))
}
if stream := root.Get("stream"); stream.Exists() {
- out, _ = sjson.SetBytes(out, "stream", stream.Bool())
+ _ = outDoc.Set("stream", stream.Bool())
}
if logprobs := root.Get("logprobs"); logprobs.Exists() {
- out, _ = sjson.SetBytes(out, "logprobs", logprobs.Bool())
+ _ = outDoc.Set("logprobs", logprobs.Bool())
}
if topLogprobs := root.Get("top_logprobs"); topLogprobs.Exists() {
- out, _ = sjson.SetBytes(out, "top_logprobs", topLogprobs.Int())
+ _ = outDoc.Set("top_logprobs", topLogprobs.Int())
}
if echo := root.Get("echo"); echo.Exists() {
- out, _ = sjson.SetBytes(out, "echo", echo.Bool())
+ _ = outDoc.Set("echo", echo.Bool())
}
- return out
+ return outDoc.Bytes()
}
// convertChatCompletionsResponseToCompletions converts chat completions API response back to completions format.
@@ -291,32 +288,32 @@ func convertCompletionsRequestToChatCompletions(rawJSON []byte) []byte {
// Returns:
// - []byte: The converted completions response
func convertChatCompletionsResponseToCompletions(rawJSON []byte) []byte {
- root := gjson.ParseBytes(rawJSON)
+ root := util.ParseGJSONBytesNoCopy(rawJSON)
// Base completions response structure
- out := []byte(`{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`)
+ outDoc := ojson.NewDoc([]byte(`{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`))
// Copy basic fields
if id := root.Get("id"); id.Exists() {
- out, _ = sjson.SetBytes(out, "id", id.String())
+ _ = outDoc.Set("id", id.String())
}
if created := root.Get("created"); created.Exists() {
- out, _ = sjson.SetBytes(out, "created", created.Int())
+ _ = outDoc.Set("created", created.Int())
}
if model := root.Get("model"); model.Exists() {
- out, _ = sjson.SetBytes(out, "model", model.String())
+ _ = outDoc.Set("model", model.String())
}
if usage := root.Get("usage"); usage.Exists() {
- out, _ = sjson.SetRawBytes(out, "usage", []byte(usage.Raw))
+ _ = outDoc.SetRawBytes("usage", []byte(usage.Raw))
}
// Convert choices from chat completions to completions format
var choices []interface{}
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
- chatChoices.ForEach(func(_, choice gjson.Result) bool {
+ chatChoices.ForEach(func(_, choice ojson.Result) bool {
completionsChoice := map[string]interface{}{
"index": choice.Get("index").Int(),
}
@@ -350,10 +347,10 @@ func convertChatCompletionsResponseToCompletions(rawJSON []byte) []byte {
if len(choices) > 0 {
choicesJSON, _ := json.Marshal(choices)
- out, _ = sjson.SetRawBytes(out, "choices", choicesJSON)
+ _ = outDoc.SetRawBytes("choices", choicesJSON)
}
- return out
+ return outDoc.Bytes()
}
// convertChatCompletionsStreamChunkToCompletions converts a streaming chat completions chunk to completions format.
@@ -365,13 +362,13 @@ func convertChatCompletionsResponseToCompletions(rawJSON []byte) []byte {
// Returns:
// - []byte: The converted completions stream chunk, or nil if should be filtered out
func convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {
- root := gjson.ParseBytes(chunkData)
+ root := util.ParseGJSONBytesNoCopy(chunkData)
// Check if this chunk has any meaningful content
hasContent := false
hasUsage := root.Get("usage").Exists()
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
- chatChoices.ForEach(func(_, choice gjson.Result) bool {
+ chatChoices.ForEach(func(_, choice ojson.Result) bool {
// Check if delta has content or finish_reason
if delta := choice.Get("delta"); delta.Exists() {
if content := delta.Get("content"); content.Exists() && content.String() != "" {
@@ -394,25 +391,25 @@ func convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {
}
// Base completions stream response structure
- out := []byte(`{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`)
+ outDoc := ojson.NewDoc([]byte(`{"id":"","object":"text_completion","created":0,"model":"","choices":[]}`))
// Copy basic fields
if id := root.Get("id"); id.Exists() {
- out, _ = sjson.SetBytes(out, "id", id.String())
+ _ = outDoc.Set("id", id.String())
}
if created := root.Get("created"); created.Exists() {
- out, _ = sjson.SetBytes(out, "created", created.Int())
+ _ = outDoc.Set("created", created.Int())
}
if model := root.Get("model"); model.Exists() {
- out, _ = sjson.SetBytes(out, "model", model.String())
+ _ = outDoc.Set("model", model.String())
}
// Convert choices from chat completions delta to completions format
var choices []interface{}
if chatChoices := root.Get("choices"); chatChoices.Exists() && chatChoices.IsArray() {
- chatChoices.ForEach(func(_, choice gjson.Result) bool {
+ chatChoices.ForEach(func(_, choice ojson.Result) bool {
completionsChoice := map[string]interface{}{
"index": choice.Get("index").Int(),
}
@@ -445,15 +442,15 @@ func convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {
if len(choices) > 0 {
choicesJSON, _ := json.Marshal(choices)
- out, _ = sjson.SetRawBytes(out, "choices", choicesJSON)
+ _ = outDoc.SetRawBytes("choices", choicesJSON)
}
// Copy usage if present
if usage := root.Get("usage"); usage.Exists() {
- out, _ = sjson.SetRawBytes(out, "usage", []byte(usage.Raw))
+ _ = outDoc.SetRawBytes("usage", []byte(usage.Raw))
}
- return out
+ return outDoc.Bytes()
}
// handleNonStreamingResponse handles non-streaming chat completion responses
@@ -466,7 +463,7 @@ func convertChatCompletionsStreamChunkToCompletions(chunkData []byte) []byte {
func (h *OpenAIAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
c.Header("Content-Type", "application/json")
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, h.GetAlt(c))
@@ -501,7 +498,7 @@ func (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byt
return
}
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
setSSEHeaders := func() {
c.Header("Content-Type", "text/event-stream")
@@ -595,10 +592,14 @@ func (h *OpenAIAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON []byt
stopKeepAlive()
commitSSE()
- initialHasFinishReason := chunkHasFinishReason(chunk)
+ termErr := chunkTerminalError(chunk)
+ if termErr != nil {
+ h.LoggingAPIResponseError(c, termErr)
+ }
+ initialHasFinishReason := termErr != nil || chunkHasFinishReason(chunk)
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunk))
flusher.Flush()
- if termErr := chunkTerminalError(chunk); termErr != nil {
+ if termErr != nil {
cliCancel(termErr.Error)
return
}
@@ -625,7 +626,7 @@ func (h *OpenAIAPIHandler) handleCompletionsNonStreamingResponse(c *gin.Context,
// Convert completions request to chat completions format
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
- modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(chatCompletionsJSON, "model").String())
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, chatCompletionsJSON, "")
@@ -664,7 +665,7 @@ func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, ra
// Convert completions request to chat completions format
chatCompletionsJSON := convertCompletionsRequestToChatCompletions(rawJSON)
- modelName := gjson.GetBytes(chatCompletionsJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(chatCompletionsJSON, "model").String())
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
setSSEHeaders := func() {
c.Header("Content-Type", "text/event-stream")
@@ -756,6 +757,14 @@ func (h *OpenAIAPIHandler) handleCompletionsStreamingResponse(c *gin.Context, ra
stopKeepAlive()
commitSSE()
+ if termErr := chunkTerminalError(chunk); termErr != nil {
+ h.LoggingAPIResponseError(c, termErr)
+ _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunk))
+ flusher.Flush()
+ cliCancel(termErr.Error)
+ return
+ }
+
// Write the first chunk
converted := convertChatCompletionsStreamChunkToCompletions(chunk)
var initialHasFinishReason bool
@@ -808,10 +817,12 @@ func chunkTerminalError(chunk []byte) *interfaces.ErrorMessage {
if bytes.HasPrefix(chunk, []byte("data:")) {
chunk = bytes.TrimSpace(chunk[5:])
}
- if gjson.GetBytes(chunk, "choices").Exists() || !gjson.GetBytes(chunk, "error").IsObject() {
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], chunk, "choices", "error")
+ if fields[0].Exists() || !fields[1].IsObject() {
return nil
}
- msg := gjson.GetBytes(chunk, "error.message").String()
+ msg := fields[1].Get("message").String()
if msg == "" {
msg = string(chunk)
}
@@ -829,8 +840,8 @@ func chunkHasFinishReason(chunk []byte) bool {
if chunkTerminalError(chunk) != nil {
return true
}
- for _, choice := range gjson.GetBytes(chunk, "choices").Array() {
- if reason := choice.Get("finish_reason"); reason.Exists() && reason.Type != gjson.Null && reason.String() != "" {
+ for _, choice := range ojson.GetBytesNoCopy(chunk, "choices").Array() {
+ if reason := choice.Get("finish_reason"); reason.Exists() && reason.Type != ojson.Null && reason.String() != "" {
return true
}
}
@@ -839,22 +850,38 @@ func chunkHasFinishReason(chunk []byte) bool {
func (h *OpenAIAPIHandler) handleStreamResult(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage, initialHasFinishReason bool) {
sawFinishReason := initialHasFinishReason
+ loggedTerminalErr := false
var terminalChunkErr *interfaces.ErrorMessage
h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{
WriteChunk: func(chunk []byte) {
if termErr := chunkTerminalError(chunk); termErr != nil {
terminalChunkErr = termErr
sawFinishReason = true
+ if !loggedTerminalErr {
+ h.LoggingAPIResponseError(c, termErr)
+ loggedTerminalErr = true
+ }
} else if !sawFinishReason && chunkHasFinishReason(chunk) {
sawFinishReason = true
}
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(chunk))
},
ChunkError: func() *interfaces.ErrorMessage {
+ if terminalChunkErr != nil && !loggedTerminalErr {
+ h.LoggingAPIResponseError(c, terminalChunkErr)
+ loggedTerminalErr = true
+ }
return terminalChunkErr
},
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
- if errMsg == nil || terminalChunkErr != nil {
+ if errMsg == nil {
+ return
+ }
+ if !loggedTerminalErr {
+ h.LoggingAPIResponseError(c, errMsg)
+ loggedTerminalErr = true
+ }
+ if terminalChunkErr != nil {
return
}
status := http.StatusInternalServerError
diff --git a/sdk/api/handlers/openai/openai_handlers_stream_error_test.go b/sdk/api/handlers/openai/openai_handlers_stream_error_test.go
index eda9706fb31..c1ecb49e049 100644
--- a/sdk/api/handlers/openai/openai_handlers_stream_error_test.go
+++ b/sdk/api/handlers/openai/openai_handlers_stream_error_test.go
@@ -242,3 +242,38 @@ func TestCompletionsHandlerSendKeepAliveBeforeFTWritesSSEErrorOnFailure(t *testi
t.Fatalf("missing SSE JSON error payload after keepalive: %q", body)
}
}
+
+func TestChatCompletionsHandlerRecordsAPIResponseErrorOnInitialEmbeddedErrorChunk(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"error":{"message":"blocked by cyber_policy","type":"invalid_request_error","code":"cyber_policy"}}`)}
+ close(chunks)
+
+ executor := &preFTKeepAliveChatExecutor{chunks: chunks}
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.RegisterExecutor(executor)
+ model := "initial-embedded-error-chat-model"
+ auth := &coreauth.Auth{ID: "initial-embedded-error-chat-auth", Provider: executor.Identifier(), Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager)
+ h := NewOpenAIAPIHandler(base)
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"`+model+`","messages":[{"role":"user","content":"hi"}],"stream":true}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ h.ChatCompletions(c)
+
+ rawErrs, exists := c.Get("API_RESPONSE_ERROR")
+ if !exists || rawErrs == nil {
+ t.Fatalf("expected API_RESPONSE_ERROR to be recorded on initial embedded error chunk, body=%q", recorder.Body.String())
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_images_handlers.go b/sdk/api/handlers/openai/openai_images_handlers.go
index c01d5c8a350..d70cc841404 100644
--- a/sdk/api/handlers/openai/openai_images_handlers.go
+++ b/sdk/api/handlers/openai/openai_images_handlers.go
@@ -3,9 +3,9 @@ package openai
import (
"bytes"
"context"
- "encoding/base64"
"encoding/json"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"mime/multipart"
"net/http"
@@ -14,6 +14,7 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -21,8 +22,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -32,6 +31,9 @@ const (
gptImage25FlareModel = "gpt-image-2.5-flare"
gptImage25SunburstModel = "gpt-image-2.5-sunburst"
gptImage25Model = "gpt-image-2.5"
+ gptImage26FlareModel = "gpt-image-2.6-flare"
+ gptImage26SunburstModel = "gpt-image-2.6-sunburst"
+ gptImage26Model = "gpt-image-2.6"
defaultXAIImagesModel = "grok-imagine-image"
xaiImagesQualityModel = "grok-imagine-image-quality"
xaiImages20Model = "grok-imagine-image-2.0"
@@ -286,7 +288,7 @@ func isSupportedImagesModel(model string) bool {
func isCodexImagesToolModel(model string) bool {
switch imagesModelBase(model) {
- case gptImage15Model, defaultImagesToolModel, gptImage25FlareModel, gptImage25SunburstModel, gptImage25Model:
+ case gptImage15Model, defaultImagesToolModel, gptImage25FlareModel, gptImage25SunburstModel, gptImage25Model, gptImage26FlareModel, gptImage26SunburstModel, gptImage26Model:
return true
default:
return false
@@ -309,7 +311,7 @@ func rejectUnsupportedImagesModel(c *gin.Context, model string) bool {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
- Message: fmt.Sprintf("Model %s is not supported on %s or %s. Use %s, %s, %s, %s, %s, %s, %s, %s, or a configured openai-compatibility image model.", model, imagesGenerationsPath, imagesEditsPath, gptImage15Model, defaultImagesToolModel, gptImage25FlareModel, gptImage25SunburstModel, gptImage25Model, defaultXAIImagesModel, xaiImagesQualityModel, xaiImages20Model),
+ Message: fmt.Sprintf("Model %s is not supported on %s or %s. Use %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, or a configured openai-compatibility image model.", model, imagesGenerationsPath, imagesEditsPath, gptImage15Model, defaultImagesToolModel, gptImage25FlareModel, gptImage25SunburstModel, gptImage25Model, gptImage26FlareModel, gptImage26SunburstModel, gptImage26Model, defaultXAIImagesModel, xaiImagesQualityModel, xaiImages20Model),
Type: "invalid_request_error",
},
})
@@ -422,7 +424,7 @@ func xaiImagesResolution(raw string, size string, fallback string) string {
func xaiImagesRef(imageURL string) []byte {
ref := []byte(`{"type":"image_url","url":""}`)
- ref, _ = sjson.SetBytes(ref, "url", strings.TrimSpace(imageURL))
+ ref, _ = ojson.SetBytes(ref, "url", strings.TrimSpace(imageURL))
return ref
}
@@ -430,61 +432,69 @@ func buildXAIImagesBaseRequest(model string, prompt string, responseFormat strin
return buildXAIImagesOriginalBaseRequest(model, prompt, normalizeImagesResponseFormat(responseFormat), aspectRatio, resolution, quality, n)
}
-func buildXAIImagesOriginalBaseRequest(model string, prompt string, responseFormat string, aspectRatio string, resolution string, quality string, n int64) []byte {
- req := []byte(`{}`)
- req, _ = sjson.SetBytes(req, "model", canonicalXAIImagesModel(model))
- req, _ = sjson.SetBytes(req, "prompt", strings.TrimSpace(prompt))
+func buildXAIImagesOriginalBaseRequestDoc(model string, prompt string, responseFormat string, aspectRatio string, resolution string, quality string, n int64) *ojson.Doc {
+ doc := ojson.NewDoc([]byte(`{}`))
+ _ = doc.Set("model", canonicalXAIImagesModel(model))
+ _ = doc.Set("prompt", strings.TrimSpace(prompt))
if rf := strings.TrimSpace(responseFormat); rf != "" {
- req, _ = sjson.SetBytes(req, "response_format", normalizeImagesResponseFormat(rf))
+ _ = doc.Set("response_format", normalizeImagesResponseFormat(rf))
}
if aspectRatio != "" {
- req, _ = sjson.SetBytes(req, "aspect_ratio", aspectRatio)
+ _ = doc.Set("aspect_ratio", aspectRatio)
}
if resolution != "" {
- req, _ = sjson.SetBytes(req, "resolution", resolution)
+ _ = doc.Set("resolution", resolution)
}
if q := strings.TrimSpace(quality); q != "" {
- req, _ = sjson.SetBytes(req, "quality", q)
+ _ = doc.Set("quality", q)
}
if n > 0 {
- req, _ = sjson.SetBytes(req, "n", n)
+ _ = doc.Set("n", n)
}
- return req
+ return doc
+}
+
+func buildXAIImagesOriginalBaseRequest(model string, prompt string, responseFormat string, aspectRatio string, resolution string, quality string, n int64) []byte {
+ return buildXAIImagesOriginalBaseRequestDoc(model, prompt, responseFormat, aspectRatio, resolution, quality, n).Bytes()
}
func buildXAIImagesGenerationsRequest(rawJSON []byte, model string, responseFormat string) []byte {
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
- size := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String())
- aspectRatio := xaiImagesAspectRatio(gjson.GetBytes(rawJSON, "aspect_ratio").String(), "")
+ var fieldsBuf [6]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "prompt", "size", "aspect_ratio", "resolution", "quality", "n")
+ prompt := strings.TrimSpace(fields[0].String())
+ size := strings.TrimSpace(fields[1].String())
+ aspectRatio := xaiImagesAspectRatio(fields[2].String(), "")
aspectRatio = xaiImagesAspectRatioFromSize(size, aspectRatio)
if aspectRatio == "" {
aspectRatio = xaiImagesDefaultAspectRatio
}
- resolution := xaiImagesResolution(gjson.GetBytes(rawJSON, "resolution").String(), size, xaiImagesDefaultResolution)
- quality := strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String())
+ resolution := xaiImagesResolution(fields[3].String(), size, xaiImagesDefaultResolution)
+ quality := strings.TrimSpace(fields[4].String())
n := int64(0)
- if v := gjson.GetBytes(rawJSON, "n"); v.Exists() && v.Type == gjson.Number {
+ if v := fields[5]; v.Exists() && v.Type == ojson.Number {
n = v.Int()
}
return buildXAIImagesBaseRequest(model, prompt, responseFormat, aspectRatio, resolution, quality, n)
}
func buildXAIImagesGenerationsOriginalRequest(rawJSON []byte, model string) []byte {
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
- responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String())
- size := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String())
- aspectRatio := xaiImagesAspectRatio(gjson.GetBytes(rawJSON, "aspect_ratio").String(), "")
+ var fieldsBuf [7]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "prompt", "response_format", "size", "aspect_ratio", "resolution", "quality", "n")
+ prompt := strings.TrimSpace(fields[0].String())
+ responseFormat := strings.TrimSpace(fields[1].String())
+ size := strings.TrimSpace(fields[2].String())
+ aspectRatio := xaiImagesAspectRatio(fields[3].String(), "")
aspectRatio = xaiImagesAspectRatioFromSize(size, aspectRatio)
- resolution := xaiImagesResolution(gjson.GetBytes(rawJSON, "resolution").String(), size, "")
- quality := strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String())
+ resolution := xaiImagesResolution(fields[4].String(), size, "")
+ quality := strings.TrimSpace(fields[5].String())
n := int64(0)
- if v := gjson.GetBytes(rawJSON, "n"); v.Exists() && v.Type == gjson.Number {
+ if v := fields[6]; v.Exists() && v.Type == ojson.Number {
n = v.Int()
}
return buildXAIImagesOriginalBaseRequest(model, prompt, responseFormat, aspectRatio, resolution, quality, n)
}
-func appendXAIImagesEditRefs(req []byte, images []string) []byte {
+func appendXAIImagesEditRefsDoc(doc *ojson.Doc, images []string) {
trimmedImages := make([]string, 0, len(images))
for _, img := range images {
if strings.TrimSpace(img) != "" {
@@ -492,23 +502,30 @@ func appendXAIImagesEditRefs(req []byte, images []string) []byte {
}
}
if len(trimmedImages) == 1 {
- req, _ = sjson.SetRawBytes(req, "image", xaiImagesRef(trimmedImages[0]))
- return req
+ _ = doc.SetRawBytes("image", xaiImagesRef(trimmedImages[0]))
+ return
}
for _, img := range trimmedImages {
- req, _ = sjson.SetRawBytes(req, "images.-1", xaiImagesRef(img))
+ _ = doc.SetRawBytes("images.-1", xaiImagesRef(img))
}
- return req
+}
+
+func appendXAIImagesEditRefs(req []byte, images []string) []byte {
+ doc := ojson.NewDoc(req)
+ appendXAIImagesEditRefsDoc(doc, images)
+ return doc.Bytes()
}
func buildXAIImagesEditRequest(model string, prompt string, images []string, responseFormat string, aspectRatio string, resolution string, quality string, n int64) []byte {
- req := buildXAIImagesBaseRequest(model, prompt, responseFormat, aspectRatio, resolution, quality, n)
- return appendXAIImagesEditRefs(req, images)
+ doc := buildXAIImagesOriginalBaseRequestDoc(model, prompt, normalizeImagesResponseFormat(responseFormat), aspectRatio, resolution, quality, n)
+ appendXAIImagesEditRefsDoc(doc, images)
+ return doc.Bytes()
}
func buildXAIImagesEditOriginalRequest(model string, prompt string, images []string, responseFormat string, aspectRatio string, resolution string, quality string, n int64) []byte {
- req := buildXAIImagesOriginalBaseRequest(model, prompt, responseFormat, aspectRatio, resolution, quality, n)
- return appendXAIImagesEditRefs(req, images)
+ doc := buildXAIImagesOriginalBaseRequestDoc(model, prompt, responseFormat, aspectRatio, resolution, quality, n)
+ appendXAIImagesEditRefsDoc(doc, images)
+ return doc.Bytes()
}
func collectXAIImagesFromJSON(rawJSON []byte) []string {
@@ -520,25 +537,25 @@ func collectXAIImagesFromJSON(rawJSON []byte) []string {
}
}
- if image := gjson.GetBytes(rawJSON, "image"); image.Exists() {
- if image.Type == gjson.String {
+ if image := ojson.GetBytesNoCopy(rawJSON, "image"); image.Exists() {
+ if image.Type == ojson.String {
appendImage(image.String())
- } else if image.Type == gjson.JSON {
+ } else if image.Type == ojson.JSON {
appendImage(image.Get("image_url.url").String())
- if imageURL := image.Get("image_url"); imageURL.Type == gjson.String {
+ if imageURL := image.Get("image_url"); imageURL.Type == ojson.String {
appendImage(imageURL.String())
}
appendImage(image.Get("url").String())
}
}
- if imagesResult := gjson.GetBytes(rawJSON, "images"); imagesResult.IsArray() {
+ if imagesResult := ojson.GetBytesNoCopy(rawJSON, "images"); imagesResult.IsArray() {
for _, img := range imagesResult.Array() {
- if img.Type == gjson.String {
+ if img.Type == ojson.String {
appendImage(img.String())
continue
}
appendImage(img.Get("image_url.url").String())
- if imageURL := img.Get("image_url"); imageURL.Type == gjson.String {
+ if imageURL := img.Get("image_url"); imageURL.Type == ojson.String {
appendImage(imageURL.String())
}
appendImage(img.Get("url").String())
@@ -548,12 +565,14 @@ func collectXAIImagesFromJSON(rawJSON []byte) []string {
}
func xaiImagesEditOptionsFromJSON(rawJSON []byte) (aspectRatio string, resolution string, quality string, n int64) {
- size := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String())
- aspectRatio = xaiImagesAspectRatio(gjson.GetBytes(rawJSON, "aspect_ratio").String(), "")
+ var fieldsBuf [5]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "size", "aspect_ratio", "resolution", "quality", "n")
+ size := strings.TrimSpace(fields[0].String())
+ aspectRatio = xaiImagesAspectRatio(fields[1].String(), "")
aspectRatio = xaiImagesAspectRatioFromSize(size, aspectRatio)
- resolution = xaiImagesResolution(gjson.GetBytes(rawJSON, "resolution").String(), size, "")
- quality = strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String())
- if v := gjson.GetBytes(rawJSON, "n"); v.Exists() && v.Type == gjson.Number {
+ resolution = xaiImagesResolution(fields[2].String(), size, "")
+ quality = strings.TrimSpace(fields[3].String())
+ if v := fields[4]; v.Exists() && v.Type == ojson.Number {
n = v.Int()
}
return aspectRatio, resolution, quality, n
@@ -602,21 +621,20 @@ func multipartFileToDataURL(fileHeader *multipart.FileHeader) (string, error) {
mediaType = http.DetectContentType(data)
}
- b64 := base64.StdEncoding.EncodeToString(data)
- return "data:" + mediaType + ";base64," + b64, nil
+ return base64.EncodeDataURL(mediaType, data), nil
}
func buildOpenAICompatImagesJSONRequest(rawJSON []byte, imageModel string, stream bool) []byte {
- payload := rawJSON
+ doc := ojson.NewDoc(rawJSON)
if model := strings.TrimSpace(imageModel); model != "" {
- payload, _ = sjson.SetBytes(payload, "model", model)
+ _ = doc.Set("model", model)
}
if stream {
- payload, _ = sjson.SetBytes(payload, "stream", true)
+ _ = doc.Set("stream", true)
} else {
- payload, _ = sjson.DeleteBytes(payload, "stream")
+ _ = doc.Delete("stream")
}
- return payload
+ return doc.Bytes()
}
func cloneMIMEHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
@@ -745,7 +763,7 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
return
}
- imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ imageModel := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "model").String()))
if imageModel == "" {
imageModel = defaultImagesToolModel
}
@@ -753,7 +771,7 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
return
}
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ prompt := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "prompt").String())
if prompt == "" {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
@@ -764,11 +782,11 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
return
}
- responseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String())
+ responseFormat := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "response_format").String()))
if responseFormat == "" {
responseFormat = "b64_json"
}
- stream := gjson.GetBytes(rawJSON, "stream").Bool()
+ stream := ojson.GetBytesNoCopy(rawJSON, "stream").Bool()
if isCodexImagesToolModel(imageModel) {
imageReq := buildOpenAICompatImagesJSONRequest(rawJSON, imageModel, stream)
@@ -787,36 +805,36 @@ func (h *OpenAIAPIHandler) ImagesGenerations(c *gin.Context) {
return
}
- tool := []byte(`{"type":"image_generation","action":"generate"}`)
- tool, _ = sjson.SetBytes(tool, "model", imageModel)
+ toolDoc := ojson.NewDoc([]byte(`{"type":"image_generation","action":"generate"}`))
+ _ = toolDoc.Set("model", imageModel)
- if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "size").String()); v != "" {
- tool, _ = sjson.SetBytes(tool, "size", v)
+ if v := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "size").String()); v != "" {
+ _ = toolDoc.Set("size", v)
}
- if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "quality").String()); v != "" {
- tool, _ = sjson.SetBytes(tool, "quality", v)
+ if v := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "quality").String()); v != "" {
+ _ = toolDoc.Set("quality", v)
}
- if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "background").String()); v != "" {
- tool, _ = sjson.SetBytes(tool, "background", v)
+ if v := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "background").String()); v != "" {
+ _ = toolDoc.Set("background", v)
}
- if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "output_format").String()); v != "" {
- tool, _ = sjson.SetBytes(tool, "output_format", v)
+ if v := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "output_format").String()); v != "" {
+ _ = toolDoc.Set("output_format", v)
}
- if v := gjson.GetBytes(rawJSON, "output_compression"); v.Exists() {
- if v.Type == gjson.Number {
- tool, _ = sjson.SetBytes(tool, "output_compression", v.Int())
+ if v := ojson.GetBytesNoCopy(rawJSON, "output_compression"); v.Exists() {
+ if v.Type == ojson.Number {
+ _ = toolDoc.Set("output_compression", v.Int())
}
}
- if v := gjson.GetBytes(rawJSON, "partial_images"); v.Exists() {
- if v.Type == gjson.Number {
- tool, _ = sjson.SetBytes(tool, "partial_images", v.Int())
+ if v := ojson.GetBytesNoCopy(rawJSON, "partial_images"); v.Exists() {
+ if v.Type == ojson.Number {
+ _ = toolDoc.Set("partial_images", v.Int())
}
}
- if v := strings.TrimSpace(gjson.GetBytes(rawJSON, "moderation").String()); v != "" {
- tool, _ = sjson.SetBytes(tool, "moderation", v)
+ if v := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "moderation").String()); v != "" {
+ _ = toolDoc.Set("moderation", v)
}
- responsesReq := buildImagesResponsesRequest(prompt, nil, tool)
+ responsesReq := buildImagesResponsesRequest(prompt, nil, toolDoc.Bytes())
if stream {
h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_generation")
return
@@ -976,40 +994,40 @@ func (h *OpenAIAPIHandler) imagesEditsFromMultipart(c *gin.Context) {
maskDataURL = &dataURL
}
- tool := []byte(`{"type":"image_generation","action":"edit"}`)
- tool, _ = sjson.SetBytes(tool, "model", imageModel)
+ toolDoc := ojson.NewDoc([]byte(`{"type":"image_generation","action":"edit"}`))
+ _ = toolDoc.Set("model", imageModel)
if v := strings.TrimSpace(c.PostForm("size")); v != "" {
- tool, _ = sjson.SetBytes(tool, "size", v)
+ _ = toolDoc.Set("size", v)
}
if v := strings.TrimSpace(c.PostForm("quality")); v != "" {
- tool, _ = sjson.SetBytes(tool, "quality", v)
+ _ = toolDoc.Set("quality", v)
}
if v := strings.TrimSpace(c.PostForm("background")); v != "" {
- tool, _ = sjson.SetBytes(tool, "background", v)
+ _ = toolDoc.Set("background", v)
}
if v := strings.TrimSpace(c.PostForm("output_format")); v != "" {
- tool, _ = sjson.SetBytes(tool, "output_format", v)
+ _ = toolDoc.Set("output_format", v)
}
if v := strings.TrimSpace(c.PostForm("input_fidelity")); v != "" {
- tool, _ = sjson.SetBytes(tool, "input_fidelity", v)
+ _ = toolDoc.Set("input_fidelity", v)
}
if v := strings.TrimSpace(c.PostForm("moderation")); v != "" {
- tool, _ = sjson.SetBytes(tool, "moderation", v)
+ _ = toolDoc.Set("moderation", v)
}
if v := strings.TrimSpace(c.PostForm("output_compression")); v != "" {
- tool, _ = sjson.SetBytes(tool, "output_compression", parseIntField(v, 0))
+ _ = toolDoc.Set("output_compression", parseIntField(v, 0))
}
if v := strings.TrimSpace(c.PostForm("partial_images")); v != "" {
- tool, _ = sjson.SetBytes(tool, "partial_images", parseIntField(v, 0))
+ _ = toolDoc.Set("partial_images", parseIntField(v, 0))
}
if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" {
- tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL))
+ _ = toolDoc.Set("input_image_mask.image_url", strings.TrimSpace(*maskDataURL))
}
- responsesReq := buildImagesResponsesRequest(prompt, images, tool)
+ responsesReq := buildImagesResponsesRequest(prompt, images, toolDoc.Bytes())
if stream {
h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit")
return
@@ -1038,7 +1056,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
return
}
- imageModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ imageModel := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "model").String()))
if imageModel == "" {
imageModel = defaultImagesToolModel
}
@@ -1046,7 +1064,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
return
}
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ prompt := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "prompt").String())
if prompt == "" {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
@@ -1057,12 +1075,12 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
return
}
- rawResponseFormat := strings.TrimSpace(gjson.GetBytes(rawJSON, "response_format").String())
+ rawResponseFormat := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "response_format").String()))
responseFormat := rawResponseFormat
if responseFormat == "" {
responseFormat = "b64_json"
}
- stream := gjson.GetBytes(rawJSON, "stream").Bool()
+ stream := ojson.GetBytesNoCopy(rawJSON, "stream").Bool()
if isCodexImagesToolModel(imageModel) {
imageReq := buildOpenAICompatImagesJSONRequest(rawJSON, imageModel, stream)
@@ -1093,7 +1111,7 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
}
var images []string
- imagesResult := gjson.GetBytes(rawJSON, "images")
+ imagesResult := ojson.GetBytesNoCopy(rawJSON, "images")
if imagesResult.IsArray() {
for _, img := range imagesResult.Array() {
url := strings.TrimSpace(img.Get("image_url").String())
@@ -1114,12 +1132,12 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
}
var maskDataURL *string
- if mask := gjson.GetBytes(rawJSON, "mask.image_url"); mask.Exists() {
+ if mask := ojson.GetBytesNoCopy(rawJSON, "mask.image_url"); mask.Exists() {
url := strings.TrimSpace(mask.String())
if url != "" {
maskDataURL = &url
}
- } else if mask := gjson.GetBytes(rawJSON, "mask.file_id"); mask.Exists() {
+ } else if mask := ojson.GetBytesNoCopy(rawJSON, "mask.file_id"); mask.Exists() {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: "Invalid request: mask.file_id is not supported (use mask.image_url instead)",
@@ -1129,26 +1147,26 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
return
}
- tool := []byte(`{"type":"image_generation","action":"edit"}`)
- tool, _ = sjson.SetBytes(tool, "model", imageModel)
+ toolDoc := ojson.NewDoc([]byte(`{"type":"image_generation","action":"edit"}`))
+ _ = toolDoc.Set("model", imageModel)
for _, field := range []string{"size", "quality", "background", "output_format", "input_fidelity", "moderation"} {
- if v := strings.TrimSpace(gjson.GetBytes(rawJSON, field).String()); v != "" {
- tool, _ = sjson.SetBytes(tool, field, v)
+ if v := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, field).String()); v != "" {
+ _ = toolDoc.Set(field, v)
}
}
for _, field := range []string{"output_compression", "partial_images"} {
- if v := gjson.GetBytes(rawJSON, field); v.Exists() && v.Type == gjson.Number {
- tool, _ = sjson.SetBytes(tool, field, v.Int())
+ if v := ojson.GetBytesNoCopy(rawJSON, field); v.Exists() && v.Type == ojson.Number {
+ _ = toolDoc.Set(field, v.Int())
}
}
if maskDataURL != nil && strings.TrimSpace(*maskDataURL) != "" {
- tool, _ = sjson.SetBytes(tool, "input_image_mask.image_url", strings.TrimSpace(*maskDataURL))
+ _ = toolDoc.Set("input_image_mask.image_url", strings.TrimSpace(*maskDataURL))
}
- responsesReq := buildImagesResponsesRequest(prompt, images, tool)
+ responsesReq := buildImagesResponsesRequest(prompt, images, toolDoc.Bytes())
if stream {
h.streamImagesFromResponses(c, responsesReq, responseFormat, "image_edit")
return
@@ -1157,10 +1175,10 @@ func (h *OpenAIAPIHandler) imagesEditsFromJSON(c *gin.Context) {
}
func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte) []byte {
- req := []byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`)
+ reqDoc := ojson.NewDoc([]byte(`{"instructions":"","stream":true,"reasoning":{"effort":"medium","summary":"auto"},"parallel_tool_calls":true,"include":["reasoning.encrypted_content"],"model":"","store":false,"tool_choice":{"type":"image_generation"}}`))
mainModel := defaultImagesMainModel
if len(toolJSON) > 0 && json.Valid(toolJSON) {
- toolModel := strings.TrimSpace(gjson.GetBytes(toolJSON, "model").String())
+ toolModel := strings.TrimSpace(ojson.GetBytesNoCopy(toolJSON, "model").String())
if idx := strings.LastIndex(toolModel, "/"); idx > 0 && idx < len(toolModel)-1 {
prefix := strings.TrimSpace(toolModel[:idx])
if prefix != "" {
@@ -1168,28 +1186,27 @@ func buildImagesResponsesRequest(prompt string, images []string, toolJSON []byte
}
}
}
- req, _ = sjson.SetBytes(req, "model", mainModel)
+ _ = reqDoc.Set("model", mainModel)
- input := []byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`)
- input, _ = sjson.SetBytes(input, "0.content.0.text", prompt)
+ inputDoc := ojson.NewDoc([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`))
+ _ = inputDoc.Set("0.content.0.text", prompt)
contentIndex := 1
for _, img := range images {
if strings.TrimSpace(img) == "" {
continue
}
- part := []byte(`{"type":"input_image","image_url":""}`)
- part, _ = sjson.SetBytes(part, "image_url", img)
+ part, _ := ojson.SetBytes([]byte(`{"type":"input_image","image_url":""}`), "image_url", img)
path := fmt.Sprintf("0.content.%d", contentIndex)
- input, _ = sjson.SetRawBytes(input, path, part)
+ _ = inputDoc.SetRawBytes(path, part)
contentIndex++
}
- req, _ = sjson.SetRawBytes(req, "input", input)
+ _ = reqDoc.SetRawBytes("input", inputDoc.Bytes())
- req, _ = sjson.SetRawBytes(req, "tools", []byte(`[]`))
+ _ = reqDoc.SetRawBytes("tools", []byte(`[]`))
if len(toolJSON) > 0 && json.Valid(toolJSON) {
- req, _ = sjson.SetRawBytes(req, "tools.-1", toolJSON)
+ _ = reqDoc.SetRawBytes("tools.-1", toolJSON)
}
- return req
+ return reqDoc.Bytes()
}
func extractXAIImagesResponse(payload []byte) (results []xaiImageResult, createdAt int64, usageRaw []byte, err error) {
@@ -1197,22 +1214,22 @@ func extractXAIImagesResponse(payload []byte) (results []xaiImageResult, created
return nil, 0, nil, fmt.Errorf("upstream returned invalid image response JSON")
}
- createdAt = gjson.GetBytes(payload, "created").Int()
+ createdAt = ojson.GetBytesNoCopy(payload, "created").Int()
if createdAt <= 0 {
createdAt = time.Now().Unix()
}
- data := gjson.GetBytes(payload, "data")
+ data := ojson.GetBytesNoCopy(payload, "data")
if data.IsArray() {
for _, item := range data.Array() {
result := xaiImageResult{
- B64JSON: strings.TrimSpace(item.Get("b64_json").String()),
- URL: strings.TrimSpace(item.Get("url").String()),
- RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
- MimeType: strings.TrimSpace(item.Get("mime_type").String()),
+ B64JSON: strings.Clone(strings.TrimSpace(item.Get("b64_json").String())),
+ URL: strings.Clone(strings.TrimSpace(item.Get("url").String())),
+ RevisedPrompt: strings.Clone(strings.TrimSpace(item.Get("revised_prompt").String())),
+ MimeType: strings.Clone(strings.TrimSpace(item.Get("mime_type").String())),
}
if result.MimeType == "" {
- result.MimeType = mimeTypeFromOutputFormat(strings.TrimSpace(item.Get("output_format").String()))
+ result.MimeType = strings.Clone(mimeTypeFromOutputFormat(strings.TrimSpace(item.Get("output_format").String())))
}
if result.MimeType == "" {
result.MimeType = "image/png"
@@ -1227,8 +1244,8 @@ func extractXAIImagesResponse(payload []byte) (results []xaiImageResult, created
return nil, 0, nil, fmt.Errorf("upstream did not return image output")
}
- if usage := gjson.GetBytes(payload, "usage"); usage.Exists() && usage.IsObject() {
- usageRaw = []byte(usage.Raw)
+ if usage := ojson.GetBytesNoCopy(payload, "usage"); usage.Exists() && usage.IsObject() {
+ usageRaw = bytes.Clone([]byte(usage.Raw))
}
return results, createdAt, usageRaw, nil
@@ -1240,34 +1257,34 @@ func buildImagesAPIResponseFromXAI(payload []byte, responseFormat string) ([]byt
return nil, err
}
- out := []byte(`{"created":0,"data":[]}`)
- out, _ = sjson.SetBytes(out, "created", createdAt)
+ outDoc := ojson.NewDoc([]byte(`{"created":0,"data":[]}`))
+ _ = outDoc.Set("created", createdAt)
responseFormat = normalizeImagesResponseFormat(responseFormat)
for _, img := range results {
- item := []byte(`{}`)
+ itemDoc := ojson.NewDoc([]byte(`{}`))
if responseFormat == "url" {
if img.URL != "" {
- item, _ = sjson.SetBytes(item, "url", img.URL)
+ _ = itemDoc.Set("url", img.URL)
} else {
- item, _ = sjson.SetBytes(item, "url", "data:"+mimeTypeFromOutputFormat(img.MimeType)+";base64,"+img.B64JSON)
+ _ = itemDoc.Set("url", "data:"+mimeTypeFromOutputFormat(img.MimeType)+";base64,"+img.B64JSON)
}
} else if img.B64JSON != "" {
- item, _ = sjson.SetBytes(item, "b64_json", img.B64JSON)
+ _ = itemDoc.Set("b64_json", img.B64JSON)
} else {
- item, _ = sjson.SetBytes(item, "url", img.URL)
+ _ = itemDoc.Set("url", img.URL)
}
if img.RevisedPrompt != "" {
- item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt)
+ _ = itemDoc.Set("revised_prompt", img.RevisedPrompt)
}
- out, _ = sjson.SetRawBytes(out, "data.-1", item)
+ _ = outDoc.SetRawBytes("data.-1", itemDoc.Bytes())
}
if len(usageRaw) > 0 && json.Valid(usageRaw) {
- out, _ = sjson.SetRawBytes(out, "usage", usageRaw)
+ _ = outDoc.SetRawBytes("usage", usageRaw)
}
- return out, nil
+ return outDoc.Bytes(), nil
}
func (h *OpenAIAPIHandler) handleXAIImages(c *gin.Context, xaiReq []byte, originalReq []byte, imageModel string, responseFormat string, streamPrefix string, stream bool) {
@@ -1565,7 +1582,7 @@ func (h *OpenAIAPIHandler) streamOpenAICompatImages(c *gin.Context, compatReq []
func (h *OpenAIAPIHandler) collectXAIImages(c *gin.Context, xaiReq []byte, originalReq []byte, imageModel string, responseFormat string) {
model := strings.TrimSpace(imageModel)
if model == "" {
- model = strings.TrimSpace(gjson.GetBytes(xaiReq, "model").String())
+ model = strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(xaiReq, "model").String()))
}
h.collectImagesWithModelAndOriginal(c, xaiReq, originalReq, h.routingXAIImagesModel(model), responseFormat)
}
@@ -1615,7 +1632,7 @@ func (h *OpenAIAPIHandler) collectImagesWithModelAndOriginal(c *gin.Context, ima
func (h *OpenAIAPIHandler) streamXAIImages(c *gin.Context, xaiReq []byte, originalReq []byte, imageModel string, responseFormat string, streamPrefix string) {
model := strings.TrimSpace(imageModel)
if model == "" {
- model = strings.TrimSpace(gjson.GetBytes(xaiReq, "model").String())
+ model = strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(xaiReq, "model").String()))
}
h.streamImagesWithModel(c, xaiReq, originalReq, h.routingXAIImagesModel(model), responseFormat, streamPrefix)
}
@@ -1706,26 +1723,26 @@ func (h *OpenAIAPIHandler) streamImagesWithModel(c *gin.Context, imageReq []byte
eventName := streamPrefix + ".completed"
responseFormat = normalizeImagesResponseFormat(result.responseFormat)
for _, img := range results {
- data := []byte(`{"type":""}`)
- data, _ = sjson.SetBytes(data, "type", eventName)
+ dataDoc := ojson.NewDoc([]byte(`{"type":""}`))
+ _ = dataDoc.Set("type", eventName)
if responseFormat == "url" {
if img.URL != "" {
- data, _ = sjson.SetBytes(data, "url", img.URL)
+ _ = dataDoc.Set("url", img.URL)
} else {
- data, _ = sjson.SetBytes(data, "url", "data:"+mimeTypeFromOutputFormat(img.MimeType)+";base64,"+img.B64JSON)
+ _ = dataDoc.Set("url", "data:"+mimeTypeFromOutputFormat(img.MimeType)+";base64,"+img.B64JSON)
}
} else if img.B64JSON != "" {
- data, _ = sjson.SetBytes(data, "b64_json", img.B64JSON)
+ _ = dataDoc.Set("b64_json", img.B64JSON)
} else {
- data, _ = sjson.SetBytes(data, "url", img.URL)
+ _ = dataDoc.Set("url", img.URL)
}
if len(usageRaw) > 0 && json.Valid(usageRaw) {
- data, _ = sjson.SetRawBytes(data, "usage", usageRaw)
+ _ = dataDoc.SetRawBytes("usage", usageRaw)
}
if strings.TrimSpace(eventName) != "" {
_, _ = fmt.Fprintf(c.Writer, "event: %s\n", eventName)
}
- _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
+ _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", string(dataDoc.Bytes()))
flusher.Flush()
streamStarted = true
}
@@ -1742,7 +1759,7 @@ func (h *OpenAIAPIHandler) collectImagesFromResponses(c *gin.Context, responsesR
cliCtx = handlers.WithDisallowFreeAuth(cliCtx)
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
- mainModel := strings.TrimSpace(gjson.GetBytes(responsesReq, "model").String())
+ mainModel := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(responsesReq, "model").String()))
if mainModel == "" {
mainModel = defaultImagesMainModel
}
@@ -1775,7 +1792,7 @@ func collectImagesFromResponsesStream(ctx context.Context, data <-chan []byte, e
if !json.Valid(payload) {
return nil, false, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("invalid SSE data JSON")}
}
- payloadType := gjson.GetBytes(payload, "type").String()
+ payloadType := ojson.GetBytesNoCopy(payload, "type").String()
if responsesSSEErrorEvent(payloadType) || responsesSSEErrorEvent(responsesSSEEventName(frame)) || responsesSSEPayloadHasError(payload) {
return nil, false, responsesSSEPayloadErrorMessage(payload)
}
@@ -1836,16 +1853,16 @@ func collectImagesFromResponsesStream(ctx context.Context, data <-chan []byte, e
}
func extractImagesFromResponsesCompleted(payload []byte) (results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, err error) {
- if gjson.GetBytes(payload, "type").String() != "response.completed" {
+ if ojson.GetBytesNoCopy(payload, "type").String() != "response.completed" {
return nil, 0, nil, imageCallResult{}, fmt.Errorf("unexpected event type")
}
- createdAt = gjson.GetBytes(payload, "response.created_at").Int()
+ createdAt = ojson.GetBytesNoCopy(payload, "response.created_at").Int()
if createdAt <= 0 {
createdAt = time.Now().Unix()
}
- output := gjson.GetBytes(payload, "response.output")
+ output := ojson.GetBytesNoCopy(payload, "response.output")
if output.IsArray() {
for _, item := range output.Array() {
if item.Get("type").String() != "image_generation_call" {
@@ -1856,12 +1873,12 @@ func extractImagesFromResponsesCompleted(payload []byte) (results []imageCallRes
continue
}
entry := imageCallResult{
- Result: res,
- RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()),
- OutputFormat: strings.TrimSpace(item.Get("output_format").String()),
- Size: strings.TrimSpace(item.Get("size").String()),
- Background: strings.TrimSpace(item.Get("background").String()),
- Quality: strings.TrimSpace(item.Get("quality").String()),
+ Result: strings.Clone(res),
+ RevisedPrompt: strings.Clone(strings.TrimSpace(item.Get("revised_prompt").String())),
+ OutputFormat: strings.Clone(strings.TrimSpace(item.Get("output_format").String())),
+ Size: strings.Clone(strings.TrimSpace(item.Get("size").String())),
+ Background: strings.Clone(strings.TrimSpace(item.Get("background").String())),
+ Quality: strings.Clone(strings.TrimSpace(item.Get("quality").String())),
}
if len(results) == 0 {
firstMeta = entry
@@ -1870,16 +1887,16 @@ func extractImagesFromResponsesCompleted(payload []byte) (results []imageCallRes
}
}
- if usage := gjson.GetBytes(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
- usageRaw = []byte(usage.Raw)
+ if usage := ojson.GetBytesNoCopy(payload, "response.tool_usage.image_gen"); usage.Exists() && usage.IsObject() {
+ usageRaw = bytes.Clone([]byte(usage.Raw))
}
return results, createdAt, usageRaw, firstMeta, nil
}
func buildImagesAPIResponse(results []imageCallResult, createdAt int64, usageRaw []byte, firstMeta imageCallResult, responseFormat string) ([]byte, error) {
- out := []byte(`{"created":0,"data":[]}`)
- out, _ = sjson.SetBytes(out, "created", createdAt)
+ outDoc := ojson.NewDoc([]byte(`{"created":0,"data":[]}`))
+ _ = outDoc.Set("created", createdAt)
responseFormat = strings.ToLower(strings.TrimSpace(responseFormat))
if responseFormat == "" {
@@ -1887,37 +1904,37 @@ func buildImagesAPIResponse(results []imageCallResult, createdAt int64, usageRaw
}
for _, img := range results {
- item := []byte(`{}`)
+ itemDoc := ojson.NewDoc([]byte(`{}`))
if responseFormat == "url" {
mt := mimeTypeFromOutputFormat(img.OutputFormat)
- item, _ = sjson.SetBytes(item, "url", "data:"+mt+";base64,"+img.Result)
+ _ = itemDoc.Set("url", "data:"+mt+";base64,"+img.Result)
} else {
- item, _ = sjson.SetBytes(item, "b64_json", img.Result)
+ _ = itemDoc.Set("b64_json", img.Result)
}
if img.RevisedPrompt != "" {
- item, _ = sjson.SetBytes(item, "revised_prompt", img.RevisedPrompt)
+ _ = itemDoc.Set("revised_prompt", img.RevisedPrompt)
}
- out, _ = sjson.SetRawBytes(out, "data.-1", item)
+ _ = outDoc.SetRawBytes("data.-1", itemDoc.Bytes())
}
if firstMeta.Background != "" {
- out, _ = sjson.SetBytes(out, "background", firstMeta.Background)
+ _ = outDoc.Set("background", firstMeta.Background)
}
if firstMeta.OutputFormat != "" {
- out, _ = sjson.SetBytes(out, "output_format", firstMeta.OutputFormat)
+ _ = outDoc.Set("output_format", firstMeta.OutputFormat)
}
if firstMeta.Quality != "" {
- out, _ = sjson.SetBytes(out, "quality", firstMeta.Quality)
+ _ = outDoc.Set("quality", firstMeta.Quality)
}
if firstMeta.Size != "" {
- out, _ = sjson.SetBytes(out, "size", firstMeta.Size)
+ _ = outDoc.Set("size", firstMeta.Size)
}
if len(usageRaw) > 0 && json.Valid(usageRaw) {
- out, _ = sjson.SetRawBytes(out, "usage", usageRaw)
+ _ = outDoc.SetRawBytes("usage", usageRaw)
}
- return out, nil
+ return outDoc.Bytes(), nil
}
func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesReq []byte, responseFormat string, streamPrefix string) {
@@ -1934,7 +1951,7 @@ func (h *OpenAIAPIHandler) streamImagesFromResponses(c *gin.Context, responsesRe
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
cliCtx = handlers.WithDisallowFreeAuth(cliCtx)
- mainModel := strings.TrimSpace(gjson.GetBytes(responsesReq, "model").String())
+ mainModel := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(responsesReq, "model").String()))
if mainModel == "" {
mainModel = defaultImagesMainModel
}
@@ -2049,30 +2066,30 @@ func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Conte
if !json.Valid(payload) {
return true, &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: fmt.Errorf("invalid SSE data JSON")}
}
- payloadType := gjson.GetBytes(payload, "type").String()
+ payloadType := ojson.GetBytesNoCopy(payload, "type").String()
if responsesSSEErrorEvent(payloadType) || responsesSSEErrorEvent(responsesSSEEventName(frame)) || responsesSSEPayloadHasError(payload) {
return true, responsesSSEPayloadErrorMessage(payload)
}
switch payloadType {
case "response.image_generation_call.partial_image":
- b64 := strings.TrimSpace(gjson.GetBytes(payload, "partial_image_b64").String())
+ b64 := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "partial_image_b64").String())
if b64 == "" {
return false, nil
}
- outputFormat := strings.TrimSpace(gjson.GetBytes(payload, "output_format").String())
- index := gjson.GetBytes(payload, "partial_image_index").Int()
+ outputFormat := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "output_format").String())
+ index := ojson.GetBytesNoCopy(payload, "partial_image_index").Int()
eventName := streamPrefix + ".partial_image"
- data := []byte(`{"type":"","partial_image_index":0}`)
- data, _ = sjson.SetBytes(data, "type", eventName)
- data, _ = sjson.SetBytes(data, "partial_image_index", index)
+ dataDoc := ojson.NewDoc([]byte(`{"type":"","partial_image_index":0}`))
+ _ = dataDoc.Set("type", eventName)
+ _ = dataDoc.Set("partial_image_index", index)
if responseFormat == "url" {
mt := mimeTypeFromOutputFormat(outputFormat)
- data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+b64)
+ _ = dataDoc.Set("url", "data:"+mt+";base64,"+b64)
} else {
- data, _ = sjson.SetBytes(data, "b64_json", b64)
+ _ = dataDoc.Set("b64_json", b64)
}
- writeEvent(eventName, data)
+ writeEvent(eventName, dataDoc.Bytes())
case "response.completed":
results, _, usageRaw, _, err := extractImagesFromResponsesCompleted(payload)
if err != nil {
@@ -2083,18 +2100,18 @@ func (h *OpenAIAPIHandler) forwardImagesStream(ctx context.Context, c *gin.Conte
}
eventName := streamPrefix + ".completed"
for _, img := range results {
- data := []byte(`{"type":""}`)
- data, _ = sjson.SetBytes(data, "type", eventName)
+ dataDoc := ojson.NewDoc([]byte(`{"type":""}`))
+ _ = dataDoc.Set("type", eventName)
if responseFormat == "url" {
mt := mimeTypeFromOutputFormat(img.OutputFormat)
- data, _ = sjson.SetBytes(data, "url", "data:"+mt+";base64,"+img.Result)
+ _ = dataDoc.Set("url", "data:"+mt+";base64,"+img.Result)
} else {
- data, _ = sjson.SetBytes(data, "b64_json", img.Result)
+ _ = dataDoc.Set("b64_json", img.Result)
}
if len(usageRaw) > 0 && json.Valid(usageRaw) {
- data, _ = sjson.SetRawBytes(data, "usage", usageRaw)
+ _ = dataDoc.SetRawBytes("usage", usageRaw)
}
- writeEvent(eventName, data)
+ writeEvent(eventName, dataDoc.Bytes())
}
return true, nil
}
diff --git a/sdk/api/handlers/openai/openai_images_handlers_test.go b/sdk/api/handlers/openai/openai_images_handlers_test.go
index e41e2318d1e..2dd9859c909 100644
--- a/sdk/api/handlers/openai/openai_images_handlers_test.go
+++ b/sdk/api/handlers/openai/openai_images_handlers_test.go
@@ -14,6 +14,7 @@ import (
"sync"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
@@ -23,7 +24,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func performImagesEndpointRequest(t *testing.T, endpointPath string, contentType string, body io.Reader, handler gin.HandlerFunc) *httptest.ResponseRecorder {
@@ -49,12 +49,12 @@ func assertUnsupportedImagesModelResponse(t *testing.T, resp *httptest.ResponseR
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String()
- expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + gptImage15Model + ", " + defaultImagesToolModel + ", " + gptImage25FlareModel + ", " + gptImage25SunburstModel + ", " + gptImage25Model + ", " + defaultXAIImagesModel + ", " + xaiImagesQualityModel + ", " + xaiImages20Model + ", or a configured openai-compatibility image model."
+ message := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.message").String()
+ expectedMessage := "Model " + model + " is not supported on " + imagesGenerationsPath + " or " + imagesEditsPath + ". Use " + gptImage15Model + ", " + defaultImagesToolModel + ", " + gptImage25FlareModel + ", " + gptImage25SunburstModel + ", " + gptImage25Model + ", " + gptImage26FlareModel + ", " + gptImage26SunburstModel + ", " + gptImage26Model + ", " + defaultXAIImagesModel + ", " + xaiImagesQualityModel + ", " + xaiImages20Model + ", or a configured openai-compatibility image model."
if message != expectedMessage {
t.Fatalf("error message = %q, want %q", message, expectedMessage)
}
- if errorType := gjson.GetBytes(resp.Body.Bytes(), "error.type").String(); errorType != "invalid_request_error" {
+ if errorType := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.type").String(); errorType != "invalid_request_error" {
t.Fatalf("error type = %q, want invalid_request_error", errorType)
}
}
@@ -71,6 +71,12 @@ func TestImagesModelValidationAllowsGPTImageAndXAIModels(t *testing.T) {
"codex/gpt-image-2.5-sunburst",
"gpt-image-2.5",
"codex/gpt-image-2.5",
+ "gpt-image-2.6-flare",
+ "codex/gpt-image-2.6-flare",
+ "gpt-image-2.6-sunburst",
+ "codex/gpt-image-2.6-sunburst",
+ "gpt-image-2.6",
+ "codex/gpt-image-2.6",
"grok-imagine-image",
"xai/grok-imagine-image",
"grok-imagine-image-quality",
@@ -122,22 +128,22 @@ func TestBuildXAIImagesGenerationsRequest(t *testing.T) {
req := buildXAIImagesGenerationsRequest(rawJSON, "xai/grok-imagine-image-quality", "url")
- if got := gjson.GetBytes(req, "model").String(); got != "grok-imagine-image-quality" {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != "grok-imagine-image-quality" {
t.Fatalf("model = %q, want grok-imagine-image-quality", got)
}
- if got := gjson.GetBytes(req, "prompt").String(); got != "abstract art" {
+ if got := ojson.GetBytesNoCopy(req, "prompt").String(); got != "abstract art" {
t.Fatalf("prompt = %q, want abstract art", got)
}
- if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "16:9" {
+ if got := ojson.GetBytesNoCopy(req, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("aspect_ratio = %q, want 16:9", got)
}
- if got := gjson.GetBytes(req, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(req, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k", got)
}
- if got := gjson.GetBytes(req, "response_format").String(); got != "url" {
+ if got := ojson.GetBytesNoCopy(req, "response_format").String(); got != "url" {
t.Fatalf("response_format = %q, want url", got)
}
- if got := gjson.GetBytes(req, "n").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(req, "n").Int(); got != 2 {
t.Fatalf("n = %d, want 2", got)
}
}
@@ -185,7 +191,7 @@ func TestBuildXAIImagesGenerationsRequestPreservesQuality(t *testing.T) {
} {
t.Run(tc.name, func(t *testing.T) {
req := buildXAIImagesGenerationsRequest([]byte(tc.rawJSON), "grok-imagine-image-2.0", "b64_json")
- q := gjson.GetBytes(req, "quality")
+ q := ojson.GetBytesNoCopy(req, "quality")
if tc.hasQuality {
if !q.Exists() || q.String() != tc.wantQuality {
t.Fatalf("quality = %q (exists=%v), want %q; req = %s", q.String(), q.Exists(), tc.wantQuality, string(req))
@@ -204,10 +210,10 @@ func TestBuildXAIImagesGenerationsRequestNineByTwenty(t *testing.T) {
req := buildXAIImagesGenerationsRequest(rawJSON, "grok-imagine-image-quality", "b64_json")
- if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "9:20" {
+ if got := ojson.GetBytesNoCopy(req, "aspect_ratio").String(); got != "9:20" {
t.Fatalf("aspect_ratio = %q, want 9:20", got)
}
- if got := gjson.GetBytes(req, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(req, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k", got)
}
}
@@ -217,10 +223,10 @@ func TestBuildXAIImagesGenerationsRequestNineByTwentyFromSize(t *testing.T) {
req := buildXAIImagesGenerationsRequest(rawJSON, "grok-imagine-image-quality", "b64_json")
- if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "9:20" {
+ if got := ojson.GetBytesNoCopy(req, "aspect_ratio").String(); got != "9:20" {
t.Fatalf("aspect_ratio from size = %q, want 9:20", got)
}
- if got := gjson.GetBytes(req, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(req, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k", got)
}
}
@@ -230,10 +236,10 @@ func TestBuildXAIImagesGenerationsRequestTwentyByNine(t *testing.T) {
req := buildXAIImagesGenerationsRequest(rawJSON, "grok-imagine-image-quality", "b64_json")
- if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "20:9" {
+ if got := ojson.GetBytesNoCopy(req, "aspect_ratio").String(); got != "20:9" {
t.Fatalf("aspect_ratio = %q, want 20:9", got)
}
- if got := gjson.GetBytes(req, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(req, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k", got)
}
}
@@ -243,10 +249,10 @@ func TestBuildXAIImagesGenerationsRequestTwentyByNineFromSize(t *testing.T) {
req := buildXAIImagesGenerationsRequest(rawJSON, "grok-imagine-image-quality", "b64_json")
- if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "20:9" {
+ if got := ojson.GetBytesNoCopy(req, "aspect_ratio").String(); got != "20:9" {
t.Fatalf("aspect_ratio from size = %q, want 20:9", got)
}
- if got := gjson.GetBytes(req, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(req, "resolution").String(); got != "2k" {
t.Fatalf("resolution = %q, want 2k", got)
}
}
@@ -272,22 +278,22 @@ func TestXAIImagesAspectRatioNineByTwenty(t *testing.T) {
func TestBuildXAIImagesEditRequest(t *testing.T) {
req := buildXAIImagesEditRequest("grok-imagine-image", "edit it", []string{"data:image/png;base64,AA==", "https://example.com/image.png"}, "b64_json", "3:2", "1k", "medium", 0)
- if got := gjson.GetBytes(req, "model").String(); got != "grok-imagine-image" {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != "grok-imagine-image" {
t.Fatalf("model = %q, want grok-imagine-image", got)
}
- if got := gjson.GetBytes(req, "quality").String(); got != "medium" {
+ if got := ojson.GetBytesNoCopy(req, "quality").String(); got != "medium" {
t.Fatalf("quality = %q, want medium", got)
}
- if got := gjson.GetBytes(req, "images.0.type").String(); got != "image_url" {
+ if got := ojson.GetBytesNoCopy(req, "images.0.type").String(); got != "image_url" {
t.Fatalf("images.0.type = %q, want image_url", got)
}
- if got := gjson.GetBytes(req, "images.0.url").String(); got != "data:image/png;base64,AA==" {
+ if got := ojson.GetBytesNoCopy(req, "images.0.url").String(); got != "data:image/png;base64,AA==" {
t.Fatalf("images.0.url = %q", got)
}
- if got := gjson.GetBytes(req, "images.1.url").String(); got != "https://example.com/image.png" {
+ if got := ojson.GetBytesNoCopy(req, "images.1.url").String(); got != "https://example.com/image.png" {
t.Fatalf("images.1.url = %q", got)
}
- if gjson.GetBytes(req, "image").Exists() {
+ if ojson.GetBytesNoCopy(req, "image").Exists() {
t.Fatalf("multiple image edits must use images array: %s", string(req))
}
}
@@ -295,16 +301,16 @@ func TestBuildXAIImagesEditRequest(t *testing.T) {
func TestBuildXAIImagesEditRequestSingleImage(t *testing.T) {
req := buildXAIImagesEditRequest("grok-imagine-image", "edit it", []string{"https://example.com/image.png"}, "url", "", "", "", 0)
- if got := gjson.GetBytes(req, "image.type").String(); got != "image_url" {
+ if got := ojson.GetBytesNoCopy(req, "image.type").String(); got != "image_url" {
t.Fatalf("image.type = %q, want image_url", got)
}
- if got := gjson.GetBytes(req, "image.url").String(); got != "https://example.com/image.png" {
+ if got := ojson.GetBytesNoCopy(req, "image.url").String(); got != "https://example.com/image.png" {
t.Fatalf("image.url = %q", got)
}
- if gjson.GetBytes(req, "quality").Exists() {
+ if ojson.GetBytesNoCopy(req, "quality").Exists() {
t.Fatalf("empty quality should be omitted: %s", string(req))
}
- if gjson.GetBytes(req, "images").Exists() {
+ if ojson.GetBytesNoCopy(req, "images").Exists() {
t.Fatalf("single image edit must use image object: %s", string(req))
}
}
@@ -333,10 +339,10 @@ func TestXAIImagesEditOptionsFromJSONPreservesQuality(t *testing.T) {
func TestBuildOpenAICompatImagesJSONRequestPreservesStreamForStreaming(t *testing.T) {
req := buildOpenAICompatImagesJSONRequest([]byte(`{"model":"compat-image","prompt":"draw","stream":false}`), "upstream-image", true)
- if got := gjson.GetBytes(req, "model").String(); got != "upstream-image" {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != "upstream-image" {
t.Fatalf("model = %q, want upstream-image; body=%s", got, string(req))
}
- if !gjson.GetBytes(req, "stream").Bool() {
+ if !ojson.GetBytesNoCopy(req, "stream").Bool() {
t.Fatalf("stream flag missing: %s", string(req))
}
}
@@ -344,10 +350,10 @@ func TestBuildOpenAICompatImagesJSONRequestPreservesStreamForStreaming(t *testin
func TestBuildOpenAICompatImagesJSONRequestDropsStreamForNonStreaming(t *testing.T) {
req := buildOpenAICompatImagesJSONRequest([]byte(`{"model":"compat-image","prompt":"draw","stream":true}`), "upstream-image", false)
- if got := gjson.GetBytes(req, "model").String(); got != "upstream-image" {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != "upstream-image" {
t.Fatalf("model = %q, want upstream-image; body=%s", got, string(req))
}
- if gjson.GetBytes(req, "stream").Exists() {
+ if ojson.GetBytesNoCopy(req, "stream").Exists() {
t.Fatalf("stream flag should be removed from non-streaming request: %s", string(req))
}
}
@@ -432,16 +438,16 @@ func TestBuildImagesAPIResponseFromXAI(t *testing.T) {
t.Fatalf("buildImagesAPIResponseFromXAI() error = %v", err)
}
- if got := gjson.GetBytes(out, "created").Int(); got != 123 {
+ if got := ojson.GetBytesNoCopy(out, "created").Int(); got != 123 {
t.Fatalf("created = %d, want 123", got)
}
- if got := gjson.GetBytes(out, "data.0.b64_json").String(); got != "AA==" {
+ if got := ojson.GetBytesNoCopy(out, "data.0.b64_json").String(); got != "AA==" {
t.Fatalf("data.0.b64_json = %q, want AA==", got)
}
- if got := gjson.GetBytes(out, "data.0.revised_prompt").String(); got != "refined" {
+ if got := ojson.GetBytesNoCopy(out, "data.0.revised_prompt").String(); got != "refined" {
t.Fatalf("data.0.revised_prompt = %q, want refined", got)
}
- if !gjson.GetBytes(out, "usage").Exists() {
+ if !ojson.GetBytesNoCopy(out, "usage").Exists() {
t.Fatalf("usage missing: %s", string(out))
}
}
@@ -704,7 +710,7 @@ func (e *xaiImagesCaptureExecutor) Execute(_ context.Context, auth *coreauth.Aut
authID: authID,
reqModel: req.Model,
requestedModel: requestedModel,
- payloadModel: strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String()),
+ payloadModel: strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "model").String()),
})
e.mu.Unlock()
return coreexecutor.Response{
@@ -786,7 +792,7 @@ func TestXAIImagesRoutesWithPrefixedModelWhenForceModelPrefixEnabled(t *testing.
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
t.Fatalf("data.0.b64_json = %q, want aW1hZ2U=", got)
}
calls := executor.Calls()
@@ -907,7 +913,7 @@ func TestXAIImagesRoutesWithPrefixedModelWhenForceModelPrefixEnabled(t *testing.
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != "unknown provider for model grok-imagine-image-2.0" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.message").String(); got != "unknown provider for model grok-imagine-image-2.0" {
t.Fatalf("error.message = %q, want %q", got, "unknown provider for model grok-imagine-image-2.0")
}
if len(executor.Calls()) != before {
@@ -992,7 +998,7 @@ func TestXAIImagesSupportsBuiltInPrefixFallbackAndCustomCredentialPrefix(t *test
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != "unknown provider for model xai/grok-imagine-image-2.0" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.message").String(); got != "unknown provider for model xai/grok-imagine-image-2.0" {
t.Fatalf("error.message = %q, want %q", got, "unknown provider for model xai/grok-imagine-image-2.0")
}
if len(executor.Calls()) != 0 {
@@ -1020,7 +1026,7 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
- if !forceB64 && gjson.GetBytes(body, "response_format").String() == "url" {
+ if !forceB64 && ojson.GetBytesNoCopy(body, "response_format").String() == "url" {
_, _ = w.Write([]byte(`{"created":123,"data":[{"url":"https://img.x.ai/generated.png","revised_prompt":"ok"}]}`))
return
}
@@ -1082,19 +1088,19 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
lastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(lastReq, "response_format").String(); got != "url" {
+ if got := ojson.GetBytesNoCopy(lastReq, "response_format").String(); got != "url" {
t.Fatalf("upstream response_format = %q, want url; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "aspect_ratio").String(); got != "16:9" {
+ if got := ojson.GetBytesNoCopy(lastReq, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("upstream aspect_ratio = %q, want 16:9; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(lastReq, "resolution").String(); got != "2k" {
t.Fatalf("upstream resolution = %q, want 2k; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.url").String(); got != "https://img.x.ai/generated.png" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.url").String(); got != "https://img.x.ai/generated.png" {
t.Fatalf("data.0.url = %q, want https://img.x.ai/generated.png; resp=%s", got, resp.Body.String())
}
- if gjson.GetBytes(resp.Body.Bytes(), "data.0.b64_json").Exists() {
+ if ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.b64_json").Exists() {
t.Fatalf("unexpected data.0.b64_json in response: %s", resp.Body.String())
}
})
@@ -1108,16 +1114,16 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
lastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(lastReq, "response_format").String(); got != "b64_json" {
+ if got := ojson.GetBytesNoCopy(lastReq, "response_format").String(); got != "b64_json" {
t.Fatalf("upstream response_format = %q, want b64_json; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "aspect_ratio").String(); got != "1:1" {
+ if got := ojson.GetBytesNoCopy(lastReq, "aspect_ratio").String(); got != "1:1" {
t.Fatalf("upstream aspect_ratio = %q, want 1:1; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(lastReq, "resolution").String(); got != "2k" {
t.Fatalf("upstream resolution = %q, want 2k; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
t.Fatalf("data.0.b64_json = %q, want aW1hZ2U=; resp=%s", got, resp.Body.String())
}
@@ -1129,13 +1135,13 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
explicitLastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(explicitLastReq, "response_format").String(); got != "b64_json" {
+ if got := ojson.GetBytesNoCopy(explicitLastReq, "response_format").String(); got != "b64_json" {
t.Fatalf("upstream response_format = %q, want b64_json; body=%s", got, explicitLastReq)
}
- if got := gjson.GetBytes(explicitLastReq, "aspect_ratio").String(); got != "4:3" {
+ if got := ojson.GetBytesNoCopy(explicitLastReq, "aspect_ratio").String(); got != "4:3" {
t.Fatalf("upstream aspect_ratio = %q, want 4:3; body=%s", got, explicitLastReq)
}
- if got := gjson.GetBytes(explicitLastReq, "resolution").String(); got != "1k" {
+ if got := ojson.GetBytesNoCopy(explicitLastReq, "resolution").String(); got != "1k" {
t.Fatalf("upstream resolution = %q, want 1k; body=%s", got, explicitLastReq)
}
})
@@ -1158,7 +1164,7 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
lastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(lastReq, "response_format").String(); got != "url" {
+ if got := ojson.GetBytesNoCopy(lastReq, "response_format").String(); got != "url" {
t.Fatalf("upstream response_format = %q, want url; body=%s", got, lastReq)
}
if !strings.Contains(resp.Body.String(), `"url":"data:image/png;base64,aW1hZ2U="`) {
@@ -1175,16 +1181,16 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
lastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(lastReq, "response_format").String(); got != "url" {
+ if got := ojson.GetBytesNoCopy(lastReq, "response_format").String(); got != "url" {
t.Fatalf("upstream response_format = %q, want url; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "aspect_ratio").String(); got != "16:9" {
+ if got := ojson.GetBytesNoCopy(lastReq, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("upstream aspect_ratio = %q, want 16:9; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(lastReq, "resolution").String(); got != "2k" {
t.Fatalf("upstream resolution = %q, want 2k; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.url").String(); got != "https://img.x.ai/generated.png" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.url").String(); got != "https://img.x.ai/generated.png" {
t.Fatalf("data.0.url = %q, want https://img.x.ai/generated.png; resp=%s", got, resp.Body.String())
}
})
@@ -1219,16 +1225,16 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
lastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(lastReq, "response_format").String(); got != "url" {
+ if got := ojson.GetBytesNoCopy(lastReq, "response_format").String(); got != "url" {
t.Fatalf("upstream response_format = %q, want url; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "aspect_ratio").String(); got != "16:9" {
+ if got := ojson.GetBytesNoCopy(lastReq, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("upstream aspect_ratio = %q, want 16:9; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(lastReq, "resolution").String(); got != "2k" {
t.Fatalf("upstream resolution = %q, want 2k; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.url").String(); got != "https://img.x.ai/generated.png" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.url").String(); got != "https://img.x.ai/generated.png" {
t.Fatalf("data.0.url = %q, want https://img.x.ai/generated.png; resp=%s", got, resp.Body.String())
}
})
@@ -1248,7 +1254,7 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.url").String(); got != "data:image/png;base64,aW1hZ2U=" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.url").String(); got != "data:image/png;base64,aW1hZ2U=" {
t.Fatalf("data.0.url = %q, want data:image/png;base64,aW1hZ2U=; resp=%s", got, resp.Body.String())
}
})
@@ -1276,19 +1282,19 @@ func TestXAIImagesPayloadDefaultAndOverrideRules(t *testing.T) {
mu.Lock()
lastReq := gotRequests[len(gotRequests)-1]
mu.Unlock()
- if got := gjson.GetBytes(lastReq, "response_format").String(); got != "b64_json" {
+ if got := ojson.GetBytesNoCopy(lastReq, "response_format").String(); got != "b64_json" {
t.Fatalf("upstream response_format = %q, want b64_json; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "aspect_ratio").String(); got != "9:16" {
+ if got := ojson.GetBytesNoCopy(lastReq, "aspect_ratio").String(); got != "9:16" {
t.Fatalf("upstream aspect_ratio = %q, want 9:16; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(lastReq, "resolution").String(); got != "2k" {
+ if got := ojson.GetBytesNoCopy(lastReq, "resolution").String(); got != "2k" {
t.Fatalf("upstream resolution = %q, want 2k; body=%s", got, lastReq)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.b64_json").String(); got != "aW1hZ2U=" {
t.Fatalf("data.0.b64_json = %q, want aW1hZ2U=; resp=%s", got, resp.Body.String())
}
- if gjson.GetBytes(resp.Body.Bytes(), "data.0.url").Exists() {
+ if ojson.GetBytesNoCopy(resp.Body.Bytes(), "data.0.url").Exists() {
t.Fatalf("unexpected data.0.url in response: %s", resp.Body.String())
}
})
diff --git a/sdk/api/handlers/openai/openai_responses_framing_metadata_test.go b/sdk/api/handlers/openai/openai_responses_framing_metadata_test.go
index fbd67a358ef..352eff395df 100644
--- a/sdk/api/handlers/openai/openai_responses_framing_metadata_test.go
+++ b/sdk/api/handlers/openai/openai_responses_framing_metadata_test.go
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
- "github.com/tidwall/gjson"
)
func TestResponsesSSEFramerFragmentedMetadataDataField(t *testing.T) {
@@ -315,7 +315,7 @@ func TestSSEFrameAccumulatorKeepsEmptyCommentInMultilineData(t *testing.T) {
t.Fatalf("frames = %q, want one", frames)
}
payload, ok := responsesSSEDataPayload(frames[0])
- if !ok || !json.Valid(payload) || gjson.GetBytes(payload, "type").String() != "response.completed" {
+ if !ok || !json.Valid(payload) || ojson.GetBytesNoCopy(payload, "type").String() != "response.completed" {
t.Fatalf("empty comment corrupted image data: %q", frames[0])
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_framing_recovery_test.go b/sdk/api/handlers/openai/openai_responses_framing_recovery_test.go
index 8511b432426..74c4eb84d83 100644
--- a/sdk/api/handlers/openai/openai_responses_framing_recovery_test.go
+++ b/sdk/api/handlers/openai/openai_responses_framing_recovery_test.go
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestResponsesSSEFramerRecoversFragmentedJSON(t *testing.T) {
@@ -85,7 +85,7 @@ func TestResponsesSSEFramerRecoversFragmentedJSON(t *testing.T) {
if tc.name == "colon-before-string-field" {
wantMarker = "data: private"
}
- if !ok || !json.Valid(payload) || gjson.GetBytes(payload, "type").String() != "response.metadata" || gjson.GetBytes(payload, "metadata.marker").String() != wantMarker {
+ if !ok || !json.Valid(payload) || ojson.GetBytesNoCopy(payload, "type").String() != "response.metadata" || ojson.GetBytesNoCopy(payload, "metadata.marker").String() != wantMarker {
t.Fatalf("Codex metadata was corrupted: %q", got)
}
if strings.Contains(strings.Join(tc.chunks, ""), ": keepalive") && !strings.Contains(got, ": keepalive") {
@@ -172,7 +172,7 @@ func TestSSEFrameAccumulatorRecoversFragmentedJSON(t *testing.T) {
t.Fatalf("frames = %q, want one", frames)
}
payload, ok := responsesSSEDataPayload(frames[0])
- if !ok || !json.Valid(payload) || gjson.GetBytes(payload, "type").String() != "response.completed" {
+ if !ok || !json.Valid(payload) || ojson.GetBytesNoCopy(payload, "type").String() != "response.completed" {
t.Fatalf("image response was corrupted: %q", frames[0])
}
if strings.Contains(strings.Join(tc.chunks, ""), ": keepalive") && !bytes.Contains(frames[0], []byte(": keepalive")) {
diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go
index 0c0eac68eb9..6eb68e89164 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers.go
@@ -17,6 +17,7 @@ import (
"sort"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/optimize-multi-agent-v2"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
@@ -27,8 +28,6 @@ import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func writeResponsesSSEChunk(w io.Writer, chunk []byte) error {
@@ -104,7 +103,7 @@ func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) {
}
if len(f.nextFramePrefix) > 0 {
payload, found := responsesSSEDataPayload(f.nextFramePrefix)
- if found && json.Valid(payload) && gjson.GetBytes(payload, "type").String() == "" &&
+ if found && json.Valid(payload) && ojson.GetBytesNoCopy(payload, "type").String() == "" &&
!responsesSSEHasField(f.nextFramePrefix, []byte("event:")) && responsesSSEFrameLen(f.nextFramePrefix) == 0 {
trimmed := bytes.TrimLeft(chunk, " \t\r\n")
field, complete, partial := responsesSSEChunkField(trimmed)
@@ -156,7 +155,7 @@ func (f *responsesSSEFramer) WriteChunk(w io.Writer, chunk []byte) {
return
}
newFrame = bytes.Equal(payload, []byte("[DONE]")) ||
- (valid && (gjson.GetBytes(payload, "type").String() != "" || hasEvent))
+ (valid && (ojson.GetBytesNoCopy(payload, "type").String() != "" || hasEvent))
if valid && !newFrame && responsesSSEFrameLen(trimmed) == 0 {
f.nextFramePrefix = append(f.nextFramePrefix, chunk...)
return
@@ -298,7 +297,7 @@ func (f *responsesSSEFramer) repairFrame(frame []byte) []byte {
f.dataFrames++
return frame
}
- payloadType := gjson.GetBytes(payload, "type").String()
+ payloadType := ojson.GetBytesNoCopy(payload, "type").String()
if f.shouldFilterPrivateEvent(streamEvent, payloadType) {
return nil
}
@@ -344,21 +343,81 @@ func (f *responsesSSEFramer) repairFrame(frame []byte) []byte {
func responsesSSEPayloadErrorMessage(payload []byte) *interfaces.ErrorMessage {
status := http.StatusBadGateway
- for _, path := range []string{"status", "status_code", "error.status", "error.status_code", "response.error.status", "response.error.status_code"} {
- candidate := int(gjson.GetBytes(payload, path).Int())
+ var candidatesBuf [6]ojson.Result
+ candidates := ojson.GetManyBytesNoCopy(candidatesBuf[:0], payload, "status", "status_code", "error.status", "error.status_code", "response.error.status", "response.error.status_code")
+ foundStatus := false
+ for _, res := range candidates {
+ candidate := int(res.Int())
if candidate >= http.StatusBadRequest && candidate <= 599 {
status = candidate
+ foundStatus = true
break
}
}
- return sanitizeResponsesStreamErrorMessage(&interfaces.ErrorMessage{StatusCode: status, Error: fmt.Errorf("%s", payload)})
+ if !foundStatus {
+ status = responsesSSEPayloadErrorStatus(payload)
+ }
+ errPayload := responsesAnnotateImageGenerationComplexityPayload(payload)
+ return sanitizeResponsesStreamErrorMessage(&interfaces.ErrorMessage{StatusCode: status, Error: fmt.Errorf("%s", errPayload)})
+}
+
+func responsesAnnotateImageGenerationComplexityPayload(payload []byte) []byte {
+ if len(payload) == 0 || !ojson.ValidBytes(payload) {
+ return payload
+ }
+ for _, basePath := range []string{"error", "response.error"} {
+ errObj := ojson.GetBytesNoCopy(payload, basePath)
+ if !errObj.IsObject() {
+ continue
+ }
+ if !strings.EqualFold(strings.TrimSpace(errObj.Get("type").String()), "image_generation_user_error") {
+ continue
+ }
+ msg := strings.TrimSpace(errObj.Get("message").String())
+ param := strings.ToLower(strings.TrimSpace(errObj.Get("param").String()))
+ if (param == "complexity" || strings.Contains(strings.ToLower(msg), "complexity")) && !strings.Contains(msg, "/v1/images/generations") {
+ doc := ojson.NewDoc(payload)
+ _ = doc.Set(basePath+".message", msg+` (Set tool_choice to {"type":"image_generation"}, use a compatible Responses model such as gpt-5.4-mini, or call /v1/images/generations directly.)`)
+ return doc.Bytes()
+ }
+ }
+ return payload
+}
+
+func responsesSSEPayloadErrorStatus(payload []byte) int {
+ var fieldsBuf [4]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], payload, "error.type", "response.error.type", "error.code", "response.error.code")
+ errorType := strings.ToLower(strings.TrimSpace(fields[0].String()))
+ if errorType == "" {
+ errorType = strings.ToLower(strings.TrimSpace(fields[1].String()))
+ }
+ errorCode := strings.ToLower(strings.TrimSpace(fields[2].String()))
+ if errorCode == "" {
+ errorCode = strings.ToLower(strings.TrimSpace(fields[3].String()))
+ }
+ switch {
+ case errorCode == "cyber_policy":
+ return http.StatusBadRequest
+ case errorType == "not_found_error", errorCode == "not_found", errorCode == "model_not_found":
+ return http.StatusNotFound
+ case errorType == "authentication_error", errorCode == "invalid_api_key", errorCode == "unauthorized":
+ return http.StatusUnauthorized
+ case errorType == "permission_error", errorCode == "forbidden", errorCode == "permission_denied":
+ return http.StatusForbidden
+ case errorType == "rate_limit_error", errorCode == "rate_limit_exceeded":
+ return http.StatusTooManyRequests
+ case errorType == "invalid_request_error", errorType == "bad_request_error", errorType == "image_generation_user_error", errorCode == "invalid_value", errorCode == "invalid_request_error":
+ return http.StatusBadRequest
+ default:
+ return http.StatusBadGateway
+ }
}
func (f *responsesSSEFramer) repairErrorPayload(payload []byte) []byte {
errMsg := responsesSSEPayloadErrorMessage(payload)
status := errMsg.StatusCode
f.terminalError = errMsg
- isResponseFailedPayload := gjson.GetBytes(payload, "type").String() == "response.failed"
+ isResponseFailedPayload := ojson.GetBytesNoCopy(payload, "type").String() == "response.failed"
failureEvent := f.failureEvent
if isResponseFailedPayload || failureEvent == "response.failed" {
failureEvent = "response.failed"
@@ -368,26 +427,26 @@ func (f *responsesSSEFramer) repairErrorPayload(payload []byte) []byte {
f.terminalEvent = failureEvent
errText := responsesStreamErrorText(errMsg, status)
seq := 0
- if s := gjson.GetBytes(payload, "sequence_number"); s.Exists() {
+ if s := ojson.GetBytesNoCopy(payload, "sequence_number"); s.Exists() {
seq = int(s.Int())
- } else if origSeq := gjson.Get(errText, "sequence_number"); origSeq.Exists() {
+ } else if origSeq := ojson.Get(errText, "sequence_number"); origSeq.Exists() {
seq = int(origSeq.Int())
} else if f != nil && f.dataFrames > 0 {
seq = f.dataFrames - 1
}
if failureEvent == "response.failed" {
chunk := handlers.BuildOpenAIResponsesStreamFailedChunk(status, errText, seq)
- if isResponseFailedPayload && gjson.GetBytes(payload, "response").IsObject() {
+ if isResponseFailedPayload && ojson.GetBytesNoCopy(payload, "response").IsObject() {
repaired := f.repairCompletedPayload(payload)
- respRaw := []byte(gjson.GetBytes(repaired, "response").Raw)
- respRaw, _ = sjson.SetBytes(respRaw, "status", "failed")
- if sanitizedErr := gjson.GetBytes(chunk, "response.error"); sanitizedErr.Exists() {
- respRaw, _ = sjson.SetRawBytes(respRaw, "error", []byte(sanitizedErr.Raw))
+ respDoc := ojson.NewDoc([]byte(ojson.GetBytesNoCopy(repaired, "response").Raw))
+ _ = respDoc.Set("status", "failed")
+ if sanitizedErr := ojson.GetBytesNoCopy(chunk, "response.error"); sanitizedErr.Exists() {
+ _ = respDoc.SetRawBytes("error", []byte(sanitizedErr.Raw))
}
- env := []byte(`{"type":"response.failed","sequence_number":0,"response":{}}`)
- env, _ = sjson.SetBytes(env, "sequence_number", seq)
- if updated, errSet := sjson.SetRawBytes(env, "response", respRaw); errSet == nil {
- chunk = updated
+ envDoc := ojson.NewDoc([]byte(`{"type":"response.failed","sequence_number":0,"response":{}}`))
+ _ = envDoc.Set("sequence_number", seq)
+ if errSet := envDoc.SetRawBytes("response", respDoc.Bytes()); errSet == nil {
+ chunk = envDoc.Bytes()
}
}
return []byte(fmt.Sprintf("event: response.failed\ndata: %s\n\n", chunk))
@@ -415,13 +474,14 @@ func responsesSSETerminalEvent(eventType string) bool {
}
func responsesSSEPayloadHasError(payload []byte) bool {
- for _, path := range []string{"error", "response.error"} {
- result := gjson.GetBytes(payload, path)
- if result.Exists() && result.Type != gjson.Null {
+ var fieldsBuf [4]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], payload, "error", "response.error", "code", "message")
+ for _, result := range fields[:2] {
+ if result.Exists() && result.Type != ojson.Null {
return true
}
}
- return gjson.GetBytes(payload, "code").Exists() && gjson.GetBytes(payload, "message").Exists()
+ return fields[2].Exists() && fields[3].Exists()
}
func responsesSSEDataPayload(frame []byte) ([]byte, bool) {
@@ -531,12 +591,14 @@ func responsesSSEFrameWithData(frame, payload []byte) []byte {
}
func (f *responsesSSEFramer) recordOutputItem(payload []byte) {
- item := gjson.GetBytes(payload, "item")
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], payload, "item", "output_index")
+ item := fields[0]
if !item.Exists() || !item.IsObject() || item.Get("type").String() == "" {
return
}
- if outputIndex := gjson.GetBytes(payload, "output_index"); outputIndex.Exists() {
+ if outputIndex := fields[1]; outputIndex.Exists() {
index := int(outputIndex.Int())
if f.outputItems == nil {
f.outputItems = make(map[int][]byte)
@@ -555,7 +617,7 @@ func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte {
if len(f.outputOrder) == 0 && len(f.unindexedOutputItems) == 0 {
return payload
}
- output := gjson.GetBytes(payload, "response.output")
+ output := ojson.GetBytesNoCopy(payload, "response.output")
if output.Exists() && (!output.IsArray() || len(output.Array()) > 0) {
return payload
}
@@ -585,7 +647,7 @@ func (f *responsesSSEFramer) repairCompletedPayload(payload []byte) []byte {
}
outputJSON.WriteByte(']')
- repaired, err := sjson.SetRawBytes(payload, "response.output", outputJSON.Bytes())
+ repaired, err := ojson.SetRawBytes(payload, "response.output", outputJSON.Bytes())
if err != nil {
return payload
}
@@ -794,13 +856,13 @@ func (h *OpenAIResponsesAPIHandler) OpenAIResponsesModels(c *gin.Context) {
})
}
-func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context, payload []byte) []byte {
- if h == nil {
- return payload
+func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2ToolsDoc(c *gin.Context, doc *ojson.Doc) bool {
+ if h == nil || doc == nil {
+ return false
}
cfg := h.CurrentConfig()
if cfg == nil {
- return payload
+ return false
}
requestCtx := context.Background()
@@ -814,26 +876,39 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context
requestHeaders = c.Request.Header
}
homeEnabled := h.AuthManager != nil && h.AuthManager.HomeEnabled()
- updated, prepared := multiagentv2.PrepareCodexMultiAgentV2Tools(
+ changed, prepared := multiagentv2.PrepareCodexMultiAgentV2ToolsDoc(
+ doc,
requestCtx,
requestHeaders,
- payload,
cfg.Client.Codex.OptimizeMultiAgentV2,
homeEnabled,
)
if prepared && c != nil {
c.Set(multiagentv2.CodexMultiAgentV2ToolsPreparedContextKey, true)
}
- return updated
+ return changed
}
-func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context, payload []byte) []byte {
- if h == nil {
+func (h *OpenAIResponsesAPIHandler) prepareCodexMultiAgentV2Tools(c *gin.Context, payload []byte) []byte {
+ if len(payload) == 0 {
+ doc := ojson.NewDoc(nil)
+ h.prepareCodexMultiAgentV2ToolsDoc(c, doc)
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !h.prepareCodexMultiAgentV2ToolsDoc(c, doc) {
return payload
}
+ return doc.Bytes()
+}
+
+func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegationDoc(c *gin.Context, doc *ojson.Doc) bool {
+ if h == nil || doc == nil {
+ return false
+ }
cfg := h.CurrentConfig()
if cfg == nil || !cfg.CodexOrphanDelegationCompatibility {
- return payload
+ return false
}
requestCtx := context.Background()
var requestHeaders http.Header
@@ -842,7 +917,28 @@ func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context,
requestHeaders = c.Request.Header
}
requestCtx = context.WithValue(requestCtx, "gin", c)
- return multiagentv2.RewriteCodexOrphanDelegationInput(requestCtx, requestHeaders, payload, true)
+ return multiagentv2.RewriteCodexOrphanDelegationInputDoc(doc, requestCtx, requestHeaders, true)
+}
+
+func (h *OpenAIResponsesAPIHandler) prepareCodexOrphanDelegation(c *gin.Context, payload []byte) []byte {
+ if len(payload) == 0 {
+ return payload
+ }
+ doc := ojson.NewDoc(payload)
+ if !h.prepareCodexOrphanDelegationDoc(c, doc) {
+ return payload
+ }
+ return doc.Bytes()
+}
+
+func (h *OpenAIResponsesAPIHandler) prepareCodexRequestPayload(c *gin.Context, payload []byte) []byte {
+ doc := ojson.NewDoc(payload)
+ toolsChanged := h.prepareCodexMultiAgentV2ToolsDoc(c, doc)
+ orphanChanged := h.prepareCodexOrphanDelegationDoc(c, doc)
+ if len(payload) == 0 || (!toolsChanged && !orphanChanged) {
+ return payload
+ }
+ return doc.Bytes()
}
// Responses handles the /v1/responses endpoint.
@@ -864,12 +960,11 @@ func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) {
return
}
- rawJSON = h.prepareCodexMultiAgentV2Tools(c, rawJSON)
- rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON)
+ rawJSON = h.prepareCodexRequestPayload(c, rawJSON)
// Check if the client requested a streaming response.
- streamResult := gjson.GetBytes(rawJSON, "stream")
- if streamResult.Type == gjson.True {
+ streamResult := ojson.GetBytesNoCopy(rawJSON, "stream")
+ if streamResult.Type == ojson.True {
h.handleStreamingResponse(c, rawJSON)
} else {
h.handleNonStreamingResponse(c, rawJSON)
@@ -889,10 +984,11 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) {
return
}
- rawJSON = h.prepareCodexOrphanDelegation(c, rawJSON)
+ doc := ojson.NewDoc(rawJSON)
+ orphanChanged := h.prepareCodexOrphanDelegationDoc(c, doc)
- streamResult := gjson.GetBytes(rawJSON, "stream")
- if streamResult.Type == gjson.True {
+ streamResult := doc.Get("stream")
+ if streamResult.Type == ojson.True {
c.JSON(http.StatusBadRequest, handlers.ErrorResponse{
Error: handlers.ErrorDetail{
Message: "Streaming not supported for compact responses",
@@ -901,14 +997,17 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) {
})
return
}
+ streamDeleted := false
if streamResult.Exists() {
- if updated, err := sjson.DeleteBytes(rawJSON, "stream"); err == nil {
- rawJSON = updated
- }
+ _ = doc.Delete("stream")
+ streamDeleted = true
+ }
+ if orphanChanged || streamDeleted {
+ rawJSON = doc.Bytes()
}
c.Header("Content-Type", "application/json")
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
resp, upstreamHeaders, errMsg := h.ExecuteWithAuthManager(cliCtx, h.HandlerType(), modelName, rawJSON, "responses/compact")
@@ -933,7 +1032,7 @@ func (h *OpenAIResponsesAPIHandler) Compact(c *gin.Context) {
func (h *OpenAIResponsesAPIHandler) handleNonStreamingResponse(c *gin.Context, rawJSON []byte) {
c.Header("Content-Type", "application/json")
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
stopKeepAlive := h.StartNonStreamingKeepAlive(c, cliCtx)
@@ -970,7 +1069,7 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ
}
// New core execution path
- modelName := gjson.GetBytes(rawJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(rawJSON, "model").String())
deliveryCtx, finishDelivery := usage.WithStreamDelivery(context.Background())
defer finishDelivery(context.Canceled)
cliCtx, cancelExecution := h.GetContextWithCancel(h, c, deliveryCtx)
@@ -1381,7 +1480,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flush
if framer != nil {
seq = framer.dataFrames
}
- if origSeq := gjson.Get(errText, "sequence_number"); origSeq.Exists() {
+ if origSeq := ojson.Get(errText, "sequence_number"); origSeq.Exists() {
seq = int(origSeq.Int())
}
if isCodexResponsesClientRequest(c) {
diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go
index 4fb269edff6..24290ebdeb3 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_error_test.go
@@ -1163,3 +1163,22 @@ func TestResponsesHandlerSendKeepAliveBeforeFT(t *testing.T) {
}
})
}
+
+func TestResponsesSSEPayloadErrorMessage_ImageGenerationUserErrorMapsToBadRequest(t *testing.T) {
+ payloads := [][]byte{
+ []byte(`{"type":"response.failed","response":{"status":"failed","error":{"type":"image_generation_user_error","code":"invalid_value","message":"Complexity 'medium' is not supported for this image model.","param":"complexity"}}}`),
+ []byte(`{"type":"error","error":{"type":"image_generation_user_error","code":"invalid_value","message":"Complexity 'medium' is not supported for this image model.","param":"complexity"}}`),
+ }
+ for _, payload := range payloads {
+ errMsg := responsesSSEPayloadErrorMessage(payload)
+ if errMsg == nil {
+ t.Fatalf("expected non-nil ErrorMessage for %s", payload)
+ }
+ if errMsg.StatusCode != http.StatusBadRequest {
+ t.Fatalf("StatusCode = %d, want %d for payload %s", errMsg.StatusCode, http.StatusBadRequest, payload)
+ }
+ if !strings.Contains(errMsg.Error.Error(), "/v1/images/generations") || !strings.Contains(errMsg.Error.Error(), "gpt-5.4-mini") {
+ t.Fatalf("expected remediation guidance in error message, got %q", errMsg.Error.Error())
+ }
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
index 203037b43fe..1477179ba56 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers_stream_test.go
@@ -9,11 +9,11 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func newResponsesStreamTestHandler(t *testing.T) (*OpenAIResponsesAPIHandler, *httptest.ResponseRecorder, *gin.Context, http.Flusher) {
@@ -127,7 +127,7 @@ func TestForwardResponsesStreamPreservesResponseFailedEventAndEnvelopeForAllClie
if !ok {
t.Fatalf("no data payload in terminal frame: %q", last)
}
- resp := gjson.GetBytes(payload, "response")
+ resp := ojson.GetBytesNoCopy(payload, "response")
if resp.Get("id").String() != "msg_1" || resp.Get("status").String() != "failed" {
t.Fatalf("response envelope lost id or status: %s", payload)
}
@@ -160,7 +160,7 @@ func TestForwardResponsesStreamResponseFailedPreservesLongOutputWhileSanitizingE
if !ok {
t.Fatalf("missing data payload: %q", body)
}
- resp := gjson.GetBytes(payload, "response")
+ resp := ojson.GetBytesNoCopy(payload, "response")
if got := resp.Get("output.0.encrypted_content").String(); got != longEncrypted {
t.Fatalf("encrypted_content len = %d, want %d", len(got), len(longEncrypted))
}
@@ -222,14 +222,14 @@ func TestForwardResponsesStreamRepairsEmptyCompletedOutputFromDoneItems(t *testi
}
payload := strings.TrimPrefix(parts[2], "data: ")
- output := gjson.Get(payload, "response.output")
+ output := ojson.Get(payload, "response.output")
if !output.IsArray() || len(output.Array()) != 2 {
t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw)
}
- if got := gjson.Get(payload, "response.output.1.name").String(); got != "shell" {
+ if got := ojson.Get(payload, "response.output.1.name").String(); got != "shell" {
t.Fatalf("expected function_call name to be preserved, got %q in %s", got, payload)
}
- if got := gjson.Get(payload, "response.output.1.arguments").String(); got != `{"cmd":"pwd"}` {
+ if got := ojson.Get(payload, "response.output.1.arguments").String(); got != `{"cmd":"pwd"}` {
t.Fatalf("expected function_call arguments to be preserved, got %q in %s", got, payload)
}
}
@@ -253,14 +253,14 @@ func TestForwardResponsesStreamRepairsMixedIndexedAndUnindexedDoneItems(t *testi
}
payload := strings.TrimPrefix(parts[2], "data: ")
- output := gjson.Get(payload, "response.output")
+ output := ojson.Get(payload, "response.output")
if !output.IsArray() || len(output.Array()) != 2 {
t.Fatalf("expected repaired completed output with 2 items, got %s", output.Raw)
}
- if got := gjson.Get(payload, "response.output.0.name").String(); got != "shell" {
+ if got := ojson.Get(payload, "response.output.0.name").String(); got != "shell" {
t.Fatalf("expected indexed function_call to be preserved first, got %q in %s", got, payload)
}
- if got := gjson.Get(payload, "response.output.1.id").String(); got != "msg-1" {
+ if got := ojson.Get(payload, "response.output.1.id").String(); got != "msg-1" {
t.Fatalf("expected unindexed message to be appended, got %q in %s", got, payload)
}
}
@@ -293,7 +293,7 @@ func TestForwardResponsesStreamRepairsMultilineCompletedOutputAsSSEDataLines(t *
if !ok {
t.Fatalf("expected completed frame to contain data payload: %q", parts[1])
}
- output := gjson.GetBytes(payload, "response.output")
+ output := ojson.GetBytesNoCopy(payload, "response.output")
if !output.IsArray() || len(output.Array()) != 1 {
t.Fatalf("expected repaired completed output with 1 item, got %s from %q", output.Raw, payload)
}
diff --git a/sdk/api/handlers/openai/openai_responses_interrupt_test.go b/sdk/api/handlers/openai/openai_responses_interrupt_test.go
index 7cbfa6960a2..9d9cbf4c4bf 100644
--- a/sdk/api/handlers/openai/openai_responses_interrupt_test.go
+++ b/sdk/api/handlers/openai/openai_responses_interrupt_test.go
@@ -15,6 +15,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -24,7 +25,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// TestResponsesInterruptInFlight verifies that a Codex response.interrupt sent while
@@ -60,7 +60,7 @@ func TestResponsesInterruptInFlight(t *testing.T) {
t.Errorf("upstream write: %v", errWrite)
}
}
- if payload := read(); gjson.GetBytes(payload, "type").String() != "response.create" {
+ if payload := read(); ojson.GetBytesNoCopy(payload, "type").String() != "response.create" {
t.Errorf("expected response.create, got %s", payload)
return
}
@@ -71,7 +71,7 @@ func TestResponsesInterruptInFlight(t *testing.T) {
return
}
write(`{"type":"response.incomplete","response":{"id":"r1","status":"incomplete","incomplete_details":{"reason":"interrupted"},"usage":{"output_tokens":7},"output":[]}}`)
- if payload := read(); gjson.GetBytes(payload, "type").String() != "response.create" {
+ if payload := read(); ojson.GetBytesNoCopy(payload, "type").String() != "response.create" {
t.Errorf("expected follow-up response.create, got %s", payload)
return
}
@@ -129,7 +129,7 @@ func TestResponsesInterruptInFlight(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != event {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != event {
t.Fatalf("expected %s, got %s", event, payload)
}
}
@@ -353,7 +353,7 @@ func TestResponsesInterruptStopsHTTPUpstream(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if got := gjson.GetBytes(created, "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(created, "type").String(); got != "response.created" {
t.Fatalf("expected response.created, got %s", created)
}
if errSend := client.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.interrupt","response_id":"r-http","mode":"discard_partial_items"}`)); errSend != nil {
@@ -363,13 +363,13 @@ func TestResponsesInterruptStopsHTTPUpstream(t *testing.T) {
if errInterrupted != nil {
t.Fatal(errInterrupted)
}
- if got := gjson.GetBytes(interrupted, "type").String(); got != "response.incomplete" {
+ if got := ojson.GetBytesNoCopy(interrupted, "type").String(); got != "response.incomplete" {
t.Fatalf("expected response.incomplete, got %s", interrupted)
}
- if got := gjson.GetBytes(interrupted, "response.id").String(); got != "r-http" {
+ if got := ojson.GetBytesNoCopy(interrupted, "response.id").String(); got != "r-http" {
t.Fatalf("interrupted response id = %q", got)
}
- if got := gjson.GetBytes(interrupted, "response.incomplete_details.reason").String(); got != "interrupted" {
+ if got := ojson.GetBytesNoCopy(interrupted, "response.incomplete_details.reason").String(); got != "interrupted" {
t.Fatalf("interrupt reason = %q, payload %s", got, interrupted)
}
select {
@@ -384,14 +384,14 @@ func TestResponsesInterruptStopsHTTPUpstream(t *testing.T) {
if errFollow != nil {
t.Fatal(errFollow)
}
- if got := gjson.GetBytes(followCreated, "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(followCreated, "type").String(); got != "response.created" {
t.Fatalf("expected follow-up response.created, got %s", followCreated)
}
_, followDone, errDone := client.ReadMessage()
if errDone != nil {
t.Fatal(errDone)
}
- if got := gjson.GetBytes(followDone, "type").String(); got != "response.completed" {
+ if got := ojson.GetBytesNoCopy(followDone, "type").String(); got != "response.completed" {
t.Fatalf("expected follow-up response.completed, got %s", followDone)
}
}
@@ -429,7 +429,7 @@ func TestResponsesInterruptUsesHomeSessionAuth(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != "response.created" {
t.Fatalf("expected response.created, got %s", payload)
}
if _, ok := manager.GetByID(authID); ok {
@@ -454,7 +454,7 @@ func TestResponsesInterruptUsesHomeSessionAuth(t *testing.T) {
if errCompleted != nil {
t.Fatal(errCompleted)
}
- if got := gjson.GetBytes(completed, "type").String(); got != "response.completed" {
+ if got := ojson.GetBytesNoCopy(completed, "type").String(); got != "response.completed" {
t.Fatalf("expected response.completed after interrupt, got %s", completed)
}
executor.mu.Lock()
@@ -470,14 +470,14 @@ func TestResponsesInterruptUsesHomeSessionAuth(t *testing.T) {
if errFollow != nil {
t.Fatal(errFollow)
}
- if got := gjson.GetBytes(followCreated, "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(followCreated, "type").String(); got != "response.created" {
t.Fatalf("expected follow-up response.created, got %s", followCreated)
}
_, followDone, errDone := client.ReadMessage()
if errDone != nil {
t.Fatal(errDone)
}
- if got := gjson.GetBytes(followDone, "type").String(); got != "response.completed" {
+ if got := ojson.GetBytesNoCopy(followDone, "type").String(); got != "response.completed" {
t.Fatalf("expected follow-up response.completed, got %s", followDone)
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_late_interrupt_test.go b/sdk/api/handlers/openai/openai_responses_late_interrupt_test.go
index 962f8e1aba9..750ae6d3c0f 100644
--- a/sdk/api/handlers/openai/openai_responses_late_interrupt_test.go
+++ b/sdk/api/handlers/openai/openai_responses_late_interrupt_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -17,7 +18,6 @@ import (
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
// A delayed control frame must not queue an error for the next create, nor
@@ -54,7 +54,7 @@ func TestResponsesLateInterruptDoesNotPoisonNextCreate(t *testing.T) {
if errRead != nil {
return
}
- if gjson.GetBytes(create, "type").String() != "response.create" {
+ if ojson.GetBytesNoCopy(create, "type").String() != "response.create" {
t.Errorf("terminal interrupt reached upstream: %s", create)
return
}
@@ -69,7 +69,7 @@ func TestResponsesLateInterruptDoesNotPoisonNextCreate(t *testing.T) {
if errInterrupt != nil {
return
}
- if gjson.GetBytes(interrupt, "response_id").String() != "r2" {
+ if ojson.GetBytesNoCopy(interrupt, "response_id").String() != "r2" {
t.Errorf("old interrupt affected active r2: %s", interrupt)
return
}
@@ -136,7 +136,7 @@ func TestResponsesLateInterruptDoesNotPoisonNextCreate(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if gjson.GetBytes(payload, "type").String() != event || gjson.GetBytes(payload, "response.id").String() != id {
+ if ojson.GetBytesNoCopy(payload, "type").String() != event || ojson.GetBytesNoCopy(payload, "response.id").String() != id {
t.Fatalf("expected %s for %s, got %s", event, id, payload)
}
}
@@ -227,7 +227,7 @@ func TestResponsesInterruptDiagnosticsAreSanitized(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if gjson.GetBytes(payload, "type").String() != "error" {
+ if ojson.GetBytesNoCopy(payload, "type").String() != "error" {
t.Fatalf("unknown interrupt must still fail: %s", payload)
}
if errClose := client.Close(); errClose != nil {
diff --git a/sdk/api/handlers/openai/openai_responses_multi_agent_test.go b/sdk/api/handlers/openai/openai_responses_multi_agent_test.go
index a5d1820866e..d1de0e41ddc 100644
--- a/sdk/api/handlers/openai/openai_responses_multi_agent_test.go
+++ b/sdk/api/handlers/openai/openai_responses_multi_agent_test.go
@@ -9,6 +9,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
multiagentv2 "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/optimize-multi-agent-v2"
@@ -17,7 +18,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundary(t *testing.T) {
@@ -38,11 +38,11 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundary(t *testing.T) {
}`)
got := handler.prepareCodexMultiAgentV2Tools(ginContext, payload)
- if namespace := gjson.GetBytes(got, "tools.0.name").String(); namespace != "collaboration" {
+ if namespace := ojson.GetBytesNoCopy(got, "tools.0.name").String(); namespace != "collaboration" {
t.Fatalf("namespace = %q, want collaboration", namespace)
}
for _, path := range []string{"tools.0.tools.0", "tools.0.tools.1"} {
- if encrypted := gjson.GetBytes(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(got, path+".parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("%s message.encrypted was not removed: %s", path, encrypted.Raw)
}
}
@@ -52,6 +52,23 @@ func TestPrepareCodexMultiAgentV2ToolsAtResponsesBoundary(t *testing.T) {
}
}
+func TestPrepareCodexRequestPayloadUnchangedReturnsSameSlice(t *testing.T) {
+ t.Parallel()
+
+ base := handlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{Client: sdkconfig.ClientConfig{Codex: sdkconfig.CodexClientConfig{OptimizeMultiAgentV2: true}}}, nil)
+ handler := NewOpenAIResponsesAPIHandler(base)
+ request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
+ request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
+ ginContext, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ginContext.Request = request
+
+ payload := []byte(`{"model":"gpt-5.4","input":"hello"}`)
+ got := handler.prepareCodexRequestPayload(ginContext, payload)
+ if len(got) == 0 || &got[0] != &payload[0] {
+ t.Fatal("expected prepareCodexRequestPayload to return original payload slice without reallocating when unchanged")
+ }
+}
+
func TestResponsesPreparesCodexMultiAgentV2ToolsForHTTPAndSSE(t *testing.T) {
t.Parallel()
@@ -76,10 +93,10 @@ func TestResponsesPreparesCodexMultiAgentV2ToolsForHTTPAndSSE(t *testing.T) {
t.Fatalf("captured payload count = %d, want 1", len(payloads))
}
captured := payloads[0]
- if encrypted := gjson.GetBytes(captured, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(captured, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("message.encrypted was not removed: %s", captured)
}
- if namespace := gjson.GetBytes(captured, "tools.0.name").String(); namespace != "collaboration" {
+ if namespace := ojson.GetBytesNoCopy(captured, "tools.0.name").String(); namespace != "collaboration" {
t.Fatalf("namespace = %q, want collaboration", namespace)
}
})
@@ -169,10 +186,10 @@ func TestResponsesWebsocketPreparesCodexMultiAgentV2Tools(t *testing.T) {
t.Fatalf("captured payload count = %d, want 1", len(payloads))
}
captured := payloads[0]
- if encrypted := gjson.GetBytes(captured, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
+ if encrypted := ojson.GetBytesNoCopy(captured, "tools.0.tools.0.parameters.properties.message.encrypted"); encrypted.Exists() {
t.Fatalf("message.encrypted was not removed: %s", captured)
}
- if namespace := gjson.GetBytes(captured, "tools.0.name").String(); namespace != "collaboration" {
+ if namespace := ojson.GetBytesNoCopy(captured, "tools.0.name").String(); namespace != "collaboration" {
t.Fatalf("namespace = %q, want collaboration", namespace)
}
}
@@ -213,10 +230,10 @@ func TestClientMultiAgentPreparationDoesNotWaitForOAuthCredential(t *testing.T)
payload := []byte(`{"input":[{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"task "}],"tools":[{"type":"function","name":"send_message","parameters":{"properties":{"message":{"encrypted":true}}}}]}`)
got := handler.prepareCodexMultiAgentV2Tools(ginContext, payload)
got = handler.prepareCodexOrphanDelegation(ginContext, got)
- if gjson.GetBytes(got, "tools.0.parameters.properties.message.encrypted").Exists() {
+ if ojson.GetBytesNoCopy(got, "tools.0.parameters.properties.message.encrypted").Exists() {
t.Fatalf("client preparation did not remove encryption: %s", got)
}
- if gjson.GetBytes(got, "input.0.type").String() != "message" {
+ if ojson.GetBytesNoCopy(got, "input.0.type").String() != "message" {
t.Fatalf("shared orphan preparation waited for credential selection: %s", got)
}
if _, exists := ginContext.Get(multiagentv2.CodexMultiAgentV2ToolsPreparedContextKey); !exists {
@@ -284,7 +301,7 @@ func TestResponsesOrphanCodexDelegationCompatibility(t *testing.T) {
t.Fatalf("captured payload count = %d, want 1", len(payloads))
}
captured := payloads[0]
- parsed := gjson.ParseBytes(captured)
+ parsed := ojson.GetBytesNoCopy(captured, "@this")
if itemType := parsed.Get("input.0.type").String(); itemType != "message" {
t.Fatalf("input.0.type = %q, want message; captured=%s", itemType, captured)
}
@@ -308,7 +325,7 @@ func TestResponsesOrphanCodexDelegationCompatibility(t *testing.T) {
t.Fatalf("captured payload count = %d, want 2", len(payloads))
}
capturedNoHeader := payloads[1]
- parsedNoHeader := gjson.ParseBytes(capturedNoHeader)
+ parsedNoHeader := ojson.GetBytesNoCopy(capturedNoHeader, "@this")
if itemType := parsedNoHeader.Get("input.0.type").String(); itemType != "function_call_output" {
t.Fatalf("input.0.type = %q, want function_call_output without header; captured=%s", itemType, capturedNoHeader)
}
diff --git a/sdk/api/handlers/openai/openai_responses_steering_auth_test.go b/sdk/api/handlers/openai/openai_responses_steering_auth_test.go
index 130d9042b52..aa8ab6a34e3 100644
--- a/sdk/api/handlers/openai/openai_responses_steering_auth_test.go
+++ b/sdk/api/handlers/openai/openai_responses_steering_auth_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -19,8 +20,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func TestResponsesSteeringDisabledAccountCannotSendAnotherFrame(t *testing.T) {
@@ -116,7 +115,7 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if errRead != nil {
return
}
- if gjson.GetBytes(raw, "type").String() == "response.steer" {
+ if ojson.GetBytesNoCopy(raw, "type").String() == "response.steer" {
upstreamSteers.Add(1)
continue
}
@@ -188,10 +187,10 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(steerErrMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(steerErrMsg, "type").String(); got != "error" {
t.Fatalf("steer after revoke event = %q, want error (%s)", got, steerErrMsg)
}
- if got := int(gjson.GetBytes(steerErrMsg, "status").Int()); got != http.StatusForbidden {
+ if got := int(ojson.GetBytesNoCopy(steerErrMsg, "status").Int()); got != http.StatusForbidden {
t.Fatalf("steer after revoke status = %d, want %d (%s)", got, http.StatusForbidden, steerErrMsg)
}
if got := upstreamSteers.Load(); got != 0 {
@@ -205,10 +204,10 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(msg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(msg, "type").String(); got != "error" {
t.Fatalf("second create after revoke event = %q, want error (%s)", got, msg)
}
- if got := int(gjson.GetBytes(msg, "status").Int()); got != http.StatusForbidden {
+ if got := int(ojson.GetBytesNoCopy(msg, "status").Int()); got != http.StatusForbidden {
t.Fatalf("second create status = %d, want %d (%s)", got, http.StatusForbidden, msg)
}
if got := upstreamCreates.Load(); got != 1 {
@@ -224,10 +223,10 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(steerModelDeniedMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(steerModelDeniedMsg, "type").String(); got != "error" {
t.Fatalf("steer with spoofed model on denied bound model event = %q, want error (%s)", got, steerModelDeniedMsg)
}
- if got := int(gjson.GetBytes(steerModelDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
+ if got := int(ojson.GetBytesNoCopy(steerModelDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
t.Fatalf("steer with spoofed model status = %d, want %d (%s)", got, http.StatusTooManyRequests, steerModelDeniedMsg)
}
if got := upstreamSteers.Load(); got != 0 {
@@ -241,10 +240,10 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(aliasDeniedMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(aliasDeniedMsg, "type").String(); got != "error" {
t.Fatalf("omitted-model continuation with denied client alias event = %q, want error (%s)", got, aliasDeniedMsg)
}
- if got := int(gjson.GetBytes(aliasDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
+ if got := int(ojson.GetBytesNoCopy(aliasDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
t.Fatalf("omitted-model continuation with denied client alias status = %d, want %d (%s)", got, http.StatusTooManyRequests, aliasDeniedMsg)
}
if got := upstreamCreates.Load(); got != 1 {
@@ -272,10 +271,10 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(steerRotatedMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(steerRotatedMsg, "type").String(); got != "error" {
t.Fatalf("steer after key rotation event = %q, want error (%s)", got, steerRotatedMsg)
}
- if got := int(gjson.GetBytes(steerRotatedMsg, "status").Int()); got != http.StatusUnauthorized {
+ if got := int(ojson.GetBytesNoCopy(steerRotatedMsg, "status").Int()); got != http.StatusUnauthorized {
t.Fatalf("steer after key rotation status = %d, want %d (%s)", got, http.StatusUnauthorized, steerRotatedMsg)
}
if got := upstreamSteers.Load(); got != 0 {
@@ -289,10 +288,10 @@ func TestResponsesSteeringRevokedClientKeyCannotSendSecondCreate(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(createRotatedMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(createRotatedMsg, "type").String(); got != "error" {
t.Fatalf("create after key rotation event = %q, want error (%s)", got, createRotatedMsg)
}
- if got := int(gjson.GetBytes(createRotatedMsg, "status").Int()); got != http.StatusUnauthorized {
+ if got := int(ojson.GetBytesNoCopy(createRotatedMsg, "status").Int()); got != http.StatusUnauthorized {
t.Fatalf("create after key rotation status = %d, want %d (%s)", got, http.StatusUnauthorized, createRotatedMsg)
}
if got := upstreamCreates.Load(); got != 2 {
@@ -307,7 +306,7 @@ type stripModelInterceptorHost struct {
func (*stripModelInterceptorHost) HasStreamInterceptors() bool { return false }
func (*stripModelInterceptorHost) InterceptRequestBeforeAuth(_ context.Context, req pluginapi.RequestInterceptRequest) pluginapi.RequestInterceptResponse {
- body, _ := sjson.DeleteBytes(req.Body, "model")
+ body, _ := ojson.DeleteBytes(req.Body, "model")
return pluginapi.RequestInterceptResponse{Body: body}
}
@@ -330,7 +329,7 @@ func TestResponsesSteeringInterceptorRewrittenBodyBindsClientModel(t *testing.T)
if errRead != nil {
return
}
- if gjson.GetBytes(raw, "type").String() == "response.steer" {
+ if ojson.GetBytesNoCopy(raw, "type").String() == "response.steer" {
upstreamSteers.Add(1)
continue
}
@@ -405,10 +404,10 @@ func TestResponsesSteeringInterceptorRewrittenBodyBindsClientModel(t *testing.T)
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(aliasDeniedMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(aliasDeniedMsg, "type").String(); got != "error" {
t.Fatalf("omitted-model continuation for denied client alias = %s, want 429 error", aliasDeniedMsg)
}
- if got := int(gjson.GetBytes(aliasDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
+ if got := int(ojson.GetBytesNoCopy(aliasDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
t.Fatalf("omitted-model continuation status = %d, want %d (%s)", got, http.StatusTooManyRequests, aliasDeniedMsg)
}
if got := upstreamCreates.Load(); got != 1 {
@@ -422,10 +421,10 @@ func TestResponsesSteeringInterceptorRewrittenBodyBindsClientModel(t *testing.T)
if err != nil {
t.Fatal(err)
}
- if got := gjson.GetBytes(steerDeniedMsg, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(steerDeniedMsg, "type").String(); got != "error" {
t.Fatalf("steer for denied client alias = %s, want 429 error", steerDeniedMsg)
}
- if got := int(gjson.GetBytes(steerDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
+ if got := int(ojson.GetBytesNoCopy(steerDeniedMsg, "status").Int()); got != http.StatusTooManyRequests {
t.Fatalf("steer status = %d, want %d (%s)", got, http.StatusTooManyRequests, steerDeniedMsg)
}
if got := upstreamSteers.Load(); got != 0 {
@@ -441,7 +440,7 @@ func TestResponsesSteeringInterceptorRewrittenBodyBindsClientModel(t *testing.T)
if errRead != nil {
t.Fatal(errRead)
}
- if got := gjson.GetBytes(msg, "type").String(); got == "error" {
+ if got := ojson.GetBytesNoCopy(msg, "type").String(); got == "error" {
t.Fatalf("omitted-model continuation rejected when only upstream model is denied: %s", msg)
}
}
@@ -457,7 +456,7 @@ func TestResponsesSteeringInterceptorRewrittenBodyBindsClientModel(t *testing.T)
if errRead != nil {
t.Fatalf("explicit client alias continuation failed: %v", errRead)
}
- if got := gjson.GetBytes(msg, "type").String(); got == "error" {
+ if got := ojson.GetBytesNoCopy(msg, "type").String(); got == "error" {
t.Fatalf("explicit client alias continuation returned error: %s", msg)
}
}
@@ -537,7 +536,7 @@ func TestResponsesSteeringSubsequentWebsocketMessagesRunRequestInterceptors(t *t
return
}
upstreamFrames <- bytes.Clone(msg)
- switch gjson.GetBytes(msg, "type").String() {
+ switch ojson.GetBytesNoCopy(msg, "type").String() {
case "response.create", "response.append":
if err := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.created","response":{"id":"resp_1"}}`)); err != nil {
return
diff --git a/sdk/api/handlers/openai/openai_responses_steering_error_test.go b/sdk/api/handlers/openai/openai_responses_steering_error_test.go
index c4da65ce14e..1cc9567354a 100644
--- a/sdk/api/handlers/openai/openai_responses_steering_error_test.go
+++ b/sdk/api/handlers/openai/openai_responses_steering_error_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -19,7 +20,6 @@ import (
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
// Exercise both WebSocket endpoints with the real handler, manager and executor.
@@ -78,7 +78,7 @@ func TestResponsesSteeringErrorRecoveryIntegration(t *testing.T) {
}
if recoverable {
corrected := read()
- if gjson.GetBytes(corrected, "instructions").String() != "CORRECTED" {
+ if ojson.GetBytesNoCopy(corrected, "instructions").String() != "CORRECTED" {
t.Errorf("corrected create missing: %s", corrected)
}
write([]byte(`{"type":"response.created","response":{"id":"corrected","output":[]}}`))
@@ -133,12 +133,12 @@ func TestResponsesSteeringErrorRecoveryIntegration(t *testing.T) {
}
break
}
- switch gjson.GetBytes(p, "type").String() {
+ switch ojson.GetBytesNoCopy(p, "type").String() {
case "response.completed":
- if gjson.GetBytes(p, "response.id").String() == "first" {
+ if ojson.GetBytesNoCopy(p, "response.id").String() == "first" {
send("REJECTED")
} else {
- completed = gjson.GetBytes(p, "response.output.0.content.0.text").String() == "RECOVERED"
+ completed = ojson.GetBytesNoCopy(p, "response.output.0.content.0.text").String() == "RECOVERED"
_ = c.Close()
}
case "error":
@@ -275,7 +275,7 @@ func TestResponsesWebsocketClosesOnIdleCodexDisconnect(t *testing.T) {
if errRead != nil {
t.Fatalf("read response before upstream close: %v", errRead)
}
- if gjson.GetBytes(payload, "type").String() == "response.completed" {
+ if ojson.GetBytesNoCopy(payload, "type").String() == "response.completed" {
break
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_steering_integration_test.go b/sdk/api/handlers/openai/openai_responses_steering_integration_test.go
index 5e9f3cc0844..359e6af24e7 100644
--- a/sdk/api/handlers/openai/openai_responses_steering_integration_test.go
+++ b/sdk/api/handlers/openai/openai_responses_steering_integration_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -19,7 +20,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
// Exercise the actual downstream handler, auth manager and Codex executor.
@@ -53,7 +53,7 @@ func TestResponsesSteeringFullDuplexIntegration(t *testing.T) {
t.Errorf("upstream write: %v", err)
}
}
- if gjson.GetBytes(read(), "type").String() != "response.create" {
+ if ojson.GetBytesNoCopy(read(), "type").String() != "response.create" {
t.Error("missing initial create")
}
write(`{"type":"response.created","response":{"id":"r1"}}`)
@@ -75,7 +75,7 @@ func TestResponsesSteeringFullDuplexIntegration(t *testing.T) {
return
}
b := read()
- if gjson.GetBytes(b, "type").String() != "response.create" || gjson.GetBytes(b, "input.0.call_id").String() != "call1" {
+ if ojson.GetBytesNoCopy(b, "type").String() != "response.create" || ojson.GetBytesNoCopy(b, "input.0.call_id").String() != "call1" {
t.Errorf("required input lost: %s", b)
}
} else {
@@ -138,14 +138,14 @@ func TestResponsesSteeringFullDuplexIntegration(t *testing.T) {
if err != nil {
t.Fatalf("downstream read before expected terminal event: %v", err)
}
- kind := gjson.GetBytes(b, "type").String()
+ kind := ojson.GetBytesNoCopy(b, "type").String()
if kind == "error" {
- if !strings.HasPrefix(scenario, "disconnect_") || gjson.GetBytes(b, "error.code").String() != "upstream_websocket_lost" {
+ if !strings.HasPrefix(scenario, "disconnect_") || ojson.GetBytesNoCopy(b, "error.code").String() != "upstream_websocket_lost" {
t.Fatalf("unexpected downstream error: %s", b)
}
break
}
- if kind == "response.created" && gjson.GetBytes(b, "response.id").String() == "r1" {
+ if kind == "response.created" && ojson.GetBytesNoCopy(b, "response.id").String() == "r1" {
send(control1)
send(control2)
}
@@ -158,11 +158,11 @@ func TestResponsesSteeringFullDuplexIntegration(t *testing.T) {
if kind == "response.steer.pending" && scenario == "tool_pending" {
send([]byte(`{"type":"response.create","previous_response_id":"r1","input":[{"type":"function_call_output","call_id":"call1","output":"found"}]}`))
}
- if kind == "response.completed" && gjson.GetBytes(b, "response.id").String() == "r2" {
+ if kind == "response.completed" && ojson.GetBytesNoCopy(b, "response.id").String() == "r2" {
if strings.HasPrefix(scenario, "disconnect_") {
t.Fatal("successor completed instead of reporting upstream disconnect")
}
- completed = gjson.GetBytes(b, "response.output.0.content.0.text").String() == "STEER_OK"
+ completed = ojson.GetBytesNoCopy(b, "response.output.0.content.0.text").String() == "STEER_OK"
_ = c.Close()
break
}
diff --git a/sdk/api/handlers/openai/openai_responses_steering_test.go b/sdk/api/handlers/openai/openai_responses_steering_test.go
index 6b44d4bebf9..344ad5da4ca 100644
--- a/sdk/api/handlers/openai/openai_responses_steering_test.go
+++ b/sdk/api/handlers/openai/openai_responses_steering_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -16,7 +17,6 @@ import (
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
// TestResponsesSteerRejectedWhenDisabled verifies that when codex.response-steering is
@@ -55,7 +55,7 @@ func TestResponsesSteerInFlightWebSocket(t *testing.T) {
t.Errorf("read create: %v", err)
return
}
- if gjson.GetBytes(p, "type").String() != "response.create" {
+ if ojson.GetBytesNoCopy(p, "type").String() != "response.create" {
t.Errorf("expected response.create, got %s", p)
return
}
@@ -69,7 +69,7 @@ func TestResponsesSteerInFlightWebSocket(t *testing.T) {
t.Errorf("read steer: %v", err)
return
}
- if gjson.GetBytes(steerPayload, "type").String() == "response.steer" {
+ if ojson.GetBytesNoCopy(steerPayload, "type").String() == "response.steer" {
close(upstreamReceivedSteer)
_ = c.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.steer.accepted","steer":{"id":"s1","previous_response_id":"r1"}}`))
} else {
@@ -122,7 +122,7 @@ func TestResponsesSteerInFlightWebSocket(t *testing.T) {
if err != nil {
t.Fatalf("read response.created: %v", err)
}
- if gjson.GetBytes(p, "type").String() != "response.created" {
+ if ojson.GetBytesNoCopy(p, "type").String() != "response.created" {
t.Fatalf("expected response.created, got %s", p)
}
diff --git a/sdk/api/handlers/openai/openai_responses_steering_validation_test.go b/sdk/api/handlers/openai/openai_responses_steering_validation_test.go
index 0fe242c3a69..dc7188ab523 100644
--- a/sdk/api/handlers/openai/openai_responses_steering_validation_test.go
+++ b/sdk/api/handlers/openai/openai_responses_steering_validation_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -17,7 +18,6 @@ import (
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
func TestResponsesSteeringLocalValidationRecovery(t *testing.T) {
@@ -49,7 +49,7 @@ func TestResponsesSteeringLocalValidationRecovery(t *testing.T) {
}
read()
write(`{"type":"response.created","response":{"id":"first","output":[]}}`)
- if p := read(); gjson.GetBytes(p, "type").String() != "response.steer" {
+ if p := read(); ojson.GetBytesNoCopy(p, "type").String() != "response.steer" {
t.Errorf("expected corrected steering, got %s", p)
return
}
@@ -57,7 +57,7 @@ func TestResponsesSteeringLocalValidationRecovery(t *testing.T) {
write(`{"type":"response.completed","response":{"id":"first","output":[]}}`)
write(`{"type":"response.created","response":{"id":"steered","previous_response_id":"first","output":[]}}`)
write(`{"type":"response.completed","response":{"id":"steered","output":[]}}`)
- if p := read(); gjson.GetBytes(p, "type").String() != "response.create" {
+ if p := read(); ojson.GetBytesNoCopy(p, "type").String() != "response.create" {
t.Errorf("expected corrected create, got %s", p)
return
}
@@ -105,24 +105,24 @@ func TestResponsesSteeringLocalValidationRecovery(t *testing.T) {
if e != nil {
t.Fatalf("local validation closed the established socket: %v", e)
}
- switch gjson.GetBytes(p, "type").String() {
+ switch ojson.GetBytesNoCopy(p, "type").String() {
case "response.created":
- if gjson.GetBytes(p, "response.id").String() == "first" && errorsSeen != 0 {
+ if ojson.GetBytesNoCopy(p, "response.id").String() == "first" && errorsSeen != 0 {
t.Fatal("local error preceded bootstrap")
}
case "error":
errorsSeen++
- if gjson.GetBytes(p, "status").Int() != 400 || gjson.GetBytes(p, "error.type").String() != "invalid_request_error" {
+ if ojson.GetBytesNoCopy(p, "status").Int() != 400 || ojson.GetBytesNoCopy(p, "error.type").String() != "invalid_request_error" {
t.Fatalf("invalid error envelope: %s", p)
}
switch errorsSeen {
case 1:
- if !strings.Contains(gjson.GetBytes(p, "error.message").String(), "JSON") {
+ if !strings.Contains(ojson.GetBytesNoCopy(p, "error.message").String(), "JSON") {
t.Errorf("missing JSON validation message: %s", p)
}
send(`{"type":"unsupported.request"}`)
case 2:
- if !strings.Contains(gjson.GetBytes(p, "error.message").String(), "unsupported") {
+ if !strings.Contains(ojson.GetBytesNoCopy(p, "error.message").String(), "unsupported") {
t.Errorf("missing type validation message: %s", p)
}
send(`{"type":"response.steer","steering_id":"corrected","input":[{"role":"user","content":[{"type":"input_text","text":"continue"}]}]}`)
@@ -132,9 +132,9 @@ func TestResponsesSteeringLocalValidationRecovery(t *testing.T) {
case "response.steer.accepted":
accepted = true
case "response.completed":
- if gjson.GetBytes(p, "response.id").String() == "first" {
+ if ojson.GetBytesNoCopy(p, "response.id").String() == "first" {
send(create)
- } else if gjson.GetBytes(p, "response.id").String() == "second" {
+ } else if ojson.GetBytesNoCopy(p, "response.id").String() == "second" {
completed = true
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket.go b/sdk/api/handlers/openai/openai_responses_websocket.go
index 71b849f9295..41e6a373af1 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket.go
@@ -15,6 +15,7 @@ import (
"time"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
@@ -26,8 +27,6 @@ import (
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -106,12 +105,15 @@ func websocketClosePayloadForUpstreamError(err error) (bool, []byte) {
StatusCode() int
}
var statusErr statusCoder
- if !errors.As(err, &statusErr) || statusErr.StatusCode() != http.StatusRequestEntityTooLarge ||
- gjson.Get(errText, "error.code").String() != "message_too_big" {
+ if !errors.As(err, &statusErr) || statusErr.StatusCode() != http.StatusRequestEntityTooLarge {
+ return false, nil
+ }
+ fields := ojson.GetMany(errText, "error.code", "error.message")
+ if fields[0].String() != "message_too_big" {
return false, nil
}
code = websocket.CloseMessageTooBig
- reason = strings.TrimSpace(gjson.Get(errText, "error.message").String())
+ reason = strings.TrimSpace(fields[1].String())
}
if reason == "" {
reason = "message too big"
@@ -199,26 +201,36 @@ func (w *responsesWebsocketWriter) closeWithPayload(payload []byte) (bool, error
return true, errClose
}
-func (w *responsesWebsocketWriter) closeForUpstreamDisconnect(err error) {
+func (w *responsesWebsocketWriter) closeForUpstreamDisconnect(err error, wsTimelineLog ...websocketTimelineAppender) bool {
if w == nil || w.conn == nil {
- return
+ return false
+ }
+ var timelineLog websocketTimelineAppender
+ if len(wsTimelineLog) > 0 {
+ timelineLog = wsTimelineLog[0]
}
if matched, _ := w.closeForUpstreamError(err); matched {
- return
+ return true
}
errMsg := handlers.ExecutionErrorMessage(err)
if !shouldExposeResponsesUpstreamError(errMsg) {
- _, _ = w.closeWithoutError()
- return
+ closed, _ := w.closeWithoutError()
+ if closed && timelineLog != nil && errMsg != nil {
+ appendWebsocketTimelineDisconnect(timelineLog, errMsg.Error, time.Now())
+ }
+ return closed
}
payload, errBuild := buildResponsesWebsocketErrorPayload(errMsg)
if errBuild != nil {
- _, _ = w.closeWithoutError()
- return
+ closed, _ := w.closeWithoutError()
+ return closed
}
wrote, errClose := w.closeWithPayload(payload)
if wrote {
+ if timelineLog != nil {
+ timelineLog.Append("response", payload, time.Now())
+ }
log.Infof(
"responses websocket: downstream_out disconnect_error event=%s payload=%s",
websocketPayloadEventType(payload),
@@ -228,6 +240,7 @@ func (w *responsesWebsocketWriter) closeForUpstreamDisconnect(err error) {
if errClose != nil && !errors.Is(errClose, websocket.ErrCloseSent) {
log.Debugf("responses websocket: upstream disconnect close failed: %v", errClose)
}
+ return wrote
}
// isWebsocketConnectionClosedError reports whether the error only means the
@@ -290,8 +303,8 @@ func (h *OpenAIResponsesAPIHandler) authenticateResponsesWebsocketTurn(c *gin.Co
return "", nil
}
authBody := payload
- if requestModelName != "" && json.Valid(payload) && strings.TrimSpace(gjson.GetBytes(payload, "model").String()) == "" {
- if updated, errSet := sjson.SetBytes(bytes.Clone(payload), "model", requestModelName); errSet == nil {
+ if requestModelName != "" && json.Valid(payload) && strings.TrimSpace(ojson.GetBytesNoCopy(payload, "model").String()) == "" {
+ if updated, errSet := ojson.SetBytes(bytes.Clone(payload), "model", requestModelName); errSet == nil {
authBody = updated
}
}
@@ -388,7 +401,8 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
log.Infof("responses websocket: client connected id=%s remote=%s", passthroughSessionID, clientIP)
wsDone := make(chan struct{})
- defer close(wsDone)
+ var disconnectWg sync.WaitGroup
+ var recordedDisconnectErr atomic.Pointer[interfaces.ErrorMessage]
var codexDuplexStream atomic.Bool
if h != nil && h.AuthManager != nil {
@@ -403,7 +417,9 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
if subscriber, ok := exec.(upstreamDisconnectSubscriber); ok && subscriber != nil {
disconnectCh := subscriber.UpstreamDisconnectChan(passthroughSessionID)
if disconnectCh != nil {
+ disconnectWg.Add(1)
go func() {
+ defer disconnectWg.Done()
select {
case <-wsDone:
return
@@ -414,7 +430,9 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
// OAuth-only steering still leaves API keys in normal mode.
return
}
- writer.closeForUpstreamDisconnect(disconnectErr)
+ if writer.closeForUpstreamDisconnect(disconnectErr, wsTimelineLog) && disconnectErr != nil {
+ recordedDisconnectErr.CompareAndSwap(nil, handlers.ExecutionErrorMessage(disconnectErr))
+ }
}
}()
}
@@ -435,10 +453,16 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
h.AuthManager.CloseExecutionSession(passthroughSessionID)
log.Infof("responses websocket: upstream execution session closed id=%s", passthroughSessionID)
}
+ close(wsDone)
cancelSocket(nil)
+ _, _ = writer.closeWithoutError()
if errClose := conn.Close(); errClose != nil && !isWebsocketConnectionClosedError(errClose) {
log.Warnf("responses websocket: close connection error: %v", errClose)
}
+ disconnectWg.Wait()
+ if errMsg := recordedDisconnectErr.Load(); errMsg != nil {
+ h.LoggingAPIResponseError(c, errMsg)
+ }
// Finish control-frame diagnostics before snapshotting the timeline.
<-inputDone
wsTimelineLog.SetContext(c)
@@ -554,13 +578,13 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
wsTimelineLog.BeginRequest()
wsTimelineLog.Append("request", payload, time.Now())
- explicitRequestModelName := strings.TrimSpace(gjson.GetBytes(payload, "model").String())
+ explicitRequestModelName := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(payload, "model").String()))
requestModelName := explicitRequestModelName
if requestModelName == "" {
requestModelName = passthroughModelName
}
if requestModelName == "" {
- requestModelName = strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
+ requestModelName = strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(lastRequest, "model").String()))
}
if _, errAuth := h.authenticateResponsesWebsocketTurn(c, upgradeRequest, payload, requestModelName, true); errAuth != nil {
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errAuth)
@@ -718,7 +742,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
var requestJSON []byte
var updatedLastRequest []byte
var errMsg *interfaces.ErrorMessage
- previousResponseID := strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String())
+ previousResponseID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "previous_response_id").String())
isPrewarm := !useUpstreamWebsocketPassthrough && shouldHandleResponsesWebsocketPrewarmLocally(payload, false)
if pendingPrewarmID != "" && previousResponseID != "" {
if previousResponseID != pendingPrewarmID {
@@ -727,7 +751,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
requestJSON, updatedLastRequest, errMsg = normalizeResponsesWebsocketPrewarmFollowup(payload, lastRequest)
}
} else if isPrewarm && previousResponseID == "" {
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if input.Exists() && !input.IsArray() {
errMsg = &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
@@ -735,10 +759,10 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
} else {
// Mid-connection self-contained prewarm without previous_response_id is a new transcript root.
- requestJSON, updatedLastRequest, errMsg = normalizeResponseCreateRequest(normalizeResponseTranscriptReplacement(payload, lastRequest))
+ requestJSON, updatedLastRequest, errMsg = normalizeResponseCreateTranscriptReplacement(payload, lastRequest)
}
- } else if pendingPrewarmID != "" && gjson.GetBytes(payload, "type").String() == wsRequestTypeCreate {
- input := gjson.GetBytes(payload, "input")
+ } else if pendingPrewarmID != "" && ojson.GetBytesNoCopy(payload, "type").String() == wsRequestTypeCreate {
+ input := ojson.GetBytesNoCopy(payload, "input")
if input.Exists() && !input.IsArray() {
errMsg = &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
@@ -746,11 +770,11 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
}
} else {
// No parent reference means a self-contained replacement, not a delta.
- requestJSON, updatedLastRequest, errMsg = normalizeResponseCreateRequest(normalizeResponseTranscriptReplacement(payload, lastRequest))
+ requestJSON, updatedLastRequest, errMsg = normalizeResponseCreateTranscriptReplacement(payload, lastRequest)
}
} else if nativeWebsocketPassthrough {
requestJSON, errMsg = normalizeResponsesWebsocketPassthroughRequest(payload, requestModelName)
- } else if len(lastRequest) == 0 && strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String()) != "" {
+ } else if len(lastRequest) == 0 && previousResponseID != "" {
errMsg = responsesWebsocketPreviousResponseNotFoundError()
} else {
requestJSON, updatedLastRequest, errMsg = normalizeResponsesWebsocketRequestWithIncrementalState(
@@ -786,14 +810,18 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
continue
}
- requestJSON = h.prepareCodexMultiAgentV2Tools(c, requestJSON)
- requestJSON = h.prepareCodexOrphanDelegation(c, requestJSON)
+ reqDoc := ojson.NewDoc(requestJSON)
+ toolsChanged := h.prepareCodexMultiAgentV2ToolsDoc(c, reqDoc)
+ orphanChanged := h.prepareCodexOrphanDelegationDoc(c, reqDoc)
+ if isPrewarm {
+ _ = reqDoc.Delete("generate")
+ }
+ if len(requestJSON) > 0 && (toolsChanged || orphanChanged || isPrewarm) {
+ requestJSON = reqDoc.Bytes()
+ }
if isPrewarm {
- if updated, errDelete := sjson.DeleteBytes(requestJSON, "generate"); errDelete == nil {
- requestJSON = updated
- }
- if updated, errDelete := sjson.DeleteBytes(updatedLastRequest, "generate"); errDelete == nil {
+ if updated, errDelete := ojson.DeleteBytes(updatedLastRequest, "generate"); errDelete == nil {
updatedLastRequest = updated
}
lastRequest = updatedLastRequest
@@ -813,15 +841,15 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
var toolCacheTurn *responsesWebsocketToolCacheTurn
nextLastRequest := lastRequest
if nativeWebsocketPassthrough {
- if modelName := strings.TrimSpace(gjson.GetBytes(requestJSON, "model").String()); modelName != "" {
- passthroughModelName = modelName
+ if modelName := strings.TrimSpace(ojson.GetBytesNoCopy(requestJSON, "model").String()); modelName != "" {
+ passthroughModelName = strings.Clone(modelName)
}
} else {
requestJSON, toolCacheTurn = prepareResponsesWebsocketFallbackTurn(downstreamSessionKey, requestJSON)
nextLastRequest = requestJSON
}
- modelName := gjson.GetBytes(requestJSON, "model").String()
+ modelName := strings.Clone(ojson.GetBytesNoCopy(requestJSON, "model").String())
lastAttemptedAuthID := pinnedAuthID
attemptedUpstreamMode := responsesWebsocketUpstreamModeUnknown
selectedAuthObserved := false
@@ -893,8 +921,10 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
attemptedUpstreamMode = responsesWebsocketUpstreamModeHTTP
}
// A connection-scoped continuation cannot rotate credentials in place. Suppress
- // credential errors and make the client replay the full turn on a new socket.
+ // credential errors and make the client replay the full turn on a new socket
+ // when another eligible target can serve the replayed turn.
var lossWriteErr error
+ var replayedPinnedAuthFailure bool
replayPinnedAuthFailure := func(errMsg *interfaces.ErrorMessage) bool {
if cliproxyexecutor.IsUpstreamWebsocketTurnError(errMsg.Error) {
// The upstream websocket was lost: tell the client, keep its socket open, and
@@ -903,7 +933,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
if errors.As(errMsg.Error, &turnErr) {
if (turnErr.FullContextRequired || turnErr.NotSent || !codexDuplexStream.Load()) &&
turnErr.EventID == "" && !turnErr.Idle && len(turnErr.Abandoned) == 0 {
- turnErr.EventID = strings.TrimSpace(gjson.GetBytes(payload, "event_id").String())
+ turnErr.EventID = strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(payload, "event_id").String()))
}
if !turnErr.FullContextRequired && !turnErr.NotSent && inputEpoch.Load() == turnStartEpoch {
inputEpoch.Add(1)
@@ -912,8 +942,16 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
lossWriteErr = writeResponsesWebsocketUpstreamLoss(writer, wsTimelineLog, errMsg)
return true
}
- return nativeWebsocketPassthrough && requestRequiresCurrentUpstreamWebsocket && pinnedAuthAttempted &&
- shouldReplayResponsesWebsocketPinnedAuthFailure(errMsg)
+ if !nativeWebsocketPassthrough || !requestRequiresCurrentUpstreamWebsocket || !pinnedAuthAttempted ||
+ !shouldReplayResponsesWebsocketPinnedAuthFailure(errMsg) {
+ return false
+ }
+ if (responsesWebsocketErrorStatus(errMsg) == http.StatusTooManyRequests || isResponsesWebsocketQuotaOrCooldownError(errMsg.Error)) &&
+ !h.responsesWebsocketHasAlternativeReplayTarget(cliCtx, modelName, requestJSON, lastAttemptedAuthID) {
+ return false
+ }
+ replayedPinnedAuthFailure = true
+ return true
}
completedOutput, completedResponseID, completedPendingToolCallIDs, forwardErrMsg, errForward := h.forwardResponsesWebsocket(
@@ -966,7 +1004,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
if pinnedAuthAttempted && shouldReleaseResponsesWebsocketPinnedAuth(forwardErrMsg) {
forgetPinnedAuth()
}
- if replayPinnedAuthFailure(forwardErrMsg) {
+ if replayedPinnedAuthFailure {
replayErr := responsesWebsocketHTTPReplayRequiredError()
wsTerminateErr = replayErr
matched, errClose := writer.closeForUpstreamError(replayErr)
@@ -998,7 +1036,7 @@ func (h *OpenAIResponsesAPIHandler) ResponsesWebsocket(c *gin.Context) {
upstreamWebsocketAuthID = ""
lastRequest = nextLastRequest
lastResponseOutput = completedOutput
- if inputContainsFullTranscript(gjson.ParseBytes(completedOutput)) {
+ if inputContainsFullTranscript(util.ParseGJSONBytesNoCopy(completedOutput)) {
_, modelKey := responsesWebsocketProviderSetForModel(responsesWebsocketResolvedModelName(modelName))
if isPluginExecutorRoute {
observedCompaction = responsesWebsocketObservedCompactionState{
@@ -1060,8 +1098,10 @@ func responsesWebsocketHTTPReplayRequiredError() error {
}
func responsesWebsocketRequestRequiresCurrentUpstream(payload []byte) bool {
- return strings.TrimSpace(gjson.GetBytes(payload, "previous_response_id").String()) != "" ||
- strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == wsRequestTypeAppend
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], payload, "previous_response_id", "type")
+ return strings.TrimSpace(fields[0].String()) != "" ||
+ strings.TrimSpace(fields[1].String()) == wsRequestTypeAppend
}
func responsesWebsocketNativePassthroughAllowed(upstreamMode string, useUpstreamWebsocket bool, pinnedAuthID string, upstreamAuthID string) bool {
@@ -1125,15 +1165,15 @@ func writeResponsesWebsocketUpstreamLoss(writer *responsesWebsocketWriter, wsTim
// A create read before the loss is answered as not sent instead.
func writeResponsesWebsocketPreviousResponseNotFound(writer *responsesWebsocketWriter, wsTimelineLog websocketTimelineAppender, payload []byte) error {
errMsg := responsesWebsocketPreviousResponseNotFoundError()
- if gjson.GetBytes(payload, "type").String() == wsRequestTypeCreate && !responsesWebsocketRequestRequiresCurrentUpstream(payload) {
+ if ojson.GetBytesNoCopy(payload, "type").String() == wsRequestTypeCreate && !responsesWebsocketRequestRequiresCurrentUpstream(payload) {
errMsg = handlers.ExecutionErrorMessage(&cliproxyexecutor.UpstreamWebsocketTurnError{NotSent: true})
}
errorPayload, errBuild := buildResponsesWebsocketErrorPayload(errMsg)
if errBuild != nil {
return errBuild
}
- if eventID := gjson.GetBytes(payload, "event_id").String(); eventID != "" {
- errorPayload, _ = sjson.SetBytes(errorPayload, "error.event_id", eventID)
+ if eventID := ojson.GetBytesNoCopy(payload, "event_id").String(); eventID != "" {
+ errorPayload, _ = ojson.SetBytes(errorPayload, "error.event_id", eventID)
}
return writeResponsesWebsocketPayload(writer, wsTimelineLog, errorPayload, time.Now())
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_disconnect_interrupt_test.go b/sdk/api/handlers/openai/openai_responses_websocket_disconnect_interrupt_test.go
index 407099f533e..bb25fb8e09d 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_disconnect_interrupt_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_disconnect_interrupt_test.go
@@ -10,13 +10,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// websocketLossInterruptExecutor runs a turn whose upstream websocket is
@@ -115,7 +115,7 @@ func TestResponsesInterruptRacingWebsocketLossIsNotAnsweredLocally(t *testing.T)
if errRead != nil {
t.Fatal(errRead)
}
- if got := gjson.GetBytes(created, "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(created, "type").String(); got != "response.created" {
t.Fatalf("expected response.created, got %s", created)
}
@@ -136,11 +136,11 @@ func TestResponsesInterruptRacingWebsocketLossIsNotAnsweredLocally(t *testing.T)
if errNext != nil {
t.Fatalf("waiting for upstream loss notice: %v", errNext)
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
if eventType == "response.incomplete" {
t.Fatalf("fabricated local incomplete after websocket loss: %s", payload)
}
- if eventType == "error" && gjson.GetBytes(payload, "error.event_id").String() == "evt-loss" {
+ if eventType == "error" && ojson.GetBytesNoCopy(payload, "error.event_id").String() == "evt-loss" {
return
}
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_forward.go b/sdk/api/handlers/openai/openai_responses_websocket_forward.go
index 4202dc11b0e..b5485d41e6f 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_forward.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_forward.go
@@ -11,16 +11,16 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
type responsesWebsocketForwardOptions struct {
@@ -238,7 +238,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
payloads := websocketJSONPayloadsFromChunk(chunk)
for i := range payloads {
- eventType := gjson.GetBytes(payloads[i], "type").String()
+ eventType := ojson.GetBytesNoCopy(payloads[i], "type").String()
if eventType == "response.subscription_usage" ||
(eventType == "response.metadata" && !isCodexResponsesClientRequest(c)) {
continue
@@ -269,7 +269,12 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocket(
// payload errors after response.created are recoverable events;
// StreamChunk.Err still arrives through errs and closes the socket.
preserveErrorEvent := responseStarted && opts.duplexStream != nil && opts.duplexStream()
- if eventType == wsEventTypeError && !preserveErrorEvent {
+ if eventType == wsEventTypeError && preserveErrorEvent {
+ turnEnded = true
+ if errMsg := responsesWebsocketErrorMessageFromPayload(payloads[i]); errMsg != nil && h != nil {
+ h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), errMsg)
+ }
+ } else if eventType == wsEventTypeError && !preserveErrorEvent {
payloadErrMsg = responsesWebsocketErrorMessageFromPayload(payloads[i])
if h != nil {
h.LoggingAPIResponseError(context.WithValue(context.Background(), "gin", c), payloadErrMsg)
@@ -353,21 +358,18 @@ func completeResponsesWebsocketLocalInterrupt(
outputItemsFallback [][]byte,
pendingToolCallIDs map[string]struct{},
) ([]byte, string, []string, *interfaces.ErrorMessage, error) {
- responseID := strings.TrimSpace(gjson.GetBytes(payload, "response_id").String())
+ responseID := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response_id").String()))
output := responseCompletedOutputFromPayload([]byte(`{"response":{"output":[]}}`), outputItemsByIndex, outputItemsFallback)
- incomplete := []byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"interrupted"}}}`)
- var errSet error
- incomplete, errSet = sjson.SetBytes(incomplete, "response.id", responseID)
- if errSet != nil {
+ incompleteDoc := ojson.NewDoc([]byte(`{"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"interrupted"}}}`))
+ if errSet := incompleteDoc.Set("response.id", responseID); errSet != nil {
cancel(errSet)
return output, responseID, sortedStringSet(pendingToolCallIDs), nil, errSet
}
- incomplete, errSet = sjson.SetRawBytes(incomplete, "response.output", output)
- if errSet != nil {
+ if errSet := incompleteDoc.SetRawBytes("response.output", output); errSet != nil {
cancel(errSet)
return output, responseID, sortedStringSet(pendingToolCallIDs), nil, errSet
}
- if errWrite := writeResponsesWebsocketPayload(writer, wsTimelineLog, incomplete, time.Now()); errWrite != nil {
+ if errWrite := writeResponsesWebsocketPayload(writer, wsTimelineLog, incompleteDoc.Bytes(), time.Now()); errWrite != nil {
cancel(errWrite)
return output, responseID, sortedStringSet(pendingToolCallIDs), nil, errWrite
}
@@ -388,11 +390,11 @@ func responsesWebsocketErrorStatus(errMsg *interfaces.ErrorMessage) int {
// shouldExposeResponsesUpstreamError reports whether a terminal upstream error
// must reach the downstream client.
//
-// Only request-shape failures are exposed: the client can act on them and no
-// credential rotation or retry can make the request succeed. Credential, quota
-// and transport failures stay silent so the client simply reconnects and retries;
-// a fresh connection carries no server-side transcript, so reconnecting already
-// implies a full context resend.
+// Request-shape failures, permanent auth failures, and terminal 429 quota or
+// model-cooldown failures (where credential failover has already exhausted all
+// eligible candidates) are exposed so the client can surface the final error
+// instead of reconnecting in a loop. Transient credential and transport failures
+// stay silent so the client reconnects with a full context resend.
func shouldExposeResponsesUpstreamError(errMsg *interfaces.ErrorMessage) bool {
if errMsg == nil {
return false
@@ -400,7 +402,52 @@ func shouldExposeResponsesUpstreamError(errMsg *interfaces.ErrorMessage) bool {
if coreauth.IsTerminalAuthError(errMsg.Error) {
return true
}
- return clienterror.IsRequestFault(responsesWebsocketErrorStatus(errMsg), errMsg.Error)
+ status := responsesWebsocketErrorStatus(errMsg)
+ if status == http.StatusTooManyRequests || isResponsesWebsocketQuotaOrCooldownError(errMsg.Error) {
+ return true
+ }
+ return clienterror.IsRequestFault(status, errMsg.Error)
+}
+
+func isResponsesWebsocketQuotaOrCooldownError(err error) bool {
+ if err == nil {
+ return false
+ }
+ type modelCooldownMarker interface {
+ IsModelCooldown() bool
+ }
+ var mcm modelCooldownMarker
+ if errors.As(err, &mcm) && mcm != nil && mcm.IsModelCooldown() {
+ return true
+ }
+ var authErr *coreauth.Error
+ if errors.As(err, &authErr) && authErr != nil && strings.EqualFold(strings.TrimSpace(authErr.Code), "model_cooldown") {
+ return true
+ }
+ body := strings.TrimSpace(err.Error())
+ type responseBodyProvider interface {
+ ResponseBody() []byte
+ }
+ var responseBody responseBodyProvider
+ if errors.As(err, &responseBody) && responseBody != nil && len(responseBody.ResponseBody()) > 0 {
+ body = strings.TrimSpace(string(responseBody.ResponseBody()))
+ }
+ if body == "" || !ojson.Valid(body) {
+ return false
+ }
+ for _, path := range []string{"error.type", "type", "response.error.type", "body.error.type"} {
+ switch strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String())) {
+ case "usage_limit_reached", "model_cooldown":
+ return true
+ }
+ }
+ for _, path := range []string{"error.code", "code", "response.error.code", "body.error.code"} {
+ switch strings.ToLower(strings.TrimSpace(ojson.Get(body, path).String())) {
+ case "usage_limit_reached", "model_cooldown":
+ return true
+ }
+ }
+ return false
}
func writeResponsesWebsocketTerminalError(
@@ -482,14 +529,16 @@ func shouldReleaseResponsesWebsocketPinnedAuth(errMsg *interfaces.ErrorMessage)
}
func collectResponsesWebsocketOutputItem(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback *[][]byte) {
- if gjson.GetBytes(payload, "type").String() != "response.output_item.done" {
+ var fieldsBuf [3]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], payload, "type", "item", "output_index")
+ if fields[0].String() != "response.output_item.done" {
return
}
- item := gjson.GetBytes(payload, "item")
+ item := fields[1]
if !item.Exists() || !item.IsObject() {
return
}
- outputIndex := gjson.GetBytes(payload, "output_index")
+ outputIndex := fields[2]
if outputIndex.Exists() {
outputItemsByIndex[outputIndex.Int()] = bytes.Clone([]byte(item.Raw))
return
@@ -498,13 +547,13 @@ func collectResponsesWebsocketOutputItem(payload []byte, outputItemsByIndex map[
}
func restoreResponsesWebsocketCompletionOutput(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
- output := gjson.GetBytes(payload, "response.output")
+ output := ojson.GetBytesNoCopy(payload, "response.output")
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
reconciledOutput, changed := reconcileResponsesWebsocketCompletionToolCalls(output, outputItemsByIndex, outputItemsFallback)
if !changed {
return payload
}
- restored, errSet := sjson.SetRawBytes(payload, "response.output", reconciledOutput)
+ restored, errSet := ojson.SetRawBytes(payload, "response.output", reconciledOutput)
if errSet != nil {
return payload
}
@@ -514,17 +563,17 @@ func restoreResponsesWebsocketCompletionOutput(payload []byte, outputItemsByInde
return payload
}
- restored, errSet := sjson.SetRawBytes(payload, "response.output", responseCompletedOutputFromPayload(payload, outputItemsByIndex, outputItemsFallback))
+ restored, errSet := ojson.SetRawBytes(payload, "response.output", responseCompletedOutputFromPayload(payload, outputItemsByIndex, outputItemsFallback))
if errSet != nil {
return payload
}
return restored
}
-func reconcileResponsesWebsocketCompletionToolCalls(output gjson.Result, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) ([]byte, bool) {
+func reconcileResponsesWebsocketCompletionToolCalls(output ojson.Result, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) ([]byte, bool) {
collectedToolCalls := make(map[string]json.RawMessage)
recordCollectedToolCall := func(raw []byte) {
- item := gjson.ParseBytes(raw)
+ item := util.ParseGJSONBytesNoCopy(raw)
if !isCompleteResponsesWebsocketToolCall(item) {
return
}
@@ -574,30 +623,30 @@ func reconcileResponsesWebsocketCompletionToolCalls(output gjson.Result, outputI
return marshaledOutput, true
}
-func isCompleteResponsesWebsocketToolCall(item gjson.Result) bool {
+func isCompleteResponsesWebsocketToolCall(item ojson.Result) bool {
if !item.Exists() || !item.IsObject() {
return false
}
callID := item.Get("call_id")
name := item.Get("name")
- if callID.Type != gjson.String || strings.TrimSpace(callID.String()) == "" || name.Type != gjson.String || strings.TrimSpace(name.String()) == "" {
+ if callID.Type != ojson.String || strings.TrimSpace(callID.String()) == "" || name.Type != ojson.String || strings.TrimSpace(name.String()) == "" {
return false
}
switch strings.TrimSpace(item.Get("type").String()) {
case "function_call":
arguments := item.Get("arguments")
- return arguments.Exists() && arguments.Type == gjson.String
+ return arguments.Exists() && arguments.Type == ojson.String
case "custom_tool_call":
input := item.Get("input")
- return input.Exists() && input.Type == gjson.String
+ return input.Exists() && input.Type == ojson.String
default:
return false
}
}
func responseCompletedOutputFromPayload(payload []byte, outputItemsByIndex map[int64][]byte, outputItemsFallback [][]byte) []byte {
- output := gjson.GetBytes(payload, "response.output")
+ output := ojson.GetBytesNoCopy(payload, "response.output")
if output.Exists() && output.IsArray() && len(output.Array()) > 0 {
return bytes.Clone([]byte(output.Raw))
}
@@ -615,7 +664,7 @@ func responseCompletedOutputFromPayload(payload []byte, outputItemsByIndex map[i
items := make([]json.RawMessage, 0, len(outputItemsByIndex)+len(outputItemsFallback))
appendCollectedItem := func(raw []byte) {
- item := gjson.ParseBytes(raw)
+ item := util.ParseGJSONBytesNoCopy(raw)
if isResponsesToolCallType(item.Get("type").String()) && !isCompleteResponsesWebsocketToolCall(item) {
return
}
@@ -636,15 +685,17 @@ func responseCompletedOutputFromPayload(payload []byte, outputItemsByIndex map[i
}
func responseCompletedIDFromPayload(payload []byte) string {
- return strings.TrimSpace(gjson.GetBytes(payload, "response.id").String())
+ return strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(payload, "response.id").String()))
}
func recordPendingToolCallIDsFromPayload(pending map[string]struct{}, payload []byte) {
if pending == nil || len(payload) == 0 {
return
}
- updatePendingToolCallIDsFromItem(pending, gjson.GetBytes(payload, "item"))
- output := gjson.GetBytes(payload, "response.output")
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], payload, "item", "response.output")
+ updatePendingToolCallIDsFromItem(pending, fields[0])
+ output := fields[1]
if output.IsArray() {
for _, item := range output.Array() {
updatePendingToolCallIDsFromItem(pending, item)
@@ -652,7 +703,7 @@ func recordPendingToolCallIDsFromPayload(pending map[string]struct{}, payload []
}
}
-func updatePendingToolCallIDsFromItem(pending map[string]struct{}, item gjson.Result) {
+func updatePendingToolCallIDsFromItem(pending map[string]struct{}, item ojson.Result) {
if pending == nil || !item.Exists() {
return
}
@@ -661,7 +712,7 @@ func updatePendingToolCallIDsFromItem(pending map[string]struct{}, item gjson.Re
if !isCompleteResponsesWebsocketToolCall(item) {
return
}
- callID := strings.TrimSpace(item.Get("call_id").String())
+ callID := strings.Clone(strings.TrimSpace(item.Get("call_id").String()))
pending[callID] = struct{}{}
case "function_call_output", "custom_tool_call_output":
callID := strings.TrimSpace(item.Get("call_id").String())
@@ -723,8 +774,12 @@ func buildResponsesWebsocketErrorPayload(errMsg *interfaces.ErrorMessage) ([]byt
status := http.StatusInternalServerError
errText := http.StatusText(status)
if errMsg != nil {
- if errMsg.StatusCode > 0 {
- status = errMsg.StatusCode
+ if code := responsesWebsocketErrorStatus(errMsg); code > 0 {
+ status = code
+ errText = http.StatusText(status)
+ }
+ if isResponsesWebsocketQuotaOrCooldownError(errMsg.Error) && (status <= 0 || status >= http.StatusInternalServerError) {
+ status = http.StatusTooManyRequests
errText = http.StatusText(status)
}
if errMsg.Error != nil && strings.TrimSpace(errMsg.Error.Error()) != "" {
@@ -737,70 +792,79 @@ func buildResponsesWebsocketErrorPayload(errMsg *interfaces.ErrorMessage) ([]byt
errCause = errMsg.Error
}
body := handlers.BuildErrorResponseBodyWithError(status, errText, errCause)
- payload := []byte(`{}`)
- var errSet error
- payload, errSet = sjson.SetBytes(payload, "type", wsEventTypeError)
- if errSet != nil {
+ payloadDoc := ojson.NewDoc([]byte(`{}`))
+ if errSet := payloadDoc.Set("type", wsEventTypeError); errSet != nil {
return nil, errSet
}
- payload, errSet = sjson.SetBytes(payload, "status", status)
- if errSet != nil {
+ if errSet := payloadDoc.Set("status", status); errSet != nil {
return nil, errSet
}
- if errMsg != nil && errMsg.Addon != nil {
- headers := []byte(`{}`)
+ if errMsg != nil {
+ headersDoc := ojson.NewDoc([]byte(`{}`))
hasHeaders := false
+ trustedDeadline := coreauth.HasTrustedRetryDeadline(errMsg.Error)
+ for key, values := range coreauth.SafeResponseHeaders(errMsg.Error) {
+ if len(values) == 0 || strings.TrimSpace(values[0]) == "" {
+ continue
+ }
+ headerPath := strings.ReplaceAll(strings.ReplaceAll(key, `\\`, `\\\\`), ".", `\\.`)
+ if errSet := headersDoc.Set(headerPath, values[0]); errSet != nil {
+ return nil, errSet
+ }
+ hasHeaders = true
+ }
for key, values := range errMsg.Addon {
- if len(values) == 0 {
+ if len(values) == 0 || strings.TrimSpace(values[0]) == "" {
+ continue
+ }
+ if trustedDeadline && http.CanonicalHeaderKey(key) == "Retry-After" {
continue
}
headerPath := strings.ReplaceAll(strings.ReplaceAll(key, `\\`, `\\\\`), ".", `\\.`)
- headers, errSet = sjson.SetBytes(headers, headerPath, values[0])
- if errSet != nil {
+ if errSet := headersDoc.Set(headerPath, values[0]); errSet != nil {
return nil, errSet
}
hasHeaders = true
}
if hasHeaders {
- payload, errSet = sjson.SetRawBytes(payload, "headers", headers)
- if errSet != nil {
+ if errSet := payloadDoc.SetRawBytes("headers", headersDoc.Bytes()); errSet != nil {
return nil, errSet
}
}
}
+ hasErrorField := false
if len(body) > 0 && json.Valid(body) {
- errorNode := gjson.GetBytes(body, "error")
+ errorNode := ojson.GetBytesNoCopy(body, "error")
+ var errSet error
if errorNode.Exists() {
- payload, errSet = sjson.SetRawBytes(payload, "error", []byte(errorNode.Raw))
+ errSet = payloadDoc.SetRawBytes("error", []byte(errorNode.Raw))
} else {
- payload, errSet = sjson.SetRawBytes(payload, "error", body)
+ errSet = payloadDoc.SetRawBytes("error", body)
}
if errSet != nil {
return nil, errSet
}
+ hasErrorField = true
}
- if !gjson.GetBytes(payload, "error").Exists() {
- payload, errSet = sjson.SetBytes(payload, "error.type", "server_error")
- if errSet != nil {
+ if !hasErrorField {
+ if errSet := payloadDoc.Set("error.type", "server_error"); errSet != nil {
return nil, errSet
}
- payload, errSet = sjson.SetBytes(payload, "error.message", errText)
- if errSet != nil {
+ if errSet := payloadDoc.Set("error.message", errText); errSet != nil {
return nil, errSet
}
}
var turnErr *cliproxyexecutor.UpstreamWebsocketTurnError
if errMsg != nil && errors.As(errMsg.Error, &turnErr) && turnErr.EventID != "" {
- payload, errSet = sjson.SetBytes(payload, "error.event_id", turnErr.EventID)
- if errSet != nil {
+ if errSet := payloadDoc.Set("error.event_id", turnErr.EventID); errSet != nil {
return nil, errSet
}
}
- return payload, nil
+ return payloadDoc.Bytes(), nil
}
func writeResponsesWebsocketError(writer *responsesWebsocketWriter, wsTimelineLog websocketTimelineAppender, errMsg *interfaces.ErrorMessage) ([]byte, error) {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_input.go b/sdk/api/handlers/openai/openai_responses_websocket_input.go
index 5ea827ebb3d..5e14b9f0e52 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_input.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_input.go
@@ -13,11 +13,11 @@ import (
"sync/atomic"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// responsesLocalInterrupt cancels an in-flight HTTP turn. A websocket upstream
@@ -168,7 +168,7 @@ func readResponsesWebsocketInput(ctx context.Context, cancel context.CancelCause
}
// Control frames bypass response.create normalization. The original
// response_id, mode, and extension fields must reach upstream unchanged.
- if json.Valid(payload) && gjson.GetBytes(payload, "type").String() == "response.interrupt" {
+ if json.Valid(payload) && ojson.GetBytesNoCopy(payload, "type").String() == "response.interrupt" {
appendResponsesInterruptDiagnostic(timeline, payload, "")
errInterrupt := interrupt(payload)
switch {
@@ -210,7 +210,7 @@ func appendResponsesInterruptDiagnostic(timeline websocketTimelineAppender, payl
}
// Hash client-controlled IDs and omit all arbitrary fields, including mode.
// Only correlation metadata is needed to diagnose control-frame timing.
- idHash := sha256.Sum256([]byte(gjson.GetBytes(payload, "response_id").String()))
+ idHash := sha256.Sum256([]byte(ojson.GetBytesNoCopy(payload, "response_id").String()))
diagnostic := struct {
Type string `json:"type"`
ResponseIDHash string `json:"response_id_hash"`
@@ -228,8 +228,8 @@ func appendResponsesInterruptDiagnostic(timeline websocketTimelineAppender, payl
}
func (h *OpenAIResponsesAPIHandler) forwardResponsesWebsocketInterrupt(ctx context.Context, sessionID string, payload []byte) error {
- responseID := gjson.GetBytes(payload, "response_id")
- if responseID.Type != gjson.String || strings.TrimSpace(responseID.String()) == "" {
+ responseID := ojson.GetBytesNoCopy(payload, "response_id")
+ if responseID.Type != ojson.String || strings.TrimSpace(responseID.String()) == "" {
return fmt.Errorf("response.interrupt requires response_id")
}
if h == nil || h.AuthManager == nil {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_late_local_interrupt_test.go b/sdk/api/handlers/openai/openai_responses_websocket_late_local_interrupt_test.go
index 327f7e86780..b202328741c 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_late_local_interrupt_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_late_local_interrupt_test.go
@@ -11,11 +11,11 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
- "github.com/tidwall/gjson"
)
// Once a terminal payload (completion or failure) has been written
@@ -130,7 +130,7 @@ func testForwardResponsesWebsocketRejectsLocalInterruptAfterTerminal(t *testing.
if errRead != nil {
t.Fatalf("read terminal message: %v", errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != terminalType {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != terminalType {
t.Fatalf("payload type = %s, want %s", got, terminalType)
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_logging_test.go b/sdk/api/handlers/openai/openai_responses_websocket_logging_test.go
index df30b8ae111..4bc60e54633 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_logging_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_logging_test.go
@@ -12,6 +12,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/api/middleware"
@@ -23,7 +24,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
)
type websocketLoggingExecutor struct {
@@ -116,7 +116,7 @@ func TestResponsesWebsocketRequestLoggingLifecycle(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if got := gjson.GetBytes(data, "type").String(); got != want {
+ if got := ojson.GetBytesNoCopy(data, "type").String(); got != want {
t.Fatalf("event = %q, want %q", got, want)
}
}
@@ -149,7 +149,7 @@ func TestResponsesWebsocketRequestLoggingLifecycle(t *testing.T) {
assertNoCapture()
send(payload)
if _, data, errRead := conn.ReadMessage(); errRead == nil {
- if gjson.GetBytes(data, "type").String() != "error" {
+ if ojson.GetBytesNoCopy(data, "type").String() != "error" {
t.Fatalf("failed upstream returned %s", data)
}
} else {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_loss_test.go b/sdk/api/handlers/openai/openai_responses_websocket_loss_test.go
index 59fa4151f5e..9faeebfef0c 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_loss_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_loss_test.go
@@ -2,6 +2,7 @@ package openai
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"net"
@@ -13,16 +14,19 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry"
+ coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
)
// lossFixture runs the real handler, manager and Codex executor against a scripted upstream.
@@ -59,7 +63,7 @@ func newLossFixture(t *testing.T, steering bool, script func(index int, c *webso
if errRead != nil {
return nil
}
- if gjson.GetBytes(p, "type").String() == "response.create" {
+ if ojson.GetBytesNoCopy(p, "type").String() == "response.create" {
f.creates.Add(1)
}
return p
@@ -149,7 +153,7 @@ func (f *lossFixture) next() []byte {
func (f *lossFixture) nextError() []byte {
f.t.Helper()
for {
- if p := f.next(); gjson.GetBytes(p, "type").String() == "error" {
+ if p := f.next(); ojson.GetBytesNoCopy(p, "type").String() == "error" {
return p
}
}
@@ -159,7 +163,7 @@ func (f *lossFixture) awaitCompleted() {
f.t.Helper()
for {
p := f.next()
- switch gjson.GetBytes(p, "type").String() {
+ switch ojson.GetBytesNoCopy(p, "type").String() {
case "response.completed":
return
case "error":
@@ -177,15 +181,15 @@ func (f *lossFixture) awaitProbeCompleted() {
f.create(`,"event_id":"` + id + `"`)
for {
p := f.next()
- switch gjson.GetBytes(p, "type").String() {
+ switch ojson.GetBytesNoCopy(p, "type").String() {
case "response.completed":
return
case "error":
- if gjson.GetBytes(p, "error.event_id").String() != id {
+ if ojson.GetBytesNoCopy(p, "error.event_id").String() != id {
f.t.Fatalf("unexpected error frame: %s", p)
}
}
- if gjson.GetBytes(p, "type").String() == "error" {
+ if ojson.GetBytesNoCopy(p, "type").String() == "error" {
break
}
}
@@ -210,14 +214,14 @@ func TestResponsesWebsocketUpstreamLossKeepsDownstreamOpen(t *testing.T) {
})
f.create("")
lost := f.nextError()
- if got := gjson.GetBytes(lost, "error.code").String(); got != "upstream_websocket_lost" {
+ if got := ojson.GetBytesNoCopy(lost, "error.code").String(); got != "upstream_websocket_lost" {
t.Fatalf("loss error = %s", lost)
}
// The turn is not replayed and a continuation cannot resume the lost socket. The full
// create follows back to back: the refusal is not a loss and must not fence it.
f.create(`,"previous_response_id":"r1"`)
f.create("")
- if got := gjson.GetBytes(f.nextError(), "error.code").String(); got != "previous_response_not_found" {
+ if got := ojson.GetBytesNoCopy(f.nextError(), "error.code").String(); got != "previous_response_not_found" {
t.Fatalf("continuation after loss = %s", got)
}
f.awaitCompleted()
@@ -249,7 +253,7 @@ func TestResponsesWebsocketIdleUpstreamEOFIsSilent(t *testing.T) {
// awaitCompleted fails on any error frame, and a loss frame would precede the completion.
// The idle socket is gone: a continuation cannot resume it.
f.create(`,"previous_response_id":"r2"`)
- if got := gjson.GetBytes(f.nextError(), "error.code").String(); got != "previous_response_not_found" {
+ if got := ojson.GetBytesNoCopy(f.nextError(), "error.code").String(); got != "previous_response_not_found" {
t.Fatalf("continuation after idle EOF = %s", got)
}
f.create("")
@@ -281,7 +285,7 @@ func TestResponsesWebsocketDuplexLossReportsEveryOperation(t *testing.T) {
}
for _, w := range want {
p := f.nextError()
- if gjson.GetBytes(p, "error.code").String() != w.code || gjson.GetBytes(p, "error.event_id").String() != w.eventID {
+ if ojson.GetBytesNoCopy(p, "error.code").String() != w.code || ojson.GetBytesNoCopy(p, "error.event_id").String() != w.eventID {
t.Fatalf("error = %s, want %s for %s", p, w.code, w.eventID)
}
}
@@ -324,7 +328,7 @@ func runAcceptedSteer(t *testing.T, successor bool, failure string) {
// The accepted steer and the create each get exactly one outcome-unknown error.
for _, id := range []string{"c1", "s1"} {
p := f.nextError()
- if gjson.GetBytes(p, "error.code").String() != "upstream_websocket_lost" || gjson.GetBytes(p, "error.event_id").String() != id {
+ if ojson.GetBytesNoCopy(p, "error.code").String() != "upstream_websocket_lost" || ojson.GetBytesNoCopy(p, "error.event_id").String() != id {
t.Fatalf("error = %s, want lost for %s", p, id)
}
}
@@ -364,7 +368,7 @@ func TestResponsesWebsocketLossCarriesEventIDWithoutSteering(t *testing.T) {
})
f.create(`,"event_id":"c1"`)
p := f.nextError()
- if gjson.GetBytes(p, "error.code").String() != "upstream_websocket_lost" || gjson.GetBytes(p, "error.event_id").String() != "c1" {
+ if ojson.GetBytesNoCopy(p, "error.code").String() != "upstream_websocket_lost" || ojson.GetBytesNoCopy(p, "error.event_id").String() != "c1" {
t.Fatalf("error = %s, want lost for c1", p)
}
}
@@ -396,7 +400,7 @@ func TestResponsesWebsocketDuplexLossBetweenTurnsReportsOnce(t *testing.T) {
f.awaitCompleted()
f.create(tc.secondAttr)
p := f.nextError()
- if gjson.GetBytes(p, "error.code").String() != "upstream_websocket_lost" || gjson.GetBytes(p, "error.event_id").String() != tc.wantID {
+ if ojson.GetBytesNoCopy(p, "error.code").String() != "upstream_websocket_lost" || ojson.GetBytesNoCopy(p, "error.event_id").String() != tc.wantID {
t.Fatalf("error = %s, want lost with event_id=%q", p, tc.wantID)
}
f.awaitProbeCompleted() // fails on any further error frame
@@ -423,7 +427,7 @@ func TestResponsesWebsocketDuplexLossAfterRefusedCreateIsSilent(t *testing.T) {
f.awaitCompleted()
provider.revoked.Store(true)
f.create(`,"event_id":"c2"`)
- if p := f.nextError(); gjson.GetBytes(p, "status").Int() != http.StatusForbidden {
+ if p := f.nextError(); ojson.GetBytesNoCopy(p, "status").Int() != http.StatusForbidden {
t.Fatalf("error = %s, want 403", p)
}
provider.revoked.Store(false)
@@ -480,7 +484,7 @@ func TestResponsesWebsocketQueuedInputBeforeIdleEOFIsNotSent(t *testing.T) {
})
f.create(`,"event_id":"c1"`)
- if got := gjson.GetBytes(f.next(), "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(f.next(), "type").String(); got != "response.created" {
t.Fatalf("first event = %s, want response.created", got)
}
f.create(`,"event_id":"c2"`)
@@ -492,13 +496,13 @@ func TestResponsesWebsocketQueuedInputBeforeIdleEOFIsNotSent(t *testing.T) {
// interrupt proves readResponsesWebsocketInput has already stamped and queued c3.
f.create(`,"event_id":"c3"`)
f.send(`{"type":"response.interrupt","response_id":"r1"}`)
- if interruptErr := f.nextError(); gjson.GetBytes(interruptErr, "status").Int() != http.StatusBadRequest {
+ if interruptErr := f.nextError(); ojson.GetBytesNoCopy(interruptErr, "status").Int() != http.StatusBadRequest {
t.Fatalf("post-EOF interrupt error = %s, want 400", interruptErr)
}
close(releaseTurn2)
errFrame := f.nextError()
- if gjson.GetBytes(errFrame, "error.code").String() != "upstream_websocket_not_sent" || gjson.GetBytes(errFrame, "error.event_id").String() != "c2" {
+ if ojson.GetBytesNoCopy(errFrame, "error.code").String() != "upstream_websocket_not_sent" || ojson.GetBytesNoCopy(errFrame, "error.event_id").String() != "c2" {
t.Fatalf("queued create after idle EOF = %s, want upstream_websocket_not_sent for c2", errFrame)
}
@@ -522,7 +526,7 @@ func TestResponsesWebsocketLossBeforeResponseCreatedIgnoresContinueRule(t *testi
f.create(`,"event_id":"c1"`)
lost := f.nextError()
- if gjson.GetBytes(lost, "error.code").String() != "upstream_websocket_lost" || gjson.GetBytes(lost, "error.event_id").String() != "c1" {
+ if ojson.GetBytesNoCopy(lost, "error.code").String() != "upstream_websocket_lost" || ojson.GetBytesNoCopy(lost, "error.event_id").String() != "c1" {
t.Fatalf("loss error = %s, want upstream_websocket_lost for c1", lost)
}
if f.connections.Load() != 1 || f.creates.Load() != 1 {
@@ -567,7 +571,7 @@ func TestResponsesWebsocketQueuedContinuationBeforeIdleEOFIs409(t *testing.T) {
})
f.create(`,"event_id":"c1"`)
- if got := gjson.GetBytes(f.next(), "type").String(); got != "response.created" {
+ if got := ojson.GetBytesNoCopy(f.next(), "type").String(); got != "response.created" {
t.Fatalf("first event = %s, want response.created", got)
}
f.create(`,"event_id":"c2","previous_response_id":"r1"`)
@@ -577,9 +581,9 @@ func TestResponsesWebsocketQueuedContinuationBeforeIdleEOFIs409(t *testing.T) {
close(releaseTurn2)
errFrame := f.nextError()
- if gjson.GetBytes(errFrame, "status").Int() != http.StatusConflict ||
- gjson.GetBytes(errFrame, "error.code").String() != "previous_response_not_found" ||
- gjson.GetBytes(errFrame, "error.event_id").String() != "c2" {
+ if ojson.GetBytesNoCopy(errFrame, "status").Int() != http.StatusConflict ||
+ ojson.GetBytesNoCopy(errFrame, "error.code").String() != "previous_response_not_found" ||
+ ojson.GetBytesNoCopy(errFrame, "error.event_id").String() != "c2" {
t.Fatalf("queued continuation after idle EOF = %s, want 409 previous_response_not_found for c2", errFrame)
}
@@ -636,7 +640,7 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
if errRead != nil {
return
}
- if gjson.GetBytes(payload, "type").String() != "response.create" {
+ if ojson.GetBytesNoCopy(payload, "type").String() != "response.create" {
continue
}
if authHeader == "Bearer key-a" {
@@ -752,15 +756,15 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
if errRead != nil {
t.Fatalf("request 1 downstream websocket closed before completion: %v (authACreates=%d authBCreates=%d)", errRead, authACreates.Load(), authBCreates.Load())
}
- if gotID := gjson.GetBytes(frame, "response.id").String(); gotID == "resp-a-buffered" {
+ if gotID := ojson.GetBytesNoCopy(frame, "response.id").String(); gotID == "resp-a-buffered" {
t.Fatalf("buffered handshake from failed credential A leaked to downstream: %s", frame)
}
- if gjson.GetBytes(frame, "type").String() == "response.completed" {
+ if ojson.GetBytesNoCopy(frame, "type").String() == "response.completed" {
completed1 = frame
break
}
}
- if got := gjson.GetBytes(completed1, "response.id").String(); got != "resp-b-1" {
+ if got := ojson.GetBytesNoCopy(completed1, "response.id").String(); got != "resp-b-1" {
t.Fatalf("request 1 completed response.id = %q, want resp-b-1; frame=%s", got, completed1)
}
if authACreates.Load() != 1 || authBCreates.Load() != 1 {
@@ -778,12 +782,12 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
if errRead != nil {
t.Fatalf("followup on conn1 closed before completion: %v", errRead)
}
- if gjson.GetBytes(frame, "type").String() == "response.completed" {
+ if ojson.GetBytesNoCopy(frame, "type").String() == "response.completed" {
completed1b = frame
break
}
}
- if got := gjson.GetBytes(completed1b, "response.id").String(); got != "resp-b-2" {
+ if got := ojson.GetBytesNoCopy(completed1b, "response.id").String(); got != "resp-b-2" {
t.Fatalf("followup on conn1 response.id = %q, want resp-b-2; frame=%s", got, completed1b)
}
if authBConns.Load() != 1 || authBCreates.Load() != 2 {
@@ -820,12 +824,12 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
if errRead != nil {
t.Fatalf("request 2 downstream websocket closed before completion: %v", errRead)
}
- if gjson.GetBytes(frame, "type").String() == "response.completed" {
+ if ojson.GetBytesNoCopy(frame, "type").String() == "response.completed" {
completed2 = frame
break
}
}
- if got := gjson.GetBytes(completed2, "response.id").String(); got != "resp-a-2" {
+ if got := ojson.GetBytesNoCopy(completed2, "response.id").String(); got != "resp-a-2" {
t.Fatalf("request 2 completed response.id = %q, want resp-a-2; frame=%s", got, completed2)
}
if authACreates.Load() != 2 || authBCreates.Load() != 2 {
@@ -848,7 +852,7 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
t.Fatalf("after continuation failure: authACreates=%d authBCreates=%d, want 3 and 2 (must not replay previous_response_id on auth-b)", authACreates.Load(), authBCreates.Load())
}
- // 4. When all eligible credentials return early 429, the downstream WebSocket closes cleanly after exhausting retries.
+ // 4. When all eligible credentials return early 429, the downstream WebSocket receives a terminal 429 error frame before closing.
authA429Next.Store(true)
authB429Next.Store(true)
conn3, resp3, errDial3 := websocket.DefaultDialer.Dial(wsURL, nil)
@@ -872,10 +876,30 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
t.Fatalf("write request 4: %v", errWrite)
}
_ = conn3.SetReadDeadline(time.Now().Add(5 * time.Second))
- _, frame4, errRead4 := conn3.ReadMessage()
- var closeErr4 *websocket.CloseError
- if !errors.As(errRead4, &closeErr4) || closeErr4.Code != websocket.CloseAbnormalClosure {
- t.Fatalf("exhausted credentials close = %v (frame=%s), want CloseAbnormalClosure (%d)", errRead4, frame4, websocket.CloseAbnormalClosure)
+ msgType4, frame4, errRead4 := conn3.ReadMessage()
+ if errRead4 != nil {
+ t.Fatalf("expected terminal 429 error frame after exhausting credentials, got read error: %v", errRead4)
+ }
+ if msgType4 != websocket.TextMessage {
+ t.Fatalf("frame4 msgType = %d, want TextMessage (%d)", msgType4, websocket.TextMessage)
+ }
+ if got := ojson.GetBytesNoCopy(frame4, "type").String(); got != "error" {
+ t.Fatalf("frame4 type = %q, want error: %s", got, frame4)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame4, "status").Int()); got != http.StatusTooManyRequests {
+ t.Fatalf("frame4 status = %d, want %d: %s", got, http.StatusTooManyRequests, frame4)
+ }
+ if got := ojson.GetBytesNoCopy(frame4, "error.type").String(); got != "usage_limit_reached" {
+ t.Fatalf("frame4 error.type = %q, want usage_limit_reached: %s", got, frame4)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame4, "error.resets_in_seconds").Int()); got != 600 {
+ t.Fatalf("frame4 error.resets_in_seconds = %d, want 600: %s", got, frame4)
+ }
+ if got := ojson.GetBytesNoCopy(frame4, "headers.Retry-After").String(); got == "" {
+ t.Fatalf("frame4 headers.Retry-After is empty: %s", frame4)
+ }
+ if _, extraFrame, errAfter := conn3.ReadMessage(); errAfter == nil {
+ t.Fatalf("expected socket close after terminal 429 error frame, got frame: %s", extraFrame)
}
if authACreates.Load() != 4 || authBCreates.Load() != 3 {
t.Fatalf("after all credentials exhausted: authACreates=%d authBCreates=%d, want 4 and 3", authACreates.Load(), authBCreates.Load())
@@ -884,3 +908,674 @@ func TestResponsesWebsocketEarly429RetriesAnotherCredentialWithoutClosingDownstr
}
}
}
+
+func TestResponsesWebsocketTerminal429WhenAllCredentialsExhaustedOrCooling(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ const early429Frame = `{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"plus","resets_in_seconds":600}}`
+
+ var creates atomic.Int32
+ var return429 atomic.Bool
+
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ c, errUpgrade := (&websocket.Upgrader{}).Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ t.Error(errUpgrade)
+ return
+ }
+ defer func() {
+ if errClose := c.Close(); errClose != nil {
+ t.Logf("close upstream websocket: %v", errClose)
+ }
+ }()
+ _ = c.SetReadDeadline(time.Now().Add(10 * time.Second))
+
+ for {
+ _, payload, errRead := c.ReadMessage()
+ if errRead != nil {
+ return
+ }
+ if ojson.GetBytesNoCopy(payload, "type").String() != "response.create" {
+ continue
+ }
+ turn := creates.Add(1)
+ if return429.Load() {
+ _ = c.WriteMessage(websocket.TextMessage, []byte(early429Frame))
+ continue
+ }
+ respID := fmt.Sprintf("resp-single-%d", turn)
+ _ = c.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.created","response":{"id":%q,"status":"in_progress","output":[]}}`, respID)))
+ _ = c.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"type":"response.completed","response":{"id":%q,"status":"completed","output":[{"id":"msg-%d","type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}]}}`, respID, turn)))
+ }
+ }))
+ t.Cleanup(upstream.Close)
+
+ cfg := &config.Config{
+ DisableCooling: false,
+ RequestRetry: 0,
+ MaxRetryCredentials: 1,
+ }
+ manager := coreauth.NewManager(nil, &coreauth.FillFirstSelector{}, nil)
+ manager.SetConfig(cfg)
+ manager.SetRetryConfig(cfg.RequestRetry, 0, cfg.MaxRetryCredentials)
+ manager.RegisterExecutor(runtimeexecutor.NewCodexAutoExecutor(cfg))
+
+ modelName := "codex-single-cred-429"
+ authID := "auth-single-429"
+ if _, errRegister := manager.Register(context.Background(), &coreauth.Auth{
+ ID: authID,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "api_key": "key-single",
+ "base_url": upstream.URL,
+ "websockets": "true",
+ },
+ }); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(authID, "codex", []*registry.ModelInfo{{ID: modelName}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(authID) })
+
+ base := handlers.NewBaseAPIHandlers(&cfg.SDKConfig, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.GET("/v1/responses", h.ResponsesWebsocket)
+ downstream := httptest.NewServer(router)
+ t.Cleanup(downstream.Close)
+ wsURL := "ws" + strings.TrimPrefix(downstream.URL, "http") + "/v1/responses"
+
+ // 1. First turn succeeds and pins the single credential.
+ conn1, resp1, errDial1 := websocket.DefaultDialer.Dial(wsURL, nil)
+ if resp1 != nil && resp1.Body != nil {
+ defer func() {
+ if errClose := resp1.Body.Close(); errClose != nil {
+ t.Logf("close first handshake response body: %v", errClose)
+ }
+ }()
+ }
+ if errDial1 != nil {
+ t.Fatalf("dial first downstream websocket: %v", errDial1)
+ }
+ defer func() {
+ if errClose := conn1.Close(); errClose != nil {
+ t.Logf("close first downstream websocket: %v", errClose)
+ }
+ }()
+
+ req1 := fmt.Sprintf(`{"type":"response.create","model":%q,"input":[{"type":"message","id":"msg-1","role":"user","content":"hello"}]}`, modelName)
+ if errWrite := conn1.WriteMessage(websocket.TextMessage, []byte(req1)); errWrite != nil {
+ t.Fatalf("write request 1: %v", errWrite)
+ }
+ for {
+ _ = conn1.SetReadDeadline(time.Now().Add(5 * time.Second))
+ _, frame, errRead := conn1.ReadMessage()
+ if errRead != nil {
+ t.Fatalf("request 1 failed: %v", errRead)
+ }
+ if ojson.GetBytesNoCopy(frame, "type").String() == "response.completed" {
+ break
+ }
+ }
+
+ // 2. Continuation on the pinned single credential hits 429 usage_limit_reached.
+ // With no alternative credential to fail over to, it must emit a terminal 429 error frame directly instead of 1012.
+ return429.Store(true)
+ req2 := fmt.Sprintf(`{"type":"response.create","model":%q,"previous_response_id":"resp-single-1","input":[{"type":"message","id":"msg-2","role":"user","content":"followup"}]}`, modelName)
+ if errWrite := conn1.WriteMessage(websocket.TextMessage, []byte(req2)); errWrite != nil {
+ t.Fatalf("write request 2: %v", errWrite)
+ }
+ _ = conn1.SetReadDeadline(time.Now().Add(5 * time.Second))
+ msgType2, frame2, errRead2 := conn1.ReadMessage()
+ if errRead2 != nil {
+ t.Fatalf("expected terminal 429 error frame on single-credential continuation, got error: %v", errRead2)
+ }
+ if msgType2 != websocket.TextMessage {
+ t.Fatalf("frame2 msgType = %d, want TextMessage (%d)", msgType2, websocket.TextMessage)
+ }
+ if got := ojson.GetBytesNoCopy(frame2, "type").String(); got != "error" {
+ t.Fatalf("frame2 type = %q, want error: %s", got, frame2)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame2, "status").Int()); got != http.StatusTooManyRequests {
+ t.Fatalf("frame2 status = %d, want %d: %s", got, http.StatusTooManyRequests, frame2)
+ }
+ if got := ojson.GetBytesNoCopy(frame2, "error.type").String(); got != "usage_limit_reached" {
+ t.Fatalf("frame2 error.type = %q, want usage_limit_reached: %s", got, frame2)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame2, "error.resets_in_seconds").Int()); got != 600 {
+ t.Fatalf("frame2 error.resets_in_seconds = %d, want 600: %s", got, frame2)
+ }
+ if _, extraFrame, errAfter := conn1.ReadMessage(); errAfter == nil {
+ t.Fatalf("expected socket close after terminal 429 error frame, got frame: %s", extraFrame)
+ }
+
+ // 3. The single credential is now cooling down. A subsequent connection must receive
+ // a terminal 429 model_cooldown error frame with reset_seconds and Retry-After.
+ conn2, resp2, errDial2 := websocket.DefaultDialer.Dial(wsURL, nil)
+ if resp2 != nil && resp2.Body != nil {
+ defer func() {
+ if errClose := resp2.Body.Close(); errClose != nil {
+ t.Logf("close second handshake response body: %v", errClose)
+ }
+ }()
+ }
+ if errDial2 != nil {
+ t.Fatalf("dial second downstream websocket: %v", errDial2)
+ }
+ defer func() {
+ if errClose := conn2.Close(); errClose != nil {
+ t.Logf("close second downstream websocket: %v", errClose)
+ }
+ }()
+
+ req3 := fmt.Sprintf(`{"type":"response.create","model":%q,"input":[{"type":"message","id":"msg-3","role":"user","content":"while cooling"}]}`, modelName)
+ if errWrite := conn2.WriteMessage(websocket.TextMessage, []byte(req3)); errWrite != nil {
+ t.Fatalf("write request 3: %v", errWrite)
+ }
+ _ = conn2.SetReadDeadline(time.Now().Add(5 * time.Second))
+ msgType3, frame3, errRead3 := conn2.ReadMessage()
+ if errRead3 != nil {
+ t.Fatalf("expected terminal 429 model_cooldown error frame, got error: %v", errRead3)
+ }
+ if msgType3 != websocket.TextMessage {
+ t.Fatalf("frame3 msgType = %d, want TextMessage (%d)", msgType3, websocket.TextMessage)
+ }
+ if got := ojson.GetBytesNoCopy(frame3, "type").String(); got != "error" {
+ t.Fatalf("frame3 type = %q, want error: %s", got, frame3)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame3, "status").Int()); got != http.StatusTooManyRequests {
+ t.Fatalf("frame3 status = %d, want %d: %s", got, http.StatusTooManyRequests, frame3)
+ }
+ if got := ojson.GetBytesNoCopy(frame3, "error.code").String(); got != "model_cooldown" {
+ t.Fatalf("frame3 error.code = %q, want model_cooldown: %s", got, frame3)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame3, "error.reset_seconds").Int()); got <= 0 {
+ t.Fatalf("frame3 error.reset_seconds = %d, want > 0: %s", got, frame3)
+ }
+ if got := ojson.GetBytesNoCopy(frame3, "headers.Retry-After").String(); got == "" {
+ t.Fatalf("frame3 headers.Retry-After is empty: %s", frame3)
+ }
+ if _, extraFrame, errAfter := conn2.ReadMessage(); errAfter == nil {
+ t.Fatalf("expected socket close after terminal 429 model_cooldown frame, got frame: %s", extraFrame)
+ }
+ if creates.Load() != 2 {
+ t.Fatalf("upstream creates = %d, want 2 (cooling credential must not be called again)", creates.Load())
+ }
+}
+
+func TestResponsesWebsocketPinned429ExposesErrorWhenOnlyAlternativeHasExpiredToken(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ const modelName = "codex-expired-alt-429"
+ upstreamUpgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, errUpgrade := upstreamUpgrader.Upgrade(w, r, nil)
+ if errUpgrade != nil {
+ return
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+ for turn := 1; ; turn++ {
+ _, raw, errRead := conn.ReadMessage()
+ if errRead != nil {
+ return
+ }
+ if ojson.GetBytesNoCopy(raw, "type").String() != "response.create" {
+ return
+ }
+ if turn == 1 {
+ _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[]}}`))
+ continue
+ }
+ _ = conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"error","status":429,"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"plus","resets_in_seconds":600}}`))
+ return
+ }
+ }))
+ defer upstream.Close()
+
+ cfg := &config.Config{
+ SDKConfig: config.SDKConfig{PassthroughHeaders: true},
+ RequestRetry: 0,
+ MaxRetryCredentials: 0,
+ QuotaExceeded: config.QuotaExceeded{
+ SwitchProject: true,
+ SwitchPreviewModel: true,
+ },
+ }
+ manager := coreauth.NewManager(nil, &coreauth.FillFirstSelector{}, nil)
+ manager.SetConfig(cfg)
+ manager.SetRetryConfig(cfg.RequestRetry, 0, cfg.MaxRetryCredentials)
+ manager.RegisterExecutor(runtimeexecutor.NewCodexAutoExecutor(cfg))
+
+ authA := &coreauth.Auth{
+ ID: "auth-active-429",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "api_key": "key-active",
+ "base_url": upstream.URL,
+ "websockets": "true",
+ },
+ }
+ authB := &coreauth.Auth{
+ ID: "auth-expired-alt",
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "api_key": "key-expired",
+ "base_url": upstream.URL,
+ "websockets": "true",
+ },
+ Metadata: map[string]any{
+ "access_token": "expired-token",
+ "expired": time.Now().Add(-time.Hour).Format(time.RFC3339),
+ },
+ }
+ for _, a := range []*coreauth.Auth{authA, authB} {
+ if _, errReg := manager.Register(context.Background(), a); errReg != nil {
+ t.Fatalf("register auth %s: %v", a.ID, errReg)
+ }
+ registry.GetGlobalRegistry().RegisterClient(a.ID, "codex", []*registry.ModelInfo{{ID: modelName}})
+ }
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(authA.ID)
+ registry.GetGlobalRegistry().UnregisterClient(authB.ID)
+ })
+
+ baseHandler := handlers.NewBaseAPIHandlers(&cfg.SDKConfig, manager)
+ handler := NewOpenAIResponsesAPIHandler(baseHandler)
+ router := gin.New()
+ router.GET("/v1/responses", handler.ResponsesWebsocket)
+ downstream := httptest.NewServer(router)
+ defer downstream.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(downstream.URL, "http") + "/v1/responses"
+ conn, resp, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
+ if resp != nil && resp.Body != nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ if errDial != nil {
+ t.Fatalf("dial downstream websocket: %v", errDial)
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+
+ req1 := fmt.Sprintf(`{"type":"response.create","model":%q,"input":[{"type":"message","id":"msg-1","role":"user","content":"turn 1"}]}`, modelName)
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(req1)); errWrite != nil {
+ t.Fatalf("write request 1: %v", errWrite)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ if _, frame1, errRead1 := conn.ReadMessage(); errRead1 != nil || ojson.GetBytesNoCopy(frame1, "type").String() != "response.completed" {
+ t.Fatalf("read request 1: frame=%s err=%v", frame1, errRead1)
+ }
+
+ req2 := fmt.Sprintf(`{"type":"response.create","model":%q,"previous_response_id":"resp-1","input":[{"type":"message","id":"msg-2","role":"user","content":"turn 2"}]}`, modelName)
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(req2)); errWrite != nil {
+ t.Fatalf("write request 2: %v", errWrite)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ _, frame2, errRead2 := conn.ReadMessage()
+ if errRead2 != nil {
+ t.Fatalf("expected terminal 429 error frame when only alternative credential is expired, got error: %v", errRead2)
+ }
+ if got := ojson.GetBytesNoCopy(frame2, "type").String(); got != "error" {
+ t.Fatalf("frame2 type = %q, want error: %s", got, frame2)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame2, "status").Int()); got != http.StatusTooManyRequests {
+ t.Fatalf("frame2 status = %d, want 429: %s", got, frame2)
+ }
+ if got := ojson.GetBytesNoCopy(frame2, "error.type").String(); got != "usage_limit_reached" {
+ t.Fatalf("frame2 error.type = %q, want usage_limit_reached: %s", got, frame2)
+ }
+}
+
+type homeCallbackStreamExecutor struct {
+ provider string
+ streamFn func(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error)
+}
+
+func (e *homeCallbackStreamExecutor) Identifier() string {
+ if e != nil && strings.TrimSpace(e.provider) != "" {
+ return strings.TrimSpace(e.provider)
+ }
+ return "codex"
+}
+
+func (*homeCallbackStreamExecutor) Execute(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (e *homeCallbackStreamExecutor) ExecuteStream(ctx context.Context, auth *coreauth.Auth, req coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ return e.streamFn(ctx, auth, req, opts)
+}
+
+func (*homeCallbackStreamExecutor) Refresh(context.Context, *coreauth.Auth) (*coreauth.Auth, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (*homeCallbackStreamExecutor) CountTokens(context.Context, *coreauth.Auth, coreexecutor.Request, coreexecutor.Options) (coreexecutor.Response, error) {
+ return coreexecutor.Response{}, errors.New("not implemented")
+}
+
+func (*homeCallbackStreamExecutor) HttpRequest(context.Context, *coreauth.Auth, *http.Request) (*http.Response, error) {
+ return nil, errors.New("not implemented")
+}
+
+func TestResponsesWebsocketHomePinned429ExposesErrorWhenNoAlternativeAvailable(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ dispatcher := &homeResponsesWebsocketDispatcher{}
+ var turnCount atomic.Int32
+ executor := &homeCallbackStreamExecutor{
+ provider: "codex",
+ streamFn: func(_ context.Context, _ *coreauth.Auth, _ coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ if turnCount.Add(1) == 1 {
+ if lifecycle, ok := opts.ExecutionLifecycle.(interface{ Retain() }); ok {
+ lifecycle.Retain()
+ }
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"type":"response.completed","response":{"id":"resp-home-1","output":[]}}`)}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ }
+ return nil, websocketPinnedFailoverStatusError{
+ status: http.StatusTooManyRequests,
+ msg: `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"team","resets_in_seconds":600}}`,
+ }
+ },
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.SetConfig(&config.Config{Home: config.HomeConfig{Enabled: true}})
+ manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1)
+ manager.RegisterExecutor(executor)
+ registry.GetGlobalRegistry().RegisterClient("home-responses-websocket-auth", "codex", []*registry.ModelInfo{{ID: "gpt-5.4"}})
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient("home-responses-websocket-auth")
+ })
+
+ base := handlers.NewBaseAPIHandlers(&config.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.GET("/v1/responses/ws", h.ResponsesWebsocket)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws"
+ conn, resp, errDial := websocket.DefaultDialer.Dial(wsURL, nil)
+ if resp != nil && resp.Body != nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ if errDial != nil {
+ t.Fatalf("dial websocket: %v", errDial)
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"gpt-5.4","input":[]}`)); errWrite != nil {
+ t.Fatalf("write turn 1: %v", errWrite)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ if _, frame1, errRead1 := conn.ReadMessage(); errRead1 != nil || ojson.GetBytesNoCopy(frame1, "type").String() != "response.completed" {
+ t.Fatalf("read turn 1: frame=%s err=%v", frame1, errRead1)
+ }
+
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"gpt-5.4","previous_response_id":"resp-home-1","input":[]}`)); errWrite != nil {
+ t.Fatalf("write turn 2: %v", errWrite)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ _, frame2, errRead2 := conn.ReadMessage()
+ if errRead2 != nil {
+ t.Fatalf("expected terminal 429 error frame on single-credential Home continuation, got error: %v", errRead2)
+ }
+ if got := ojson.GetBytesNoCopy(frame2, "type").String(); got != "error" {
+ t.Fatalf("frame2 type = %q, want error: %s", got, frame2)
+ }
+ if got := int(ojson.GetBytesNoCopy(frame2, "status").Int()); got != http.StatusTooManyRequests {
+ t.Fatalf("frame2 status = %d, want 429: %s", got, frame2)
+ }
+ if got := ojson.GetBytesNoCopy(frame2, "error.type").String(); got != "usage_limit_reached" {
+ t.Fatalf("frame2 error.type = %q, want usage_limit_reached: %s", got, frame2)
+ }
+}
+
+type authHeaderCheckingHomeDispatcher struct {
+ mu sync.Mutex
+ requiredAuth string
+ authIDs []string
+}
+
+func (*authHeaderCheckingHomeDispatcher) HeartbeatOK() bool { return true }
+func (*authHeaderCheckingHomeDispatcher) AbortAmbiguousDispatch() {}
+
+func (d *authHeaderCheckingHomeDispatcher) RPopAuth(ctx context.Context, model string, sessionID string, headers http.Header, count int) ([]byte, error) {
+ return d.RPopAuthWithConstraints(ctx, model, sessionID, headers, count, nil, "")
+}
+
+func (d *authHeaderCheckingHomeDispatcher) RPopAuthWithConstraints(_ context.Context, _ string, _ string, headers http.Header, _ int, excludedAuthIDs []string, pinnedAuthID string) ([]byte, error) {
+ if strings.TrimSpace(headers.Get("Authorization")) != d.requiredAuth {
+ return []byte(`{"error":{"code":"auth_not_found","message":"missing or invalid client authorization header"}}`), nil
+ }
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ excluded := make(map[string]struct{}, len(excludedAuthIDs))
+ for _, id := range excludedAuthIDs {
+ excluded[id] = struct{}{}
+ }
+ for _, id := range d.authIDs {
+ if pinnedAuthID != "" && id != pinnedAuthID {
+ continue
+ }
+ if _, skip := excluded[id]; skip {
+ continue
+ }
+ return json.Marshal(coreauth.Auth{
+ ID: id,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ Attributes: map[string]string{
+ "websockets": "true",
+ },
+ })
+ }
+ return []byte(`{"error":{"code":"auth_not_found","message":"no more auths"}}`), nil
+}
+
+func TestResponsesWebsocketHomePinned429ReplaysWith1012WhenAuthenticatedAlternativeExists(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ dispatcher := &authHeaderCheckingHomeDispatcher{
+ requiredAuth: "Bearer client-secret-key",
+ authIDs: []string{"home-auth-a", "home-auth-b"},
+ }
+ var turnCount atomic.Int32
+ executor := &homeCallbackStreamExecutor{
+ provider: "codex",
+ streamFn: func(_ context.Context, auth *coreauth.Auth, _ coreexecutor.Request, opts coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ if turnCount.Add(1) == 1 {
+ if lifecycle, ok := opts.ExecutionLifecycle.(interface{ Retain() }); ok {
+ lifecycle.Retain()
+ }
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"type":"response.completed","response":{"id":"resp-home-1","output":[]}}`)}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ }
+ if auth != nil && auth.ID == "home-auth-a" {
+ return nil, websocketPinnedFailoverStatusError{
+ status: http.StatusTooManyRequests,
+ msg: `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","plan_type":"team","resets_in_seconds":600}}`,
+ }
+ }
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Payload: []byte(`{"type":"response.completed","response":{"id":"resp-home-2","output":[]}}`)}
+ close(chunks)
+ return &coreexecutor.StreamResult{Chunks: chunks}, nil
+ },
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.SetConfig(&config.Config{Home: config.HomeConfig{Enabled: true}})
+ manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1)
+ manager.RegisterExecutor(executor)
+ for _, id := range []string{"home-auth-a", "home-auth-b"} {
+ registry.GetGlobalRegistry().RegisterClient(id, "codex", []*registry.ModelInfo{{ID: "gpt-5.4"}})
+ }
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient("home-auth-a")
+ registry.GetGlobalRegistry().UnregisterClient("home-auth-b")
+ })
+
+ base := handlers.NewBaseAPIHandlers(&config.SDKConfig{}, manager)
+ h := NewOpenAIResponsesAPIHandler(base)
+ router := gin.New()
+ router.GET("/v1/responses/ws", h.ResponsesWebsocket)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/responses/ws"
+ dialHeaders := http.Header{"Authorization": {"Bearer client-secret-key"}}
+ conn, resp, errDial := websocket.DefaultDialer.Dial(wsURL, dialHeaders)
+ if resp != nil && resp.Body != nil {
+ defer func() {
+ _ = resp.Body.Close()
+ }()
+ }
+ if errDial != nil {
+ t.Fatalf("dial websocket: %v", errDial)
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"gpt-5.4","input":[]}`)); errWrite != nil {
+ t.Fatalf("write turn 1: %v", errWrite)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ if _, frame1, errRead1 := conn.ReadMessage(); errRead1 != nil || ojson.GetBytesNoCopy(frame1, "type").String() != "response.completed" {
+ t.Fatalf("read turn 1: frame=%s err=%v", frame1, errRead1)
+ }
+
+ if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"response.create","model":"gpt-5.4","previous_response_id":"resp-home-1","input":[]}`)); errWrite != nil {
+ t.Fatalf("write turn 2: %v", errWrite)
+ }
+ _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
+ _, frame2, errRead2 := conn.ReadMessage()
+ if errRead2 == nil {
+ t.Fatalf("expected 1012 replay close when authenticated Home alternative exists, got frame: %s", frame2)
+ }
+ if !websocket.IsCloseError(errRead2, websocket.CloseServiceRestart) {
+ t.Fatalf("turn 2 close error = %v, want 1012 ServiceRestart", errRead2)
+ }
+}
+
+type websocketHintTestError struct {
+ status int
+ msg string
+ hint *time.Duration
+}
+
+func (e websocketHintTestError) Error() string { return e.msg }
+func (e websocketHintTestError) StatusCode() int { return e.status }
+func (e websocketHintTestError) RetryAfter() *time.Duration { return e.hint }
+
+type websocketTwoAuthHomeDispatcher struct {
+ mu sync.Mutex
+ authIDs []string
+}
+
+func (*websocketTwoAuthHomeDispatcher) HeartbeatOK() bool { return true }
+func (*websocketTwoAuthHomeDispatcher) AbortAmbiguousDispatch() {}
+
+func (d *websocketTwoAuthHomeDispatcher) RPopAuth(ctx context.Context, model string, sessionID string, headers http.Header, count int) ([]byte, error) {
+ return d.RPopAuthWithConstraints(ctx, model, sessionID, headers, count, nil, "")
+}
+
+func (d *websocketTwoAuthHomeDispatcher) RPopAuthWithConstraints(_ context.Context, _ string, _ string, _ http.Header, _ int, excludedAuthIDs []string, _ string) ([]byte, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ excluded := make(map[string]struct{}, len(excludedAuthIDs))
+ for _, id := range excludedAuthIDs {
+ excluded[id] = struct{}{}
+ }
+ for _, id := range d.authIDs {
+ if _, skip := excluded[id]; skip {
+ continue
+ }
+ return json.Marshal(coreauth.Auth{
+ ID: id,
+ Provider: "codex",
+ Status: coreauth.StatusActive,
+ })
+ }
+ return []byte(`{"error":{"code":"request_retry_exceeded","message":"no more auths"}}`), nil
+}
+
+func TestBuildResponsesWebsocketErrorPayloadSuppressesStaleRetryAfterWhenTrustedDeadlineHasNoHeader(t *testing.T) {
+ zero := time.Duration(0)
+ positive := 256 * time.Second
+ dispatcher := &websocketTwoAuthHomeDispatcher{authIDs: []string{"home-a", "home-b"}}
+ executor := &homeCallbackStreamExecutor{
+ provider: "codex",
+ streamFn: func(_ context.Context, auth *coreauth.Auth, _ coreexecutor.Request, _ coreexecutor.Options) (*coreexecutor.StreamResult, error) {
+ hint := &positive
+ if auth != nil && auth.ID == "home-a" {
+ hint = &zero
+ }
+ chunks := make(chan coreexecutor.StreamChunk, 1)
+ chunks <- coreexecutor.StreamChunk{Err: websocketHintTestError{
+ status: http.StatusTooManyRequests,
+ msg: `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`,
+ hint: hint,
+ }}
+ close(chunks)
+ return &coreexecutor.StreamResult{
+ Headers: http.Header{"Retry-After": {"30"}, "X-Request-Id": {"req-1"}},
+ Chunks: chunks,
+ }, nil
+ },
+ }
+
+ manager := coreauth.NewManager(nil, nil, nil)
+ manager.SetConfig(&config.Config{Home: config.HomeConfig{Enabled: true}})
+ manager.SetRetryConfig(0, time.Second, 2)
+ manager.PublishHomeDispatch(dispatcher, executionregistry.New(), 1)
+ manager.RegisterExecutor(executor)
+
+ result, errExec := manager.ExecuteStream(context.Background(), []string{"codex"}, coreexecutor.Request{Model: "gpt-5.4"}, coreexecutor.Options{Stream: true})
+ if errExec != nil {
+ t.Fatalf("ExecuteStream() error = %v", errExec)
+ }
+ chunk, ok := <-result.Chunks
+ if !ok || chunk.Err == nil {
+ t.Fatalf("stream chunk = %#v, %t; want error", chunk, ok)
+ }
+ if !coreauth.HasTrustedRetryDeadline(chunk.Err) {
+ t.Fatal("expected HasTrustedRetryDeadline(chunk.Err) = true")
+ }
+ if got := coreauth.SafeResponseHeaders(chunk.Err).Get("Retry-After"); got != "" {
+ t.Fatalf("expected empty SafeResponseHeaders Retry-After, got %q", got)
+ }
+
+ payload, errBuild := buildResponsesWebsocketErrorPayload(&interfaces.ErrorMessage{
+ StatusCode: http.StatusTooManyRequests,
+ Error: chunk.Err,
+ Addon: http.Header{"Retry-After": {"30"}, "X-Request-Id": {"req-1"}},
+ })
+ if errBuild != nil {
+ t.Fatalf("buildResponsesWebsocketErrorPayload() error = %v", errBuild)
+ }
+ if got := ojson.GetBytesNoCopy(payload, "headers.Retry-After").String(); got != "" {
+ t.Fatalf("stale Addon Retry-After leaked into websocket error payload: %q (%s)", got, payload)
+ }
+ if got := ojson.GetBytesNoCopy(payload, "headers.X-Request-Id").String(); got != "req-1" {
+ t.Fatalf("headers.X-Request-Id = %q, want req-1 (%s)", got, payload)
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_metadata_test.go b/sdk/api/handlers/openai/openai_responses_websocket_metadata_test.go
index 61a621e0432..b77739f9393 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_metadata_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_metadata_test.go
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
@@ -15,7 +16,6 @@ import (
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
type websocketMetadataExecutor struct {
@@ -98,7 +98,7 @@ func TestResponsesWebsocketFiltersModerationMetadataForStandardClients(t *testin
if errRead != nil {
t.Fatalf("read websocket: %v; events: %v", errRead, gotTypes)
}
- eventType := gjson.GetBytes(payload, "type").String()
+ eventType := ojson.GetBytesNoCopy(payload, "type").String()
gotTypes = append(gotTypes, eventType)
if eventType == "error" {
t.Fatalf("unexpected error event: %s", payload)
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go b/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go
index 0950fda29c1..367281714e4 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_prewarm.go
@@ -6,22 +6,23 @@ import (
"strings"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func shouldHandleResponsesWebsocketPrewarmLocally(rawJSON []byte, allowIncrementalInputWithPreviousResponseID bool) bool {
if allowIncrementalInputWithPreviousResponseID {
return false
}
- if strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String()) != wsRequestTypeCreate {
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "type", "generate")
+ if strings.TrimSpace(fields[0].String()) != wsRequestTypeCreate {
return false
}
- generateResult := gjson.GetBytes(rawJSON, "generate")
+ generateResult := fields[1]
return generateResult.Exists() && !generateResult.Bool()
}
@@ -55,18 +56,20 @@ func writeResponsesWebsocketSyntheticPrewarm(
return "", errWrite
}
}
- return gjson.GetBytes(payloads[0], "response.id").String(), nil
+ return strings.Clone(ojson.GetBytesNoCopy(payloads[0], "response.id").String()), nil
}
// A synthetic warm-up acknowledges input that never reached the upstream.
// Materialize that input before compacted-history detection can mistake the
// client's remaining delta for a complete replacement transcript.
func normalizeResponsesWebsocketPrewarmFollowup(rawJSON, warmupRequest []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
- requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "type", "input")
+ requestType := strings.TrimSpace(fields[0].String())
if requestType != wsRequestTypeCreate && requestType != wsRequestTypeAppend {
return nil, warmupRequest, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unsupported websocket request type: %s", requestType)}
}
- input := gjson.GetBytes(rawJSON, "input")
+ input := fields[1]
if !input.IsArray() {
return nil, warmupRequest, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("websocket request requires array field: input")}
}
@@ -74,55 +77,47 @@ func normalizeResponsesWebsocketPrewarmFollowup(rawJSON, warmupRequest []byte) (
if errMerge != nil {
return nil, warmupRequest, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: errMerge}
}
- normalized := normalizeResponseTranscriptReplacement(rawJSON, warmupRequest)
- var errSet error
- normalized, errSet = sjson.SetRawBytes(normalized, "input", merged)
- if errSet != nil {
+ doc := ojson.NewDoc(rawJSON)
+ normalizeResponseTranscriptReplacementDoc(doc, warmupRequest)
+ if errSet := doc.SetRawBytes("input", merged); errSet != nil {
return nil, warmupRequest, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: errSet}
}
+ normalized := doc.Bytes()
return normalized, normalized, nil
}
func syntheticResponsesWebsocketPrewarmPayloads(requestJSON []byte) ([][]byte, error) {
responseID := "resp_prewarm_" + uuid.NewString()
createdAt := time.Now().Unix()
- modelName := strings.TrimSpace(gjson.GetBytes(requestJSON, "model").String())
+ modelName := strings.TrimSpace(ojson.GetBytesNoCopy(requestJSON, "model").String())
- createdPayload := []byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`)
- var errSet error
- createdPayload, errSet = sjson.SetBytes(createdPayload, "response.id", responseID)
- if errSet != nil {
+ createdDoc := ojson.NewDoc([]byte(`{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"output":[]}}`))
+ if errSet := createdDoc.Set("response.id", responseID); errSet != nil {
return nil, errSet
}
- createdPayload, errSet = sjson.SetBytes(createdPayload, "response.created_at", createdAt)
- if errSet != nil {
+ if errSet := createdDoc.Set("response.created_at", createdAt); errSet != nil {
return nil, errSet
}
if modelName != "" {
- createdPayload, errSet = sjson.SetBytes(createdPayload, "response.model", modelName)
- if errSet != nil {
+ if errSet := createdDoc.Set("response.model", modelName); errSet != nil {
return nil, errSet
}
-
}
- completedPayload := []byte(`{"type":"response.completed","sequence_number":1,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`)
- completedPayload, errSet = sjson.SetBytes(completedPayload, "response.id", responseID)
- if errSet != nil {
+ completedDoc := ojson.NewDoc([]byte(`{"type":"response.completed","sequence_number":1,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null,"output":[],"usage":{"input_tokens":0,"input_tokens_details":{"cached_tokens":0},"output_tokens":0,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":0}}}`))
+ if errSet := completedDoc.Set("response.id", responseID); errSet != nil {
return nil, errSet
}
- completedPayload, errSet = sjson.SetBytes(completedPayload, "response.created_at", createdAt)
- if errSet != nil {
+ if errSet := completedDoc.Set("response.created_at", createdAt); errSet != nil {
return nil, errSet
}
if modelName != "" {
- completedPayload, errSet = sjson.SetBytes(completedPayload, "response.model", modelName)
- if errSet != nil {
+ if errSet := completedDoc.Set("response.model", modelName); errSet != nil {
return nil, errSet
}
}
- return [][]byte{createdPayload, completedPayload}, nil
+ return [][]byte{createdDoc.Bytes(), completedDoc.Bytes()}, nil
}
// inputContainsFullTranscript returns true when the input array carries compact
@@ -133,7 +128,7 @@ func syntheticResponsesWebsocketPrewarmPayloads(requestJSON []byte) ([][]byte, e
//
// Assistant messages alone are not enough to classify the payload as a replay:
// incremental websocket requests may legitimately append assistant items.
-func inputContainsFullTranscript(input gjson.Result) bool {
+func inputContainsFullTranscript(input ojson.Result) bool {
if !input.IsArray() {
return false
}
@@ -146,7 +141,7 @@ func inputContainsFullTranscript(input gjson.Result) bool {
return false
}
-func inputWithoutCompactionItems(input gjson.Result) string {
+func inputWithoutCompactionItems(input ojson.Result) string {
if !input.IsArray() {
return normalizeJSONArrayRaw([]byte(input.Raw))
}
@@ -166,8 +161,8 @@ func normalizeJSONArrayRaw(raw []byte) string {
if trimmed == "" {
return "[]"
}
- result := gjson.Parse(trimmed)
- if result.Type == gjson.JSON && result.IsArray() {
+ result := ojson.Parse(trimmed)
+ if result.Type == ojson.JSON && result.IsArray() {
return trimmed
}
return "[]"
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_replay_sanitize_test.go b/sdk/api/handlers/openai/openai_responses_websocket_replay_sanitize_test.go
new file mode 100644
index 00000000000..d83d5c6a898
--- /dev/null
+++ b/sdk/api/handlers/openai/openai_responses_websocket_replay_sanitize_test.go
@@ -0,0 +1,244 @@
+package openai
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/MoeForge/moego/ojson"
+)
+
+// TestMergeResponsesWebsocketInputDropsReplayedContentTokenCount guards issue #7146:
+// items replayed from cached upstream output must not carry the output-only
+// internal_chat_message_metadata_passthrough.content_token_count field into the
+// next upstream request, while client-sent items stay untouched.
+func TestMergeResponsesWebsocketInputDropsReplayedContentTokenCount(t *testing.T) {
+ lastRequest := []byte(`{"model":"test-model","stream":true,"input":[{"type":"message","id":"msg-1"}]}`)
+ lastResponseOutput := []byte(`[
+ {"type":"message","id":"assistant-1","role":"assistant","phase":"commentary","internal_chat_message_metadata_passthrough":{"turn_id":"turn-1","create_time":1793474327.5,"content_token_count":64},"content":[{"type":"output_text","text":"hi"}]},
+ {"type":"message","id":"assistant-2","role":"assistant","internal_chat_message_metadata_passthrough":{ "content_token_count":3 },"content":[{"type":"output_text","text":"only-count"}]},
+ {"type":"message","id":"assistant-3","role":"assistant","internal_chat_message_metadata_passthrough":{"turn_id":"turn-3"},"content":[{"type":"output_text","text":"no-count"}]},
+ {"type":"message","id":"assistant-4","role":"assistant","\u0069nternal_chat_message_metadata_passthrough":{"content\u005ftoken_count":7,"turn_id":"turn-escaped"},"content":[{"type":"output_text","text":"escaped"}]},
+ {"type":"message","id":"assistant-5","role":"assistant","internal_chat_message_metadata_passthrough":{"content_token_count":8,"turn_id":"turn-first","content_token_count":9},"content":[{"type":"output_text","text":"dup child"}]},
+ {"type":"message","id":"assistant-6","role":"assistant","internal_chat_message_metadata_passthrough":{"content_token_count":10},"internal_chat_message_metadata_passthrough":{"content_token_count":11,"turn_id":"turn-second"},"content":[{"type":"output_text","text":"dup parent"}]},
+ {"type":"message","id":"assistant-7","role":"assistant","internal_chat_message_metadata_passthrough":null,"content":[{"type":"output_text","text":"null passthrough"}]},
+ {"type":"message","id":"assistant-8","role":"assistant","internal_chat_message_metadata_passthrough":{"content_token_count":12},"tag":"first","tag":"second","content":[{"type":"output_text","text":"dup non-passthrough"}]},
+ {"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"{}","internal_chat_message_metadata_passthrough":{"turn_id":"turn-1","content_token_count":5}}
+ ]`)
+ // The client-sent append item carries the field as well: client state is never rewritten.
+ raw := []byte(`{"type":"response.create","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1","internal_chat_message_metadata_passthrough":{"content_token_count":99}}]}`)
+
+ normalized, _, errMsg := normalizeResponsesWebsocketRequest(raw, lastRequest, lastResponseOutput)
+ if errMsg != nil {
+ t.Fatalf("unexpected error: %v", errMsg.Error)
+ }
+
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
+ if len(input) != 11 {
+ t.Fatalf("merged input len = %d, want 11: %s", len(input), normalized)
+ }
+ if input[0].Get("id").String() != "msg-1" {
+ t.Fatalf("unexpected previous-request item: %s", input[0].Raw)
+ }
+
+ first := input[1]
+ if first.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("replayed assistant message must not carry content_token_count: %s", first.Raw)
+ }
+ if first.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-1" ||
+ !first.Get("internal_chat_message_metadata_passthrough.create_time").Exists() {
+ t.Fatalf("replayed assistant message must keep other passthrough keys: %s", first.Raw)
+ }
+
+ second := input[2]
+ if second.Get("internal_chat_message_metadata_passthrough").Exists() {
+ t.Fatalf("passthrough object emptied by removal must be dropped: %s", second.Raw)
+ }
+
+ third := input[3]
+ if third.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-3" {
+ t.Fatalf("passthrough without content_token_count must stay untouched: %s", third.Raw)
+ }
+
+ escaped := input[4]
+ if escaped.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("escaped passthrough key must not bypass removal: %s", escaped.Raw)
+ }
+ if escaped.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-escaped" {
+ t.Fatalf("escaped passthrough must keep other keys: %s", escaped.Raw)
+ }
+
+ duplicateChild := input[5]
+ if duplicateChild.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("duplicate content_token_count members must all be removed: %s", duplicateChild.Raw)
+ }
+ if duplicateChild.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-first" {
+ t.Fatalf("duplicate-child passthrough must keep other keys: %s", duplicateChild.Raw)
+ }
+
+ duplicateParent := input[6]
+ if duplicateParent.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("duplicate passthrough members must not hide content_token_count: %s", duplicateParent.Raw)
+ }
+ if duplicateParent.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-second" {
+ t.Fatalf("duplicate-parent passthrough must keep other keys: %s", duplicateParent.Raw)
+ }
+
+ nullPassthrough := input[7]
+ if !nullPassthrough.Get("internal_chat_message_metadata_passthrough").Exists() ||
+ nullPassthrough.Get("internal_chat_message_metadata_passthrough").Type != ojson.Null {
+ t.Fatalf("null passthrough must stay untouched: %s", nullPassthrough.Raw)
+ }
+
+ duplicateOther := input[8]
+ if duplicateOther.Get("internal_chat_message_metadata_passthrough").Exists() {
+ t.Fatalf("emptied passthrough must be dropped beside duplicate members: %s", duplicateOther.Raw)
+ }
+ duplicateTags := make([]string, 0, 2)
+ duplicateOther.ForEach(func(key, value ojson.Result) bool {
+ if key.String() == "tag" {
+ duplicateTags = append(duplicateTags, value.String())
+ }
+ return true
+ })
+ if len(duplicateTags) != 2 || duplicateTags[0] != "first" || duplicateTags[1] != "second" {
+ t.Fatalf("duplicate non-passthrough members must be preserved in order: %s", duplicateOther.Raw)
+ }
+
+ fourth := input[9]
+ if fourth.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("replayed tool call must not carry content_token_count: %s", fourth.Raw)
+ }
+ if fourth.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-1" ||
+ fourth.Get("name").String() != "lookup" {
+ t.Fatalf("replayed tool call must keep other fields: %s", fourth.Raw)
+ }
+
+ last := input[10]
+ if !last.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("client-sent input must not be rewritten: %s", last.Raw)
+ }
+}
+
+// TestResponsesWebsocketToolCacheSanitizesTransactionalLifecycle locks the
+// recordResponse -> commit -> default cache -> repair path of issue #7146,
+// including a large argument payload and item-level payload events.
+func TestResponsesWebsocketToolCacheSanitizesTransactionalLifecycle(t *testing.T) {
+ sessionKey := "session-7146-transaction"
+ defer defaultWebsocketToolOutputCache.deleteSession(sessionKey)
+ defer defaultWebsocketToolCallCache.deleteSession(sessionKey)
+
+ largeArgument := strings.Repeat("x", 64<<10)
+ turn := newResponsesWebsocketToolCacheTurn(sessionKey)
+ turn.recordResponse([]byte(`{"type":"response.output_item.done","item":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"` + largeArgument + `","\u0069nternal_chat_message_metadata_passthrough":{"turn_id":"tx","content_token_count":9}}}`))
+ turn.recordResponse([]byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"fc-2","call_id":"call-2","name":"grep","arguments":"{}","internal_chat_message_metadata_passthrough":{"content_token_count":2}}]}}`))
+ turn.commit()
+
+ repaired := repairResponsesWebsocketToolCallsWithoutRecording(sessionKey, []byte(`{"input":[{"type":"function_call_output","call_id":"call-1","output":"ok"},{"type":"function_call_output","call_id":"call-2","output":"ok"}]}`))
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
+ if len(input) != 4 {
+ t.Fatalf("repaired input len = %d, want 4: %s", len(input), repaired)
+ }
+ if injected := input[0]; injected.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() || injected.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "tx" || len(injected.Get("arguments").String()) != len(largeArgument) {
+ t.Fatalf("transactional upstream call was not sanitized intact: %s", injected.Raw)
+ }
+ if injected := input[2]; injected.Get("internal_chat_message_metadata_passthrough").Exists() {
+ t.Fatalf("emptied transactional passthrough must be dropped: %s", injected.Raw)
+ }
+}
+
+// TestResponsesWebsocketToolCachePreservesClientItems keeps the client-data
+// boundary of issue #7146: only tool calls recorded from upstream response
+// payloads are sanitized, while client tool results round-trip unchanged
+// through the repair cache.
+func TestResponsesWebsocketToolCachePreservesClientItems(t *testing.T) {
+ outputCache := newWebsocketToolOutputCache(time.Minute, 10)
+ callCache := newWebsocketToolOutputCache(time.Minute, 10)
+ sessionKey := "session-7146-client"
+
+ warmPayload := []byte(`{"input":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call-1","id":"fco-1","output":"ok","internal_chat_message_metadata_passthrough":{"content_token_count":42,"turn_id":"client"}}]}`)
+ repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, warmPayload)
+
+ raw := []byte(`{"input":[{"type":"function_call","id":"fc-next","call_id":"call-1","name":"lookup","arguments":"{}"}]}`)
+ repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
+
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
+ if len(input) != 2 {
+ t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
+ }
+ if input[1].Get("internal_chat_message_metadata_passthrough.content_token_count").Int() != 42 ||
+ input[1].Get("internal_chat_message_metadata_passthrough.turn_id").String() != "client" {
+ t.Fatalf("client tool output must not be sanitized through the cache: %s", input[1].Raw)
+ }
+}
+
+// TestResponsesWebsocketToolCachePreservesTransactionalClientItems verifies
+// the same client boundary through prepareResponsesWebsocketFallbackTurn and
+// commit into the default caches.
+func TestResponsesWebsocketToolCachePreservesTransactionalClientItems(t *testing.T) {
+ const sessionKey = "session-7146-transaction-client"
+ defer defaultWebsocketToolOutputCache.deleteSession(sessionKey)
+ defer defaultWebsocketToolCallCache.deleteSession(sessionKey)
+
+ _, turn := prepareResponsesWebsocketFallbackTurn(sessionKey, []byte(`{"input":[{"type":"function_call_output","call_id":"call-1","id":"fco-1","output":"ok","internal_chat_message_metadata_passthrough":{"content_token_count":42,"turn_id":"client"}}]}`))
+ turn.commit()
+
+ repaired := repairResponsesWebsocketToolCallsWithoutRecording(sessionKey, []byte(`{"input":[{"type":"function_call","id":"fc-next","call_id":"call-1","name":"lookup","arguments":"{}"}]}`))
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
+ if len(input) != 2 || input[1].Get("internal_chat_message_metadata_passthrough.content_token_count").Int() != 42 || input[1].Get("internal_chat_message_metadata_passthrough.turn_id").String() != "client" {
+ t.Fatalf("transactional client output must not be sanitized: %s", repaired)
+ }
+}
+
+// TestResponsesWebsocketReplaySanitizedItemLeavesInvalidJSONUnchanged ensures
+// malformed upstream items are never truncated by the cleanup.
+func TestResponsesWebsocketReplaySanitizedItemLeavesInvalidJSONUnchanged(t *testing.T) {
+ invalidInputs := []string{
+ `{"internal_chat_message_metadata_passthrough":{"content_token_count":1},"content":`,
+ `{"internal_chat_message_metadata_passthrough":{"content_token_count":1},"content":"unterminated`,
+ `{"internal_chat_message_metadata_passthrough":{"content_token_count":1}} trailing`,
+ `{"internal_chat_message_metadata_passthrough":{"content_token_count":1},"content":}`,
+ }
+ for _, invalid := range invalidInputs {
+ cleaned := responsesWebsocketReplaySanitizedItem(invalid)
+ if cleaned != invalid {
+ t.Fatalf("invalid item mutated: in=%q out=%q", invalid, cleaned)
+ }
+ if cleanedBytes := responsesWebsocketReplaySanitizedBytes([]byte(invalid)); string(cleanedBytes) != invalid {
+ t.Fatalf("invalid bytes mutated: in=%q out=%q", invalid, cleanedBytes)
+ }
+ }
+}
+
+// TestResponsesWebsocketToolCacheStripsContentTokenCount covers the second replay
+// carrier of issue #7146: tool calls recorded from upstream payloads into the
+// repair cache must be sanitized before being re-inserted into a later request.
+func TestResponsesWebsocketToolCacheStripsContentTokenCount(t *testing.T) {
+ outputCache := newWebsocketToolOutputCache(time.Minute, 10)
+ callCache := newWebsocketToolOutputCache(time.Minute, 10)
+ sessionKey := "session-7146"
+
+ recordResponsesWebsocketToolCallsFromPayloadWithCache(callCache, sessionKey, []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"lookup","arguments":"{}","internal_chat_message_metadata_passthrough":{"turn_id":"turn-1","content_token_count":9}},{"type":"function_call","id":"fc-2","call_id":"call-2","name":"grep","arguments":"{}","internal_chat_message_metadata_passthrough":{"content_token_count":2}}]}}`))
+
+ raw := []byte(`{"input":[{"type":"function_call_output","call_id":"call-1","output":"ok"},{"type":"function_call_output","call_id":"call-2","output":"ok"}]}`)
+ repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
+
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
+ if len(input) != 4 {
+ t.Fatalf("repaired input len = %d, want 4: %s", len(input), repaired)
+ }
+ for index, item := range input {
+ if item.Get("internal_chat_message_metadata_passthrough.content_token_count").Exists() {
+ t.Fatalf("repaired input[%d] must not carry content_token_count: %s", index, repaired)
+ }
+ }
+ if injected := input[0]; injected.Get("type").String() != "function_call" ||
+ injected.Get("internal_chat_message_metadata_passthrough.turn_id").String() != "turn-1" ||
+ injected.Get("name").String() != "lookup" {
+ t.Fatalf("inserted cached call must keep other fields: %s", injected.Raw)
+ }
+ if injected := input[2]; injected.Get("type").String() != "function_call" ||
+ injected.Get("internal_chat_message_metadata_passthrough").Exists() {
+ t.Fatalf("passthrough object emptied by removal must be dropped from inserted call: %s", injected.Raw)
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_requests.go b/sdk/api/handlers/openai/openai_responses_websocket_requests.go
index 2504b1aa846..f958de90e78 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_requests.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_requests.go
@@ -8,11 +8,10 @@ import (
"slices"
"strings"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
func normalizeResponsesWebsocketRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
@@ -28,7 +27,7 @@ func normalizeResponsesWebsocketRequestWithLastResponseID(rawJSON []byte, lastRe
}
func normalizeResponsesWebsocketRequestWithIncrementalState(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, lastResponseID string, lastResponsePendingToolCallIDs []string, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
- requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
+ requestType := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "type").String())
switch requestType {
case wsRequestTypeCreate:
// log.Infof("responses websocket: response.create request")
@@ -47,8 +46,8 @@ func normalizeResponsesWebsocketRequestWithIncrementalState(rawJSON []byte, last
}
}
-func normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
- input := gjson.GetBytes(rawJSON, "input")
+func normalizeResponseCreateRequestDoc(doc *ojson.Doc) ([]byte, []byte, *interfaces.ErrorMessage) {
+ input := doc.Get("input")
if input.Exists() && !input.IsArray() {
return nil, nil, &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
@@ -56,25 +55,33 @@ func normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces
}
}
- normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
- if errDelete != nil {
- normalized = bytes.Clone(rawJSON)
- }
- normalized, _ = sjson.SetBytes(normalized, "stream", true)
- if !gjson.GetBytes(normalized, "input").Exists() {
- normalized, _ = sjson.SetRawBytes(normalized, "input", []byte("[]"))
+ _ = doc.Delete("type")
+ _ = doc.Set("stream", true)
+ if !input.Exists() {
+ _ = doc.SetRawBytes("input", []byte("[]"))
}
- modelName := strings.TrimSpace(gjson.GetBytes(normalized, "model").String())
+ modelName := strings.TrimSpace(doc.Get("model").String())
if modelName == "" {
return nil, nil, &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
Error: fmt.Errorf("missing model in response.create request"),
}
}
+ normalized := doc.Bytes()
return normalized, bytes.Clone(normalized), nil
}
+func normalizeResponseCreateRequest(rawJSON []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
+ return normalizeResponseCreateRequestDoc(ojson.NewDoc(rawJSON))
+}
+
+func normalizeResponseCreateTranscriptReplacement(rawJSON []byte, lastRequest []byte) ([]byte, []byte, *interfaces.ErrorMessage) {
+ doc := ojson.NewDoc(rawJSON)
+ normalizeResponseTranscriptReplacementDoc(doc, lastRequest)
+ return normalizeResponseCreateRequestDoc(doc)
+}
+
func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, lastResponseOutput []byte, lastResponseID string, lastResponsePendingToolCallIDs []string, allowIncrementalInputWithPreviousResponseID bool, allowCompactionReplayBypass bool) ([]byte, []byte, *interfaces.ErrorMessage) {
if len(lastRequest) == 0 {
return nil, lastRequest, &interfaces.ErrorMessage{
@@ -83,7 +90,7 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last
}
}
- nextInput := gjson.GetBytes(rawJSON, "input")
+ nextInput := ojson.GetBytesNoCopy(rawJSON, "input")
if !nextInput.Exists() || !nextInput.IsArray() {
return nil, lastRequest, &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
@@ -103,7 +110,7 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last
// Websocket v2 mode uses response.create with previous_response_id + incremental input.
// Do not expand it into a full input transcript; upstream expects the incremental payload.
if allowIncrementalInputWithPreviousResponseID {
- prev := strings.TrimSpace(gjson.GetBytes(rawJSON, "previous_response_id").String())
+ prev := strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "previous_response_id").String())
if prev == "" {
if !inputSatisfiesPendingToolCalls(nextInput, lastResponsePendingToolCallIDs) {
normalized := normalizeResponseTranscriptReplacement(rawJSON, lastRequest)
@@ -112,24 +119,23 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last
prev = strings.TrimSpace(lastResponseID)
}
if prev != "" {
- normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
- if errDelete != nil {
- normalized = bytes.Clone(rawJSON)
- }
- normalized, _ = sjson.SetBytes(normalized, "previous_response_id", prev)
- if !gjson.GetBytes(normalized, "model").Exists() {
- modelName := strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
+ doc := ojson.NewDoc(rawJSON)
+ _ = doc.Delete("type")
+ _ = doc.Set("previous_response_id", prev)
+ if !doc.Get("model").Exists() {
+ modelName := strings.TrimSpace(ojson.GetBytesNoCopy(lastRequest, "model").String())
if modelName != "" {
- normalized, _ = sjson.SetBytes(normalized, "model", modelName)
+ _ = doc.Set("model", modelName)
}
}
- if !gjson.GetBytes(normalized, "instructions").Exists() {
- instructions := gjson.GetBytes(lastRequest, "instructions")
+ if !doc.Get("instructions").Exists() {
+ instructions := ojson.GetBytesNoCopy(lastRequest, "instructions")
if instructions.Exists() {
- normalized, _ = sjson.SetRawBytes(normalized, "instructions", []byte(instructions.Raw))
+ _ = doc.SetRawBytes("instructions", []byte(instructions.Raw))
}
}
- normalized, _ = sjson.SetBytes(normalized, "stream", true)
+ _ = doc.Set("stream", true)
+ normalized := doc.Bytes()
return normalized, bytes.Clone(normalized), nil
}
}
@@ -159,41 +165,26 @@ func normalizeResponseSubsequentRequest(rawJSON []byte, lastRequest []byte, last
}
}
- normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
- if errDelete != nil {
- normalized = bytes.Clone(rawJSON)
- }
- normalized, _ = sjson.DeleteBytes(normalized, "previous_response_id")
- if !gjson.GetBytes(normalized, "model").Exists() {
- modelName := strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
- if modelName != "" {
- normalized, _ = sjson.SetBytes(normalized, "model", modelName)
- }
- }
- if !gjson.GetBytes(normalized, "instructions").Exists() {
- instructions := gjson.GetBytes(lastRequest, "instructions")
- if instructions.Exists() {
- normalized, _ = sjson.SetRawBytes(normalized, "instructions", []byte(instructions.Raw))
- }
- }
- normalized, _ = sjson.SetBytes(normalized, "stream", true)
- var errSet error
- normalized, errSet = sjson.SetRawBytes(normalized, "input", mergedInput)
- if errSet != nil {
+ doc := ojson.NewDoc(rawJSON)
+ normalizeResponseTranscriptReplacementDoc(doc, lastRequest)
+ if errSet := doc.SetRawBytes("input", mergedInput); errSet != nil {
return nil, lastRequest, &interfaces.ErrorMessage{
StatusCode: http.StatusBadRequest,
Error: fmt.Errorf("failed to merge websocket input: %w", errSet),
}
}
+ normalized := doc.Bytes()
return normalized, normalized, nil
}
-func shouldReplaceWebsocketTranscript(rawJSON []byte, nextInput gjson.Result) bool {
- requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
+func shouldReplaceWebsocketTranscript(rawJSON []byte, nextInput ojson.Result) bool {
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "type", "previous_response_id")
+ requestType := strings.TrimSpace(fields[0].String())
if requestType != wsRequestTypeCreate && requestType != wsRequestTypeAppend {
return false
}
- previousResponseID := gjson.GetBytes(rawJSON, "previous_response_id")
+ previousResponseID := fields[1]
if strings.TrimSpace(previousResponseID.String()) != "" {
return false
}
@@ -218,7 +209,7 @@ func shouldReplaceWebsocketTranscript(rawJSON []byte, nextInput gjson.Result) bo
return false
}
-func inputHasCodexLocalCompactionSummary(input gjson.Result) bool {
+func inputHasCodexLocalCompactionSummary(input ojson.Result) bool {
if !input.IsArray() {
return false
}
@@ -253,9 +244,9 @@ func inputHasCodexLocalCompactionSummary(input gjson.Result) bool {
return hasSummary
}
-func codexLocalCompactionMessageText(message gjson.Result) string {
+func codexLocalCompactionMessageText(message ojson.Result) string {
content := message.Get("content")
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String()
}
if !content.IsArray() {
@@ -271,7 +262,7 @@ func codexLocalCompactionMessageText(message gjson.Result) string {
return text.String()
}
-func inputSatisfiesPendingToolCalls(input gjson.Result, pendingCallIDs []string) bool {
+func inputSatisfiesPendingToolCalls(input ojson.Result, pendingCallIDs []string) bool {
if len(pendingCallIDs) == 0 {
return true
}
@@ -300,26 +291,28 @@ func inputSatisfiesPendingToolCalls(input gjson.Result, pendingCallIDs []string)
return true
}
-func normalizeResponseTranscriptReplacement(rawJSON []byte, lastRequest []byte) []byte {
- normalized, errDelete := sjson.DeleteBytes(rawJSON, "type")
- if errDelete != nil {
- normalized = bytes.Clone(rawJSON)
- }
- normalized, _ = sjson.DeleteBytes(normalized, "previous_response_id")
- if !gjson.GetBytes(normalized, "model").Exists() {
- modelName := strings.TrimSpace(gjson.GetBytes(lastRequest, "model").String())
+func normalizeResponseTranscriptReplacementDoc(doc *ojson.Doc, lastRequest []byte) {
+ _ = doc.Delete("type")
+ _ = doc.Delete("previous_response_id")
+ if !doc.Get("model").Exists() {
+ modelName := strings.TrimSpace(ojson.GetBytesNoCopy(lastRequest, "model").String())
if modelName != "" {
- normalized, _ = sjson.SetBytes(normalized, "model", modelName)
+ _ = doc.Set("model", modelName)
}
}
- if !gjson.GetBytes(normalized, "instructions").Exists() {
- instructions := gjson.GetBytes(lastRequest, "instructions")
+ if !doc.Get("instructions").Exists() {
+ instructions := ojson.GetBytesNoCopy(lastRequest, "instructions")
if instructions.Exists() {
- normalized, _ = sjson.SetRawBytes(normalized, "instructions", []byte(instructions.Raw))
+ _ = doc.SetRawBytes("instructions", []byte(instructions.Raw))
}
}
- normalized, _ = sjson.SetBytes(normalized, "stream", true)
- return bytes.Clone(normalized)
+ _ = doc.Set("stream", true)
+}
+
+func normalizeResponseTranscriptReplacement(rawJSON []byte, lastRequest []byte) []byte {
+ doc := ojson.NewDoc(rawJSON)
+ normalizeResponseTranscriptReplacementDoc(doc, lastRequest)
+ return doc.Bytes()
}
type responsesWebsocketInputItem struct {
@@ -339,6 +332,145 @@ type responsesWebsocketMergeInputItem struct {
callID string
}
+// responsesWebsocketReplayPassthroughPath is the passthrough object that can
+// carry output-only upstream fields on response items.
+const responsesWebsocketReplayPassthroughPath = "internal_chat_message_metadata_passthrough"
+
+// responsesWebsocketReplaySanitizedItem removes every output-only
+// content_token_count member from a valid response item that CPA replays as
+// input, along with every passthrough object left empty. Parent and child
+// members are matched by their decoded key names, including duplicates and
+// escaped spellings. Retained members are rebuilt from raw field slices
+// without reserializing values; invalid items return unchanged.
+func responsesWebsocketReplaySanitizedItem(raw string) string {
+ if !responsesWebsocketReplayMayContainPassthroughKey(raw) || !json.Valid([]byte(raw)) {
+ return raw
+ }
+ item := ojson.Parse(raw)
+ if !item.IsObject() || item.Raw != raw {
+ return raw
+ }
+ cleaned, changed := responsesWebsocketSanitizeReplayResult(item)
+ if !changed {
+ return raw
+ }
+ return string(cleaned)
+}
+
+// responsesWebsocketReplaySanitizedBytes is the []byte form used by upstream
+// cache recording entries. Unchanged items return the caller's slice so the
+// storage layer still performs its single detached ownership copy.
+func responsesWebsocketReplaySanitizedBytes(raw []byte) []byte {
+ if len(raw) == 0 || !json.Valid(raw) {
+ return raw
+ }
+ item := util.ParseGJSONBytesNoCopy(raw)
+ if !item.IsObject() || len(item.Raw) != len(raw) || !responsesWebsocketReplayMayContainPassthroughKey(item.Raw) {
+ return raw
+ }
+ cleaned, changed := responsesWebsocketSanitizeReplayResult(item)
+ if !changed {
+ return raw
+ }
+ return cleaned
+}
+
+// responsesWebsocketReplayMayContainPassthroughKey is a conservative,
+// copy-free screen. A decoded passthrough key contains an underscore and the
+// fragments on either side of one underscore; Unicode escapes therefore
+// require the universal \\u prefix, while literal spellings contain either
+// "internal" or "chat_message_metadata_passthrough".
+func responsesWebsocketReplayMayContainPassthroughKey(raw string) bool {
+ return strings.Contains(raw, "internal") ||
+ strings.Contains(raw, "chat_message_metadata_passthrough") ||
+ strings.Contains(raw, `\u`)
+}
+
+func responsesWebsocketSanitizeReplayResult(item ojson.Result) ([]byte, bool) {
+ memberCount := len(item.Map())
+ if memberCount == 0 {
+ return nil, false
+ }
+ members := make([]struct{ key, value string }, 0, memberCount)
+ changed := false
+ invalid := false
+ item.ForEach(func(key, value ojson.Result) bool {
+ if key.Raw == "" || value.Raw == "" {
+ invalid = true
+ return false
+ }
+ valueRaw := value.Raw
+ if key.String() == responsesWebsocketReplayPassthroughPath && value.IsObject() {
+ cleaned, valueChanged, keep := responsesWebsocketSanitizePassthroughObject(value)
+ if valueChanged {
+ changed = true
+ if !keep {
+ return true
+ }
+ valueRaw = cleaned
+ }
+ }
+ members = append(members, struct{ key, value string }{key: key.Raw, value: valueRaw})
+ return true
+ })
+ if invalid || !changed {
+ return nil, false
+ }
+
+ out := make([]byte, 0, len(item.Raw))
+ out = append(out, '{')
+ for index, member := range members {
+ if index > 0 {
+ out = append(out, ',')
+ }
+ out = append(out, member.key...)
+ out = append(out, ':')
+ out = append(out, member.value...)
+ }
+ out = append(out, '}')
+ return out, true
+}
+
+// responsesWebsocketSanitizePassthroughObject drops every decoded
+// content_token_count member from a passthrough object. Members are collected
+// as raw slices and materialized only after a removal is confirmed.
+func responsesWebsocketSanitizePassthroughObject(passthrough ojson.Result) (string, bool, bool) {
+ members := make([]struct{ key, value string }, 0, len(passthrough.Map()))
+ removed := false
+ invalid := false
+ passthrough.ForEach(func(key, value ojson.Result) bool {
+ if key.String() == "content_token_count" {
+ removed = true
+ return true
+ }
+ if key.Raw == "" || value.Raw == "" {
+ invalid = true
+ return false
+ }
+ members = append(members, struct{ key, value string }{key: key.Raw, value: value.Raw})
+ return true
+ })
+ if invalid || !removed {
+ return "", false, len(passthrough.Map()) > 0
+ }
+ if len(members) == 0 {
+ return "{}", true, false
+ }
+
+ out := make([]byte, 0, len(passthrough.Raw))
+ out = append(out, '{')
+ for index, member := range members {
+ if index > 0 {
+ out = append(out, ',')
+ }
+ out = append(out, member.key...)
+ out = append(out, ':')
+ out = append(out, member.value...)
+ }
+ out = append(out, '}')
+ return string(out), true, true
+}
+
func mergeResponsesWebsocketInput(lastRequest []byte, lastResponseOutput []byte, appendRaw string) ([]byte, error) {
previousInput, errPrevious := responsesWebsocketPreviousInputNoCopy(lastRequest)
if errPrevious != nil {
@@ -357,11 +489,18 @@ func mergeResponsesWebsocketInput(lastRequest []byte, lastResponseOutput []byte,
return item.itemType == "compaction_trigger"
})
}
+ itemCountBeforeResponse := len(items)
var errResponse error
items, errResponse = appendResponsesWebsocketMergeInputResult(items, responseInput)
if errResponse != nil {
return nil, fmt.Errorf("invalid previous response output: %w", errResponse)
}
+ // Replayed response items become upstream input. Drop output-only
+ // fields the client never sends so another credential cannot reject
+ // them; previous-request and appended client items stay untouched.
+ for index := itemCountBeforeResponse; index < len(items); index++ {
+ items[index].raw = responsesWebsocketReplaySanitizedItem(items[index].raw)
+ }
}
items, errAppend := appendResponsesWebsocketMergeInputItems(items, appendRaw)
@@ -374,23 +513,23 @@ func mergeResponsesWebsocketInput(lastRequest []byte, lastResponseOutput []byte,
return marshalResponsesWebsocketMergeInputItems(items), nil
}
-func responsesWebsocketPreviousInputNoCopy(lastRequest []byte) (gjson.Result, error) {
+func responsesWebsocketPreviousInputNoCopy(lastRequest []byte) (ojson.Result, error) {
if !json.Valid(lastRequest) {
- return gjson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
+ return ojson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
}
root := util.ParseGJSONBytesNoCopy(lastRequest)
- if root.Type == gjson.Null {
- return gjson.Parse("[]"), nil
+ if root.Type == ojson.Null {
+ return ojson.Parse("[]"), nil
}
if !root.IsObject() {
- return gjson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
+ return ojson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
}
- var input gjson.Result
+ var input ojson.Result
inputFound := false
invalidInput := false
- root.ForEach(func(key, value gjson.Result) bool {
+ root.ForEach(func(key, value ojson.Result) bool {
if !strings.EqualFold(key.String(), "input") {
return true
}
@@ -400,16 +539,16 @@ func responsesWebsocketPreviousInputNoCopy(lastRequest []byte) (gjson.Result, er
// selected array out of the caller-owned request buffer.
inputFound = true
input = value
- if value.Type != gjson.Null && !value.IsArray() {
+ if value.Type != ojson.Null && !value.IsArray() {
invalidInput = true
}
return true
})
if invalidInput {
- return gjson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
+ return ojson.Result{}, responsesWebsocketPreviousInputDecodeError(lastRequest)
}
- if !inputFound || input.Type == gjson.Null {
- return gjson.Parse("[]"), nil
+ if !inputFound || input.Type == ojson.Null {
+ return ojson.Parse("[]"), nil
}
return input, nil
}
@@ -426,8 +565,8 @@ func appendResponsesWebsocketMergeInputItems(items []responsesWebsocketMergeInpu
if rawArray == "" {
rawArray = "[]"
}
- parsed := gjson.Parse(rawArray)
- if gjson.Valid(rawArray) {
+ parsed := ojson.Parse(rawArray)
+ if ojson.Valid(rawArray) {
return appendResponsesWebsocketMergeInputResult(items, parsed)
}
@@ -438,8 +577,8 @@ func appendResponsesWebsocketMergeInputItems(items []responsesWebsocketMergeInpu
return items, nil
}
-func appendResponsesWebsocketMergeInputResult(items []responsesWebsocketMergeInputItem, input gjson.Result) ([]responsesWebsocketMergeInputItem, error) {
- if input.Type == gjson.Null {
+func appendResponsesWebsocketMergeInputResult(items []responsesWebsocketMergeInputItem, input ojson.Result) ([]responsesWebsocketMergeInputItem, error) {
+ if input.Type == ojson.Null {
return items, nil
}
if !input.IsArray() {
@@ -455,7 +594,7 @@ func appendResponsesWebsocketMergeInputResult(items []responsesWebsocketMergeInp
for _, rawItem := range rawItems {
item := responsesWebsocketMergeInputItem{raw: rawItem.Raw}
if rawItem.IsObject() {
- rawItem.ForEach(func(key, value gjson.Result) bool {
+ rawItem.ForEach(func(key, value ojson.Result) bool {
metadataKey := key.String()
switch {
case strings.EqualFold(metadataKey, "type"):
@@ -688,7 +827,7 @@ func dedupeResponsesWebsocketInputItems(items []responsesWebsocketInputItem) []r
}
func dedupeResponsesWebsocketInputItemsByID(payload []byte) []byte {
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if !input.Exists() || !input.IsArray() {
return payload
}
@@ -696,7 +835,7 @@ func dedupeResponsesWebsocketInputItemsByID(payload []byte) []byte {
if errDedupe != nil || dedupedInput == input.Raw {
return payload
}
- updated, errSet := sjson.SetRawBytes(payload, "input", []byte(dedupedInput))
+ updated, errSet := ojson.SetRawBytes(payload, "input", []byte(dedupedInput))
if errSet != nil {
return payload
}
@@ -719,7 +858,9 @@ func normalizeResponsesWebsocketPassthroughRequest(rawJSON []byte, modelName str
}
}
- requestType := strings.TrimSpace(gjson.GetBytes(rawJSON, "type").String())
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "type", "model")
+ requestType := strings.TrimSpace(fields[0].String())
switch requestType {
case wsRequestTypeCreate, wsRequestTypeAppend:
default:
@@ -729,8 +870,8 @@ func normalizeResponsesWebsocketPassthroughRequest(rawJSON []byte, modelName str
}
}
- normalized := bytes.Clone(rawJSON)
- if strings.TrimSpace(gjson.GetBytes(normalized, "model").String()) == "" {
+ doc := ojson.NewDoc(rawJSON)
+ if strings.TrimSpace(fields[1].String()) == "" {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return nil, &interfaces.ErrorMessage{
@@ -738,8 +879,8 @@ func normalizeResponsesWebsocketPassthroughRequest(rawJSON []byte, modelName str
Error: fmt.Errorf("missing model in response.create request"),
}
}
- normalized, _ = sjson.SetBytes(normalized, "model", modelName)
+ _ = doc.Set("model", modelName)
}
- normalized, _ = sjson.SetBytes(normalized, "stream", true)
- return normalized, nil
+ _ = doc.Set("stream", true)
+ return doc.Bytes(), nil
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_session.go b/sdk/api/handlers/openai/openai_responses_websocket_session.go
index 9fde5f8d7ed..dbb7d475157 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_session.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_session.go
@@ -1,6 +1,7 @@
package openai
import (
+ "context"
"fmt"
"strconv"
"strings"
@@ -87,6 +88,13 @@ func (h *OpenAIResponsesAPIHandler) responsesWebsocketAvailableAuthsForModel(mod
return available, modelKey
}
+func (h *OpenAIResponsesAPIHandler) responsesWebsocketHasAlternativeReplayTarget(ctx context.Context, modelName string, rawJSON []byte, failedAuthID string) bool {
+ if h == nil || h.BaseAPIHandler == nil || h.AuthManager == nil {
+ return false
+ }
+ return h.HasAlternativeExecutionTarget(ctx, h.HandlerType(), modelName, rawJSON, failedAuthID)
+}
+
func (h *OpenAIResponsesAPIHandler) responsesWebsocketUsesCodexWebsocketPassthrough(modelName string) bool {
return h.responsesWebsocketUsesUpstreamWebsocketPassthrough(modelName)
}
@@ -208,32 +216,5 @@ func responsesWebsocketAuthSupportsCompactionReplay(auth *coreauth.Auth) bool {
}
func responsesWebsocketAuthAvailableForModel(auth *coreauth.Auth, modelName string, now time.Time) bool {
- if auth == nil {
- return false
- }
- if auth.Disabled || auth.Status == coreauth.StatusDisabled {
- return false
- }
- if modelName != "" && len(auth.ModelStates) > 0 {
- state, ok := auth.ModelStates[modelName]
- if (!ok || state == nil) && modelName != "" {
- baseModel := strings.TrimSpace(thinking.ParseSuffix(modelName).ModelName)
- if baseModel != "" && baseModel != modelName {
- state, ok = auth.ModelStates[baseModel]
- }
- }
- if ok && state != nil {
- if state.Status == coreauth.StatusDisabled {
- return false
- }
- if state.Unavailable && !state.NextRetryAfter.IsZero() && state.NextRetryAfter.After(now) {
- return false
- }
- return true
- }
- }
- if auth.Unavailable && !auth.NextRetryAfter.IsZero() && auth.NextRetryAfter.After(now) {
- return false
- }
- return true
+ return coreauth.IsAuthAvailableForModel(auth, modelName, now)
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_test.go b/sdk/api/handlers/openai/openai_responses_websocket_test.go
index 02bf123dfc9..ce0cfaca0d6 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_test.go
@@ -19,6 +19,7 @@ import (
"time"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -32,7 +33,6 @@ import (
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/pluginapi"
- "github.com/tidwall/gjson"
)
type homeResponsesWebsocketDispatcher struct {
@@ -148,7 +148,7 @@ func TestResponsesWebsocketHomeSelectedAuthCallbackPinsAndReusesFirstSelection(t
if errRead != nil {
t.Fatalf("read websocket response %d: %v", index+1, errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("response %d type = %q, want %q: %s", index+1, got, wsEventTypeCompleted, payload)
}
if index == 0 {
@@ -640,7 +640,7 @@ type websocketProviderRouteHost struct{}
func (*websocketProviderRouteHost) HasModelRouters() bool { return true }
func (*websocketProviderRouteHost) RouteModel(_ context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
- if !gjson.GetBytes(req.Body, "route_to_claude").Bool() {
+ if !ojson.GetBytesNoCopy(req.Body, "route_to_claude").Bool() {
return pluginapi.ModelRouteResponse{}, false
}
return pluginapi.ModelRouteResponse{
@@ -1223,14 +1223,14 @@ func TestNormalizeResponsesWebsocketRequestCreate(t *testing.T) {
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "type").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "type").Exists() {
t.Fatalf("normalized create request must not include type field")
}
- if !gjson.GetBytes(normalized, "stream").Bool() {
+ if !ojson.GetBytesNoCopy(normalized, "stream").Bool() {
t.Fatalf("normalized create request must force stream=true")
}
- if gjson.GetBytes(normalized, "model").String() != "test-model" {
- t.Fatalf("unexpected model: %s", gjson.GetBytes(normalized, "model").String())
+ if ojson.GetBytesNoCopy(normalized, "model").String() != "test-model" {
+ t.Fatalf("unexpected model: %s", ojson.GetBytesNoCopy(normalized, "model").String())
}
if !bytes.Equal(last, normalized) {
t.Fatalf("last request snapshot should match normalized request")
@@ -1434,7 +1434,7 @@ func TestResponsesWebsocketToolCacheScanPreservesJSONRequestSemantics(t *testing
t.Run("uses last duplicate previous response id", func(t *testing.T) {
payload := []byte(`{"previous_response_id":"resp-first","previous_response_id":null,"input":[{"type":"function_call_output","id":"fco-1","call_id":"missing-call","output":"orphan"}]}`)
repaired, _ := prepareResponsesWebsocketFallbackTurn("duplicate-previous-response-session", payload)
- if inputCount := gjson.GetBytes(repaired, "input.#").Int(); inputCount != 0 {
+ if inputCount := ojson.GetBytesNoCopy(repaired, "input.#").Int(); inputCount != 0 {
t.Fatalf("repaired input count = %d, want 0 when the last previous_response_id is null", inputCount)
}
})
@@ -1452,14 +1452,14 @@ func TestNormalizeResponsesWebsocketRequestCreateWithHistory(t *testing.T) {
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "type").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "type").Exists() {
t.Fatalf("normalized subsequent create request must not include type field")
}
- if gjson.GetBytes(normalized, "model").String() != "test-model" {
- t.Fatalf("unexpected model: %s", gjson.GetBytes(normalized, "model").String())
+ if ojson.GetBytesNoCopy(normalized, "model").String() != "test-model" {
+ t.Fatalf("unexpected model: %s", ojson.GetBytesNoCopy(normalized, "model").String())
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 4 {
t.Fatalf("merged input len = %d, want 4", len(input))
}
@@ -1486,24 +1486,24 @@ func TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDIncremental(t *
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "type").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "type").Exists() {
t.Fatalf("normalized request must not include type field")
}
- if gjson.GetBytes(normalized, "previous_response_id").String() != "resp-1" {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").String() != "resp-1" {
t.Fatalf("previous_response_id must be preserved in incremental mode")
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 1 {
t.Fatalf("incremental input len = %d, want 1", len(input))
}
if input[0].Get("id").String() != "tool-out-1" {
t.Fatalf("unexpected incremental input item id: %s", input[0].Get("id").String())
}
- if gjson.GetBytes(normalized, "model").String() != "test-model" {
- t.Fatalf("unexpected model: %s", gjson.GetBytes(normalized, "model").String())
+ if ojson.GetBytesNoCopy(normalized, "model").String() != "test-model" {
+ t.Fatalf("unexpected model: %s", ojson.GetBytesNoCopy(normalized, "model").String())
}
- if gjson.GetBytes(normalized, "instructions").String() != "be helpful" {
- t.Fatalf("unexpected instructions: %s", gjson.GetBytes(normalized, "instructions").String())
+ if ojson.GetBytesNoCopy(normalized, "instructions").String() != "be helpful" {
+ t.Fatalf("unexpected instructions: %s", ojson.GetBytesNoCopy(normalized, "instructions").String())
}
if !bytes.Equal(next, normalized) {
t.Fatalf("next request snapshot should match normalized request")
@@ -1522,21 +1522,21 @@ func TestNormalizeResponsesWebsocketRequestInjectsPreviousResponseIDForIncrement
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if got := gjson.GetBytes(normalized, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytesNoCopy(normalized, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("previous_response_id = %q, want resp-1", got)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 1 {
t.Fatalf("incremental input len = %d, want 1: %s", len(input), normalized)
}
if input[0].Get("id").String() != "tool-out-1" {
t.Fatalf("unexpected incremental input item id: %s", input[0].Get("id").String())
}
- if gjson.GetBytes(normalized, "model").String() != "test-model" {
- t.Fatalf("unexpected model: %s", gjson.GetBytes(normalized, "model").String())
+ if ojson.GetBytesNoCopy(normalized, "model").String() != "test-model" {
+ t.Fatalf("unexpected model: %s", ojson.GetBytesNoCopy(normalized, "model").String())
}
- if gjson.GetBytes(normalized, "instructions").String() != "be helpful" {
- t.Fatalf("unexpected instructions: %s", gjson.GetBytes(normalized, "instructions").String())
+ if ojson.GetBytesNoCopy(normalized, "instructions").String() != "be helpful" {
+ t.Fatalf("unexpected instructions: %s", ojson.GetBytesNoCopy(normalized, "instructions").String())
}
if !bytes.Equal(next, normalized) {
t.Fatalf("next request snapshot should match normalized request")
@@ -1552,10 +1552,10 @@ func TestNormalizeResponsesWebsocketRequestInjectsPreviousResponseIDWhenPendingO
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if got := gjson.GetBytes(normalized, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytesNoCopy(normalized, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("previous_response_id = %q, want resp-1", got)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 1 || input[0].Get("id").String() != "tool-out-1" {
t.Fatalf("unexpected incremental input: %s", normalized)
}
@@ -1572,10 +1572,10 @@ func TestNormalizeResponsesWebsocketRequestSkipsPreviousResponseIDWhenPendingOut
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must not be injected when pending tool output is missing: %s", normalized)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 1 {
t.Fatalf("replacement input len = %d, want 1: %s", len(input), normalized)
}
@@ -1609,13 +1609,13 @@ func TestNormalizeResponsesWebsocketRequestReplacesCodexLocalCompactionTranscrip
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").Exists() {
t.Fatalf("replacement request must not include previous_response_id: %s", normalized)
}
- if got, want := gjson.GetBytes(normalized, "input").Raw, gjson.GetBytes(raw, "input").Raw; got != want {
+ if got, want := ojson.GetBytesNoCopy(normalized, "input").Raw, ojson.GetBytesNoCopy(raw, "input").Raw; got != want {
t.Fatalf("replacement input did not preserve the complete new transcript:\n got: %s\nwant: %s", got, want)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
wantIDs := []string{"", "initial-context", "compacted-user", "local-summary", "turn-context", "incoming-user"}
if len(input) != len(wantIDs) {
t.Fatalf("replacement input len = %d, want %d: %s", len(input), len(wantIDs), normalized)
@@ -1639,19 +1639,19 @@ func TestNormalizeResponsesWebsocketRequestReplacesCodexLocalCompactionTranscrip
t.Fatalf("replacement input contains stale item %q: %s", staleID, normalized)
}
}
- if got := gjson.GetBytes(normalized, "model").String(); got != "gpt-5.6-sol" {
+ if got := ojson.GetBytesNoCopy(normalized, "model").String(); got != "gpt-5.6-sol" {
t.Fatalf("model = %q, want gpt-5.6-sol", got)
}
- if got := gjson.GetBytes(normalized, "instructions").String(); got != "be helpful" {
+ if got := ojson.GetBytesNoCopy(normalized, "instructions").String(); got != "be helpful" {
t.Fatalf("instructions = %q, want be helpful", got)
}
- if !gjson.GetBytes(normalized, "stream").Bool() {
+ if !ojson.GetBytesNoCopy(normalized, "stream").Bool() {
t.Fatalf("stream must be enabled: %s", normalized)
}
- if !gjson.GetBytes(normalized, "parallel_tool_calls").Bool() {
+ if !ojson.GetBytesNoCopy(normalized, "parallel_tool_calls").Bool() {
t.Fatalf("parallel_tool_calls was not preserved: %s", normalized)
}
- if got := gjson.GetBytes(normalized, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite").String(); got != "true" {
+ if got := ojson.GetBytesNoCopy(normalized, "client_metadata.ws_request_header_x_openai_internal_codex_responses_lite").String(); got != "true" {
t.Fatalf("Responses Lite client metadata = %q, want true: %s", got, normalized)
}
if !bytes.Equal(next, normalized) {
@@ -1660,7 +1660,7 @@ func TestNormalizeResponsesWebsocketRequestReplacesCodexLocalCompactionTranscrip
}
func TestShouldReplaceWebsocketTranscriptCodexLocalCompactionSemantics(t *testing.T) {
- compactedInput := gjson.Parse(fmt.Sprintf(`[
+ compactedInput := ojson.Parse(fmt.Sprintf(`[
{"type":"message","role":"developer","content":[{"type":"input_text","text":"initial context"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"retained context"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":%q}]}
@@ -1681,7 +1681,7 @@ func TestShouldReplaceWebsocketTranscriptCodexLocalCompactionSemantics(t *testin
t.Fatal("response.append must not be treated as a full local compaction reset")
}
- ordinaryInput := gjson.Parse(`[
+ ordinaryInput := ojson.Parse(`[
{"type":"message","role":"developer","content":"Please summarize future messages."},
{"type":"message","role":"user","content":[{"type":"input_text","text":"Please create a compacted summary of this text."}]}
]`)
@@ -1710,7 +1710,7 @@ func TestCodexLocalCompactionSummaryContentShapes(t *testing.T) {
if test.name == "developer summary" {
role = "developer"
}
- input := gjson.Parse(fmt.Sprintf(`[{"type":"message","role":%q,"content":%s}]`, role, test.content))
+ input := ojson.Parse(fmt.Sprintf(`[{"type":"message","role":%q,"content":%s}]`, role, test.content))
if got := inputHasCodexLocalCompactionSummary(input); got != test.want {
t.Fatalf("inputHasCodexLocalCompactionSummary() = %t, want %t", got, test.want)
}
@@ -1736,7 +1736,7 @@ func TestCodexLocalCompactionSummaryAdditionalToolsConstraints(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- if got := inputHasCodexLocalCompactionSummary(gjson.Parse(test.input)); got != test.want {
+ if got := inputHasCodexLocalCompactionSummary(ojson.Parse(test.input)); got != test.want {
t.Fatalf("inputHasCodexLocalCompactionSummary() = %t, want %t", got, test.want)
}
})
@@ -1758,7 +1758,7 @@ func TestCodexLocalCompactionSummaryRejectsOrdinaryHistoryItems(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- input := gjson.Parse(fmt.Sprintf(`[%s,{"type":"message","role":"user","content":[{"type":"input_text","text":%q}]}]`, test.historyItem, codexLocalCompactionSummaryPrefix+"\nSummary body."))
+ input := ojson.Parse(fmt.Sprintf(`[%s,{"type":"message","role":"user","content":[{"type":"input_text","text":%q}]}]`, test.historyItem, codexLocalCompactionSummaryPrefix+"\nSummary body."))
if inputHasCodexLocalCompactionSummary(input) {
t.Fatal("ordinary transcript history must not match the local user-summary shape")
}
@@ -1781,10 +1781,10 @@ func TestNormalizeResponsesWebsocketRequestWithPreviousResponseIDMergedWhenIncre
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must be removed when incremental mode is disabled")
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 4 {
t.Fatalf("merged input len = %d, want 4", len(input))
}
@@ -1811,7 +1811,7 @@ func TestNormalizeResponsesWebsocketRequestAppend(t *testing.T) {
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 5 {
t.Fatalf("merged input len = %d, want 5", len(input))
}
@@ -1846,8 +1846,8 @@ func TestWebsocketJSONPayloadsFromChunk(t *testing.T) {
if len(payloads) != 1 {
t.Fatalf("payloads len = %d, want 1", len(payloads))
}
- if gjson.GetBytes(payloads[0], "type").String() != "response.created" {
- t.Fatalf("unexpected payload type: %s", gjson.GetBytes(payloads[0], "type").String())
+ if ojson.GetBytesNoCopy(payloads[0], "type").String() != "response.created" {
+ t.Fatalf("unexpected payload type: %s", ojson.GetBytesNoCopy(payloads[0], "type").String())
}
}
@@ -1858,8 +1858,8 @@ func TestWebsocketJSONPayloadsFromPlainJSONChunk(t *testing.T) {
if len(payloads) != 1 {
t.Fatalf("payloads len = %d, want 1", len(payloads))
}
- if gjson.GetBytes(payloads[0], "type").String() != "response.completed" {
- t.Fatalf("unexpected payload type: %s", gjson.GetBytes(payloads[0], "type").String())
+ if ojson.GetBytesNoCopy(payloads[0], "type").String() != "response.completed" {
+ t.Fatalf("unexpected payload type: %s", ojson.GetBytesNoCopy(payloads[0], "type").String())
}
}
@@ -1867,7 +1867,7 @@ func TestResponseCompletedOutputFromPayload(t *testing.T) {
payload := []byte(`{"type":"response.completed","response":{"id":"resp-1","output":[{"type":"message","id":"out-1"}]}}`)
output := responseCompletedOutputFromPayload(payload, nil, nil)
- items := gjson.ParseBytes(output).Array()
+ items := ojson.GetBytesNoCopy(output, "@this").Array()
if len(items) != 1 {
t.Fatalf("output len = %d, want 1", len(items))
}
@@ -1885,7 +1885,7 @@ func TestResponseCompletedOutputFromPayloadDropsIncompleteCollectedToolCalls(t *
}
output := responseCompletedOutputFromPayload(payload, collector, nil)
- items := gjson.ParseBytes(output).Array()
+ items := ojson.GetBytesNoCopy(output, "@this").Array()
if len(items) != 2 {
t.Fatalf("output len = %d, want 2: %s", len(items), output)
}
@@ -1912,7 +1912,7 @@ func TestRestoreResponsesWebsocketCompletionOutputReconcilesConflictingToolCall(
collector := map[int64][]byte{0: []byte(`{"type":"custom_tool_call","id":"ctc-1","call_id":"call-1","name":"exec","input":"pwd","status":"completed"}`)}
restored := restoreResponsesWebsocketCompletionOutput(payload, collector, nil)
- output := gjson.GetBytes(restored, "response.output").Array()
+ output := ojson.GetBytesNoCopy(restored, "response.output").Array()
if len(output) != 2 {
t.Fatalf("restored output len = %d, want 2: %s", len(output), restored)
}
@@ -1922,13 +1922,13 @@ func TestRestoreResponsesWebsocketCompletionOutputReconcilesConflictingToolCall(
if output[1].Get("type").String() != "custom_tool_call" || output[1].Get("call_id").String() != "call-1" {
t.Fatalf("conflicting tool call was not reconciled: %s", output[1].Raw)
}
- if input := output[1].Get("input"); input.Type != gjson.String || input.String() != "pwd" {
+ if input := output[1].Get("input"); input.Type != ojson.String || input.String() != "pwd" {
t.Fatalf("reconciled custom tool input = %s, want string pwd", input.Raw)
}
lastRequest := []byte(`{"model":"gpt-test","stream":true,"input":[{"type":"message","id":"user-1","role":"user","content":"run pwd"}]}`)
nextRequest := []byte(`{"type":"response.create","previous_response_id":"resp-1","input":[{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"}]}`)
- completedOutput := []byte(gjson.GetBytes(restored, "response.output").Raw)
+ completedOutput := []byte(ojson.GetBytesNoCopy(restored, "response.output").Raw)
normalized, _, errMsg := normalizeResponsesWebsocketRequestWithIncrementalState(
nextRequest,
lastRequest,
@@ -1941,10 +1941,10 @@ func TestRestoreResponsesWebsocketCompletionOutputReconcilesConflictingToolCall(
if errMsg != nil {
t.Fatalf("normalize next request: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must not be forwarded to HTTP/SSE upstream: %s", normalized)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 4 {
t.Fatalf("replayed input len = %d, want 4: %s", len(input), normalized)
}
@@ -1963,7 +1963,7 @@ func TestRestoreResponsesWebsocketCompletionOutputReconcilesConflictingToolCall(
if !ok {
t.Fatalf("reconciled custom tool call was not cached")
}
- if gjson.GetBytes(cached, "type").String() != "custom_tool_call" || gjson.GetBytes(cached, "input").String() != "pwd" {
+ if ojson.GetBytesNoCopy(cached, "type").String() != "custom_tool_call" || ojson.GetBytesNoCopy(cached, "input").String() != "pwd" {
t.Fatalf("cached tool call is invalid: %s", cached)
}
}
@@ -1994,7 +1994,7 @@ func TestIsCompleteResponsesWebsocketToolCallRequiresStringFields(t *testing.T)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- if got := isCompleteResponsesWebsocketToolCall(gjson.Parse(tt.item)); got != tt.want {
+ if got := isCompleteResponsesWebsocketToolCall(ojson.Parse(tt.item)); got != tt.want {
t.Fatalf("isCompleteResponsesWebsocketToolCall() = %t, want %t", got, tt.want)
}
})
@@ -2092,14 +2092,14 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedOutput(t *testing.T) {
cacheWarm := []byte(`{"previous_response_id":"resp-1","input":[{"type":"function_call_output","call_id":"call-1","output":"ok"}]}`)
warmed := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, cacheWarm)
- if gjson.GetBytes(warmed, "input.0.call_id").String() != "call-1" {
+ if ojson.GetBytesNoCopy(warmed, "input.0.call_id").String() != "call-1" {
t.Fatalf("expected warmup output to remain")
}
raw := []byte(`{"input":[{"type":"function_call","call_id":"call-1","name":"tool"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 3 {
t.Fatalf("repaired input len = %d, want 3", len(input))
}
@@ -2122,7 +2122,7 @@ func TestRepairResponsesWebsocketToolCallsDeduplicatesInputItemsByID(t *testing.
t.Run(fmt.Sprintf("session_key_%q", sessionKey), func(t *testing.T) {
repaired := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, raw)
- items := gjson.GetBytes(repaired, "input").Array()
+ items := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(items) != 1 {
t.Fatalf("repaired input len = %d, want 1: %s", len(items), repaired)
}
@@ -2174,13 +2174,13 @@ func TestResponsesWebsocketToolCacheTurnCommitsOnlyOnSuccess(t *testing.T) {
_, turn := prepareResponsesWebsocketFallbackTurn(sessionKey, []byte(`{"input":[{"type":"function_call_output","id":"fco-1","call_id":"call-1","output":"cached result"}]}`))
beforeCommit := repairResponsesWebsocketToolCallsWithoutRecording(sessionKey, []byte(`{"input":[{"type":"function_call","id":"fc-next","call_id":"call-1","name":"lookup","arguments":"{}"}]}`))
- if gjson.GetBytes(beforeCommit, "input.#").Int() != 0 {
+ if ojson.GetBytesNoCopy(beforeCommit, "input.#").Int() != 0 {
t.Fatalf("uncommitted turn populated global cache: %s", beforeCommit)
}
turn.commit()
afterCommit := repairResponsesWebsocketToolCallsWithoutRecording(sessionKey, []byte(`{"input":[{"type":"function_call","id":"fc-next","call_id":"call-1","name":"lookup","arguments":"{}"}]}`))
- input := gjson.GetBytes(afterCommit, "input").Array()
+ input := ojson.GetBytesNoCopy(afterCommit, "input").Array()
if len(input) != 2 || input[1].Get("output").String() != "cached result" {
t.Fatalf("committed turn was not available to tool repair: %s", afterCommit)
}
@@ -2210,7 +2210,7 @@ func TestRepairResponsesWebsocketToolCallsDropsOrphanFunctionCall(t *testing.T)
raw := []byte(`{"input":[{"type":"function_call","call_id":"call-1","name":"tool"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 1 {
t.Fatalf("repaired input len = %d, want 1", len(input))
}
@@ -2229,7 +2229,7 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCallForOrphanOutput(t *te
raw := []byte(`{"input":[{"type":"function_call_output","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 3 {
t.Fatalf("repaired input len = %d, want 3", len(input))
}
@@ -2254,10 +2254,10 @@ func TestRepairResponsesWebsocketToolCallsKeepsPreviousResponseOutputIncremental
raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"function_call_output","call_id":"call-1","id":"tool-out-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" {
+ if got := ojson.GetBytesNoCopy(repaired, "previous_response_id").String(); got != "resp-latest" {
t.Fatalf("previous_response_id = %q, want resp-latest", got)
}
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 2 {
t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
}
@@ -2279,10 +2279,10 @@ func TestRepairResponsesWebsocketToolCallsKeepsPreviousResponseCallIncremental(t
raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"function_call","id":"fc-1","call_id":"call-1","name":"tool"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" {
+ if got := ojson.GetBytesNoCopy(repaired, "previous_response_id").String(); got != "resp-latest" {
t.Fatalf("previous_response_id = %q, want resp-latest", got)
}
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 2 {
t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
}
@@ -2302,7 +2302,7 @@ func TestRepairResponsesWebsocketToolCallsDropsOrphanOutputWhenCallMissing(t *te
raw := []byte(`{"input":[{"type":"function_call_output","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 1 {
t.Fatalf("repaired input len = %d, want 1", len(input))
}
@@ -2317,14 +2317,14 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCustomToolOutput(t *testi
cacheWarm := []byte(`{"previous_response_id":"resp-1","input":[{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"}]}`)
warmed := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, cacheWarm)
- if gjson.GetBytes(warmed, "input.0.call_id").String() != "call-1" {
+ if ojson.GetBytesNoCopy(warmed, "input.0.call_id").String() != "call-1" {
t.Fatalf("expected warmup output to remain")
}
raw := []byte(`{"input":[{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 3 {
t.Fatalf("repaired input len = %d, want 3", len(input))
}
@@ -2346,7 +2346,7 @@ func TestRepairResponsesWebsocketToolCallsDropsOrphanCustomToolCall(t *testing.T
raw := []byte(`{"input":[{"type":"custom_tool_call","call_id":"call-1","name":"apply_patch"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCache(cache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 1 {
t.Fatalf("repaired input len = %d, want 1", len(input))
}
@@ -2365,7 +2365,7 @@ func TestRepairResponsesWebsocketToolCallsInsertsCachedCustomToolCallForOrphanOu
raw := []byte(`{"input":[{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 3 {
t.Fatalf("repaired input len = %d, want 3", len(input))
}
@@ -2390,10 +2390,10 @@ func TestRepairResponsesWebsocketToolCallsKeepsPreviousResponseCustomToolOutputI
raw := []byte(`{"previous_response_id":"resp-latest","input":[{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- if got := gjson.GetBytes(repaired, "previous_response_id").String(); got != "resp-latest" {
+ if got := ojson.GetBytesNoCopy(repaired, "previous_response_id").String(); got != "resp-latest" {
t.Fatalf("previous_response_id = %q, want resp-latest", got)
}
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 2 {
t.Fatalf("repaired input len = %d, want 2: %s", len(input), repaired)
}
@@ -2413,7 +2413,7 @@ func TestRepairResponsesWebsocketToolCallsDropsOrphanCustomToolOutputWhenCallMis
raw := []byte(`{"input":[{"type":"custom_tool_call_output","call_id":"call-1","output":"ok"},{"type":"message","id":"msg-1"}]}`)
repaired := repairResponsesWebsocketToolCallsWithCaches(outputCache, callCache, sessionKey, raw)
- input := gjson.GetBytes(repaired, "input").Array()
+ input := ojson.GetBytesNoCopy(repaired, "input").Array()
if len(input) != 1 {
t.Fatalf("repaired input len = %d, want 1", len(input))
}
@@ -2449,7 +2449,7 @@ func TestRecordResponsesWebsocketToolCallsFromPayloadWithCache(t *testing.T) {
if !ok {
t.Fatalf("expected cached tool call")
}
- if gjson.GetBytes(cached, "type").String() != "function_call" || gjson.GetBytes(cached, "call_id").String() != "call-1" {
+ if ojson.GetBytesNoCopy(cached, "type").String() != "function_call" || ojson.GetBytesNoCopy(cached, "call_id").String() != "call-1" {
t.Fatalf("unexpected cached tool call: %s", cached)
}
}
@@ -2465,7 +2465,7 @@ func TestRecordResponsesWebsocketCustomToolCallsFromCompletedPayloadWithCache(t
if !ok {
t.Fatalf("expected cached custom tool call")
}
- if gjson.GetBytes(cached, "type").String() != "custom_tool_call" || gjson.GetBytes(cached, "call_id").String() != "call-1" {
+ if ojson.GetBytesNoCopy(cached, "type").String() != "custom_tool_call" || ojson.GetBytesNoCopy(cached, "call_id").String() != "call-1" {
t.Fatalf("unexpected cached custom tool call: %s", cached)
}
}
@@ -2481,7 +2481,7 @@ func TestRecordResponsesWebsocketCustomToolCallsFromOutputItemDoneWithCache(t *t
if !ok {
t.Fatalf("expected cached custom tool call")
}
- if gjson.GetBytes(cached, "type").String() != "custom_tool_call" || gjson.GetBytes(cached, "call_id").String() != "call-1" {
+ if ojson.GetBytesNoCopy(cached, "type").String() != "custom_tool_call" || ojson.GetBytesNoCopy(cached, "call_id").String() != "call-1" {
t.Fatalf("unexpected cached custom tool call: %s", cached)
}
}
@@ -2541,7 +2541,7 @@ func testForwardResponsesWebsocketCompletedOutput(t *testing.T, preserve bool) {
serverErrCh <- fmt.Errorf("unexpected websocket error message: %v", errMsg.Error)
return
}
- if gjson.GetBytes(completedOutput, "0.id").String() != "call-1" {
+ if ojson.GetBytesNoCopy(completedOutput, "0.id").String() != "call-1" {
serverErrCh <- errors.New("completed output not restored")
return
}
@@ -2577,7 +2577,7 @@ func testForwardResponsesWebsocketCompletedOutput(t *testing.T, preserve bool) {
if errReadMessage != nil {
t.Fatalf("read output item websocket message: %v", errReadMessage)
}
- if got := gjson.GetBytes(outputItemPayload, "type").String(); got != "response.output_item.done" {
+ if got := ojson.GetBytesNoCopy(outputItemPayload, "type").String(); got != "response.output_item.done" {
t.Fatalf("output item payload type = %s, want response.output_item.done", got)
}
@@ -2585,8 +2585,8 @@ func testForwardResponsesWebsocketCompletedOutput(t *testing.T, preserve bool) {
if errReadMessage != nil {
t.Fatalf("read completion websocket message: %v", errReadMessage)
}
- if gjson.GetBytes(payload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("payload type = %s, want %s", gjson.GetBytes(payload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(payload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("payload type = %s, want %s", ojson.GetBytesNoCopy(payload, "type").String(), wsEventTypeCompleted)
}
if strings.Contains(string(payload), "response.done") {
t.Fatalf("payload unexpectedly rewrote completed event: %s", payload)
@@ -2595,7 +2595,7 @@ func testForwardResponsesWebsocketCompletedOutput(t *testing.T, preserve bool) {
if string(payload) != `{"type":"response.completed","response":{"id":"resp-1","output":[]}}` {
t.Fatalf("native completion changed: %s", payload)
}
- } else if got := gjson.GetBytes(payload, "response.output.0.id").String(); got != "call-1" {
+ } else if got := ojson.GetBytesNoCopy(payload, "response.output.0.id").String(); got != "call-1" {
t.Fatalf("downstream completion output id = %q, want call-1; payload=%s", got, payload)
}
@@ -2648,7 +2648,7 @@ func TestForwardResponsesWebsocketTreatsResponseDoneAsTerminalWithoutRewriting(t
serverErrCh <- fmt.Errorf("unexpected websocket error message: %v", errMsg.Error)
return
}
- if gjson.GetBytes(completedOutput, "0.id").String() != "out-1" {
+ if ojson.GetBytesNoCopy(completedOutput, "0.id").String() != "out-1" {
serverErrCh <- errors.New("done output not captured")
return
}
@@ -2680,7 +2680,7 @@ func TestForwardResponsesWebsocketTreatsResponseDoneAsTerminalWithoutRewriting(t
if errReadMessage != nil {
t.Fatalf("read websocket message: %v", errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != "response.done" {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != "response.done" {
t.Fatalf("payload type = %s, want response.done; payload=%s", got, payload)
}
@@ -2700,7 +2700,7 @@ func TestShouldExposeResponsesUpstreamError(t *testing.T) {
{status: http.StatusUnprocessableEntity, want: true},
{status: http.StatusUnauthorized},
{status: http.StatusRequestTimeout},
- {status: http.StatusTooManyRequests},
+ {status: http.StatusTooManyRequests, want: true},
{status: http.StatusInternalServerError},
}
@@ -2741,12 +2741,25 @@ func TestResponsesUpstreamErrorBodyDrivesExposure(t *testing.T) {
body: `{"error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"too long"}}`,
want: true,
},
- // Credential, quota and transport failures stay silent: the client just
+ // Credential and transport failures stay silent: the client just
// reconnects, and a fresh socket already implies a full context resend.
+ // Terminal 429 / quota / model-cooldown errors are exposed once failover ends.
{name: "unauthorized", status: http.StatusUnauthorized, body: "invalid token"},
{name: "payment required", status: http.StatusPaymentRequired, body: "insufficient credits"},
{name: "forbidden", status: http.StatusForbidden, body: "forbidden"},
- {name: "too many requests", status: http.StatusTooManyRequests, body: "usage limit reached"},
+ {name: "too many requests", status: http.StatusTooManyRequests, body: "usage limit reached", want: true},
+ {
+ name: "usage limit reached at 502",
+ status: http.StatusBadGateway,
+ body: `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached","resets_in_seconds":600}}`,
+ want: true,
+ },
+ {
+ name: "model cooldown at 500",
+ status: http.StatusInternalServerError,
+ body: `{"error":{"code":"model_cooldown","message":"All credentials for model gpt-5 are cooling down","reset_seconds":60}}`,
+ want: true,
+ },
{name: "request timeout", status: http.StatusRequestTimeout, body: "timeout"},
{name: "bad gateway", status: http.StatusBadGateway, body: "bad gateway"},
{
@@ -2840,7 +2853,7 @@ func TestForwardResponsesWebsocketTreatsErrorPayloadAsTerminal(t *testing.T) {
if errReadMessage != nil {
t.Fatalf("read websocket message: %v", errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeError {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeError {
t.Fatalf("payload type = %s, want %s; payload=%s", got, wsEventTypeError, payload)
}
@@ -3082,17 +3095,17 @@ func TestResponsesWebsocketSendsJSONErrorOnUpstreamCyberPolicyDisconnect(t *test
t.Fatalf("msgType = %d, want TextMessage (%d)", msgType, websocket.TextMessage)
}
- if gjson.GetBytes(payload, "type").String() != "error" {
- t.Fatalf("payload type = %q, want %q", gjson.GetBytes(payload, "type").String(), "error")
+ if ojson.GetBytesNoCopy(payload, "type").String() != "error" {
+ t.Fatalf("payload type = %q, want %q", ojson.GetBytesNoCopy(payload, "type").String(), "error")
}
- if status := int(gjson.GetBytes(payload, "status").Int()); status != http.StatusBadRequest {
+ if status := int(ojson.GetBytesNoCopy(payload, "status").Int()); status != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
}
- if gjson.GetBytes(payload, "error.code").String() != "cyber_policy" {
- t.Fatalf("error.code = %q, want %q", gjson.GetBytes(payload, "error.code").String(), "cyber_policy")
+ if ojson.GetBytesNoCopy(payload, "error.code").String() != "cyber_policy" {
+ t.Fatalf("error.code = %q, want %q", ojson.GetBytesNoCopy(payload, "error.code").String(), "cyber_policy")
}
- if !strings.Contains(gjson.GetBytes(payload, "error.message").String(), "cybersecurity risk") {
- t.Fatalf("error.message = %q, want cybersecurity risk text", gjson.GetBytes(payload, "error.message").String())
+ if !strings.Contains(ojson.GetBytesNoCopy(payload, "error.message").String(), "cybersecurity risk") {
+ t.Fatalf("error.message = %q, want cybersecurity risk text", ojson.GetBytesNoCopy(payload, "error.message").String())
}
if _, duplicate, errRead := conn.ReadMessage(); errRead == nil {
t.Fatalf("received duplicate error frame: %s", duplicate)
@@ -3117,15 +3130,6 @@ func TestResponsesWebsocketHidesNonClientUpstreamDisconnectErrors(t *testing.T)
name: "upstream read timeout",
err: errors.New("read tcp 198.18.0.1:53030->145.223.58.12:6281: i/o timeout"),
},
- {
- // Credential failover already ran and lost; the client only needs to
- // reconnect, so no downstream error is produced.
- name: "quota exhausted",
- err: websocketPinnedFailoverStatusError{
- status: http.StatusTooManyRequests,
- msg: `{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}`,
- },
- },
{
name: "credential rejected",
err: websocketPinnedFailoverStatusError{
@@ -3220,7 +3224,7 @@ func TestResponsesWebsocketExposesCyberPolicyRegardlessOfStatus(t *testing.T) {
if errRead != nil {
t.Fatalf("cyber_policy rejection was hidden at status %d: %v", status, errRead)
}
- if got := gjson.GetBytes(payload, "error.code").String(); got != "cyber_policy" {
+ if got := ojson.GetBytesNoCopy(payload, "error.code").String(); got != "cyber_policy" {
t.Fatalf("error.code = %q, want cyber_policy: %s", got, payload)
}
})
@@ -3268,23 +3272,23 @@ func TestResponsesWebsocketExposesTerminalOAuthError(t *testing.T) {
if errRead != nil {
t.Fatalf("terminal OAuth rejection was hidden: %v", errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != "error" {
t.Fatalf("type = %q, want error: %s", got, payload)
}
- if got := gjson.GetBytes(payload, "status").Int(); got != http.StatusServiceUnavailable {
+ if got := ojson.GetBytesNoCopy(payload, "status").Int(); got != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503: %s", got, payload)
}
- if got := gjson.GetBytes(payload, "error.type").String(); got != "authentication_error" {
+ if got := ojson.GetBytesNoCopy(payload, "error.type").String(); got != "authentication_error" {
t.Fatalf("error.type = %q, want authentication_error: %s", got, payload)
}
- if got := gjson.GetBytes(payload, "error.code").String(); got != "upstream_authentication_required" {
+ if got := ojson.GetBytesNoCopy(payload, "error.code").String(); got != "upstream_authentication_required" {
t.Fatalf("error.code = %q, want upstream_authentication_required: %s", got, payload)
}
- retryable := gjson.GetBytes(payload, "error.retryable")
+ retryable := ojson.GetBytesNoCopy(payload, "error.retryable")
if !retryable.Exists() || retryable.Bool() {
t.Fatalf("error.retryable = %v, want explicit false: %s", retryable, payload)
}
- if !strings.Contains(gjson.GetBytes(payload, "error.message").String(), "refresh_token_reused") {
+ if !strings.Contains(ojson.GetBytesNoCopy(payload, "error.message").String(), "refresh_token_reused") {
t.Fatalf("error.message missing refresh_token_reused: %s", payload)
}
}
@@ -3315,7 +3319,7 @@ func TestResponsesWebsocketTerminalErrorWrittenOnceAcrossForwardAndDisconnect(t
defer wg.Done()
<-start
payload, _, errWrite := writeResponsesWebsocketTerminalError(writer, nil, errMsg, nil)
- if !errors.Is(errWrite, websocket.ErrCloseSent) || gjson.GetBytes(payload, "error.code").String() != "cyber_policy" {
+ if !errors.Is(errWrite, websocket.ErrCloseSent) || ojson.GetBytesNoCopy(payload, "error.code").String() != "cyber_policy" {
resultCh <- fmt.Errorf("err-channel terminal write failed: err=%v payload=%s", errWrite, payload)
}
}()
@@ -3324,7 +3328,7 @@ func TestResponsesWebsocketTerminalErrorWrittenOnceAcrossForwardAndDisconnect(t
<-start
payload := []byte(`{"type":"error","status":400,"error":{"type":"invalid_request","code":"cyber_policy","message":"blocked"}}`)
writtenPayload, _, errWrite := writeResponsesWebsocketTerminalError(writer, nil, errMsg, payload)
- if !errors.Is(errWrite, websocket.ErrCloseSent) || gjson.GetBytes(writtenPayload, "error.code").String() != "cyber_policy" {
+ if !errors.Is(errWrite, websocket.ErrCloseSent) || ojson.GetBytesNoCopy(writtenPayload, "error.code").String() != "cyber_policy" {
resultCh <- fmt.Errorf("payload terminal write failed: err=%v payload=%s", errWrite, writtenPayload)
}
}()
@@ -3358,7 +3362,7 @@ func TestResponsesWebsocketTerminalErrorWrittenOnceAcrossForwardAndDisconnect(t
break
}
textFrames++
- if got := gjson.GetBytes(payload, "error.code").String(); got != "cyber_policy" {
+ if got := ojson.GetBytesNoCopy(payload, "error.code").String(); got != "cyber_policy" {
t.Fatalf("terminal error code = %q, want cyber_policy: %s", got, payload)
}
}
@@ -3431,13 +3435,13 @@ func TestResponsesWebsocketCodexWebsocketPassthroughPassesCompactedRequestWithou
if len(payloads) != 2 {
t.Fatalf("passthrough payload count = %d, want 2", len(payloads))
}
- if got := gjson.GetBytes(payloads[0], "input").Raw; got != gjson.GetBytes(firstRequest, "input").Raw {
- t.Fatalf("first passthrough input = %s, want %s", got, gjson.GetBytes(firstRequest, "input").Raw)
+ if got := ojson.GetBytesNoCopy(payloads[0], "input").Raw; got != ojson.GetBytesNoCopy(firstRequest, "input").Raw {
+ t.Fatalf("first passthrough input = %s, want %s", got, ojson.GetBytesNoCopy(firstRequest, "input").Raw)
}
- if got := gjson.GetBytes(payloads[1], "input").Raw; got != gjson.GetBytes(compactedRequest, "input").Raw {
- t.Fatalf("compacted passthrough input = %s, want %s", got, gjson.GetBytes(compactedRequest, "input").Raw)
+ if got := ojson.GetBytesNoCopy(payloads[1], "input").Raw; got != ojson.GetBytesNoCopy(compactedRequest, "input").Raw {
+ t.Fatalf("compacted passthrough input = %s, want %s", got, ojson.GetBytesNoCopy(compactedRequest, "input").Raw)
}
- if got := gjson.GetBytes(payloads[1], "model").String(); got != "test-model" {
+ if got := ojson.GetBytesNoCopy(payloads[1], "model").String(); got != "test-model" {
t.Fatalf("compacted passthrough model = %s, want test-model", got)
}
if bytes.Contains(payloads[1], []byte(`"content":"first"`)) || bytes.Contains(payloads[1], []byte(`"id":"out-1"`)) {
@@ -3512,16 +3516,16 @@ func TestResponsesWebsocketXAIWebsocketPassthroughKeepsNativeIncrementalRequest(
t.Fatalf("xai websocket payload count = %d, want 2", len(payloads))
}
secondPayload := payloads[1]
- if got := gjson.GetBytes(secondPayload, "type").String(); got != wsRequestTypeCreate {
+ if got := ojson.GetBytesNoCopy(secondPayload, "type").String(); got != wsRequestTypeCreate {
t.Fatalf("incremental xai payload type = %q, want %q: %s", got, wsRequestTypeCreate, secondPayload)
}
- if got := gjson.GetBytes(secondPayload, "model").String(); got != modelName {
+ if got := ojson.GetBytesNoCopy(secondPayload, "model").String(); got != modelName {
t.Fatalf("second xai payload model = %s, want %s", got, modelName)
}
- if got := gjson.GetBytes(secondPayload, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytesNoCopy(secondPayload, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("second xai previous_response_id = %q, want resp-1: %s", got, secondPayload)
}
- input := gjson.GetBytes(secondPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(secondPayload, "input").Array()
if len(input) != 1 || input[0].Get("id").String() != "msg-2" {
t.Fatalf("second xai incremental input is not the client delta: %s", secondPayload)
}
@@ -3590,7 +3594,7 @@ func TestResponsesWebsocketFullRequestCanRouteFromNativeWebsocketToBuiltInProvid
t.Fatalf("read first websocket response: %v", errRead)
}
_, nativeResponse, errRead := conn.ReadMessage()
- if errRead != nil || gjson.GetBytes(nativeResponse, "response.output").Raw != "[]" {
+ if errRead != nil || ojson.GetBytesNoCopy(nativeResponse, "response.output").Raw != "[]" {
t.Fatalf("native completion = %s, error = %v", nativeResponse, errRead)
}
@@ -3605,11 +3609,11 @@ func TestResponsesWebsocketFullRequestCanRouteFromNativeWebsocketToBuiltInProvid
if errRead != nil {
t.Fatalf("read routed websocket response: %v", errRead)
}
- if got := gjson.GetBytes(response, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(response, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("routed response type = %q, want %q: %s", got, wsEventTypeCompleted, response)
}
t.Logf("native completion: %s; routed completion: %s", nativeResponse, response)
- if got := gjson.GetBytes(response, "response.output.0.id").String(); got != "out-1" {
+ if got := ojson.GetBytesNoCopy(response, "response.output.0.id").String(); got != "out-1" {
t.Fatalf("cross-provider output repair lost: %s", response)
}
if got := len(codexExecutor.Payloads()); got != 1 {
@@ -3671,7 +3675,7 @@ func TestResponsesWebsocketHidesProviderRouteAuthFailure(t *testing.T) {
if errRead != nil {
t.Fatalf("read first response: %v", errRead)
}
- if got := gjson.GetBytes(firstResponse, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(firstResponse, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("first response type = %q, want %q: %s", got, wsEventTypeCompleted, firstResponse)
}
@@ -3837,10 +3841,10 @@ func TestResponsesWebsocketClosesForHTTPReplayWhenWebsocketEligibilityChanges(t
t.Fatalf("executor payload count = %d, want 2; transport switch must not call HTTP upstream", len(payloads))
}
second := payloads[1]
- if got := gjson.GetBytes(second, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytesNoCopy(second, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("stable websocket previous_response_id = %q, want resp-1: %s", got, second)
}
- if input := gjson.GetBytes(second, "input").Array(); len(input) != 1 || input[0].Get("id").String() != "msg-2" {
+ if input := ojson.GetBytesNoCopy(second, "input").Array(); len(input) != 1 || input[0].Get("id").String() != "msg-2" {
t.Fatalf("stable websocket payload is not incremental: %s", second)
}
@@ -3869,10 +3873,10 @@ func TestResponsesWebsocketClosesForHTTPReplayWhenWebsocketEligibilityChanges(t
t.Fatalf("executor payload count after replay = %d, want 4", len(payloads))
}
httpDelta := payloads[3]
- if gjson.GetBytes(httpDelta, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(httpDelta, "previous_response_id").Exists() {
t.Fatalf("HTTP-mode delta retained previous_response_id: %s", httpDelta)
}
- input := gjson.GetBytes(httpDelta, "input").Array()
+ input := ojson.GetBytesNoCopy(httpDelta, "input").Array()
wantIDs := []string{"msg-1", "out-1", "msg-2", "out-2", "msg-3", "out-3", "msg-4"}
if len(input) != len(wantIDs) {
t.Fatalf("HTTP-mode canonical input len = %d, want %d: %s", len(input), len(wantIDs), httpDelta)
@@ -3928,13 +3932,13 @@ func TestResponsesWebsocketRejectsUnknownPreviousResponseOnNewSocket(t *testing.
if errRead != nil {
t.Fatalf("read websocket response: %v", errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeError {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeError {
t.Fatalf("response type = %q, want %q: %s", got, wsEventTypeError, payload)
}
- if got := int(gjson.GetBytes(payload, "status").Int()); got != http.StatusConflict {
+ if got := int(ojson.GetBytesNoCopy(payload, "status").Int()); got != http.StatusConflict {
t.Fatalf("response status = %d, want %d: %s", got, http.StatusConflict, payload)
}
- if got := gjson.GetBytes(payload, "error.code").String(); got != "previous_response_not_found" {
+ if got := ojson.GetBytesNoCopy(payload, "error.code").String(); got != "previous_response_not_found" {
t.Fatalf("response error code = %q, want previous_response_not_found: %s", got, payload)
}
if got := len(executor.Payloads()); got != 0 {
@@ -3949,14 +3953,14 @@ func TestResponsesWebsocketRejectsUnknownPreviousResponseOnNewSocket(t *testing.
if errRead != nil {
t.Fatalf("read full recovery response: %v", errRead)
}
- if got := gjson.GetBytes(recoveryPayload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(recoveryPayload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("recovery response type = %q, want %q: %s", got, wsEventTypeCompleted, recoveryPayload)
}
payloads := executor.Payloads()
if len(payloads) != 1 {
t.Fatalf("executor payload count after recovery = %d, want 1", len(payloads))
}
- if got := len(gjson.GetBytes(payloads[0], "input").Array()); got != 3 {
+ if got := len(ojson.GetBytesNoCopy(payloads[0], "input").Array()); got != 3 {
t.Fatalf("full recovery input len = %d, want 3: %s", got, payloads[0])
}
}
@@ -4001,7 +4005,7 @@ func TestResponsesWebsocketClosesAfterNonRetryableClientError(t *testing.T) {
t.Fatalf("write first websocket message: %v", errWrite)
}
_, firstResponse, errRead := conn.ReadMessage()
- if errRead != nil || gjson.GetBytes(firstResponse, "type").String() != wsEventTypeCompleted {
+ if errRead != nil || ojson.GetBytesNoCopy(firstResponse, "type").String() != wsEventTypeCompleted {
t.Fatalf("first websocket response = %s, err=%v", firstResponse, errRead)
}
@@ -4013,10 +4017,10 @@ func TestResponsesWebsocketClosesAfterNonRetryableClientError(t *testing.T) {
if errRead != nil {
t.Fatalf("read client error response: %v", errRead)
}
- if got := gjson.GetBytes(errorResponse, "type").String(); got != wsEventTypeError {
+ if got := ojson.GetBytesNoCopy(errorResponse, "type").String(); got != wsEventTypeError {
t.Fatalf("client error response type = %q, want %q: %s", got, wsEventTypeError, errorResponse)
}
- if got := int(gjson.GetBytes(errorResponse, "status").Int()); got != http.StatusBadRequest {
+ if got := int(ojson.GetBytesNoCopy(errorResponse, "status").Int()); got != http.StatusBadRequest {
t.Fatalf("client error response status = %d, want %d: %s", got, http.StatusBadRequest, errorResponse)
}
if _, duplicate, errRead := conn.ReadMessage(); errRead == nil {
@@ -4078,7 +4082,7 @@ func TestResponsesWebsocketExposesItemNotPersistedAndRecoversOnReconnect(t *test
t.Fatalf("write first request: %v", errWrite)
}
if _, firstResponse, errRead := conn.ReadMessage(); errRead != nil ||
- gjson.GetBytes(firstResponse, "type").String() != wsEventTypeCompleted {
+ ojson.GetBytesNoCopy(firstResponse, "type").String() != wsEventTypeCompleted {
t.Fatalf("first response = %s, err=%v", firstResponse, errRead)
}
@@ -4091,13 +4095,13 @@ func TestResponsesWebsocketExposesItemNotPersistedAndRecoversOnReconnect(t *test
if errRead != nil {
t.Fatalf("item miss was hidden from the client: %v", errRead)
}
- if got := gjson.GetBytes(errorResponse, "type").String(); got != wsEventTypeError {
+ if got := ojson.GetBytesNoCopy(errorResponse, "type").String(); got != wsEventTypeError {
t.Fatalf("response type = %q, want %q: %s", got, wsEventTypeError, errorResponse)
}
- if got := int(gjson.GetBytes(errorResponse, "status").Int()); got != http.StatusNotFound {
+ if got := int(ojson.GetBytesNoCopy(errorResponse, "status").Int()); got != http.StatusNotFound {
t.Fatalf("status = %d, want %d: %s", got, http.StatusNotFound, errorResponse)
}
- if msg := gjson.GetBytes(errorResponse, "error.message").String(); !strings.Contains(msg, "Items are not persisted") {
+ if msg := ojson.GetBytesNoCopy(errorResponse, "error.message").String(); !strings.Contains(msg, "Items are not persisted") {
t.Fatalf("error.message lost the upstream reason: %q", msg)
}
if _, extra, errRead := conn.ReadMessage(); errRead == nil {
@@ -4119,7 +4123,7 @@ func TestResponsesWebsocketExposesItemNotPersistedAndRecoversOnReconnect(t *test
if errRead != nil {
t.Fatalf("read rebuilt response: %v", errRead)
}
- if got := gjson.GetBytes(recovered, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(recovered, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("rebuilt response type = %q, want %q: %s", got, wsEventTypeCompleted, recovered)
}
@@ -4129,10 +4133,10 @@ func TestResponsesWebsocketExposesItemNotPersistedAndRecoversOnReconnect(t *test
}
// The rebuilt turn must carry the full input and none of the failed turn's state.
rebuilt := payloads[2]
- if got := gjson.GetBytes(rebuilt, "previous_response_id").String(); got != "" {
+ if got := ojson.GetBytesNoCopy(rebuilt, "previous_response_id").String(); got != "" {
t.Fatalf("rebuilt upstream request still pinned previous_response_id=%q: %s", got, rebuilt)
}
- inputIDs := gjson.GetBytes(rebuilt, "input.#.id").Array()
+ inputIDs := ojson.GetBytesNoCopy(rebuilt, "input.#.id").Array()
if len(inputIDs) != 2 || inputIDs[0].String() != "msg-1" || inputIDs[1].String() != "msg-2" {
t.Fatalf("rebuilt upstream input lost context: %s", rebuilt)
}
@@ -4249,7 +4253,7 @@ func TestResponsesWebsocketSwitchesPinnedAuthAcrossProviders(t *testing.T) {
if errRead != nil {
t.Fatalf("read websocket response %d: %v", turn, errRead)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("response %d type = %s, want %s: %s", turn, got, wsEventTypeCompleted, payload)
}
}
@@ -4490,11 +4494,11 @@ func TestResponsesWebsocketPrewarmPreservesCompactedFollowup(t *testing.T) {
warmup := `{"type":"response.create","model":"prewarm-prefix-model","instructions":"legacy base","generate":false,"input":[{"type":"additional_tools","id":"warm-tools","role":"developer","tools":[{"type":"namespace","name":"functions","tools":[{"type":"custom","name":"exec"}]}]},{"type":"message","id":"warm-base","role":"developer","content":"base instructions"}]}`
send(warmup)
created := read()
- parent := gjson.GetBytes(created, "response.id").String()
+ parent := ojson.GetBytesNoCopy(created, "response.id").String()
if !strings.HasPrefix(parent, "resp_prewarm_") {
t.Fatalf("expected synthetic warmup: %s", created)
}
- if got := gjson.GetBytes(read(), "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(read(), "type").String(); got != wsEventTypeCompleted {
t.Fatalf("warmup event=%s", got)
}
if executor.streamCalls != 0 {
@@ -4513,7 +4517,7 @@ func TestResponsesWebsocketPrewarmPreservesCompactedFollowup(t *testing.T) {
}
if tc.invalidType {
send(fmt.Sprintf(`{"type":"unsupported","previous_response_id":%q,"input":[]}`, parent))
- if gjson.GetBytes(read(), "type").String() != "error" || executor.streamCalls != 0 {
+ if ojson.GetBytesNoCopy(read(), "type").String() != "error" || executor.streamCalls != 0 {
t.Fatal("invalid request type reached upstream")
}
}
@@ -4523,20 +4527,20 @@ func TestResponsesWebsocketPrewarmPreservesCompactedFollowup(t *testing.T) {
} else {
send(`{"type":"response.create","input":{}}`)
}
- if gjson.GetBytes(read(), "type").String() != "error" || executor.streamCalls != 0 {
+ if ojson.GetBytesNoCopy(read(), "type").String() != "error" || executor.streamCalls != 0 {
t.Fatal("invalid delta did not fail before upstream")
}
}
send(followup)
result := read()
if tc.wrongParent {
- if gjson.GetBytes(result, "type").String() != "error" || executor.streamCalls != 0 {
+ if ojson.GetBytesNoCopy(result, "type").String() != "error" || executor.streamCalls != 0 {
t.Fatalf("unrelated parent inherited warmup: %s", result)
}
return
}
if tc.failFirst {
- if gjson.GetBytes(result, "type").String() != "error" {
+ if ojson.GetBytesNoCopy(result, "type").String() != "error" {
t.Fatalf("expected first failure: %s", result)
}
// Terminal upstream errors close the connection. Codex reconnects
@@ -4548,22 +4552,22 @@ func TestResponsesWebsocketPrewarmPreservesCompactedFollowup(t *testing.T) {
t.Fatal(errReconnect)
}
send(warmup)
- newParent := gjson.GetBytes(read(), "response.id").String()
- if gjson.GetBytes(read(), "type").String() != wsEventTypeCompleted {
+ newParent := ojson.GetBytesNoCopy(read(), "response.id").String()
+ if ojson.GetBytesNoCopy(read(), "type").String() != wsEventTypeCompleted {
t.Fatal("retry warmup failed")
}
send(strings.ReplaceAll(followup, parent, newParent))
result = read()
}
- if gjson.GetBytes(result, "type").String() != wsEventTypeCompleted {
+ if ojson.GetBytesNoCopy(result, "type").String() != wsEventTypeCompleted {
t.Fatalf("followup failed: %s", result)
}
for _, forwarded := range executor.payloads {
- if gjson.GetBytes(forwarded, "model").String() != "prewarm-prefix-model" || gjson.GetBytes(forwarded, "instructions").String() != "legacy base" {
+ if ojson.GetBytesNoCopy(forwarded, "model").String() != "prewarm-prefix-model" || ojson.GetBytesNoCopy(forwarded, "instructions").String() != "legacy base" {
t.Fatalf("request defaults lost: %s", forwarded)
}
- input := gjson.GetBytes(forwarded, "input").Array()
- want := gjson.Parse(tc.input).Array()
+ input := ojson.GetBytesNoCopy(forwarded, "input").Array()
+ want := ojson.Parse(tc.input).Array()
if tc.wantPrefix {
if len(input) != len(want)+2 || input[0].Get("id").String() != "warm-tools" || input[1].Get("id").String() != "warm-base" {
t.Fatalf("acknowledged tools/base prefix lost or duplicated: %s", forwarded)
@@ -4577,20 +4581,20 @@ func TestResponsesWebsocketPrewarmPreservesCompactedFollowup(t *testing.T) {
t.Fatalf("delta changed: got %s want %s", input[i].Raw, want[i].Raw)
}
}
- if gjson.GetBytes(forwarded, "previous_response_id").Exists() || gjson.GetBytes(forwarded, "generate").Exists() {
+ if ojson.GetBytesNoCopy(forwarded, "previous_response_id").Exists() || ojson.GetBytesNoCopy(forwarded, "generate").Exists() {
t.Fatalf("synthetic state leaked upstream: %s", forwarded)
}
- if gjson.GetBytes(forwarded, "client_metadata.keep").String() != "unchanged" {
+ if ojson.GetBytesNoCopy(forwarded, "client_metadata.keep").String() != "unchanged" {
t.Fatal("metadata changed")
}
}
if tc.name == "compacted_delta" {
send(`{"type":"response.create","model":"prewarm-prefix-model","previous_response_id":"resp-upstream","input":[{"type":"function_call_output","name":"automation_update","output":"next heartbeat"}]}`)
- if gjson.GetBytes(read(), "type").String() != wsEventTypeCompleted {
+ if ojson.GetBytesNoCopy(read(), "type").String() != wsEventTypeCompleted {
t.Fatal("real-response continuation failed")
}
last := executor.payloads[len(executor.payloads)-1]
- if len(gjson.GetBytes(last, `input.#(type=="additional_tools")#`).Array()) != 1 || gjson.GetBytes(last, "input").Array()[len(gjson.GetBytes(last, "input").Array())-1].Get("output").String() != "next heartbeat" {
+ if len(ojson.GetBytesNoCopy(last, `input.#(type=="additional_tools")#`).Array()) != 1 || ojson.GetBytesNoCopy(last, "input").Array()[len(ojson.GetBytesNoCopy(last, "input").Array())-1].Get("output").String() != "next heartbeat" {
t.Fatalf("continuation lost or duplicated state: %s", last)
}
}
@@ -4642,14 +4646,14 @@ func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {
if errReadMessage != nil {
t.Fatalf("read prewarm created message: %v", errReadMessage)
}
- if gjson.GetBytes(createdPayload, "type").String() != "response.created" {
- t.Fatalf("created payload type = %s, want response.created", gjson.GetBytes(createdPayload, "type").String())
+ if ojson.GetBytesNoCopy(createdPayload, "type").String() != "response.created" {
+ t.Fatalf("created payload type = %s, want response.created", ojson.GetBytesNoCopy(createdPayload, "type").String())
}
- prewarmResponseID := gjson.GetBytes(createdPayload, "response.id").String()
+ prewarmResponseID := ojson.GetBytesNoCopy(createdPayload, "response.id").String()
if prewarmResponseID == "" {
t.Fatalf("prewarm response id is empty")
}
- if got := gjson.GetBytes(createdPayload, "response.model").String(); got != "test-model" {
+ if got := ojson.GetBytesNoCopy(createdPayload, "response.model").String(); got != "test-model" {
t.Fatalf("prewarm response.model = %q, want test-model", got)
}
if executor.streamCalls != 0 {
@@ -4660,14 +4664,14 @@ func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {
if errReadMessage != nil {
t.Fatalf("read prewarm completed message: %v", errReadMessage)
}
- if gjson.GetBytes(completedPayload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("completed payload type = %s, want %s", gjson.GetBytes(completedPayload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(completedPayload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("completed payload type = %s, want %s", ojson.GetBytesNoCopy(completedPayload, "type").String(), wsEventTypeCompleted)
}
- if gjson.GetBytes(completedPayload, "response.id").String() != prewarmResponseID {
- t.Fatalf("completed response id = %s, want %s", gjson.GetBytes(completedPayload, "response.id").String(), prewarmResponseID)
+ if ojson.GetBytesNoCopy(completedPayload, "response.id").String() != prewarmResponseID {
+ t.Fatalf("completed response id = %s, want %s", ojson.GetBytesNoCopy(completedPayload, "response.id").String(), prewarmResponseID)
}
- if gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int() != 0 {
- t.Fatalf("prewarm total tokens = %d, want 0", gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int())
+ if ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int() != 0 {
+ t.Fatalf("prewarm total tokens = %d, want 0", ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int())
}
secondRequest := fmt.Sprintf(`{"type":"response.create","previous_response_id":%q,"input":[{"type":"message","id":"msg-1"}]}`, prewarmResponseID)
@@ -4680,8 +4684,8 @@ func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {
if errReadMessage != nil {
t.Fatalf("read upstream completed message: %v", errReadMessage)
}
- if gjson.GetBytes(upstreamPayload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("upstream payload type = %s, want %s", gjson.GetBytes(upstreamPayload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(upstreamPayload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("upstream payload type = %s, want %s", ojson.GetBytesNoCopy(upstreamPayload, "type").String(), wsEventTypeCompleted)
}
if executor.streamCalls != 1 {
t.Fatalf("stream calls after follow-up = %d, want 1", executor.streamCalls)
@@ -4690,16 +4694,16 @@ func TestResponsesWebsocketPrewarmHandledLocallyForSSEUpstream(t *testing.T) {
t.Fatalf("captured upstream payloads = %d, want 1", len(executor.payloads))
}
forwarded := executor.payloads[0]
- if gjson.GetBytes(forwarded, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(forwarded, "previous_response_id").Exists() {
t.Fatalf("previous_response_id leaked upstream: %s", forwarded)
}
- if gjson.GetBytes(forwarded, "generate").Exists() {
+ if ojson.GetBytesNoCopy(forwarded, "generate").Exists() {
t.Fatalf("generate leaked upstream: %s", forwarded)
}
- if gjson.GetBytes(forwarded, "model").String() != "test-model" {
- t.Fatalf("forwarded model = %s, want test-model", gjson.GetBytes(forwarded, "model").String())
+ if ojson.GetBytesNoCopy(forwarded, "model").String() != "test-model" {
+ t.Fatalf("forwarded model = %s, want test-model", ojson.GetBytesNoCopy(forwarded, "model").String())
}
- input := gjson.GetBytes(forwarded, "input").Array()
+ input := ojson.GetBytesNoCopy(forwarded, "input").Array()
if len(input) != 1 || input[0].Get("id").String() != "msg-1" {
t.Fatalf("unexpected forwarded input: %s", forwarded)
}
@@ -4750,8 +4754,8 @@ func TestResponsesWebsocketMidConnectionPrewarmHandledLocallyForSSEUpstream(t *t
if errReadMessage != nil {
t.Fatalf("read frame 1 response: %v", errReadMessage)
}
- if gjson.GetBytes(frame1Payload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("frame 1 payload type = %s, want %s", gjson.GetBytes(frame1Payload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(frame1Payload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("frame 1 payload type = %s, want %s", ojson.GetBytesNoCopy(frame1Payload, "type").String(), wsEventTypeCompleted)
}
if executor.streamCalls != 1 {
t.Fatalf("stream calls after frame 1 = %d, want 1", executor.streamCalls)
@@ -4767,10 +4771,10 @@ func TestResponsesWebsocketMidConnectionPrewarmHandledLocallyForSSEUpstream(t *t
if errReadMessage != nil {
t.Fatalf("read frame 2 created message: %v", errReadMessage)
}
- if gjson.GetBytes(createdPayload, "type").String() != "response.created" {
- t.Fatalf("frame 2 created payload type = %s, want response.created (got: %s)", gjson.GetBytes(createdPayload, "type").String(), createdPayload)
+ if ojson.GetBytesNoCopy(createdPayload, "type").String() != "response.created" {
+ t.Fatalf("frame 2 created payload type = %s, want response.created (got: %s)", ojson.GetBytesNoCopy(createdPayload, "type").String(), createdPayload)
}
- prewarmResponseID := gjson.GetBytes(createdPayload, "response.id").String()
+ prewarmResponseID := ojson.GetBytesNoCopy(createdPayload, "response.id").String()
if prewarmResponseID == "" || !strings.HasPrefix(prewarmResponseID, "resp_prewarm_") {
t.Fatalf("frame 2 prewarm response id unexpected: %q", prewarmResponseID)
}
@@ -4782,17 +4786,17 @@ func TestResponsesWebsocketMidConnectionPrewarmHandledLocallyForSSEUpstream(t *t
if errReadMessage != nil {
t.Fatalf("read frame 2 completed message: %v", errReadMessage)
}
- if gjson.GetBytes(completedPayload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("frame 2 completed payload type = %s, want %s", gjson.GetBytes(completedPayload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(completedPayload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("frame 2 completed payload type = %s, want %s", ojson.GetBytesNoCopy(completedPayload, "type").String(), wsEventTypeCompleted)
}
- if gjson.GetBytes(completedPayload, "response.id").String() != prewarmResponseID {
- t.Fatalf("frame 2 completed response id = %s, want %s", gjson.GetBytes(completedPayload, "response.id").String(), prewarmResponseID)
+ if ojson.GetBytesNoCopy(completedPayload, "response.id").String() != prewarmResponseID {
+ t.Fatalf("frame 2 completed response id = %s, want %s", ojson.GetBytesNoCopy(completedPayload, "response.id").String(), prewarmResponseID)
}
- if len(gjson.GetBytes(completedPayload, "response.output").Array()) != 0 {
- t.Fatalf("frame 2 prewarm output should be empty, got: %s", gjson.GetBytes(completedPayload, "response.output").Raw)
+ if len(ojson.GetBytesNoCopy(completedPayload, "response.output").Array()) != 0 {
+ t.Fatalf("frame 2 prewarm output should be empty, got: %s", ojson.GetBytesNoCopy(completedPayload, "response.output").Raw)
}
- if gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int() != 0 {
- t.Fatalf("frame 2 prewarm total tokens = %d, want 0", gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int())
+ if ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int() != 0 {
+ t.Fatalf("frame 2 prewarm total tokens = %d, want 0", ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int())
}
// Frame 3: follow-up referencing the mid-connection prewarm response id
@@ -4806,8 +4810,8 @@ func TestResponsesWebsocketMidConnectionPrewarmHandledLocallyForSSEUpstream(t *t
if errReadMessage != nil {
t.Fatalf("read frame 3 response: %v", errReadMessage)
}
- if gjson.GetBytes(frame3Payload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("frame 3 payload type = %s, want %s", gjson.GetBytes(frame3Payload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(frame3Payload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("frame 3 payload type = %s, want %s", ojson.GetBytesNoCopy(frame3Payload, "type").String(), wsEventTypeCompleted)
}
if executor.streamCalls != 2 {
t.Fatalf("stream calls after frame 3 = %d, want 2", executor.streamCalls)
@@ -4816,13 +4820,13 @@ func TestResponsesWebsocketMidConnectionPrewarmHandledLocallyForSSEUpstream(t *t
t.Fatalf("captured upstream payloads = %d, want 2", len(executor.payloads))
}
forwarded := executor.payloads[1]
- if gjson.GetBytes(forwarded, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(forwarded, "previous_response_id").Exists() {
t.Fatalf("previous_response_id leaked upstream: %s", forwarded)
}
- if gjson.GetBytes(forwarded, "generate").Exists() {
+ if ojson.GetBytesNoCopy(forwarded, "generate").Exists() {
t.Fatalf("generate leaked upstream: %s", forwarded)
}
- inputItems := gjson.GetBytes(forwarded, "input").Array()
+ inputItems := ojson.GetBytesNoCopy(forwarded, "input").Array()
hasPrewarm := false
hasFollowup := false
hasInitial := false
@@ -4901,12 +4905,12 @@ func TestResponsesWebsocketMidConnectionPrewarmEmptyInputFollowupForSSEUpstream(
if errReadMessage != nil {
t.Fatalf("read frame 2 created message: %v", errReadMessage)
}
- prewarmID := gjson.GetBytes(createdPayload, "response.id").String()
+ prewarmID := ojson.GetBytesNoCopy(createdPayload, "response.id").String()
_, completedPayload, errReadMessage := conn.ReadMessage()
if errReadMessage != nil {
t.Fatalf("read frame 2 completed message: %v", errReadMessage)
}
- if len(gjson.GetBytes(completedPayload, "response.output").Array()) != 0 {
+ if len(ojson.GetBytesNoCopy(completedPayload, "response.output").Array()) != 0 {
t.Fatalf("frame 2 prewarm output not empty: %s", completedPayload)
}
@@ -4924,7 +4928,7 @@ func TestResponsesWebsocketMidConnectionPrewarmEmptyInputFollowupForSSEUpstream(
t.Fatalf("stream calls after frame 3 = %d, want 2", executor.streamCalls)
}
forwarded := executor.payloads[1]
- inputItems := gjson.GetBytes(forwarded, "input").Array()
+ inputItems := ojson.GetBytesNoCopy(forwarded, "input").Array()
if len(inputItems) != 1 || inputItems[0].Get("id").String() != "msg-prewarm" {
t.Fatalf("unexpected forwarded input with empty continuation: %s", forwarded)
}
@@ -4975,10 +4979,10 @@ func TestResponsesWebsocketMidConnectionIncrementalPrewarmHandledLocallyForSSEUp
if errReadMessage != nil {
t.Fatalf("read frame 1 response: %v", errReadMessage)
}
- if gjson.GetBytes(frame1Payload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("frame 1 payload type = %s, want %s", gjson.GetBytes(frame1Payload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(frame1Payload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("frame 1 payload type = %s, want %s", ojson.GetBytesNoCopy(frame1Payload, "type").String(), wsEventTypeCompleted)
}
- frame1ResponseID := gjson.GetBytes(frame1Payload, "response.id").String()
+ frame1ResponseID := ojson.GetBytesNoCopy(frame1Payload, "response.id").String()
if executor.streamCalls != 1 {
t.Fatalf("stream calls after frame 1 = %d, want 1", executor.streamCalls)
}
@@ -4994,10 +4998,10 @@ func TestResponsesWebsocketMidConnectionIncrementalPrewarmHandledLocallyForSSEUp
if errReadMessage != nil {
t.Fatalf("read frame 2 created message: %v", errReadMessage)
}
- if gjson.GetBytes(createdPayload, "type").String() != "response.created" {
- t.Fatalf("frame 2 created payload type = %s, want response.created (got: %s)", gjson.GetBytes(createdPayload, "type").String(), createdPayload)
+ if ojson.GetBytesNoCopy(createdPayload, "type").String() != "response.created" {
+ t.Fatalf("frame 2 created payload type = %s, want response.created (got: %s)", ojson.GetBytesNoCopy(createdPayload, "type").String(), createdPayload)
}
- prewarmResponseID := gjson.GetBytes(createdPayload, "response.id").String()
+ prewarmResponseID := ojson.GetBytesNoCopy(createdPayload, "response.id").String()
if prewarmResponseID == "" || !strings.HasPrefix(prewarmResponseID, "resp_prewarm_") {
t.Fatalf("frame 2 prewarm response id unexpected: %q", prewarmResponseID)
}
@@ -5009,17 +5013,17 @@ func TestResponsesWebsocketMidConnectionIncrementalPrewarmHandledLocallyForSSEUp
if errReadMessage != nil {
t.Fatalf("read frame 2 completed message: %v", errReadMessage)
}
- if gjson.GetBytes(completedPayload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("frame 2 completed payload type = %s, want %s", gjson.GetBytes(completedPayload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(completedPayload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("frame 2 completed payload type = %s, want %s", ojson.GetBytesNoCopy(completedPayload, "type").String(), wsEventTypeCompleted)
}
- if gjson.GetBytes(completedPayload, "response.id").String() != prewarmResponseID {
- t.Fatalf("frame 2 completed response id = %s, want %s", gjson.GetBytes(completedPayload, "response.id").String(), prewarmResponseID)
+ if ojson.GetBytesNoCopy(completedPayload, "response.id").String() != prewarmResponseID {
+ t.Fatalf("frame 2 completed response id = %s, want %s", ojson.GetBytesNoCopy(completedPayload, "response.id").String(), prewarmResponseID)
}
- if len(gjson.GetBytes(completedPayload, "response.output").Array()) != 0 {
- t.Fatalf("frame 2 prewarm output should be empty, got: %s", gjson.GetBytes(completedPayload, "response.output").Raw)
+ if len(ojson.GetBytesNoCopy(completedPayload, "response.output").Array()) != 0 {
+ t.Fatalf("frame 2 prewarm output should be empty, got: %s", ojson.GetBytesNoCopy(completedPayload, "response.output").Raw)
}
- if gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int() != 0 {
- t.Fatalf("frame 2 prewarm total tokens = %d, want 0", gjson.GetBytes(completedPayload, "response.usage.total_tokens").Int())
+ if ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int() != 0 {
+ t.Fatalf("frame 2 prewarm total tokens = %d, want 0", ojson.GetBytesNoCopy(completedPayload, "response.usage.total_tokens").Int())
}
// Frame 3: follow-up referencing the prewarm response id
@@ -5033,8 +5037,8 @@ func TestResponsesWebsocketMidConnectionIncrementalPrewarmHandledLocallyForSSEUp
if errReadMessage != nil {
t.Fatalf("read frame 3 response: %v", errReadMessage)
}
- if gjson.GetBytes(frame3Payload, "type").String() != wsEventTypeCompleted {
- t.Fatalf("frame 3 payload type = %s, want %s", gjson.GetBytes(frame3Payload, "type").String(), wsEventTypeCompleted)
+ if ojson.GetBytesNoCopy(frame3Payload, "type").String() != wsEventTypeCompleted {
+ t.Fatalf("frame 3 payload type = %s, want %s", ojson.GetBytesNoCopy(frame3Payload, "type").String(), wsEventTypeCompleted)
}
if executor.streamCalls != 2 {
t.Fatalf("stream calls after frame 3 = %d, want 2", executor.streamCalls)
@@ -5043,7 +5047,7 @@ func TestResponsesWebsocketMidConnectionIncrementalPrewarmHandledLocallyForSSEUp
t.Fatalf("captured upstream payloads = %d, want 2", len(executor.payloads))
}
forwarded := executor.payloads[1]
- inputItems := gjson.GetBytes(forwarded, "input").Array()
+ inputItems := ojson.GetBytesNoCopy(forwarded, "input").Array()
hasInitial := false
hasIncremental := false
hasFollowup := false
@@ -5113,7 +5117,7 @@ func TestResponsesWebsocketMergesTranscriptForNonPassthroughUpstream(t *testing.
if errReadMessage != nil {
t.Fatalf("read websocket message %d: %v", i+1, errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("message %d payload type = %s, want %s", i+1, got, wsEventTypeCompleted)
}
}
@@ -5122,10 +5126,10 @@ func TestResponsesWebsocketMergesTranscriptForNonPassthroughUpstream(t *testing.
t.Fatalf("upstream payload count = %d, want 2", len(executor.payloads))
}
secondPayload := executor.payloads[1]
- if gjson.GetBytes(secondPayload, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(secondPayload, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must not be sent on non-passthrough upstream: %s", secondPayload)
}
- input := gjson.GetBytes(secondPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(secondPayload, "input").Array()
if len(input) != 3 {
t.Fatalf("second upstream input len = %d, want 3: %s", len(input), secondPayload)
}
@@ -5186,7 +5190,7 @@ func TestResponsesWebsocketUsesObservedCompactionResponseForReplay(t *testing.T)
}
}
- input := gjson.GetBytes(executor.payloads[2], "input").Array()
+ input := ojson.GetBytesNoCopy(executor.payloads[2], "input").Array()
if len(input) != len(tc.wantIDs) {
t.Fatalf("unexpected post-compaction input: %s", executor.payloads[2])
}
@@ -5264,7 +5268,7 @@ func TestResponsesWebsocketRetainsObservedCompactionAcrossSubsequentTurns(t *tes
// Turn 4 must still bypass stale merge and keep exactly cmp-1 and turn-4-user,
// without old-user or old-assistant reappearing even after turn 3's normal assistant output.
- inputTurn4 := gjson.GetBytes(executor.payloads[3], "input").Array()
+ inputTurn4 := ojson.GetBytesNoCopy(executor.payloads[3], "input").Array()
wantIDsTurn4 := []string{"cmp-1", "turn-4-user"}
if len(inputTurn4) != len(wantIDsTurn4) {
t.Fatalf("turn 4 post-compaction input len = %d, want %d: %s", len(inputTurn4), len(wantIDsTurn4), executor.payloads[3])
@@ -5298,14 +5302,14 @@ type websocketPluginRouteExecutorHost struct {
func (h *websocketPluginRouteExecutorHost) HasModelRouters() bool { return true }
func (h *websocketPluginRouteExecutorHost) RouteModel(_ context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
- if pluginID := strings.TrimSpace(gjson.GetBytes(req.Body, "route_plugin_id").String()); pluginID != "" {
+ if pluginID := strings.TrimSpace(ojson.GetBytesNoCopy(req.Body, "route_plugin_id").String()); pluginID != "" {
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetExecutor,
Target: pluginID,
}, true
}
- if !gjson.GetBytes(req.Body, "route_to_plugin").Bool() {
+ if !ojson.GetBytesNoCopy(req.Body, "route_to_plugin").Bool() {
return pluginapi.ModelRouteResponse{}, false
}
return pluginapi.ModelRouteResponse{
@@ -5384,7 +5388,7 @@ func TestResponsesWebsocketUsesObservedCompactionResponseForPluginRouteOverride(
t.Fatalf("expected at least 3 payloads on plugin host, got %d", len(pluginHost.payloads))
}
thirdPayload := pluginHost.payloads[2]
- input := gjson.GetBytes(thirdPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(thirdPayload, "input").Array()
wantIDs := []string{"cmp-plugin-1", "new-user"}
if len(input) != len(wantIDs) {
t.Fatalf("plugin-routed post-compaction input len = %d, want %d: %s", len(input), len(wantIDs), thirdPayload)
@@ -5454,7 +5458,7 @@ func TestResponsesWebsocketPluginSwitchDoesNotInheritObservedCompaction(t *testi
t.Fatalf("expected 2 payloads, got %d", len(pluginHost.payloads))
}
secondPayload := pluginHost.payloads[1]
- input := gjson.GetBytes(secondPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(secondPayload, "input").Array()
// Target changed, so bypass is disabled and history is merged: old-user, cmp-a (from lastResponseOutput), new-user
wantIDs := []string{"old-user", "cmp-a", "new-user"}
if len(input) != len(wantIDs) {
@@ -5475,7 +5479,7 @@ type websocketCustomProviderRouteHost struct {
func (h *websocketCustomProviderRouteHost) HasModelRouters() bool { return true }
func (h *websocketCustomProviderRouteHost) RouteModel(_ context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
- if !gjson.GetBytes(req.Body, "route_to_custom").Bool() {
+ if !ojson.GetBytesNoCopy(req.Body, "route_to_custom").Bool() {
return pluginapi.ModelRouteResponse{}, false
}
return pluginapi.ModelRouteResponse{
@@ -5553,7 +5557,7 @@ func TestResponsesWebsocketProviderRoutePinsCompactionAuth(t *testing.T) {
t.Fatalf("expected 2 payloads, got %d", len(executor.payloads))
}
secondPayload := executor.payloads[1]
- input := gjson.GetBytes(secondPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(secondPayload, "input").Array()
wantIDs := []string{"cmp-prov-1", "new-user"}
if len(input) != len(wantIDs) {
t.Fatalf("provider routed replay input len = %d, want %d: %s", len(input), len(wantIDs), secondPayload)
@@ -5633,7 +5637,7 @@ func TestResponsesWebsocketSuccessiveCompactionsInSameSession(t *testing.T) {
t.Fatalf("expected 5 payloads, got %d", len(executor.payloads))
}
fifthPayload := executor.payloads[4]
- inputFifth := gjson.GetBytes(fifthPayload, "input").Array()
+ inputFifth := ojson.GetBytesNoCopy(fifthPayload, "input").Array()
wantIDsFifth := []string{"cmp-2", "turn-5-user"}
if len(inputFifth) != len(wantIDsFifth) {
t.Fatalf("second replay input len = %d, want %d: %s", len(inputFifth), len(wantIDsFifth), fifthPayload)
@@ -5724,7 +5728,7 @@ func TestResponsesWebsocketUsesObservedCompactionResponseForHomeRuntimeAuth(t *t
t.Fatalf("expected at least 3 payloads on home executor, got %d", len(executor.payloads))
}
thirdPayload := executor.payloads[2]
- input := gjson.GetBytes(thirdPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(thirdPayload, "input").Array()
wantIDs := []string{"cmp-home-1", "new-home-user"}
if len(input) != len(wantIDs) {
t.Fatalf("home runtime post-compaction input len = %d, want %d: %s", len(input), len(wantIDs), thirdPayload)
@@ -5819,7 +5823,7 @@ func TestResponsesWebsocketProviderRouteSwitchDoesNotInheritCompaction(t *testin
t.Fatalf("expected at least 1 payload on executor B, got %d", len(executorB.payloads))
}
payloadB := executorB.payloads[0]
- inputB := gjson.GetBytes(payloadB, "input").Array()
+ inputB := ojson.GetBytesNoCopy(payloadB, "input").Array()
// Target changed, so bypass is disabled and history is merged: old-user, cmp-a-1 (from lastResponseOutput), new-user
wantIDs := []string{"old-user", "cmp-a-1", "new-user"}
if len(inputB) != len(wantIDs) {
@@ -5906,7 +5910,7 @@ func TestResponsesWebsocketProviderRouteSwitchBackDoesNotInheritStaleCompaction(
t.Fatalf("expected at least 2 payloads on executor A, got %d", len(executorA.payloads))
}
payloadA2 := executorA.payloads[1]
- inputA2 := gjson.GetBytes(payloadA2, "input").Array()
+ inputA2 := ojson.GetBytesNoCopy(payloadA2, "input").Array()
// Stale compaction was cleared during turn 2, so history was merged back in!
if len(inputA2) <= 2 {
t.Fatalf("expected merged fallback input on turn 3 switchback, got %d items: %s", len(inputA2), payloadA2)
@@ -5989,7 +5993,7 @@ func TestResponsesWebsocketProviderRouteIgnoresResidualPinnedAuth(t *testing.T)
t.Fatalf("expected at least 2 payloads on routed executor, got %d", len(routedExecutor.payloads))
}
thirdPayload := routedExecutor.payloads[1]
- input := gjson.GetBytes(thirdPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(thirdPayload, "input").Array()
wantIDs := []string{"cmp-override-1", "replay-user"}
if len(input) != len(wantIDs) {
t.Fatalf("replay input len = %d, want %d: %s", len(input), len(wantIDs), thirdPayload)
@@ -6063,7 +6067,7 @@ func TestResponsesWebsocketPluginToProviderOverrideSwitch(t *testing.T) {
t.Fatalf("expected at least 1 payload on provider executor, got %d", len(provExecutor.payloads))
}
secondPayload := provExecutor.payloads[0]
- input := gjson.GetBytes(secondPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(secondPayload, "input").Array()
// Target kind changed from plugin to provider, so bypass is disabled and history is merged: old-user, cmp-plugin-1, new-user
wantIDs := []string{"old-user", "cmp-plugin-1", "new-user"}
if len(input) != len(wantIDs) {
@@ -6083,10 +6087,10 @@ type websocketMultiKindRouteHost struct {
func (h *websocketMultiKindRouteHost) HasModelRouters() bool { return true }
func (h *websocketMultiKindRouteHost) RouteModel(ctx context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
- if gjson.GetBytes(req.Body, "route_to_plugin").Bool() {
+ if ojson.GetBytesNoCopy(req.Body, "route_to_plugin").Bool() {
return h.pluginHost.RouteModel(ctx, req)
}
- if gjson.GetBytes(req.Body, "route_to_provider").Bool() {
+ if ojson.GetBytesNoCopy(req.Body, "route_to_provider").Bool() {
return pluginapi.ModelRouteResponse{
Handled: true,
TargetKind: pluginapi.ModelRouteTargetProvider,
@@ -6114,8 +6118,8 @@ type websocketDynamicProviderRouteHost struct{}
func (h *websocketDynamicProviderRouteHost) HasModelRouters() bool { return true }
func (h *websocketDynamicProviderRouteHost) RouteModel(_ context.Context, req pluginapi.ModelRouteRequest) (pluginapi.ModelRouteResponse, bool) {
- provider := strings.TrimSpace(gjson.GetBytes(req.Body, "route_provider").String())
- targetModel := strings.TrimSpace(gjson.GetBytes(req.Body, "route_target_model").String())
+ provider := strings.TrimSpace(ojson.GetBytesNoCopy(req.Body, "route_provider").String())
+ targetModel := strings.TrimSpace(ojson.GetBytesNoCopy(req.Body, "route_target_model").String())
if provider == "" {
return pluginapi.ModelRouteResponse{}, false
}
@@ -6178,7 +6182,7 @@ func TestResponsesWebsocketDoesNotInjectPreviousResponseIDWhenPendingToolOutputM
if errReadMessage != nil {
t.Fatalf("read websocket message %d: %v", i+1, errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("message %d payload type = %s, want %s", i+1, got, wsEventTypeCompleted)
}
}
@@ -6191,10 +6195,10 @@ func TestResponsesWebsocketDoesNotInjectPreviousResponseIDWhenPendingToolOutputM
t.Fatalf("upstream payload count = %d, want 2", len(payloads))
}
secondPayload := payloads[1]
- if gjson.GetBytes(secondPayload, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(secondPayload, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must not be injected when pending tool output is missing: %s", secondPayload)
}
- input := gjson.GetBytes(secondPayload, "input").Array()
+ input := ojson.GetBytesNoCopy(secondPayload, "input").Array()
if len(input) != 3 {
t.Fatalf("second upstream input len = %d, want 3: %s", len(input), secondPayload)
}
@@ -6259,7 +6263,7 @@ func TestResponsesWebsocketStripsGenerateWhenWebsocketAttemptFallsBackToHTTP(t *
if errReadMessage != nil {
t.Fatalf("read websocket message: %v", errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("payload type = %s, want %s: %s", got, wsEventTypeCompleted, payload)
}
@@ -6271,7 +6275,7 @@ func TestResponsesWebsocketStripsGenerateWhenWebsocketAttemptFallsBackToHTTP(t *
if len(wsPayloads) != 1 {
t.Fatalf("auth-ws payload count = %d, want 1", len(wsPayloads))
}
- if !gjson.GetBytes(wsPayloads[0], "generate").Exists() {
+ if !ojson.GetBytesNoCopy(wsPayloads[0], "generate").Exists() {
t.Fatalf("websocket attempt payload unexpectedly stripped generate: %s", wsPayloads[0])
}
@@ -6279,7 +6283,7 @@ func TestResponsesWebsocketStripsGenerateWhenWebsocketAttemptFallsBackToHTTP(t *
if len(httpPayloads) != 1 {
t.Fatalf("auth-http payload count = %d, want 1", len(httpPayloads))
}
- if gjson.GetBytes(httpPayloads[0], "generate").Exists() {
+ if ojson.GetBytesNoCopy(httpPayloads[0], "generate").Exists() {
t.Fatalf("generate leaked after HTTP fallback: %s", httpPayloads[0])
}
@@ -6291,7 +6295,7 @@ func TestResponsesWebsocketStripsGenerateWhenWebsocketAttemptFallsBackToHTTP(t *
if errReadSecond != nil {
t.Fatalf("read second websocket message: %v", errReadSecond)
}
- if got := gjson.GetBytes(secondPayload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(secondPayload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("second payload type = %s, want %s: %s", got, wsEventTypeCompleted, secondPayload)
}
if got := executor.AuthIDs(); len(got) != 3 || got[2] != "auth-http" {
@@ -6384,7 +6388,7 @@ func TestResponsesWebsocketPinsOnlyWebsocketCapableAuth(t *testing.T) {
if errReadMessage != nil {
t.Fatalf("read websocket message %d: %v", i+1, errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("message %d payload type = %s, want %s", i+1, got, wsEventTypeCompleted)
}
}
@@ -6457,13 +6461,13 @@ func TestResponsesWebsocketUsesNativeIncrementalAfterPinningWebsocketAuthFromMix
if len(payloads) != 3 {
t.Fatalf("payload count = %d, want 3", len(payloads))
}
- if gjson.GetBytes(payloads[1], "previous_response_id").Exists() || len(gjson.GetBytes(payloads[1], "input").Array()) != 3 {
+ if ojson.GetBytesNoCopy(payloads[1], "previous_response_id").Exists() || len(ojson.GetBytesNoCopy(payloads[1], "input").Array()) != 3 {
t.Fatalf("first request on newly selected websocket auth must be canonical: %s", payloads[1])
}
- if got := gjson.GetBytes(payloads[2], "previous_response_id").String(); got != "resp-2" {
+ if got := ojson.GetBytesNoCopy(payloads[2], "previous_response_id").String(); got != "resp-2" {
t.Fatalf("stable pinned websocket previous_response_id = %q, want resp-2: %s", got, payloads[2])
}
- input := gjson.GetBytes(payloads[2], "input").Array()
+ input := ojson.GetBytesNoCopy(payloads[2], "input").Array()
if len(input) != 1 || input[0].Get("id").String() != "msg-3" {
t.Fatalf("stable pinned websocket request is not incremental: %s", payloads[2])
}
@@ -6535,7 +6539,7 @@ func TestResponsesWebsocketReplaysImmediatelyAfterPinnedAuthFailure(t *testing.T
if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(firstRequest)); errWrite != nil {
t.Fatalf("write first websocket message: %v", errWrite)
}
- if _, payload, errRead := conn.ReadMessage(); errRead != nil || gjson.GetBytes(payload, "type").String() != wsEventTypeCompleted {
+ if _, payload, errRead := conn.ReadMessage(); errRead != nil || ojson.GetBytesNoCopy(payload, "type").String() != wsEventTypeCompleted {
t.Fatalf("first websocket response = %s, err=%v", payload, errRead)
}
@@ -6561,7 +6565,7 @@ func TestResponsesWebsocketReplaysImmediatelyAfterPinnedAuthFailure(t *testing.T
if errWrite := replayConn.WriteMessage(websocket.TextMessage, []byte(fullReplay)); errWrite != nil {
t.Fatalf("write full replay: %v", errWrite)
}
- if _, replayPayload, errReadReplay := replayConn.ReadMessage(); errReadReplay != nil || gjson.GetBytes(replayPayload, "type").String() != wsEventTypeCompleted {
+ if _, replayPayload, errReadReplay := replayConn.ReadMessage(); errReadReplay != nil || ojson.GetBytesNoCopy(replayPayload, "type").String() != wsEventTypeCompleted {
t.Fatalf("full replay response = %s, err=%v", replayPayload, errReadReplay)
}
if got := executor.AuthIDs(); len(got) != 3 || got[2] != "auth-b" {
@@ -6572,7 +6576,7 @@ func TestResponsesWebsocketReplaysImmediatelyAfterPinnedAuthFailure(t *testing.T
t.Fatalf("auth-b payloads = %d, want 1", len(authBPayloads))
}
authBPayload := authBPayloads[0]
- if gjson.GetBytes(authBPayload, "previous_response_id").Exists() || len(gjson.GetBytes(authBPayload, "input").Array()) != 3 {
+ if ojson.GetBytesNoCopy(authBPayload, "previous_response_id").Exists() || len(ojson.GetBytesNoCopy(authBPayload, "input").Array()) != 3 {
t.Fatalf("auth-b did not receive full replay: %s", authBPayload)
}
})
@@ -6753,7 +6757,7 @@ func TestResponsesWebsocketReleasesPinnedAuthAfterStreamClosed408(t *testing.T)
if errWrite := conn.WriteMessage(websocket.TextMessage, []byte(firstRequest)); errWrite != nil {
t.Fatalf("write first websocket message: %v", errWrite)
}
- if _, payload, errRead := conn.ReadMessage(); errRead != nil || gjson.GetBytes(payload, "type").String() != wsEventTypeCompleted {
+ if _, payload, errRead := conn.ReadMessage(); errRead != nil || ojson.GetBytesNoCopy(payload, "type").String() != wsEventTypeCompleted {
t.Fatalf("first websocket response = %s, err=%v", payload, errRead)
}
@@ -6766,7 +6770,7 @@ func TestResponsesWebsocketReleasesPinnedAuthAfterStreamClosed408(t *testing.T)
if errRead != nil {
break
}
- if gjson.GetBytes(payload, "type").String() == wsEventTypeError {
+ if ojson.GetBytesNoCopy(payload, "type").String() == wsEventTypeError {
t.Fatalf("stream transport failure was exposed to the client: %s", payload)
}
}
@@ -6783,7 +6787,7 @@ func TestResponsesWebsocketReleasesPinnedAuthAfterStreamClosed408(t *testing.T)
if errWrite := replayConn.WriteMessage(websocket.TextMessage, []byte(fullReplay)); errWrite != nil {
t.Fatalf("write full replay: %v", errWrite)
}
- if _, replayResponse, errReadReplay := replayConn.ReadMessage(); errReadReplay != nil || gjson.GetBytes(replayResponse, "type").String() != wsEventTypeCompleted {
+ if _, replayResponse, errReadReplay := replayConn.ReadMessage(); errReadReplay != nil || ojson.GetBytesNoCopy(replayResponse, "type").String() != wsEventTypeCompleted {
t.Fatalf("full replay response = %s, err=%v", replayResponse, errReadReplay)
}
authIDs := executor.AuthIDs()
@@ -6793,7 +6797,7 @@ func TestResponsesWebsocketReleasesPinnedAuthAfterStreamClosed408(t *testing.T)
replayAuthID := authIDs[2]
replayPayloads := executor.Payloads(replayAuthID)
replayPayload := replayPayloads[len(replayPayloads)-1]
- if gjson.GetBytes(replayPayload, "previous_response_id").Exists() || len(gjson.GetBytes(replayPayload, "input").Array()) != 3 {
+ if ojson.GetBytesNoCopy(replayPayload, "previous_response_id").Exists() || len(ojson.GetBytesNoCopy(replayPayload, "input").Array()) != 3 {
t.Fatalf("replay auth %s did not receive full replay: %s", replayAuthID, replayPayload)
}
}
@@ -6809,10 +6813,10 @@ func TestNormalizeResponsesWebsocketRequestTreatsTranscriptReplacementAsReset(t
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must not exist in transcript replacement mode")
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(items) != 2 {
t.Fatalf("replacement input len = %d, want 2: %s", len(items), normalized)
}
@@ -6835,7 +6839,7 @@ func TestNormalizeResponsesWebsocketRequestDoesNotTreatDeveloperMessageAsReplace
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(items) != 4 {
t.Fatalf("merged input len = %d, want 4: %s", len(items), normalized)
}
@@ -6862,7 +6866,7 @@ func TestNormalizeResponsesWebsocketRequestDropsDuplicateFunctionCallsByCallID(t
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(items) != 3 {
t.Fatalf("merged input len = %d, want 3: %s", len(items), normalized)
}
@@ -6885,7 +6889,7 @@ func TestNormalizeResponsesWebsocketRequestDropsDuplicateInputItemsByID(t *testi
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(items) != 3 {
t.Fatalf("merged input len = %d, want 3: %s", len(items), normalized)
}
@@ -6908,10 +6912,10 @@ func TestNormalizeResponsesWebsocketRequestTreatsCustomToolTranscriptReplacement
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if gjson.GetBytes(normalized, "previous_response_id").Exists() {
+ if ojson.GetBytesNoCopy(normalized, "previous_response_id").Exists() {
t.Fatalf("previous_response_id must not exist in transcript replacement mode")
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(items) != 3 {
t.Fatalf("replacement input len = %d, want 3: %s", len(items), normalized)
}
@@ -6937,7 +6941,7 @@ func TestNormalizeResponsesWebsocketRequestDropsDuplicateCustomToolCallsByCallID
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- items := gjson.GetBytes(normalized, "input").Array()
+ items := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(items) != 3 {
t.Fatalf("merged input len = %d, want 3: %s", len(items), normalized)
}
@@ -6953,7 +6957,7 @@ func TestDedupeResponsesWebsocketInputItemsByIDAfterRepair(t *testing.T) {
deduped := dedupeResponsesWebsocketInputItemsByID(payload)
- items := gjson.GetBytes(deduped, "input").Array()
+ items := ojson.GetBytesNoCopy(deduped, "input").Array()
if len(items) != 2 {
t.Fatalf("deduped input len = %d, want 2: %s", len(items), deduped)
}
@@ -6975,7 +6979,7 @@ func TestDedupeResponsesWebsocketInputItemsByIDKeepsReferencedToolCall(t *testin
deduped := dedupeResponsesWebsocketInputItemsByID(payload)
- items := gjson.GetBytes(deduped, "input").Array()
+ items := ojson.GetBytesNoCopy(deduped, "input").Array()
if len(items) != 2 {
t.Fatalf("deduped input len = %d, want 2: %s", len(items), deduped)
}
@@ -7034,7 +7038,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplac
if errReadMessage != nil {
t.Fatalf("read websocket message %d: %v", i+1, errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("message %d payload type = %s, want %s", i+1, got, wsEventTypeCompleted)
}
}
@@ -7062,7 +7066,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplac
if errReadMessage != nil {
t.Fatalf("read post-compact websocket message: %v", errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("post-compact payload type = %s, want %s", got, wsEventTypeCompleted)
}
@@ -7077,7 +7081,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnCustomToolTranscriptReplac
}
merged := executor.streamPayloads[2]
- items := gjson.GetBytes(merged, "input").Array()
+ items := ojson.GetBytesNoCopy(merged, "input").Array()
if len(items) != 3 {
t.Fatalf("merged input len = %d, want 3: %s", len(items), merged)
}
@@ -7138,7 +7142,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t
if errReadMessage != nil {
t.Fatalf("read websocket message %d: %v", i+1, errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("message %d payload type = %s, want %s", i+1, got, wsEventTypeCompleted)
}
}
@@ -7168,7 +7172,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t
if errReadMessage != nil {
t.Fatalf("read post-compact websocket message: %v", errReadMessage)
}
- if got := gjson.GetBytes(payload, "type").String(); got != wsEventTypeCompleted {
+ if got := ojson.GetBytesNoCopy(payload, "type").String(); got != wsEventTypeCompleted {
t.Fatalf("post-compact payload type = %s, want %s", got, wsEventTypeCompleted)
}
@@ -7183,7 +7187,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t
}
merged := executor.streamPayloads[2]
- items := gjson.GetBytes(merged, "input").Array()
+ items := ojson.GetBytesNoCopy(merged, "input").Array()
if len(items) != 2 {
t.Fatalf("merged input len = %d, want 2: %s", len(items), merged)
}
@@ -7197,7 +7201,7 @@ func TestResponsesWebsocketCompactionResetsTurnStateOnTranscriptReplacement(t *t
}
func TestInputContainsFullTranscriptFalseForAssistantMessageOnly(t *testing.T) {
- input := gjson.Parse(`[
+ input := ojson.Parse(`[
{"type":"message","role":"user","content":"hello"},
{"type":"message","role":"assistant","content":"hi there"}
]`)
@@ -7208,7 +7212,7 @@ func TestInputContainsFullTranscriptFalseForAssistantMessageOnly(t *testing.T) {
func TestInputContainsFullTranscriptDetectsCompactionItem(t *testing.T) {
for _, typ := range []string{"compaction", "compaction_summary"} {
- input := gjson.Parse(`[{"type":"message","role":"user","content":"hello"},{"type":"` + typ + `","encrypted_content":"summary"}]`)
+ input := ojson.Parse(`[{"type":"message","role":"user","content":"hello"},{"type":"` + typ + `","encrypted_content":"summary"}]`)
if !inputContainsFullTranscript(input) {
t.Fatalf("expected full transcript for type=%s", typ)
}
@@ -7222,7 +7226,7 @@ func TestInputContainsFullTranscriptFalseForIncremental(t *testing.T) {
`[{"type":"message","role":"user","content":"next question"}]`,
`[]`,
} {
- if inputContainsFullTranscript(gjson.Parse(raw)) {
+ if inputContainsFullTranscript(ojson.Parse(raw)) {
t.Fatalf("incremental input must not be detected as full transcript: %s", raw)
}
}
@@ -7252,7 +7256,7 @@ func TestNormalizeSubsequentRequestCompactSkipsMerge(t *testing.T) {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 2 {
t.Fatalf("input len = %d, want 2 (compacted only); stale state was not skipped", len(input))
}
@@ -7279,10 +7283,10 @@ func TestNormalizeSubsequentRequestReasoningContinuationWithPreviousResponseID(t
if errMsg != nil {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- if got := gjson.GetBytes(normalized, "previous_response_id").String(); got != "resp-1" {
+ if got := ojson.GetBytesNoCopy(normalized, "previous_response_id").String(); got != "resp-1" {
t.Fatalf("previous_response_id = %q, want resp-1; payload=%s", got, normalized)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 2 || input[0].Get("id").String() != "reasoning-1" || input[1].Get("id").String() != "output-1" {
t.Fatalf("incremental continuation was replaced or merged: %s", normalized)
}
@@ -7306,7 +7310,7 @@ func TestResponsesWebsocketOutputCollectorRestoresCompletedOutput(t *testing.T)
outputItemsByIndex,
outputItemsFallback,
)
- items := gjson.ParseBytes(output).Array()
+ items := ojson.GetBytesNoCopy(output, "@this").Array()
if len(items) != 3 {
t.Fatalf("collected output len = %d, want 3: %s", len(items), output)
}
@@ -7339,7 +7343,7 @@ func TestNormalizeSubsequentRequestCompactMergesWhenCompactionReplayUnsupported(
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 7 {
t.Fatalf("input len = %d, want 7 (merged fallback without compaction items)", len(input))
}
@@ -7384,7 +7388,7 @@ func TestNormalizeSubsequentRequestDropsConsumedCompactionTrigger(t *testing.T)
if errMsg != nil {
t.Fatalf("normalize compact replay: %v", errMsg.Error)
}
- for _, item := range gjson.GetBytes(normalized, "input").Array() {
+ for _, item := range ojson.GetBytesNoCopy(normalized, "input").Array() {
if item.Get("type").String() == "compaction_trigger" {
t.Fatalf("consumed compaction_trigger was replayed: %s", normalized)
}
@@ -7409,7 +7413,7 @@ func TestNormalizeSubsequentRequestIncrementalInputStillMerges(t *testing.T) {
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
// Should be merged: msg-1 + msg-2 + fc-1 + fco-1 = 4 items
if len(input) != 4 {
@@ -7443,7 +7447,7 @@ func TestNormalizeSubsequentRequestAssistantInputTriggersTranscriptReplacement(t
t.Fatalf("unexpected error: %v", errMsg.Error)
}
- input := gjson.GetBytes(normalized, "input").Array()
+ input := ojson.GetBytesNoCopy(normalized, "input").Array()
if len(input) != 1 {
t.Fatalf("input len = %d, want 1 (transcript replacement, not merge)", len(input))
}
@@ -7809,7 +7813,7 @@ func (p *revocableWebsocketAccessProvider) Authenticate(_ context.Context, r *ht
if p.revoked.Load() {
return nil, sdkaccess.NewDeniedError("key revoked", http.StatusForbidden)
}
- if p.deniedModel != "" && strings.TrimSpace(gjson.GetBytes(body, "model").String()) == p.deniedModel {
+ if p.deniedModel != "" && strings.TrimSpace(ojson.GetBytesNoCopy(body, "model").String()) == p.deniedModel {
return nil, sdkaccess.NewDeniedError("quota exhausted for model", http.StatusTooManyRequests)
}
principal := "key-1"
@@ -7917,7 +7921,7 @@ func TestResponsesWebsocketReauthorizesEachTurnAndKeepsSocketOpen(t *testing.T)
turn1Payload := `{"type":"response.create","model":"test-model","input":[{"type":"message","role":"user","content":"hi"}]}`
msg1 := sendTurn(turn1Payload)
- if got := gjson.GetBytes(msg1, "type").String(); got != "response.completed" {
+ if got := ojson.GetBytesNoCopy(msg1, "type").String(); got != "response.completed" {
t.Fatalf("turn 1 type = %q, want response.completed (%s)", got, msg1)
}
@@ -7930,10 +7934,10 @@ func TestResponsesWebsocketReauthorizesEachTurnAndKeepsSocketOpen(t *testing.T)
`{"type":"response.create","input":[{"type":"message","role":"user","content":"missing model"}]}`,
} {
msgDeniedInherited := sendTurn(rawContinuation)
- if got := gjson.GetBytes(msgDeniedInherited, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(msgDeniedInherited, "type").String(); got != "error" {
t.Fatalf("inherited model turn (%s) type = %q, want error (%s)", rawContinuation, got, msgDeniedInherited)
}
- if got := int(gjson.GetBytes(msgDeniedInherited, "status").Int()); got != http.StatusTooManyRequests {
+ if got := int(ojson.GetBytesNoCopy(msgDeniedInherited, "status").Int()); got != http.StatusTooManyRequests {
t.Fatalf("inherited model turn (%s) status = %d, want %d (%s)", rawContinuation, got, http.StatusTooManyRequests, msgDeniedInherited)
}
if got := len(executor.Payloads()); got != 1 {
@@ -7949,13 +7953,13 @@ func TestResponsesWebsocketReauthorizesEachTurnAndKeepsSocketOpen(t *testing.T)
// Revoked key on already open socket must fail with 403 error event and not execute.
msg2 := sendTurn(turn1Payload)
- if got := gjson.GetBytes(msg2, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(msg2, "type").String(); got != "error" {
t.Fatalf("turn 2 after revoke type = %q, want error (%s)", got, msg2)
}
- if got := int(gjson.GetBytes(msg2, "status").Int()); got != http.StatusForbidden {
+ if got := int(ojson.GetBytesNoCopy(msg2, "status").Int()); got != http.StatusForbidden {
t.Fatalf("turn 2 status = %d, want %d (%s)", got, http.StatusForbidden, msg2)
}
- if got := gjson.GetBytes(msg2, "error.message").String(); got != "key revoked" {
+ if got := ojson.GetBytesNoCopy(msg2, "error.message").String(); got != "key revoked" {
t.Fatalf("turn 2 error.message = %q, want %q (%s)", got, "key revoked", msg2)
}
if got := len(executor.Payloads()); got != 1 {
@@ -7965,13 +7969,13 @@ func TestResponsesWebsocketReauthorizesEachTurnAndKeepsSocketOpen(t *testing.T)
// Un-revoke key, then send a turn for a model denied with 429: must return 429 error event and keep socket open.
provider.revoked.Store(false)
msg3 := sendTurn(`{"type":"response.create","model":"quota-blocked-model","input":[{"type":"message","role":"user","content":"blocked"}]}`)
- if got := gjson.GetBytes(msg3, "type").String(); got != "error" {
+ if got := ojson.GetBytesNoCopy(msg3, "type").String(); got != "error" {
t.Fatalf("turn 3 model-denied type = %q, want error (%s)", got, msg3)
}
- if got := int(gjson.GetBytes(msg3, "status").Int()); got != http.StatusTooManyRequests {
+ if got := int(ojson.GetBytesNoCopy(msg3, "status").Int()); got != http.StatusTooManyRequests {
t.Fatalf("turn 3 status = %d, want %d (%s)", got, http.StatusTooManyRequests, msg3)
}
- if got := gjson.GetBytes(msg3, "error.message").String(); got != "quota exhausted for model" {
+ if got := ojson.GetBytesNoCopy(msg3, "error.message").String(); got != "quota exhausted for model" {
t.Fatalf("turn 3 error.message = %q, want %q (%s)", got, "quota exhausted for model", msg3)
}
if got := len(executor.Payloads()); got != 1 {
@@ -7980,7 +7984,7 @@ func TestResponsesWebsocketReauthorizesEachTurnAndKeepsSocketOpen(t *testing.T)
// Subsequent turn on the same open socket with allowed model succeeds.
msg4 := sendTurn(`{"type":"response.create","model":"test-model","input":[{"type":"message","role":"user","content":"allowed again"}]}`)
- if got := gjson.GetBytes(msg4, "type").String(); got != "response.completed" {
+ if got := ojson.GetBytesNoCopy(msg4, "type").String(); got != "response.completed" {
t.Fatalf("turn 4 type = %q, want response.completed (%s)", got, msg4)
}
if got := len(executor.Payloads()); got != 2 {
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_timeline.go b/sdk/api/handlers/openai/openai_responses_websocket_timeline.go
index 70ee202eec8..ef2fc843ab8 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_timeline.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_timeline.go
@@ -9,12 +9,12 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/interfaces"
requestlogging "github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
)
type websocketTimelineAppender interface {
@@ -214,7 +214,7 @@ func appendWebsocketEvent(builder *strings.Builder, eventType string, payload []
}
func websocketPayloadEventType(payload []byte) string {
- eventType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
+ eventType := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "type").String())
if eventType == "" {
return "-"
}
@@ -255,9 +255,9 @@ func (e *responsesWebsocketPayloadError) StatusCode() int {
}
func responsesWebsocketErrorMessageFromPayload(payload []byte) *interfaces.ErrorMessage {
- status := int(gjson.GetBytes(payload, "status").Int())
+ status := int(ojson.GetBytesNoCopy(payload, "status").Int())
if status <= 0 {
- status = int(gjson.GetBytes(payload, "status_code").Int())
+ status = int(ojson.GetBytesNoCopy(payload, "status_code").Int())
}
if status <= 0 {
status = http.StatusInternalServerError
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
index 47de9d341c5..77aff3c6543 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_toolcall_repair.go
@@ -8,8 +8,8 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
- "github.com/tidwall/gjson"
)
const (
@@ -83,6 +83,9 @@ func (c *websocketToolOutputCache) record(sessionKey string, callID string, item
if _, exists := session.outputs[callID]; !exists {
session.order = append(session.order, callID)
}
+ // Do not sanitize here: client-originated items also reach this method
+ // (repair first pass, turn commit) and must round-trip unchanged.
+ // Upstream items are sanitized at their recording entry points.
session.outputs[callID] = append(json.RawMessage(nil), item...)
for len(session.order) > c.maxPerSession {
@@ -154,7 +157,7 @@ func websocketDownstreamSessionKey(req *http.Request) string {
return requestID
}
if raw := strings.TrimSpace(req.Header.Get("X-Codex-Turn-Metadata")); raw != "" {
- if sessionID := strings.TrimSpace(gjson.Get(raw, "session_id").String()); sessionID != "" {
+ if sessionID := strings.TrimSpace(ojson.Get(raw, "session_id").String()); sessionID != "" {
return sessionID
}
}
@@ -254,7 +257,7 @@ func (t *responsesWebsocketToolCacheTurn) recordResponse(payload []byte) {
if !output.Exists() || !output.IsArray() {
return
}
- output.ForEach(func(_, item gjson.Result) bool {
+ output.ForEach(func(_, item ojson.Result) bool {
if isCompleteResponsesWebsocketToolCall(item) {
t.recordItem(payload, item)
}
@@ -268,7 +271,7 @@ func (t *responsesWebsocketToolCacheTurn) recordResponse(payload []byte) {
}
}
-func (t *responsesWebsocketToolCacheTurn) recordItem(payload []byte, item gjson.Result) {
+func (t *responsesWebsocketToolCacheTurn) recordItem(payload []byte, item ojson.Result) {
if t == nil || !item.Exists() {
return
}
@@ -276,6 +279,11 @@ func (t *responsesWebsocketToolCacheTurn) recordItem(payload []byte, item gjson.
if !ok {
return
}
+ if rawItem != nil {
+ // Upstream response items are sanitized once here; recordRawItem
+ // still owns the single detached copy for turn storage.
+ rawItem = responsesWebsocketReplaySanitizedBytes(rawItem)
+ }
t.recordRawItem(item.Get("type").String(), item.Get("call_id").String(), rawItem)
}
@@ -406,20 +414,20 @@ func repairResponsesWebsocketToolCallsWithCachesMode(
return updated
}
-func parseResponsesWebsocketRepairRequest(payload []byte) (gjson.Result, json.RawMessage, bool) {
+func parseResponsesWebsocketRepairRequest(payload []byte) (ojson.Result, json.RawMessage, bool) {
if !json.Valid(payload) {
- return gjson.Result{}, nil, false
+ return ojson.Result{}, nil, false
}
root := util.ParseGJSONBytesNoCopy(payload)
if !root.IsObject() {
- return gjson.Result{}, nil, false
+ return ojson.Result{}, nil, false
}
- var input gjson.Result
+ var input ojson.Result
var previousResponseID json.RawMessage
inputFound := false
valid := true
- root.ForEach(func(key, value gjson.Result) bool {
+ root.ForEach(func(key, value ojson.Result) bool {
switch {
case strings.EqualFold(key.String(), "input"):
if !value.IsArray() && strings.TrimSpace(value.Raw) != "null" {
@@ -439,12 +447,12 @@ func parseResponsesWebsocketRepairRequest(payload []byte) (gjson.Result, json.Ra
return true
})
if !valid || !inputFound || !input.IsArray() {
- return gjson.Result{}, nil, false
+ return ojson.Result{}, nil, false
}
return input, previousResponseID, true
}
-func replaceResponsesWebsocketRawResult(payload []byte, result gjson.Result, replacement []byte) ([]byte, bool) {
+func replaceResponsesWebsocketRawResult(payload []byte, result ojson.Result, replacement []byte) ([]byte, bool) {
if result.Index < 0 || result.Index > len(payload) || len(result.Raw) > len(payload)-result.Index {
return nil, false
}
@@ -455,11 +463,11 @@ func replaceResponsesWebsocketRawResult(payload []byte, result gjson.Result, rep
return updated, true
}
-func parseResponsesWebsocketInputItemsNoCopy(payload []byte, input gjson.Result) ([]responsesWebsocketInputItem, []json.RawMessage, bool) {
+func parseResponsesWebsocketInputItemsNoCopy(payload []byte, input ojson.Result) ([]responsesWebsocketInputItem, []json.RawMessage, bool) {
var items []responsesWebsocketInputItem
var rawItems []json.RawMessage
valid := true
- input.ForEach(func(_, itemResult gjson.Result) bool {
+ input.ForEach(func(_, itemResult ojson.Result) bool {
rawItem, ok := responsesWebsocketRawMessageForResult(payload, itemResult)
if !ok {
valid = false
@@ -480,7 +488,7 @@ func parseResponsesWebsocketInputItemsNoCopy(payload []byte, input gjson.Result)
return items, rawItems, true
}
-func responsesWebsocketRawMessageForResult(payload []byte, result gjson.Result) (json.RawMessage, bool) {
+func responsesWebsocketRawMessageForResult(payload []byte, result ojson.Result) (json.RawMessage, bool) {
if result.Index < 0 || result.Index > len(payload) || len(result.Raw) > len(payload)-result.Index {
return nil, false
}
@@ -534,8 +542,8 @@ func repairResponsesToolCallItems(
if item.callID == "" {
// Codex sends standalone named results for heartbeat and delegation
// input. These intentionally have no preceding call or call_id.
- name := gjson.GetBytes(item.raw, "name")
- if item.itemType == "function_call_output" && name.Type == gjson.String && strings.TrimSpace(name.String()) != "" {
+ name := ojson.GetBytesNoCopy(item.raw, "name")
+ if item.itemType == "function_call_output" && name.Type == ojson.String && strings.TrimSpace(name.String()) != "" {
filtered = append(filtered, item)
}
continue
@@ -635,7 +643,7 @@ func recordResponsesWebsocketToolCallsFromPayloadWithCache(cache *websocketToolO
if !output.Exists() || !output.IsArray() {
return
}
- output.ForEach(func(_, item gjson.Result) bool {
+ output.ForEach(func(_, item ojson.Result) bool {
if !isCompleteResponsesWebsocketToolCall(item) {
return true
}
@@ -643,6 +651,7 @@ func recordResponsesWebsocketToolCallsFromPayloadWithCache(cache *websocketToolO
if !ok {
return false
}
+ rawItem = responsesWebsocketReplaySanitizedBytes(rawItem)
callID := strings.TrimSpace(item.Get("call_id").String())
cache.record(sessionKey, callID, rawItem)
return true
@@ -656,6 +665,7 @@ func recordResponsesWebsocketToolCallsFromPayloadWithCache(cache *websocketToolO
if !ok {
return
}
+ rawItem = responsesWebsocketReplaySanitizedBytes(rawItem)
callID := strings.TrimSpace(item.Get("call_id").String())
cache.record(sessionKey, callID, rawItem)
}
diff --git a/sdk/api/handlers/openai/openai_responses_websocket_unknown_interrupt_test.go b/sdk/api/handlers/openai/openai_responses_websocket_unknown_interrupt_test.go
index 3df1f554c42..847092614b0 100644
--- a/sdk/api/handlers/openai/openai_responses_websocket_unknown_interrupt_test.go
+++ b/sdk/api/handlers/openai/openai_responses_websocket_unknown_interrupt_test.go
@@ -10,6 +10,7 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
@@ -17,7 +18,6 @@ import (
runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
- "github.com/tidwall/gjson"
)
// An unknown interrupt while a websocket response is running belongs to the
@@ -52,7 +52,7 @@ func TestResponsesUnknownInterruptDoesNotCancelWebsocketTurn(t *testing.T) {
if errRead != nil {
return
}
- if gjson.GetBytes(create, "type").String() != "response.create" {
+ if ojson.GetBytesNoCopy(create, "type").String() != "response.create" {
t.Errorf("expected response.create, got %s", create)
return
}
@@ -68,7 +68,7 @@ func TestResponsesUnknownInterruptDoesNotCancelWebsocketTurn(t *testing.T) {
if errInterrupt != nil {
return
}
- if gjson.GetBytes(interrupt, "response_id").String() != "r1" {
+ if ojson.GetBytesNoCopy(interrupt, "response_id").String() != "r1" {
t.Errorf("upstream received unexpected frame: %s", interrupt)
return
}
@@ -128,7 +128,7 @@ func TestResponsesUnknownInterruptDoesNotCancelWebsocketTurn(t *testing.T) {
if errRead != nil {
t.Fatal(errRead)
}
- if gjson.GetBytes(payload, "type").String() != event || gjson.GetBytes(payload, "response.id").String() != id {
+ if ojson.GetBytesNoCopy(payload, "type").String() != event || ojson.GetBytesNoCopy(payload, "response.id").String() != id {
t.Fatalf("expected %s for %s, got %s", event, id, payload)
}
}
diff --git a/sdk/api/handlers/openai/openai_speech_handlers.go b/sdk/api/handlers/openai/openai_speech_handlers.go
index 5975f6f8207..b9c3ecb69a6 100644
--- a/sdk/api/handlers/openai/openai_speech_handlers.go
+++ b/sdk/api/handlers/openai/openai_speech_handlers.go
@@ -9,10 +9,10 @@ import (
"strings"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
- "github.com/tidwall/gjson"
)
const (
@@ -60,7 +60,7 @@ func (h *OpenAIAPIHandler) handleXAISpeech(c *gin.Context) {
return
}
- requested := strings.TrimSpace(gjson.GetBytes(raw, "model").String())
+ requested := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(raw, "model").String()))
model, ok := speechRoutingModel(requested)
if !ok {
writeSpeechError(c, http.StatusBadRequest, fmt.Sprintf("Model %s is not supported on /v1/audio/speech. Use %s.", requested, defaultXAISpeechModel))
@@ -128,12 +128,14 @@ func speechModelBase(model string) string {
}
func buildXAISpeechPayload(raw []byte) ([]byte, string, error) {
- if !gjson.ValidBytes(raw) {
+ if !ojson.ValidBytes(raw) {
return nil, "", errors.New("body must be valid JSON")
}
- text := strings.TrimSpace(gjson.GetBytes(raw, "input").String())
+ var fieldsBuf [6]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], raw, "input", "text", "voice", "voice_id", "language", "speed")
+ text := strings.TrimSpace(fields[0].String())
if text == "" {
- text = strings.TrimSpace(gjson.GetBytes(raw, "text").String())
+ text = strings.TrimSpace(fields[1].String())
}
if text == "" {
return nil, "", errors.New("input is required")
@@ -142,11 +144,11 @@ func buildXAISpeechPayload(raw []byte) ([]byte, string, error) {
return nil, "", errors.New("input is longer than 60000 characters")
}
- voice := strings.TrimSpace(gjson.GetBytes(raw, "voice").String())
+ voice := strings.TrimSpace(fields[2].String())
if voice == "" {
- voice = gjson.GetBytes(raw, "voice_id").String()
+ voice = fields[3].String()
}
- language := strings.TrimSpace(gjson.GetBytes(raw, "language").String())
+ language := strings.TrimSpace(fields[4].String())
if language == "" {
language = "auto"
}
@@ -155,7 +157,7 @@ func buildXAISpeechPayload(raw []byte) ([]byte, string, error) {
"voice_id": mapXAISpeechVoice(voice),
"language": language,
}
- if speed := gjson.GetBytes(raw, "speed"); speed.Exists() && speed.Type == gjson.Number && speed.Float() > 0 {
+ if speed := fields[5]; speed.Exists() && speed.Type == ojson.Number && speed.Float() > 0 {
body["speed"] = speed.Float()
}
format, output, err := speechOutputFormat(raw)
@@ -180,16 +182,18 @@ func mapXAISpeechVoice(voice string) string {
if mapped, ok := openAISpeechVoices[voice]; ok {
return mapped
}
- return voice
+ return strings.Clone(voice)
}
func speechOutputFormat(raw []byte) (string, map[string]any, error) {
- responseFormat := strings.ToLower(strings.TrimSpace(gjson.GetBytes(raw, "response_format").String()))
+ var fieldsBuf [2]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], raw, "response_format", "output_format")
+ responseFormat := strings.ToLower(strings.TrimSpace(fields[0].String()))
if responseFormat != "" {
format, output, err := speechCodecFormat(responseFormat, defaultXAISpeechSampleRate)
return format, output, err
}
- native := gjson.GetBytes(raw, "output_format")
+ native := fields[1]
if !native.Exists() {
return "mp3", nil, nil
}
@@ -198,7 +202,7 @@ func speechOutputFormat(raw []byte) (string, map[string]any, error) {
}
codec := strings.ToLower(strings.TrimSpace(native.Get("codec").String()))
sampleRate := defaultXAISpeechSampleRate
- if rate := native.Get("sample_rate"); rate.Exists() && rate.Type == gjson.Number && rate.Float() > 0 {
+ if rate := native.Get("sample_rate"); rate.Exists() && rate.Type == ojson.Number && rate.Float() > 0 {
sampleRate = int(rate.Float())
}
return speechCodecFormat(codec, sampleRate)
@@ -208,11 +212,16 @@ func speechCodecFormat(codec string, sampleRate int) (string, map[string]any, er
switch codec {
case "", "mp3":
return "mp3", nil, nil
- case "wav", "pcm":
+ case "wav":
+ if sampleRate <= 0 {
+ sampleRate = defaultXAISpeechSampleRate
+ }
+ return "wav", map[string]any{"codec": "wav", "sample_rate": sampleRate}, nil
+ case "pcm":
if sampleRate <= 0 {
sampleRate = defaultXAISpeechSampleRate
}
- return codec, map[string]any{"codec": codec, "sample_rate": sampleRate}, nil
+ return "pcm", map[string]any{"codec": "pcm", "sample_rate": sampleRate}, nil
default:
return "", nil, errors.New("response_format must be mp3, wav, or pcm")
}
diff --git a/sdk/api/handlers/openai/openai_speech_handlers_test.go b/sdk/api/handlers/openai/openai_speech_handlers_test.go
index f5b74b80bf9..ffc5314c41a 100644
--- a/sdk/api/handlers/openai/openai_speech_handlers_test.go
+++ b/sdk/api/handlers/openai/openai_speech_handlers_test.go
@@ -5,7 +5,7 @@ import (
"testing"
"unicode/utf8"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestBuildXAISpeechPayloadMapsOpenAIVoice(t *testing.T) {
@@ -16,19 +16,19 @@ func TestBuildXAISpeechPayloadMapsOpenAIVoice(t *testing.T) {
if format != "mp3" {
t.Fatalf("format = %q, want mp3", format)
}
- if gjson.GetBytes(payload, "text").String() != "Hello" {
+ if ojson.GetBytesNoCopy(payload, "text").String() != "Hello" {
t.Fatalf("text = %s", payload)
}
- if gjson.GetBytes(payload, "voice_id").String() != "ara" {
+ if ojson.GetBytesNoCopy(payload, "voice_id").String() != "ara" {
t.Fatalf("voice_id = %s", payload)
}
- if gjson.GetBytes(payload, "language").String() != "auto" {
+ if ojson.GetBytesNoCopy(payload, "language").String() != "auto" {
t.Fatalf("language = %s", payload)
}
- if gjson.GetBytes(payload, "output_format").Exists() {
+ if ojson.GetBytesNoCopy(payload, "output_format").Exists() {
t.Fatalf("mp3 should omit output_format: %s", payload)
}
- if gjson.GetBytes(payload, "model").Exists() {
+ if ojson.GetBytesNoCopy(payload, "model").Exists() {
t.Fatalf("upstream payload should omit model: %s", payload)
}
}
@@ -41,16 +41,16 @@ func TestBuildXAISpeechPayloadWavAndSpeed(t *testing.T) {
if format != "wav" {
t.Fatalf("format = %q, want wav", format)
}
- if gjson.GetBytes(payload, "voice_id").String() != "luna" {
+ if ojson.GetBytesNoCopy(payload, "voice_id").String() != "luna" {
t.Fatalf("voice_id = %s", payload)
}
- if gjson.GetBytes(payload, "language").String() != "zh" {
+ if ojson.GetBytesNoCopy(payload, "language").String() != "zh" {
t.Fatalf("language = %s", payload)
}
- if gjson.GetBytes(payload, "speed").Float() != 1.25 {
+ if ojson.GetBytesNoCopy(payload, "speed").Float() != 1.25 {
t.Fatalf("speed = %s", payload)
}
- if gjson.GetBytes(payload, "output_format.codec").String() != "wav" || gjson.GetBytes(payload, "output_format.sample_rate").Int() != 24000 {
+ if ojson.GetBytesNoCopy(payload, "output_format.codec").String() != "wav" || ojson.GetBytesNoCopy(payload, "output_format.sample_rate").Int() != 24000 {
t.Fatalf("output_format = %s", payload)
}
}
@@ -63,10 +63,10 @@ func TestBuildXAISpeechPayloadNativePCMKeepsSampleRate(t *testing.T) {
if format != "pcm" {
t.Fatalf("format = %q, want pcm", format)
}
- if gjson.GetBytes(payload, "voice_id").String() != "eve" {
+ if ojson.GetBytesNoCopy(payload, "voice_id").String() != "eve" {
t.Fatalf("voice_id = %s", payload)
}
- if gjson.GetBytes(payload, "output_format.sample_rate").Int() != 16000 {
+ if ojson.GetBytesNoCopy(payload, "output_format.sample_rate").Int() != 16000 {
t.Fatalf("sample_rate = %s", payload)
}
}
@@ -89,7 +89,7 @@ func TestBuildXAISpeechPayloadRejects(t *testing.T) {
if err != nil {
t.Fatalf("buildXAISpeechPayload() error = %v", err)
}
- if gjson.GetBytes(payload, "speed").Exists() {
+ if ojson.GetBytesNoCopy(payload, "speed").Exists() {
t.Fatalf("speed should be omitted: %s", payload)
}
return
@@ -148,3 +148,29 @@ func TestSpeechResponseContentType(t *testing.T) {
t.Fatalf("audio upstream = %q", got)
}
}
+
+func TestSpeechOutputFormatClonesAgainstInputBufferMutation(t *testing.T) {
+ rawWav := []byte(`{"input":"hello","response_format":"wav"}`)
+ formatWav, outputWav, errWav := speechOutputFormat(rawWav)
+ if errWav != nil {
+ t.Fatalf("speechOutputFormat(wav) error = %v", errWav)
+ }
+ for i := range rawWav {
+ rawWav[i] = 'x'
+ }
+ if formatWav != "wav" || outputWav["codec"] != "wav" {
+ t.Fatalf("after buffer mutation: format=%q codec=%v, want wav", formatWav, outputWav["codec"])
+ }
+
+ rawPCM := []byte(`{"input":"hello","output_format":{"codec":"pcm","sample_rate":16000}}`)
+ _, formatPCM, errPCM := buildXAISpeechPayload(rawPCM)
+ if errPCM != nil {
+ t.Fatalf("buildXAISpeechPayload(pcm) error = %v", errPCM)
+ }
+ for i := range rawPCM {
+ rawPCM[i] = 'x'
+ }
+ if formatPCM != "pcm" {
+ t.Fatalf("after buffer mutation: format=%q, want pcm", formatPCM)
+ }
+}
diff --git a/sdk/api/handlers/openai/openai_videos_handlers.go b/sdk/api/handlers/openai/openai_videos_handlers.go
index 55cd0a3d50b..390b73b0fdd 100644
--- a/sdk/api/handlers/openai/openai_videos_handlers.go
+++ b/sdk/api/handlers/openai/openai_videos_handlers.go
@@ -12,6 +12,7 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror"
@@ -21,8 +22,6 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
const (
@@ -34,6 +33,7 @@ const (
defaultOpenAIVideosModel = "sora-2"
defaultXAIVideosModel = "grok-imagine-video"
xaiVideos15Model = "grok-imagine-video-1.5"
+ xaiVideos15LiteModel = "grok-imagine-video-1.5-lite"
xaiVideos15PreviewAlias = "grok-imagine-video-1.5-preview"
xaiVideosHandlerType = "openai-video"
defaultVideosSeconds = "4"
@@ -149,7 +149,7 @@ func videosModelBase(model string) string {
func isXAIVideosModel(model string) bool {
prefix, baseModel := imagesModelParts(model)
baseModel = strings.ToLower(strings.TrimSpace(baseModel))
- if baseModel != defaultXAIVideosModel && baseModel != xaiVideos15Model && baseModel != xaiVideos15PreviewAlias {
+ if baseModel != defaultXAIVideosModel && baseModel != xaiVideos15Model && baseModel != xaiVideos15LiteModel && baseModel != xaiVideos15PreviewAlias {
return false
}
@@ -203,6 +203,8 @@ func canonicalXAIVideosModel(model string) string {
return defaultXAIVideosModel
case xaiVideos15Model, xaiVideos15PreviewAlias:
return xaiVideos15Model
+ case xaiVideos15LiteModel:
+ return xaiVideos15LiteModel
}
return defaultXAIVideosModel
}
@@ -216,6 +218,8 @@ func routingXAIVideosModel(model string) string {
return defaultXAIVideosModel
case xaiVideos15Model:
return xaiVideos15Model
+ case xaiVideos15LiteModel:
+ return xaiVideos15LiteModel
case xaiVideos15PreviewAlias:
return xaiVideos15PreviewAlias
}
@@ -264,26 +268,26 @@ func videosCreateRequestFromForm(c *gin.Context) ([]byte, error) {
return nil, errForm
}
}
- rawJSON := []byte(`{}`)
+ rawDoc := ojson.NewDoc([]byte(`{}`))
for _, field := range []string{"model", "prompt", "seconds", "size", "aspect_ratio", "resolution"} {
if value := strings.TrimSpace(c.PostForm(field)); value != "" {
- rawJSON, _ = sjson.SetBytes(rawJSON, field, value)
+ _ = rawDoc.Set(field, value)
}
}
if value := strings.TrimSpace(firstPostForm(c, "input_reference[image_url]", "input_reference.image_url", "image_url")); value != "" {
- rawJSON, _ = sjson.SetBytes(rawJSON, "input_reference.image_url", value)
+ _ = rawDoc.Set("input_reference.image_url", value)
}
if value := strings.TrimSpace(firstPostForm(c, "input_reference[file_id]", "input_reference.file_id", "file_id")); value != "" {
- rawJSON, _ = sjson.SetBytes(rawJSON, "input_reference.file_id", value)
+ _ = rawDoc.Set("input_reference.file_id", value)
}
if refs := strings.TrimSpace(c.PostForm("reference_image_urls")); refs != "" {
for _, ref := range strings.Split(refs, ",") {
if ref = strings.TrimSpace(ref); ref != "" {
- rawJSON, _ = sjson.SetBytes(rawJSON, "reference_image_urls.-1", ref)
+ _ = rawDoc.Set("reference_image_urls.-1", ref)
}
}
}
- return rawJSON, nil
+ return rawDoc.Bytes(), nil
}
func firstPostForm(c *gin.Context, keys ...string) string {
@@ -310,15 +314,15 @@ func (h *OpenAIAPIHandler) videoAuthBindingTTL() time.Duration {
}
func videoIDFromPayload(payload []byte) string {
- videoID := strings.TrimSpace(gjson.GetBytes(payload, "request_id").String())
+ videoID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "request_id").String())
if videoID == "" {
- videoID = strings.TrimSpace(gjson.GetBytes(payload, "id").String())
+ videoID = strings.TrimSpace(ojson.GetBytesNoCopy(payload, "id").String())
}
- return videoID
+ return strings.Clone(videoID)
}
func (h *OpenAIAPIHandler) bindVideoAuthIDFromPayload(payload []byte, authID string) {
- h.bindVideoAuthIDAndModelFromPayload(payload, authID, strings.TrimSpace(gjson.GetBytes(payload, "model").String()))
+ h.bindVideoAuthIDAndModelFromPayload(payload, authID, strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(payload, "model").String())))
}
func (h *OpenAIAPIHandler) bindVideoAuthIDAndModelFromPayload(payload []byte, authID string, model string) {
@@ -350,24 +354,27 @@ func (h *OpenAIAPIHandler) modelWithVideoAuthBinding(videoID string, fallbackMod
}
func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideoCreateMetadata, error) {
- prompt := strings.TrimSpace(gjson.GetBytes(rawJSON, "prompt").String())
+ var fieldsBuf [5]ojson.Result
+ fields := ojson.GetManyBytesNoCopy(fieldsBuf[:0], rawJSON, "prompt", "seconds", "size", "aspect_ratio", "resolution")
+ prompt := strings.Clone(strings.TrimSpace(fields[0].String()))
if prompt == "" {
return nil, xaiVideoCreateMetadata{}, fmt.Errorf("prompt is required")
}
- seconds, duration, err := normalizeXAIVideosSeconds(gjson.GetBytes(rawJSON, "seconds").String())
+ seconds, duration, err := normalizeXAIVideosSeconds(fields[1].String())
if err != nil {
return nil, xaiVideoCreateMetadata{}, err
}
- size, aspectRatio, resolution, err := xaiVideosSizeOptions(gjson.GetBytes(rawJSON, "size").String())
+ size, aspectRatio, resolution, err := xaiVideosSizeOptions(fields[2].String())
if err != nil {
return nil, xaiVideoCreateMetadata{}, err
}
- if value := xaiVideosAspectRatio(gjson.GetBytes(rawJSON, "aspect_ratio").String(), ""); value != "" {
+ size = strings.Clone(size)
+ if value := xaiVideosAspectRatio(fields[3].String(), ""); value != "" {
aspectRatio = value
}
- if value := xaiVideosResolution(gjson.GetBytes(rawJSON, "resolution").String(), ""); value != "" {
+ if value := xaiVideosResolution(fields[4].String(), ""); value != "" {
resolution = value
}
@@ -384,17 +391,17 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo
}
videoModel := canonicalXAIVideosModel(model)
- req := []byte(`{}`)
- req, _ = sjson.SetBytes(req, "model", videoModel)
- req, _ = sjson.SetBytes(req, "prompt", prompt)
- req, _ = sjson.SetRawBytes(req, "duration", []byte(strconv.FormatInt(duration, 10)))
- req, _ = sjson.SetBytes(req, "aspect_ratio", aspectRatio)
- req, _ = sjson.SetBytes(req, "resolution", resolution)
+ reqDoc := ojson.NewDoc([]byte(`{}`))
+ _ = reqDoc.Set("model", videoModel)
+ _ = reqDoc.Set("prompt", prompt)
+ _ = reqDoc.SetRawBytes("duration", []byte(strconv.FormatInt(duration, 10)))
+ _ = reqDoc.Set("aspect_ratio", aspectRatio)
+ _ = reqDoc.Set("resolution", resolution)
if imageURL != "" {
- req, _ = sjson.SetBytes(req, "image.url", imageURL)
+ _ = reqDoc.Set("image.url", imageURL)
}
for _, image := range referenceImages {
- req, _ = sjson.SetBytes(req, "reference_images.-1.url", image)
+ _ = reqDoc.Set("reference_images.-1.url", image)
}
meta := xaiVideoCreateMetadata{
@@ -405,7 +412,7 @@ func buildXAIVideosCreateRequest(rawJSON []byte, model string) ([]byte, xaiVideo
Size: size,
CreatedAt: time.Now().Unix(),
}
- return req, meta, nil
+ return reqDoc.Bytes(), meta, nil
}
func normalizeXAIVideosSeconds(raw string) (string, int64, error) {
@@ -468,13 +475,15 @@ func xaiVideosResolution(raw string, fallback string) string {
return "480p"
case "720p":
return "720p"
+ case "1080p":
+ return "1080p"
default:
return fallback
}
}
func xaiVideosInputImageURL(rawJSON []byte) (string, error) {
- inputRef := gjson.GetBytes(rawJSON, "input_reference")
+ inputRef := ojson.GetBytesNoCopy(rawJSON, "input_reference")
if inputRef.Exists() {
imageURL := strings.TrimSpace(inputRef.Get("image_url").String())
fileID := strings.TrimSpace(inputRef.Get("file_id").String())
@@ -489,9 +498,9 @@ func xaiVideosInputImageURL(rawJSON []byte) (string, error) {
}
}
- image := gjson.GetBytes(rawJSON, "image")
+ image := ojson.GetBytesNoCopy(rawJSON, "image")
if image.Exists() {
- if image.Type == gjson.String {
+ if image.Type == ojson.String {
return strings.TrimSpace(image.String()), nil
}
if value := strings.TrimSpace(image.Get("url").String()); value != "" {
@@ -502,7 +511,7 @@ func xaiVideosInputImageURL(rawJSON []byte) (string, error) {
}
}
- return strings.TrimSpace(gjson.GetBytes(rawJSON, "image_url").String()), nil
+ return strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "image_url").String()), nil
}
func collectXAIVideoReferenceImages(rawJSON []byte) []string {
@@ -513,12 +522,12 @@ func collectXAIVideoReferenceImages(rawJSON []byte) []string {
out = append(out, value)
}
}
- collectArray := func(result gjson.Result) {
+ collectArray := func(result ojson.Result) {
if !result.IsArray() {
return
}
- result.ForEach(func(_, item gjson.Result) bool {
- if item.Type == gjson.String {
+ result.ForEach(func(_, item ojson.Result) bool {
+ if item.Type == ojson.String {
appendRef(item.String())
return true
}
@@ -532,34 +541,34 @@ func collectXAIVideoReferenceImages(rawJSON []byte) []string {
return true
})
}
- collectArray(gjson.GetBytes(rawJSON, "reference_images"))
- collectArray(gjson.GetBytes(rawJSON, "reference_image_urls"))
+ collectArray(ojson.GetBytesNoCopy(rawJSON, "reference_images"))
+ collectArray(ojson.GetBytesNoCopy(rawJSON, "reference_image_urls"))
return out
}
func buildVideosCreateAPIResponseFromXAI(payload []byte, meta xaiVideoCreateMetadata) ([]byte, error) {
- requestID := strings.TrimSpace(gjson.GetBytes(payload, "request_id").String())
+ requestID := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "request_id").String())
if requestID == "" {
- requestID = strings.TrimSpace(gjson.GetBytes(payload, "id").String())
+ requestID = strings.TrimSpace(ojson.GetBytesNoCopy(payload, "id").String())
}
if requestID == "" {
return nil, fmt.Errorf("xAI video response did not include request_id")
}
- out := []byte(`{"object":"video","progress":0,"status":"queued"}`)
- out, _ = sjson.SetBytes(out, "id", requestID)
- out, _ = sjson.SetBytes(out, "model", meta.Model)
- out, _ = sjson.SetBytes(out, "prompt", meta.Prompt)
- out, _ = sjson.SetBytes(out, "seconds", meta.Seconds)
- out, _ = sjson.SetBytes(out, "size", meta.Size)
- out, _ = sjson.SetBytes(out, "created_at", meta.CreatedAt)
- if status := openAIVideoStatus(gjson.GetBytes(payload, "status").String()); status != "" {
- out, _ = sjson.SetBytes(out, "status", status)
+ outDoc := ojson.NewDoc([]byte(`{"object":"video","progress":0,"status":"queued"}`))
+ _ = outDoc.Set("id", requestID)
+ _ = outDoc.Set("model", meta.Model)
+ _ = outDoc.Set("prompt", meta.Prompt)
+ _ = outDoc.Set("seconds", meta.Seconds)
+ _ = outDoc.Set("size", meta.Size)
+ _ = outDoc.Set("created_at", meta.CreatedAt)
+ if status := openAIVideoStatus(ojson.GetBytesNoCopy(payload, "status").String()); status != "" {
+ _ = outDoc.Set("status", status)
}
- if progress := gjson.GetBytes(payload, "progress"); progress.Exists() {
- out, _ = sjson.SetRawBytes(out, "progress", []byte(progress.Raw))
+ if progress := ojson.GetBytesNoCopy(payload, "progress"); progress.Exists() {
+ _ = outDoc.SetRawBytes("progress", []byte(progress.Raw))
}
- return out, nil
+ return outDoc.Bytes(), nil
}
func buildVideosFailedAPIResponse(model string, code string, message string) []byte {
@@ -576,12 +585,12 @@ func buildVideosFailedAPIResponse(model string, code string, message string) []b
message = "Video generation failed"
}
- out := []byte(`{"object":"video","status":"failed","progress":0}`)
- out, _ = sjson.SetBytes(out, "id", "video_"+strings.ReplaceAll(uuid.NewString(), "-", ""))
- out, _ = sjson.SetBytes(out, "model", model)
- out, _ = sjson.SetBytes(out, "error.code", code)
- out, _ = sjson.SetBytes(out, "error.message", message)
- return out
+ outDoc := ojson.NewDoc([]byte(`{"object":"video","status":"failed","progress":0}`))
+ _ = outDoc.Set("id", "video_"+strings.ReplaceAll(uuid.NewString(), "-", ""))
+ _ = outDoc.Set("model", model)
+ _ = outDoc.Set("error.code", code)
+ _ = outDoc.Set("error.message", message)
+ return outDoc.Bytes()
}
func writeVideosFailedError(c *gin.Context, status int, model string, code string, message string) {
@@ -592,89 +601,87 @@ func writeVideosFailedError(c *gin.Context, status int, model string, code strin
}
func buildVideosRetrieveAPIResponseFromXAI(videoID string, payload []byte, fallbackModel string) ([]byte, error) {
- out := []byte(`{"object":"video"}`)
- out, _ = sjson.SetBytes(out, "id", videoID)
- model := strings.TrimSpace(gjson.GetBytes(payload, "model").String())
+ outDoc := ojson.NewDoc([]byte(`{"object":"video"}`))
+ _ = outDoc.Set("id", videoID)
+ model := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "model").String())
if model == "" {
model = responseVideosModel(fallbackModel)
}
- out, _ = sjson.SetBytes(out, "model", model)
+ _ = outDoc.Set("model", model)
for _, field := range []string{"created_at", "completed_at", "expires_at", "prompt", "remixed_from_video_id", "size"} {
- if value := gjson.GetBytes(payload, field); value.Exists() {
- out, _ = sjson.SetRawBytes(out, field, []byte(value.Raw))
+ if value := ojson.GetBytesNoCopy(payload, field); value.Exists() {
+ _ = outDoc.SetRawBytes(field, []byte(value.Raw))
}
}
- if status := openAIVideoStatus(gjson.GetBytes(payload, "status").String()); status != "" {
- out, _ = sjson.SetBytes(out, "status", status)
+ if status := openAIVideoStatus(ojson.GetBytesNoCopy(payload, "status").String()); status != "" {
+ _ = outDoc.Set("status", status)
}
- if progress := gjson.GetBytes(payload, "progress"); progress.Exists() {
- out, _ = sjson.SetRawBytes(out, "progress", []byte(progress.Raw))
+ if progress := ojson.GetBytesNoCopy(payload, "progress"); progress.Exists() {
+ _ = outDoc.SetRawBytes("progress", []byte(progress.Raw))
}
- if seconds := gjson.GetBytes(payload, "seconds"); seconds.Exists() {
- out, _ = sjson.SetRawBytes(out, "seconds", []byte(seconds.Raw))
- } else if duration := gjson.GetBytes(payload, "video.duration"); duration.Exists() {
- out, _ = sjson.SetBytes(out, "seconds", duration.String())
+ if seconds := ojson.GetBytesNoCopy(payload, "seconds"); seconds.Exists() {
+ _ = outDoc.SetRawBytes("seconds", []byte(seconds.Raw))
+ } else if duration := ojson.GetBytesNoCopy(payload, "video.duration"); duration.Exists() {
+ _ = outDoc.Set("seconds", duration.String())
}
- if videoURL := strings.TrimSpace(gjson.GetBytes(payload, "video.url").String()); videoURL != "" {
- out, _ = sjson.SetBytes(out, "video_url", videoURL)
+ if videoURL := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "video.url").String()); videoURL != "" {
+ _ = outDoc.Set("video_url", videoURL)
}
- out = setOpenAIVideoErrorFromXAI(out, payload)
- return out, nil
+ setOpenAIVideoErrorFromXAIDoc(outDoc, payload)
+ return outDoc.Bytes(), nil
}
-func setOpenAIVideoErrorFromXAI(out []byte, payload []byte) []byte {
- if errPayload := gjson.GetBytes(payload, "error"); errPayload.Exists() {
- out = markOpenAIVideoFailed(out)
- if errPayload.Type == gjson.JSON && json.Valid([]byte(errPayload.Raw)) {
+func setOpenAIVideoErrorFromXAIDoc(outDoc *ojson.Doc, payload []byte) {
+ if errPayload := ojson.GetBytesNoCopy(payload, "error"); errPayload.Exists() {
+ markOpenAIVideoFailedDoc(outDoc)
+ if errPayload.Type == ojson.JSON && json.Valid([]byte(errPayload.Raw)) {
message := strings.TrimSpace(errPayload.Get("message").String())
if message != "" {
- code := strings.TrimSpace(gjson.GetBytes(payload, "code").String())
+ code := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "code").String())
if code == "" {
code = strings.TrimSpace(errPayload.Get("code").String())
}
if code == "" {
code = "video_generation_failed"
}
- out, _ = sjson.SetBytes(out, "error.code", code)
- out, _ = sjson.SetBytes(out, "error.message", message)
+ _ = outDoc.Set("error.code", code)
+ _ = outDoc.Set("error.message", message)
}
- return out
+ return
}
message := strings.TrimSpace(errPayload.String())
if message != "" {
- code := strings.TrimSpace(gjson.GetBytes(payload, "code").String())
+ code := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "code").String())
if code == "" {
code = "video_generation_failed"
}
- out, _ = sjson.SetBytes(out, "error.code", code)
- out, _ = sjson.SetBytes(out, "error.message", message)
+ _ = outDoc.Set("error.code", code)
+ _ = outDoc.Set("error.message", message)
}
- return out
+ return
}
- code := strings.TrimSpace(gjson.GetBytes(payload, "code").String())
+ code := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "code").String())
if code != "" {
- out = markOpenAIVideoFailed(out)
- out, _ = sjson.SetBytes(out, "error.code", code)
- out, _ = sjson.SetBytes(out, "error.message", code)
+ markOpenAIVideoFailedDoc(outDoc)
+ _ = outDoc.Set("error.code", code)
+ _ = outDoc.Set("error.message", code)
}
- return out
}
-func markOpenAIVideoFailed(out []byte) []byte {
- if !gjson.GetBytes(out, "status").Exists() {
- out, _ = sjson.SetBytes(out, "status", "failed")
+func markOpenAIVideoFailedDoc(outDoc *ojson.Doc) {
+ if !outDoc.Get("status").Exists() {
+ _ = outDoc.Set("status", "failed")
}
- if !gjson.GetBytes(out, "progress").Exists() {
- out, _ = sjson.SetRawBytes(out, "progress", []byte("0"))
+ if !outDoc.Get("progress").Exists() {
+ _ = outDoc.SetRawBytes("progress", []byte("0"))
}
- return out
}
func xaiVideoContentURLFromPayload(payload []byte) (string, error) {
- rawURL := strings.TrimSpace(gjson.GetBytes(payload, "video.url").String())
+ rawURL := strings.TrimSpace(ojson.GetBytesNoCopy(payload, "video.url").String())
if rawURL == "" {
return "", fmt.Errorf("xAI video response did not include video.url")
}
@@ -707,7 +714,7 @@ func (h *OpenAIAPIHandler) VideosCreate(c *gin.Context) {
return
}
- videoModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ videoModel := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "model").String()))
if videoModel == "" {
videoModel = defaultXAIVideosModel
}
@@ -748,7 +755,7 @@ func (h *OpenAIAPIHandler) handleXAIVideosNativePost(c *gin.Context) {
return
}
- videoModel := strings.TrimSpace(gjson.GetBytes(rawJSON, "model").String())
+ videoModel := strings.Clone(strings.TrimSpace(ojson.GetBytesNoCopy(rawJSON, "model").String()))
if videoModel == "" {
videoModel = defaultXAIVideosModel
}
@@ -757,7 +764,7 @@ func (h *OpenAIAPIHandler) handleXAIVideosNativePost(c *gin.Context) {
}
routingModel := routingXAIVideosModel(videoModel)
- rawJSON, _ = sjson.SetBytes(rawJSON, "model", canonicalXAIVideosModel(videoModel))
+ rawJSON, _ = ojson.SetBytes(rawJSON, "model", canonicalXAIVideosModel(videoModel))
h.collectXAIVideosNative(c, rawJSON, routingModel, true)
}
@@ -777,7 +784,7 @@ func (h *OpenAIAPIHandler) XAIVideosRetrieve(c *gin.Context) {
}
payload := []byte(`{}`)
- payload, _ = sjson.SetBytes(payload, "request_id", requestID)
+ payload, _ = ojson.SetBytes(payload, "request_id", requestID)
h.collectXAIVideosNative(c, payload, defaultXAIVideosModel, false)
}
@@ -794,7 +801,7 @@ func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) {
}
payload := []byte(`{}`)
- payload, _ = sjson.SetBytes(payload, "request_id", videoID)
+ payload, _ = ojson.SetBytes(payload, "request_id", videoID)
c.Header("Content-Type", "application/json")
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
@@ -817,7 +824,7 @@ func (h *OpenAIAPIHandler) VideosRetrieve(c *gin.Context) {
return
}
- out, err := buildVideosRetrieveAPIResponseFromXAI(videoID, resp, defaultOpenAIVideosModel)
+ out, err := buildVideosRetrieveAPIResponseFromXAI(videoID, resp, executionModel)
if err != nil {
errMsg := &interfaces.ErrorMessage{StatusCode: http.StatusBadGateway, Error: err}
h.WriteErrorResponse(c, errMsg)
@@ -858,7 +865,7 @@ func (h *OpenAIAPIHandler) VideosContent(c *gin.Context) {
}
payload := []byte(`{}`)
- payload, _ = sjson.SetBytes(payload, "request_id", videoID)
+ payload, _ = ojson.SetBytes(payload, "request_id", videoID)
cliCtx, cliCancel := h.GetContextWithCancel(h, c, context.Background())
selectedAuthID := ""
diff --git a/sdk/api/handlers/openai/openai_videos_handlers_test.go b/sdk/api/handlers/openai/openai_videos_handlers_test.go
index b4941d8e0b4..c23895381b8 100644
--- a/sdk/api/handlers/openai/openai_videos_handlers_test.go
+++ b/sdk/api/handlers/openai/openai_videos_handlers_test.go
@@ -11,13 +11,13 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
apihandlers "github.com/router-for-me/CLIProxyAPI/v8/sdk/api/handlers"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
coreexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/tidwall/gjson"
)
func performVideosEndpointRequest(t *testing.T, method string, endpointPath string, contentType string, body io.Reader, handler gin.HandlerFunc) *httptest.ResponseRecorder {
@@ -63,12 +63,13 @@ func performVideosRouteRequest(t *testing.T, method string, routePath string, re
}
type videoAuthCaptureExecutor struct {
- mu sync.Mutex
- requestID string
- contentURL string
- authIDs []string
- models []string
- payloadModels []string
+ mu sync.Mutex
+ requestID string
+ contentURL string
+ authIDs []string
+ models []string
+ payloadModels []string
+ payloadResolutions []string
}
func (e *videoAuthCaptureExecutor) Identifier() string { return "xai" }
@@ -81,10 +82,11 @@ func (e *videoAuthCaptureExecutor) Execute(_ context.Context, auth *coreauth.Aut
e.mu.Lock()
e.authIDs = append(e.authIDs, authID)
e.models = append(e.models, req.Model)
- e.payloadModels = append(e.payloadModels, strings.TrimSpace(gjson.GetBytes(req.Payload, "model").String()))
+ e.payloadModels = append(e.payloadModels, strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "model").String()))
+ e.payloadResolutions = append(e.payloadResolutions, ojson.GetBytesNoCopy(req.Payload, "resolution").String())
e.mu.Unlock()
- requestID := strings.TrimSpace(gjson.GetBytes(req.Payload, "request_id").String())
+ requestID := strings.TrimSpace(ojson.GetBytesNoCopy(req.Payload, "request_id").String())
if requestID == "" {
requestID = e.requestID
}
@@ -188,6 +190,10 @@ func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) {
"xai/grok-imagine-video-1.5-preview",
"x-ai/grok-imagine-video-1.5-preview",
"grok/grok-imagine-video-1.5-preview",
+ "grok-imagine-video-1.5-lite",
+ "xai/grok-imagine-video-1.5-lite",
+ "x-ai/grok-imagine-video-1.5-lite",
+ "grok/grok-imagine-video-1.5-lite",
} {
if !isSupportedVideosModel(model) {
t.Fatalf("expected %s to be supported", model)
@@ -208,6 +214,9 @@ func TestVideosModelValidationAllowsXAIVideoModel(t *testing.T) {
if isSupportedVideosModel("codex/grok-imagine-video-1.5-preview") {
t.Fatal("expected codex/grok-imagine-video-1.5-preview to be rejected")
}
+ if isSupportedVideosModel("codex/grok-imagine-video-1.5-lite") {
+ t.Fatal("expected codex/grok-imagine-video-1.5-lite to be rejected")
+ }
}
func TestBuildXAIVideosCreateRequestMapsSoraModelToXAIBackend(t *testing.T) {
@@ -218,7 +227,7 @@ func TestBuildXAIVideosCreateRequestMapsSoraModelToXAIBackend(t *testing.T) {
t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
}
- if got := gjson.GetBytes(req, "model").String(); got != defaultXAIVideosModel {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != defaultXAIVideosModel {
t.Fatalf("upstream model = %q, want %s", got, defaultXAIVideosModel)
}
if meta.Model != defaultXAIVideosModel {
@@ -234,22 +243,22 @@ func TestBuildXAIVideosCreateRequest(t *testing.T) {
t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
}
- if got := gjson.GetBytes(req, "model").String(); got != defaultXAIVideosModel {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != defaultXAIVideosModel {
t.Fatalf("model = %q, want %s", got, defaultXAIVideosModel)
}
- if got := gjson.GetBytes(req, "prompt").String(); got != "a cat playing piano" {
+ if got := ojson.GetBytesNoCopy(req, "prompt").String(); got != "a cat playing piano" {
t.Fatalf("prompt = %q", got)
}
- if got := gjson.GetBytes(req, "duration").Int(); got != 8 {
+ if got := ojson.GetBytesNoCopy(req, "duration").Int(); got != 8 {
t.Fatalf("duration = %d, want 8", got)
}
- if got := gjson.GetBytes(req, "aspect_ratio").String(); got != "16:9" {
+ if got := ojson.GetBytesNoCopy(req, "aspect_ratio").String(); got != "16:9" {
t.Fatalf("aspect_ratio = %q, want 16:9", got)
}
- if got := gjson.GetBytes(req, "resolution").String(); got != "720p" {
+ if got := ojson.GetBytesNoCopy(req, "resolution").String(); got != "720p" {
t.Fatalf("resolution = %q, want 720p", got)
}
- if got := gjson.GetBytes(req, "image.url").String(); got != "https://example.com/cat.png" {
+ if got := ojson.GetBytesNoCopy(req, "image.url").String(); got != "https://example.com/cat.png" {
t.Fatalf("image.url = %q", got)
}
if meta.Seconds != "8" || meta.Size != "1280x720" || meta.Prompt != "a cat playing piano" {
@@ -265,7 +274,7 @@ func TestBuildXAIVideosCreateRequestAllowsVideo15Model(t *testing.T) {
t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
}
- if got := gjson.GetBytes(req, "model").String(); got != xaiVideos15Model {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != xaiVideos15Model {
t.Fatalf("model = %q, want %s", got, xaiVideos15Model)
}
if meta.Model != xaiVideos15Model {
@@ -284,7 +293,7 @@ func TestBuildXAIVideosCreateRequestNormalizesVideo15PreviewAlias(t *testing.T)
t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
}
- if got := gjson.GetBytes(req, "model").String(); got != xaiVideos15Model {
+ if got := ojson.GetBytesNoCopy(req, "model").String(); got != xaiVideos15Model {
t.Fatalf("model = %q, want %s", got, xaiVideos15Model)
}
if meta.Model != xaiVideos15Model {
@@ -303,7 +312,7 @@ func TestBuildXAIVideosCreateRequestAllowsCustomSeconds(t *testing.T) {
t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
}
- if got := gjson.GetBytes(req, "duration").Int(); got != 6 {
+ if got := ojson.GetBytesNoCopy(req, "duration").Int(); got != 6 {
t.Fatalf("duration = %d, want 6", got)
}
if meta.Seconds != "6" {
@@ -332,13 +341,13 @@ func TestBuildXAIVideosCreateRequestPreservesMultiReferenceSeconds(t *testing.T)
t.Fatalf("buildXAIVideosCreateRequest() error = %v", err)
}
- if got := gjson.GetBytes(req, "duration").Int(); got != tt.duration {
+ if got := ojson.GetBytesNoCopy(req, "duration").Int(); got != tt.duration {
t.Fatalf("duration = %d, want %d", got, tt.duration)
}
if meta.Seconds != tt.seconds {
t.Fatalf("meta seconds = %q, want %q", meta.Seconds, tt.seconds)
}
- if got := gjson.GetBytes(req, "reference_images.#").Int(); got != 2 {
+ if got := ojson.GetBytesNoCopy(req, "reference_images.#").Int(); got != 2 {
t.Fatalf("reference image count = %d, want 2", got)
}
})
@@ -367,16 +376,16 @@ func TestBuildVideosCreateAPIResponseFromXAI(t *testing.T) {
t.Fatalf("buildVideosCreateAPIResponseFromXAI() error = %v", err)
}
- if got := gjson.GetBytes(out, "id").String(); got != "vid_123" {
+ if got := ojson.GetBytesNoCopy(out, "id").String(); got != "vid_123" {
t.Fatalf("id = %q, want vid_123", got)
}
- if got := gjson.GetBytes(out, "object").String(); got != "video" {
+ if got := ojson.GetBytesNoCopy(out, "object").String(); got != "video" {
t.Fatalf("object = %q, want video", got)
}
- if got := gjson.GetBytes(out, "status").String(); got != "queued" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "queued" {
t.Fatalf("status = %q, want queued", got)
}
- if got := gjson.GetBytes(out, "created_at").Int(); got != 123 {
+ if got := ojson.GetBytesNoCopy(out, "created_at").Int(); got != 123 {
t.Fatalf("created_at = %d, want 123", got)
}
}
@@ -389,31 +398,31 @@ func TestBuildVideosRetrieveAPIResponseFromXAI(t *testing.T) {
t.Fatalf("buildVideosRetrieveAPIResponseFromXAI() error = %v", err)
}
- if got := gjson.GetBytes(out, "id").String(); got != "91989464-273f-95df-8197-703b4fefd40e" {
+ if got := ojson.GetBytesNoCopy(out, "id").String(); got != "91989464-273f-95df-8197-703b4fefd40e" {
t.Fatalf("id = %q", got)
}
- if got := gjson.GetBytes(out, "object").String(); got != "video" {
+ if got := ojson.GetBytesNoCopy(out, "object").String(); got != "video" {
t.Fatalf("object = %q, want video", got)
}
- if got := gjson.GetBytes(out, "model").String(); got != defaultXAIVideosModel {
+ if got := ojson.GetBytesNoCopy(out, "model").String(); got != defaultXAIVideosModel {
t.Fatalf("model = %q, want %s", got, defaultXAIVideosModel)
}
- if got := gjson.GetBytes(out, "status").String(); got != "completed" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "completed" {
t.Fatalf("status = %q, want completed", got)
}
- if got := gjson.GetBytes(out, "progress").Int(); got != 100 {
+ if got := ojson.GetBytesNoCopy(out, "progress").Int(); got != 100 {
t.Fatalf("progress = %d, want 100", got)
}
- if got := gjson.GetBytes(out, "seconds").String(); got != "4" {
+ if got := ojson.GetBytesNoCopy(out, "seconds").String(); got != "4" {
t.Fatalf("seconds = %q, want 4", got)
}
- if got := gjson.GetBytes(out, "video_url").String(); got != "https://vidgen.x.ai/xai-vidgen-bucket/xai-video-08609066-e7e9-43ba-bd8d-bd29cb6221d9.mp4" {
+ if got := ojson.GetBytesNoCopy(out, "video_url").String(); got != "https://vidgen.x.ai/xai-vidgen-bucket/xai-video-08609066-e7e9-43ba-bd8d-bd29cb6221d9.mp4" {
t.Fatalf("video_url = %q", got)
}
- if gjson.GetBytes(out, "video").Exists() {
+ if ojson.GetBytesNoCopy(out, "video").Exists() {
t.Fatalf("video field must not be exposed in OpenAI retrieve response: %s", string(out))
}
- if gjson.GetBytes(out, "usage").Exists() {
+ if ojson.GetBytesNoCopy(out, "usage").Exists() {
t.Fatalf("usage field must not be exposed in OpenAI retrieve response: %s", string(out))
}
}
@@ -426,16 +435,16 @@ func TestBuildVideosRetrieveAPIResponseFromXAINormalizesTopLevelError(t *testing
t.Fatalf("buildVideosRetrieveAPIResponseFromXAI() error = %v", err)
}
- if got := gjson.GetBytes(out, "status").String(); got != "failed" {
+ if got := ojson.GetBytesNoCopy(out, "status").String(); got != "failed" {
t.Fatalf("status = %q, want failed", got)
}
- if got := gjson.GetBytes(out, "progress").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(out, "progress").Int(); got != 0 {
t.Fatalf("progress = %d, want 0", got)
}
- if got := gjson.GetBytes(out, "error.code").String(); got != "invalid-argument" {
+ if got := ojson.GetBytesNoCopy(out, "error.code").String(); got != "invalid-argument" {
t.Fatalf("error.code = %q, want invalid-argument", got)
}
- if got := gjson.GetBytes(out, "error.message").String(); got != "1080p video resolution is not available for your team." {
+ if got := ojson.GetBytesNoCopy(out, "error.message").String(); got != "1080p video resolution is not available for your team." {
t.Fatalf("error.message = %q", got)
}
}
@@ -448,13 +457,13 @@ func TestBuildVideosRetrieveAPIResponseFromXAINormalizesNestedError(t *testing.T
t.Fatalf("buildVideosRetrieveAPIResponseFromXAI() error = %v", err)
}
- if got := gjson.GetBytes(out, "error.code").String(); got != "content_policy_violation" {
+ if got := ojson.GetBytesNoCopy(out, "error.code").String(); got != "content_policy_violation" {
t.Fatalf("error.code = %q, want content_policy_violation", got)
}
- if got := gjson.GetBytes(out, "error.message").String(); got != "The request was rejected by the safety system." {
+ if got := ojson.GetBytesNoCopy(out, "error.message").String(); got != "The request was rejected by the safety system." {
t.Fatalf("error.message = %q", got)
}
- if gjson.GetBytes(out, "error.type").Exists() {
+ if ojson.GetBytesNoCopy(out, "error.type").Exists() {
t.Fatalf("error.type must not be present: %s", string(out))
}
}
@@ -658,29 +667,29 @@ func TestVideosCreateRejectsUnsupportedModel(t *testing.T) {
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "object").String(); got != "video" {
t.Fatalf("object = %q, want video", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != "not-a-video-model" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "model").String(); got != "not-a-video-model" {
t.Fatalf("model = %q, want not-a-video-model", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "status").String(); got != "failed" {
t.Fatalf("status = %q, want failed", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "progress").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "progress").Int(); got != 0 {
t.Fatalf("progress = %d, want 0", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.code").String(); got != "invalid_request_error" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.code").String(); got != "invalid_request_error" {
t.Fatalf("error.code = %q, want invalid_request_error", got)
}
expectedMessage := "Model not-a-video-model is not supported on " + openAIVideosPath + ". Use " + defaultOpenAIVideosModel + "."
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != expectedMessage {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.message").String(); got != expectedMessage {
t.Fatalf("error.message = %q, want %q", got, expectedMessage)
}
- if gjson.GetBytes(resp.Body.Bytes(), "error.type").Exists() {
+ if ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.type").Exists() {
t.Fatalf("error.type must not be present: %s", resp.Body.String())
}
- if id := gjson.GetBytes(resp.Body.Bytes(), "id").String(); !strings.HasPrefix(id, "video_") {
+ if id := ojson.GetBytesNoCopy(resp.Body.Bytes(), "id").String(); !strings.HasPrefix(id, "video_") {
t.Fatalf("id = %q, want video_ prefix", id)
}
}
@@ -694,26 +703,26 @@ func TestVideosCreateInvalidSizeReturnsFailedVideoResource(t *testing.T) {
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "object").String(); got != "video" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "object").String(); got != "video" {
t.Fatalf("object = %q, want video", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel {
t.Fatalf("model = %q, want %s", got, defaultXAIVideosModel)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "status").String(); got != "failed" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "status").String(); got != "failed" {
t.Fatalf("status = %q, want failed", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "progress").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "progress").Int(); got != 0 {
t.Fatalf("progress = %d, want 0", got)
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.code").String(); got != "invalid_request_error" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.code").String(); got != "invalid_request_error" {
t.Fatalf("error.code = %q, want invalid_request_error", got)
}
expectedMessage := "Invalid request: size must be one of 720x1280, 1280x720, 1024x1792, or 1792x1024"
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.message").String(); got != expectedMessage {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.message").String(); got != expectedMessage {
t.Fatalf("error.message = %q, want %q", got, expectedMessage)
}
- if gjson.GetBytes(resp.Body.Bytes(), "error.type").Exists() {
+ if ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.type").Exists() {
t.Fatalf("error.type must not be present: %s", resp.Body.String())
}
}
@@ -727,7 +736,7 @@ func TestXAIVideosNativeRejectsUnsupportedModel(t *testing.T) {
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- message := gjson.GetBytes(resp.Body.Bytes(), "error.message").String()
+ message := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.message").String()
expectedMessage := "Model sora-2 is not supported on " + xaiVideosGenerationsAPI + ", " + xaiVideosEditsAPI + ", or " + xaiVideosExtensionsAPI + ". Use " + defaultXAIVideosModel + "."
if message != expectedMessage {
t.Fatalf("error message = %q, want %q", message, expectedMessage)
@@ -743,7 +752,7 @@ func TestXAIVideosNativeRejectsInvalidJSON(t *testing.T) {
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
}
- if got := gjson.GetBytes(resp.Body.Bytes(), "error.type").String(); got != "invalid_request_error" {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "error.type").String(); got != "invalid_request_error" {
t.Fatalf("error type = %q, want invalid_request_error", got)
}
}
@@ -757,11 +766,11 @@ func TestVideosCreateBindsRetrieveToSelectedAuth(t *testing.T) {
if createResp.Code != http.StatusOK {
t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
}
- videoID := gjson.GetBytes(createResp.Body.Bytes(), "id").String()
+ videoID := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "id").String()
if videoID != executor.requestID {
t.Fatalf("created video id = %q, want %q", videoID, executor.requestID)
}
- if got := gjson.GetBytes(createResp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel {
+ if got := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "model").String(); got != defaultXAIVideosModel {
t.Fatalf("created model = %q, want %s", got, defaultXAIVideosModel)
}
@@ -788,7 +797,7 @@ func TestXAIVideosNativeCreateBindsRetrieveToSelectedAuth(t *testing.T) {
if createResp.Code != http.StatusOK {
t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
}
- videoID := gjson.GetBytes(createResp.Body.Bytes(), "request_id").String()
+ videoID := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "request_id").String()
if videoID != executor.requestID {
t.Fatalf("created request_id = %q, want %q", videoID, executor.requestID)
}
@@ -845,7 +854,7 @@ func TestXAIVideosNativeRetrieveUsesCanonicalBoundModel(t *testing.T) {
if createResp.Code != http.StatusOK {
t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
}
- videoID := gjson.GetBytes(createResp.Body.Bytes(), "request_id").String()
+ videoID := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "request_id").String()
if videoID != executor.requestID {
t.Fatalf("created request_id = %q, want %q", videoID, executor.requestID)
}
@@ -882,6 +891,172 @@ func TestXAIVideosNativeRetrieveUsesCanonicalBoundModel(t *testing.T) {
}
}
+func TestVideosLiteCreatePreservesResolution(t *testing.T) {
+ resetVideoAuthBindingsForTest(t)
+ executor := &videoAuthCaptureExecutor{requestID: "video-lite-resolution"}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ manager.RegisterExecutor(executor)
+ auth := &coreauth.Auth{ID: "video-lite-resolution-auth", Provider: "xai", Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("manager.Register(): %v", errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: xaiVideos15LiteModel}})
+ manager.RefreshSchedulerEntry(auth.ID)
+ t.Cleanup(func() {
+ registry.GetGlobalRegistry().UnregisterClient(auth.ID)
+ })
+ handler := NewOpenAIAPIHandler(apihandlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager))
+
+ for _, tc := range []struct {
+ name string
+ resolution string
+ want string
+ }{
+ {name: "1080p", resolution: "1080p", want: "1080p"},
+ {name: "480p", resolution: "480p", want: "480p"},
+ {name: "720p", resolution: "720p", want: "720p"},
+ {name: "default", want: "720p"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ body := `{"model":"grok-imagine-video-1.5-lite","prompt":"make a video"`
+ if tc.resolution != "" {
+ body += `,"resolution":` + strconv.Quote(tc.resolution)
+ }
+ body += `}`
+ executor.mu.Lock()
+ before := len(executor.payloadResolutions)
+ executor.mu.Unlock()
+ resp := performVideosEndpointRequest(t, http.MethodPost, openAIVideosPath, "application/json", strings.NewReader(body), handler.VideosCreate)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("create status = %d, want %d: %s", resp.Code, http.StatusOK, resp.Body.String())
+ }
+ executor.mu.Lock()
+ defer executor.mu.Unlock()
+ if len(executor.payloadResolutions) != before+1 {
+ t.Fatalf("upstream calls = %d, want %d", len(executor.payloadResolutions), before+1)
+ }
+ if got := executor.payloadResolutions[before]; got != tc.want {
+ t.Fatalf("upstream resolution = %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestVideosLiteCreateRetrievePreservesModelAndAuth(t *testing.T) {
+ const liteModel = "grok-imagine-video-1.5-lite"
+ for _, route := range []string{openAIVideosPath, xaiVideosGenerationsAPI, xaiVideosEditsAPI, xaiVideosExtensionsAPI} {
+ t.Run(route, func(t *testing.T) {
+ for _, model := range []string{liteModel, "xai/" + liteModel, "x-ai/" + liteModel, "grok/" + liteModel} {
+ t.Run(model, func(t *testing.T) {
+ resetVideoAuthBindingsForTest(t)
+ executor := &videoAuthCaptureExecutor{requestID: "video-lite-bound"}
+ manager := coreauth.NewManager(nil, &coreauth.RoundRobinSelector{}, nil)
+ manager.RegisterExecutor(executor)
+ authModels := []struct {
+ authID string
+ model string
+ }{
+ {authID: "video-lite-default-auth", model: defaultXAIVideosModel},
+ {authID: "video-lite-auth-a", model: liteModel},
+ {authID: "video-lite-auth-b", model: liteModel},
+ }
+ for _, entry := range authModels {
+ auth := &coreauth.Auth{ID: entry.authID, Provider: "xai", Status: coreauth.StatusActive}
+ if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("manager.Register(%s): %v", entry.authID, errRegister)
+ }
+ registry.GetGlobalRegistry().RegisterClient(entry.authID, auth.Provider, []*registry.ModelInfo{{ID: entry.model}})
+ manager.RefreshSchedulerEntry(entry.authID)
+ }
+ t.Cleanup(func() {
+ for _, entry := range authModels {
+ registry.GetGlobalRegistry().UnregisterClient(entry.authID)
+ }
+ })
+ handler := NewOpenAIAPIHandler(apihandlers.NewBaseAPIHandlers(&sdkconfig.SDKConfig{}, manager))
+ createHandler := handler.VideosCreate
+ retrieveHandler := handler.VideosRetrieve
+ idField, retrieveRoute := "id", openAIVideosPath+"/:video_id"
+ retrievePath := openAIVideosPath + "/" + executor.requestID
+ if route != openAIVideosPath {
+ switch route {
+ case xaiVideosGenerationsAPI:
+ createHandler = handler.XAIVideosGenerations
+ case xaiVideosEditsAPI:
+ createHandler = handler.XAIVideosEdits
+ case xaiVideosExtensionsAPI:
+ createHandler = handler.XAIVideosExtensions
+ }
+ retrieveHandler = handler.XAIVideosRetrieve
+ idField, retrieveRoute = "request_id", videosPath+"/:request_id"
+ retrievePath = videosPath + "/" + executor.requestID
+ }
+
+ body := strings.NewReader(`{"model":` + strconv.Quote(model) + `,"prompt":"make a video"}`)
+ createResp := performVideosEndpointRequest(t, http.MethodPost, route, "application/json", body, createHandler)
+ if createResp.Code != http.StatusOK {
+ t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
+ }
+ if got := ojson.GetBytesNoCopy(createResp.Body.Bytes(), idField).String(); got != executor.requestID {
+ t.Fatalf("created %s = %q, want %q", idField, got, executor.requestID)
+ }
+ retrieveResp := performVideosRouteRequest(t, http.MethodGet, retrieveRoute, retrievePath, "", nil, retrieveHandler)
+ if retrieveResp.Code != http.StatusOK {
+ t.Fatalf("retrieve status = %d, want %d: %s", retrieveResp.Code, http.StatusOK, retrieveResp.Body.String())
+ }
+ if route == openAIVideosPath {
+ for _, resp := range []*httptest.ResponseRecorder{createResp, retrieveResp} {
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), "model").String(); got != liteModel {
+ t.Fatalf("response model = %q, want %s", got, liteModel)
+ }
+ }
+ }
+ authIDs := executor.AuthIDs()
+ if len(authIDs) != 2 || (authIDs[0] != "video-lite-auth-a" && authIDs[0] != "video-lite-auth-b") || authIDs[1] != authIDs[0] {
+ t.Fatalf("authIDs = %v, want both calls to use the same Lite auth", authIDs)
+ }
+ models := executor.Models()
+ if len(models) != 2 || models[0] != liteModel || models[1] != liteModel {
+ t.Fatalf("models = %v, want both calls to route with %s", models, liteModel)
+ }
+ payloadModels := executor.PayloadModels()
+ if len(payloadModels) != 2 || payloadModels[0] != liteModel {
+ t.Fatalf("payload models = %v, want create payload model %s", payloadModels, liteModel)
+ }
+ })
+ }
+ })
+ }
+}
+
+func TestVideosLiteRejectsForeignProviderPrefix(t *testing.T) {
+ handler := &OpenAIAPIHandler{}
+ for _, route := range []struct {
+ path string
+ handler gin.HandlerFunc
+ }{
+ {openAIVideosPath, handler.VideosCreate},
+ {xaiVideosGenerationsAPI, handler.XAIVideosGenerations},
+ {xaiVideosEditsAPI, handler.XAIVideosEdits},
+ {xaiVideosExtensionsAPI, handler.XAIVideosExtensions},
+ } {
+ t.Run(route.path, func(t *testing.T) {
+ body := strings.NewReader(`{"model":"codex/grok-imagine-video-1.5-lite","prompt":"make a video"}`)
+ resp := performVideosEndpointRequest(t, http.MethodPost, route.path, "application/json", body, route.handler)
+ if resp.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d: %s", resp.Code, http.StatusBadRequest, resp.Body.String())
+ }
+ errorField := "error.type"
+ if route.path == openAIVideosPath {
+ errorField = "error.code"
+ }
+ if got := ojson.GetBytesNoCopy(resp.Body.Bytes(), errorField).String(); got != "invalid_request_error" {
+ t.Fatalf("%s = %q, want invalid_request_error", errorField, got)
+ }
+ })
+ }
+}
+
func TestVideosCreatePreviewAliasUsesPreviewAuthWithGAPayload(t *testing.T) {
resetVideoAuthBindingsForTest(t)
executor := &videoAuthCaptureExecutor{requestID: "video-openai-preview-alias"}
@@ -891,8 +1066,8 @@ func TestVideosCreatePreviewAliasUsesPreviewAuthWithGAPayload(t *testing.T) {
if createResp.Code != http.StatusOK {
t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
}
- videoID := gjson.GetBytes(createResp.Body.Bytes(), "id").String()
- if got := gjson.GetBytes(createResp.Body.Bytes(), "model").String(); got != xaiVideos15Model {
+ videoID := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "id").String()
+ if got := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "model").String(); got != xaiVideos15Model {
t.Fatalf("response model = %q, want %s", got, xaiVideos15Model)
}
@@ -913,8 +1088,8 @@ func TestVideosCreatePreviewAliasUsesDefaultXAIModelsWithGAPayload(t *testing.T)
if createResp.Code != http.StatusOK {
t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
}
- videoID := gjson.GetBytes(createResp.Body.Bytes(), "id").String()
- if got := gjson.GetBytes(createResp.Body.Bytes(), "model").String(); got != xaiVideos15Model {
+ videoID := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "id").String()
+ if got := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "model").String(); got != xaiVideos15Model {
t.Fatalf("response model = %q, want %s", got, xaiVideos15Model)
}
@@ -935,7 +1110,7 @@ func TestXAIVideosNativePreviewAliasUsesPreviewAuthWithGAPayload(t *testing.T) {
if createResp.Code != http.StatusOK {
t.Fatalf("create status = %d, want %d: %s", createResp.Code, http.StatusOK, createResp.Body.String())
}
- videoID := gjson.GetBytes(createResp.Body.Bytes(), "request_id").String()
+ videoID := ojson.GetBytesNoCopy(createResp.Body.Bytes(), "request_id").String()
retrieveResp := performVideosRouteRequest(t, http.MethodGet, videosPath+"/:request_id", videosPath+"/"+videoID, "", nil, handler.XAIVideosRetrieve)
if retrieveResp.Code != http.StatusOK {
@@ -1033,7 +1208,7 @@ func TestVideosCreateFormRequest(t *testing.T) {
t.Fatalf("videosCreateRequestFromFormContext() error = %v", err)
}
- if got := gjson.GetBytes(rawJSON, "input_reference.image_url").String(); got != "https://example.com/a.png" {
+ if got := ojson.GetBytesNoCopy(rawJSON, "input_reference.image_url").String(); got != "https://example.com/a.png" {
t.Fatalf("input_reference.image_url = %q", got)
}
}
diff --git a/sdk/auth/antigravity_models.go b/sdk/auth/antigravity_models.go
new file mode 100644
index 00000000000..87dd67ac2ef
--- /dev/null
+++ b/sdk/auth/antigravity_models.go
@@ -0,0 +1,327 @@
+package auth
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "maps"
+ "net/http"
+ "net/url"
+ "slices"
+ "strings"
+
+ "github.com/MoeForge/moego/ojson"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
+ log "github.com/sirupsen/logrus"
+)
+
+const (
+ // AntigravityModelBaseURLDaily is the default Cloud Code endpoint for Antigravity model discovery.
+ AntigravityModelBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
+ // AntigravityModelsPath is the upstream path for per-account model capability discovery.
+ AntigravityModelsPath = "/v1internal:fetchAvailableModels"
+ // AntigravityPersistedHintsMetaKey is the auth metadata key storing persisted model capability hints.
+ AntigravityPersistedHintsMetaKey = "antigravity_capability_hints"
+
+ antigravityMaxCatalogModels = 1024
+ antigravityMaxModelIDBytes = 256
+)
+
+// AntigravityProbeStatus classifies the outcome of an Antigravity model discovery request.
+type AntigravityProbeStatus int
+
+const (
+ AntigravityProbeStatusSuccess AntigravityProbeStatus = iota
+ AntigravityProbeStatusAuthError
+ AntigravityProbeStatusTransientError
+)
+
+// AntigravityModelCapabilityHints holds normalized per-account model IDs and web-search capability IDs.
+type AntigravityModelCapabilityHints struct {
+ WebSearchModelIDs map[string]struct{}
+ // Nil means unknown; an empty non-nil set is an authoritative empty catalog.
+ ModelIDs map[string]struct{}
+}
+
+// Clone returns a deep copy of the capability hints.
+func (h AntigravityModelCapabilityHints) Clone() AntigravityModelCapabilityHints {
+ return AntigravityModelCapabilityHints{
+ WebSearchModelIDs: maps.Clone(h.WebSearchModelIDs),
+ ModelIDs: maps.Clone(h.ModelIDs),
+ }
+}
+
+// AntigravityModelProbeRequest configures a single-shot fetchAvailableModels probe.
+type AntigravityModelProbeRequest struct {
+ BaseURLs []string
+ ProxyURL string
+ AccessToken string
+ ProjectID string
+ UserAgent string
+}
+
+// FetchAntigravityAvailableModels performs a single-shot POST /v1internal:fetchAvailableModels request
+// and parses the returned per-account model catalog and webSearchModelIds.
+func FetchAntigravityAvailableModels(ctx context.Context, req AntigravityModelProbeRequest) (AntigravityModelCapabilityHints, AntigravityProbeStatus) {
+ baseURL := ""
+ for _, candidate := range req.BaseURLs {
+ if trimmed := strings.TrimRight(strings.TrimSpace(candidate), "/"); trimmed != "" {
+ baseURL = trimmed
+ break
+ }
+ }
+ if baseURL == "" {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ accessToken := strings.TrimSpace(req.AccessToken)
+ if accessToken == "" {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusAuthError
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ client := &http.Client{
+ CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+ transport, _, errProxy := proxyutil.BuildHTTPTransport(strings.TrimSpace(req.ProxyURL))
+ if errProxy != nil {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ if transport != nil {
+ client.Transport = transport
+ defer transport.CloseIdleConnections()
+ }
+
+ doc := ojson.NewDoc([]byte("{}"))
+ doc.Set("project", strings.TrimSpace(req.ProjectID))
+ httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+AntigravityModelsPath, bytes.NewReader(doc.Bytes()))
+ if errReq != nil {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ userAgent := strings.TrimSpace(req.UserAgent)
+ if userAgent == "" {
+ userAgent = AntigravityUserAgent()
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("Authorization", "Bearer "+accessToken)
+ httpReq.Header.Set("User-Agent", userAgent)
+
+ resp, errDo := client.Do(httpReq)
+ if errDo != nil {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ defer func() {
+ if errClose := resp.Body.Close(); errClose != nil {
+ log.Debug("antigravity model fetch: response close failed")
+ }
+ }()
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusAuthError
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ const maxCatalogBytes = 8 << 20
+ data, errRead := io.ReadAll(io.LimitReader(resp.Body, maxCatalogBytes+1))
+ if errRead != nil || len(data) > maxCatalogBytes {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ hints, ok := ParseAntigravityModelCapabilityHints(data)
+ if !ok {
+ return AntigravityModelCapabilityHints{}, AntigravityProbeStatusTransientError
+ }
+ return hints, AntigravityProbeStatusSuccess
+}
+
+// NormalizeAntigravityModelID normalizes an Antigravity model identifier for case-insensitive matching.
+func NormalizeAntigravityModelID(modelID string) string {
+ trimmed := strings.ToLower(strings.TrimSpace(modelID))
+ if len(trimmed) > antigravityMaxModelIDBytes {
+ return ""
+ }
+ return strings.Clone(trimmed)
+}
+
+func sanitizeAntigravityPersistedEndpoint(endpoint string) string {
+ trimmed := strings.TrimRight(strings.TrimSpace(endpoint), "/")
+ if trimmed == "" {
+ return ""
+ }
+ if parsed, errParse := url.Parse(trimmed); errParse == nil && parsed.User != nil {
+ parsed.User = nil
+ return strings.TrimRight(parsed.String(), "/")
+ }
+ return trimmed
+}
+
+// ParseAntigravityModelCapabilityHints decodes a fetchAvailableModels response payload using ojson.
+func ParseAntigravityModelCapabilityHints(body []byte) (AntigravityModelCapabilityHints, bool) {
+ if len(body) == 0 || !ojson.ValidBytes(body) {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ top := ojson.ParseBytes(body)
+ if !top.IsObject() {
+ return AntigravityModelCapabilityHints{}, false
+ }
+
+ webSearchResult := top.Get("webSearchModelIds")
+ webSearchModels := make(map[string]struct{})
+ if webSearchResult.Exists() && webSearchResult.Type != ojson.Null {
+ if !webSearchResult.IsArray() {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ array := webSearchResult.Array()
+ if len(array) > antigravityMaxCatalogModels {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ for _, item := range array {
+ if item.Type != ojson.String {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ if normalized := NormalizeAntigravityModelID(item.Str); normalized != "" {
+ webSearchModels[normalized] = struct{}{}
+ }
+ }
+ }
+
+ modelsResult := top.Get("models")
+ var modelIDs map[string]struct{}
+ if modelsResult.Exists() && modelsResult.Type != ojson.Null {
+ if !modelsResult.IsObject() {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ modelMap := modelsResult.Map()
+ if len(modelMap) > antigravityMaxCatalogModels {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ modelIDs = make(map[string]struct{}, len(modelMap))
+ for id := range modelMap {
+ if normalized := NormalizeAntigravityModelID(id); normalized != "" {
+ modelIDs[normalized] = struct{}{}
+ }
+ }
+ }
+
+ return AntigravityModelCapabilityHints{
+ WebSearchModelIDs: webSearchModels,
+ ModelIDs: modelIDs,
+ }, true
+}
+
+// BuildPersistedAntigravityCapabilityHints serializes capability hints for storage in auth metadata.
+// It returns nil when hints.ModelIDs is nil so unknown catalogs are never persisted as authoritative empty sets.
+func BuildPersistedAntigravityCapabilityHints(project, endpoint string, hints AntigravityModelCapabilityHints) map[string]any {
+ if hints.ModelIDs == nil {
+ return nil
+ }
+ modelIDs := make([]string, 0, len(hints.ModelIDs))
+ for id := range hints.ModelIDs {
+ if normalized := NormalizeAntigravityModelID(id); normalized != "" {
+ modelIDs = append(modelIDs, normalized)
+ }
+ }
+ slices.Sort(modelIDs)
+ modelItems := make([]any, len(modelIDs))
+ for i, id := range modelIDs {
+ modelItems[i] = id
+ }
+ value := map[string]any{
+ "model_ids": modelItems,
+ }
+ if project = strings.TrimSpace(project); project != "" {
+ value["project_id"] = project
+ }
+ if endpoint = sanitizeAntigravityPersistedEndpoint(endpoint); endpoint != "" {
+ value["endpoint"] = endpoint
+ }
+ if len(hints.WebSearchModelIDs) > 0 {
+ webSearchIDs := make([]string, 0, len(hints.WebSearchModelIDs))
+ for id := range hints.WebSearchModelIDs {
+ if normalized := NormalizeAntigravityModelID(id); normalized != "" {
+ webSearchIDs = append(webSearchIDs, normalized)
+ }
+ }
+ slices.Sort(webSearchIDs)
+ if len(webSearchIDs) > 0 {
+ webSearchItems := make([]any, len(webSearchIDs))
+ for i, id := range webSearchIDs {
+ webSearchItems[i] = id
+ }
+ value["web_search_model_ids"] = webSearchItems
+ }
+ }
+ return value
+}
+
+// ParsePersistedAntigravityCapabilityHints extracts and validates persisted capability hints from auth metadata.
+func ParsePersistedAntigravityCapabilityHints(metadata map[string]any, expectedProject, expectedEndpoint string) (AntigravityModelCapabilityHints, bool) {
+ if metadata == nil {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ raw, ok := metadata[AntigravityPersistedHintsMetaKey]
+ if !ok || raw == nil {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ rawMap, ok := raw.(map[string]any)
+ if !ok {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ persistedProject, _ := rawMap["project_id"].(string)
+ persistedEndpoint, _ := rawMap["endpoint"].(string)
+ if strings.TrimSpace(persistedProject) != strings.TrimSpace(expectedProject) {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ if sanitizeAntigravityPersistedEndpoint(persistedEndpoint) != sanitizeAntigravityPersistedEndpoint(expectedEndpoint) {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ rawModels, hasModels := rawMap["model_ids"]
+ if !hasModels || rawModels == nil {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ modelIDs, ok := parsePersistedAntigravityModelSet(rawModels)
+ if !ok {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ webSearchModels := make(map[string]struct{})
+ if rawWebSearch, hasWebSearch := rawMap["web_search_model_ids"]; hasWebSearch && rawWebSearch != nil {
+ parsedWebSearch, okWebSearch := parsePersistedAntigravityModelSet(rawWebSearch)
+ if !okWebSearch {
+ return AntigravityModelCapabilityHints{}, false
+ }
+ webSearchModels = parsedWebSearch
+ }
+ return AntigravityModelCapabilityHints{
+ ModelIDs: modelIDs,
+ WebSearchModelIDs: webSearchModels,
+ }, true
+}
+
+func parsePersistedAntigravityModelSet(raw any) (map[string]struct{}, bool) {
+ switch items := raw.(type) {
+ case []any:
+ set := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ s, ok := item.(string)
+ if !ok {
+ return nil, false
+ }
+ if normalized := NormalizeAntigravityModelID(s); normalized != "" {
+ set[normalized] = struct{}{}
+ }
+ }
+ return set, true
+ case []string:
+ set := make(map[string]struct{}, len(items))
+ for _, s := range items {
+ if normalized := NormalizeAntigravityModelID(s); normalized != "" {
+ set[normalized] = struct{}{}
+ }
+ }
+ return set, true
+ default:
+ return nil, false
+ }
+}
diff --git a/sdk/auth/antigravity_models_test.go b/sdk/auth/antigravity_models_test.go
new file mode 100644
index 00000000000..11459d85040
--- /dev/null
+++ b/sdk/auth/antigravity_models_test.go
@@ -0,0 +1,248 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestNormalizeAntigravityModelID(t *testing.T) {
+ cases := []struct {
+ input string
+ expected string
+ }{
+ {"gemini-3-flash", "gemini-3-flash"},
+ {" GEMINI-3-FLASH ", "gemini-3-flash"},
+ {"", ""},
+ {" ", ""},
+ {strings.Repeat("a", 256), strings.Repeat("a", 256)},
+ {strings.Repeat("a", 257), ""},
+ }
+ for _, c := range cases {
+ got := NormalizeAntigravityModelID(c.input)
+ if got != c.expected {
+ t.Errorf("NormalizeAntigravityModelID(%q) = %q, want %q", c.input, got, c.expected)
+ }
+ }
+}
+
+func TestParseAntigravityModelCapabilityHints(t *testing.T) {
+ t.Run("valid payload with models and web search", func(t *testing.T) {
+ body := []byte(`{"models":{"gemini-3-flash":{"displayName":"Gemini 3 Flash"},"claude-sonnet-5-5-medium":{"displayName":"Claude Sonnet 5.5"}},"webSearchModelIds":["gemini-3-flash"]}`)
+ hints, ok := ParseAntigravityModelCapabilityHints(body)
+ if !ok {
+ t.Fatal("expected ok to be true")
+ }
+ if hints.ModelIDs == nil {
+ t.Fatal("expected non-nil ModelIDs")
+ }
+ if len(hints.ModelIDs) != 2 {
+ t.Errorf("got %d models, want 2", len(hints.ModelIDs))
+ }
+ if _, exists := hints.ModelIDs["gemini-3-flash"]; !exists {
+ t.Errorf("missing gemini-3-flash in ModelIDs")
+ }
+ if len(hints.WebSearchModelIDs) != 1 {
+ t.Errorf("got %d webSearchModelIds, want 1", len(hints.WebSearchModelIDs))
+ }
+ if _, exists := hints.WebSearchModelIDs["gemini-3-flash"]; !exists {
+ t.Errorf("missing gemini-3-flash in WebSearchModelIDs")
+ }
+ })
+
+ t.Run("empty catalog is authoritative empty", func(t *testing.T) {
+ body := []byte(`{"models":{}}`)
+ hints, ok := ParseAntigravityModelCapabilityHints(body)
+ if !ok {
+ t.Fatal("expected ok to be true")
+ }
+ if hints.ModelIDs == nil {
+ t.Fatal("expected non-nil empty ModelIDs")
+ }
+ if len(hints.ModelIDs) != 0 {
+ t.Errorf("expected 0 models, got %d", len(hints.ModelIDs))
+ }
+ })
+
+ t.Run("missing models field leaves ModelIDs nil", func(t *testing.T) {
+ body := []byte(`{"webSearchModelIds":["gemini-3.5-flash-lite"]}`)
+ hints, ok := ParseAntigravityModelCapabilityHints(body)
+ if !ok {
+ t.Fatal("expected ok to be true")
+ }
+ if hints.ModelIDs != nil {
+ t.Fatal("expected nil ModelIDs for unknown catalog")
+ }
+ if len(hints.WebSearchModelIDs) != 1 {
+ t.Errorf("got %d webSearchModelIds, want 1", len(hints.WebSearchModelIDs))
+ }
+ })
+
+ t.Run("invalid json returns false", func(t *testing.T) {
+ _, ok := ParseAntigravityModelCapabilityHints([]byte(`{not valid json`))
+ if ok {
+ t.Fatal("expected false for invalid JSON")
+ }
+ })
+
+ t.Run("clones model IDs out of caller buffer", func(t *testing.T) {
+ buf := []byte(`{"models":{"gemini-3-flash":{}},"webSearchModelIds":["gemini-3-flash"]}`)
+ hints, ok := ParseAntigravityModelCapabilityHints(buf)
+ if !ok {
+ t.Fatal("expected ok to be true")
+ }
+ for i := range buf {
+ buf[i] = 'x'
+ }
+ if _, exists := hints.ModelIDs["gemini-3-flash"]; !exists {
+ t.Fatal("expected gemini-3-flash in ModelIDs after buffer mutation")
+ }
+ if _, exists := hints.WebSearchModelIDs["gemini-3-flash"]; !exists {
+ t.Fatal("expected gemini-3-flash in WebSearchModelIDs after buffer mutation")
+ }
+ })
+}
+
+func TestPersistedAntigravityCapabilityHintsRoundTrip(t *testing.T) {
+ hints := AntigravityModelCapabilityHints{
+ ModelIDs: map[string]struct{}{
+ "gemini-3-flash": {},
+ "gemini-4-argon": {},
+ },
+ WebSearchModelIDs: map[string]struct{}{
+ "gemini-3-flash": {},
+ },
+ }
+ project := "aicode-consumers"
+ endpointWithUser := "https://user:password@cloudcode-pa.googleapis.com"
+ expectedEndpoint := "https://cloudcode-pa.googleapis.com"
+
+ serialized := BuildPersistedAntigravityCapabilityHints(project, endpointWithUser, hints)
+ if ep, ok := serialized["endpoint"].(string); !ok || ep != expectedEndpoint {
+ t.Errorf("expected endpoint %q, got %q", expectedEndpoint, ep)
+ }
+
+ metadata := map[string]any{
+ AntigravityPersistedHintsMetaKey: serialized,
+ }
+
+ parsed, ok := ParsePersistedAntigravityCapabilityHints(metadata, project, expectedEndpoint)
+ if !ok {
+ t.Fatal("expected parse to succeed")
+ }
+ if len(parsed.ModelIDs) != 2 {
+ t.Errorf("got %d models, want 2", len(parsed.ModelIDs))
+ }
+ if len(parsed.WebSearchModelIDs) != 1 {
+ t.Errorf("got %d web search models, want 1", len(parsed.WebSearchModelIDs))
+ }
+
+ // Mismatched project
+ if _, okMismatch := ParsePersistedAntigravityCapabilityHints(metadata, "other-project", expectedEndpoint); okMismatch {
+ t.Error("expected mismatch for different project")
+ }
+
+ // Mismatched endpoint
+ if _, okMismatch := ParsePersistedAntigravityCapabilityHints(metadata, project, "https://other.com"); okMismatch {
+ t.Error("expected mismatch for different endpoint")
+ }
+
+ // Nil ModelIDs (unknown catalog) must not serialize as authoritative empty catalog
+ if gotNil := BuildPersistedAntigravityCapabilityHints(project, expectedEndpoint, AntigravityModelCapabilityHints{}); gotNil != nil {
+ t.Errorf("expected nil persisted hints for unknown catalog, got %v", gotNil)
+ }
+}
+
+func TestFetchAntigravityAvailableModels(t *testing.T) {
+ t.Run("successful probe", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("expected POST, got %s", r.Method)
+ }
+ if r.Header.Get("Authorization") != "Bearer test-token" {
+ t.Errorf("unexpected authorization header: %s", r.Header.Get("Authorization"))
+ }
+ if r.Header.Get("Content-Type") != "application/json" {
+ t.Errorf("unexpected content-type: %s", r.Header.Get("Content-Type"))
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"models":{"gemini-3-flash":{}},"webSearchModelIds":["gemini-3-flash"]}`))
+ }))
+ defer server.Close()
+
+ hints, status := FetchAntigravityAvailableModels(context.Background(), AntigravityModelProbeRequest{
+ BaseURLs: []string{server.URL},
+ AccessToken: "test-token",
+ ProjectID: "aicode-consumers",
+ })
+ if status != AntigravityProbeStatusSuccess {
+ t.Fatalf("expected success, got %v", status)
+ }
+ if len(hints.ModelIDs) != 1 {
+ t.Errorf("got %d models, want 1", len(hints.ModelIDs))
+ }
+ })
+
+ t.Run("auth error 401", func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer server.Close()
+
+ _, status := FetchAntigravityAvailableModels(context.Background(), AntigravityModelProbeRequest{
+ BaseURLs: []string{server.URL},
+ AccessToken: "bad-token",
+ })
+ if status != AntigravityProbeStatusAuthError {
+ t.Fatalf("expected auth error, got %v", status)
+ }
+ })
+
+ t.Run("empty base urls returns transient error", func(t *testing.T) {
+ _, status := FetchAntigravityAvailableModels(context.Background(), AntigravityModelProbeRequest{
+ BaseURLs: nil,
+ AccessToken: "token",
+ })
+ if status != AntigravityProbeStatusTransientError {
+ t.Fatalf("expected transient error, got %v", status)
+ }
+ })
+
+ t.Run("empty access token returns auth error", func(t *testing.T) {
+ _, status := FetchAntigravityAvailableModels(context.Background(), AntigravityModelProbeRequest{
+ BaseURLs: []string{"https://example.com"},
+ AccessToken: "",
+ })
+ if status != AntigravityProbeStatusAuthError {
+ t.Fatalf("expected auth error, got %v", status)
+ }
+ })
+
+ t.Run("redirect is not followed", func(t *testing.T) {
+ redirectTargetVisited := false
+ targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ redirectTargetVisited = true
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer targetServer.Close()
+
+ sourceServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, targetServer.URL, http.StatusFound)
+ }))
+ defer sourceServer.Close()
+
+ _, status := FetchAntigravityAvailableModels(context.Background(), AntigravityModelProbeRequest{
+ BaseURLs: []string{sourceServer.URL},
+ AccessToken: "token",
+ })
+ if redirectTargetVisited {
+ t.Fatal("redirect target should not have been visited")
+ }
+ if status != AntigravityProbeStatusTransientError {
+ t.Fatalf("expected transient error for 302 without redirect, got %v", status)
+ }
+ })
+}
diff --git a/sdk/auth/codex_auth_record_test.go b/sdk/auth/codex_auth_record_test.go
index 4865627ce86..9af33a83d2e 100644
--- a/sdk/auth/codex_auth_record_test.go
+++ b/sdk/auth/codex_auth_record_test.go
@@ -1,8 +1,8 @@
package auth
import (
- "encoding/base64"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"strings"
"testing"
diff --git a/sdk/auth/manager.go b/sdk/auth/manager.go
index 403426a8bd0..4cbefd98e85 100644
--- a/sdk/auth/manager.go
+++ b/sdk/auth/manager.go
@@ -9,6 +9,7 @@ import (
"strings"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
+ xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
)
@@ -94,6 +95,38 @@ func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config
if legacyClaudeCredential != nil {
coreauth.MergeExistingAuthMetadata(record, legacyClaudeCredential.Metadata)
}
+ legacyXAICredential, errLegacyXAI := xaiauth.FindMatchingLegacyCredential(ctx, m.store, record)
+ if errLegacyXAI != nil {
+ return record, "", errLegacyXAI
+ }
+ adoptXAICredential := legacyXAICredential != nil && record != nil &&
+ strings.EqualFold(strings.TrimSpace(record.Provider), "xai") &&
+ metadataStringValue(record.Metadata, "email") == "" &&
+ metadataStringValue(record.Metadata, "sub") != "" &&
+ metadataStringValue(legacyXAICredential.Metadata, "email") != ""
+ if adoptXAICredential {
+ // The upstream stopped returning the email; keep the account on its
+ // richer email-named credential (richest-known name wins) so the next
+ // login that returns the email finds the same file again. Renaming is
+ // limited to stores that resolve the save path from the record name;
+ // every built-in backend (file, git, Postgres, object) exposes the
+ // SetBaseDir capability, so unknown stores keep the ordinary
+ // migration instead of risking a rename they cannot honor.
+ if _, isFileNameStore := m.store.(interface{ SetBaseDir(string) }); !isFileNameStore {
+ adoptXAICredential = false
+ } else if disabled, hasDisabled := record.Metadata["disabled"].(bool); hasDisabled && disabled {
+ // The destination file deliberately disables this credential; renaming
+ // would leave it disabled while the richer file gets deleted, losing
+ // the operator's opt-out and the saved credential on re-login.
+ adoptXAICredential = false
+ }
+ }
+ if legacyXAICredential != nil {
+ coreauth.MergeExistingAuthMetadata(record, legacyXAICredential.Metadata)
+ }
+ if adoptXAICredential {
+ adoptLegacyXAIFileName(record, legacyXAICredential)
+ }
savedPath, err := m.store.Save(coreauth.WithAuthCreationIntent(ctx), record)
if err != nil {
@@ -111,5 +144,54 @@ func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config
return record, savedPath, fmt.Errorf("cliproxy auth: canonical Claude credential saved but legacy credential cleanup failed: %w", errDelete)
}
}
+ if legacyXAICredential != nil && !adoptXAICredential {
+ if strings.TrimSpace(savedPath) == "" {
+ return record, "", fmt.Errorf("cliproxy auth: canonical xAI credential was not persisted; legacy credential retained")
+ }
+ legacyID := strings.TrimSpace(legacyXAICredential.ID)
+ if legacyID == "" {
+ legacyID = strings.TrimSpace(legacyXAICredential.FileName)
+ }
+ if errDelete := m.store.Delete(ctx, legacyID); errDelete != nil {
+ return record, savedPath, fmt.Errorf("cliproxy auth: canonical xAI credential saved but legacy credential cleanup failed: %w", errDelete)
+ }
+ }
return record, savedPath, nil
}
+
+func metadataStringValue(metadata map[string]any, key string) string {
+ value, _ := metadata[key].(string)
+ return strings.TrimSpace(value)
+}
+
+// adoptLegacyXAIFileName points a subject-only xAI record at the existing
+// email-named credential so the save refreshes that file in place. The token
+// storage email is backfilled because SaveTokenToFile merges the record
+// metadata over the serialized storage and would otherwise drop the email.
+// Path attributes pointing at the superseded subject-named file are cleared
+// so the store resolves the adopted name.
+func adoptLegacyXAIFileName(record, legacy *coreauth.Auth) {
+ legacyFileName := strings.TrimSpace(legacy.FileName)
+ if legacyFileName == "" {
+ legacyFileName = strings.TrimSpace(legacy.ID)
+ }
+ if legacyFileName == "" {
+ return
+ }
+ oldBaseName := filepath.Base(strings.TrimSpace(record.FileName))
+ if oldBaseName == "" || oldBaseName == "." {
+ oldBaseName = filepath.Base(strings.TrimSpace(record.ID))
+ }
+ record.ID = legacyFileName
+ record.FileName = legacyFileName
+ if storage, ok := record.Storage.(*xaiauth.TokenStorage); ok && storage != nil {
+ if email := metadataStringValue(legacy.Metadata, "email"); email != "" {
+ storage.Email = email
+ }
+ }
+ for key, value := range record.Attributes {
+ if strings.TrimSpace(value) != "" && strings.EqualFold(filepath.Base(strings.TrimSpace(value)), oldBaseName) {
+ delete(record.Attributes, key)
+ }
+ }
+}
diff --git a/sdk/auth/manager_test.go b/sdk/auth/manager_test.go
index 21cf6b358a2..5c42d114f3d 100644
--- a/sdk/auth/manager_test.go
+++ b/sdk/auth/manager_test.go
@@ -9,6 +9,7 @@ import (
"time"
claudeauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/claude"
+ xaiauth "github.com/router-for-me/CLIProxyAPI/v8/internal/auth/xai"
"github.com/router-for-me/CLIProxyAPI/v8/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
)
@@ -193,6 +194,185 @@ func TestManagerLogin_MigratesMatchingLegacyClaudeCredential(t *testing.T) {
}
}
+func TestManagerLogin_ReloginKeepsCanonicalXAICredential(t *testing.T) {
+ authDir := t.TempDir()
+ targetFileName := xaiauth.CredentialFileName("a+b@example.com", "sub-first")
+ targetPath := filepath.Join(authDir, targetFileName)
+
+ existing := map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "old-token",
+ "prefix": "team",
+ }
+ raw, errMarshal := json.Marshal(existing)
+ if errMarshal != nil {
+ t.Fatalf("marshal canonical credential: %v", errMarshal)
+ }
+ if errWrite := os.WriteFile(targetPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write canonical credential: %v", errWrite)
+ }
+
+ newRecord := &coreauth.Auth{
+ ID: targetFileName,
+ FileName: targetFileName,
+ Provider: "xai",
+ Storage: &xaiauth.TokenStorage{
+ Type: "xai",
+ Email: "a+b@example.com",
+ Subject: "sub-first",
+ AccessToken: "new-token",
+ },
+ Metadata: map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "new-token",
+ },
+ }
+
+ store := NewFileTokenStore()
+ store.SetBaseDir(authDir)
+ mgr := NewManager(store, &dummyAuthenticator{provider: "xai", record: newRecord})
+
+ _, savedPath, errLogin := mgr.Login(context.Background(), "xai", &config.Config{AuthDir: authDir}, nil)
+ if errLogin != nil {
+ t.Fatalf("Login error: %v", errLogin)
+ }
+ if savedPath != targetPath {
+ t.Fatalf("savedPath = %s, want %s", savedPath, targetPath)
+ }
+ savedRaw, errRead := os.ReadFile(targetPath)
+ if errRead != nil {
+ t.Fatalf("canonical credential was deleted by re-login: %v", errRead)
+ }
+ var saved map[string]any
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal canonical credential: %v", errUnmarshal)
+ }
+ if saved["access_token"] != "new-token" || saved["prefix"] != "team" {
+ t.Fatalf("saved credential = %#v, want new token with preserved prefix", saved)
+ }
+}
+
+func TestManagerLogin_SubjectOnlyReloginKeepsEmailCredential(t *testing.T) {
+ authDir := t.TempDir()
+ emailFileName := xaiauth.CredentialFileName("a+b@example.com", "sub-first")
+ emailPath := filepath.Join(authDir, emailFileName)
+
+ existing := map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "old-token",
+ "prefix": "team",
+ "weight": float64(5),
+ }
+ raw, errMarshal := json.Marshal(existing)
+ if errMarshal != nil {
+ t.Fatalf("marshal canonical credential: %v", errMarshal)
+ }
+ if errWrite := os.WriteFile(emailPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write canonical credential: %v", errWrite)
+ }
+
+ // The upstream stops returning an email, so the authenticator names the
+ // record after the subject; its identity keys omit the missing email.
+ subjectFileName := xaiauth.CredentialFileName("", "sub-first")
+ newRecord := &coreauth.Auth{
+ ID: subjectFileName,
+ FileName: subjectFileName,
+ Provider: "xai",
+ Storage: &xaiauth.TokenStorage{
+ Type: "xai",
+ Subject: "sub-first",
+ AccessToken: "new-token",
+ },
+ Metadata: map[string]any{
+ "type": "xai",
+ "sub": "sub-first",
+ "access_token": "new-token",
+ },
+ }
+
+ store := NewFileTokenStore()
+ store.SetBaseDir(authDir)
+ mgr := NewManager(store, &dummyAuthenticator{provider: "xai", record: newRecord})
+
+ _, savedPath, errLogin := mgr.Login(context.Background(), "xai", &config.Config{AuthDir: authDir}, nil)
+ if errLogin != nil {
+ t.Fatalf("Login error: %v", errLogin)
+ }
+ if savedPath != emailPath {
+ t.Fatalf("savedPath = %s, want canonical email credential %s", savedPath, emailPath)
+ }
+ savedRaw, errRead := os.ReadFile(emailPath)
+ if errRead != nil {
+ t.Fatalf("read canonical credential: %v", errRead)
+ }
+ var saved map[string]any
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal canonical credential: %v", errUnmarshal)
+ }
+ if saved["access_token"] != "new-token" || saved["prefix"] != "team" || saved["weight"] != float64(5) || saved["email"] != "a+b@example.com" {
+ t.Fatalf("saved credential = %#v, want new token with preserved settings and email", saved)
+ }
+ entries, errReadDir := os.ReadDir(authDir)
+ if errReadDir != nil {
+ t.Fatalf("ReadDir error: %v", errReadDir)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("auth dir holds %d files, want the single canonical credential", len(entries))
+ }
+
+ // Return leg: the upstream returns the email again, and that login must
+ // land on the same canonical file with settings intact, completing the
+ // email+subject -> subject-only -> email+subject round trip.
+ returnRecord := &coreauth.Auth{
+ ID: emailFileName,
+ FileName: emailFileName,
+ Provider: "xai",
+ Storage: &xaiauth.TokenStorage{
+ Type: "xai",
+ Email: "a+b@example.com",
+ Subject: "sub-first",
+ AccessToken: "round-trip-token",
+ },
+ Metadata: map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "round-trip-token",
+ },
+ }
+ returnMgr := NewManager(store, &dummyAuthenticator{provider: "xai", record: returnRecord})
+ _, savedPath, errLogin = returnMgr.Login(context.Background(), "xai", &config.Config{AuthDir: authDir}, nil)
+ if errLogin != nil {
+ t.Fatalf("return Login error: %v", errLogin)
+ }
+ if savedPath != emailPath {
+ t.Fatalf("return savedPath = %s, want canonical email credential %s", savedPath, emailPath)
+ }
+ savedRaw, errRead = os.ReadFile(emailPath)
+ if errRead != nil {
+ t.Fatalf("read round-trip credential: %v", errRead)
+ }
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal round-trip credential: %v", errUnmarshal)
+ }
+ if saved["access_token"] != "round-trip-token" || saved["prefix"] != "team" || saved["weight"] != float64(5) || saved["email"] != "a+b@example.com" {
+ t.Fatalf("round-trip credential = %#v, want newest token with preserved settings and email", saved)
+ }
+ entries, errReadDir = os.ReadDir(authDir)
+ if errReadDir != nil {
+ t.Fatalf("ReadDir error: %v", errReadDir)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("auth dir holds %d files after round trip, want the single canonical credential", len(entries))
+ }
+}
+
func TestManagerLogin_DoesNotMigrateLegacyClaudeCredentialFromDifferentOrganization(t *testing.T) {
authDir := t.TempDir()
legacyFileName := "claude-user@example.com.json"
@@ -315,3 +495,82 @@ func TestManagerLogin_MultipleOrganizationsSharingEmailCoexist(t *testing.T) {
t.Fatalf("store.List returned %d records, want 2 distinct organization records", len(records))
}
}
+
+func TestManagerLogin_MigratesMatchingLegacyXAICredential(t *testing.T) {
+ authDir := t.TempDir()
+ legacyFileName := "xai-a-b@example.com.json"
+ targetFileName := xaiauth.CredentialFileName("a+b@example.com", "sub-first")
+ legacyPath := filepath.Join(authDir, legacyFileName)
+ targetPath := filepath.Join(authDir, targetFileName)
+
+ existing := map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "old-token",
+ "prefix": "team",
+ "proxy_url": "http://127.0.0.1:8080",
+ "disabled": true,
+ "weight": float64(5),
+ }
+ raw, errMarshal := json.Marshal(existing)
+ if errMarshal != nil {
+ t.Fatalf("marshal legacy credential: %v", errMarshal)
+ }
+ if errWrite := os.WriteFile(legacyPath, raw, 0o600); errWrite != nil {
+ t.Fatalf("write legacy credential: %v", errWrite)
+ }
+
+ newRecord := &coreauth.Auth{
+ ID: targetFileName,
+ FileName: targetFileName,
+ Provider: "xai",
+ Storage: &xaiauth.TokenStorage{
+ Type: "xai",
+ Email: "a+b@example.com",
+ Subject: "sub-first",
+ AccessToken: "new-token",
+ },
+ Metadata: map[string]any{
+ "type": "xai",
+ "email": "a+b@example.com",
+ "sub": "sub-first",
+ "access_token": "new-token",
+ },
+ }
+
+ store := NewFileTokenStore()
+ store.SetBaseDir(authDir)
+ mgr := NewManager(store, &dummyAuthenticator{provider: "xai", record: newRecord})
+
+ _, savedPath, errLogin := mgr.Login(context.Background(), "xai", &config.Config{AuthDir: authDir}, nil)
+ if errLogin != nil {
+ t.Fatalf("Login error: %v", errLogin)
+ }
+ if savedPath != targetPath {
+ t.Fatalf("savedPath = %s, want %s", savedPath, targetPath)
+ }
+ if _, errStat := os.Stat(legacyPath); !os.IsNotExist(errStat) {
+ t.Fatalf("legacy credential still exists or stat failed: %v", errStat)
+ }
+
+ savedRaw, errRead := os.ReadFile(targetPath)
+ if errRead != nil {
+ t.Fatalf("read migrated credential: %v", errRead)
+ }
+ var saved map[string]any
+ if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil {
+ t.Fatalf("unmarshal migrated credential: %v", errUnmarshal)
+ }
+ for key, want := range map[string]any{
+ "access_token": "new-token",
+ "prefix": "team",
+ "proxy_url": "http://127.0.0.1:8080",
+ "disabled": true,
+ "weight": float64(5),
+ } {
+ if got := saved[key]; got != want {
+ t.Errorf("%s = %#v, want %#v", key, got, want)
+ }
+ }
+}
diff --git a/sdk/cliproxy/antigravity_models.go b/sdk/cliproxy/antigravity_models.go
index 6c46f95f3c2..b6bb6180620 100644
--- a/sdk/cliproxy/antigravity_models.go
+++ b/sdk/cliproxy/antigravity_models.go
@@ -1,16 +1,11 @@
package cliproxy
import (
- "bytes"
"context"
"crypto/sha256"
- "encoding/json"
"fmt"
- "io"
"maps"
"math/rand/v2"
- "net/http"
- "slices"
"strings"
"sync"
"sync/atomic"
@@ -18,29 +13,28 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ sdkauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
- "github.com/router-for-me/CLIProxyAPI/v8/sdk/proxyutil"
- log "github.com/sirupsen/logrus"
"golang.org/x/sync/singleflight"
)
const (
- antigravityModelBaseURLDaily = "https://daily-cloudcode-pa.googleapis.com"
- antigravityModelsPath = "/v1internal:fetchAvailableModels"
- antigravityPersistedHintsMetaKey = "antigravity_capability_hints"
+ antigravityModelBaseURLDaily = sdkauth.AntigravityModelBaseURLDaily
+ antigravityModelsPath = sdkauth.AntigravityModelsPath
+ antigravityPersistedHintsMetaKey = sdkauth.AntigravityPersistedHintsMetaKey
antigravityCapabilityCacheTTL = registry.ModelsRefreshInterval
antigravityCapabilityRetryBase = time.Minute
antigravityCapabilityRetryMax = 30 * time.Minute
antigravityRefreshScanInterval = time.Minute
)
-type antigravityProbeStatus int
+type antigravityProbeStatus = sdkauth.AntigravityProbeStatus
const (
- antigravityProbeStatusSuccess antigravityProbeStatus = iota
- antigravityProbeStatusAuthError
- antigravityProbeStatusTransientError
+ antigravityProbeStatusSuccess = sdkauth.AntigravityProbeStatusSuccess
+ antigravityProbeStatusAuthError = sdkauth.AntigravityProbeStatusAuthError
+ antigravityProbeStatusTransientError = sdkauth.AntigravityProbeStatusTransientError
)
type antigravityCapabilityCacheEntry struct {
@@ -87,11 +81,6 @@ var (
antigravityProbeSlots = make(chan struct{}, modelRegistrationMaxWorkersPerCategory)
)
-type antigravityFetchAvailableModelsResponse struct {
- WebSearchModelIDs []string `json:"webSearchModelIds"`
- Models map[string]json.RawMessage `json:"models"`
-}
-
type antigravityModelCapabilityHints struct {
WebSearchModelIDs map[string]struct{}
// Nil means unknown; an empty non-nil set is an authoritative empty catalog.
@@ -227,114 +216,23 @@ func (s *Service) persistAntigravityCapabilityHints(ctx context.Context, auth *c
}
func buildPersistedAntigravityCapabilityHintsValue(project, endpoint string, hints antigravityModelCapabilityHints) map[string]any {
- modelIDs := make([]string, 0, len(hints.ModelIDs))
- for id := range hints.ModelIDs {
- if normalized := normalizeAntigravityFetchedModelID(id); normalized != "" {
- modelIDs = append(modelIDs, normalized)
- }
- }
- slices.Sort(modelIDs)
- modelItems := make([]any, len(modelIDs))
- for i, id := range modelIDs {
- modelItems[i] = id
- }
- value := map[string]any{
- "model_ids": modelItems,
- }
- if project = strings.TrimSpace(project); project != "" {
- value["project_id"] = project
- }
- if endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/"); endpoint != "" {
- value["endpoint"] = endpoint
- }
- if len(hints.WebSearchModelIDs) > 0 {
- webSearchIDs := make([]string, 0, len(hints.WebSearchModelIDs))
- for id := range hints.WebSearchModelIDs {
- if normalized := normalizeAntigravityFetchedModelID(id); normalized != "" {
- webSearchIDs = append(webSearchIDs, normalized)
- }
- }
- slices.Sort(webSearchIDs)
- if len(webSearchIDs) > 0 {
- webSearchItems := make([]any, len(webSearchIDs))
- for i, id := range webSearchIDs {
- webSearchItems[i] = id
- }
- value["web_search_model_ids"] = webSearchItems
- }
- }
- return value
+ return sdkauth.BuildPersistedAntigravityCapabilityHints(project, endpoint, sdkauth.AntigravityModelCapabilityHints{
+ ModelIDs: hints.ModelIDs,
+ WebSearchModelIDs: hints.WebSearchModelIDs,
+ })
}
func parsePersistedAntigravityCapabilityHints(metadata map[string]any, expectedProject, expectedEndpoint string) (antigravityModelCapabilityHints, bool) {
- if metadata == nil {
- return antigravityModelCapabilityHints{}, false
- }
- raw, ok := metadata[antigravityPersistedHintsMetaKey]
- if !ok || raw == nil {
- return antigravityModelCapabilityHints{}, false
- }
- rawMap, ok := raw.(map[string]any)
- if !ok {
- return antigravityModelCapabilityHints{}, false
- }
- persistedProject, _ := rawMap["project_id"].(string)
- persistedEndpoint, _ := rawMap["endpoint"].(string)
- if strings.TrimSpace(persistedProject) != strings.TrimSpace(expectedProject) {
- return antigravityModelCapabilityHints{}, false
- }
- if strings.TrimRight(strings.TrimSpace(persistedEndpoint), "/") != strings.TrimRight(strings.TrimSpace(expectedEndpoint), "/") {
- return antigravityModelCapabilityHints{}, false
- }
- rawModels, hasModels := rawMap["model_ids"]
- if !hasModels || rawModels == nil {
- return antigravityModelCapabilityHints{}, false
- }
- modelIDs, ok := parsePersistedAntigravityModelSet(rawModels)
+ hints, ok := sdkauth.ParsePersistedAntigravityCapabilityHints(metadata, expectedProject, expectedEndpoint)
if !ok {
return antigravityModelCapabilityHints{}, false
}
- webSearchModels := make(map[string]struct{})
- if rawWebSearch, hasWebSearch := rawMap["web_search_model_ids"]; hasWebSearch && rawWebSearch != nil {
- parsedWebSearch, okWebSearch := parsePersistedAntigravityModelSet(rawWebSearch)
- if !okWebSearch {
- return antigravityModelCapabilityHints{}, false
- }
- webSearchModels = parsedWebSearch
- }
return antigravityModelCapabilityHints{
- ModelIDs: modelIDs,
- WebSearchModelIDs: webSearchModels,
+ ModelIDs: hints.ModelIDs,
+ WebSearchModelIDs: hints.WebSearchModelIDs,
}, true
}
-func parsePersistedAntigravityModelSet(raw any) (map[string]struct{}, bool) {
- switch items := raw.(type) {
- case []any:
- set := make(map[string]struct{}, len(items))
- for _, item := range items {
- s, ok := item.(string)
- if !ok {
- return nil, false
- }
- if normalized := normalizeAntigravityFetchedModelID(s); normalized != "" {
- set[normalized] = struct{}{}
- }
- }
- return set, true
- case []string:
- set := make(map[string]struct{}, len(items))
- for _, s := range items {
- if normalized := normalizeAntigravityFetchedModelID(s); normalized != "" {
- set[normalized] = struct{}{}
- }
- }
- return set, true
- default:
- return nil, false
- }
-}
-
func (s *Service) fetchAntigravityModelCapabilityHintsForAuth(ctx context.Context, auth *coreauth.Auth) antigravityModelCapabilityHints {
_, hints := s.fetchAntigravityModelCapabilityHints(ctx, auth)
return hints
@@ -458,57 +356,21 @@ func (s *Service) fetchAntigravityModelCapabilityHints(ctx context.Context, auth
}
func (s *Service) probeAntigravityModelCapabilityHints(ctx context.Context, auth *coreauth.Auth, baseURLs []string, proxyURL, accessToken, userAgent string) (antigravityModelCapabilityHints, antigravityProbeStatus) {
- if len(baseURLs) == 0 {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- client := &http.Client{}
- transport, _, errProxy := proxyutil.BuildHTTPTransport(proxyURL)
- if errProxy != nil {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- if transport != nil {
- client.Transport = transport
- defer transport.CloseIdleConnections()
- }
- project, _ := auth.Metadata["project_id"].(string)
- body, errMarshal := json.Marshal(map[string]string{"project": project})
- if errMarshal != nil {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- // Default to daily and use only the first explicitly configured endpoint.
- // Racing catalogs from different endpoints can grant incompatible entitlements.
- req, errReq := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(baseURLs[0], "/")+antigravityModelsPath, bytes.NewReader(body))
- if errReq != nil {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Authorization", "Bearer "+accessToken)
- req.Header.Set("User-Agent", userAgent)
- resp, errDo := client.Do(req)
- if errDo != nil {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- defer func() {
- if errClose := resp.Body.Close(); errClose != nil {
- log.Debug("antigravity model fetch: response close failed")
- }
- }()
- if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusAuthError
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- const maxCatalogBytes = 8 << 20
- data, errRead := io.ReadAll(io.LimitReader(resp.Body, maxCatalogBytes+1))
- if errRead != nil || len(data) > maxCatalogBytes {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- hints, ok := parseAntigravityModelCapabilityHints(data)
- if !ok {
- return antigravityModelCapabilityHints{}, antigravityProbeStatusTransientError
- }
- return hints, antigravityProbeStatusSuccess
+ project := ""
+ if auth != nil && auth.Metadata != nil {
+ project, _ = auth.Metadata["project_id"].(string)
+ }
+ hints, status := sdkauth.FetchAntigravityAvailableModels(ctx, sdkauth.AntigravityModelProbeRequest{
+ BaseURLs: baseURLs,
+ ProxyURL: proxyURL,
+ AccessToken: accessToken,
+ ProjectID: project,
+ UserAgent: userAgent,
+ })
+ return antigravityModelCapabilityHints{
+ ModelIDs: hints.ModelIDs,
+ WebSearchModelIDs: hints.WebSearchModelIDs,
+ }, status
}
func (s *Service) antigravityModelFetchProxyURL(auth *coreauth.Auth) string {
@@ -569,27 +431,14 @@ func resolveAntigravityModelBaseURL(auth *coreauth.Auth) string {
}
func parseAntigravityModelCapabilityHints(body []byte) (antigravityModelCapabilityHints, bool) {
- var parsed antigravityFetchAvailableModelsResponse
- if err := json.Unmarshal(body, &parsed); err != nil {
+ hints, ok := sdkauth.ParseAntigravityModelCapabilityHints(body)
+ if !ok {
return antigravityModelCapabilityHints{}, false
}
- webSearchModels := make(map[string]struct{}, len(parsed.WebSearchModelIDs))
- for _, modelID := range parsed.WebSearchModelIDs {
- modelID = normalizeAntigravityFetchedModelID(modelID)
- if modelID != "" {
- webSearchModels[modelID] = struct{}{}
- }
- }
- var modelIDs map[string]struct{}
- if parsed.Models != nil {
- modelIDs = make(map[string]struct{}, len(parsed.Models))
- for id := range parsed.Models {
- if normalized := normalizeAntigravityFetchedModelID(id); normalized != "" {
- modelIDs[normalized] = struct{}{}
- }
- }
- }
- return antigravityModelCapabilityHints{WebSearchModelIDs: webSearchModels, ModelIDs: modelIDs}, true
+ return antigravityModelCapabilityHints{
+ ModelIDs: hints.ModelIDs,
+ WebSearchModelIDs: hints.WebSearchModelIDs,
+ }, true
}
func applyAntigravityFetchedModelCapabilities(models []*ModelInfo, hints antigravityModelCapabilityHints) []*ModelInfo {
@@ -610,7 +459,7 @@ func applyAntigravityFetchedModelCapabilities(models []*ModelInfo, hints antigra
}
func normalizeAntigravityFetchedModelID(modelID string) string {
- return strings.ToLower(strings.TrimSpace(modelID))
+ return sdkauth.NormalizeAntigravityModelID(modelID)
}
// WaitAntigravityProbes waits for any in-flight asynchronous Antigravity capability probes to complete.
diff --git a/sdk/cliproxy/auth/auto_refresh_loop.go b/sdk/cliproxy/auth/auto_refresh_loop.go
index ab54ba48356..b7f87bf02d0 100644
--- a/sdk/cliproxy/auth/auto_refresh_loop.go
+++ b/sdk/cliproxy/auth/auto_refresh_loop.go
@@ -132,7 +132,7 @@ func (l *authAutoRefreshLoop) rebuild(now time.Time) {
l.manager.mu.RLock()
for id, auth := range l.manager.auths {
- next, ok := nextRefreshCheckAt(now, auth, l.interval)
+ next, ok := l.manager.nextRefreshCheckAt(now, auth, l.interval)
if !ok {
continue
}
@@ -275,7 +275,7 @@ func (l *authAutoRefreshLoop) handleDueAuth(ctx context.Context, now time.Time,
manager.mu.RUnlock()
return
}
- next, shouldSchedule := nextRefreshCheckAt(now, auth, l.interval)
+ next, shouldSchedule := manager.nextRefreshCheckAt(now, auth, l.interval)
shouldRefresh := manager.shouldRefresh(auth, now)
exec, _ := manager.executorLocked(executorKeyFromAuth(auth))
registrationEpoch := auth.RegistrationEpoch
@@ -300,7 +300,7 @@ func (l *authAutoRefreshLoop) handleDueAuth(ctx context.Context, now time.Time,
if job == nil {
manager.mu.RLock()
auth = manager.auths[authID]
- next, shouldSchedule = nextRefreshCheckAt(now, auth, l.interval)
+ next, shouldSchedule = manager.nextRefreshCheckAt(now, auth, l.interval)
manager.mu.RUnlock()
if shouldSchedule {
if !next.After(now) {
@@ -336,7 +336,7 @@ func (l *authAutoRefreshLoop) applyDirty(now time.Time) {
for _, authID := range dirty {
l.manager.mu.RLock()
auth := l.manager.auths[authID]
- next, ok := nextRefreshCheckAt(now, auth, l.interval)
+ next, ok := l.manager.nextRefreshCheckAt(now, auth, l.interval)
l.manager.mu.RUnlock()
if !ok {
@@ -392,6 +392,15 @@ func (l *authAutoRefreshLoop) remove(authID string) {
delete(l.index, authID)
}
+// nextRefreshCheckAt excludes local Codex credentials that cannot renew before
+// evaluating generic expiry-based scheduling. Home can renew without a local RT.
+func (m *Manager) nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time.Time, bool) {
+ if m.codexAutoRefreshUnavailable(auth, now) {
+ return time.Time{}, false
+ }
+ return nextRefreshCheckAt(now, auth, interval)
+}
+
func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time.Time, bool) {
if auth == nil {
return time.Time{}, false
@@ -421,6 +430,19 @@ func nextRefreshCheckAt(now time.Time, auth *Auth, interval time.Duration) (time
return auth.NextRefreshAfter, true
}
+ if authHasUnchangedRejectedRefreshToken(auth) {
+ if auth.HasValidAccessToken(now) {
+ if exp, ok := auth.AccessTokenExpirationTime(); ok && !exp.IsZero() && exp.After(now) {
+ return exp, true
+ }
+ return time.Time{}, false
+ }
+ if auth.Disabled || auth.Status == StatusDisabled {
+ return time.Time{}, false
+ }
+ return now, true
+ }
+
if siwcDueAt, hasSIWC, _ := CodexSIWCNeedsRefresh(auth, now); hasSIWC {
minDue := siwcDueAt
if outerDueAt, hasOuter, _ := CodexOuterNeedsRefresh(auth, now); hasOuter && outerDueAt.Before(minDue) {
diff --git a/sdk/cliproxy/auth/codex_forcemap_ws_forward_test.go b/sdk/cliproxy/auth/codex_forcemap_ws_forward_test.go
index 9996ccd3ba1..abc7195d5ad 100644
--- a/sdk/cliproxy/auth/codex_forcemap_ws_forward_test.go
+++ b/sdk/cliproxy/auth/codex_forcemap_ws_forward_test.go
@@ -5,7 +5,7 @@ import (
"strings"
"testing"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
)
func parseWSDataEventTypesFromForwardedChunks(forwarded [][]byte) []string {
@@ -18,8 +18,8 @@ func parseWSDataEventTypesFromForwardedChunks(forwarded [][]byte) []string {
continue
}
j := bytes.TrimSpace(ln[5:])
- if gjson.ValidBytes(j) {
- types = append(types, gjson.GetBytes(j, "type").String())
+ if ojson.ValidBytes(j) {
+ types = append(types, ojson.GetBytesNoCopy(j, "type").String())
}
}
}
diff --git a/sdk/cliproxy/auth/codex_no_refresh_token_test.go b/sdk/cliproxy/auth/codex_no_refresh_token_test.go
new file mode 100644
index 00000000000..03112caf021
--- /dev/null
+++ b/sdk/cliproxy/auth/codex_no_refresh_token_test.go
@@ -0,0 +1,110 @@
+package auth
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+)
+
+type codexRenewalEvaluator struct{}
+
+func (codexRenewalEvaluator) ShouldRefresh(time.Time, *Auth) bool { return true }
+
+func TestCodexAutoRefreshRequiresRenewalCredential(t *testing.T) {
+ now := time.Now()
+ lead := CodexOuterRefreshLead
+ setRefreshLeadFactory(t, "codex", func() *time.Duration { return &lead })
+ for _, tc := range []struct {
+ name string
+ token any
+ siwc map[string]any
+ home bool
+ runtime any
+ want bool
+ }{
+ {name: "missing RT"},
+ {name: "empty RT", token: ""},
+ {name: "whitespace RT", token: " \t"},
+ {name: "non-string RT", token: 123},
+ {name: "outer RT", token: "refresh", want: true},
+ {name: "SIWC renewal", siwc: map[string]any{"refresh_token": "siwc-refresh", "client_id": "client"}, want: true},
+ {name: "SIWC missing client", siwc: map[string]any{"refresh_token": "siwc-refresh"}},
+ {name: "SIWC missing RT", siwc: map[string]any{"client_id": "client"}},
+ {name: "Home renewal", home: true, want: true},
+ {name: "runtime renewal", runtime: codexRenewalEvaluator{}, want: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, disabled := range []bool{false, true} {
+ m := NewManager(nil, &RoundRobinSelector{}, nil)
+ cfg := &internalconfig.Config{}
+ cfg.Home.Enabled = tc.home
+ m.SetConfig(cfg)
+ a := &Auth{ID: "codex-no-rt", Provider: "codex", Disabled: disabled, Runtime: tc.runtime, Metadata: map[string]any{"access_token": "access", "expired": now.Add(-time.Hour).Format(time.RFC3339)}}
+ if tc.token != nil {
+ a.Metadata["refresh_token"] = tc.token
+ }
+ if tc.siwc != nil {
+ a.Metadata["siwc"] = tc.siwc
+ }
+ if got := m.shouldRefresh(a, now); got != tc.want {
+ t.Fatalf("disabled=%v shouldRefresh=%v, want %v", disabled, got, tc.want)
+ }
+ if _, got := m.nextRefreshCheckAt(now, a, time.Second); got != tc.want {
+ t.Fatalf("disabled=%v scheduled=%v, want %v", disabled, got, tc.want)
+ }
+ a.NextRefreshAfter = now.Add(time.Minute)
+ if _, got := m.nextRefreshCheckAt(now, a, time.Second); got != tc.want {
+ t.Fatalf("disabled=%v pending scheduled=%v, want %v", disabled, got, tc.want)
+ }
+ }
+ })
+ }
+}
+
+func TestCodexNoRefreshTokenRemovedFromAutoRefreshQueue(t *testing.T) {
+ now := time.Now()
+ lead := CodexOuterRefreshLead
+ setRefreshLeadFactory(t, "codex", func() *time.Duration { return &lead })
+ m := NewManager(nil, &RoundRobinSelector{}, nil)
+ m.RegisterExecutor(&countingRefreshExecutor{id: "codex"})
+ a, err := m.Register(context.Background(), &Auth{ID: "access-only", Provider: "codex", Metadata: map[string]any{"access_token": "access", "expired": now.Add(-time.Hour).Format(time.RFC3339)}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ loop := newAuthAutoRefreshLoop(m, time.Second, 1)
+ loop.rebuild(now)
+ if _, ok := loop.peek(); ok {
+ t.Fatal("access-only credential queued by rebuild")
+ }
+ loop.upsert(a.ID, now)
+ loop.queueReschedule(a.ID)
+ loop.applyDirty(now)
+ if _, ok := loop.peek(); ok {
+ t.Fatal("access-only credential retained by dirty reschedule")
+ }
+ loop.upsert(a.ID, now)
+ loop.handleDue(context.Background(), now)
+ if _, ok := loop.peek(); ok {
+ t.Fatal("access-only credential requeued by due handler")
+ }
+ if len(loop.jobs) != 0 {
+ t.Fatal("access-only credential submitted for refresh")
+ }
+
+ // A later credential update must restore scheduling without restarting the loop.
+ a.Metadata["refresh_token"] = "restored-refresh"
+ if _, errUpdate := m.Update(context.Background(), a); errUpdate != nil {
+ t.Fatal(errUpdate)
+ }
+ loop.queueReschedule(a.ID)
+ loop.applyDirty(now)
+ if next, ok := loop.peek(); !ok || next.After(now) {
+ t.Fatal("renewable credential was not scheduled after update")
+ }
+ loop.handleDue(context.Background(), now)
+ if len(loop.jobs) != 1 {
+ t.Fatal("renewable credential was not submitted for refresh")
+ }
+}
diff --git a/sdk/cliproxy/auth/codex_siwc_selection_test.go b/sdk/cliproxy/auth/codex_siwc_selection_test.go
new file mode 100644
index 00000000000..fc0a33641e4
--- /dev/null
+++ b/sdk/cliproxy/auth/codex_siwc_selection_test.go
@@ -0,0 +1,286 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
+)
+
+type recordingSIWCExecutor struct {
+ id string
+ onExecute func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error)
+ onStream func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error)
+}
+
+func (e *recordingSIWCExecutor) Identifier() string { return e.id }
+
+func (e *recordingSIWCExecutor) Execute(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ if e.onExecute != nil {
+ return e.onExecute(ctx, auth, req, opts)
+ }
+ return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil
+}
+
+func (e *recordingSIWCExecutor) ExecuteStream(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ if e.onStream != nil {
+ return e.onStream(ctx, auth, req, opts)
+ }
+ ch := make(chan cliproxyexecutor.StreamChunk, 1)
+ ch <- cliproxyexecutor.StreamChunk{Payload: []byte("data: {}\n\n")}
+ close(ch)
+ return &cliproxyexecutor.StreamResult{Chunks: ch}, nil
+}
+
+func (e *recordingSIWCExecutor) Refresh(ctx context.Context, auth *Auth) (*Auth, error) {
+ return auth, nil
+}
+
+func (e *recordingSIWCExecutor) CountTokens(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, nil
+}
+
+func (e *recordingSIWCExecutor) HttpRequest(ctx context.Context, auth *Auth, req *http.Request) (*http.Response, error) {
+ return nil, nil
+}
+
+func registerTestCodexModels(t *testing.T, authID string) {
+ t.Helper()
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(authID, "codex", []*registry.ModelInfo{
+ {ID: "gpt-5.4"},
+ {ID: "gpt-image-2.6"},
+ })
+ t.Cleanup(func() {
+ reg.UnregisterClient(authID)
+ })
+}
+
+func TestManagerExecuteOpenAIImageSelectsOuterTrackWhenSIWCExpiredOrRejected(t *testing.T) {
+ now := time.Now().UTC()
+ future := now.Add(2 * time.Hour).Format(time.RFC3339)
+ past := now.Add(-2 * time.Hour).Format(time.RFC3339)
+
+ manager := NewManager(nil, nil, nil)
+ var pickedAuthID string
+ var wasSIWCForReq bool
+ manager.RegisterExecutor(&recordingSIWCExecutor{
+ id: "codex",
+ onExecute: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ pickedAuthID = auth.ID
+ wasSIWCForReq = IsCodexUseSIWCForRequest(ctx, auth, opts.Headers)
+ return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil
+ },
+ })
+
+ auth := &Auth{
+ ID: "codex-siwc-expired-outer-valid",
+ Provider: "codex",
+ Status: StatusActive,
+ RejectedAccessToken: "",
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "outer-valid-token",
+ "expired": future,
+ "use_siwc": true,
+ "siwc": map[string]any{
+ "access_token": "siwc-expired-token",
+ "expired": past,
+ },
+ },
+ }
+ auth.RejectedSIWCAccessToken = "siwc-expired-token"
+
+ registerTestCodexModels(t, auth.ID)
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatalf("Register error: %v", err)
+ }
+
+ // 1. Default (non-Codex, non-image) request must see this auth as blocked because SIWC is expired/rejected.
+ _, errNonImage := manager.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{
+ Model: "gpt-5.4",
+ Payload: []byte(`{"model":"gpt-5.4","input":"hi"}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-response"),
+ Headers: http.Header{"User-Agent": []string{"Python-urllib/3.14"}},
+ })
+ if errNonImage == nil {
+ t.Fatal("expected non-Codex non-image request to fail when SIWC token is expired/rejected")
+ }
+
+ // 2. OpenAI image request with non-Codex UA must succeed because it uses the outer credential track.
+ _, errImage := manager.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{
+ Model: "gpt-image-2.6",
+ Payload: []byte(`{"model":"gpt-image-2.6","prompt":"a cat"}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-image"),
+ Headers: http.Header{"User-Agent": []string{"Python-urllib/3.14"}},
+ Metadata: map[string]any{"request_path": "/v1/images/generations"},
+ })
+ if errImage != nil {
+ t.Fatalf("expected OpenAI image request to select outer-valid auth despite expired SIWC, got error: %v", errImage)
+ }
+ if pickedAuthID != auth.ID {
+ t.Fatalf("pickedAuthID = %q, want %q", pickedAuthID, auth.ID)
+ }
+ if wasSIWCForReq {
+ t.Fatal("IsCodexUseSIWCForRequest was true during image execution, want false")
+ }
+}
+
+func TestManagerExecuteOpenAIImageBlocksWhenOuterExpiredEvenIfSIWCValid(t *testing.T) {
+ now := time.Now().UTC()
+ future := now.Add(2 * time.Hour).Format(time.RFC3339)
+ past := now.Add(-2 * time.Hour).Format(time.RFC3339)
+
+ manager := NewManager(nil, nil, nil)
+ var pickedAuthID string
+ manager.RegisterExecutor(&recordingSIWCExecutor{
+ id: "codex",
+ onExecute: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
+ pickedAuthID = auth.ID
+ return cliproxyexecutor.Response{Payload: []byte(`{"ok":true}`)}, nil
+ },
+ })
+
+ // Auth 1: outer token is expired, SIWC token is valid.
+ authOuterExpired := &Auth{
+ ID: "codex-1-outer-expired",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "outer-expired-token",
+ "expired": past,
+ "use_siwc": true,
+ "siwc": map[string]any{
+ "access_token": "siwc-valid-token",
+ "expired": future,
+ },
+ },
+ }
+ // Auth 2: outer token is valid, SIWC token is valid.
+ authOuterValid := &Auth{
+ ID: "codex-2-outer-valid",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "outer-valid-token-2",
+ "expired": future,
+ "use_siwc": true,
+ "siwc": map[string]any{
+ "access_token": "siwc-valid-token-2",
+ "expired": future,
+ },
+ },
+ }
+
+ registerTestCodexModels(t, authOuterExpired.ID)
+ registerTestCodexModels(t, authOuterValid.ID)
+ if _, err := manager.Register(context.Background(), authOuterExpired); err != nil {
+ t.Fatalf("Register authOuterExpired error: %v", err)
+ }
+ if _, err := manager.Register(context.Background(), authOuterValid); err != nil {
+ t.Fatalf("Register authOuterValid error: %v", err)
+ }
+
+ // Image request must skip authOuterExpired and pick authOuterValid.
+ _, errImage := manager.Execute(context.Background(), []string{"codex"}, cliproxyexecutor.Request{
+ Model: "gpt-image-2.6",
+ Payload: []byte(`{"model":"gpt-image-2.6","prompt":"a cat"}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-image"),
+ Headers: http.Header{"User-Agent": []string{"Python-urllib/3.14"}},
+ Metadata: map[string]any{"request_path": "/v1/images/generations"},
+ })
+ if errImage != nil {
+ t.Fatalf("Execute image error: %v", errImage)
+ }
+ if pickedAuthID != authOuterValid.ID {
+ t.Fatalf("pickedAuthID = %q, want %q (authOuterExpired should be skipped for image request)", pickedAuthID, authOuterValid.ID)
+ }
+}
+
+func TestManagerExecuteStreamOpenAIImage401RejectsOuterTokenNotSIWC(t *testing.T) {
+ now := time.Now().UTC()
+ future := now.Add(2 * time.Hour).Format(time.RFC3339)
+
+ manager := NewManager(nil, nil, nil)
+ manager.RegisterExecutor(&recordingSIWCExecutor{
+ id: "codex",
+ onStream: func(ctx context.Context, auth *Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
+ ch := make(chan cliproxyexecutor.StreamChunk, 1)
+ ch <- cliproxyexecutor.StreamChunk{
+ Err: &Error{
+ Code: "unauthorized",
+ Message: "outer token rejected during stream",
+ HTTPStatus: http.StatusUnauthorized,
+ },
+ }
+ close(ch)
+ return &cliproxyexecutor.StreamResult{Chunks: ch}, nil
+ },
+ })
+
+ auth := &Auth{
+ ID: "codex-stream-401",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": "outer-stream-token",
+ "expired": future,
+ "use_siwc": true,
+ "siwc": map[string]any{
+ "access_token": "siwc-stream-token",
+ "expired": future,
+ },
+ },
+ }
+ registerTestCodexModels(t, auth.ID)
+ if _, err := manager.Register(context.Background(), auth); err != nil {
+ t.Fatalf("Register error: %v", err)
+ }
+
+ sr, errStream := manager.ExecuteStream(context.Background(), []string{"codex"}, cliproxyexecutor.Request{
+ Model: "gpt-image-2.6",
+ Payload: []byte(`{"model":"gpt-image-2.6","prompt":"a cat","stream":true}`),
+ }, cliproxyexecutor.Options{
+ SourceFormat: sdktranslator.FromString("openai-image"),
+ Headers: http.Header{"User-Agent": []string{"Python-urllib/3.14"}},
+ Metadata: map[string]any{"request_path": "/v1/images/generations"},
+ })
+ if errStream != nil {
+ t.Fatalf("ExecuteStream error: %v", errStream)
+ }
+ for range sr.Chunks {
+ }
+
+ updated, ok := manager.GetByID(auth.ID)
+ if !ok || updated == nil {
+ t.Fatal("expected auth to remain registered")
+ }
+ if updated.RejectedAccessToken != "outer-stream-token" {
+ t.Fatalf("RejectedAccessToken = %q, want %q", updated.RejectedAccessToken, "outer-stream-token")
+ }
+ if updated.RejectedSIWCAccessToken != "" {
+ t.Fatalf("RejectedSIWCAccessToken = %q, want empty (SIWC token must not be marked rejected on image stream 401)", updated.RejectedSIWCAccessToken)
+ }
+}
+
+func TestHomeDispatchHeadersSynthesizesCodexUserAgentForImageRequests(t *testing.T) {
+ ctx := WithCodexClientRequest(context.Background(), true)
+ in := http.Header{"User-Agent": []string{"Python-urllib/3.14"}}
+ out := homeDispatchHeaders(ctx, in)
+ if got := out.Get("User-Agent"); got != "codex_cli_rs" {
+ t.Fatalf("homeDispatchHeaders User-Agent = %q, want codex_cli_rs", got)
+ }
+ if got := in.Get("User-Agent"); got != "Python-urllib/3.14" {
+ t.Fatalf("original headers mutated: User-Agent = %q", got)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_cooldown.go b/sdk/cliproxy/auth/conductor_cooldown.go
index 388ac914f67..ee6be67acc8 100644
--- a/sdk/cliproxy/auth/conductor_cooldown.go
+++ b/sdk/cliproxy/auth/conductor_cooldown.go
@@ -1076,7 +1076,7 @@ func (m *Manager) MarkResult(ctx context.Context, result Result) {
NextRecoverAt: next,
BackoffLevel: backoffLevel,
})
- } else if isInvalidGrantResultError(result.Error) {
+ } else if isInvalidGrantResultError(result.Error) || isCreditBalanceTooLowResultError(result.Error) {
if disableCooling {
state.NextRetryAfter = time.Time{}
} else {
@@ -1926,10 +1926,33 @@ func statusCodeFromError(err error) int {
return 0
}
+func errorCodeFromError(err error) string {
+ if err == nil {
+ return ""
+ }
+ var authErr *Error
+ if errors.As(err, &authErr) && authErr != nil && strings.TrimSpace(authErr.Code) != "" {
+ return strings.TrimSpace(authErr.Code)
+ }
+ type errorCoder interface {
+ ErrorCode() string
+ }
+ var ec errorCoder
+ if errors.As(err, &ec) && ec != nil {
+ return strings.TrimSpace(ec.ErrorCode())
+ }
+ return ""
+}
+
func isRequestScopedError(err error) bool {
if err == nil {
return false
}
+ // A preserved terminal response belongs to this request unless the executor
+ // explicitly classifies the rejection as safe to retry on another credential.
+ if isRequestTerminatedError(err) && !isCredentialScopedError(err) {
+ return true
+ }
requestErr, ok := errors.AsType[cliproxyexecutor.RequestScopedError](err)
return ok && requestErr != nil && requestErr.IsRequestScoped()
}
@@ -1967,6 +1990,9 @@ func resultErrorFromError(err error) *Error {
} else {
resultErr = &Error{Message: err.Error()}
}
+ if resultErr.Code == "" {
+ resultErr.Code = errorCodeFromError(err)
+ }
if resultErr.HTTPStatus == 0 {
resultErr.HTTPStatus = statusCodeFromError(err)
}
@@ -2350,6 +2376,10 @@ func isTerminalRefreshError(auth *Auth, err error) bool {
return isTerminalRefreshError(auth, partErr.OuterRefreshError()) ||
isTerminalRefreshError(auth, partErr.SIWCRefreshError())
}
+ var authErr *Error
+ if errors.As(err, &authErr) && authErr != nil && isTerminalRefreshGrantMessage(authErr.Code) {
+ return true
+ }
if isUnauthorizedError(err) || isTerminalRefreshGrantMessage(err.Error()) {
return true
}
@@ -2408,8 +2438,13 @@ func sanitizeRenewalError(auth *Auth, err error) *Error {
}
}
terminal := isTerminalRefreshError(auth, err)
+ code := terminalRefreshErrorCode(raw, statusCode)
+ var existingAuthErr *Error
+ if errors.As(err, &existingAuthErr) && existingAuthErr != nil && isKnownSafeOAuthErrorCode(existingAuthErr.Code) {
+ code = strings.ToLower(strings.TrimSpace(existingAuthErr.Code))
+ }
return &Error{
- Code: terminalRefreshErrorCode(raw, statusCode),
+ Code: code,
Message: sanitizeRenewalMessage(raw),
HTTPStatus: statusCode,
Retryable: !terminal,
@@ -2592,6 +2627,35 @@ func isInvalidGrantResultError(err *Error) bool {
return false
}
+func isCreditBalanceTooLowError(err error) bool {
+ if err == nil {
+ return false
+ }
+ status := statusCodeFromError(err)
+ if clienterror.IsCreditBalanceTooLow(status, err) {
+ return true
+ }
+ var authErr *Error
+ if errors.As(err, &authErr) && authErr != nil && authErr.Message != "" {
+ return clienterror.IsCreditBalanceTooLow(status, errors.New(authErr.Message))
+ }
+ return false
+}
+
+func isCreditBalanceTooLowResultError(err *Error) bool {
+ if err == nil {
+ return false
+ }
+ status := statusCodeFromResult(err)
+ if clienterror.IsCreditBalanceTooLow(status, err) {
+ return true
+ }
+ if err.Message != "" {
+ return clienterror.IsCreditBalanceTooLow(status, errors.New(err.Message))
+ }
+ return false
+}
+
func isModelSupportResultError(err *Error) bool {
if err == nil {
return false
@@ -2674,11 +2738,43 @@ func isRequestScopedResultError(err *Error) bool {
}
func isCountTokensEndpointNotFoundError(err error, requestedModel string) bool {
- if err == nil || statusCodeFromError(err) != http.StatusNotFound {
+ if err == nil {
return false
}
baseModel := thinking.ParseSuffix(requestedModel).ModelName
- return !isExplicitModelNotFoundError(err, baseModel)
+ if isExplicitModelNotFoundError(err, baseModel) {
+ return false
+ }
+ status := statusCodeFromError(err)
+ if status == http.StatusNotFound {
+ return true
+ }
+ if isCredentialScopedError(err) || isCloudflareChallengeError(err) || isInvalidGrantError(err) || isCreditBalanceTooLowError(err) || isModelSupportErrorMessage(err.Error()) {
+ return false
+ }
+ switch status {
+ case http.StatusNotImplemented:
+ return true
+ case 0:
+ return isCountTokensUnsupportedErrorCode(errorCodeFromError(err)) || errors.Is(err, errors.ErrUnsupported)
+ default:
+ return false
+ }
+}
+
+func isCountTokensUnsupportedErrorCode(value string) bool {
+ normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value)))
+ switch normalized {
+ case "not_implemented",
+ "count_tokens_unsupported",
+ "count_tokens_not_supported",
+ "unsupported_operation",
+ "unsupported_capability",
+ "method_not_implemented":
+ return true
+ default:
+ return false
+ }
}
func isResponsesCompactRequest(opts cliproxyexecutor.Options) bool {
@@ -2689,7 +2785,7 @@ func isResponsesCompactRequestFaultError(opts cliproxyexecutor.Options, err erro
if !isResponsesCompactRequest(opts) || err == nil {
return false
}
- if isCredentialScopedError(err) || isCloudflareChallengeError(err) || isInvalidGrantError(err) {
+ if isCredentialScopedError(err) || isCloudflareChallengeError(err) || isInvalidGrantError(err) || isCreditBalanceTooLowError(err) {
return false
}
status := statusCodeFromError(err)
@@ -2717,10 +2813,10 @@ func isResponsesCompactAvailabilityNeutralError(opts cliproxyexecutor.Options, e
if resultErr != nil && resultErr.Code == ErrorCodeForceCooldown {
return false
}
- if isCredentialScopedError(err) || isCloudflareChallengeError(err) || isInvalidGrantError(err) {
+ if isCredentialScopedError(err) || isCloudflareChallengeError(err) || isInvalidGrantError(err) || isCreditBalanceTooLowError(err) {
return false
}
- if resultErr != nil && (isCloudflareChallengeResultError(resultErr) || isInvalidGrantResultError(resultErr)) {
+ if resultErr != nil && (isCloudflareChallengeResultError(resultErr) || isInvalidGrantResultError(resultErr) || isCreditBalanceTooLowResultError(resultErr)) {
return false
}
status := statusCodeFromError(err)
@@ -2737,6 +2833,9 @@ func isExplicitModelNotFoundError(err error, requestedModel string) bool {
if err == nil {
return false
}
+ if isModelNotFoundIdentifier(errorCodeFromError(err)) {
+ return true
+ }
var authErr *Error
if errors.As(err, &authErr) && authErr != nil {
if isModelNotFoundIdentifier(authErr.Code) || isStructuredModelNotFoundError(authErr.Message, requestedModel) || isStructuredModelNotFoundError(authErr.Error(), requestedModel) {
@@ -2943,6 +3042,9 @@ func isRequestInvalidError(err error) bool {
if isInvalidGrantError(err) {
return false
}
+ if isCreditBalanceTooLowError(err) {
+ return false
+ }
if isModelSupportError(err) {
return false
}
@@ -3014,6 +3116,13 @@ func applyAuthFailureState(auth *Auth, resultErr *Error, retryAfter *time.Durati
} else {
auth.NextRetryAfter = now.Add(30 * time.Minute)
}
+ } else if isCreditBalanceTooLowResultError(resultErr) {
+ auth.StatusMessage = "payment_required"
+ if disableCooling {
+ auth.NextRetryAfter = time.Time{}
+ } else {
+ auth.NextRetryAfter = now.Add(30 * time.Minute)
+ }
} else {
switch statusCode {
case 401:
diff --git a/sdk/cliproxy/auth/conductor_disabled_refresh_outcome_test.go b/sdk/cliproxy/auth/conductor_disabled_refresh_outcome_test.go
new file mode 100644
index 00000000000..2b2ef9a1d7c
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_disabled_refresh_outcome_test.go
@@ -0,0 +1,136 @@
+package auth
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+// Embed the executor stub for the unused request methods.
+type disabledRefreshOutcomeExecutor struct {
+ mockOAuthErrorExecutor
+ extendExpiry bool
+}
+
+func (e *disabledRefreshOutcomeExecutor) Refresh(_ context.Context, a *Auth) (*Auth, error) {
+ e.refreshCalls.Add(1)
+ if e.extendExpiry {
+ // A provider can renew validity without rotating the credential strings.
+ a.Metadata["expires_at"] = time.Now().Add(24 * time.Hour).Format(time.RFC3339)
+ }
+ return a, nil
+}
+
+func TestDisabledRefreshOutcomeJobCycle(t *testing.T) {
+ for _, disabledFlag := range []bool{true, false} {
+ for _, renew := range []bool{false, true} {
+ name := "status-disabled"
+ if disabledFlag {
+ name = "disabled-flag"
+ }
+ if renew {
+ name += "/renewed"
+ } else {
+ name += "/no-op"
+ }
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ m := NewManager(nil, &RoundRobinSelector{}, nil)
+ e := &disabledRefreshOutcomeExecutor{mockOAuthErrorExecutor: mockOAuthErrorExecutor{id: "disabled-outcome"}, extendExpiry: renew}
+ m.RegisterExecutor(e)
+ setRefreshLeadFactory(t, e.id, func() *time.Duration { d := time.Minute; return &d })
+ oldRefresh := time.Now().Add(-48 * time.Hour)
+ a := &Auth{ID: name, Provider: e.id, Disabled: disabledFlag, Status: StatusDisabled,
+ LastRefreshedAt: oldRefresh, RefreshFailures: 3,
+ Metadata: map[string]any{"access_token": "unchanged", "expires_at": time.Now().Add(-time.Hour).Format(time.RFC3339)}}
+ if disabledFlag {
+ a.Status = StatusActive
+ }
+ registered, err := m.Register(ctx, a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now()
+ if at, ok := nextRefreshCheckAt(now, registered, time.Minute); !ok || at.After(now) {
+ t.Fatalf("disabled auth must remain eligible for proactive refresh: %v, %v", at, ok)
+ }
+ job := m.markRefreshPending(nil, a.ID, registered.RegistrationEpoch, now)
+ if job == nil {
+ t.Fatal("refresh was not queued")
+ }
+ if !m.beginRefreshJob(ctx, job) {
+ t.Fatal("refresh did not begin")
+ }
+ refreshed, err := m.refreshAuthForRequestAtEpoch(ctx, a.ID, "", job.registrationEpoch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.finishRefreshJob(job, time.Time{}, false)
+ m.mu.RLock()
+ current := m.auths[a.ID].Clone()
+ pending := m.refreshJobs[a.ID]
+ m.mu.RUnlock()
+ if pending != nil {
+ t.Fatal("job was not removed")
+ }
+ if e.refreshCalls.Load() != 1 {
+ t.Fatal("expected one refresh")
+ }
+ if !current.LastRefreshedAt.After(oldRefresh) || current.RefreshFailures != 0 {
+ t.Fatalf("refresh outcome lost: timestamp=%v failures=%d", current.LastRefreshedAt, current.RefreshFailures)
+ }
+ if !current.Disabled || current.Status != StatusDisabled {
+ t.Fatal("disabled state changed")
+ }
+ if !current.NextRefreshAfter.Equal(refreshed.NextRefreshAfter) {
+ t.Fatal("job completion overwrote refresh outcome")
+ }
+ if !renew {
+ want := current.LastRefreshedAt.Add(refreshIneffectiveBackoff)
+ if !current.NextRefreshAfter.Equal(want) {
+ t.Fatalf("backoff=%v, want %v", current.NextRefreshAfter, want)
+ }
+ if m.markRefreshPending(nil, a.ID, current.RegistrationEpoch, current.LastRefreshedAt) != nil {
+ t.Fatal("no-op refresh immediately requeued")
+ }
+ }
+ at, ok := nextRefreshCheckAt(current.LastRefreshedAt, current, time.Minute)
+ if !ok || !at.After(current.LastRefreshedAt) {
+ t.Fatalf("expected future proactive refresh, got %v, %v", at, ok)
+ }
+ })
+ }
+ }
+}
+
+func TestDisabledRefreshOutcomeFileSnapshotPreserved(t *testing.T) {
+ ctx := context.Background()
+ m := NewManager(nil, &RoundRobinSelector{}, nil)
+ now := time.Now()
+ a := &Auth{ID: "disabled-snapshot", Provider: "codex", Disabled: true, Status: StatusDisabled,
+ LastRefreshedAt: now.Add(-time.Hour), NextRefreshAfter: now.Add(time.Hour), RefreshFailures: 4,
+ Metadata: map[string]any{"access_token": "unchanged"}}
+ existing, err := m.Register(ctx, a)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "auth.json")
+ data := []byte(`{"access_token":"unchanged"}`)
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ snapshot := existing.Clone()
+ snapshot.LastRefreshedAt = time.Time{}
+ snapshot.NextRefreshAfter = time.Time{}
+ snapshot.RefreshFailures = 0
+ snapshot.BindFileSnapshot(path, data)
+ updated, err := m.Update(ctx, snapshot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !updated.LastRefreshedAt.Equal(existing.LastRefreshedAt) || !updated.NextRefreshAfter.Equal(existing.NextRefreshAfter) || updated.RefreshFailures != existing.RefreshFailures {
+ t.Fatalf("file snapshot replaced runtime refresh state: %+v", updated)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go
index 8b2f8980236..df107aaeb28 100644
--- a/sdk/cliproxy/auth/conductor_execution.go
+++ b/sdk/cliproxy/auth/conductor_execution.go
@@ -120,10 +120,14 @@ func preferredExecutionAttemptError(fallback, upstream error) error {
// Execute performs a non-streaming execution using the configured selector and executor.
// It supports multiple providers for the same model and round-robins the starting provider per model.
+func isOpenAIImageExecutionOptions(opts cliproxyexecutor.Options) bool {
+ return strings.EqualFold(strings.TrimSpace(opts.SourceFormat.String()), "openai-image")
+}
+
func (m *Manager) Execute(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
ctx = cliproxyexecutor.WithRequestProxyURL(ctx, opts.ProxyURL)
req, opts = cliproxysession.Enrich(req, opts)
- ctx = WithCodexClientRequest(ctx, IsCodexClientRequest(ctx, opts.Headers))
+ ctx = WithCodexClientRequest(ctx, isOpenAIImageExecutionOptions(opts) || IsCodexClientRequest(ctx, opts.Headers))
ctx = coreusage.CaptureIdentity(ctx)
normalized := m.normalizeProviders(providers)
if len(normalized) == 0 {
@@ -240,7 +244,7 @@ func (m *Manager) ExecuteCount(ctx context.Context, providers []string, req clip
func (m *Manager) ExecuteStream(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (*cliproxyexecutor.StreamResult, error) {
ctx = cliproxyexecutor.WithRequestProxyURL(ctx, opts.ProxyURL)
req, opts = cliproxysession.Enrich(req, opts)
- ctx = WithCodexClientRequest(ctx, IsCodexClientRequest(ctx, opts.Headers))
+ ctx = WithCodexClientRequest(ctx, isOpenAIImageExecutionOptions(opts) || IsCodexClientRequest(ctx, opts.Headers))
ctx = coreusage.CaptureIdentity(ctx)
if m.HomeEnabled() {
if unlockSession := m.lockHomeWebsocketSession(ctx, opts); unlockSession != nil {
@@ -564,7 +568,7 @@ func (m *Manager) executeMixedOnce(ctx context.Context, providers []string, req
execCtx = coreusage.WithSelectionID(execCtx, selectionIDFromOptions(pickOpts))
execCtx = newUpstreamAttemptContext(execCtx)
- models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel)
+ models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAliasForRequest(execCtx, auth, routeModel, pickOpts.Headers)
if len(models) == 0 {
continue
}
@@ -782,7 +786,7 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
execCtx = coreusage.WithSelectionID(execCtx, selectionIDFromOptions(pickOpts))
execCtx = newUpstreamAttemptContext(execCtx)
- models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAlias(auth, routeModel)
+ models, pooled, aliasResult, routing := m.preparedExecutionModelsWithAliasForRequest(execCtx, auth, routeModel, pickOpts.Headers)
if len(models) == 0 {
continue
}
@@ -888,10 +892,11 @@ func (m *Manager) executeCountMixedOnce(ctx context.Context, providers []string,
}
action, okAction := matchRequestScopedErrorAction(auth, errExec, m.runtimeConfigSnapshot())
applyRequestScopedActionToResult(action, okAction, &result)
- // Some Anthropic-compatible upstreams do not implement the
- // count_tokens route and return a generic endpoint 404. Record
- // the failure for hooks and metrics without suspending a model
- // that remains usable through the messages endpoint.
+ // Some Anthropic-compatible upstreams and plugin executors do not
+ // implement count_tokens and return a generic endpoint 404 or an
+ // explicit 501 / unsupported-capability error. Record the failure
+ // for hooks and metrics without suspending a model that remains
+ // usable through the messages endpoint.
if isCountTokensEndpointNotFoundError(errExec, execReq.Model) && (result.Error == nil || result.Error.Code != ErrorCodeForceCooldown) {
m.recordAvailabilityNeutralResult(execCtx, result)
} else {
@@ -1111,7 +1116,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
models, pooled, aliasResult, routing := m.executionModelCandidatesWithAlias(auth, routeModel)
// Home owns dispatched credential availability; only local selections need state filtering.
if selection == nil {
- models = m.filterExecutionModels(auth, routeModel, models, pooled)
+ models = m.filterExecutionModelsForRequest(execCtx, auth, routeModel, models, pooled, pickOpts.Headers)
}
if selection != nil && aliasResult.ForceMapping && responseAlias != "" {
aliasResult.OriginalAlias = responseAlias
@@ -1228,6 +1233,19 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
if hasUpstreamExecutionAttempt(errStream) {
upstreamErr = errStream
}
+ action, okAction := matchRequestScopedErrorAction(auth, errStream, m.runtimeConfigSnapshot())
+ if isRequestScopedStop(action, okAction) || (!okAction && (isRequestInvalidError(errStream) || cliproxyexecutor.IsUpstreamWebsocketTurnError(errStream))) {
+ // Only redispatch needs a release acknowledgement. Preserve the final
+ // response even when Home cannot acknowledge the released admission.
+ if selection != nil {
+ releaseAttempt()
+ selection.End("request_stopped")
+ }
+ if isRequestScopedStop(action, okAction) {
+ return nil, wrapRequestStopError(errStream)
+ }
+ return nil, errStream
+ }
if selection != nil {
excludeAuth := shouldExcludeHomeAuthAfterStreamError(execCtx, auth, errStream)
if homeSameAuthRetries[auth.ID] > 0 {
@@ -1248,11 +1266,7 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
if errCtx := execCtx.Err(); errCtx != nil && ctx != nil && ctx.Err() != nil {
return nil, errCtx
}
- action, okAction := matchRequestScopedErrorAction(auth, errStream, m.runtimeConfigSnapshot())
if okAction {
- if isRequestScopedStop(action, okAction) {
- return nil, wrapRequestStopError(errStream)
- }
lastErr = errStream
if homeMode {
roundTiming.Observe(lastErr)
@@ -1262,9 +1276,6 @@ func (m *Manager) executeStreamMixedOnce(ctx context.Context, providers []string
}
continue
}
- if isRequestInvalidError(errStream) || cliproxyexecutor.IsUpstreamWebsocketTurnError(errStream) {
- return nil, errStream
- }
lastErr = errStream
if homeMode {
roundTiming.Observe(lastErr)
diff --git a/sdk/cliproxy/auth/conductor_home.go b/sdk/cliproxy/auth/conductor_home.go
index 063c5f1ef5d..d6474210003 100644
--- a/sdk/cliproxy/auth/conductor_home.go
+++ b/sdk/cliproxy/auth/conductor_home.go
@@ -435,19 +435,30 @@ func setHomeUserAPIKeyOnGinContext(ctx context.Context, apiKey string) {
}
func homeDispatchHeaders(ctx context.Context, headers http.Header) http.Header {
- apiKey, ok := homeQueryCredentialFromContext(ctx)
- if !ok {
- return headers
+ var out http.Header
+ ensureCloned := func() {
+ if out != nil {
+ return
+ }
+ out = headers.Clone()
+ if out == nil {
+ out = http.Header{}
+ }
+ }
+ if IsCodexClientRequest(ctx, headers) && !IsCodexClientUserAgent(headerValueFromHTTPHeader(headers, "User-Agent")) {
+ ensureCloned()
+ out.Set("User-Agent", "codex_cli_rs")
}
- out := headers.Clone()
- if out == nil {
- out = http.Header{}
+ if apiKey, ok := homeQueryCredentialFromContext(ctx); ok {
+ ensureCloned()
+ if out.Get("Authorization") == "" && out.Get("X-Goog-Api-Key") == "" && out.Get("X-Api-Key") == "" {
+ out.Set("X-Goog-Api-Key", apiKey)
+ }
}
- if out.Get("Authorization") != "" || out.Get("X-Goog-Api-Key") != "" || out.Get("X-Api-Key") != "" {
+ if out != nil {
return out
}
- out.Set("X-Goog-Api-Key", apiKey)
- return out
+ return headers
}
func homeQueryCredentialFromContext(ctx context.Context) (string, bool) {
@@ -932,6 +943,37 @@ func (m *Manager) homeRuntimeAuthByID(sessionID string, authID string) (*Auth, P
return auth.Clone(), executor, logicalProvider, true
}
+func (m *Manager) hasAlternativeHomeTargetForModel(ctx context.Context, model string, opts cliproxyexecutor.Options, excludedAuthID string) bool {
+ if m == nil || !m.HomeEnabled() {
+ return false
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ probeOpts := opts
+ probeOpts.Metadata = cloneRequestMetadata(opts.Metadata)
+ delete(probeOpts.Metadata, cliproxyexecutor.PinnedAuthMetadataKey)
+ delete(probeOpts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey)
+ delete(probeOpts.Metadata, cliproxyexecutor.SelectedAuthCallbackMetadataKey)
+ delete(probeOpts.Metadata, cliproxyexecutor.FinalizedResponseFormatCallbackMetadataKey)
+ probeOpts = withHomeRetryRound(probeOpts, 0)
+ probeOpts = withHomeAuthCount(probeOpts, 2)
+ if excludedAuthID != "" {
+ probeOpts = withHomeExcludedAuthIDs(probeOpts, map[string]struct{}{excludedAuthID: {}})
+ }
+ selection, errSelection := m.pickHomeDispatchSelection(ctx, model, probeOpts)
+ if errSelection != nil || selection == nil {
+ return false
+ }
+ selectionAuth := selection.CloneAuth()
+ _ = m.endHomeSelectionBeforeRedispatch(ctx, selection, "replay_target_probe")
+ if selectionAuth == nil {
+ return false
+ }
+ candidateID := strings.TrimSpace(selectionAuth.ID)
+ return candidateID != "" && candidateID != excludedAuthID
+}
+
func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
if m == nil {
return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
@@ -986,8 +1028,10 @@ func (m *Manager) pickHomeDispatchSelection(ctx context.Context, model string, o
}
if sessionID := homeExecutionSessionIDFromMetadata(opts.Metadata); sessionID != "" {
if cliproxyexecutor.RequiredUpstreamWebsocket(ctx) && pinnedAuthID != "" {
- if _, ok := m.GetExecutionSessionAuthByID(sessionID, pinnedAuthID); ok {
- return nil, &cliproxyexecutor.UpstreamWebsocketTurnError{FullContextRequired: true}
+ if _, excluded := excludedAuthIDs[pinnedAuthID]; !excluded {
+ if _, ok := m.GetExecutionSessionAuthByID(sessionID, pinnedAuthID); ok {
+ return nil, &cliproxyexecutor.UpstreamWebsocketTurnError{FullContextRequired: true}
+ }
}
}
if pinnedAuthID != "" {
diff --git a/sdk/cliproxy/auth/conductor_home_execution.go b/sdk/cliproxy/auth/conductor_home_execution.go
index b790b4f405b..f30d811fd92 100644
--- a/sdk/cliproxy/auth/conductor_home_execution.go
+++ b/sdk/cliproxy/auth/conductor_home_execution.go
@@ -7,9 +7,9 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
coreusage "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/usage"
- "github.com/tidwall/sjson"
)
func (m *Manager) executeHome(ctx context.Context, providers []string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, countTokens bool) (cliproxyexecutor.Response, error) {
@@ -367,7 +367,7 @@ func sanitizeDownstreamWebsocketFallbackRequest(ctx context.Context, auth *Auth,
if !cliproxyexecutor.DownstreamWebsocket(ctx) || authWebsocketsEnabled(auth) || len(req.Payload) == 0 {
return req
}
- updated, errDelete := sjson.DeleteBytes(req.Payload, "generate")
+ updated, errDelete := ojson.DeleteBytes(req.Payload, "generate")
if errDelete != nil {
return req
}
diff --git a/sdk/cliproxy/auth/conductor_issue6742_test.go b/sdk/cliproxy/auth/conductor_issue6742_test.go
index e418dee15b0..3b2247e6bfc 100644
--- a/sdk/cliproxy/auth/conductor_issue6742_test.go
+++ b/sdk/cliproxy/auth/conductor_issue6742_test.go
@@ -2,10 +2,10 @@ package auth
import (
"context"
- "encoding/base64"
"encoding/json"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"net/http"
"testing"
"time"
diff --git a/sdk/cliproxy/auth/conductor_lifecycle.go b/sdk/cliproxy/auth/conductor_lifecycle.go
index 9a54071bc93..117c15c0d8f 100644
--- a/sdk/cliproxy/auth/conductor_lifecycle.go
+++ b/sdk/cliproxy/auth/conductor_lifecycle.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
+ "net/http"
"strings"
"sync"
"time"
@@ -341,6 +342,9 @@ func (m *Manager) updateInternalLocked(ctx context.Context, base, auth *Auth, mo
if mode == updateModeRefresh {
merged := MergeRefreshedAuth(base, existing, auth)
if merged != nil {
+ if merged.Disabled || merged.Status == StatusDisabled {
+ merged.RefreshFailures = auth.RefreshFailures
+ }
auth = merged
NormalizeCredentialMetadata(auth.Metadata)
}
@@ -460,6 +464,23 @@ func (m *Manager) updateInternalLocked(ctx context.Context, base, auth *Auth, mo
if len(resumed) > 0 {
cooldownStateChanged = true
}
+ } else if auth.RequiresSignIn && !auth.HasValidAccessToken(now) {
+ auth.Unavailable = true
+ auth.Status = StatusError
+ if auth.StatusMessage == "" {
+ if existing.StatusMessage != "" {
+ auth.StatusMessage = existing.StatusMessage
+ } else {
+ auth.StatusMessage = "unauthorized (refresh token invalid)"
+ }
+ }
+ if auth.LastError == nil {
+ if existing.LastError != nil {
+ auth.LastError = cloneError(existing.LastError)
+ } else if auth.RefreshError != nil {
+ auth.LastError = &Error{Code: "unauthorized", Message: auth.RefreshError.Message, HTTPStatus: http.StatusUnauthorized}
+ }
+ }
}
}
} else {
@@ -488,15 +509,18 @@ func (m *Manager) updateInternalLocked(ctx context.Context, base, auth *Auth, mo
} else {
applyPreservedRejectedTokens(existing, auth, mode == updateModeRefresh)
applyPreservedRenewalState(existing, auth, mode, now)
- if !credChanged {
- auth.LastRefreshedAt = existing.LastRefreshedAt
- auth.NextRefreshAfter = existing.NextRefreshAfter
- auth.RefreshFailures = existing.RefreshFailures
- } else if authHasUnchangedRejectedRefreshToken(auth) {
- if exp, ok := auth.AccessTokenExpirationTime(); ok && !exp.IsZero() && exp.After(now) {
- auth.NextRefreshAfter = exp
- } else {
- auth.NextRefreshAfter = time.Time{}
+ // Refresh updates must retain their outcome rather than the pending job marker.
+ if mode != updateModeRefresh {
+ if !credChanged {
+ auth.LastRefreshedAt = existing.LastRefreshedAt
+ auth.NextRefreshAfter = existing.NextRefreshAfter
+ auth.RefreshFailures = existing.RefreshFailures
+ } else if authHasUnchangedRejectedRefreshToken(auth) {
+ if exp, ok := auth.AccessTokenExpirationTime(); ok && !exp.IsZero() && exp.After(now) {
+ auth.NextRefreshAfter = exp
+ } else {
+ auth.NextRefreshAfter = time.Time{}
+ }
}
}
}
@@ -603,22 +627,39 @@ func equalErrorPtr(a, b *Error) bool {
// Remove deletes an auth from runtime state without persisting.
// Disk and token-store deletion must be handled by the caller.
func (m *Manager) Remove(ctx context.Context, id string) {
+ m.RemoveIf(ctx, id, nil)
+}
+
+// RemoveIf deletes an auth from runtime state while holding the per-auth
+// mutation gate when predicate is nil or returns true for the current auth.
+func (m *Manager) RemoveIf(ctx context.Context, id string, predicate func(*Auth) bool) bool {
if m == nil {
- return
+ return false
}
id = strings.TrimSpace(id)
if id == "" {
- return
+ return false
}
- _ = ctx
releaseMutation := m.lockAuthMutation(id)
defer releaseMutation()
+ if predicate != nil {
+ m.mu.RLock()
+ snapshot := m.auths[id]
+ if snapshot != nil {
+ snapshot = snapshot.Clone()
+ }
+ m.mu.RUnlock()
+ if !predicate(snapshot) {
+ return false
+ }
+ }
+
m.mu.Lock()
existing := m.auths[id]
if existing == nil {
m.mu.Unlock()
- return
+ return false
}
provider := strings.TrimSpace(existing.Provider)
delete(m.auths, id)
@@ -666,6 +707,7 @@ func (m *Manager) Remove(ctx context.Context, id string) {
}
}
m.persistCooldownStates(context.Background())
+ return true
}
func (m *Manager) invalidateSessionAffinity(authID string) {
diff --git a/sdk/cliproxy/auth/conductor_models.go b/sdk/cliproxy/auth/conductor_models.go
index 3ac8b28f4a2..bebb4374f96 100644
--- a/sdk/cliproxy/auth/conductor_models.go
+++ b/sdk/cliproxy/auth/conductor_models.go
@@ -2,6 +2,8 @@ package auth
import (
"bytes"
+ "context"
+ "net/http"
"strconv"
"strings"
"time"
@@ -301,6 +303,10 @@ func executionResultModel(routeModel, upstreamModel string, pooled bool) string
}
func (m *Manager) filterExecutionModels(auth *Auth, routeModel string, candidates []string, pooled bool) []string {
+ return m.filterExecutionModelsForRequest(nil, auth, routeModel, candidates, pooled)
+}
+
+func (m *Manager) filterExecutionModelsForRequest(ctx context.Context, auth *Auth, routeModel string, candidates []string, pooled bool, clientHeaders ...http.Header) []string {
if len(candidates) == 0 {
return nil
}
@@ -308,7 +314,7 @@ func (m *Manager) filterExecutionModels(auth *Auth, routeModel string, candidate
out := make([]string, 0, len(candidates))
for _, upstreamModel := range candidates {
stateModel := m.stateModelForExecution(auth, routeModel, upstreamModel, pooled)
- blocked, _, _ := isAuthBlockedForModel(auth, stateModel, now)
+ blocked, _, _ := isAuthBlockedForModelForRequest(ctx, auth, stateModel, now, clientHeaders...)
if blocked {
continue
}
@@ -324,8 +330,12 @@ func (m *Manager) preparedExecutionModels(auth *Auth, routeModel string) ([]stri
}
func (m *Manager) preparedExecutionModelsWithAlias(auth *Auth, routeModel string) ([]string, bool, OAuthModelAliasResult, *apiKeyModelRoutingSnapshot) {
+ return m.preparedExecutionModelsWithAliasForRequest(nil, auth, routeModel)
+}
+
+func (m *Manager) preparedExecutionModelsWithAliasForRequest(ctx context.Context, auth *Auth, routeModel string, clientHeaders ...http.Header) ([]string, bool, OAuthModelAliasResult, *apiKeyModelRoutingSnapshot) {
candidates, pooled, aliasResult, routing := m.executionModelCandidatesWithAlias(auth, routeModel)
- return m.filterExecutionModels(auth, routeModel, candidates, pooled), pooled, aliasResult, routing
+ return m.filterExecutionModelsForRequest(ctx, auth, routeModel, candidates, pooled, clientHeaders...), pooled, aliasResult, routing
}
func (m *Manager) executionModelCandidatesWithAlias(auth *Auth, routeModel string) ([]string, bool, OAuthModelAliasResult, *apiKeyModelRoutingSnapshot) {
diff --git a/sdk/cliproxy/auth/conductor_overrides_test.go b/sdk/cliproxy/auth/conductor_overrides_test.go
index 2149cb7e8e6..ddd04ae2095 100644
--- a/sdk/cliproxy/auth/conductor_overrides_test.go
+++ b/sdk/cliproxy/auth/conductor_overrides_test.go
@@ -436,6 +436,7 @@ type authFallbackExecutor struct {
streamFirstErrors map[string]error
streamTailErrors map[string]error
countTokenErrors map[string]error
+ onCountTokens func(auth *Auth)
}
func (e *authFallbackExecutor) Identifier() string {
@@ -481,7 +482,11 @@ func (e *authFallbackExecutor) Refresh(_ context.Context, auth *Auth) (*Auth, er
func (e *authFallbackExecutor) CountTokens(_ context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
e.mu.Lock()
err := e.countTokenErrors[auth.ID]
+ onCount := e.onCountTokens
e.mu.Unlock()
+ if onCount != nil {
+ onCount(auth)
+ }
if err != nil {
return cliproxyexecutor.Response{}, err
}
@@ -1701,6 +1706,112 @@ func TestManager_DeepSeekInsufficientBalanceRotatesCredentialAndRebindsSession(t
}
}
+func TestManager_AnthropicCreditBalanceTooLowRotatesCredentialAndRebindsSession(t *testing.T) {
+ m := NewManager(nil, nil, nil)
+ m.SetRetryConfig(2, 30*time.Second, 0)
+ affinity := NewSessionAffinitySelectorWithConfig(SessionAffinityConfig{
+ Fallback: &RoundRobinSelector{},
+ TTL: time.Hour,
+ })
+ defer affinity.Stop()
+ m.SetSelector(affinity)
+
+ const provider = "claude"
+ const model = "claude-sonnet-4-6"
+ const creditBalanceLowBody = `{"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits."}}`
+
+ executor := &authFallbackExecutor{
+ id: provider,
+ executeErrors: map[string]error{
+ "aa-empty-anthropic-key": &Error{
+ HTTPStatus: http.StatusBadRequest,
+ Message: creditBalanceLowBody,
+ },
+ },
+ }
+ m.RegisterExecutor(executor)
+
+ depletedAuth := &Auth{ID: "aa-empty-anthropic-key", Provider: provider}
+ availableAuth := &Auth{ID: "bb-claude-oauth", Provider: provider}
+
+ reg := registry.GetGlobalRegistry()
+ models := []*registry.ModelInfo{{ID: model}}
+ reg.RegisterClient(depletedAuth.ID, provider, models)
+ reg.RegisterClient(availableAuth.ID, provider, models)
+ t.Cleanup(func() {
+ reg.UnregisterClient(depletedAuth.ID)
+ reg.UnregisterClient(availableAuth.ID)
+ })
+
+ if _, errRegister := m.Register(context.Background(), depletedAuth); errRegister != nil {
+ t.Fatalf("register depleted auth: %v", errRegister)
+ }
+ if _, errRegister := m.Register(context.Background(), availableAuth); errRegister != nil {
+ t.Fatalf("register available auth: %v", errRegister)
+ }
+
+ opts := cliproxyexecutor.Options{Metadata: map[string]any{
+ cliproxyexecutor.DerivedSessionIDMetadataKey: "anthropic-credit-balance-low",
+ }}
+ beforeExecute := time.Now()
+ resp, errExecute := m.Execute(
+ context.Background(),
+ []string{provider},
+ cliproxyexecutor.Request{Model: model},
+ opts,
+ )
+ if errExecute != nil {
+ t.Fatalf("expected fallback to the next credential, got error: %v", errExecute)
+ }
+ if got := string(resp.Payload); got != availableAuth.ID {
+ t.Fatalf("served by %q, want %q", got, availableAuth.ID)
+ }
+
+ resp, errExecute = m.Execute(
+ context.Background(),
+ []string{provider},
+ cliproxyexecutor.Request{Model: model},
+ opts,
+ )
+ if errExecute != nil {
+ t.Fatalf("expected rebound session to use the next credential, got error: %v", errExecute)
+ }
+ if got := string(resp.Payload); got != availableAuth.ID {
+ t.Fatalf("rebound session served by %q, want %q", got, availableAuth.ID)
+ }
+ wantCalls := []string{depletedAuth.ID, availableAuth.ID, availableAuth.ID}
+ if calls := executor.ExecuteCalls(); !slices.Equal(calls, wantCalls) {
+ t.Fatalf("credential calls = %v, want %v", calls, wantCalls)
+ }
+
+ updatedDepleted, ok := m.GetByID(depletedAuth.ID)
+ if !ok || updatedDepleted == nil {
+ t.Fatal("expected depleted auth to remain registered")
+ }
+ state := updatedDepleted.ModelStates[model]
+ if state == nil {
+ t.Fatal("expected the depleted credential to be cooled down for the model")
+ }
+ if !state.Unavailable {
+ t.Fatal("expected the depleted credential to be unavailable for the model")
+ }
+ if state.NextRetryAfter.Before(beforeExecute.Add(29 * time.Minute)) {
+ t.Fatalf("cooldown expires at %v, want approximately 30 minutes", state.NextRetryAfter)
+ }
+
+ authLevel := &Auth{ID: "cc-auth-level-credit-low", Provider: provider}
+ applyAuthFailureState(authLevel, &Error{
+ HTTPStatus: http.StatusBadRequest,
+ Message: creditBalanceLowBody,
+ }, nil, beforeExecute, false)
+ if authLevel.StatusMessage != "payment_required" {
+ t.Fatalf("auth StatusMessage = %q, want %q", authLevel.StatusMessage, "payment_required")
+ }
+ if authLevel.NextRetryAfter.Before(beforeExecute.Add(29 * time.Minute)) {
+ t.Fatalf("auth cooldown expires at %v, want approximately 30 minutes", authLevel.NextRetryAfter)
+ }
+}
+
func TestManager_DeepSeekCredentialFailuresRotateCredential(t *testing.T) {
tests := []struct {
name string
@@ -1961,6 +2072,108 @@ func TestManager_ExecuteCount_GenericRouteNotFoundDoesNotSuspendModel(t *testing
}
}
+func TestManager_ExecuteCount_NotImplementedDoesNotSuspendModelOrExtendCooldown(t *testing.T) {
+ previous := quotaCooldownDisabled.Load()
+ quotaCooldownDisabled.Store(false)
+ t.Cleanup(func() { quotaCooldownDisabled.Store(previous) })
+
+ hook := &resultCaptureHook{}
+ m := NewManager(nil, nil, hook)
+ executor := &authFallbackExecutor{
+ id: "kiro",
+ countTokenErrors: map[string]error{
+ "kiro-count-501-auth": &Error{
+ Code: "not_implemented",
+ HTTPStatus: http.StatusNotImplemented,
+ Message: "count-tokens/http-request are not implemented yet",
+ },
+ },
+ }
+ m.RegisterExecutor(executor)
+
+ model := "claude-opus-5.5"
+ auth := &Auth{ID: "kiro-count-501-auth", Provider: "kiro"}
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(auth.ID, auth.Provider, []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+
+ if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register auth: %v", errRegister)
+ }
+
+ for attempt := 1; attempt <= 2; attempt++ {
+ if _, errCount := m.ExecuteCount(context.Background(), []string{"kiro"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}); errCount == nil {
+ t.Fatalf("attempt %d: expected count_tokens 501 error", attempt)
+ }
+ }
+
+ updated, ok := m.GetByID(auth.ID)
+ if !ok || updated == nil {
+ t.Fatal("expected auth to remain registered")
+ }
+ if updated.Failed != 2 {
+ t.Fatalf("failed request count = %d, want 2", updated.Failed)
+ }
+ results := hook.Results()
+ if len(results) != 2 || results[0].Success || results[0].Error == nil || results[0].Error.HTTPStatus != http.StatusNotImplemented || results[0].Error.Code != "not_implemented" {
+ t.Fatalf("recorded results = %#v, want two failed 501 not_implemented results", results)
+ }
+ if updated.Unavailable {
+ t.Fatal("expected count_tokens 501 to keep auth available")
+ }
+ if state := updated.ModelStates[model]; state != nil {
+ t.Fatalf("expected count_tokens 501 to avoid model cooldown state, got %#v", state)
+ }
+ if count := reg.GetModelCount(model); count != 1 {
+ t.Fatalf("available model count = %d, want 1", count)
+ }
+
+ resp, errExecute := m.Execute(context.Background(), []string{"kiro"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExecute != nil {
+ t.Fatalf("execute after count_tokens 501: %v", errExecute)
+ }
+ if string(resp.Payload) != auth.ID {
+ t.Fatalf("execute payload = %q, want %q", string(resp.Payload), auth.ID)
+ }
+
+ // Simulate a short inference cooldown landing while an ExecuteCount 501 is in flight,
+ // and verify ExecuteCount does not extend the 5s inference cooldown to 1m.
+ var deadlineBefore time.Time
+ executor.mu.Lock()
+ executor.onCountTokens = func(selected *Auth) {
+ shortRetry := 5 * time.Second
+ m.MarkResult(context.Background(), Result{
+ AuthID: selected.ID,
+ Provider: selected.Provider,
+ Model: model,
+ RouteModel: model,
+ Success: false,
+ RetryAfter: &shortRetry,
+ Error: &Error{
+ HTTPStatus: http.StatusInternalServerError,
+ Message: "upstream 500",
+ },
+ })
+ cooledBefore, _ := m.GetByID(selected.ID)
+ if cooledBefore != nil && cooledBefore.ModelStates[model] != nil {
+ deadlineBefore = cooledBefore.ModelStates[model].NextRetryAfter
+ }
+ }
+ executor.mu.Unlock()
+
+ if _, errCount := m.ExecuteCount(context.Background(), []string{"kiro"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}); errCount == nil {
+ t.Fatal("expected count_tokens 501 error during active cooldown check")
+ }
+ if deadlineBefore.IsZero() {
+ t.Fatal("expected active model cooldown deadline to be recorded")
+ }
+ cooledAfter, _ := m.GetByID(auth.ID)
+ stateAfter := cooledAfter.ModelStates[model]
+ if stateAfter == nil || !stateAfter.NextRetryAfter.Equal(deadlineBefore) {
+ t.Fatalf("count_tokens 501 modified existing inference cooldown deadline: got %v, want %v", stateAfter, deadlineBefore)
+ }
+}
+
func TestManager_ExecuteCount_ExplicitModelNotFoundSuspendsModel(t *testing.T) {
previous := quotaCooldownDisabled.Load()
quotaCooldownDisabled.Store(false)
@@ -2171,6 +2384,46 @@ func TestIsCountTokensEndpointNotFoundError(t *testing.T) {
err: &Error{HTTPStatus: http.StatusInternalServerError, Message: "404 page not found"},
want: false,
},
+ {
+ name: "501 not implemented with code",
+ err: &Error{Code: "not_implemented", HTTPStatus: http.StatusNotImplemented, Message: "count-tokens/http-request are not implemented yet"},
+ want: true,
+ },
+ {
+ name: "plain 501 not implemented",
+ err: &Error{HTTPStatus: http.StatusNotImplemented, Message: "501 Not Implemented"},
+ want: true,
+ },
+ {
+ name: "typed not_implemented without status",
+ err: &Error{Code: "not_implemented", Message: "CountTokens not implemented"},
+ want: true,
+ },
+ {
+ name: "typed count_tokens_unsupported",
+ err: &Error{Code: "count_tokens_unsupported", HTTPStatus: http.StatusNotImplemented, Message: "count_tokens endpoint is not available for Kiro"},
+ want: true,
+ },
+ {
+ name: "stdlib ErrUnsupported",
+ err: fmt.Errorf("count_tokens: %w", errors.ErrUnsupported),
+ want: true,
+ },
+ {
+ name: "501 with explicit model_not_found code",
+ err: &Error{Code: "model_not_found", HTTPStatus: http.StatusNotImplemented, Message: "model claude-missing was not found"},
+ want: false,
+ },
+ {
+ name: "401 unauthorized with not_implemented code",
+ err: &Error{Code: "not_implemented", HTTPStatus: http.StatusUnauthorized, Message: "unauthorized"},
+ want: false,
+ },
+ {
+ name: "429 rate limit with not_implemented code",
+ err: &Error{Code: "not_implemented", HTTPStatus: http.StatusTooManyRequests, Message: "rate limit"},
+ want: false,
+ },
}
for _, tc := range tests {
@@ -2486,3 +2739,79 @@ func TestManager_MarkResult_RequestFaultBodyDoesNotCooldownModelOrAuth(t *testin
t.Fatalf("expected real 401 authentication error to set model cooldown state, got %#v", state)
}
}
+
+func TestManager_Execute_DevinMCPConfigurationIssue403_DoesNotRetryOrCooldown(t *testing.T) {
+ prev := quotaCooldownDisabled.Load()
+ quotaCooldownDisabled.Store(false)
+ t.Cleanup(func() { quotaCooldownDisabled.Store(prev) })
+
+ m := NewManager(nil, nil, nil)
+ m.SetRetryConfig(3, 30*time.Second, 0)
+
+ mcpErr := &Error{
+ HTTPStatus: http.StatusForbidden,
+ Message: "devin upstream error (permission_denied): Unable to process request due to an MCP configuration issue.",
+ }
+ executor := &authFallbackExecutor{
+ id: "devin",
+ executeErrors: map[string]error{
+ "auth-devin-1": mcpErr,
+ "auth-devin-2": mcpErr,
+ },
+ streamFirstErrors: map[string]error{
+ "auth-devin-1": mcpErr,
+ "auth-devin-2": mcpErr,
+ },
+ }
+ m.RegisterExecutor(executor)
+
+ model := "devin/claude-opus-5-5"
+ reg := registry.GetGlobalRegistry()
+ for _, id := range []string{"auth-devin-1", "auth-devin-2"} {
+ auth := &Auth{
+ ID: id,
+ Provider: "devin",
+ Status: StatusActive,
+ }
+ if _, errRegister := m.Register(context.Background(), auth); errRegister != nil {
+ t.Fatalf("register %s: %v", id, errRegister)
+ }
+ reg.RegisterClient(id, "devin", []*registry.ModelInfo{{ID: model}})
+ cleanupID := id
+ t.Cleanup(func() { reg.UnregisterClient(cleanupID) })
+ }
+
+ _, errExec := m.Execute(context.Background(), []string{"devin"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errExec == nil {
+ t.Fatal("expected Execute error")
+ }
+ if statusCodeFromError(errExec) != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", statusCodeFromError(errExec), http.StatusForbidden)
+ }
+ if gotCalls := len(executor.executeCalls); gotCalls != 1 {
+ t.Fatalf("upstream execute attempts = %d (%v), want 1 (must not retry deterministic request fault)", gotCalls, executor.executeCalls)
+ }
+
+ _, errStream := m.ExecuteStream(context.Background(), []string{"devin"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errStream == nil {
+ t.Fatal("expected ExecuteStream error")
+ }
+ if statusCodeFromError(errStream) != http.StatusForbidden {
+ t.Fatalf("stream status = %d, want %d", statusCodeFromError(errStream), http.StatusForbidden)
+ }
+ if gotStreamCalls := len(executor.streamCalls); gotStreamCalls != 1 {
+ t.Fatalf("upstream stream attempts = %d (%v), want 1 (must not retry deterministic request fault)", gotStreamCalls, executor.streamCalls)
+ }
+ for _, id := range []string{"auth-devin-1", "auth-devin-2"} {
+ updated, ok := m.GetByID(id)
+ if !ok || updated == nil {
+ t.Fatalf("expected %s to exist", id)
+ }
+ if updated.Unavailable || !updated.NextRetryAfter.IsZero() {
+ t.Fatalf("%s unexpectedly cooled down at auth level: unavailable=%v nextRetryAfter=%v", id, updated.Unavailable, updated.NextRetryAfter)
+ }
+ if state := updated.ModelStates[model]; state != nil && (state.Unavailable || !state.NextRetryAfter.IsZero()) {
+ t.Fatalf("%s unexpectedly cooled down at model level: %#v", id, state)
+ }
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_refresh.go b/sdk/cliproxy/auth/conductor_refresh.go
index b2421f7ac94..cf28ed43e7f 100644
--- a/sdk/cliproxy/auth/conductor_refresh.go
+++ b/sdk/cliproxy/auth/conductor_refresh.go
@@ -114,8 +114,25 @@ func (m *Manager) queueRefreshUnschedule(authID string) {
loop.remove(authID)
}
+// codexAutoRefreshUnavailable mirrors the executor's local renewal requirements.
+// Runtime evaluators and Home retain control over their own refresh mechanisms.
+func (m *Manager) codexAutoRefreshUnavailable(a *Auth, now time.Time) bool {
+ if a == nil || !strings.EqualFold(strings.TrimSpace(a.Provider), "codex") || m.HomeEnabled() {
+ return false
+ }
+ if evaluator, ok := a.Runtime.(RefreshEvaluator); ok && evaluator != nil {
+ return false
+ }
+ // Intentionally ignore refreshToken: the local Codex executor only reads refresh_token.
+ if authMetadataString(a, "refresh_token") != "" {
+ return false
+ }
+ _, hasSIWC, _ := CodexSIWCNeedsRefresh(a, now)
+ return !hasSIWC
+}
+
func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
- if a == nil {
+ if a == nil || m.codexAutoRefreshUnavailable(a, now) {
return false
}
if hasUnauthorizedAuthFailure(a) || hasDisabledInvalidGrantFailure(a) {
@@ -124,6 +141,12 @@ func (m *Manager) shouldRefresh(a *Auth, now time.Time) bool {
if !a.NextRefreshAfter.IsZero() && now.Before(a.NextRefreshAfter) {
return false
}
+ if authHasUnchangedRejectedRefreshToken(a) {
+ if a.HasValidAccessToken(now) {
+ return false
+ }
+ return !a.Disabled && a.Status != StatusDisabled
+ }
if evaluator, ok := a.Runtime.(RefreshEvaluator); ok && evaluator != nil {
return evaluator.ShouldRefresh(now, a)
}
@@ -882,6 +905,10 @@ func shouldPreserveRejectedAccessToken(existing, updated *Auth, isRefresh bool)
if updated.CodexRefreshPatch != nil {
return !updated.CodexRefreshPatch.RefreshedOuter()
}
+ if (existing.RejectedRefreshToken != "" && authRefreshToken(updated) == existing.RejectedRefreshToken) ||
+ existing.RequiresSignIn || updated.RequiresSignIn {
+ return true
+ }
return false
}
return false
@@ -1263,6 +1290,33 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
checkNow := time.Now()
accessTokenRejected := failedAccessToken != "" && authMatchesTokenPartition(auth, failedAccessToken)
if !accessTokenRejected && auth.HasValidAccessToken(checkNow) {
+ shouldReschedule := false
+ shouldUnschedule := false
+ m.mu.Lock()
+ if current := m.auths[id]; current != nil &&
+ current.RegistrationEpoch == auth.RegistrationEpoch &&
+ current.CredentialVersion == auth.CredentialVersion &&
+ authHasUnchangedRejectedRefreshToken(current) {
+ if exp, ok := current.AccessTokenExpirationTime(); ok && !exp.IsZero() && exp.After(checkNow) {
+ if !current.NextRefreshAfter.Equal(exp) {
+ current.NextRefreshAfter = exp
+ shouldReschedule = true
+ }
+ } else if !current.NextRefreshAfter.IsZero() {
+ current.NextRefreshAfter = time.Time{}
+ shouldUnschedule = true
+ }
+ m.auths[id] = current
+ if m.scheduler != nil {
+ m.scheduler.upsertAuth(current.Clone())
+ }
+ }
+ m.mu.Unlock()
+ if shouldReschedule {
+ m.queueRefreshReschedule(id)
+ } else if shouldUnschedule {
+ m.queueRefreshUnschedule(id)
+ }
if auth.RefreshError != nil {
return nil, auth.RefreshError
}
@@ -1280,7 +1334,15 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
current.Generation++
current.UpdatedAt = checkNow
current.Unavailable = true
- current.Status = StatusError
+ if current.Disabled || current.Status == StatusDisabled {
+ current.Status = StatusDisabled
+ if current.StatusMessage == "" {
+ current.StatusMessage = "disabled"
+ }
+ } else {
+ current.Status = StatusError
+ current.StatusMessage = "unauthorized (refresh token invalid)"
+ }
current.NextRefreshAfter = time.Time{}
current.NextRetryAfter = time.Time{}
current.RefreshFailures = 0
@@ -1290,7 +1352,6 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
msg = current.RefreshError.Message
}
current.LastError = &Error{Code: "unauthorized", Message: msg, HTTPStatus: http.StatusUnauthorized}
- current.StatusMessage = "unauthorized (refresh token invalid)"
m.auths[id] = current
if m.scheduler != nil {
m.scheduler.upsertAuth(current.Clone())
@@ -1323,6 +1384,24 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
m.queueRefreshReschedule(id)
return nil, err
}
+ if err == nil {
+ probe := updated
+ if probe == nil {
+ probe = base.Clone()
+ } else {
+ probe = probe.Clone()
+ }
+ applyPreservedRenewalState(base, probe, updateModeRefresh, now)
+ if !secretGrantChanged(base, probe) && authHasUnchangedRejectedRefreshToken(probe) {
+ if probe.RefreshError != nil {
+ err = probe.RefreshError
+ } else if base != nil && base.LastError != nil {
+ err = base.LastError
+ } else {
+ err = &Error{Code: "unauthorized", Message: "unauthorized (refresh token invalid)", HTTPStatus: http.StatusUnauthorized}
+ }
+ }
+ }
log.Debugf("refreshed %s, %s, %v", auth.Provider, auth.ID, err)
if err != nil {
unauthorized := isUnauthorizedError(err)
@@ -1404,6 +1483,28 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
current.RefreshFailures = 0
current.StatusMessage = "disabled (invalid grant)"
shouldUnschedule = true
+ } else if isDisabled && terminalRefresh {
+ current.Unavailable = true
+ current.Status = StatusDisabled
+ current.RefreshFailures = 0
+ if current.StatusMessage == "" {
+ current.StatusMessage = "disabled"
+ }
+ if !authHasUnchangedRejectedRefreshToken(current) {
+ current.NextRefreshAfter = now.Add(refreshFailureBackoff)
+ shouldReschedule = true
+ } else if hasValidAccessToken && !accessTokenRejected {
+ if exp, ok := current.AccessTokenExpirationTime(); ok && !exp.IsZero() && exp.After(now) {
+ current.NextRefreshAfter = exp
+ shouldReschedule = true
+ } else {
+ current.NextRefreshAfter = time.Time{}
+ shouldUnschedule = true
+ }
+ } else {
+ current.NextRefreshAfter = time.Time{}
+ shouldUnschedule = true
+ }
} else if isDisabled {
current.Unavailable = true
current.Status = StatusDisabled
@@ -1525,7 +1626,8 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
}
}
applyPreservedRejectedTokens(base, updated, true)
- if updated.RejectedAccessToken == "" || authAccessToken(updated) != updated.RejectedAccessToken {
+ canServeWithAccessToken := !updated.RequiresSignIn || updated.HasValidAccessToken(now)
+ if canServeWithAccessToken && (updated.RejectedAccessToken == "" || authAccessToken(updated) != updated.RejectedAccessToken) {
updated.LastError = nil
updated.StatusMessage = ""
updated.Unavailable = false
@@ -1534,6 +1636,15 @@ func (m *Manager) refreshAuthForRequestAtEpoch(ctx context.Context, id, failedAc
updated.Status = StatusActive
}
_ = clearUnauthorizedModelStates(updated, now)
+ } else if !canServeWithAccessToken && !updated.Disabled && updated.Status != StatusDisabled {
+ updated.Unavailable = true
+ updated.Status = StatusError
+ if updated.StatusMessage == "" {
+ updated.StatusMessage = "unauthorized (refresh token invalid)"
+ }
+ if updated.LastError == nil && updated.RefreshError != nil {
+ updated.LastError = &Error{Code: "unauthorized", Message: updated.RefreshError.Message, HTTPStatus: http.StatusUnauthorized}
+ }
}
updated.UpdatedAt = now
if m.shouldRefresh(updated, now) {
diff --git a/sdk/cliproxy/auth/conductor_refresh_disabled_test.go b/sdk/cliproxy/auth/conductor_refresh_disabled_test.go
index 77457b0032b..6d6b68892d4 100644
--- a/sdk/cliproxy/auth/conductor_refresh_disabled_test.go
+++ b/sdk/cliproxy/auth/conductor_refresh_disabled_test.go
@@ -2,6 +2,8 @@ package auth
import (
"context"
+ "encoding/base64"
+ "errors"
"fmt"
"net/http"
"sync/atomic"
@@ -304,3 +306,151 @@ func TestRefreshAuthForRequest_RawFmtError_RecognizesInvalidGrant(t *testing.T)
t.Fatalf("backoff diff = %v, want ~1m", diff)
}
}
+
+func TestDisabledCodexRejectedRefreshTokenDoesNotSpin(t *testing.T) {
+ ctx := context.Background()
+ now := time.Now().UTC().Truncate(time.Second)
+ lead := CodexOuterRefreshLead
+ setRefreshLeadFactory(t, "codex", func() *time.Duration { return &lead })
+
+ // Access token still valid for 66h, i.e. inside the 96h CodexOuterRefreshLead window.
+ exp := now.Add(66 * time.Hour)
+ payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, exp.Unix())))
+ accessToken := "eyJhbGciOiJub25lIn0." + payload + ".sig"
+
+ a := &Auth{
+ ID: "codex-disabled.json",
+ FileName: "codex-disabled.json",
+ Provider: "codex",
+ Status: StatusActive,
+ Disabled: true,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": accessToken,
+ "refresh_token": "rt",
+ "disabled": true,
+ },
+ }
+ ex := &unauthorizedRefreshExecutor{
+ id: "codex",
+ refreshErr: errors.New(
+ `token refresh failed with status 401: {"error":{"message":"Could not validate your refresh token. Please try signing in again.","type":"invalid_request_error","code":"invalid_refresh_token"}}`,
+ ),
+ }
+ m := NewManager(nil, &FillFirstSelector{}, nil)
+ m.RegisterExecutor(ex)
+ if _, errReg := m.Register(ctx, a); errReg != nil {
+ t.Fatalf("Register error: %v", errReg)
+ }
+
+ // 1. First refresh attempt fails terminally; NextRefreshAfter must be deferred to exp (not 5m backoff).
+ m.refreshAuth(ctx, a.ID)
+ got, _ := m.GetByID(a.ID)
+ if got == nil {
+ t.Fatal("auth not found after refreshAuth")
+ }
+ if !got.Disabled || got.Status != StatusDisabled || !got.Unavailable {
+ t.Fatalf("status = %s disabled=%v unavailable=%v, want disabled/unavailable", got.Status, got.Disabled, got.Unavailable)
+ }
+ if !got.RequiresSignIn || got.RejectedRefreshToken != "rt" {
+ t.Fatalf("RequiresSignIn=%v RejectedRefreshToken=%q, want true / \"rt\"", got.RequiresSignIn, got.RejectedRefreshToken)
+ }
+ if !got.NextRefreshAfter.Equal(exp) {
+ t.Fatalf("NextRefreshAfter = %v, want access token expiry %v", got.NextRefreshAfter, exp)
+ }
+
+ // 2. Even if NextRefreshAfter has elapsed while the access token is still valid inside the 96h lead window,
+ // a refresh job must restore NextRefreshAfter=exp and the scheduler must not spin or re-trigger before exp.
+ m.mu.Lock()
+ m.auths[a.ID].NextRefreshAfter = now.Add(-time.Second)
+ epoch := m.auths[a.ID].RegistrationEpoch
+ m.mu.Unlock()
+
+ job := m.markRefreshPending(nil, a.ID, epoch, now)
+ if job == nil {
+ t.Fatal("expected markRefreshPending to create job when NextRefreshAfter is in the past")
+ }
+ if !m.beginRefreshJob(ctx, job) {
+ t.Fatal("expected beginRefreshJob to succeed")
+ }
+ _, _ = m.refreshAuthForRequestAtEpoch(ctx, a.ID, "", epoch)
+ m.finishRefreshJob(job, time.Time{}, false)
+
+ afterJob, _ := m.GetByID(a.ID)
+ if !afterJob.NextRefreshAfter.Equal(exp) {
+ t.Fatalf("after job completion NextRefreshAfter = %v, want %v (must not be zeroed by finishRefreshJob)", afterJob.NextRefreshAfter, exp)
+ }
+ if ex.RefreshCalls() != 1 {
+ t.Fatalf("upstream RefreshCalls = %d, want 1", ex.RefreshCalls())
+ }
+
+ // 3. Even with NextRefreshAfter cleared, shouldRefresh must be false and nextRefreshCheckAt must defer to exp.
+ m.mu.Lock()
+ m.auths[a.ID].NextRefreshAfter = time.Time{}
+ gen0 := m.auths[a.ID].Generation
+ m.mu.Unlock()
+
+ m.mu.RLock()
+ cur := m.auths[a.ID]
+ shouldRef := m.shouldRefresh(cur, now)
+ nextAt, okNext := m.nextRefreshCheckAt(now, cur, refreshCheckInterval)
+ m.mu.RUnlock()
+ if shouldRef {
+ t.Fatal("shouldRefresh at now = true, want false for unchanged rejected refresh token with unexpired access token")
+ }
+ if !okNext || !nextAt.Equal(exp) {
+ t.Fatalf("nextRefreshCheckAt at now = (%v, %v), want (%v, true)", nextAt, okNext, exp)
+ }
+
+ loop := newAuthAutoRefreshLoop(m, refreshCheckInterval, 1)
+ loop.rebuild(now)
+ loop.handleDue(ctx, now)
+ if len(loop.jobs) != 0 {
+ t.Fatalf("loop queued %d jobs at now, want 0", len(loop.jobs))
+ }
+ afterLoop, _ := m.GetByID(a.ID)
+ if afterLoop.Generation != gen0 {
+ t.Fatalf("Generation bumped from %d to %d without due refresh", gen0, afterLoop.Generation)
+ }
+
+ // 4. Once the access token expires on the disabled credential, it must be unscheduled and retain StatusDisabled.
+ afterExp := exp.Add(time.Second)
+ m.mu.RLock()
+ cur = m.auths[a.ID]
+ shouldRefExpired := m.shouldRefresh(cur, afterExp)
+ _, okExpired := m.nextRefreshCheckAt(afterExp, cur, refreshCheckInterval)
+ m.mu.RUnlock()
+ if shouldRefExpired || okExpired {
+ t.Fatalf("at access token expiry for disabled credential: shouldRefresh=%v scheduled=%v, want false/false", shouldRefExpired, okExpired)
+ }
+
+ // 5. A disabled credential whose access token is already expired on the first terminal refresh failure
+ // must unschedule immediately with NextRefreshAfter=zero and keep StatusDisabled.
+ expPast := now.Add(-time.Hour)
+ expiredPayload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, expPast.Unix())))
+ expiredAccessToken := "eyJhbGciOiJub25lIn0." + expiredPayload + ".sig"
+ expiredDisabled := &Auth{
+ ID: "codex-disabled-expired.json",
+ FileName: "codex-disabled-expired.json",
+ Provider: "codex",
+ Status: StatusActive,
+ Disabled: true,
+ Metadata: map[string]any{
+ "type": "codex",
+ "access_token": expiredAccessToken,
+ "refresh_token": "rt-expired",
+ "disabled": true,
+ },
+ }
+ if _, errRegExp := m.Register(ctx, expiredDisabled); errRegExp != nil {
+ t.Fatalf("Register expiredDisabled error: %v", errRegExp)
+ }
+ m.refreshAuth(ctx, expiredDisabled.ID)
+ gotExp, _ := m.GetByID(expiredDisabled.ID)
+ if gotExp.Status != StatusDisabled || !gotExp.NextRefreshAfter.IsZero() {
+ t.Fatalf("expired disabled terminal refresh: Status=%s NextRefreshAfter=%v, want StatusDisabled and zero time", gotExp.Status, gotExp.NextRefreshAfter)
+ }
+ if _, ok := m.nextRefreshCheckAt(now, gotExp, refreshCheckInterval); ok {
+ t.Fatal("expired disabled credential with rejected refresh token must not be scheduled")
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_selection.go b/sdk/cliproxy/auth/conductor_selection.go
index 7205eb9b1de..b30333bc7e5 100644
--- a/sdk/cliproxy/auth/conductor_selection.go
+++ b/sdk/cliproxy/auth/conductor_selection.go
@@ -555,14 +555,18 @@ func (m *Manager) SetRoundTripperProvider(p RoundTripperProvider) {
}
func (m *Manager) availableAuthsForRouteModel(auths []*Auth, provider, routeModel string, now time.Time) ([]*Auth, error) {
- return m.availableAuthsForRouteModelWithPriorityMode(auths, provider, routeModel, now, false)
+ return m.availableAuthsForRouteModelForRequest(nil, auths, provider, routeModel, now, false)
}
func (m *Manager) availableAuthsForRouteModelAcrossPriorities(auths []*Auth, provider, routeModel string, now time.Time) ([]*Auth, error) {
- return m.availableAuthsForRouteModelWithPriorityMode(auths, provider, routeModel, now, true)
+ return m.availableAuthsForRouteModelForRequest(nil, auths, provider, routeModel, now, true)
}
func (m *Manager) availableAuthsForRouteModelWithPriorityMode(auths []*Auth, provider, routeModel string, now time.Time, allPriorities bool) ([]*Auth, error) {
+ return m.availableAuthsForRouteModelForRequest(nil, auths, provider, routeModel, now, allPriorities)
+}
+
+func (m *Manager) availableAuthsForRouteModelForRequest(ctx context.Context, auths []*Auth, provider, routeModel string, now time.Time, allPriorities bool, clientHeaders ...http.Header) ([]*Auth, error) {
if len(auths) == 0 {
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
}
@@ -573,7 +577,7 @@ func (m *Manager) availableAuthsForRouteModelWithPriorityMode(auths []*Auth, pro
var earliest time.Time
for _, candidate := range auths {
checkModel := m.selectionModelForAuth(candidate, routeModel)
- blocked, reason, next := isAuthBlockedForModel(candidate, checkModel, now)
+ blocked, reason, next := isAuthBlockedForModelForRequest(ctx, candidate, checkModel, now, clientHeaders...)
if !blocked {
priority := authPriority(candidate)
availableByPriority[priority] = append(availableByPriority[priority], candidate)
@@ -628,11 +632,11 @@ func (m *Manager) availableAuthsForRouteModelWithPriorityMode(auths []*Auth, pro
// the plugin scheduler, plus the candidates handed to the configured selector. Both are equal
// unless session affinity or an across-priorities scheduler is active, in which case the selector
// or scheduler additionally receives lower priority tiers.
-func (m *Manager) availableAuthsForSelector(selector Selector, auths []*Auth, provider, routeModel string, now time.Time, schedulerAcross bool) (priorityAuths, selectorAuths []*Auth, err error) {
+func (m *Manager) availableAuthsForSelector(ctx context.Context, selector Selector, auths []*Auth, provider, routeModel string, now time.Time, schedulerAcross bool, clientHeaders ...http.Header) (priorityAuths, selectorAuths []*Auth, err error) {
_, sessionAffinity := selector.(*SessionAffinitySelector)
if !sessionAffinity && !schedulerAcross {
- priorityAuths, err = m.availableAuthsForRouteModel(auths, provider, routeModel, now)
+ priorityAuths, err = m.availableAuthsForRouteModelForRequest(ctx, auths, provider, routeModel, now, false, clientHeaders...)
if err != nil {
return nil, nil, err
}
@@ -642,7 +646,7 @@ func (m *Manager) availableAuthsForSelector(selector Selector, auths []*Auth, pr
// One availability pass and one clone pass serve both lists: the highest priority tier is a
// subset of the across-priority candidates, so it is narrowed from the same cloned auths.
- allAuths, errAcross := m.availableAuthsForRouteModelAcrossPriorities(auths, provider, routeModel, now)
+ allAuths, errAcross := m.availableAuthsForRouteModelForRequest(ctx, auths, provider, routeModel, now, true, clientHeaders...)
if errAcross != nil {
return nil, nil, errAcross
}
@@ -682,7 +686,7 @@ func selectorContextForAvailableAuths(ctx context.Context, selector Selector, ro
return context.WithValue(ctx, prevalidatedAuthCandidatesKey{}, true)
}
-func (m *Manager) transientlyUnavailableSessionAffinityAuthIDs(selector Selector, affinityCandidates []*Auth, routeModel string, now time.Time) map[string]struct{} {
+func (m *Manager) transientlyUnavailableSessionAffinityAuthIDs(ctx context.Context, selector Selector, affinityCandidates []*Auth, routeModel string, now time.Time, clientHeaders ...http.Header) map[string]struct{} {
affinity, ok := selector.(*SessionAffinitySelector)
if !ok || affinity == nil || !affinity.rebindOnQuotaOnly || len(affinityCandidates) == 0 {
return nil
@@ -696,7 +700,7 @@ func (m *Manager) transientlyUnavailableSessionAffinityAuthIDs(selector Selector
continue
}
checkModel := m.selectionModelForAuth(candidate, routeModel)
- if !isAuthBlockedByQuotaOrAuthForModel(candidate, checkModel, now) {
+ if !isAuthBlockedByQuotaOrAuthForModelForRequest(ctx, candidate, checkModel, now, clientHeaders...) {
if authIDs == nil {
authIDs = make(map[string]struct{}, len(affinityCandidates))
}
@@ -1192,7 +1196,11 @@ func (m *Manager) requestRetryRoundExclusions(retryRound int, defaultRequestRetr
}
func retryRoundAvailabilityForAuth(auth *Auth, model string, now time.Time) (bool, time.Time) {
- blocked, reason, next := isAuthBlockedForModel(auth, model, now)
+ return retryRoundAvailabilityForAuthForRequest(nil, auth, model, now)
+}
+
+func retryRoundAvailabilityForAuthForRequest(ctx context.Context, auth *Auth, model string, now time.Time, clientHeaders ...http.Header) (bool, time.Time) {
+ blocked, reason, next := isAuthBlockedForModelForRequest(ctx, auth, model, now, clientHeaders...)
if !blocked {
return true, time.Time{}
}
@@ -1290,6 +1298,10 @@ func (m *Manager) closestCooldownWait(providers []string, model string, attempt
}
func (m *Manager) closestCooldownWaitWithAttempted(providers []string, model string, attempt int, eligibility authSelectionEligibility, pinnedAuthID string, defaultRequestRetry int, status int, attempted map[string]struct{}) (time.Duration, bool) {
+ return m.closestCooldownWaitWithAttemptedForRequest(nil, providers, model, attempt, eligibility, pinnedAuthID, defaultRequestRetry, status, attempted)
+}
+
+func (m *Manager) closestCooldownWaitWithAttemptedForRequest(ctx context.Context, providers []string, model string, attempt int, eligibility authSelectionEligibility, pinnedAuthID string, defaultRequestRetry int, status int, attempted map[string]struct{}, clientHeaders ...http.Header) (time.Duration, bool) {
if m == nil || len(providers) == 0 {
return 0, false
}
@@ -1337,7 +1349,7 @@ func (m *Manager) closestCooldownWaitWithAttempted(providers []string, model str
if strings.TrimSpace(model) != "" {
checkModel = m.selectionModelForAuth(auth, model)
}
- retryEligible, next := retryRoundAvailabilityForAuth(auth, checkModel, now)
+ retryEligible, next := retryRoundAvailabilityForAuthForRequest(ctx, auth, checkModel, now, clientHeaders...)
if !retryEligible {
continue
}
@@ -1388,6 +1400,10 @@ func (m *Manager) closestCooldownWaitWithAttempted(providers []string, model str
}
func (m *Manager) retryAllowed(attempt int, providers []string, model string, eligibility authSelectionEligibility, pinnedAuthID string, defaultRequestRetry int) bool {
+ return m.retryAllowedForRequest(nil, attempt, providers, model, eligibility, pinnedAuthID, defaultRequestRetry)
+}
+
+func (m *Manager) retryAllowedForRequest(ctx context.Context, attempt int, providers []string, model string, eligibility authSelectionEligibility, pinnedAuthID string, defaultRequestRetry int, clientHeaders ...http.Header) bool {
if m == nil || attempt < 0 || len(providers) == 0 {
return false
}
@@ -1435,7 +1451,7 @@ func (m *Manager) retryAllowed(attempt int, providers []string, model string, el
if strings.TrimSpace(model) != "" {
checkModel = m.selectionModelForAuth(auth, model)
}
- if retryEligible, _ := retryRoundAvailabilityForAuth(auth, checkModel, now); retryEligible {
+ if retryEligible, _ := retryRoundAvailabilityForAuthForRequest(ctx, auth, checkModel, now, clientHeaders...); retryEligible {
return true
}
}
@@ -1507,10 +1523,10 @@ func (m *Manager) shouldRetryAfterErrorWithAttempted(ctx context.Context, opts c
}
eligibility := authSelectionEligibilityForRequest(ctx, opts)
pinnedAuthID := pinnedAuthIDFromMetadata(opts.Metadata)
- if !isRequestRetryRoundError(err) || !m.retryAllowed(attempt, providers, model, eligibility, pinnedAuthID, defaultRequestRetry) {
+ if !isRequestRetryRoundError(err) || !m.retryAllowedForRequest(ctx, attempt, providers, model, eligibility, pinnedAuthID, defaultRequestRetry, opts.Headers) {
return 0, false
}
- wait, found := m.closestCooldownWaitWithAttempted(providers, model, attempt, eligibility, pinnedAuthID, defaultRequestRetry, status, attempted)
+ wait, found := m.closestCooldownWaitWithAttemptedForRequest(ctx, providers, model, attempt, eligibility, pinnedAuthID, defaultRequestRetry, status, attempted, opts.Headers)
if found {
if wait > 0 && (maxWait <= 0 || wait > maxWait) {
return 0, false
@@ -1904,6 +1920,13 @@ func (m *Manager) routeAwareSelectionRequired(auth *Auth, routeModel string) boo
return m.selectionModelKeyForAuth(auth, routeModel) != canonicalModelKey(routeModel)
}
+func requestAwareCredSelectionRequired(ctx context.Context, auth *Auth, clientHeaders ...http.Header) bool {
+ if auth == nil || !IsCodexUseSIWC(auth) {
+ return false
+ }
+ return !IsCodexUseSIWCForRequest(ctx, auth, clientHeaders...)
+}
+
func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, error) {
if m.HomeEnabled() {
clearSchedulerSelectionID(opts.Metadata)
@@ -1970,13 +1993,13 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op
return nil, nil, &Error{Code: "auth_not_found", Message: "no auth available"}
}
now := time.Now()
- available, selectorAuths, errAvailable := m.availableAuthsForSelector(selector, candidates, provider, model, now, schedulerAcross)
+ available, selectorAuths, errAvailable := m.availableAuthsForSelector(ctx, selector, candidates, provider, model, now, schedulerAcross, opts.Headers)
if errAvailable != nil {
m.mu.RUnlock()
m.warnLogAuthUnavailable(ctx, []string{provider}, model, opts, tried, errAvailable)
return nil, nil, errAvailable
}
- transientAuthIDs := m.transientlyUnavailableSessionAffinityAuthIDs(selector, affinityCandidates, model, now)
+ transientAuthIDs := m.transientlyUnavailableSessionAffinityAuthIDs(ctx, selector, affinityCandidates, model, now, opts.Headers)
m.mu.RUnlock()
selected, handled, errPick := m.pickViaPluginScheduler(ctx, pluginScheduler, provider, []string{provider}, model, opts, tried, available)
@@ -2190,9 +2213,9 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
return m.pickNextLegacy(ctx, provider, model, opts, tried)
}
eligibility := authSelectionEligibilityForRequest(ctx, opts)
- if strings.TrimSpace(model) != "" {
+ targetKey := canonicalSchedulingProvider(provider)
+ if strings.TrimSpace(model) != "" || targetKey == "codex" {
m.mu.RLock()
- targetKey := canonicalSchedulingProvider(provider)
for _, candidate := range m.auths {
if candidate == nil || canonicalSchedulingProvider(executorKeyFromAuth(candidate)) != targetKey || candidate.Disabled {
continue
@@ -2203,7 +2226,7 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
if _, used := tried[candidate.ID]; used {
continue
}
- if m.routeAwareSelectionRequired(candidate, model) {
+ if m.routeAwareSelectionRequired(candidate, model) || requestAwareCredSelectionRequired(ctx, candidate, opts.Headers) {
m.mu.RUnlock()
return m.pickNextLegacy(ctx, provider, model, opts, tried)
}
@@ -2238,6 +2261,72 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
return authCopy, executor, nil
}
+// HasAlternativeTargetForModel reports whether at least one eligible credential
+// other than excludedAuthID can serve model across providers.
+func (m *Manager) HasAlternativeTargetForModel(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, excludedAuthID string) bool {
+ if m == nil {
+ return false
+ }
+ excludedAuthID = strings.TrimSpace(excludedAuthID)
+ opts = ensureRequestedModelMetadata(opts, model)
+ if m.HomeEnabled() {
+ return m.hasAlternativeHomeTargetForModel(ctx, model, opts, excludedAuthID)
+ }
+ normalized := m.normalizeProviders(providers)
+ providerSet := make(map[string]struct{}, len(normalized))
+ for _, provider := range normalized {
+ p := canonicalSchedulingProvider(provider)
+ if p == "" {
+ continue
+ }
+ if _, ok := m.Executor(p); !ok {
+ continue
+ }
+ providerSet[p] = struct{}{}
+ }
+ if len(providerSet) == 0 {
+ return false
+ }
+ eligibility := authSelectionEligibilityForRequest(ctx, opts)
+ modelKey := canonicalModelKey(model)
+ registryRef := registry.GetGlobalRegistry()
+
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ candidates := make([]*Auth, 0, len(m.auths))
+ for _, candidate := range m.auths {
+ if candidate == nil || candidate.Disabled {
+ continue
+ }
+ candidateID := strings.TrimSpace(candidate.ID)
+ if candidateID == "" || candidateID == excludedAuthID {
+ continue
+ }
+ if !eligibility.allows(candidate) {
+ continue
+ }
+ providerKey := canonicalSchedulingProvider(executorKeyFromAuth(candidate))
+ if providerKey == "" {
+ continue
+ }
+ if _, ok := providerSet[providerKey]; !ok {
+ continue
+ }
+ if _, ok := m.executorLocked(providerKey); !ok {
+ continue
+ }
+ if modelKey != "" && !m.authSupportsRouteModel(registryRef, candidate, model) {
+ continue
+ }
+ candidates = append(candidates, candidate)
+ }
+ if len(candidates) == 0 {
+ return false
+ }
+ available, _, errAvailable := m.availableAuthsForSelector(ctx, m.selector, candidates, "mixed", model, time.Now(), false, opts.Headers)
+ return errAvailable == nil && len(available) > 0
+}
+
func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, model string, opts cliproxyexecutor.Options, tried map[string]struct{}) (*Auth, ProviderExecutor, string, error) {
if m.HomeEnabled() {
clearSchedulerSelectionID(opts.Metadata)
@@ -2319,13 +2408,13 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m
return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
}
now := time.Now()
- available, selectorAuths, errAvailable := m.availableAuthsForSelector(selector, candidates, "mixed", model, now, schedulerAcross)
+ available, selectorAuths, errAvailable := m.availableAuthsForSelector(ctx, selector, candidates, "mixed", model, now, schedulerAcross, opts.Headers)
if errAvailable != nil {
m.mu.RUnlock()
m.warnLogAuthUnavailable(ctx, providers, model, opts, tried, errAvailable)
return nil, nil, "", errAvailable
}
- transientAuthIDs := m.transientlyUnavailableSessionAffinityAuthIDs(selector, affinityCandidates, model, now)
+ transientAuthIDs := m.transientlyUnavailableSessionAffinityAuthIDs(ctx, selector, affinityCandidates, model, now, opts.Headers)
m.mu.RUnlock()
selected, handled, errPick := m.pickViaPluginScheduler(ctx, pluginScheduler, "mixed", providers, model, opts, tried, available)
@@ -2398,11 +2487,12 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s
return nil, nil, "", &Error{Code: "auth_not_found", Message: "no auth available"}
}
eligibility := authSelectionEligibilityForRequest(ctx, opts)
- if strings.TrimSpace(model) != "" {
- providerSet := make(map[string]struct{}, len(eligibleProviders))
- for _, providerKey := range eligibleProviders {
- providerSet[providerKey] = struct{}{}
- }
+ providerSet := make(map[string]struct{}, len(eligibleProviders))
+ for _, providerKey := range eligibleProviders {
+ providerSet[providerKey] = struct{}{}
+ }
+ _, hasCodex := providerSet["codex"]
+ if strings.TrimSpace(model) != "" || hasCodex {
m.mu.RLock()
for _, candidate := range m.auths {
if candidate == nil || candidate.Disabled {
@@ -2417,7 +2507,7 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s
if _, used := tried[candidate.ID]; used {
continue
}
- if m.routeAwareSelectionRequired(candidate, model) {
+ if m.routeAwareSelectionRequired(candidate, model) || requestAwareCredSelectionRequired(ctx, candidate, opts.Headers) {
m.mu.RUnlock()
return m.pickNextMixedLegacy(ctx, providers, model, opts, tried)
}
@@ -2542,7 +2632,7 @@ func (m *Manager) warnLogAuthUnavailable(ctx context.Context, providers []string
}
totalCandidates++
checkModel := m.selectionModelForAuth(candidate, model)
- blocked, reason, next := isAuthBlockedForModel(candidate, checkModel, now)
+ blocked, reason, next := isAuthBlockedForModelForRequest(ctx, candidate, checkModel, now, opts.Headers)
if blocked && reason == blockReasonCooldown {
coolingSummaries = append(coolingSummaries, authCoolingSummary(candidate, checkModel, next, now))
}
diff --git a/sdk/cliproxy/auth/conductor_stream.go b/sdk/cliproxy/auth/conductor_stream.go
index be5c66cb9b1..47f87de43f9 100644
--- a/sdk/cliproxy/auth/conductor_stream.go
+++ b/sdk/cliproxy/auth/conductor_stream.go
@@ -145,7 +145,7 @@ func (m *Manager) recordCanceledUpstreamCooldown(ctx context.Context, auth *Auth
}
func (m *Manager) wrapStreamResult(ctx context.Context, auth *Auth, provider, resultModel, routeModel string, headers http.Header, buffered []cliproxyexecutor.StreamChunk, remaining <-chan cliproxyexecutor.StreamChunk, aliasResult OAuthModelAliasResult, ephemeralResult bool, opts cliproxyexecutor.Options, executionSeq uint64) *cliproxyexecutor.StreamResult {
- ctx = WithCodexClientRequest(ctx, IsCodexClientRequest(ctx, opts.Headers))
+ ctx = WithCodexClientRequest(ctx, isOpenAIImageExecutionOptions(opts) || IsCodexClientRequest(ctx, opts.Headers))
accessToken := authAccessTokenForRequest(ctx, auth, opts.Headers)
out := make(chan cliproxyexecutor.StreamChunk)
streamStart := time.Now()
diff --git a/sdk/cliproxy/auth/conductor_terminal_response_test.go b/sdk/cliproxy/auth/conductor_terminal_response_test.go
new file mode 100644
index 00000000000..3322c250de0
--- /dev/null
+++ b/sdk/cliproxy/auth/conductor_terminal_response_test.go
@@ -0,0 +1,214 @@
+package auth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "reflect"
+ "testing"
+ "time"
+
+ internalconfig "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executionregistry"
+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
+)
+
+type credentialTerminalResponseError struct{ error }
+
+func (e credentialTerminalResponseError) Unwrap() error { return e.error }
+func (credentialTerminalResponseError) IsCredentialScoped() bool { return true }
+
+func TestManagerTerminalResponsePolicy(t *testing.T) {
+ for _, homeMode := range []bool{false, true} {
+ for _, mode := range []string{"execute", "count", "stream", "bootstrap"} {
+ for _, policy := range []string{"default", "wrapped", "stop", "stop-and-cooldown", "continue", "continue-and-cooldown", "credential", "ordinary"} {
+ t.Run(fmt.Sprintf("home=%t/%s/%s", homeMode, mode, policy), func(t *testing.T) {
+ terminal := &cliproxyexecutor.RequestTerminatedError{
+ HTTPStatus: http.StatusBadGateway,
+ Header: http.Header{"X-Terminal-Response": []string{"preserved"}},
+ Body: []byte(`{"detail":"request outcome is unknown"}`),
+ }
+ var failure error = terminal
+ var rules []internalconfig.RequestScopedErrorRule
+ switch policy {
+ case "wrapped":
+ failure = fmt.Errorf("executor response: %w", terminal)
+ case "credential":
+ terminal.HTTPStatus = http.StatusTooManyRequests
+ failure = credentialTerminalResponseError{error: terminal}
+ case "ordinary":
+ failure = &Error{HTTPStatus: http.StatusBadGateway, Message: "temporary upstream failure"}
+ case "default":
+ default:
+ rules = []internalconfig.RequestScopedErrorRule{{Status: terminal.HTTPStatus, Match: []string{"outcome is unknown"}, Action: policy}}
+ }
+ metadata := map[string]any{"request_scoped_errors": rules}
+ executor := &retryContractHomeExecutor{failure: failure, streamBootstrap: mode == "bootstrap"}
+ manager := NewManager(nil, &FillFirstSelector{}, nil)
+ manager.SetConfig(&internalconfig.Config{Home: internalconfig.HomeConfig{Enabled: homeMode}})
+ manager.SetRetryConfig(1, 0, 2)
+ manager.RegisterExecutor(executor)
+ executions := executionregistry.New()
+ const model = "terminal-response-model"
+ if homeMode {
+ manager.PublishHomeDispatch(&retryContractHomeDispatcher{authIDs: []string{"home-retry-a", "home-retry-b"}, metadata: metadata}, executions, 1)
+ } else {
+ for _, id := range []string{"home-retry-a", "home-retry-b"} {
+ registry.GetGlobalRegistry().RegisterClient(id, executor.Identifier(), []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { registry.GetGlobalRegistry().UnregisterClient(id) })
+ if _, errRegister := manager.Register(context.Background(), &Auth{ID: id, Provider: executor.Identifier(), Status: StatusActive, Metadata: metadata}); errRegister != nil {
+ t.Fatal(errRegister)
+ }
+ }
+ }
+ errExecute := invokeTerminalResponse(manager, executor.Identifier(), model, mode)
+ stop := policy == "default" || policy == "wrapped" || policy == "stop" || policy == "stop-and-cooldown"
+ wantCalls := []string{"home-retry-a"}
+ if stop {
+ requireTerminalResponse(t, errExecute, terminal)
+ } else {
+ wantCalls = append(wantCalls, "home-retry-b")
+ if errExecute != nil {
+ t.Fatalf("safe credential failover failed: %v", errExecute)
+ }
+ }
+ if calls := executor.Calls(); !reflect.DeepEqual(calls, wantCalls) {
+ t.Fatalf("credential executions = %v, want %v", calls, wantCalls)
+ }
+ if active := executions.FreezeInFlight(time.Time{}).Executions; len(active) != 0 {
+ t.Fatalf("execution scopes leaked: %v", active)
+ }
+ if !homeMode && (policy == "default" || policy == "wrapped" || policy == "stop") {
+ auth, _ := manager.GetByID("home-retry-a")
+ if auth.Unavailable || !auth.NextRetryAfter.IsZero() {
+ t.Fatalf("terminal response cooled credential: %+v", auth)
+ }
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestHomeTerminalResponseReleasesWithoutAcknowledgement(t *testing.T) {
+ for _, mode := range []string{"execute", "count", "stream", "bootstrap"} {
+ for _, action := range []string{"", "stop", "stop-and-cooldown"} {
+ t.Run(mode+"/"+action, func(t *testing.T) {
+ terminal := &cliproxyexecutor.RequestTerminatedError{HTTPStatus: http.StatusBadGateway, Body: []byte(`{"detail":"request outcome is unknown"}`)}
+ var rules []internalconfig.RequestScopedErrorRule
+ if action != "" {
+ rules = []internalconfig.RequestScopedErrorRule{{Status: terminal.HTTPStatus, Match: []string{"outcome is unknown"}, Action: action}}
+ }
+ executor := &retryContractHomeExecutor{failure: terminal, streamBootstrap: mode == "bootstrap"}
+ manager := NewManager(nil, nil, nil)
+ manager.SetConfig(&internalconfig.Config{
+ Home: internalconfig.HomeConfig{Enabled: true},
+ CredentialConcurrency: internalconfig.CredentialConcurrencyConfig{CPACancelBound: time.Nanosecond},
+ })
+ manager.SetRetryConfig(1, 0, 2)
+ manager.RegisterExecutor(executor)
+ executions := executionregistry.New()
+ releases := make(chan executionregistry.ReleaseGroup, 2)
+ executions.SetReleaseSink(func(group executionregistry.ReleaseGroup, sequence int64) *executionregistry.ReleaseTicket {
+ releases <- group
+ return executionregistry.NewReleaseTicket(group, sequence, make(chan struct{}))
+ })
+ dispatcher := &accountedHomeExecutionDispatcher{auths: []Auth{{ID: "home-retry-a", Provider: executor.Identifier(), Status: StatusActive, Metadata: map[string]any{"request_scoped_errors": rules}}}}
+ manager.PublishHomeDispatch(dispatcher, executions, 1)
+ requireTerminalResponse(t, invokeTerminalResponse(manager, executor.Identifier(), "terminal-response-model", mode), terminal)
+ if calls := executor.Calls(); !reflect.DeepEqual(calls, []string{"home-retry-a"}) {
+ t.Fatalf("terminal response was replayed: %v", calls)
+ }
+ if dispatcher.calls.Load() != 1 || len(executions.FreezeInFlight(time.Time{}).Executions) != 0 {
+ t.Fatal("terminal execution redispatched or leaked its scope")
+ }
+ select {
+ case group := <-releases:
+ if group != (executionregistry.ReleaseGroup{CredentialID: "home-retry-a", Model: "terminal-response-model"}) {
+ t.Fatalf("wrong concurrency release: %+v", group)
+ }
+ default:
+ t.Fatal("concurrency admission was not released")
+ }
+ select {
+ case <-releases:
+ t.Fatal("concurrency admission released twice")
+ default:
+ }
+ })
+ }
+ }
+}
+
+func TestManagerTerminalUnauthorizedDoesNotRefresh(t *testing.T) {
+ for _, mode := range []string{"execute", "count", "stream", "bootstrap"} {
+ t.Run(mode, func(t *testing.T) {
+ terminal := &cliproxyexecutor.RequestTerminatedError{HTTPStatus: http.StatusUnauthorized, Body: []byte(`{"detail":"terminal rejection"}`)}
+ executor := &claudeCancellationTestExecutor{
+ executeFn: func(context.Context, *Auth) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, terminal
+ },
+ countFn: func(context.Context, *Auth) (cliproxyexecutor.Response, error) {
+ return cliproxyexecutor.Response{}, terminal
+ },
+ streamFn: func(context.Context, *Auth) (*cliproxyexecutor.StreamResult, error) {
+ if mode == "bootstrap" {
+ chunks := make(chan cliproxyexecutor.StreamChunk, 1)
+ chunks <- cliproxyexecutor.StreamChunk{Err: terminal}
+ close(chunks)
+ return &cliproxyexecutor.StreamResult{Chunks: chunks}, nil
+ }
+ return nil, terminal
+ },
+ }
+ manager, auth, model := newClaudeCancellationTestManager(t, executor, nil)
+ requireTerminalResponse(t, invokeTerminalResponse(manager, executor.Identifier(), model, mode), terminal)
+ if calls := executor.executeCalls.Load() + executor.countCalls.Load() + executor.streamCalls.Load(); calls != 1 || executor.refreshCalls.Load() != 0 {
+ t.Fatalf("terminal 401 was retried or refreshed: executions=%d refreshes=%d", calls, executor.refreshCalls.Load())
+ }
+ requireClaudeCancellationNeutral(t, manager, auth.ID, model)
+ })
+ }
+}
+
+func invokeTerminalResponse(manager *Manager, provider, model, mode string) error {
+ req := cliproxyexecutor.Request{Model: model}
+ switch mode {
+ case "execute":
+ _, errExecute := manager.Execute(context.Background(), []string{provider}, req, cliproxyexecutor.Options{})
+ return errExecute
+ case "count":
+ _, errCount := manager.ExecuteCount(context.Background(), []string{provider}, req, cliproxyexecutor.Options{})
+ return errCount
+ default:
+ stream, errStream := manager.ExecuteStream(context.Background(), []string{provider}, req, cliproxyexecutor.Options{Stream: true})
+ if errStream != nil {
+ return errStream
+ }
+ for chunk := range stream.Chunks {
+ if chunk.Err != nil {
+ return chunk.Err
+ }
+ }
+ return nil
+ }
+}
+
+func requireTerminalResponse(t *testing.T, err error, terminal *cliproxyexecutor.RequestTerminatedError) {
+ t.Helper()
+ var response *cliproxyexecutor.RequestTerminatedError
+ if !errors.As(err, &response) || response != terminal {
+ t.Fatalf("response = %v, want original terminal response", err)
+ }
+ var downstream interface {
+ StatusCode() int
+ ResponseHeaders() http.Header
+ ResponseBody() []byte
+ }
+ if !errors.As(err, &downstream) || downstream.StatusCode() != terminal.HTTPStatus ||
+ !reflect.DeepEqual(downstream.ResponseHeaders(), terminal.Header) || string(downstream.ResponseBody()) != string(terminal.Body) {
+ t.Fatalf("downstream status, headers or body changed: %v", err)
+ }
+}
diff --git a/sdk/cliproxy/auth/conductor_unauthorized_refresh_test.go b/sdk/cliproxy/auth/conductor_unauthorized_refresh_test.go
index 1ac52ae2acf..9b3272b99f7 100644
--- a/sdk/cliproxy/auth/conductor_unauthorized_refresh_test.go
+++ b/sdk/cliproxy/auth/conductor_unauthorized_refresh_test.go
@@ -1829,3 +1829,104 @@ func containsSubstr(s, sub string) bool {
}
return false
}
+
+func TestManager_ForceRefreshAuth_SignInRequiredDoesNotReadmitDeadCredential_Issue7135(t *testing.T) {
+ ctx := context.Background()
+ now := time.Now().UTC().Truncate(time.Second)
+ futureExp := now.Add(6 * time.Hour)
+
+ // Single-account pool where the access_token has future nominal expiry, but upstream rejects it with 401
+ // and refresh fails terminally with refresh_token_reused.
+ auth := &Auth{
+ ID: "codex-single-7135.json",
+ FileName: "codex-single-7135.json",
+ Provider: "codex",
+ Status: StatusActive,
+ Metadata: map[string]any{
+ "type": "codex",
+ "email": "dead@example.com",
+ "access_token": "revoked-access-token",
+ "refresh_token": "revoked-refresh-token",
+ "expired": futureExp.Format(time.RFC3339),
+ },
+ }
+
+ executor := &unauthorizedRefreshExecutor{
+ id: "codex",
+ tokenInvalid: map[string]struct{}{"revoked-access-token": {}},
+ refreshErr: errors.New(`token refresh failed with status 401: {"error":{"code":"refresh_token_reused","message":"Refresh token has already been used"}}`),
+ }
+ m := NewManager(nil, &FillFirstSelector{}, nil)
+ m.RegisterExecutor(executor)
+ model := "gpt-6.1-terra-issue7135"
+ reg := registry.GetGlobalRegistry()
+ reg.RegisterClient(auth.ID, "codex", []*registry.ModelInfo{{ID: model}})
+ t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
+
+ if _, errReg := m.Register(ctx, auth); errReg != nil {
+ t.Fatalf("register auth: %v", errReg)
+ }
+
+ // Initial inference triggers 401 + terminal refresh failure -> quarantines credential.
+ _, errInit := m.Execute(ctx, []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errInit == nil {
+ t.Fatal("expected initial Execute to fail with 401")
+ }
+ beforeForce, _ := m.GetByID(auth.ID)
+ if !beforeForce.Unavailable || beforeForce.Status != StatusError || !beforeForce.RequiresSignIn {
+ t.Fatalf("precondition failed: Unavailable=%v Status=%s RequiresSignIn=%v", beforeForce.Unavailable, beforeForce.Status, beforeForce.RequiresSignIn)
+ }
+
+ // Simulate an executor returning (auth, nil) with nil CodexRefreshPatch and unchanged tokens when
+ // refresh_token is already in RejectedRefreshToken and disk did not rotate.
+ executor.mu.Lock()
+ executor.refreshErr = nil
+ executor.refreshPatch = nil
+ executor.refreshTokens = map[string]string{auth.ID: "revoked-access-token"}
+ executor.mu.Unlock()
+
+ if _, errForce := m.ForceRefreshAuth(ctx, auth.ID); errForce == nil {
+ t.Fatal("expected ForceRefreshAuth with unchanged rejected refresh_token and unchanged access_token to fail, got nil")
+ }
+
+ afterForce, _ := m.GetByID(auth.ID)
+ if !afterForce.Unavailable || afterForce.Status != StatusError || !afterForce.RequiresSignIn {
+ t.Fatalf("ForceRefreshAuth flipped terminal sign-in-required credential to active: Unavailable=%v Status=%s RequiresSignIn=%v StatusMessage=%q",
+ afterForce.Unavailable, afterForce.Status, afterForce.RequiresSignIn, afterForce.StatusMessage)
+ }
+ if afterForce.RejectedAccessToken != "revoked-access-token" {
+ t.Fatalf("RejectedAccessToken = %q, want %q preserved", afterForce.RejectedAccessToken, "revoked-access-token")
+ }
+
+ // Next inference must fail with 503 auth_unavailable without attempting upstream Execute on the dead credential.
+ execCallsBefore := len(executor.ExecuteCalls())
+ _, errAfterForce := m.Execute(ctx, []string{"codex"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{})
+ if errAfterForce == nil {
+ t.Fatal("expected Execute after ForceRefreshAuth to fail with auth_unavailable")
+ }
+ var authUnavailableErr *Error
+ if !errors.As(errAfterForce, &authUnavailableErr) || authUnavailableErr == nil || authUnavailableErr.StatusCode() != http.StatusServiceUnavailable || authUnavailableErr.Code != "auth_unavailable" {
+ t.Fatalf("Execute after ForceRefreshAuth error = %#v, want 503 auth_unavailable", errAfterForce)
+ }
+ if gotCalls := len(executor.ExecuteCalls()); gotCalls != execCallsBefore {
+ t.Fatalf("dead credential was selected for inference after ForceRefreshAuth (%d -> %d execute calls)", execCallsBefore, gotCalls)
+ }
+
+ // Even if an inactive SIWC partition rotates while use_siwc=false and the active outer token remains rejected,
+ // the credential must remain Unavailable=true and Status=StatusError.
+ withSIWC := afterForce.Clone()
+ withSIWC.Metadata["use_siwc"] = false
+ withSIWC.Metadata["siwc"] = map[string]any{
+ "access_token": "siwc-at-1",
+ "refresh_token": "siwc-rt-1",
+ "client_id": "cid-1",
+ }
+ if _, errUpdSIWC := m.Update(ctx, withSIWC); errUpdSIWC != nil {
+ t.Fatalf("Update(withSIWC) error: %v", errUpdSIWC)
+ }
+ afterUpdSIWC, _ := m.GetByID(auth.ID)
+ if !afterUpdSIWC.Unavailable || afterUpdSIWC.Status != StatusError || !afterUpdSIWC.RequiresSignIn {
+ t.Fatalf("Update of inactive SIWC partition cleared terminal outer failure: Unavailable=%v Status=%s RequiresSignIn=%v",
+ afterUpdSIWC.Unavailable, afterUpdSIWC.Status, afterUpdSIWC.RequiresSignIn)
+ }
+}
diff --git a/sdk/cliproxy/auth/cooldown_view.go b/sdk/cliproxy/auth/cooldown_view.go
index 5f52767c83a..48324fe0493 100644
--- a/sdk/cliproxy/auth/cooldown_view.go
+++ b/sdk/cliproxy/auth/cooldown_view.go
@@ -132,6 +132,8 @@ func cooldownErrorReason(err *Error) string {
return "cloudflare_challenge"
case isInvalidGrantResultError(err):
return "invalid_grant"
+ case isCreditBalanceTooLowResultError(err):
+ return "payment_required"
case isTransientRateLimitResultError(err):
return "transient_rate_limit"
}
diff --git a/sdk/cliproxy/auth/cooldown_view_test.go b/sdk/cliproxy/auth/cooldown_view_test.go
index 4af80cc0df1..03104ec65b3 100644
--- a/sdk/cliproxy/auth/cooldown_view_test.go
+++ b/sdk/cliproxy/auth/cooldown_view_test.go
@@ -126,6 +126,7 @@ func TestCooldownSnapshotForAuthReasons(t *testing.T) {
{name: "invalid grant", err: &Error{HTTPStatus: 400, Message: "invalid_grant"}, reason: "invalid_grant", status: 400},
{name: "unauthorized", err: &Error{HTTPStatus: 401}, reason: "unauthorized", status: 401},
{name: "payment", err: &Error{HTTPStatus: 402}, reason: "payment_required", status: 402},
+ {name: "anthropic credit balance too low", err: &Error{HTTPStatus: 400, Message: `{"type":"error","error":{"type":"invalid_request_error","message":"Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits."}}`}, reason: "payment_required", status: 400},
{name: "forbidden", err: &Error{HTTPStatus: 403}, reason: "payment_required", status: 403},
{name: "not found", err: &Error{HTTPStatus: 404}, reason: "not_found", status: 404},
{name: "gateway not challenge", err: &Error{HTTPStatus: 520, Message: "cloudflare challenge"}, reason: "transient_error", status: 520},
diff --git a/sdk/cliproxy/auth/errors.go b/sdk/cliproxy/auth/errors.go
index 7b00e3509f6..b54e14b78b2 100644
--- a/sdk/cliproxy/auth/errors.go
+++ b/sdk/cliproxy/auth/errors.go
@@ -105,6 +105,14 @@ func (e *Error) Error() string {
return e.Code + ": " + e.Message
}
+// ErrorCode returns the machine-readable error code embedded in the Error.
+func (e *Error) ErrorCode() string {
+ if e == nil {
+ return ""
+ }
+ return e.Code
+}
+
// StatusCode implements optional status accessor for manager decision making.
func (e *Error) StatusCode() int {
if e == nil {
diff --git a/sdk/cliproxy/auth/metadata_merge.go b/sdk/cliproxy/auth/metadata_merge.go
index f6592944b2b..b29b699e86f 100644
--- a/sdk/cliproxy/auth/metadata_merge.go
+++ b/sdk/cliproxy/auth/metadata_merge.go
@@ -35,6 +35,8 @@ func MergeExistingAuthMetadata(target *Auth, existingMap map[string]any) {
target.Disabled = disabled
}
}
+ _, targetHadOAuthProvider := target.Metadata["oauth_provider"]
+ _, targetHadPortal := target.Metadata["portal"]
for k, v := range existingMap {
if IsAuthTokenPayloadKey(k) {
continue
@@ -45,6 +47,12 @@ func MergeExistingAuthMetadata(target *Auth, existingMap map[string]any) {
continue
}
}
+ if strings.EqualFold(strings.TrimSpace(k), "portal") && targetHadOAuthProvider {
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(k), "oauth_provider") && targetHadPortal {
+ continue
+ }
if _, exists := target.Metadata[k]; !exists {
target.Metadata[k] = v
}
@@ -134,7 +142,7 @@ func MergeRefreshedAuth(base, current, updated *Auth) *Auth {
merged.Unavailable = current.Unavailable
merged.Status = current.Status
merged.StatusMessage = current.StatusMessage
- } else if (updated.Status == StatusActive || updated.Status == "") && (merged.RejectedAccessToken == "" || authAccessToken(merged) != merged.RejectedAccessToken) {
+ } else if (updated.Status == StatusActive || updated.Status == "") && (!merged.RequiresSignIn || merged.HasValidAccessToken(time.Now())) && (merged.RejectedAccessToken == "" || authAccessToken(merged) != merged.RejectedAccessToken) {
// Successful refresh clears previous auth error and restores active
merged.Status = StatusActive
merged.Unavailable = false
diff --git a/sdk/cliproxy/auth/metadata_merge_test.go b/sdk/cliproxy/auth/metadata_merge_test.go
index 7a49c7dd1f6..c4bf8b2d155 100644
--- a/sdk/cliproxy/auth/metadata_merge_test.go
+++ b/sdk/cliproxy/auth/metadata_merge_test.go
@@ -523,3 +523,37 @@ func TestMergeRefreshedAndExistingAuthMetadataPreservesSIWC(t *testing.T) {
t.Fatalf("merged siwc lost client_id or device_id: %#v", mergedSIWC)
}
}
+
+func TestMergeExistingAuthMetadataPortalAttributionDeterministic(t *testing.T) {
+ // When target initially has neither oauth_provider nor portal, both fields from existingMap
+ // must be preserved deterministically regardless of map iteration order.
+ for i := 0; i < 20; i++ {
+ target := &Auth{
+ Provider: "zcode",
+ Metadata: map[string]any{"access_token": "new-tok"},
+ }
+ MergeExistingAuthMetadata(target, map[string]any{
+ "oauth_provider": "zcode-bigmodel",
+ "portal": "zcode-bigmodel",
+ })
+ if target.Metadata["oauth_provider"] != "zcode-bigmodel" || target.Metadata["portal"] != "zcode-bigmodel" {
+ t.Fatalf("iteration %d: expected both oauth_provider and portal preserved, got %#v", i, target.Metadata)
+ }
+ }
+
+ // When target explicitly defines a new oauth_provider, stale portal from existingMap is skipped.
+ reloginTarget := &Auth{
+ Provider: "zcode",
+ Metadata: map[string]any{"oauth_provider": "zcode-zai", "access_token": "new-tok"},
+ }
+ MergeExistingAuthMetadata(reloginTarget, map[string]any{
+ "oauth_provider": "zcode-bigmodel",
+ "portal": "zcode-bigmodel",
+ })
+ if reloginTarget.Metadata["oauth_provider"] != "zcode-zai" {
+ t.Fatalf("expected oauth_provider=zcode-zai, got %#v", reloginTarget.Metadata["oauth_provider"])
+ }
+ if _, hasPortal := reloginTarget.Metadata["portal"]; hasPortal {
+ t.Fatalf("expected stale portal to be skipped when target defines oauth_provider, got %#v", reloginTarget.Metadata["portal"])
+ }
+}
diff --git a/sdk/cliproxy/auth/response_model_rewriter.go b/sdk/cliproxy/auth/response_model_rewriter.go
index 6ce75e2c5fe..da6f40bbc61 100644
--- a/sdk/cliproxy/auth/response_model_rewriter.go
+++ b/sdk/cliproxy/auth/response_model_rewriter.go
@@ -3,9 +3,8 @@ package auth
import (
"bytes"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
var modelFieldPaths = []string{"model", "modelVersion", "response.model", "response.modelVersion", "message.model"}
@@ -20,7 +19,7 @@ func rewriteSSEPayloadLines(payload []byte, targetModel string) []byte {
out := make([][]byte, 0, len(lines))
for _, line := range lines {
prefix, jsonData, ok := extractSSEDataLine(line)
- if ok && len(jsonData) > 0 && jsonData[0] == '{' && gjson.ValidBytes(jsonData) {
+ if ok && len(jsonData) > 0 && jsonData[0] == '{' && ojson.ValidBytes(jsonData) {
rewritten := rewriteModelInResponse(jsonData, targetModel)
line = append(append([]byte{}, prefix...), rewritten...)
}
@@ -37,12 +36,21 @@ func rewriteModelInResponse(data []byte, targetModel string) []byte {
if targetModel == "" || len(data) == 0 {
return data
}
- for _, path := range modelFieldPaths {
- if gjson.GetBytes(data, path).Exists() {
- data, _ = sjson.SetBytes(data, path, targetModel)
+ var resultsBuf [5]ojson.Result
+ results := ojson.GetManyBytesNoCopy(resultsBuf[:0], data, modelFieldPaths...)
+ var doc *ojson.Doc
+ for i, path := range modelFieldPaths {
+ if i < len(results) && results[i].Exists() {
+ if doc == nil {
+ doc = ojson.NewDoc(data)
+ }
+ _ = doc.Set(path, targetModel)
}
}
- return data
+ if doc == nil {
+ return data
+ }
+ return doc.Bytes()
}
// StreamRewriteOptions configures the stream rewriter.
@@ -73,11 +81,15 @@ func (r *StreamRewriter) rewriteModel(data []byte) []byte {
return data
}
targetModel := r.options.RewriteModel
- rewroteAny := false
- for _, path := range modelFieldPaths {
- if gjson.GetBytes(data, path).Exists() {
- data, _ = sjson.SetBytes(data, path, targetModel)
- rewroteAny = true
+ var resultsBuf [5]ojson.Result
+ results := ojson.GetManyBytesNoCopy(resultsBuf[:0], data, modelFieldPaths...)
+ var doc *ojson.Doc
+ for i, path := range modelFieldPaths {
+ if i < len(results) && results[i].Exists() {
+ if doc == nil {
+ doc = ojson.NewDoc(data)
+ }
+ _ = doc.Set(path, targetModel)
if r.loggedPaths == nil {
r.loggedPaths = make(map[string]bool)
}
@@ -87,10 +99,11 @@ func (r *StreamRewriter) rewriteModel(data []byte) []byte {
}
}
}
- if rewroteAny {
- r.rewrittenChunks++
+ if doc == nil {
+ return data
}
- return data
+ r.rewrittenChunks++
+ return doc.Bytes()
}
func (r *StreamRewriter) rewriteSSELines(payload []byte) []byte {
@@ -101,7 +114,7 @@ func (r *StreamRewriter) rewriteSSELines(payload []byte) []byte {
out := make([][]byte, 0, len(lines))
for _, line := range lines {
prefix, jsonData, ok := extractSSEDataLine(line)
- if ok && len(jsonData) > 0 && jsonData[0] == '{' && gjson.ValidBytes(jsonData) {
+ if ok && len(jsonData) > 0 && jsonData[0] == '{' && ojson.ValidBytes(jsonData) {
rewritten := r.rewriteModel(jsonData)
line = append(append([]byte{}, prefix...), rewritten...)
}
@@ -146,7 +159,7 @@ func (r *StreamRewriter) RewriteChunk(chunk []byte) []byte {
// Handle raw JSON chunks (Gemini/OpenAI format without SSE "data:" prefix)
trimmed := bytes.TrimSpace(chunk)
- if len(trimmed) > 0 && trimmed[0] == '{' && gjson.ValidBytes(trimmed) {
+ if len(trimmed) > 0 && trimmed[0] == '{' && ojson.ValidBytes(trimmed) {
rewritten := trimmed
if r.options.RewriteModel != "" {
rewritten = r.rewriteModel(rewritten)
@@ -166,7 +179,7 @@ func (r *StreamRewriter) RewriteChunk(chunk []byte) []byte {
} else {
processChunk = chunk
}
- } else if gjson.ValidBytes(extractLastDataPayload(chunk)) {
+ } else if ojson.ValidBytes(extractLastDataPayload(chunk)) {
processChunk = chunk
} else if len(bytes.TrimSpace(chunk)) == 0 {
return chunk
@@ -198,7 +211,7 @@ func (r *StreamRewriter) RewriteChunk(chunk []byte) []byte {
dataPrefix, jsonData, found := extractSSEDataLine(line)
if found && len(jsonData) > 0 && jsonData[0] == '{' {
- if !gjson.ValidBytes(jsonData) {
+ if !ojson.ValidBytes(jsonData) {
if pendingEvent != nil {
r.pendingBuf = append(pendingEvent, '\n')
r.pendingBuf = append(r.pendingBuf, line...)
@@ -302,7 +315,7 @@ func safeReplaceGlued(chunk []byte, old, new []byte) []byte {
part = remaining[lineStart+1 : idx+1]
}
_, jsonData, ok := extractSSEDataLine(part)
- if ok && len(jsonData) > 0 && gjson.ValidBytes(jsonData) {
+ if ok && len(jsonData) > 0 && ojson.ValidBytes(jsonData) {
result = append(result, remaining[:idx]...)
result = append(result, new...)
remaining = remaining[idx+len(old):]
diff --git a/sdk/cliproxy/auth/response_model_rewriter_antigravity_sim_test.go b/sdk/cliproxy/auth/response_model_rewriter_antigravity_sim_test.go
index 65b62a0413d..47a2d69a6e2 100644
--- a/sdk/cliproxy/auth/response_model_rewriter_antigravity_sim_test.go
+++ b/sdk/cliproxy/auth/response_model_rewriter_antigravity_sim_test.go
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
gemresponses "github.com/router-for-me/CLIProxyAPI/v8/internal/translator/gemini/openai/responses"
- "github.com/tidwall/gjson"
)
func antigravityLiveSSEChunks(t *testing.T) [][]byte {
@@ -95,12 +95,12 @@ func parseCompletedFromSSE(payload []byte) bool {
continue
}
line = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
- if gjson.Get(line, "type").String() == "response.completed" {
+ if ojson.Get(line, "type").String() == "response.completed" {
return true
}
}
trim := strings.TrimSpace(string(payload))
- if strings.HasPrefix(trim, "{") && gjson.Get(trim, "type").String() == "response.completed" {
+ if strings.HasPrefix(trim, "{") && ojson.Get(trim, "type").String() == "response.completed" {
return true
}
return false
diff --git a/sdk/cliproxy/auth/response_model_rewriter_test.go b/sdk/cliproxy/auth/response_model_rewriter_test.go
index b5c91c4a64c..e94427a7f91 100644
--- a/sdk/cliproxy/auth/response_model_rewriter_test.go
+++ b/sdk/cliproxy/auth/response_model_rewriter_test.go
@@ -3,7 +3,7 @@ package auth
import (
"bytes"
- "github.com/tidwall/gjson"
+ "github.com/MoeForge/moego/ojson"
"strings"
"testing"
@@ -223,7 +223,7 @@ func TestNormalizeGluedSSEEvents_SplitsValidGlueOnly(t *testing.T) {
for _, line := range bytes.Split(inside, []byte("\n")) {
if bytes.HasPrefix(line, []byte("data:")) {
_, jd, ok := extractSSEDataLine(line)
- if !ok || !gjson.ValidBytes(jd) {
+ if !ok || !ojson.ValidBytes(jd) {
t.Fatalf("baseline invalid")
}
}
@@ -231,7 +231,7 @@ func TestNormalizeGluedSSEEvents_SplitsValidGlueOnly(t *testing.T) {
for _, line := range bytes.Split([]byte(gotInside), []byte("\n")) {
if bytes.HasPrefix(line, []byte("data:")) {
_, jd, ok := extractSSEDataLine(line)
- if !ok || !gjson.ValidBytes(jd) {
+ if !ok || !ojson.ValidBytes(jd) {
t.Fatalf("corrupted JSON after normalize: %q", gotInside)
}
}
@@ -251,7 +251,7 @@ func TestNormalizeGluedSSEEvents_SplitsCodexDataGlueOnly(t *testing.T) {
for _, line := range bytes.Split([]byte(gotInside), []byte("\n")) {
if bytes.HasPrefix(line, []byte("data:")) {
_, jd, ok := extractSSEDataLine(line)
- if !ok || !gjson.ValidBytes(jd) {
+ if !ok || !ojson.ValidBytes(jd) {
t.Fatalf("corrupted JSON: %q", gotInside)
}
}
@@ -270,10 +270,10 @@ func parseResponsesWSDataEventTypes(payload []byte) []string {
if bytes.HasPrefix(line, []byte("data:")) {
line = bytes.TrimSpace(line[len("data:"):])
}
- if len(line) == 0 || !gjson.ValidBytes(line) {
+ if len(line) == 0 || !ojson.ValidBytes(line) {
continue
}
- types = append(types, gjson.GetBytes(line, "type").String())
+ types = append(types, ojson.GetBytesNoCopy(line, "type").String())
}
return types
}
diff --git a/sdk/cliproxy/auth/selector.go b/sdk/cliproxy/auth/selector.go
index fc7805413a6..9d88187de83 100644
--- a/sdk/cliproxy/auth/selector.go
+++ b/sdk/cliproxy/auth/selector.go
@@ -14,12 +14,13 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/credentialweight"
"github.com/router-for-me/CLIProxyAPI/v8/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
cliproxysession "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/session"
)
@@ -195,8 +196,8 @@ func ExtractUpstreamErrorSummary(raw string) string {
if idx := strings.Index(raw, ": {"); idx != -1 && idx < 50 {
jsonPart = strings.TrimSpace(raw[idx+2:])
}
- if gjson.Valid(jsonPart) {
- parsed := gjson.Parse(jsonPart)
+ if ojson.Valid(jsonPart) {
+ parsed := ojson.Parse(jsonPart)
var code, message string
if errNode := parsed.Get("error"); errNode.Exists() {
if errNode.IsObject() {
@@ -205,7 +206,7 @@ func ExtractUpstreamErrorSummary(raw string) string {
code = strings.TrimSpace(errNode.Get("type").String())
}
message = strings.TrimSpace(errNode.Get("message").String())
- } else if errNode.Type == gjson.String {
+ } else if errNode.Type == ojson.String {
message = strings.TrimSpace(errNode.String())
}
}
@@ -467,10 +468,14 @@ func preferCodexWebsocketAuths(ctx context.Context, provider string, available [
}
func collectAvailableByPriority(auths []*Auth, model string, now time.Time) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {
+ return collectAvailableByPriorityForRequest(nil, auths, model, now)
+}
+
+func collectAvailableByPriorityForRequest(ctx context.Context, auths []*Auth, model string, now time.Time, clientHeaders ...http.Header) (available map[int][]*Auth, cooldownCount int, earliest time.Time) {
available = make(map[int][]*Auth)
for i := 0; i < len(auths); i++ {
candidate := auths[i]
- blocked, reason, next := isAuthBlockedForModel(candidate, model, now)
+ blocked, reason, next := isAuthBlockedForModelForRequest(ctx, candidate, model, now, clientHeaders...)
if !blocked {
priority := authPriority(candidate)
available[priority] = append(available[priority], candidate)
@@ -513,7 +518,7 @@ func getSelectorAvailableAuthsWithPriorityMode(ctx context.Context, auths []*Aut
return auths, nil
}
}
- return getAvailableAuthsWithPriorityMode(auths, provider, model, now, allPriorities)
+ return getAvailableAuthsWithPriorityModeForRequest(ctx, auths, provider, model, now, allPriorities)
}
func getAvailableAuthsAcrossPriorities(auths []*Auth, provider, model string, now time.Time) ([]*Auth, error) {
@@ -521,11 +526,15 @@ func getAvailableAuthsAcrossPriorities(auths []*Auth, provider, model string, no
}
func getAvailableAuthsWithPriorityMode(auths []*Auth, provider, model string, now time.Time, allPriorities bool) ([]*Auth, error) {
+ return getAvailableAuthsWithPriorityModeForRequest(nil, auths, provider, model, now, allPriorities)
+}
+
+func getAvailableAuthsWithPriorityModeForRequest(ctx context.Context, auths []*Auth, provider, model string, now time.Time, allPriorities bool, clientHeaders ...http.Header) ([]*Auth, error) {
if len(auths) == 0 {
return nil, &Error{Code: "auth_not_found", Message: "no auth candidates"}
}
- availableByPriority, cooldownCount, earliest := collectAvailableByPriority(auths, model, now)
+ availableByPriority, cooldownCount, earliest := collectAvailableByPriorityForRequest(ctx, auths, model, now, clientHeaders...)
if len(availableByPriority) == 0 {
if cooldownCount == len(auths) && !earliest.IsZero() {
providerForError := provider
@@ -820,7 +829,17 @@ func (s *FillFirstSelector) Pick(ctx context.Context, provider, model string, op
return available[0], nil
}
+// IsAuthAvailableForModel reports whether auth is eligible for selection on model at now.
+func IsAuthAvailableForModel(auth *Auth, model string, now time.Time) bool {
+ blocked, _, _ := isAuthBlockedForModel(auth, model, now)
+ return !blocked
+}
+
func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, blockReason, time.Time) {
+ return isAuthBlockedForModelForRequest(nil, auth, model, now)
+}
+
+func isAuthBlockedForModelForRequest(ctx context.Context, auth *Auth, model string, now time.Time, clientHeaders ...http.Header) (bool, blockReason, time.Time) {
if auth == nil {
return true, blockReasonOther, time.Time{}
}
@@ -830,7 +849,7 @@ func isAuthBlockedForModel(auth *Auth, model string, now time.Time) (bool, block
if hasUnauthorizedAuthFailure(auth) {
return true, blockReasonOther, time.Time{}
}
- if exp, ok := auth.AccessTokenExpirationTime(); ok && !exp.IsZero() && !exp.After(now) {
+ if exp, ok := auth.AccessTokenExpirationTimeForRequest(ctx, clientHeaders...); ok && !exp.IsZero() && !exp.After(now) {
return true, blockReasonOther, time.Time{}
}
if auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && auth.Quota.NextRecoverAt.After(now) {
@@ -958,7 +977,7 @@ func shouldRebindSessionAffinityOnError(err *Error) bool {
if err == nil || shouldSkipCredentialCooldown(err) {
return false
}
- if isModelSupportResultError(err) || isCloudflareChallengeResultError(err) || isInvalidGrantResultError(err) {
+ if isModelSupportResultError(err) || isCloudflareChallengeResultError(err) || isInvalidGrantResultError(err) || isCreditBalanceTooLowResultError(err) {
return true
}
switch statusCodeFromResult(err) {
@@ -970,6 +989,10 @@ func shouldRebindSessionAffinityOnError(err *Error) bool {
}
func isAuthBlockedByQuotaOrAuthForModel(auth *Auth, model string, now time.Time) bool {
+ return isAuthBlockedByQuotaOrAuthForModelForRequest(nil, auth, model, now)
+}
+
+func isAuthBlockedByQuotaOrAuthForModelForRequest(ctx context.Context, auth *Auth, model string, now time.Time, clientHeaders ...http.Header) bool {
if auth == nil {
return true
}
@@ -979,7 +1002,7 @@ func isAuthBlockedByQuotaOrAuthForModel(auth *Auth, model string, now time.Time)
if hasUnauthorizedAuthFailure(auth) {
return true
}
- if exp, ok := auth.AccessTokenExpirationTime(); ok && !exp.IsZero() && !exp.After(now) {
+ if exp, ok := auth.AccessTokenExpirationTimeForRequest(ctx, clientHeaders...); ok && !exp.IsZero() && !exp.After(now) {
return true
}
if auth.Quota.Exceeded && auth.Quota.Reason == "credential_quota" && (auth.Quota.NextRecoverAt.IsZero() || auth.Quota.NextRecoverAt.After(now)) {
@@ -1034,7 +1057,7 @@ func (s *SessionAffinitySelector) shouldPreserveBindingWhenUnavailable(ctx conte
}
for _, candidate := range candidates {
if candidate != nil && candidate.ID == authID {
- return !isAuthBlockedByQuotaOrAuthForModel(candidate, model, now)
+ return !isAuthBlockedByQuotaOrAuthForModelForRequest(ctx, candidate, model, now)
}
}
return false
@@ -1832,7 +1855,7 @@ func extractConversationAlias(payload []byte) string {
if len(payload) == 0 {
return ""
}
- root := gjson.ParseBytes(payload)
+ root := util.ParseGJSONBytesNoCopy(payload)
conversation := root.Get("conversation")
if !conversation.Exists() {
req := root.Get("request")
@@ -1842,7 +1865,7 @@ func extractConversationAlias(payload []byte) string {
}
if sid := normalizedSessionCandidate(conversation.Get("id").String()); sid != "" {
return "conv:" + sid
- } else if conversation.Type == gjson.String {
+ } else if conversation.Type == ojson.String {
if sid := normalizedSessionCandidate(conversation.String()); sid != "" {
return "conv:" + sid
}
@@ -1892,9 +1915,9 @@ func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) {
var systemPrompt, firstUserMsg, firstAssistantMsg string
// OpenAI/Claude messages format
- messages := gjson.GetBytes(payload, "messages")
+ messages := ojson.GetBytesNoCopy(payload, "messages")
if messages.Exists() && messages.IsArray() {
- messages.ForEach(func(_, msg gjson.Result) bool {
+ messages.ForEach(func(_, msg ojson.Result) bool {
role := msg.Get("role").String()
content := extractMessageContent(msg.Get("content"))
if content == "" {
@@ -1925,17 +1948,17 @@ func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) {
// Claude API: top-level "system" field (array or string)
if systemPrompt == "" {
- topSystem := gjson.GetBytes(payload, "system")
+ topSystem := ojson.GetBytesNoCopy(payload, "system")
if topSystem.Exists() {
if topSystem.IsArray() {
- topSystem.ForEach(func(_, part gjson.Result) bool {
+ topSystem.ForEach(func(_, part ojson.Result) bool {
if text := part.Get("text").String(); text != "" && systemPrompt == "" {
systemPrompt = truncateString(text, 100)
return false
}
return true
})
- } else if topSystem.Type == gjson.String {
+ } else if topSystem.Type == ojson.String {
systemPrompt = truncateString(topSystem.String(), 100)
}
}
@@ -1943,9 +1966,9 @@ func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) {
// Gemini format
if systemPrompt == "" && firstUserMsg == "" {
- sysInstr := gjson.GetBytes(payload, "systemInstruction.parts")
+ sysInstr := ojson.GetBytesNoCopy(payload, "systemInstruction.parts")
if sysInstr.Exists() && sysInstr.IsArray() {
- sysInstr.ForEach(func(_, part gjson.Result) bool {
+ sysInstr.ForEach(func(_, part ojson.Result) bool {
if text := part.Get("text").String(); text != "" && systemPrompt == "" {
systemPrompt = truncateString(text, 100)
return false
@@ -1954,11 +1977,11 @@ func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) {
})
}
- contents := gjson.GetBytes(payload, "contents")
+ contents := ojson.GetBytesNoCopy(payload, "contents")
if contents.Exists() && contents.IsArray() {
- contents.ForEach(func(_, msg gjson.Result) bool {
+ contents.ForEach(func(_, msg ojson.Result) bool {
role := msg.Get("role").String()
- msg.Get("parts").ForEach(func(_, part gjson.Result) bool {
+ msg.Get("parts").ForEach(func(_, part ojson.Result) bool {
text := part.Get("text").String()
if text == "" {
return true
@@ -1985,13 +2008,13 @@ func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) {
// OpenAI Responses API format (v1/responses)
if systemPrompt == "" && firstUserMsg == "" {
- if instr := gjson.GetBytes(payload, "instructions").String(); instr != "" {
+ if instr := ojson.GetBytesNoCopy(payload, "instructions").String(); instr != "" {
systemPrompt = truncateString(instr, 100)
}
- input := gjson.GetBytes(payload, "input")
+ input := ojson.GetBytesNoCopy(payload, "input")
if input.Exists() && input.IsArray() {
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
itemType := item.Get("type").String()
if itemType == "reasoning" {
return true
@@ -2010,7 +2033,7 @@ func extractMessageHashIDs(payload []byte) (primaryID, fallbackID string) {
// Handle both string content and array content (multimodal).
content := item.Get("content")
var text string
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
text = content.String()
} else {
text = extractResponsesAPIContent(content)
@@ -2079,16 +2102,16 @@ func truncateString(s string, maxLen int) string {
// extractMessageContent extracts text content from a message content field.
// Handles both string content and array content (multimodal messages).
// For array content, extracts text from all text-type elements.
-func extractMessageContent(content gjson.Result) string {
+func extractMessageContent(content ojson.Result) string {
// String content: "Hello world"
- if content.Type == gjson.String {
+ if content.Type == ojson.String {
return content.String()
}
// Array content: [{"type":"text","text":"Hello"},{"type":"image",...}]
if content.IsArray() {
var texts []string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
// Handle Claude format: {"type":"text","text":"content"}
if part.Get("type").String() == "text" {
if text := part.Get("text").String(); text != "" {
@@ -2107,12 +2130,12 @@ func extractMessageContent(content gjson.Result) string {
return ""
}
-func extractResponsesAPIContent(content gjson.Result) string {
+func extractResponsesAPIContent(content ojson.Result) string {
if !content.IsArray() {
return ""
}
var texts []string
- content.ForEach(func(_, part gjson.Result) bool {
+ content.ForEach(func(_, part ojson.Result) bool {
partType := part.Get("type").String()
if partType == "input_text" || partType == "output_text" || partType == "text" {
if text := part.Get("text").String(); text != "" {
diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go
index 48dd5a69395..1d43d746c48 100644
--- a/sdk/cliproxy/auth/types.go
+++ b/sdk/cliproxy/auth/types.go
@@ -4,9 +4,9 @@ import (
"bytes"
"context"
"crypto/sha256"
- "encoding/base64"
"encoding/hex"
"encoding/json"
+ "github.com/MoeForge/moego/base64"
"io"
"net/http"
"net/url"
@@ -83,6 +83,10 @@ type Auth struct {
Unavailable bool `json:"unavailable"`
// ProxyURL overrides the global proxy setting for this auth if provided.
ProxyURL string `json:"proxy_url,omitempty"`
+ // ProxyConnectionPool carries the connection-pool block of this api-key
+ // credential. Nil means no pooling, which is the default. Pools are per
+ // credential, so an override here never shares connections with another auth.
+ ProxyConnectionPool *ProxyConnectionPoolOverride `json:"proxy_connection_pool,omitempty"`
// Attributes stores provider specific metadata needed by executors (immutable configuration).
Attributes map[string]string `json:"attributes,omitempty"`
// Metadata stores runtime mutable provider state (e.g. tokens, cookies).
@@ -490,6 +494,9 @@ func (a *Auth) Clone() *Auth {
copyAuth.Attributes[key] = value
}
}
+ if a.ProxyConnectionPool != nil {
+ copyAuth.ProxyConnectionPool = a.ProxyConnectionPool.Clone()
+ }
if a.Metadata != nil {
copyAuth.Metadata = cloneMetadataMap(a.Metadata)
}
@@ -503,6 +510,49 @@ func (a *Auth) Clone() *Auth {
return ©Auth
}
+// ProxyConnectionPoolOverride holds the per-api-key connection-pool settings.
+// Omitted fields fall back to the pool defaults (300s idle, 2 idle per host).
+type ProxyConnectionPoolOverride struct {
+ Enabled *bool `json:"enabled,omitempty"`
+ IdleConnTimeout string `json:"idle-conn-timeout,omitempty"`
+ MaxIdleConnsPerHost *int `json:"max-idle-conns-per-host,omitempty"`
+}
+
+// Clone deep copies the override so auth snapshots never share mutable state.
+func (o *ProxyConnectionPoolOverride) seedKey() string {
+ if o == nil {
+ return ""
+ }
+ var b strings.Builder
+ b.WriteString("pool:")
+ if o.Enabled != nil {
+ b.WriteString(strconv.FormatBool(*o.Enabled))
+ }
+ b.WriteByte(0)
+ b.WriteString(strings.TrimSpace(o.IdleConnTimeout))
+ b.WriteByte(0)
+ if o.MaxIdleConnsPerHost != nil {
+ b.WriteString(strconv.Itoa(*o.MaxIdleConnsPerHost))
+ }
+ return b.String()
+}
+
+func (o *ProxyConnectionPoolOverride) Clone() *ProxyConnectionPoolOverride {
+ if o == nil {
+ return nil
+ }
+ out := &ProxyConnectionPoolOverride{IdleConnTimeout: o.IdleConnTimeout}
+ if o.Enabled != nil {
+ enabled := *o.Enabled
+ out.Enabled = &enabled
+ }
+ if o.MaxIdleConnsPerHost != nil {
+ maxIdle := *o.MaxIdleConnsPerHost
+ out.MaxIdleConnsPerHost = &maxIdle
+ }
+ return out
+}
+
func cloneMetadataMap(src map[string]any) map[string]any {
if src == nil {
return nil
@@ -558,6 +608,18 @@ func AuthGrantFingerprint(a *Auth) string {
if accessToken == "" {
accessToken = authAccessToken(a)
}
+ if accessToken == "" {
+ accessToken = authMetadataString(a, "api_key")
+ }
+ if accessToken == "" && a.Attributes != nil {
+ accessToken = strings.TrimSpace(a.Attributes["api_key"])
+ }
+ if accessToken == "" {
+ accessToken = authMetadataString(a, "session_token")
+ }
+ if accessToken == "" && a.Attributes != nil {
+ accessToken = strings.TrimSpace(a.Attributes["session_token"])
+ }
refreshToken := authRefreshToken(a)
email := authMetadataString(a, "email")
if email == "" && a.Attributes != nil {
@@ -645,7 +707,11 @@ func (a *Auth) indexSeed() string {
}
}
if apiPrefix != "" {
- return apiPrefix + ":" + strings.TrimSpace(baseURL) + "+" + strings.TrimSpace(apiKey)
+ seed := apiPrefix + ":" + strings.TrimSpace(baseURL) + "+" + strings.TrimSpace(apiKey)
+ if poolSeed := a.ProxyConnectionPool.seedKey(); poolSeed != "" {
+ seed += "\x00" + poolSeed
+ }
+ return seed
}
if id := strings.TrimSpace(a.ID); id != "" {
@@ -1269,6 +1335,37 @@ func CodexSIWCNeedsRefresh(a *Auth, now time.Time) (dueAt time.Time, hasRefresh
return now, true, true
}
+// OuterAccessTokenExpirationTime returns the expiration time of the outer access_token,
+// ignoring the nested "siwc" section.
+func (a *Auth) OuterAccessTokenExpirationTime() (time.Time, bool) {
+ if a == nil {
+ return time.Time{}, false
+ }
+ tokenStr := authOuterAccessToken(a)
+ if tokenStr == "" {
+ return time.Time{}, false
+ }
+ if a.RejectedAccessToken != "" && a.RejectedAccessToken == tokenStr {
+ return time.Unix(0, 0), true
+ }
+ if jwtExp, ok := parseJWTExp(tokenStr); ok {
+ return jwtExp, true
+ }
+ return a.OuterExpirationTime()
+}
+
+// AccessTokenExpirationTimeForRequest returns the expiration time of the access_token
+// selected for the request context and client headers.
+func (a *Auth) AccessTokenExpirationTimeForRequest(ctx context.Context, clientHeaders ...http.Header) (time.Time, bool) {
+ if a == nil {
+ return time.Time{}, false
+ }
+ if IsCodexUseSIWC(a) && !IsCodexUseSIWCForRequest(ctx, a, clientHeaders...) {
+ return a.OuterAccessTokenExpirationTime()
+ }
+ return a.AccessTokenExpirationTime()
+}
+
// AccessTokenExpirationTime returns the expiration time of the specific access_token.
// If the access_token is a JWT, its exp claim takes strict precedence.
func (a *Auth) AccessTokenExpirationTime() (time.Time, bool) {
diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go
index 3797f08f6b7..fcf2f90b7e4 100644
--- a/sdk/cliproxy/auth/types_test.go
+++ b/sdk/cliproxy/auth/types_test.go
@@ -1,8 +1,8 @@
package auth
import (
- "encoding/base64"
"fmt"
+ "github.com/MoeForge/moego/base64"
"os"
"path/filepath"
"strings"
diff --git a/sdk/cliproxy/builder.go b/sdk/cliproxy/builder.go
index 532bde10c59..c703be536ca 100644
--- a/sdk/cliproxy/builder.go
+++ b/sdk/cliproxy/builder.go
@@ -10,6 +10,7 @@ import (
configaccess "github.com/router-for-me/CLIProxyAPI/v8/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v8/internal/api"
"github.com/router-for-me/CLIProxyAPI/v8/internal/pluginhost"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
@@ -292,6 +293,8 @@ func (b *Builder) Build() (*Service, error) {
appliedRoutingState: appliedRoutingState,
serverOptions: append([]api.ServerOption(nil), b.serverOptions...),
}
+ service.proxyPoolPublisher = helps.RegisterProxyPoolPublisher()
+ helps.AdvanceProxyPoolGeneration(service.proxyPoolPublisher, service.cfg)
if b.postAuthHook != nil {
service.serverOptions = append(service.serverOptions, api.WithPostAuthHook(b.postAuthHook))
}
diff --git a/sdk/cliproxy/config_model_display_name_test.go b/sdk/cliproxy/config_model_display_name_test.go
index a5e071a55d2..2b4d85f7340 100644
--- a/sdk/cliproxy/config_model_display_name_test.go
+++ b/sdk/cliproxy/config_model_display_name_test.go
@@ -91,7 +91,7 @@ func TestBuildCodexConfigModelsSelectsDefaultsOrConfiguredModels(t *testing.T) {
defaultIDs[model.ID] = struct{}{}
}
}
- for _, modelID := range []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5"} {
+ for _, modelID := range []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "gpt-image-2.6-flare", "gpt-image-2.6-sunburst", "gpt-image-2.6"} {
if _, ok := defaultIDs[modelID]; !ok {
t.Errorf("missing default model %q", modelID)
}
diff --git a/sdk/cliproxy/kv_store.go b/sdk/cliproxy/kv_store.go
new file mode 100644
index 00000000000..63488b4a8c6
--- /dev/null
+++ b/sdk/cliproxy/kv_store.go
@@ -0,0 +1,33 @@
+package cliproxy
+
+import (
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
+ log "github.com/sirupsen/logrus"
+)
+
+func (s *Service) applyKVStoreConfig(cfg *config.Config) {
+ if s == nil {
+ if errShutdown := kvstore.ShutdownLocal(); errShutdown != nil {
+ log.Errorf("failed to shutdown local kv store: %v", errShutdown)
+ }
+ return
+ }
+ s.kvStoreManaged.Store(true)
+ if cfg == nil || cfg.Home.Enabled || home.Current() != nil || cfg.KVStore.Disabled {
+ if errShutdown := kvstore.ShutdownLocal(); errShutdown != nil {
+ log.Errorf("failed to shutdown local kv store: %v", errShutdown)
+ }
+ return
+ }
+ _ = kvstore.ConfigureLocal(true, cfg.EffectiveKVStorePath())
+}
+
+func (s *Service) shutdownKVStore() error {
+ if s == nil {
+ return nil
+ }
+ s.kvStoreManaged.Store(false)
+ return kvstore.ShutdownLocal()
+}
diff --git a/sdk/cliproxy/kv_store_test.go b/sdk/cliproxy/kv_store_test.go
new file mode 100644
index 00000000000..e569fb816f2
--- /dev/null
+++ b/sdk/cliproxy/kv_store_test.go
@@ -0,0 +1,167 @@
+package cliproxy
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/home"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/kvstore"
+)
+
+func TestServiceApplyKVStoreConfig_LifecycleAndHotReload(t *testing.T) {
+ t.Cleanup(func() {
+ home.ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ })
+
+ dir := t.TempDir()
+ authDir := filepath.Join(dir, "auths")
+ defaultAuthDBPath := filepath.Join(authDir, config.DefaultKVStorePath)
+ dbPath1 := filepath.Join(dir, "kv1.db")
+ dbPath2 := filepath.Join(dir, "kv2.db")
+
+ // Unmanaged service (before applyKVStoreConfig / Run) does not open KVStore on applyConfigUpdate.
+ unmanagedSvc := &Service{}
+ unmanagedSvc.applyConfigUpdate(&config.Config{
+ AuthDir: authDir,
+ })
+ if client, active, errClient := kvstore.CurrentKVClient(); client != nil || active || errClient != nil {
+ t.Fatalf("CurrentKVClient(unmanaged applyConfigUpdate) = %v, %v, %v; want nil, false, nil", client, active, errClient)
+ }
+
+ // Default configuration (Disabled: false, Path: "") opens /kv.db.
+ svc := &Service{}
+ svc.applyKVStoreConfig(&config.Config{
+ AuthDir: authDir,
+ })
+ clientDefault, active, errClient := kvstore.CurrentKVClient()
+ if errClient != nil || !active || clientDefault == nil {
+ t.Fatalf("CurrentKVClient(default enabled) = %v, %v, %v; want client, true, nil", clientDefault, active, errClient)
+ }
+ if _, errStat := os.Stat(defaultAuthDBPath); errStat != nil {
+ t.Fatalf("expected defaultAuthDBPath %s to exist: %v", defaultAuthDBPath, errStat)
+ }
+
+ // Explicit Path overrides AuthDir.
+ svc.applyKVStoreConfig(&config.Config{
+ AuthDir: authDir,
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath1},
+ })
+ client1, active, errClient := kvstore.CurrentKVClient()
+ if errClient != nil || !active || client1 == nil || client1 == clientDefault {
+ t.Fatalf("CurrentKVClient(explicit path) = %v, %v, %v; want new client, true, nil", client1, active, errClient)
+ }
+ if _, errStat := os.Stat(dbPath1); errStat != nil {
+ t.Fatalf("expected dbPath1 %s to exist: %v", dbPath1, errStat)
+ }
+
+ // Same path reload reuses the store instance.
+ svc.applyKVStoreConfig(&config.Config{
+ AuthDir: authDir,
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath1},
+ })
+ clientSame, active, errClient := kvstore.CurrentKVClient()
+ if errClient != nil || !active || clientSame != client1 {
+ t.Fatalf("same path reload did not reuse store: got %v want %v", clientSame, client1)
+ }
+
+ // Switching path closes old store and opens new store.
+ svc.applyKVStoreConfig(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath2},
+ })
+ client2, active, errClient := kvstore.CurrentKVClient()
+ if errClient != nil || !active || client2 == nil || client2 == client1 {
+ t.Fatalf("path switch failed: client=%v active=%v err=%v", client2, active, errClient)
+ }
+
+ // Disabling (Disabled: true) closes the local store and falls back to memory mode.
+ svc.applyKVStoreConfig(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: true, Path: dbPath2},
+ })
+ if client, active, errClient := kvstore.CurrentKVClient(); client != nil || active || errClient != nil {
+ t.Fatalf("CurrentKVClient(disabled) = %v, %v, %v; want nil, false, nil", client, active, errClient)
+ }
+
+ // applyKVStoreConfig(nil) shuts down the local store cleanly.
+ svc.applyKVStoreConfig(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath2},
+ })
+ svc.applyKVStoreConfig(nil)
+ if client, active, errClient := kvstore.CurrentKVClient(); client != nil || active || errClient != nil {
+ t.Fatalf("CurrentKVClient(nil cfg) = %v, %v, %v; want nil, false, nil", client, active, errClient)
+ }
+
+ // applyConfigUpdate hot-reloads KVStore when svc is managed, and Shutdown closes it and resets managed state.
+ svc.applyConfigUpdate(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath1},
+ })
+ if client, active, errClient := kvstore.CurrentKVClient(); errClient != nil || !active || client == nil {
+ t.Fatalf("CurrentKVClient(after applyConfigUpdate) = %v, %v, %v; want client, true, nil", client, active, errClient)
+ }
+ if errShutdown := svc.Shutdown(context.Background()); errShutdown != nil {
+ t.Fatalf("Shutdown() error = %v", errShutdown)
+ }
+ if client, active, errClient := kvstore.CurrentKVClient(); client != nil || active || errClient != nil {
+ t.Fatalf("CurrentKVClient(after Shutdown) = %v, %v, %v; want nil, false, nil", client, active, errClient)
+ }
+
+ var nilSvc *Service
+ nilSvc.applyKVStoreConfig(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath1},
+ })
+ if errNilShutdown := nilSvc.shutdownKVStore(); errNilShutdown != nil {
+ t.Fatalf("nilSvc.shutdownKVStore() error = %v; want nil", errNilShutdown)
+ }
+}
+
+func TestServiceApplyKVStoreConfig_HomeModeNeverOpensBBolt(t *testing.T) {
+ t.Cleanup(func() {
+ home.ClearCurrent()
+ _ = kvstore.ShutdownLocal()
+ })
+
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "home-should-not-create.db")
+ cfg := &config.Config{
+ Home: config.HomeConfig{Enabled: true},
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath},
+ }
+ forceHomeRuntimeConfig(cfg)
+ if !cfg.KVStore.Disabled {
+ t.Fatal("forceHomeRuntimeConfig did not set KVStore.Disabled = true")
+ }
+
+ svc := &Service{}
+ svc.applyKVStoreConfig(&config.Config{
+ Home: config.HomeConfig{Enabled: true},
+ KVStore: config.KVStoreConfig{Disabled: false, Path: dbPath},
+ })
+ if _, errStat := os.Stat(dbPath); !os.IsNotExist(errStat) {
+ t.Fatalf("Home mode created bbolt file at %s (err=%v)", dbPath, errStat)
+ }
+
+ // Active home.Current() must also isolate and shut down any local bbolt store even if Home.Enabled is false.
+ localBeforeHomePath := filepath.Join(dir, "local-before-home.db")
+ svc.applyKVStoreConfig(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: false, Path: localBeforeHomePath},
+ })
+ if client, active, errClient := kvstore.CurrentKVClient(); errClient != nil || !active || client == nil {
+ t.Fatalf("expected local store active before Home activation, got %v, %v, %v", client, active, errClient)
+ }
+
+ homeActivePath := filepath.Join(dir, "home-active-should-not-create.db")
+ home.SetCurrent(&home.Client{})
+ svc.applyKVStoreConfig(&config.Config{
+ KVStore: config.KVStoreConfig{Disabled: false, Path: homeActivePath},
+ })
+ if _, errStat := os.Stat(homeActivePath); !os.IsNotExist(errStat) {
+ t.Fatalf("active home.Current() created bbolt file at %s (err=%v)", homeActivePath, errStat)
+ }
+ home.ClearCurrent()
+ if client, active, errClient := kvstore.CurrentKVClient(); client != nil || active || errClient != nil {
+ t.Fatalf("expected local store shut down when home.Current() was active, got %v, %v, %v", client, active, errClient)
+ }
+}
diff --git a/sdk/cliproxy/model_registration_config_fence_test.go b/sdk/cliproxy/model_registration_config_fence_test.go
index 74c76d30502..4d0603b8b40 100644
--- a/sdk/cliproxy/model_registration_config_fence_test.go
+++ b/sdk/cliproxy/model_registration_config_fence_test.go
@@ -65,7 +65,7 @@ func TestModelRegistrationDroppedForGlobalExclusionsIsReplacedWithoutWatcher(t *
// Worst case: the obsolete registration finishes after the commit but before the runtime apply.
commit := service.commitConfigUpdate(newCfg)
- if commit.cfg != newCfg {
+ if commit.cfg == nil {
t.Fatal("config update was rejected")
}
releaseOld()
@@ -102,7 +102,7 @@ func TestModelRegistrationFenceRetainsGlobalExclusionsAfterAuthUpdate(t *testing
t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
epoch := reg.ClientRegistrationEpoch(auth.ID)
fence := newModelRegistrationFence(auth, oldCfg)
- if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg == nil {
t.Fatal("config update was rejected")
}
auth = auth.Clone()
@@ -169,7 +169,7 @@ func TestModelRegistrationAllowsIrrelevantConfigReload(t *testing.T) {
reg.RegisterClient(auth.ID, "claude", []*ModelInfo{{ID: "model-old"}})
t.Cleanup(func() { reg.UnregisterClient(auth.ID) })
fence := newModelRegistrationFence(auth, oldCfg)
- if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg == nil {
t.Fatal("config update was rejected")
}
if tc.disabled {
@@ -263,7 +263,7 @@ func TestModelRegistrationRejectsObsoleteGlobalExclusions(t *testing.T) {
<-oldStarted
// Reload while the old registration is paused after capturing its fence.
- if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg == nil {
t.Fatal("config update was rejected")
}
newAuth := synthesize(newCfg)
diff --git a/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go b/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go
index 4d63c162bb0..8cff1b1b723 100644
--- a/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go
+++ b/sdk/cliproxy/model_registration_plugin_exclusion_fence_test.go
@@ -54,7 +54,7 @@ func TestModelRegistrationPluginExclusionSources(t *testing.T) {
modelProvider := &exclusionFenceModelProvider{provider: provider, onModels: func() pluginapi.ModelResponse {
calls++
if calls == 1 {
- if commit := service.commitConfigUpdate(newCfg); commit.cfg != newCfg {
+ if commit := service.commitConfigUpdate(newCfg); commit.cfg == nil {
t.Fatal("config update was rejected")
}
}
diff --git a/sdk/cliproxy/proxy_pool_publisher_test.go b/sdk/cliproxy/proxy_pool_publisher_test.go
new file mode 100644
index 00000000000..d71452b547a
--- /dev/null
+++ b/sdk/cliproxy/proxy_pool_publisher_test.go
@@ -0,0 +1,140 @@
+package cliproxy
+
+import (
+ "context"
+ "net/http"
+ "path/filepath"
+ "testing"
+
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/config"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
+ coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
+)
+
+// TestServiceProxyPoolFenceIsPerService pins that building or reloading one service
+// never retires the connection pools another service is still serving. The fence used
+// to be a single process-wide counter, so creating a second SDK service permanently
+// degraded the first one to a fresh transport per request.
+func TestServiceProxyPoolFenceIsPerService(t *testing.T) {
+ helps.ResetProxyTransports()
+ t.Cleanup(helps.ResetProxyTransports)
+
+ build := func() *Service {
+ t.Helper()
+ dir := t.TempDir()
+ service, errBuild := NewBuilder().
+ WithConfig(&config.Config{AuthDir: dir}).
+ WithConfigPath(filepath.Join(dir, "config.yaml")).
+ Build()
+ if errBuild != nil {
+ t.Fatalf("build service: %v", errBuild)
+ }
+ return service
+ }
+
+ auth := &coreauth.Auth{
+ ID: "service-pool-key",
+ ProxyURL: "direct",
+ ProxyConnectionPool: &coreauth.ProxyConnectionPoolOverride{},
+ }
+ transportFor := func(service *Service) *http.Transport {
+ t.Helper()
+ client := helps.NewProxyAwareHTTPClient(context.Background(), service.currentConfig(), auth, 0)
+ transport, ok := client.Transport.(*http.Transport)
+ if !ok || transport == nil {
+ t.Fatalf("transport = %T, want a pooled *http.Transport", client.Transport)
+ }
+ return transport
+ }
+
+ first := build()
+ if first.proxyPoolPublisher == 0 {
+ t.Fatal("service did not reserve a pool publisher")
+ }
+ firstTransport := transportFor(first)
+ if got := transportFor(first); got != firstTransport {
+ t.Fatal("first service did not reuse its pool")
+ }
+
+ // A second, independent service publishes its own config.
+ second := build()
+ if second.proxyPoolPublisher == first.proxyPoolPublisher {
+ t.Fatal("services share one pool publisher")
+ }
+ if got := transportFor(first); got != firstTransport {
+ t.Fatal("building another service retired the active service's pool")
+ }
+
+ // Reloading the second service must not touch the first service's pools either.
+ if commit := second.commitConfigUpdate(&config.Config{AuthDir: second.currentConfig().AuthDir}); commit.cfg == nil {
+ t.Fatal("config update was rejected")
+ }
+ if got := transportFor(first); got != firstTransport {
+ t.Fatal("reloading another service retired the active service's pool")
+ }
+
+ // Shutting down the second service retires its publisher and fences its snapshot
+ // without touching the first service's pool.
+ secondTransport := transportFor(second)
+ secondCfgBeforeShutdown := second.currentConfig()
+ if errShutdown := second.Shutdown(context.Background()); errShutdown != nil {
+ t.Fatalf("shutdown second service: %v", errShutdown)
+ }
+ if got := transportFor(first); got != firstTransport {
+ t.Fatal("shutting down another service retired the active service's pool")
+ }
+ afterShutdownClient := helps.NewProxyAwareHTTPClient(context.Background(), secondCfgBeforeShutdown, auth, 0)
+ afterShutdownTransport, ok := afterShutdownClient.Transport.(*http.Transport)
+ if !ok || afterShutdownTransport == nil || afterShutdownTransport == secondTransport || !afterShutdownTransport.DisableKeepAlives {
+ t.Fatalf("post-shutdown snapshot must be fenced into an unpooled transport, got %#v", afterShutdownClient.Transport)
+ }
+}
+
+func TestOpenAICompatExecutorRebindsWhenConfigGenerationAdvances(t *testing.T) {
+ helps.ResetProxyTransports()
+ t.Cleanup(helps.ResetProxyTransports)
+
+ dir := t.TempDir()
+ service, errBuild := NewBuilder().
+ WithConfig(&config.Config{AuthDir: dir}).
+ WithConfigPath(filepath.Join(dir, "config.yaml")).
+ Build()
+ if errBuild != nil {
+ t.Fatalf("build service: %v", errBuild)
+ }
+
+ compatAuth := &coreauth.Auth{
+ ID: "compat-pool-key",
+ Provider: "openai-compatibility",
+ Label: "ollama",
+ Attributes: map[string]string{
+ "compat_name": "ollama",
+ "provider_key": "ollama",
+ "base_url": "https://ollama.com/v1",
+ },
+ ProxyConnectionPool: &coreauth.ProxyConnectionPoolOverride{},
+ }
+
+ // Register initial executor bound to generation 1 config.
+ service.ensureExecutorsForAuthWithContext(context.Background(), compatAuth, false)
+ exec1, ok := service.coreManager.Executor("openai-compatible-ollama")
+ if !ok || exec1 == nil {
+ t.Fatal("expected initial openai-compatible-ollama executor")
+ }
+
+ // Advance service config generation (simulating a config reload before watcher delivers the new auth).
+ if commit := service.commitConfigUpdate(&config.Config{AuthDir: dir}); commit.cfg == nil {
+ t.Fatal("config update rejected")
+ }
+
+ // Non-forced ensureExecutorsForAuth (used by watcher auth updates) must replace the executor
+ // because its bound config snapshot is from the retired generation.
+ service.ensureExecutorsForAuthWithContext(context.Background(), compatAuth, false)
+ exec2, ok := service.coreManager.Executor("openai-compatible-ollama")
+ if !ok || exec2 == nil {
+ t.Fatal("expected rebound openai-compatible-ollama executor")
+ }
+ if exec2 == exec1 {
+ t.Fatal("ensureExecutorsForAuth kept stale OpenAICompatExecutor holding retired config generation")
+ }
+}
diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go
index b64c3e05278..69893b79325 100644
--- a/sdk/cliproxy/service.go
+++ b/sdk/cliproxy/service.go
@@ -6,6 +6,7 @@ package cliproxy
import (
"context"
"sync"
+ "sync/atomic"
"time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/api"
@@ -45,6 +46,10 @@ type Service struct {
configSequence uint64
appliedRoutingState *routingRuntimeState
+ // proxyPoolPublisher scopes connection-pool fencing to this service, so a reload
+ // here never retires config snapshots served by another service.
+ proxyPoolPublisher uint64
+
// configPath is the path to the configuration file.
configPath string
@@ -72,6 +77,9 @@ type Service struct {
// discoveryManager manages local network mDNS / DNS-SD service advertising.
discoveryManager *discoveryAdvertiserManager
+ // kvStoreManaged tracks whether this service instance owns local KVStore lifecycle.
+ kvStoreManaged atomic.Bool
+
// serverErr channel for server startup/shutdown errors.
serverErr chan error
diff --git a/sdk/cliproxy/service_codex_executor_binding_test.go b/sdk/cliproxy/service_codex_executor_binding_test.go
index 62f0368f1d7..c1884891916 100644
--- a/sdk/cliproxy/service_codex_executor_binding_test.go
+++ b/sdk/cliproxy/service_codex_executor_binding_test.go
@@ -366,7 +366,7 @@ func TestApplyWatcherConfigUpdate_PreservesExistingWebsocketConnections(t *testi
if !okUpdated || updatedExec == nil {
t.Fatalf("expected %s executor after config hot reload", tt.provider)
}
- if cfgUser, okCfgUser := updatedExec.(interface{ UsesConfig(*config.Config) bool }); !okCfgUser || !cfgUser.UsesConfig(reloadedCfg) {
+ if cfgUser, okCfgUser := updatedExec.(interface{ UsesConfig(*config.Config) bool }); !okCfgUser || !cfgUser.UsesConfig(service.currentConfig()) {
t.Fatalf("expected %s executor to use reloaded config", tt.provider)
}
if got := closedConnections.Load(); got != 0 {
diff --git a/sdk/cliproxy/service_codex_models_test.go b/sdk/cliproxy/service_codex_models_test.go
index e7993b5ba6f..4806528010d 100644
--- a/sdk/cliproxy/service_codex_models_test.go
+++ b/sdk/cliproxy/service_codex_models_test.go
@@ -121,7 +121,7 @@ func TestRegisterModelsForAuthCodexAPIKeyModels(t *testing.T) {
name: "defaults without explicit models",
entry: config.CodexKey{APIKey: "default-key"},
wantIDs: codexModelIDSet(defaultModels),
- wantPresent: []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5"},
+ wantPresent: []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "gpt-image-2.6-flare", "gpt-image-2.6-sunburst", "gpt-image-2.6"},
},
{
name: "only explicitly configured models",
@@ -132,7 +132,7 @@ func TestRegisterModelsForAuthCodexAPIKeyModels(t *testing.T) {
}},
},
wantIDs: map[string]struct{}{"configured-codex": {}},
- wantAbsent: []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5"},
+ wantAbsent: []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "gpt-image-2.6-flare", "gpt-image-2.6-sunburst", "gpt-image-2.6"},
},
{
name: "exclusions apply to defaults",
@@ -337,7 +337,7 @@ func TestRegisterConfigAPIKeyAuthsCodexModelModes(t *testing.T) {
t.Errorf("missing registered model %q", modelID)
}
}
- for _, modelID := range []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5"} {
+ for _, modelID := range []string{"gpt-image-1.5", "gpt-image-2", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2.5", "gpt-image-2.6-flare", "gpt-image-2.6-sunburst", "gpt-image-2.6"} {
_, registered := registeredIDs[modelID]
if registered != testCase.wantImages {
t.Errorf("registered model %q = %t, want %t", modelID, registered, testCase.wantImages)
diff --git a/sdk/cliproxy/service_config.go b/sdk/cliproxy/service_config.go
index bc6214f4c14..cbda28e2716 100644
--- a/sdk/cliproxy/service_config.go
+++ b/sdk/cliproxy/service_config.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
"github.com/router-for-me/CLIProxyAPI/v8/internal/watcher/synthesizer"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v8/sdk/config"
@@ -155,6 +156,9 @@ func (s *Service) commitConfigUpdate(newCfg *config.Config) configCommit {
log.WithError(errValidate).Warn("rejected invalid model catalog sources")
return configCommit{}
}
+ // Clone before assigning the generation: callers may still hold the input snapshot.
+ newCfg = newCfg.CloneForRuntime()
+ helps.AdvanceProxyPoolGeneration(s.proxyPoolPublisher, newCfg)
s.cfgMu.Lock()
s.cfg = newCfg
s.cfgMu.Unlock()
@@ -201,6 +205,9 @@ func (s *Service) applyConfigRuntime(ctx context.Context, commit configCommit, s
return false
}
s.applyDiscoveryConfigContext(ctx, cfg)
+ if s.kvStoreManaged.Load() {
+ s.applyKVStoreConfig(cfg)
+ }
if errContext := ctx.Err(); errContext != nil {
return false
}
@@ -348,4 +355,5 @@ func forceHomeRuntimeConfig(cfg *config.Config) {
cfg.RemoteManagement.AllowRemote = false
cfg.RemoteManagement.DisableControlPanel = true
cfg.Plugins.StoreAuth = nil
+ cfg.KVStore.Disabled = true
}
diff --git a/sdk/cliproxy/service_executionregistry_test.go b/sdk/cliproxy/service_executionregistry_test.go
index dbf92290776..38f7f785d09 100644
--- a/sdk/cliproxy/service_executionregistry_test.go
+++ b/sdk/cliproxy/service_executionregistry_test.go
@@ -1448,9 +1448,11 @@ func TestServiceHeartbeatFailoverDoesNotDrainBlockingScope(t *testing.T) {
t.Fatal("heartbeat failover started draining the blocking scope")
case <-time.After(50 * time.Millisecond):
}
+ // The first retry waits a jittered delay of up to homeSubscriberRetryInitial,
+ // so allow headroom beyond it, matching the sibling failover tests.
select {
case <-secondSubscribe:
- case <-time.After(time.Second):
+ case <-time.After(3 * homeSubscriberRetryInitial):
t.Fatal("new subscription did not start while the old scope remained active")
}
service.homeMu.Lock()
diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go
index 2c2083d959c..c83c246c23d 100644
--- a/sdk/cliproxy/service_executors.go
+++ b/sdk/cliproxy/service_executors.go
@@ -360,7 +360,7 @@ func (s *Service) registerOpenAICompatProviderExecutor(providerKey string, auth
nextExecutor := s.wrapOpenAICompatIfPluginAuth(compatExecutor, auth, cfg)
if !forceReplace {
if existingExecutor, hasExecutor := s.coreManager.Executor(providerKey); hasExecutor {
- if shouldKeepExistingOpenAICompatExecutor(s, existingExecutor, nextExecutor, respectNonOwned) {
+ if shouldKeepExistingOpenAICompatExecutor(s, existingExecutor, nextExecutor, cfg, respectNonOwned) {
return
}
}
@@ -405,13 +405,16 @@ func pluginAuthProviderLookupKeys(auth *coreauth.Auth, fallback string) []string
return keys
}
-func shouldKeepExistingOpenAICompatExecutor(s *Service, existing, next coreauth.ProviderExecutor, respectNonOwned bool) bool {
+func shouldKeepExistingOpenAICompatExecutor(s *Service, existing, next coreauth.ProviderExecutor, cfg *config.Config, respectNonOwned bool) bool {
if existing == nil || next == nil {
return false
}
if shouldUpgradeOpenAICompatToPluginRefresh(existing, next) {
return false
}
+ if cfgHolder, ok := existing.(interface{ UsesConfig(*config.Config) bool }); ok && !cfgHolder.UsesConfig(cfg) {
+ return false
+ }
if pluginhost.IsPluginRefreshCompatExecutor(existing) && pluginhost.IsPluginRefreshCompatExecutor(next) {
return true
}
diff --git a/sdk/cliproxy/service_lifecycle.go b/sdk/cliproxy/service_lifecycle.go
index f08eb1b4cf3..3da93345a47 100644
--- a/sdk/cliproxy/service_lifecycle.go
+++ b/sdk/cliproxy/service_lifecycle.go
@@ -12,6 +12,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v8/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor"
+ "github.com/router-for-me/CLIProxyAPI/v8/internal/runtime/executor/helps"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v8/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v8/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/auth"
@@ -63,6 +64,7 @@ func (s *Service) Run(ctx context.Context) error {
forceHomeRuntimeConfig(s.cfg)
cfg = s.cfg
s.cfgMu.Unlock()
+ s.applyKVStoreConfig(cfg)
redisqueue.SetUsageStatisticsEnabled(true)
}
@@ -78,6 +80,7 @@ func (s *Service) Run(ctx context.Context) error {
if errEnsureAuthDir := s.ensureAuthDir(); errEnsureAuthDir != nil {
return errEnsureAuthDir
}
+ s.applyKVStoreConfig(cfg)
}
s.applyRetryConfig(cfg)
@@ -389,6 +392,14 @@ func (s *Service) Shutdown(ctx context.Context) error {
}
}
+ if errShutdownKV := s.shutdownKVStore(); errShutdownKV != nil {
+ log.Errorf("failed to stop local kv store: %v", errShutdownKV)
+ if shutdownErr == nil {
+ shutdownErr = errShutdownKV
+ }
+ }
+
+ helps.RetireProxyPoolPublisher(s.proxyPoolPublisher)
usage.StopDefault()
})
return shutdownErr
diff --git a/sdk/cliproxy/session/identity.go b/sdk/cliproxy/session/identity.go
index d0dcd91b642..c362bf22590 100644
--- a/sdk/cliproxy/session/identity.go
+++ b/sdk/cliproxy/session/identity.go
@@ -10,10 +10,10 @@ import (
"strings"
"unicode"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -139,7 +139,7 @@ func NormalizeExplicitID(raw string) string {
if raw == "" || len(raw) > 256 {
return ""
}
- return raw
+ return strings.Clone(raw)
}
// ClaudeMetadataIdentities extracts session_id, parent_session_id, and agent_id from Claude user_id metadata.
@@ -159,7 +159,7 @@ func ClaudeMetadataIdentities(payload []byte) (sessionID, parentSessionID, agent
return "", "", ""
}
if strings.HasPrefix(userID, "{") {
- parsed := gjson.Parse(userID)
+ parsed := ojson.Parse(userID)
sessionID = NormalizeExplicitID(parsed.Get("session_id").String())
parentSessionID = NormalizeExplicitID(parsed.Get("parent_session_id").String())
if parentSessionID == "" {
@@ -471,7 +471,7 @@ func hasExplicitSession(headers map[string][]string, payload []byte) bool {
if NormalizeExplicitID(conversation.Get("id").String()) != "" {
return true
}
- return conversation.Type == gjson.String && NormalizeExplicitID(conversation.String()) != ""
+ return conversation.Type == ojson.String && NormalizeExplicitID(conversation.String()) != ""
}
func headerValue(headers map[string][]string, name string) string {
diff --git a/sdk/cliproxy/session/info.go b/sdk/cliproxy/session/info.go
index 3531adbd9a3..7cd2202b8fc 100644
--- a/sdk/cliproxy/session/info.go
+++ b/sdk/cliproxy/session/info.go
@@ -8,9 +8,9 @@ import (
"strings"
"unicode/utf8"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v8/sdk/cliproxy/executor"
- "github.com/tidwall/gjson"
)
// SessionInfo encapsulates incoming request details needed for session affinity and upstream reporting.
@@ -36,19 +36,19 @@ type SessionTreeInfo = SessionInfo
// sessionObject indexes top-level fields once so missing session keys do not
// rescan large message arrays for every candidate path.
type sessionObject struct {
- result gjson.Result
- fields map[string]gjson.Result
+ result ojson.Result
+ fields map[string]ojson.Result
duplicates map[string]bool
}
-func newSessionObject(result gjson.Result) sessionObject {
+func newSessionObject(result ojson.Result) sessionObject {
object := sessionObject{result: result}
- if result.Type != gjson.JSON {
+ if result.Type != ojson.JSON {
return object
}
- result.ForEach(func(key, value gjson.Result) bool {
+ result.ForEach(func(key, value ojson.Result) bool {
if object.fields == nil {
- object.fields = make(map[string]gjson.Result)
+ object.fields = make(map[string]ojson.Result)
}
if _, exists := object.fields[key.Str]; exists {
if object.duplicates == nil {
@@ -67,7 +67,7 @@ func (object sessionObject) Exists() bool {
return object.result.Exists()
}
-func (object sessionObject) Get(path string) gjson.Result {
+func (object sessionObject) Get(path string) ojson.Result {
key, remaining, nested := strings.Cut(path, ".")
if nested && object.duplicates[key] {
return object.result.Get(path)
@@ -298,9 +298,9 @@ func ExtractSessionInfo(headers http.Header, payload []byte, metadata map[string
break
}
}
- var codexTurnMetaJSON gjson.Result
+ var codexTurnMetaJSON ojson.Result
if codexTurnMeta != "" {
- codexTurnMetaJSON = gjson.Parse(codexTurnMeta)
+ codexTurnMetaJSON = ojson.Parse(codexTurnMeta)
}
if sid == "" && codexTurnMetaJSON.Exists() {
@@ -788,7 +788,7 @@ func ExtractSessionInfo(headers http.Header, payload []byte, metadata map[string
}
if sid := normalizedSessionCandidate(conversation.Get("id").String()); sid != "" {
conversationID = "conv:" + sid
- } else if conversation.Type == gjson.String {
+ } else if conversation.Type == ojson.String {
if sid := normalizedSessionCandidate(conversation.String()); sid != "" {
conversationID = "conv:" + sid
}
@@ -947,16 +947,21 @@ func finalizeSessionInfo(info SessionInfo) (SessionInfo, bool) {
if info.SessionID == "" {
return SessionInfo{}, false
}
- info.SessionID = BoundSessionIdentity(info.SessionID)
+ info.SessionID = strings.Clone(BoundSessionIdentity(info.SessionID))
if info.ParentSessionID != "" {
- info.ParentSessionID = BoundSessionIdentity(info.ParentSessionID)
+ info.ParentSessionID = strings.Clone(BoundSessionIdentity(info.ParentSessionID))
}
if info.AgentName == "" {
info.AgentName = "main"
+ } else {
+ info.AgentName = strings.Clone(info.AgentName)
}
if info.ClientType == "" {
info.ClientType = "generic"
+ } else {
+ info.ClientType = strings.Clone(info.ClientType)
}
+ info.NodeKind = strings.Clone(info.NodeKind)
// Self-referential loop protection
if info.ParentSessionID == info.SessionID {
info.ParentSessionID = ""
diff --git a/sdk/cliproxy/session/info_test.go b/sdk/cliproxy/session/info_test.go
index f04d216aa37..987980723f1 100644
--- a/sdk/cliproxy/session/info_test.go
+++ b/sdk/cliproxy/session/info_test.go
@@ -762,3 +762,24 @@ func TestExtractSessionInfoEnhancedHarnesses(t *testing.T) {
t.Fatalf("Thread X-Parent-Thread-Id extraction failed: %+v", info)
}
}
+
+func TestSessionExtractionClonesStringsAgainstBufferMutation(t *testing.T) {
+ raw := []byte(`{"session_id":"sess-alpha","parent_session_id":"sess-parent","metadata":{"agent_id":"worker-one"},"messages":[{"role":"user","content":"hello world"}]}`)
+ info, ok := ExtractSessionInfo(nil, raw, nil)
+ if !ok {
+ t.Fatal("expected ExtractSessionInfo to succeed")
+ }
+ turns := ExtractCanonicalTurns("", raw)
+ if len(turns) != 1 || len(turns[0].Parts) != 1 {
+ t.Fatalf("unexpected turns: %+v", turns)
+ }
+ for i := range raw {
+ raw[i] = 'X'
+ }
+ if info.AgentName != "worker-one" {
+ t.Fatalf("AgentName corrupted after buffer mutation: got %q, want worker-one", info.AgentName)
+ }
+ if turns[0].Role != "user" || turns[0].Parts[0].Value != "hello world" {
+ t.Fatalf("CanonicalTurn corrupted after buffer mutation: %+v", turns[0])
+ }
+}
diff --git a/sdk/cliproxy/session/lcp.go b/sdk/cliproxy/session/lcp.go
index 37993dab91f..759cc11fbab 100644
--- a/sdk/cliproxy/session/lcp.go
+++ b/sdk/cliproxy/session/lcp.go
@@ -12,9 +12,9 @@ import (
"sync"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
const (
@@ -114,7 +114,7 @@ func ExtractCanonicalTurns(format sdktranslator.Format, payload []byte) []Canoni
return normalizeCanonicalTurns(turns)
}
-func inferCanonicalFormat(root gjson.Result) sdktranslator.Format {
+func inferCanonicalFormat(root ojson.Result) sdktranslator.Format {
if req := root.Get("request"); req.Exists() && !root.Get("contents").Exists() {
root = req
}
@@ -125,7 +125,7 @@ func inferCanonicalFormat(root gjson.Result) sdktranslator.Format {
return sdktranslator.FormatOpenAIResponse
}
if input := root.Get("input"); input.Exists() {
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
return sdktranslator.FormatInteractions
}
for _, item := range input.Array() {
@@ -142,13 +142,13 @@ func inferCanonicalFormat(root gjson.Result) sdktranslator.Format {
return sdktranslator.FormatOpenAI
}
-func appendMessagesTurns(turns *[]CanonicalTurn, root gjson.Result, includeTopLevelSystem bool) {
+func appendMessagesTurns(turns *[]CanonicalTurn, root ojson.Result, includeTopLevelSystem bool) {
if includeTopLevelSystem {
if system := root.Get("system"); system.Exists() {
appendTurn(turns, "system", canonicalPartsFromJSON(system))
}
}
- root.Get("messages").ForEach(func(_, message gjson.Result) bool {
+ root.Get("messages").ForEach(func(_, message ojson.Result) bool {
if !hasCanonicalTurnCapacity(turns) {
return false
}
@@ -171,7 +171,7 @@ func appendMessagesTurns(turns *[]CanonicalTurn, root gjson.Result, includeTopLe
})
}
-func appendResponsesTurns(turns *[]CanonicalTurn, root gjson.Result) {
+func appendResponsesTurns(turns *[]CanonicalTurn, root ojson.Result) {
if instructions := root.Get("instructions"); instructions.Exists() {
appendTurn(turns, "system", canonicalPartsFromJSON(instructions))
}
@@ -182,11 +182,11 @@ func appendResponsesTurns(turns *[]CanonicalTurn, root gjson.Result) {
if !input.Exists() {
return
}
- if input.Type == gjson.String {
+ if input.Type == ojson.String {
appendTurn(turns, "user", canonicalPartsFromJSON(input))
return
}
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if !hasCanonicalTurnCapacity(turns) {
return false
}
@@ -216,7 +216,7 @@ func appendResponsesTurns(turns *[]CanonicalTurn, root gjson.Result) {
})
}
-func appendGeminiTurns(turns *[]CanonicalTurn, root gjson.Result) {
+func appendGeminiTurns(turns *[]CanonicalTurn, root ojson.Result) {
if req := root.Get("request"); req.Exists() && !root.Get("contents").Exists() {
root = req
}
@@ -232,7 +232,7 @@ func appendGeminiTurns(turns *[]CanonicalTurn, root gjson.Result) {
} else if system := root.Get("system_instruction"); system.Exists() {
appendTurn(turns, "system", canonicalPartsFromJSON(system))
}
- root.Get("contents").ForEach(func(_, content gjson.Result) bool {
+ root.Get("contents").ForEach(func(_, content ojson.Result) bool {
if !hasCanonicalTurnCapacity(turns) {
return false
}
@@ -250,7 +250,7 @@ func appendGeminiTurns(turns *[]CanonicalTurn, root gjson.Result) {
})
}
-func appendInteractionTurns(turns *[]CanonicalTurn, root gjson.Result) {
+func appendInteractionTurns(turns *[]CanonicalTurn, root ojson.Result) {
if system := root.Get("system_instruction"); system.Exists() {
appendTurn(turns, "system", canonicalPartsFromJSON(system))
} else if system := root.Get("systemInstruction"); system.Exists() {
@@ -259,20 +259,20 @@ func appendInteractionTurns(turns *[]CanonicalTurn, root gjson.Result) {
appendInteractionValue(turns, root.Get("input"), "")
}
-func appendInteractionValue(turns *[]CanonicalTurn, value gjson.Result, inheritedRole string) bool {
+func appendInteractionValue(turns *[]CanonicalTurn, value ojson.Result, inheritedRole string) bool {
if !value.Exists() {
return true
}
if !hasCanonicalTurnCapacity(turns) {
return false
}
- if value.Type == gjson.JSON && value.IsArray() {
- value.ForEach(func(_, child gjson.Result) bool {
+ if value.Type == ojson.JSON && value.IsArray() {
+ value.ForEach(func(_, child ojson.Result) bool {
return appendInteractionValue(turns, child, inheritedRole)
})
return hasCanonicalTurnCapacity(turns)
}
- if value.Type != gjson.JSON {
+ if value.Type != ojson.JSON {
appendTurn(turns, defaultInteractionRole(inheritedRole), canonicalPartsFromJSON(value))
return hasCanonicalTurnCapacity(turns)
}
@@ -281,7 +281,7 @@ func appendInteractionValue(turns *[]CanonicalTurn, value gjson.Result, inherite
if role == "" {
role = inheritedRole
}
- steps.ForEach(func(_, child gjson.Result) bool {
+ steps.ForEach(func(_, child ojson.Result) bool {
return appendInteractionValue(turns, child, role)
})
return hasCanonicalTurnCapacity(turns)
@@ -322,30 +322,30 @@ func appendTurn(turns *[]CanonicalTurn, role string, parts []CanonicalPart) {
if !hasCanonicalTurnCapacity(turns) || len(parts) == 0 {
return
}
- *turns = append(*turns, CanonicalTurn{Role: role, Parts: limitCanonicalParts(nil, parts)})
+ *turns = append(*turns, CanonicalTurn{Role: strings.Clone(role), Parts: limitCanonicalParts(nil, parts)})
}
func hasCanonicalTurnCapacity(turns *[]CanonicalTurn) bool {
return turns != nil && len(*turns) < maxCanonicalTurns
}
-func canonicalPartsFromJSON(value gjson.Result) []CanonicalPart {
+func canonicalPartsFromJSON(value ojson.Result) []CanonicalPart {
if !value.Exists() {
return nil
}
switch value.Type {
- case gjson.String:
+ case ojson.String:
return []CanonicalPart{canonicalTextPart(value)}
- case gjson.Number, gjson.True, gjson.False:
- return []CanonicalPart{{Kind: "value", Value: value.Raw, OriginalSize: len(value.Raw)}}
- case gjson.JSON:
+ case ojson.Number, ojson.True, ojson.False:
+ return []CanonicalPart{{Kind: "value", Value: strings.Clone(value.Raw), OriginalSize: len(value.Raw)}}
+ case ojson.JSON:
if isReasoningJSONPart(value) {
return nil
}
if value.IsArray() {
parts := make([]CanonicalPart, 0, maxCanonicalPartsPerTurn+1)
var droppedCount int
- value.ForEach(func(_, child gjson.Result) bool {
+ value.ForEach(func(_, child ojson.Result) bool {
if len(parts) >= maxCanonicalPartsPerTurn {
droppedCount++
return true
@@ -359,7 +359,7 @@ func canonicalPartsFromJSON(value gjson.Result) []CanonicalPart {
}
return parts
}
- if text := value.Get("text"); text.Type == gjson.String {
+ if text := value.Get("text"); text.Type == ojson.String {
return []CanonicalPart{canonicalTextPart(text)}
}
if content := value.Get("content"); content.Exists() {
@@ -389,32 +389,33 @@ func canonicalPartsFromJSON(value gjson.Result) []CanonicalPart {
}
}
-func canonicalTextPart(value gjson.Result) CanonicalPart {
+func canonicalTextPart(value ojson.Result) CanonicalPart {
text := normalizeText(value.String(), false)
if len(text) > largePartThreshold {
return CanonicalPart{
Kind: "text",
- Value: sparseSample(text, sparseFingerprintBytes),
+ Value: strings.Clone(sparseSample(text, sparseFingerprintBytes)),
Digest: computeHexSHA256(text),
OriginalSize: len(text),
Sampled: true,
}
}
- return CanonicalPart{Kind: "text", Value: text, OriginalSize: len(text)}
+ return CanonicalPart{Kind: "text", Value: strings.Clone(text), OriginalSize: len(text)}
}
-func canonicalResourcePart(value gjson.Result) CanonicalPart {
+func canonicalResourcePart(value ojson.Result) CanonicalPart {
part := canonicalTextPart(value)
part.Kind = "resource"
return part
}
-func canonicalJSONPart(kind string, value gjson.Result) CanonicalPart {
+func canonicalJSONPart(kind string, value ojson.Result) CanonicalPart {
raw := value.Raw
+ clonedKind := strings.Clone(kind)
if len(raw) > largePartThreshold {
return CanonicalPart{
- Kind: kind,
- Value: sparseSample(raw, sparseFingerprintBytes),
+ Kind: clonedKind,
+ Value: strings.Clone(sparseSample(raw, sparseFingerprintBytes)),
Digest: computeHexSHA256(raw),
OriginalSize: len(raw),
Sampled: true,
@@ -424,9 +425,10 @@ func canonicalJSONPart(kind string, value gjson.Result) CanonicalPart {
if errUnmarshal := json.Unmarshal([]byte(raw), &decoded); errUnmarshal == nil {
if encoded, errMarshal := json.Marshal(decoded); errMarshal == nil {
raw = string(encoded)
+ return CanonicalPart{Kind: clonedKind, Value: raw, OriginalSize: len(raw)}
}
}
- return CanonicalPart{Kind: kind, Value: raw, OriginalSize: len(raw)}
+ return CanonicalPart{Kind: clonedKind, Value: strings.Clone(raw), OriginalSize: len(raw)}
}
func computeHexSHA256(data string) string {
@@ -486,22 +488,22 @@ func limitCanonicalPartsCount(parts []CanonicalPart, dropped int) []CanonicalPar
return parts
}
-func isReasoningJSONPart(value gjson.Result) bool {
- if value.Type != gjson.JSON || value.IsArray() {
+func isReasoningJSONPart(value ojson.Result) bool {
+ if value.Type != ojson.JSON || value.IsArray() {
return false
}
typ := strings.ToLower(strings.TrimSpace(value.Get("type").String()))
if typ == "thinking" || typ == "reasoning" || typ == "thought" || strings.Contains(typ, "reasoning") {
return true
}
- return value.Get("thought").Type == gjson.True && value.Get("thought").Bool()
+ return value.Get("thought").Type == ojson.True && value.Get("thought").Bool()
}
func isToolPartType(value string) bool {
return strings.Contains(value, "tool") || strings.Contains(value, "function_call") || value == "function"
}
-func geminiToolPartKind(value gjson.Result) string {
+func geminiToolPartKind(value ojson.Result) string {
for _, field := range []string{"functionCall", "function_call"} {
if value.Get(field).Exists() {
return "tool:function_call"
diff --git a/sdk/cliproxy/usage/manager.go b/sdk/cliproxy/usage/manager.go
index 686d78a46ae..9b38c25c007 100644
--- a/sdk/cliproxy/usage/manager.go
+++ b/sdk/cliproxy/usage/manager.go
@@ -84,6 +84,10 @@ type Record struct {
// accepted upstream stream that ended without its terminal frame.
const FailureStreamTruncatedCode = "stream_truncated"
+// FailureRequestScopedCode marks failures belonging to the request rather than
+// the selected credential, including local capability validation failures.
+const FailureRequestScopedCode = "request_scoped"
+
// Failure holds HTTP failure metadata for an upstream request attempt.
type Failure struct {
StatusCode int
@@ -116,6 +120,8 @@ type Detail struct {
VideoInputTokens *int64
FileInputTokens *int64
CachedTextTokens *int64
+ CachedImageTokens *int64
+ CachedAudioTokens *int64
TextOutputTokens *int64
ImageOutputTokens *int64
AudioOutputTokens *int64
@@ -777,6 +783,7 @@ func (m *Manager) run(ctx context.Context) {
return
}
item := m.queue[0]
+ m.queue[0] = queueItem{}
m.queue = m.queue[1:]
m.mu.Unlock()
m.dispatch(item)
diff --git a/sdk/cliproxy/usage/manager_test.go b/sdk/cliproxy/usage/manager_test.go
index e69a8bf0870..3a9f0d8577f 100644
--- a/sdk/cliproxy/usage/manager_test.go
+++ b/sdk/cliproxy/usage/manager_test.go
@@ -2,8 +2,11 @@ package usage
import (
"context"
+ "net/http"
+ "runtime"
"testing"
"time"
+ "weak"
)
func TestStreamFromContextDefaultsMissingToFalse(t *testing.T) {
@@ -186,3 +189,70 @@ func TestManagerPublishBackfillsContextAuthFields(t *testing.T) {
t.Fatal("timed out waiting for usage record")
}
}
+
+type notifyUsagePlugin struct {
+ delivered chan struct{}
+}
+
+func (p *notifyUsagePlugin) HandleUsage(_ context.Context, _ Record) {
+ p.delivered <- struct{}{}
+}
+
+type retainedRequestPayload struct {
+ data [64 << 10]byte
+}
+
+type retainedRequestContextKey struct{}
+
+//go:noinline
+func publishConsumableRecord(mgr *Manager) (weak.Pointer[retainedRequestPayload], weak.Pointer[CacheDetails], weak.Pointer[string]) {
+ ctxPayload := &retainedRequestPayload{}
+ cacheDetails := &CacheDetails{}
+ headerSlice := []string{"req-header-value"}
+
+ ctx := context.WithValue(context.Background(), retainedRequestContextKey{}, ctxPayload)
+ mgr.Publish(ctx, Record{
+ Provider: "openai",
+ Model: "gpt-5.4",
+ ResponseHeaders: http.Header{"X-Request-Id": headerSlice},
+ Detail: Detail{
+ CacheDetails: cacheDetails,
+ },
+ })
+
+ return weak.Make(ctxPayload), weak.Make(cacheDetails), weak.Make(&headerSlice[0])
+}
+
+func TestManagerReleasesConsumedQueueItemReferencesWhenIdle(t *testing.T) {
+ mgr := NewManager(10)
+ t.Cleanup(mgr.Stop)
+
+ plugin := ¬ifyUsagePlugin{delivered: make(chan struct{}, 1)}
+ mgr.Register(plugin)
+
+ ctxWeak, detailWeak, headerWeak := publishConsumableRecord(mgr)
+
+ select {
+ case <-plugin.delivered:
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for usage record dispatch")
+ }
+
+ for range 10 {
+ runtime.Gosched()
+ runtime.GC()
+ if ctxWeak.Value() == nil && detailWeak.Value() == nil && headerWeak.Value() == nil {
+ break
+ }
+ }
+
+ if ctxWeak.Value() != nil {
+ t.Fatalf("consumed request context payload is still retained while manager is idle")
+ }
+ if detailWeak.Value() != nil {
+ t.Fatalf("consumed record Detail.CacheDetails is still retained while manager is idle")
+ }
+ if headerWeak.Value() != nil {
+ t.Fatalf("consumed record ResponseHeaders backing value is still retained while manager is idle")
+ }
+}
diff --git a/sdk/config/config.go b/sdk/config/config.go
index e584ab82f78..735deaf99bc 100644
--- a/sdk/config/config.go
+++ b/sdk/config/config.go
@@ -13,6 +13,7 @@ type Config = internalconfig.Config
type ModelCatalogs = internalconfig.ModelCatalogs
type ClientConfig = internalconfig.ClientConfig
+type ClaudeClientConfig = internalconfig.ClaudeClientConfig
type CodexClientConfig = internalconfig.CodexClientConfig
type CodexWebSearchResultCacheConfig = internalconfig.CodexWebSearchResultCacheConfig
type StreamingConfig = internalconfig.StreamingConfig
@@ -28,6 +29,7 @@ type PayloadRule = internalconfig.PayloadRule
type PayloadFilterRule = internalconfig.PayloadFilterRule
type PayloadModelRule = internalconfig.PayloadModelRule
+type ProxyConnectionPoolConfig = internalconfig.ProxyConnectionPoolConfig
type GeminiKey = internalconfig.GeminiKey
type CodexKey = internalconfig.CodexKey
type XAIKey = internalconfig.XAIKey
diff --git a/sdk/pluginabi/types.go b/sdk/pluginabi/types.go
index 7be274ca9a9..2d7b2654a5f 100644
--- a/sdk/pluginabi/types.go
+++ b/sdk/pluginabi/types.go
@@ -31,7 +31,10 @@ const (
// and splices validated byte-range edits in a single pass.
// Version 9 lets ModelRegistrar.RegisterModels and ModelProvider.StaticModels
// publish model catalogs for multiple providers in one response via Registrations.
- SchemaVersion uint32 = 9
+ // Version 10 lets a plugin expose multiple OAuth/auth provider identifiers and
+ // per-entry display metadata via auth.identifier (identifiers/entries) or
+ // Capabilities.AuthProviders / MultiAuthProvider / AuthProviderEntries.
+ SchemaVersion uint32 = 10
// SchemaVersionStreamChunkOmitRequestBody is the first schema version that omits
// request bodies on payload stream-chunk interceptor calls.
SchemaVersionStreamChunkOmitRequestBody uint32 = 3
@@ -56,6 +59,9 @@ const (
// SchemaVersionMultiProviderModels is the first schema version where model
// registration responses may publish models for multiple providers via Registrations.
SchemaVersionMultiProviderModels uint32 = 9
+ // SchemaVersionMultiAuthProviders is the first schema version where a plugin
+ // may register multiple auth provider identifiers.
+ SchemaVersionMultiAuthProviders uint32 = 10
)
const (
@@ -172,6 +178,14 @@ func (e *Error) Error() string {
return e.Message
}
+// ErrorCode returns the machine-readable error code embedded in the Error.
+func (e *Error) ErrorCode() string {
+ if e == nil {
+ return ""
+ }
+ return e.Code
+}
+
// StatusCode returns the HTTP status code embedded in the Error, or 0 if unset.
func (e *Error) StatusCode() int {
if e == nil {
diff --git a/sdk/pluginabi/types_test.go b/sdk/pluginabi/types_test.go
index a9f892271da..d79c4c04349 100644
--- a/sdk/pluginabi/types_test.go
+++ b/sdk/pluginabi/types_test.go
@@ -28,8 +28,11 @@ func TestEnvelopeRoundTrip(t *testing.T) {
}
func TestMethodNamesAreStable(t *testing.T) {
- if SchemaVersion != 9 {
- t.Fatalf("SchemaVersion = %d, want 9", SchemaVersion)
+ if SchemaVersion != 10 {
+ t.Fatalf("SchemaVersion = %d, want 10", SchemaVersion)
+ }
+ if SchemaVersionMultiAuthProviders != 10 {
+ t.Fatalf("SchemaVersionMultiAuthProviders = %d, want 10", SchemaVersionMultiAuthProviders)
}
if SchemaVersionWebSocketResponseObserver != 4 {
t.Fatalf("SchemaVersionWebSocketResponseObserver = %d, want 4", SchemaVersionWebSocketResponseObserver)
diff --git a/sdk/pluginapi/types.go b/sdk/pluginapi/types.go
index e7c0da24887..718961c7639 100644
--- a/sdk/pluginapi/types.go
+++ b/sdk/pluginapi/types.go
@@ -78,6 +78,10 @@ type Capabilities struct {
ModelProvider ModelProvider
// AuthProvider lets the host parse, login, poll, and refresh plugin provider auths.
AuthProvider AuthProvider
+ // AuthProviders lets a plugin register multiple provider auth entries (for example,
+ // multi-portal vendors sharing the same plugin). When non-empty, each entry is exposed
+ // under its own identifier alongside AuthProvider.
+ AuthProviders []AuthProvider
// FrontendAuthProvider authenticates frontend requests before proxy handling.
FrontendAuthProvider FrontendAuthProvider
// FrontendAuthProviderExclusive makes this frontend auth provider the only active request auth provider when selected.
@@ -313,6 +317,30 @@ type AuthProvider interface {
RefreshAuth(context.Context, AuthRefreshRequest) (AuthRefreshResponse, error)
}
+// MultiAuthProvider is an optional extension implemented by an AuthProvider
+// that handles multiple provider identifiers (for example, multi-portal vendors).
+type MultiAuthProvider interface {
+ AuthProvider
+ Identifiers() []string
+}
+
+// AuthProviderEntry describes user-facing display metadata for one OAuth login entry.
+type AuthProviderEntry struct {
+ // Identifier is the provider key used for login and callback routing.
+ Identifier string `json:"identifier"`
+ // Name is the user-facing display label for this entry; falls back to Metadata.Name when empty.
+ Name string `json:"name,omitempty"`
+ // Logo is the display asset reference for this entry; falls back to Metadata.Logo when empty.
+ Logo string `json:"logo,omitempty"`
+}
+
+// AuthProviderEntries is an optional extension implemented by an AuthProvider
+// that publishes per-entry display metadata (name and logo).
+type AuthProviderEntries interface {
+ AuthProvider
+ AuthEntries() []AuthProviderEntry
+}
+
// AuthLoginStartRequest asks a plugin to start a provider login flow.
type AuthLoginStartRequest struct {
// Provider is the provider key for the login flow.
diff --git a/sdk/pluginhost/host.go b/sdk/pluginhost/host.go
index 077ad81bace..87e994801cb 100644
--- a/sdk/pluginhost/host.go
+++ b/sdk/pluginhost/host.go
@@ -58,6 +58,9 @@ type RegisteredPluginInfo = internalpluginhost.RegisteredPluginInfo
// RegisteredPluginMenu describes a plugin-owned resource menu entry.
type RegisteredPluginMenu = internalpluginhost.RegisteredPluginMenu
+// AuthProviderEntry describes user-facing display metadata for one OAuth login entry.
+type AuthProviderEntry = pluginapi.AuthProviderEntry
+
// Host wraps the internal plugin host behind a public SDK surface.
type Host struct {
inner *internalpluginhost.Host
@@ -156,11 +159,24 @@ func (h *Host) RefreshAuth(ctx context.Context, auth *coreauth.Auth) (*coreauth.
return h.inner.RefreshAuth(ctx, auth)
}
+// AuthProviderIdentifiers returns the provider identifiers handled by active auth-provider plugins.
+func (h *Host) AuthProviderIdentifiers() []string {
+ if h == nil || h.inner == nil {
+ return nil
+ }
+ return h.inner.AuthProviderIdentifiers()
+}
+
// HasAuthProvider reports whether an active plugin handles provider auth for provider.
func (h *Host) HasAuthProvider(provider string) bool {
return h != nil && h.inner != nil && h.inner.HasAuthProvider(provider)
}
+// SameAuthProvider reports whether two provider keys belong to the same active auth-provider plugin.
+func (h *Host) SameAuthProvider(providerA, providerB string) bool {
+ return h != nil && h.inner != nil && h.inner.SameAuthProvider(providerA, providerB)
+}
+
// StartLogin starts a provider login flow through an active auth-provider plugin.
func (h *Host) StartLogin(ctx context.Context, provider string, baseURL string, metadata ...map[string]any) (pluginapi.AuthLoginStartResponse, bool, error) {
if h == nil || h.inner == nil {
diff --git a/sdk/pluginstore/pluginstore.go b/sdk/pluginstore/pluginstore.go
index 69102c306f8..2adafa035eb 100644
--- a/sdk/pluginstore/pluginstore.go
+++ b/sdk/pluginstore/pluginstore.go
@@ -37,6 +37,7 @@ const (
type Source = internalpluginstore.Source
type Registry = internalpluginstore.Registry
type Plugin = internalpluginstore.Plugin
+type PluginLocale = internalpluginstore.PluginLocale
type Version = internalpluginstore.Version
type Release = internalpluginstore.Release
type ReleaseAsset = internalpluginstore.ReleaseAsset
@@ -117,6 +118,10 @@ func NormalizeSources(registryURLs []string) ([]Source, error) {
return internalpluginstore.NormalizeSources(registryURLs)
}
+func NormalizePluginLocales(locales map[string]PluginLocale) map[string]PluginLocale {
+ return internalpluginstore.NormalizePluginLocales(locales)
+}
+
func SourceID(registryURL string) string {
return internalpluginstore.SourceID(registryURL)
}
diff --git a/sdk/pluginstore/pluginstore_test.go b/sdk/pluginstore/pluginstore_test.go
index 3bbca50b027..a1c90d8c39a 100644
--- a/sdk/pluginstore/pluginstore_test.go
+++ b/sdk/pluginstore/pluginstore_test.go
@@ -40,6 +40,11 @@ func TestManifestFromReleaseBuildsPinnedManifest(t *testing.T) {
Description: "Adds sample provider support.",
Author: "author-name",
Repository: "https://github.com/author-name/sample-provider",
+ Locales: map[string]PluginLocale{
+ "zh-CN": {
+ Description: "增加示例提供商支持。",
+ },
+ },
},
Release{TagName: "v0.2.0"},
)
@@ -52,6 +57,9 @@ func TestManifestFromReleaseBuildsPinnedManifest(t *testing.T) {
if manifest.Version != "0.2.0" || manifest.ReleaseTag != "v0.2.0" {
t.Fatalf("manifest version fields = %q/%q, want 0.2.0/v0.2.0", manifest.Version, manifest.ReleaseTag)
}
+ if manifest.Locales["zh-CN"].Description != "增加示例提供商支持。" || manifest.Plugin().Locales["zh-CN"].Description != "增加示例提供商支持。" {
+ t.Fatalf("manifest locales = %#v, want zh-CN description", manifest.Locales)
+ }
}
func TestManifestFromPluginBuildsDirectManifest(t *testing.T) {
diff --git a/sdk/proxyutil/proxy.go b/sdk/proxyutil/proxy.go
index 4119baa9507..e83c214b698 100644
--- a/sdk/proxyutil/proxy.go
+++ b/sdk/proxyutil/proxy.go
@@ -4,9 +4,9 @@ import (
"bufio"
"context"
"crypto/tls"
- "encoding/base64"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"net"
"net/http"
"net/url"
@@ -106,6 +106,14 @@ func NewDirectTransport() *http.Transport {
return clone
}
+// socksForwardDialer dials the SOCKS5 proxy itself. It mirrors net/http's
+// DefaultTransport dialer so the resulting upstream socket gets TCP keepalive:
+// proxy.Direct would leave keepalive at the OS default of hours, which lets a
+// silently dead peer pin an otherwise reusable connection.
+func socksForwardDialer() *net.Dialer {
+ return &net.Dialer{KeepAlive: 30 * time.Second}
+}
+
// BuildHTTPTransport constructs an HTTP transport for the provided proxy setting.
func BuildHTTPTransport(raw string) (*http.Transport, Mode, error) {
setting, errParse := Parse(raw)
@@ -126,7 +134,7 @@ func BuildHTTPTransport(raw string) (*http.Transport, Mode, error) {
password, _ := setting.URL.User.Password()
proxyAuth = &proxy.Auth{User: username, Password: password}
}
- dialer, errSOCKS5 := proxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, proxy.Direct)
+ dialer, errSOCKS5 := proxy.SOCKS5("tcp", setting.URL.Host, proxyAuth, socksForwardDialer())
if errSOCKS5 != nil {
return nil, setting.Mode, fmt.Errorf("create SOCKS5 dialer failed: %w", errSOCKS5)
}
diff --git a/sdk/proxyutil/proxy_test.go b/sdk/proxyutil/proxy_test.go
index 7dc05ce00dc..06f769e1fee 100644
--- a/sdk/proxyutil/proxy_test.go
+++ b/sdk/proxyutil/proxy_test.go
@@ -9,9 +9,9 @@ import (
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
- "encoding/base64"
"errors"
"fmt"
+ "github.com/MoeForge/moego/base64"
"io"
"math/big"
"net"
diff --git a/sdk/translator/registry.go b/sdk/translator/registry.go
index 145606bfad8..db5b64577fb 100644
--- a/sdk/translator/registry.go
+++ b/sdk/translator/registry.go
@@ -2,12 +2,12 @@ package translator
import (
"context"
+ "strings"
"sync"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
log "github.com/sirupsen/logrus"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// Registry manages translation functions across schemas.
@@ -138,8 +138,8 @@ func (r *Registry) TranslateRequestEnvelope(ctx context.Context, from, to Format
return req
}
- if req.Model != "" && gjson.GetBytes(req.Body, "model").String() != req.Model {
- if updated, err := sjson.SetBytes(req.Body, "model", req.Model); err != nil {
+ if req.Model != "" && ojson.GetBytesNoCopy(req.Body, "model").String() != req.Model {
+ if updated, err := ojson.SetBytes(req.Body, "model", req.Model); err != nil {
log.Warnf("translator: failed to normalize model in request fallback: %v", err)
} else {
req.Body = updated
@@ -169,14 +169,14 @@ func (r *Registry) TranslateRequestEnvelope(ctx context.Context, from, to Format
// configurationUpdates captures only Responses update items before and after a plugin
// normalizer. A native cross-protocol translation removing updates is not a plugin edit.
func configurationUpdates(body []byte) []string {
- input := gjson.GetBytes(body, "input")
+ input := ojson.GetBytesNoCopy(body, "input")
if !input.IsArray() {
return nil
}
var updates []string
- input.ForEach(func(_, item gjson.Result) bool {
+ input.ForEach(func(_, item ojson.Result) bool {
if item.Get("type").String() == "configuration_update" {
- updates = append(updates, item.Raw)
+ updates = append(updates, strings.Clone(item.Raw))
}
return true
})
diff --git a/sdk/translator/registry_summary_test.go b/sdk/translator/registry_summary_test.go
index df23767dd05..935a0f2c43a 100644
--- a/sdk/translator/registry_summary_test.go
+++ b/sdk/translator/registry_summary_test.go
@@ -2,10 +2,10 @@ package translator
import (
"bytes"
+ "context"
"testing"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
+ "github.com/MoeForge/moego/ojson"
)
func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) {
@@ -84,7 +84,7 @@ func TestRegistryTranslateRequestAppliesSummaryIntent(t *testing.T) {
return []byte(test.translated), nil
}, ResponseTransform{})
out := registry.TranslateRequest(test.from, test.to, "model", []byte(test.input), false)
- result := gjson.GetBytes(out, test.path)
+ result := ojson.GetBytesNoCopy(out, test.path)
if result.Exists() != test.wantExists {
t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out)
}
@@ -107,10 +107,10 @@ func TestRegistryTranslateRequestActivatesClaudeForEnabledSummary(t *testing.T)
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
false,
)
- if got := gjson.GetBytes(out, "thinking.type").String(); got != "adaptive" {
+ if got := ojson.GetBytesNoCopy(out, "thinking.type").String(); got != "adaptive" {
t.Fatalf("thinking.type = %q, want adaptive; body=%s", got, out)
}
- if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
+ if got := ojson.GetBytesNoCopy(out, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
}
}
@@ -127,7 +127,7 @@ func TestRegistryTranslateRequestDoesNotActivateClaudeForDisabledSummary(t *test
[]byte(`{"reasoning":{"summary":null},"input":"hi"}`),
false,
)
- if gjson.GetBytes(out, "thinking").Exists() {
+ if ojson.GetBytesNoCopy(out, "thinking").Exists() {
t.Fatalf("disabled summary activated Claude thinking: %s", out)
}
}
@@ -136,7 +136,7 @@ func TestRegistryTranslateRequestPreservesNativeClaudeMissingDisplay(t *testing.
registry := NewRegistry()
body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"}}`)
out := registry.TranslateRequest(FormatClaude, FormatClaude, "claude-opus-5", body, true)
- if gjson.GetBytes(out, "thinking.display").Exists() {
+ if ojson.GetBytesNoCopy(out, "thinking.display").Exists() {
t.Fatalf("native Claude request without display gained one: %s", out)
}
}
@@ -148,7 +148,7 @@ func TestRegistryTranslateRequestDoesNotMixSummaryIntoFallback(t *testing.T) {
if !bytes.Equal(out, body) {
t.Fatalf("missing translator changed fallback body: got %s, want %s", out, body)
}
- if gjson.GetBytes(out, "generationConfig").Exists() {
+ if ojson.GetBytesNoCopy(out, "generationConfig").Exists() {
t.Fatalf("missing translator mixed Gemini fields into Responses body: %s", out)
}
}
@@ -162,7 +162,7 @@ func TestRegistryTranslateRequestPluginMissDoesNotMixSummary(t *testing.T) {
if !bytes.Equal(out, body) {
t.Fatalf("plugin translation miss changed fallback body: got %s, want %s", out, body)
}
- if gjson.GetBytes(out, "generationConfig").Exists() {
+ if ojson.GetBytesNoCopy(out, "generationConfig").Exists() {
t.Fatalf("plugin translation miss mixed Gemini fields into Responses body: %s", out)
}
}
@@ -181,7 +181,7 @@ func TestRegistryTranslateRequestAppliesSummaryAfterPluginTranslation(t *testing
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
false,
)
- if !gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool() {
+ if !ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.includeThoughts").Bool() {
t.Fatalf("plugin-translated request lost canonical summary: %s", out)
}
}
@@ -196,14 +196,14 @@ func TestRegistryTranslateRequestPluginNormalizerOwnsSourceSummaryIntent(t *test
{
name: "removed summary remains absent",
normalize: func(body []byte) []byte {
- out, _ := sjson.DeleteBytes(body, "reasoning.summary")
+ out, _ := ojson.DeleteBytes(body, "reasoning.summary")
return out
},
},
{
name: "disabled summary replaces enabled intent",
normalize: func(body []byte) []byte {
- out, _ := sjson.SetBytes(body, "reasoning.summary", nil)
+ out, _ := ojson.SetBytes(body, "reasoning.summary", nil)
return out
},
wantExists: true,
@@ -228,7 +228,7 @@ func TestRegistryTranslateRequestPluginNormalizerOwnsSourceSummaryIntent(t *test
[]byte(`{"reasoning":{"summary":"auto"},"input":"hi"}`),
false,
)
- result := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts")
+ result := ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.includeThoughts")
if result.Exists() != test.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v; body=%s", result.Exists(), test.wantExists, out)
}
@@ -245,10 +245,10 @@ func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) {
return []byte(`{"generationConfig":{"thinkingConfig":{"thinkingLevel":"high"}}}`), nil
}, ResponseTransform{})
hooks := &fakePluginHooks{normalizeRequest: func(body []byte) []byte {
- if !gjson.GetBytes(body, "generationConfig.thinkingConfig.includeThoughts").Bool() {
+ if !ojson.GetBytesNoCopy(body, "generationConfig.thinkingConfig.includeThoughts").Bool() {
t.Fatalf("normalizer did not receive canonical enabled summary: %s", body)
}
- out, _ := sjson.DeleteBytes(body, "generationConfig.thinkingConfig.includeThoughts")
+ out, _ := ojson.DeleteBytes(body, "generationConfig.thinkingConfig.includeThoughts")
return out
}}
registry.SetPluginHooks(hooks)
@@ -260,7 +260,30 @@ func TestRegistryTranslateRequestNormalizerOwnsFinalSummaryField(t *testing.T) {
[]byte(`{"reasoning":{"effort":"high","summary":"auto"},"input":"hi"}`),
false,
)
- if gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Exists() {
+ if ojson.GetBytesNoCopy(out, "generationConfig.thinkingConfig.includeThoughts").Exists() {
t.Fatalf("summary post-processing overrode request normalizer: %s", out)
}
}
+
+func TestRegistryTranslateRequestDetectsInPlaceConfigurationUpdateMutation(t *testing.T) {
+ registry := NewRegistry()
+ registry.SetPluginHooks(&fakePluginHooks{normalizeRequest: func(body []byte) []byte {
+ for i := 0; i+4 <= len(body); i++ {
+ if string(body[i:i+4]) == "auto" {
+ copy(body[i:i+4], "none")
+ break
+ }
+ }
+ return body
+ }})
+
+ raw := []byte(`{"model":"gpt-5","input":[{"type":"configuration_update","reasoning":{"summary":"auto"}}]}`)
+ env := registry.TranslateRequestEnvelope(context.Background(), FormatOpenAIResponse, FormatClaude, RequestEnvelope{
+ Format: FormatOpenAIResponse,
+ Model: "gpt-5",
+ Body: raw,
+ })
+ if !env.ConfigurationUpdatesChanged {
+ t.Fatal("expected ConfigurationUpdatesChanged to be true when normalizer mutates input buffer in-place")
+ }
+}
diff --git a/sdk/translator/registry_test.go b/sdk/translator/registry_test.go
index 9dd2eb30d8f..3b9179d7377 100644
--- a/sdk/translator/registry_test.go
+++ b/sdk/translator/registry_test.go
@@ -5,8 +5,8 @@ import (
"errors"
"testing"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
- "github.com/tidwall/gjson"
)
type fakePluginHooks struct {
@@ -121,7 +121,7 @@ func TestTranslateRequest_FallbackNormalizesModel(t *testing.T) {
input := []byte(tt.payload)
got := r.TranslateRequest(Format("a"), Format("b"), tt.model, input, false)
- gotModel := gjson.GetBytes(got, "model").String()
+ gotModel := ojson.GetBytesNoCopy(got, "model").String()
if gotModel != tt.wantModel {
t.Errorf("model = %q, want %q", gotModel, tt.wantModel)
}
@@ -132,11 +132,11 @@ func TestTranslateRequest_FallbackNormalizesModel(t *testing.T) {
// Verify other fields are preserved.
for _, key := range []string{"input", "stream"} {
- orig := gjson.Get(tt.payload, key)
+ orig := ojson.Get(tt.payload, key)
if !orig.Exists() {
continue
}
- after := gjson.GetBytes(got, key)
+ after := ojson.GetBytesNoCopy(got, key)
if orig.Raw != after.Raw {
t.Errorf("field %q changed: got %s, want %s", key, after.Raw, orig.Raw)
}
@@ -157,7 +157,7 @@ func TestTranslateRequest_RegisteredTransformTakesPrecedence(t *testing.T) {
input := []byte(`{"model":"copilot/gpt-5-mini","input":"ping"}`)
got := r.TranslateRequest(from, to, "gpt-5-mini", input, false)
- gotModel := gjson.GetBytes(got, "model").String()
+ gotModel := ojson.GetBytesNoCopy(got, "model").String()
if gotModel != "from-transform" {
t.Errorf("expected registered transform to take precedence, got model = %q", gotModel)
}
@@ -257,7 +257,7 @@ func TestTranslateRequest_PluginTranslatorOnlyWhenNativeMissing(t *testing.T) {
missingNative.SetPluginHooks(missingHooks)
gotMissing := missingNative.TranslateRequest(from, to, "resolved", []byte(`{"model":"prefixed/resolved"}`), false)
- if gjson.GetBytes(gotMissing, "model").String() != "plugin-request" {
+ if ojson.GetBytesNoCopy(gotMissing, "model").String() != "plugin-request" {
t.Fatalf("plugin request translator was not used, got %s", gotMissing)
}
if !hasCall(missingHooks.calls, "translate-request") {
@@ -275,7 +275,7 @@ func TestTranslateRequest_PluginTranslatorOnlyWhenNativeMissing(t *testing.T) {
}, ResponseTransform{})
gotNative := withNative.TranslateRequest(from, to, "resolved", []byte(`{"model":"prefixed/resolved"}`), false)
- if gjson.GetBytes(gotNative, "model").String() != "native-request" {
+ if ojson.GetBytesNoCopy(gotNative, "model").String() != "native-request" {
t.Fatalf("native request transformer was not preserved, got %s", gotNative)
}
if hasCall(nativeHooks.calls, "translate-request") {
@@ -296,7 +296,7 @@ func TestTranslateNonStream_PluginTranslatorOnlyWhenNativeMissing(t *testing.T)
missingNative.SetPluginHooks(missingHooks)
gotMissing := missingNative.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"output":"raw"}`), nil)
- if gjson.GetBytes(gotMissing, "output").String() != "plugin-response" {
+ if ojson.GetBytesNoCopy(gotMissing, "output").String() != "plugin-response" {
t.Fatalf("plugin response translator was not used, got %s", gotMissing)
}
if !hasCall(missingHooks.calls, "translate-response") {
@@ -316,7 +316,7 @@ func TestTranslateNonStream_PluginTranslatorOnlyWhenNativeMissing(t *testing.T)
})
gotNative := withNative.TranslateNonStream(ctx, from, to, "model", nil, nil, []byte(`{"output":"raw"}`), nil)
- if gjson.GetBytes(gotNative, "output").String() != "native-response" {
+ if ojson.GetBytesNoCopy(gotNative, "output").String() != "native-response" {
t.Fatalf("native response transformer was not preserved, got %s", gotNative)
}
if hasCall(nativeHooks.calls, "translate-response") {
@@ -384,7 +384,7 @@ func TestRequestEnvelopePreservesRegisteredTransformDispatch(t *testing.T) {
})
withoutMetadata := r.TranslateRequest(from, to, "home-model", []byte(`{"input":"hello"}`), false)
- if gjson.GetBytes(withoutMetadata, "source").String() != "native" {
+ if ojson.GetBytesNoCopy(withoutMetadata, "source").String() != "native" {
t.Fatalf("without metadata used unexpected transform: %s", withoutMetadata)
}
withMetadata, err := NewPipeline(r).TranslateRequest(context.Background(), from, to, RequestEnvelope{
@@ -393,7 +393,7 @@ func TestRequestEnvelopePreservesRegisteredTransformDispatch(t *testing.T) {
if err != nil {
t.Fatalf("pipeline translation failed: %v", err)
}
- if gjson.GetBytes(withMetadata.Body, "source").String() != "model-info" {
+ if ojson.GetBytesNoCopy(withMetadata.Body, "source").String() != "model-info" {
t.Fatalf("envelope metadata was not used: %s", withMetadata.Body)
}
if withMetadata.ModelInfo != modelInfo {
@@ -411,7 +411,7 @@ func TestRequestEnvelopePreservesRegisteredTransformDispatch(t *testing.T) {
customWithMetadata := r.TranslateRequestEnvelope(context.Background(), from, to, RequestEnvelope{
Format: from, Model: "home-model", Body: payload, ModelInfo: modelInfo,
})
- if gjson.GetBytes(customWithMetadata.Body, "source").String() != "custom" {
+ if ojson.GetBytesNoCopy(customWithMetadata.Body, "source").String() != "custom" {
t.Fatalf("request metadata bypassed custom transform for %s: %s", payload, customWithMetadata.Body)
}
}
diff --git a/test/builtin_tools_translation_test.go b/test/builtin_tools_translation_test.go
index 63bcf19cfb5..a9c74192956 100644
--- a/test/builtin_tools_translation_test.go
+++ b/test/builtin_tools_translation_test.go
@@ -5,8 +5,8 @@ import (
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) {
@@ -19,16 +19,16 @@ func TestOpenAIToCodex_PreservesBuiltinTools(t *testing.T) {
out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAI, sdktranslator.FormatCodex, "gpt-5", in, false)
- if got := gjson.GetBytes(out, "tools.#").Int(); got != 1 {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != 1 {
t.Fatalf("expected 1 tool, got %d: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.type").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.type").String(); got != "web_search" {
t.Fatalf("expected tools[0].type=web_search, got %q: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tools.0.search_context_size").String(); got != "high" {
+ if got := ojson.GetBytesNoCopy(out, "tools.0.search_context_size").String(); got != "high" {
t.Fatalf("expected tools[0].search_context_size=high, got %q: %s", got, string(out))
}
- if got := gjson.GetBytes(out, "tool_choice.type").String(); got != "web_search" {
+ if got := ojson.GetBytesNoCopy(out, "tool_choice.type").String(); got != "web_search" {
t.Fatalf("expected tool_choice.type=web_search, got %q: %s", got, string(out))
}
}
@@ -42,7 +42,7 @@ func TestOpenAIResponsesToOpenAI_IgnoresBuiltinTools(t *testing.T) {
out := sdktranslator.TranslateRequest(sdktranslator.FormatOpenAIResponse, sdktranslator.FormatOpenAI, "gpt-5", in, false)
- if got := gjson.GetBytes(out, "tools.#").Int(); got != 0 {
+ if got := ojson.GetBytesNoCopy(out, "tools.#").Int(); got != 0 {
t.Fatalf("expected 0 tools (builtin tools not supported in Chat Completions), got %d: %s", got, string(out))
}
}
diff --git a/test/codex_claude_parallel_function_calls_test.go b/test/codex_claude_parallel_function_calls_test.go
index e04b472b454..77ed5d03e65 100644
--- a/test/codex_claude_parallel_function_calls_test.go
+++ b/test/codex_claude_parallel_function_calls_test.go
@@ -5,9 +5,9 @@ import (
"strings"
"testing"
+ "github.com/MoeForge/moego/ojson"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestCodexToClaudeParallelFunctionCallsHaveValidLifecycle(t *testing.T) {
@@ -50,7 +50,7 @@ func TestCodexToClaudeParallelFunctionCallsHaveValidLifecycle(t *testing.T) {
if !strings.HasPrefix(line, "data: ") {
continue
}
- event := gjson.Parse(strings.TrimPrefix(line, "data: "))
+ event := ojson.Parse(strings.TrimPrefix(line, "data: "))
if messageState == 2 {
t.Fatalf("event emitted after message_stop: %s", event.Raw)
}
diff --git a/test/gemini_ref_sibling_test.go b/test/gemini_ref_sibling_test.go
index b130e47e5b6..e612b3622ff 100644
--- a/test/gemini_ref_sibling_test.go
+++ b/test/gemini_ref_sibling_test.go
@@ -5,8 +5,8 @@ import (
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
+ "github.com/MoeForge/moego/ojson"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestIssue6897_GeminiToolParametersPreserveRefSiblingConstraints(t *testing.T) {
@@ -32,7 +32,7 @@ func TestIssue6897_GeminiToolParametersPreserveRefSiblingConstraints(t *testing.
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := sdktranslator.TranslateRequest(tc.format, sdktranslator.FormatGemini, "gemini-3-flash", []byte(tc.body), false)
- parameters := gjson.GetBytes(out, "tools.0.functionDeclarations.0.parametersJsonSchema")
+ parameters := ojson.GetBytesNoCopy(out, "tools.0.functionDeclarations.0.parametersJsonSchema")
if parameters.Get("properties.x.allOf.0.minLength").Int() != 5 || parameters.Get("properties.x.minLength").Int() != 1 {
t.Fatalf("translated tool parameters lost the reference conjunction: %s", out)
}
@@ -42,3 +42,81 @@ func TestIssue6897_GeminiToolParametersPreserveRefSiblingConstraints(t *testing.
})
}
}
+
+func TestIssue7149_GeminiToolParametersNumericConstraintsNormalizedAcrossTargets(t *testing.T) {
+ geminiBody := `{
+ "contents":[{"role":"user","parts":[{"text":"hi"}]}],
+ "tools":[{
+ "functionDeclarations":[{
+ "name":"run_command",
+ "description":"Run a command",
+ "parameters":{
+ "type":"OBJECT",
+ "minProperties":"1",
+ "maxProperties":"10",
+ "properties":{
+ "Message":{
+ "type":"STRING",
+ "minLength":"1",
+ "maxLength":"200",
+ "enum":["1","2"],
+ "default":"1"
+ },
+ "Items":{
+ "type":"ARRAY",
+ "minItems":"1",
+ "maxItems":"5",
+ "items":{"type":"STRING","minLength":"2"}
+ }
+ }
+ }
+ }]
+ }]
+ }`
+
+ targets := []struct {
+ name string
+ to sdktranslator.Format
+ schemaPath string
+ }{
+ {"gemini", sdktranslator.FormatGemini, "tools.0.function_declarations.0.parametersJsonSchema"},
+ {"antigravity", sdktranslator.FormatAntigravity, "request.tools.0.functionDeclarations.0.parametersJsonSchema"},
+ {"openai", sdktranslator.FormatOpenAI, "tools.0.function.parameters"},
+ {"claude", sdktranslator.FormatClaude, "tools.0.input_schema"},
+ {"codex", sdktranslator.FormatCodex, "tools.0.parameters"},
+ {"interactions", sdktranslator.FormatInteractions, "tools.0.parameters"},
+ }
+
+ for _, tc := range targets {
+ t.Run(tc.name, func(t *testing.T) {
+ out := sdktranslator.TranslateRequest(sdktranslator.FormatGemini, tc.to, "gemini-2.5-pro", []byte(geminiBody), false)
+ schema := ojson.GetBytesNoCopy(out, tc.schemaPath)
+ if !schema.Exists() {
+ t.Fatalf("expected schema at %s, got: %s", tc.schemaPath, out)
+ }
+ for _, check := range []struct {
+ path string
+ want int64
+ }{
+ {"minProperties", 1},
+ {"maxProperties", 10},
+ {"properties.Message.minLength", 1},
+ {"properties.Message.maxLength", 200},
+ {"properties.Items.minItems", 1},
+ {"properties.Items.maxItems", 5},
+ {"properties.Items.items.minLength", 2},
+ } {
+ got := schema.Get(check.path)
+ if got.Type != ojson.Number || got.Int() != check.want {
+ t.Errorf("[%s] %s = %s (type %v), want number %d; schema=%s", tc.name, check.path, got.Raw, got.Type, check.want, schema.Raw)
+ }
+ }
+ if got := schema.Get("properties.Message.enum.0"); got.Type != ojson.String || got.String() != "1" {
+ t.Errorf("[%s] enum.0 = %s (type %v), want string \"1\"", tc.name, got.Raw, got.Type)
+ }
+ if got := schema.Get("properties.Message.default"); got.Type != ojson.String || got.String() != "1" {
+ t.Errorf("[%s] default = %s (type %v), want string \"1\"", tc.name, got.Raw, got.Type)
+ }
+ })
+ }
+}
diff --git a/test/summary_intent_translation_test.go b/test/summary_intent_translation_test.go
index 0c8011b2fd4..a6bbe77a3ec 100644
--- a/test/summary_intent_translation_test.go
+++ b/test/summary_intent_translation_test.go
@@ -5,12 +5,12 @@ import (
"testing"
"time"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/antigravity"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/translator"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
)
func TestSummaryIntentTranslation(t *testing.T) {
@@ -74,7 +74,7 @@ func TestSummaryIntentTranslation(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
out := sdktranslator.TranslateRequest(test.from, test.to, "", []byte(test.body), true)
- result := gjson.GetBytes(out, test.path)
+ result := ojson.GetBytesNoCopy(out, test.path)
if result.Exists() != test.wantExists {
t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out)
}
@@ -98,7 +98,7 @@ func TestInvalidInteractionsSummaryDoesNotWriteTargetControl(t *testing.T) {
} {
t.Run(test.name, func(t *testing.T) {
out := sdktranslator.TranslateRequest(sdktranslator.FormatInteractions, test.to, "model", body, false)
- if result := gjson.GetBytes(out, test.path); result.Exists() {
+ if result := ojson.GetBytesNoCopy(out, test.path); result.Exists() {
t.Fatalf("invalid Interactions summary wrote %s=%s; body=%s", test.path, result.Raw, out)
}
})
@@ -165,14 +165,14 @@ func TestSummaryIntentFinalPipeline(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking() error = %v; body=%s", err, out)
}
- result := gjson.GetBytes(out, test.path)
+ result := ojson.GetBytesNoCopy(out, test.path)
if result.Exists() != test.wantExists {
t.Fatalf("%s exists = %v, want %v; body=%s", test.path, result.Exists(), test.wantExists, out)
}
if test.wantExists && result.String() != test.want {
t.Fatalf("%s = %q, want %q; body=%s", test.path, result.String(), test.want, out)
}
- if test.to == sdktranslator.FormatClaude && gjson.GetBytes(out, "thinking.type").String() == "disabled" && gjson.GetBytes(out, "thinking.display").Exists() {
+ if test.to == sdktranslator.FormatClaude && ojson.GetBytesNoCopy(out, "thinking.type").String() == "disabled" && ojson.GetBytesNoCopy(out, "thinking.display").Exists() {
t.Fatalf("disabled Claude thinking retained display: %s", out)
}
})
@@ -198,13 +198,13 @@ func TestGeminiSummaryOnlyProducesValidClaudeThinking(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
body := []byte(`{"model":"` + test.model + `","generationConfig":{"thinkingConfig":{"includeThoughts":true}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
out := sdktranslator.TranslateRequest(sdktranslator.FormatGemini, sdktranslator.FormatClaude, test.model, body, false)
- if got := gjson.GetBytes(out, "thinking.type").String(); got != test.wantType {
+ if got := ojson.GetBytesNoCopy(out, "thinking.type").String(); got != test.wantType {
t.Fatalf("thinking.type = %q, want %q; body=%s", got, test.wantType, out)
}
- if got := gjson.GetBytes(out, "thinking.display").String(); got != "summarized" {
+ if got := ojson.GetBytesNoCopy(out, "thinking.display").String(); got != "summarized" {
t.Fatalf("thinking.display = %q, want summarized; body=%s", got, out)
}
- budget := gjson.GetBytes(out, "thinking.budget_tokens")
+ budget := ojson.GetBytesNoCopy(out, "thinking.budget_tokens")
if test.wantBudget > 0 {
if budget.Int() != test.wantBudget {
t.Fatalf("thinking.budget_tokens = %d, want %d; body=%s", budget.Int(), test.wantBudget, out)
@@ -219,11 +219,11 @@ func TestGeminiSummaryOnlyProducesValidClaudeThinking(t *testing.T) {
func TestNativeClaudeMissingDisplayPreservesSignatureOnlyHistory(t *testing.T) {
body := []byte(`{"model":"claude-opus-5","thinking":{"type":"adaptive"},"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"opus-signature"}]},{"role":"user","content":"continue"}]}`)
out := sdktranslator.TranslateRequest(sdktranslator.FormatClaude, sdktranslator.FormatClaude, "claude-opus-5", body, true)
- if gjson.GetBytes(out, "thinking.display").Exists() {
+ if ojson.GetBytesNoCopy(out, "thinking.display").Exists() {
t.Fatalf("native Claude request without display gained one: %s", out)
}
- if got := gjson.GetBytes(out, "messages").Raw; got != gjson.GetBytes(body, "messages").Raw {
- t.Fatalf("signature-only history changed: got %s, want %s", got, gjson.GetBytes(body, "messages").Raw)
+ if got := ojson.GetBytesNoCopy(out, "messages").Raw; got != ojson.GetBytesNoCopy(body, "messages").Raw {
+ t.Fatalf("signature-only history changed: got %s, want %s", got, ojson.GetBytesNoCopy(body, "messages").Raw)
}
}
@@ -258,7 +258,7 @@ func TestAntigravityIncludeThoughtsPreservesExplicitness(t *testing.T) {
if err != nil {
t.Fatalf("ApplyThinking() error = %v; body=%s", err, out)
}
- result := gjson.GetBytes(out, "request.generationConfig.thinkingConfig.includeThoughts")
+ result := ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.includeThoughts")
if result.Exists() != test.wantExists {
t.Fatalf("includeThoughts exists = %v, want %v; body=%s", result.Exists(), test.wantExists, out)
}
@@ -267,7 +267,7 @@ func TestAntigravityIncludeThoughtsPreservesExplicitness(t *testing.T) {
t.Fatalf("includeThoughts = %s, want %s; body=%s", got, test.want, out)
}
}
- if gjson.GetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts").Exists() {
+ if ojson.GetBytesNoCopy(out, "request.generationConfig.thinkingConfig.include_thoughts").Exists() {
t.Fatalf("snake_case includeThoughts left in payload: %s", out)
}
})
diff --git a/test/thinking_conversion_test.go b/test/thinking_conversion_test.go
index dc23b2369a4..d7d49a283d5 100644
--- a/test/thinking_conversion_test.go
+++ b/test/thinking_conversion_test.go
@@ -17,11 +17,10 @@ import (
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/openai"
_ "github.com/router-for-me/CLIProxyAPI/v8/internal/thinking/provider/xai"
+ "github.com/MoeForge/moego/ojson"
"github.com/router-for-me/CLIProxyAPI/v8/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v8/internal/thinking"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v8/sdk/translator"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
)
// thinkingTestCase represents a common test case structure for both suffix and body tests.
@@ -3434,7 +3433,7 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
true,
)
if applyTo == "claude" {
- body, _ = sjson.SetBytes(body, "max_tokens", 200000)
+ body, _ = ojson.SetBytes(body, "max_tokens", 200000)
}
body, err := thinking.ApplyThinking(body, tc.model, tc.from, applyTo, applyTo)
@@ -3450,7 +3449,7 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
}
for _, fieldPath := range tc.expectAbsent {
- if gjson.GetBytes(body, fieldPath).Exists() {
+ if ojson.GetBytesNoCopy(body, fieldPath).Exists() {
t.Fatalf("expected field %s to be absent, body=%s", fieldPath, string(body))
}
}
@@ -3459,17 +3458,17 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
var hasThinking bool
switch tc.to {
case "gemini":
- hasThinking = gjson.GetBytes(body, "generationConfig.thinkingConfig").Exists()
+ hasThinking = ojson.GetBytesNoCopy(body, "generationConfig.thinkingConfig").Exists()
case "antigravity":
- hasThinking = gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists()
+ hasThinking = ojson.GetBytesNoCopy(body, "request.generationConfig.thinkingConfig").Exists()
case "claude":
- hasThinking = gjson.GetBytes(body, "thinking").Exists()
+ hasThinking = ojson.GetBytesNoCopy(body, "thinking").Exists()
case "openai":
- hasThinking = gjson.GetBytes(body, "reasoning_effort").Exists()
+ hasThinking = ojson.GetBytesNoCopy(body, "reasoning_effort").Exists()
case "codex":
- hasThinking = gjson.GetBytes(body, "reasoning.effort").Exists() || gjson.GetBytes(body, "reasoning").Exists()
+ hasThinking = ojson.GetBytesNoCopy(body, "reasoning.effort").Exists() || ojson.GetBytesNoCopy(body, "reasoning").Exists()
case "kimi":
- hasThinking = gjson.GetBytes(body, "thinking").Exists() || gjson.GetBytes(body, "reasoning_effort").Exists()
+ hasThinking = ojson.GetBytesNoCopy(body, "thinking").Exists() || ojson.GetBytesNoCopy(body, "reasoning_effort").Exists()
}
if hasThinking {
t.Fatalf("expected no thinking field but found one, body=%s", string(body))
@@ -3478,12 +3477,12 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
}
assertField := func(fieldPath, expected string) {
- val := gjson.GetBytes(body, fieldPath)
+ val := ojson.GetBytesNoCopy(body, fieldPath)
if !val.Exists() {
t.Fatalf("expected field %s not found, body=%s", fieldPath, string(body))
}
actualValue := val.String()
- if val.Type == gjson.Number {
+ if val.Type == ojson.Number {
actualValue = fmt.Sprintf("%d", val.Int())
}
if actualValue != expected {
@@ -3500,7 +3499,7 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
// 2.1.220 always sends thinking.type="adaptive" alongside
// output_config.effort. Emitting effort on its own would be a wire
// shape the real client never produces.
- if tc.to == "claude" && gjson.GetBytes(body, "output_config.effort").Exists() {
+ if tc.to == "claude" && ojson.GetBytesNoCopy(body, "output_config.effort").Exists() {
assertField("thinking.type", "adaptive")
}
if tc.expectField3 != "" {
@@ -3515,7 +3514,7 @@ func runThinkingTests(t *testing.T, cases []thinkingTestCase) {
// Each case declares its expected visibility independently from the
// extractor under test. Empty means the provider field must be absent.
wantIncludeThoughts := tc.includeThoughts
- itVal := gjson.GetBytes(body, path)
+ itVal := ojson.GetBytesNoCopy(body, path)
if wantIncludeThoughts == "" {
if itVal.Exists() {
t.Fatalf("includeThoughts should be absent without summary intent, body=%s", string(body))